diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index c34caa0c866..47e93a2836c 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4600,7 +4600,17 @@ class DiscordAdapter(BasePlatformAdapter): """Create a Discord thread from a slash command and start a session in it.""" if not await self._check_slash_authorization(interaction, "/thread"): 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] /thread: interaction expired before defer. " + "Creating the thread anyway, skipping interaction followups.", + ) result = await self._create_thread( interaction, name=name, @@ -4610,7 +4620,8 @@ class DiscordAdapter(BasePlatformAdapter): if not result.get("success"): error = result.get("error", "unknown error") - await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) + if deferred_response: + await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) return thread_id = result.get("thread_id") @@ -4618,7 +4629,8 @@ class DiscordAdapter(BasePlatformAdapter): # Tell the user where the thread is link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**" - await interaction.followup.send(f"Created thread {link}", ephemeral=True) + if deferred_response: + await interaction.followup.send(f"Created thread {link}", ephemeral=True) # Track thread participation so follow-ups don't require @mention if thread_id: diff --git a/tests/gateway/test_discord_thread_slash_expired_defer.py b/tests/gateway/test_discord_thread_slash_expired_defer.py new file mode 100644 index 00000000000..0c7cf312d00 --- /dev/null +++ b/tests/gateway/test_discord_thread_slash_expired_defer.py @@ -0,0 +1,72 @@ +"""Sibling coverage for expired slash defer handling: /thread creation must +still run when the interaction token has expired (Discord error 10062), just +skipping the ephemeral followups.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter + + +class _UnknownInteraction(Exception): + status = 404 + code = 10062 + + +def _adapter(): + a = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + a._check_slash_authorization = AsyncMock(return_value=True) + return a + + +@pytest.mark.asyncio +async def test_thread_create_slash_survives_expired_defer(): + adapter = _adapter() + interaction = SimpleNamespace( + response=SimpleNamespace(defer=AsyncMock(side_effect=_UnknownInteraction("Unknown interaction"))), + followup=SimpleNamespace(send=AsyncMock()), + ) + adapter._create_thread = AsyncMock( + return_value={"success": True, "thread_id": "999", "thread_name": "t"} + ) + adapter._threads = SimpleNamespace(mark=lambda _tid: None) + + await adapter._handle_thread_create_slash(interaction, name="t") + + adapter._create_thread.assert_awaited_once() + interaction.followup.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_thread_create_slash_normal_defer_still_follows_up(): + adapter = _adapter() + interaction = SimpleNamespace( + response=SimpleNamespace(defer=AsyncMock()), + followup=SimpleNamespace(send=AsyncMock()), + ) + adapter._create_thread = AsyncMock( + return_value={"success": True, "thread_id": "999", "thread_name": "t"} + ) + adapter._threads = SimpleNamespace(mark=lambda _tid: None) + + await adapter._handle_thread_create_slash(interaction, name="t") + + interaction.followup.send.assert_awaited() + + +@pytest.mark.asyncio +async def test_thread_create_slash_reraises_non_expiry_errors(): + adapter = _adapter() + interaction = SimpleNamespace( + response=SimpleNamespace(defer=AsyncMock(side_effect=RuntimeError("boom"))), + followup=SimpleNamespace(send=AsyncMock()), + ) + adapter._create_thread = AsyncMock() + + with pytest.raises(RuntimeError): + await adapter._handle_thread_create_slash(interaction, name="t") + + adapter._create_thread.assert_not_awaited()