feat(webhooks): create Discord thread per event
This commit is contained in:
@@ -1332,10 +1332,29 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||||||
error=f"No chat_id or home channel for {platform_name}",
|
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
|
metadata = None
|
||||||
thread_id = extra.get("message_thread_id") or extra.get("thread_id")
|
thread_id = extra.get("message_thread_id") or extra.get("thread_id")
|
||||||
if thread_id:
|
if thread_id:
|
||||||
metadata = {"thread_id": 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.
|
"""Send a message to a Discord channel or thread.
|
||||||
|
|
||||||
When metadata contains a thread_id, the message is sent to that
|
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
|
Forum channels (type 15) reject direct messages — a thread post is
|
||||||
created automatically.
|
created automatically.
|
||||||
@@ -2908,6 +2910,41 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
return result
|
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
|
# Format and split message if needed
|
||||||
formatted = self.format_message(content)
|
formatted = self.format_message(content)
|
||||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
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
|
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
|
# Forum channel tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1497,6 +1497,55 @@ class TestDeliverCrossPlatformThreadId:
|
|||||||
"12345", "hello", metadata=None
|
"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:
|
class TestInsecureNoAuthSafetyRail:
|
||||||
"""connect() refuses to start when INSECURE_NO_AUTH is combined with a
|
"""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.
|
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}
|
## GitHub PR Review (Step by Step) {#github-pr-review}
|
||||||
|
|||||||
Reference in New Issue
Block a user