diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 8d388b78564..573d0c6880c 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -811,6 +811,68 @@ class WebhookAdapter(BasePlatformAdapter): route_config.get("deliver_extra", {}), payload ), } + + # Discord routes can create the event thread before the agent starts. + # This leaves only a compact, templated index message in the parent + # channel while routing every agent/status message into the thread. + deliver_extra = deliver_config["deliver_extra"] + thread_starter = deliver_extra.get("thread_starter") + if ( + deliver_config["deliver"] == "discord" + and deliver_extra.get("create_thread") + and thread_starter + ): + starter_delivery = { + "deliver": "discord", + "deliver_extra": dict(deliver_extra), + } + starter_delivery["deliver_extra"].pop("thread_starter", None) + try: + starter_result = await self._direct_deliver( + str(thread_starter), starter_delivery + ) + except Exception: + logger.exception( + "[webhook] Discord thread starter failed route=%s delivery=%s", + route_name, + delivery_id, + ) + self._seen_deliveries.pop(delivery_id, None) + return web.json_response( + { + "status": "error", + "error": "Thread creation failed", + "delivery_id": delivery_id, + }, + status=502, + ) + + created_thread_id = ( + starter_result.raw_response.get("thread_id") + if starter_result.success + and isinstance(starter_result.raw_response, dict) + else None + ) + if not created_thread_id: + logger.warning( + "[webhook] Discord thread starter rejected route=%s delivery=%s error=%s", + route_name, + delivery_id, + starter_result.error, + ) + self._seen_deliveries.pop(delivery_id, None) + return web.json_response( + { + "status": "error", + "error": "Thread creation failed", + "delivery_id": delivery_id, + }, + status=502, + ) + + deliver_extra["thread_id"] = str(created_thread_id) + deliver_extra.pop("create_thread", None) + deliver_extra.pop("thread_starter", None) self._delivery_info[session_chat_id] = deliver_config self._delivery_info_created[session_chat_id] = now self._delivery_info_order.append((now, session_chat_id)) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 5ae5407ffb3..bf50e992ad3 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -558,6 +558,104 @@ class TestRenderDeliveryExtra: assert result["static"] == 42 # non-string left as-is +class TestDiscordWebhookThreadStarter: + @pytest.mark.asyncio + async def test_creates_templated_starter_before_agent_and_routes_run_to_thread(self): + routes = { + "betterstack-alerts": { + "secret": _INSECURE_NO_AUTH, + "prompt": "Investigate {data.attributes.name}", + "deliver": "discord", + "deliver_extra": { + "chat_id": "1495620631117430905", + "create_thread": True, + "thread_starter": ( + "Investigating BetterStack Webhook: " + "{data.attributes.name}" + ), + "thread_name": "BetterStack: {data.attributes.name}", + }, + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + discord_target = MagicMock() + discord_target.send = AsyncMock( + return_value=SendResult( + success=True, + message_id="starter-123", + raw_response={"thread_id": "thread-456"}, + ) + ) + runner = MagicMock() + runner.adapters = {Platform.DISCORD: discord_target} + adapter.gateway_runner = runner + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + response = await cli.post( + "/webhooks/betterstack-alerts", + json={"data": {"attributes": {"name": "API Metrics"}}}, + headers={"X-Request-ID": "thread-starter-success"}, + ) + assert response.status == 202 + + discord_target.send.assert_awaited_once_with( + "1495620631117430905", + "Investigating BetterStack Webhook: API Metrics", + metadata={ + "create_thread": True, + "thread_name": "BetterStack: API Metrics", + }, + ) + adapter.handle_message.assert_called_once() + session_delivery = adapter._delivery_info[ + "webhook:betterstack-alerts:thread-starter-success" + ] + assert session_delivery["deliver_extra"]["thread_id"] == "thread-456" + assert "create_thread" not in session_delivery["deliver_extra"] + assert "thread_starter" not in session_delivery["deliver_extra"] + + @pytest.mark.asyncio + async def test_thread_creation_failure_does_not_start_agent_and_allows_retry(self): + routes = { + "alerts": { + "secret": _INSECURE_NO_AUTH, + "prompt": "Investigate {title}", + "deliver": "discord", + "deliver_extra": { + "chat_id": "123", + "create_thread": True, + "thread_starter": "Investigating: {title}", + }, + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + discord_target = MagicMock() + discord_target.send = AsyncMock( + return_value=SendResult(success=False, error="Missing Access") + ) + runner = MagicMock() + runner.adapters = {Platform.DISCORD: discord_target} + adapter.gateway_runner = runner + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + response = await cli.post( + "/webhooks/alerts", + json={"title": "Database alert"}, + headers={"X-Request-ID": "thread-starter-failure"}, + ) + assert response.status == 502 + + adapter.handle_message.assert_not_called() + assert "thread-starter-failure" not in adapter._seen_deliveries + assert not adapter._delivery_info + + # =================================================================== # Event filtering # =================================================================== diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index 1731754c50f..503c7b3005c 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -85,7 +85,7 @@ Routes define how different webhook sources are handled. Each route is a named e | `script` | No | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin. JSON object stdout replaces the payload before templating; text stdout is exposed as `script_output`; empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. | | `skills` | No | List of skill names to load for the agent run. | | `deliver` | No | Where to send the response: `github_comment`, `telegram`, `discord`, `slack`, `signal`, `sms`, `whatsapp`, `matrix`, `mattermost`, `homeassistant`, `email`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`, or `log` (default). | -| `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. | +| `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. For Discord, `create_thread: true` creates a thread per event; optional `thread_starter` makes a compact parent-channel message before the agent starts, and `thread_name` controls the thread title. | | `deliver_only` | No | If `true`, skip the agent entirely — the rendered `prompt` template becomes the literal message that gets delivered. Zero LLM cost, sub-second delivery. See [Direct Delivery Mode](#direct-delivery-mode) for use cases. Requires `deliver` to be a real target (not `log`). | ### Full example @@ -228,8 +228,12 @@ If `chat_id` is not provided in `deliver_extra`, the delivery falls back to the ### 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. +delivery. By default, the first agent response becomes the thread starter and +any later messages from the same webhook run are routed into that thread. + +Set `thread_starter` to create the thread immediately, before the agent starts. +The rendered `thread_starter` is the only webhook message posted in the parent +channel; all agent status messages and findings are routed into its thread. ```yaml platforms: @@ -243,14 +247,17 @@ platforms: deliver_extra: chat_id: "123456789012345678" create_thread: true + thread_starter: "Investigating alert: {alert.name}" 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. +Threads** permissions in the target channel. `thread_starter` and `thread_name` +support the same payload templates as other `deliver_extra` values. +`thread_name` is truncated to Discord's 100-character thread-name limit. If +`chat_id` is omitted, the Discord home channel is used. If creating the starter +thread fails, Hermes returns HTTP 502 and does not start the agent, allowing the +webhook provider to retry safely. ---