fix(discord): widen expired-defer handling to /thread slash command

Same 10062 degrade-gracefully pattern as _run_simple_slash: create the
thread anyway, skip the ephemeral followups that need a live
interaction token. Non-expiry defer errors still raise.
This commit is contained in:
teknium1 2026-07-07 06:30:20 -07:00 committed by Teknium
parent b9d9b8aad6
commit 4c3a388cba
2 changed files with 87 additions and 3 deletions

View file

@ -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:

View file

@ -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()