mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(webhooks): support compact Discord thread starters
This commit is contained in:
parent
579d149f0b
commit
15134fb875
3 changed files with 174 additions and 7 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue