diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index dc48a336f33..c34caa0c866 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1626,6 +1626,31 @@ class DiscordAdapter(BasePlatformAdapter): return True return False + @staticmethod + def _is_discord_unknown_interaction(exc: BaseException) -> bool: + """True for Discord's expired interaction token error.""" + code = getattr(exc, "code", None) + if code is None: + data = getattr(exc, "data", None) + if isinstance(data, dict): + code = data.get("code") + try: + code = int(code) + except (TypeError, ValueError): + code = None + + status = getattr(exc, "status", None) + response = getattr(exc, "response", None) + if status is None and response is not None: + status = getattr(response, "status", None) or getattr(response, "status_code", None) + try: + status = int(status) + except (TypeError, ValueError): + status = None + + message = str(exc).lower() + return code == 10062 or (status == 404 and "unknown interaction" in message) + def _command_sync_mutation_interval_seconds(self) -> float: return _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS @@ -3964,9 +3989,22 @@ class DiscordAdapter(BasePlatformAdapter): if not await self._check_slash_authorization(interaction, command_text): return - await interaction.response.defer(ephemeral=True) + deferred_response = False + try: + await interaction.response.defer(ephemeral=True) + deferred_response = True + except Exception as e: + if not self._is_discord_unknown_interaction(e): + raise + logger.warning( + "[Discord] slash %s: interaction expired before defer. " + "Executing command anyway, skipping interaction followup.", + command_text, + ) event = self._build_slash_event(interaction, command_text) await self.handle_message(event) + if not deferred_response: + return try: if followup_msg: await interaction.edit_original_response(content=followup_msg) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 19710626ca6..f35ca2fa945 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -158,6 +158,34 @@ async def test_registers_native_restart_slash_command(adapter): ) +@pytest.mark.asyncio +async def test_run_simple_slash_executes_when_defer_interaction_expired(adapter): + class UnknownInteraction(Exception): + status = 404 + code = 10062 + + interaction = SimpleNamespace( + channel=_FakeTextChannel(channel_id=123, name="general"), + channel_id=123, + guild_id=456, + user=SimpleNamespace(id=42, name="Jezza", display_name="Jezza"), + response=SimpleNamespace(defer=AsyncMock(side_effect=UnknownInteraction("Unknown interaction"))), + edit_original_response=AsyncMock(), + delete_original_response=AsyncMock(), + ) + adapter.handle_message = AsyncMock() + + await adapter._run_simple_slash(interaction, "/reset", "Session reset~") + + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "/reset" + assert event.source.chat_id == "123" + interaction.edit_original_response.assert_not_awaited() + interaction.delete_original_response.assert_not_awaited() + + # ------------------------------------------------------------------ # Auto-registration from COMMAND_REGISTRY # ------------------------------------------------------------------ @@ -1117,4 +1145,3 @@ def test_register_skill_command_autocomplete_filters_by_name_and_description(ada # (covered in other tests). The autocomplete filter itself is exercised # via direct function call in the real-discord integration path. assert skill_cmd.callback is not None -