feat(webhook): add {__raw__} template token and thread_id passthrough for forum topics
- {__raw__} in webhook prompt templates dumps the full JSON payload (truncated at 4000 chars)
- _deliver_cross_platform now passes thread_id/message_thread_id from deliver_extra as metadata, enabling Telegram forum topic delivery
- Tests for both features
This commit is contained in:
@@ -484,6 +484,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
Supports dot-notation access into nested dicts:
|
||||
``{pull_request.title}`` → ``payload["pull_request"]["title"]``
|
||||
|
||||
Special token ``{__raw__}`` dumps the entire payload as indented
|
||||
JSON (truncated to 4000 chars). Useful for monitoring alerts or
|
||||
any webhook where the agent needs to see the full payload.
|
||||
"""
|
||||
if not template:
|
||||
truncated = json.dumps(payload, indent=2)[:4000]
|
||||
@@ -494,6 +498,9 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
def _resolve(match: re.Match) -> str:
|
||||
key = match.group(1)
|
||||
# Special token: dump the entire payload as JSON
|
||||
if key == "__raw__":
|
||||
return json.dumps(payload, indent=2)[:4000]
|
||||
value: Any = payload
|
||||
for part in key.split("."):
|
||||
if isinstance(value, dict):
|
||||
@@ -613,4 +620,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
error=f"No chat_id or home channel for {platform_name}",
|
||||
)
|
||||
|
||||
return await adapter.send(chat_id, content)
|
||||
# Pass thread_id from deliver_extra so Telegram forum topics work
|
||||
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)
|
||||
|
||||
@@ -617,3 +617,107 @@ class TestCheckRequirements:
|
||||
@patch("gateway.platforms.webhook.AIOHTTP_AVAILABLE", False)
|
||||
def test_returns_false_without_aiohttp(self):
|
||||
assert check_webhook_requirements() is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# __raw__ template token
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestRawTemplateToken:
|
||||
"""Tests for the {__raw__} special token in _render_prompt."""
|
||||
|
||||
def test_raw_resolves_to_full_json_payload(self):
|
||||
"""{__raw__} in a template dumps the entire payload as JSON."""
|
||||
adapter = _make_adapter()
|
||||
payload = {"action": "opened", "number": 42}
|
||||
result = adapter._render_prompt(
|
||||
"Payload: {__raw__}", payload, "push", "test"
|
||||
)
|
||||
expected_json = json.dumps(payload, indent=2)
|
||||
assert result == f"Payload: {expected_json}"
|
||||
|
||||
def test_raw_truncated_at_4000_chars(self):
|
||||
"""{__raw__} output is truncated at 4000 characters for large payloads."""
|
||||
adapter = _make_adapter()
|
||||
# Build a payload whose JSON repr exceeds 4000 chars
|
||||
payload = {"data": "x" * 5000}
|
||||
result = adapter._render_prompt("{__raw__}", payload, "push", "test")
|
||||
assert len(result) <= 4000
|
||||
|
||||
def test_raw_mixed_with_other_variables(self):
|
||||
"""{__raw__} can be mixed with regular template variables."""
|
||||
adapter = _make_adapter()
|
||||
payload = {"action": "closed", "number": 7}
|
||||
result = adapter._render_prompt(
|
||||
"Action={action} Raw={__raw__}", payload, "push", "test"
|
||||
)
|
||||
assert result.startswith("Action=closed Raw=")
|
||||
assert '"action": "closed"' in result
|
||||
assert '"number": 7' in result
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Cross-platform delivery thread_id passthrough
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDeliverCrossPlatformThreadId:
|
||||
"""Tests for thread_id passthrough in _deliver_cross_platform."""
|
||||
|
||||
def _setup_adapter_with_mock_target(self):
|
||||
"""Set up a webhook adapter with a mocked gateway_runner and target adapter."""
|
||||
adapter = _make_adapter()
|
||||
mock_target = AsyncMock()
|
||||
mock_target.send = AsyncMock(return_value=SendResult(success=True))
|
||||
|
||||
mock_runner = MagicMock()
|
||||
mock_runner.adapters = {Platform("telegram"): mock_target}
|
||||
mock_runner.config.get_home_channel.return_value = None
|
||||
|
||||
adapter.gateway_runner = mock_runner
|
||||
return adapter, mock_target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_id_passed_as_metadata(self):
|
||||
"""thread_id from deliver_extra is passed as metadata to adapter.send()."""
|
||||
adapter, mock_target = self._setup_adapter_with_mock_target()
|
||||
delivery = {
|
||||
"deliver_extra": {
|
||||
"chat_id": "12345",
|
||||
"thread_id": "999",
|
||||
}
|
||||
}
|
||||
await adapter._deliver_cross_platform("telegram", "hello", delivery)
|
||||
mock_target.send.assert_awaited_once_with(
|
||||
"12345", "hello", metadata={"thread_id": "999"}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_thread_id_passed_as_thread_id(self):
|
||||
"""message_thread_id from deliver_extra is mapped to thread_id in metadata."""
|
||||
adapter, mock_target = self._setup_adapter_with_mock_target()
|
||||
delivery = {
|
||||
"deliver_extra": {
|
||||
"chat_id": "12345",
|
||||
"message_thread_id": "888",
|
||||
}
|
||||
}
|
||||
await adapter._deliver_cross_platform("telegram", "hello", delivery)
|
||||
mock_target.send.assert_awaited_once_with(
|
||||
"12345", "hello", metadata={"thread_id": "888"}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_thread_id_sends_no_metadata(self):
|
||||
"""When no thread_id is present, metadata is None."""
|
||||
adapter, mock_target = self._setup_adapter_with_mock_target()
|
||||
delivery = {
|
||||
"deliver_extra": {
|
||||
"chat_id": "12345",
|
||||
}
|
||||
}
|
||||
await adapter._deliver_cross_platform("telegram", "hello", delivery)
|
||||
mock_target.send.assert_awaited_once_with(
|
||||
"12345", "hello", metadata=None
|
||||
)
|
||||
|
||||
@@ -257,7 +257,7 @@ class TestCrossPlatformDelivery:
|
||||
|
||||
assert result.success is True
|
||||
mock_tg_adapter.send.assert_awaited_once_with(
|
||||
"12345", "I've acknowledged the alert."
|
||||
"12345", "I've acknowledged the alert.", metadata=None
|
||||
)
|
||||
# Delivery info should be cleaned up
|
||||
assert chat_id not in adapter._delivery_info
|
||||
|
||||
Reference in New Issue
Block a user