From 06f964e78ef746415fa2f50915d80e65ef285b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Staub?= Date: Wed, 3 Jun 2026 00:31:10 +0200 Subject: [PATCH] fix(discord): stream edits in threads --- plugins/platforms/discord/adapter.py | 21 +++++- tests/gateway/test_discord_streaming.py | 99 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_discord_streaming.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 12cf05c38c9e..616a975510f5 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1637,14 +1637,29 @@ async def edit_message( content: str, *, finalize: bool = False, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Edit a previously sent Discord message.""" + """Edit a previously sent Discord message. + + Streaming/edit consumers pass ``metadata={'thread_id': ...}`` for + Discord thread replies. Match ``send()`` routing so progressive edits + target the thread message, not the parent channel. + """ if not self._client: return SendResult(success=False, error="Not connected") try: - channel = self._client.get_channel(int(chat_id)) + thread_id = None + if metadata and metadata.get("thread_id"): + thread_id = metadata["thread_id"] + + target_id = thread_id or chat_id + channel = self._client.get_channel(int(target_id)) if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await self._client.fetch_channel(int(target_id)) + if not channel: + target_kind = "Thread" if thread_id else "Channel" + return SendResult(success=False, error=f"{target_kind} {target_id} not found") + msg = await channel.fetch_message(int(message_id)) formatted = self.format_message(content) if len(formatted) > self.MAX_MESSAGE_LENGTH: diff --git a/tests/gateway/test_discord_streaming.py b/tests/gateway/test_discord_streaming.py new file mode 100644 index 000000000000..2a3355493b74 --- /dev/null +++ b/tests/gateway/test_discord_streaming.py @@ -0,0 +1,99 @@ +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig +from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + for name in ("discord", "discord.ext", "discord.ext.commands"): + sys.modules.setdefault(name, discord_mod) + + +_ensure_discord_mock() + +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 + + +@pytest.fixture +def adapter(): + a = DiscordAdapter(PlatformConfig(enabled=True, token="fake-token")) + a._client = MagicMock() + return a + + +@pytest.mark.asyncio +async def test_edit_message_uses_thread_metadata(adapter): + parent = SimpleNamespace(id=111, fetch_message=AsyncMock()) + edited = SimpleNamespace(edit=AsyncMock()) + thread = SimpleNamespace(id=222, fetch_message=AsyncMock(return_value=edited)) + + def get_channel(channel_id): + return {111: parent, 222: thread}.get(channel_id) + + adapter._client.get_channel = MagicMock(side_effect=get_channel) + adapter._client.fetch_channel = AsyncMock(return_value=None) + + result = await adapter.edit_message( + "111", + "555", + "stream update", + metadata={"thread_id": "222"}, + ) + + assert result.success is True + parent.fetch_message.assert_not_awaited() + thread.fetch_message.assert_awaited_once_with(555) + edited.edit.assert_awaited_once_with(content="stream update") + + +@pytest.mark.asyncio +async def test_stream_consumer_edits_discord_thread_preview(adapter): + sent_message = SimpleNamespace(id=555) + edited_message = SimpleNamespace(edit=AsyncMock()) + thread = SimpleNamespace( + id=222, + send=AsyncMock(return_value=sent_message), + fetch_message=AsyncMock(return_value=edited_message), + ) + parent = SimpleNamespace(id=111) + + def get_channel(channel_id): + return {111: parent, 222: thread}.get(channel_id) + + adapter._client.get_channel = MagicMock(side_effect=get_channel) + adapter._client.fetch_channel = AsyncMock(return_value=None) + adapter._is_forum_parent = MagicMock(return_value=False) + + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="111", + config=StreamConsumerConfig(edit_interval=0.0, buffer_threshold=1, cursor=""), + metadata={"thread_id": "222"}, + initial_reply_to_id="444", + ) + + task = asyncio.create_task(consumer.run()) + consumer.on_delta("Hello") + await asyncio.sleep(0.08) + consumer.on_delta(" world") + await asyncio.sleep(0.08) + consumer.finish() + await asyncio.wait_for(task, timeout=1.0) + + thread.send.assert_awaited_once() + assert thread.send.await_args.kwargs["content"] == "Hello" + assert thread.send.await_args.kwargs["reference"] is not None + thread.fetch_message.assert_awaited_with(555) + edited_message.edit.assert_any_await(content="Hello world") + assert consumer.final_response_sent is True