feat(webhooks): create Discord thread per event

This commit is contained in:
Ben Barclay 2026-07-24 06:58:02 +00:00
parent 46c7a4076f
commit 579d149f0b
5 changed files with 163 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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