Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6553,6 +6553,56 @@ def _derive_auto_thread_name(self, content: str) -> str:
thread_name = thread_name[:77] + "..."
return thread_name

@staticmethod
def _is_starter_message_thread(candidate: Any, message_id: Any) -> bool:
"""Return whether ``candidate`` is the thread backed by ``message_id``."""
if candidate is None or message_id is None:
return False
if str(getattr(candidate, "id", "")) != str(message_id):
return False

thread_type = getattr(discord, "Thread", None)
return not isinstance(thread_type, type) or isinstance(candidate, thread_type)

async def _resolve_starter_message_thread(
self,
message: 'DiscordMessage',
) -> Tuple[Optional[Any], bool]:
"""Return an existing starter thread and whether absence was confirmed."""
message_id = getattr(message, "id", None)
existing_thread = getattr(message, "thread", None)
if self._is_starter_message_thread(existing_thread, message_id):
return existing_thread, False

client = self._client
if client is None or message_id is None:
return None, False

try:
get_channel = getattr(client, "get_channel", None)
existing_thread = get_channel(int(message_id)) if callable(get_channel) else None
if self._is_starter_message_thread(existing_thread, message_id):
return existing_thread, False

fetch_channel = getattr(client, "fetch_channel", None)
if callable(fetch_channel):
existing_thread = await fetch_channel(int(message_id))
if self._is_starter_message_thread(existing_thread, message_id):
return existing_thread, False
if existing_thread is None:
return None, True
except Exception as exc:
not_found_type = getattr(discord, "NotFound", None)
if isinstance(not_found_type, type) and isinstance(exc, not_found_type):
return None, True
logger.debug(
"[%s] Could not resolve existing Discord thread for starter message %s",
self.name,
message_id,
exc_info=True,
)
return None, False

async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
"""Create a thread from a user message for auto-threading.

Expand All @@ -6579,6 +6629,29 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
return thread
except Exception as direct_error:
last_direct_error = direct_error
# A transport error can arrive after Discord committed the
# create. Give both the gateway cache and REST lookup a brief
# chance to expose that thread before creating a fallback.
absence_confirmed = False
for reconciliation_attempt in range(2):
existing_thread, absence_confirmed = await self._resolve_starter_message_thread(message)
if existing_thread is not None:
return existing_thread
if reconciliation_attempt == 0:
await asyncio.sleep(0.25)

if not absence_confirmed:
if attempt == 0:
await asyncio.sleep(0.75)
continue
logger.warning(
"[%s] Direct auto-thread creation failed and reconciliation remained "
"inconclusive; skipping fallback to avoid a duplicate thread. Direct error: %s",
self.name,
direct_error,
)
return None

try:
seed_msg = await message.channel.send(
f"\U0001f9f5 Thread created by Hermes: **{thread_name}**"
Expand Down
107 changes: 105 additions & 2 deletions tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def adapter():
adapter._client = SimpleNamespace(
tree=FakeTree(),
get_channel=lambda _id: None,
fetch_channel=AsyncMock(),
fetch_channel=AsyncMock(return_value=None),
user=SimpleNamespace(id=99999, name="HermesBot"),
)
adapter._text_batch_delay_seconds = 0 # disable batching for tests
Expand Down Expand Up @@ -427,6 +427,110 @@ async def test_auto_create_thread_strips_mention_syntax_from_name(adapter):
assert name == "please help"


def _failed_auto_thread_message():
return SimpleNamespace(
id=123,
content="Trip idea",
create_thread=AsyncMock(side_effect=ConnectionError("response lost")),
thread=None,
channel=SimpleNamespace(send=AsyncMock()),
author=SimpleNamespace(display_name="Jezza"),
)


@pytest.mark.asyncio
async def test_auto_create_thread_reuses_attached_starter_thread(adapter):
existing = _FakeThreadChannel(channel_id=123, name="already-there")
message = _failed_auto_thread_message()
message.thread = existing

result = await adapter._auto_create_thread(message)

assert result is existing
message.channel.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_auto_create_thread_reuses_cached_starter_thread(adapter):
existing = _FakeThreadChannel(channel_id=123, name="already-there")
message = _failed_auto_thread_message()
adapter._client.get_channel = MagicMock(return_value=existing)

result = await adapter._auto_create_thread(message)

assert result is existing
adapter._client.get_channel.assert_called_once_with(message.id)
adapter._client.fetch_channel.assert_not_awaited()
message.channel.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_auto_create_thread_fetches_starter_thread_after_cache_miss(adapter):
existing = _FakeThreadChannel(channel_id=123, name="already-there")
message = _failed_auto_thread_message()
adapter._client.get_channel = MagicMock(return_value=None)
adapter._client.fetch_channel = AsyncMock(return_value=existing)

result = await adapter._auto_create_thread(message)

assert result is existing
adapter._client.fetch_channel.assert_awaited_once_with(message.id)
message.channel.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_auto_create_thread_retries_reconciliation_before_fallback(adapter, monkeypatch):
existing = _FakeThreadChannel(channel_id=123, name="eventually-visible")
message = _failed_auto_thread_message()
adapter._client.get_channel = MagicMock(return_value=None)
adapter._client.fetch_channel = AsyncMock(side_effect=[None, existing])
sleep = AsyncMock()
monkeypatch.setattr("plugins.platforms.discord.adapter.asyncio.sleep", sleep)

result = await adapter._auto_create_thread(message)

assert result is existing
assert adapter._client.fetch_channel.await_count == 2
sleep.assert_awaited_once_with(0.25)
message.channel.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_auto_create_thread_does_not_reuse_unrelated_channel(adapter, monkeypatch):
unrelated = _FakeTextChannel(channel_id=999)
fallback = SimpleNamespace(id=456, name="fallback")
seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=fallback))
message = _failed_auto_thread_message()
message.thread = unrelated
message.channel.send.return_value = seed_message
adapter._client.get_channel = MagicMock(return_value=unrelated)
adapter._client.fetch_channel = AsyncMock(return_value=None)
monkeypatch.setattr("plugins.platforms.discord.adapter.asyncio.sleep", AsyncMock())

result = await adapter._auto_create_thread(message)

assert result is fallback
message.channel.send.assert_awaited_once()


@pytest.mark.asyncio
async def test_auto_create_thread_skips_fallback_when_reconciliation_is_inconclusive(
adapter,
monkeypatch,
):
message = _failed_auto_thread_message()
adapter._client.get_channel = MagicMock(return_value=None)
adapter._client.fetch_channel = AsyncMock(side_effect=ConnectionError("lookup failed"))
monkeypatch.setattr("plugins.platforms.discord.adapter.asyncio.sleep", AsyncMock())

result = await adapter._auto_create_thread(message)

assert result is None
assert message.create_thread.await_count == 2
assert adapter._client.fetch_channel.await_count == 4
message.channel.send.assert_not_awaited()


@pytest.mark.asyncio
async def test_rename_thread_edits_only_when_current_name_matches(adapter):
thread = SimpleNamespace(
Expand Down Expand Up @@ -601,4 +705,3 @@ def test_register_skill_command_payload_fits_discord_8kb_limit(adapter):
f"point of this design is that it stays small regardless of skill count"
)


Loading