diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 315f2e7cd37..8d388b78564 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -1332,10 +1332,29 @@ class WebhookAdapter(BasePlatformAdapter): error=f"No chat_id or home channel for {platform_name}", ) - # Pass thread_id from deliver_extra so Telegram forum topics work + # Pass platform-specific thread routing/creation hints through the + # common adapter metadata channel. An explicit thread_id wins over + # create_thread so all later sends from one webhook run stay together. metadata = None thread_id = extra.get("message_thread_id") or extra.get("thread_id") if thread_id: metadata = {"thread_id": thread_id} - return await adapter.send(chat_id, content, metadata=metadata) + if platform_name == "discord" and extra.get("create_thread") and not thread_id: + metadata = metadata or {} + metadata["create_thread"] = True + if extra.get("thread_name"): + metadata["thread_name"] = extra["thread_name"] + + result = await adapter.send(chat_id, content, metadata=metadata) + # Agent-mode webhooks may emit interim messages before their final + # answer. Once Discord creates the event thread, pin subsequent sends + # for this delivery to that same thread instead of creating siblings. + if ( + platform_name == "discord" + and result.success + and isinstance(result.raw_response, dict) + and result.raw_response.get("thread_id") + ): + extra["thread_id"] = str(result.raw_response["thread_id"]) + return result diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 161669a8a6e..c4639ae70ab 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -2865,7 +2865,9 @@ class DiscordAdapter(BasePlatformAdapter): """Send a message to a Discord channel or thread. When metadata contains a thread_id, the message is sent to that - thread instead of the parent channel identified by chat_id. + thread instead of the parent channel identified by chat_id. When it + contains create_thread, a fresh thread is created in the parent text + channel and the message is posted there. Forum channels (type 15) reject direct messages — a thread post is created automatically. @@ -2908,6 +2910,41 @@ class DiscordAdapter(BasePlatformAdapter): ) return result + if metadata and metadata.get("create_thread") and not thread_id: + thread_name = str(metadata.get("thread_name") or "").strip() + if not thread_name: + thread_name = _derive_forum_thread_name(content) + # Discord thread names are capped at 100 characters. + thread_name = thread_name[:100] + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + starter_content = chunks[0] if chunks else thread_name + starter_msg = await channel.send(content=starter_content) + thread = await starter_msg.create_thread(name=thread_name) + message_ids = [str(starter_msg.id)] + for chunk in chunks[1:]: + msg = await thread.send(content=chunk) + message_ids.append(str(msg.id)) + created_thread_id = str(thread.id) + if message_ids: + self._last_self_message_id[created_thread_id] = message_ids[-1] + result = SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={ + "message_ids": message_ids, + "thread_id": created_thread_id, + }, + ) + await asyncio.to_thread( + self._record_discord_response, + reply_to=reply_to, + result=result, + content=content, + final=final_delivery, + ) + return result + # Format and split message if needed formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index cd2950f9fbb..ac806b49c0c 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -160,6 +160,34 @@ async def test_send_does_not_retry_on_unrelated_errors(): assert send_calls[0]["reference"] is reference_obj +@pytest.mark.asyncio +async def test_send_creates_new_thread_with_content_as_starter(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + thread = SimpleNamespace(id=5678, send=AsyncMock()) + starter = SimpleNamespace( + id=1234, + create_thread=AsyncMock(return_value=thread), + ) + channel = SimpleNamespace(send=AsyncMock(return_value=starter)) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + + result = await adapter.send( + "555", + "Investigated alert details", + metadata={"create_thread": True, "thread_name": "API latency alert"}, + ) + + assert result.success is True + assert result.message_id == "1234" + assert result.raw_response["thread_id"] == "5678" + channel.send.assert_awaited_once_with(content="Investigated alert details") + starter.create_thread.assert_awaited_once_with(name="API latency alert") + thread.send.assert_not_awaited() + + # --------------------------------------------------------------------------- # Forum channel tests # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 95ca6079ffb..5ae5407ffb3 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -1497,6 +1497,55 @@ class TestDeliverCrossPlatformThreadId: "12345", "hello", metadata=None ) + @pytest.mark.asyncio + async def test_discord_create_thread_metadata_is_forwarded(self): + adapter, mock_target = self._setup_adapter_with_mock_target() + runner = adapter.gateway_runner + assert runner is not None + runner.adapters[Platform("discord")] = mock_target + delivery = { + "deliver_extra": { + "chat_id": "12345", + "create_thread": True, + "thread_name": "Alert: API latency", + } + } + + await adapter._deliver_cross_platform("discord", "hello", delivery) + + mock_target.send.assert_awaited_once_with( + "12345", + "hello", + metadata={ + "create_thread": True, + "thread_name": "Alert: API latency", + }, + ) + @pytest.mark.asyncio + async def test_discord_reuses_created_thread_on_subsequent_send(self): + adapter, mock_target = self._setup_adapter_with_mock_target() + runner = adapter.gateway_runner + assert runner is not None + runner.adapters[Platform("discord")] = mock_target + mock_target.send.side_effect = [ + SendResult(success=True, raw_response={"thread_id": "5678"}), + SendResult(success=True), + ] + delivery = { + "deliver_extra": { + "chat_id": "12345", + "create_thread": True, + "thread_name": "Alert: API latency", + } + } + + await adapter._deliver_cross_platform("discord", "interim", delivery) + await adapter._deliver_cross_platform("discord", "final", delivery) + + assert mock_target.send.await_args_list[1].kwargs["metadata"] == { + "thread_id": "5678" + } + class TestInsecureNoAuthSafetyRail: """connect() refuses to start when INSECURE_NO_AUTH is combined with a diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index e8bc90b2924..1731754c50f 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -225,6 +225,33 @@ webhooks: If `chat_id` is not provided in `deliver_extra`, the delivery falls back to the home channel configured for the target platform. +### Discord Thread Delivery + +Set `create_thread: true` to create a fresh Discord thread for each webhook +delivery. The first response becomes the thread starter, and any later messages +from the same webhook run are routed into that thread. + +```yaml +platforms: + webhook: + extra: + routes: + alerts: + events: ["alert"] + prompt: "Investigate this alert: {__raw__}" + deliver: "discord" + deliver_extra: + chat_id: "123456789012345678" + create_thread: true + thread_name: "Alert: {alert.name}" +``` + +The bot needs Discord's **Create Public Threads** and **Send Messages in +Threads** permissions in the target channel. `thread_name` supports the same +payload templates as other `deliver_extra` values and is truncated to Discord's +100-character thread-name limit. If `chat_id` is omitted, the Discord home +channel is used. + --- ## GitHub PR Review (Step by Step) {#github-pr-review}