diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 93419755a1..eb33a3fa3b 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -170,13 +170,6 @@ _DISCORD_SELECT_MAX_ROWS = 5 # Model-select capacity: keep 2 rows for Back/Cancel, fill the rest with selects. _DISCORD_MODEL_SELECT_CAPACITY = (_DISCORD_SELECT_MAX_ROWS - 2) * _DISCORD_SELECT_MAX_OPTIONS _DISCORD_BUTTON_LABEL_LIMIT = 80 -# Default Discord attachment cap for DMs / channels without a guild boost -# context. 20 MiB since the Sep 3 2026 API change (10 MiB before); guild -# channels expose a boost-raised limit via ``guild.filesize_limit``, but -# discord.py's fallback constant can lag the platform default, so the -# effective limit is never taken below this floor. See issue #50846 and -# https://docs.discord.com/developers/change-log (Sep 3, 2026). -_DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES = 20 * 1024 * 1024 _DISCORD_ELLIPSIS = "\u2026" _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({ "non_conversational", "non_conversational_history", @@ -3164,24 +3157,6 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): success=True, message_id=last_id, continuation_message_ids=tuple(continuation_ids), ) - @staticmethod - def _discord_upload_limit_bytes(channel: Any) -> int: - """Return the effective Discord attachment size limit for *channel*. - - Prefer the guild's boost-aware ``filesize_limit`` when present; fall - back to the platform default for DMs / group DMs without a guild. - The guild value is floored at the platform default: discord.py's - unboosted-tier constant can lag a platform-wide raise (10 MiB in - 2.7.1 vs the 20 MiB default since Sep 3 2026), and under-reporting - makes the preflight reject files Discord would accept. - """ - guild = getattr(channel, "guild", None) - if guild is not None: - limit = getattr(guild, "filesize_limit", None) - if isinstance(limit, int) and limit > 0: - return max(limit, _DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES) - return _DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES - async def play_tts(self, chat_id: str, audio_path: str, **kwargs) -> SendResult: """Play auto-TTS audio: in the guild's VC if joined, else as a file attachment.""" for gid, text_ch_id in self._voice_text_channels.items(): diff --git a/plugins/platforms/discord/adapter_media.py b/plugins/platforms/discord/adapter_media.py index 917297c261..21482aff5a 100644 --- a/plugins/platforms/discord/adapter_media.py +++ b/plugins/platforms/discord/adapter_media.py @@ -11,8 +11,60 @@ from gateway.platforms.base import SendResult logger = logging.getLogger("plugins.platforms.discord.adapter") +# Default Discord attachment cap for DMs / channels without a guild boost +# context. 20 MiB since the Sep 3 2026 API change (10 MiB before); guild +# channels expose a boost-raised limit via ``guild.filesize_limit``, but +# discord.py's fallback constant can lag the platform default, so the +# effective limit is never taken below this floor. See issue #50846 and +# https://docs.discord.com/developers/change-log (Sep 3, 2026). +_DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES = 20 * 1024 * 1024 + class DiscordMediaMixin: + @staticmethod + def _discord_upload_limit_bytes(channel: Any) -> int: + """Return the effective Discord attachment size limit for *channel*. + + Prefer the guild's boost-aware ``filesize_limit`` when present; fall + back to the platform default for DMs / group DMs without a guild. + The guild value is floored at the platform default: discord.py's + unboosted-tier constant can lag a platform-wide raise (10 MiB in + 2.7.1 vs the 20 MiB default since Sep 3 2026), and under-reporting + makes the preflight reject files Discord would accept. + """ + guild = getattr(channel, "guild", None) + if guild is not None: + limit = getattr(guild, "filesize_limit", None) + if isinstance(limit, int) and limit > 0: + return max(limit, _DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES) + return _DISCORD_DEFAULT_UPLOAD_LIMIT_BYTES + + async def _reject_oversized_upload(self, channel: Any, file_path: str, filename: str) -> Optional[SendResult]: + """Preflight ``file_path`` against the channel's upload cap (#50846): a doomed + ``413`` round-trip is skipped and the user gets a notice naming the size and the + limit. Returns the failed result, or ``None`` when the file may be uploaded.""" + try: + file_size = os.path.getsize(file_path) + except OSError as exc: + return SendResult(success=False, error=f"Cannot stat file {filename}: {exc}") + limit = self._discord_upload_limit_bytes(channel) + if file_size <= limit: + return None + size_mb = file_size / (1024 * 1024) + limit_mb = limit / (1024 * 1024) + error = f"File too large for Discord upload: {filename} is {size_mb:.1f} MB (limit {limit_mb:.0f} MB)" + logger.warning("[%s] %s", self.name, error) + notice = ( + f"⚠️ Could not attach `{filename}` — {size_mb:.1f} MB exceeds Discord's " + f"{limit_mb:.0f} MB upload limit for this channel. Compress the file or share a link instead." + ) + try: + if not self._is_forum_parent(channel): + await channel.send(content=notice) + except Exception: + logger.debug("[%s] Failed to send oversized-file notice for %s", self.name, filename, exc_info=True) + return SendResult(success=False, error=error) + async def _send_file_attachment( self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, @@ -33,35 +85,9 @@ class DiscordMediaMixin: if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") filename = file_name or os.path.basename(file_path) - try: - file_size = os.path.getsize(file_path) - except OSError as exc: - return SendResult(success=False, error=f"Cannot stat file {filename}: {exc}") - # Reject oversized files before upload (#50846): no doomed 413 round-trip, - # and the user gets an explicit notice instead of a silent failure. - limit = self._discord_upload_limit_bytes(channel) - if file_size > limit: - size_mb = file_size / (1024 * 1024) - limit_mb = limit / (1024 * 1024) - error = ( - f"File too large for Discord upload: {filename} is " - f"{size_mb:.1f} MB (limit {limit_mb:.0f} MB)" - ) - logger.warning("[%s] %s", self.name, error) - notice = ( - f"⚠️ Could not attach `{filename}` — {size_mb:.1f} MB exceeds " - f"Discord's {limit_mb:.0f} MB upload limit for this channel. " - f"Compress the file or share a link instead." - ) - try: - if not self._is_forum_parent(channel): - await channel.send(content=notice) - except Exception: - logger.debug( - "[%s] Failed to send oversized-file notice for %s", - self.name, filename, exc_info=True, - ) - return SendResult(success=False, error=error) + rejected = await self._reject_oversized_upload(channel, file_path, filename) + if rejected is not None: + return rejected logger.info( "[%s] Sending file attachment %s (%s) to %s", self.name, filename, os.path.splitext(filename)[1].lower() or "no-ext", chat_id, @@ -246,6 +272,9 @@ class DiscordMediaMixin: if not os.path.exists(audio_path): return SendResult(success=False, error=f"Audio file not found: {audio_path}") filename = os.path.basename(audio_path) + rejected = await self._reject_oversized_upload(channel, audio_path, filename) + if rejected is not None: + return rejected reference = self._reply_reference_for_send(reply_to, channel) with open(audio_path, "rb") as f: file_data = f.read()