feat(webhooks): support compact Discord thread starters

This commit is contained in:
Ben Barclay 2026-07-27 05:19:03 +00:00
parent 579d149f0b
commit 15134fb875
3 changed files with 174 additions and 7 deletions

View file

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

View file

@ -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
# ===================================================================

View file

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