From b9d9b8aad685530e078a368a7d364b4918fd3042 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 28 May 2026 11:57:42 +0800
Subject: [PATCH] fix(discord): handle expired slash defer interactions
---
plugins/platforms/discord/adapter.py | 40 +++++++++++++++++++-
tests/gateway/test_discord_slash_commands.py | 29 +++++++++++++-
2 files changed, 67 insertions(+), 2 deletions(-)
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
-