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