diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 5f7c78cfa..ae2e7f27a 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 9b8a91318..f323b95af 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -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 + ) diff --git a/tests/gateway/test_webhook_integration.py b/tests/gateway/test_webhook_integration.py index 14b9b6974..899989810 100644 --- a/tests/gateway/test_webhook_integration.py +++ b/tests/gateway/test_webhook_integration.py @@ -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