From 2de512df40f38d17a296a15143c7bf57f90f6b08 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Wed, 27 May 2026 11:15:10 -0700 Subject: [PATCH] fix(webhook): forward Telegram DM-topic routing keys from deliver_extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32270 (2026-05-25) introduced a fail-loud guard in `TelegramAdapter.send()`: a private-chat send carrying a thread_id is rejected with "requires a reply anchor" unless metadata includes one of `telegram_reply_to_message_id`, `direct_messages_topic_id`, or `telegram_dm_topic_created_for_send`. The agent/cron paths populate those keys themselves, but `WebhookAdapter._deliver_cross_platform` only forwarded `thread_id`, so any webhook subscription targeting a private DM topic now fails on every delivery — Hermes accepts the webhook (`HTTP 202`, `delivered: true`), then the cross-platform send is refused before it ever hits Telegram. Plumb the four DM-topic routing keys straight through from `deliver_extra` to the metadata dict. Webhook route configs are the only place the operator can express the opt-out, so this is the canonical place to wire them in. Mirrors `TelegramAdapter.send()` predicate precedence: `telegram_reply_to_message_id` (anchor) > `direct_messages_topic_id` (true Bot-API DM topic) > `telegram_dm_topic_created_for_send` (Hermes-created DM topic) > `telegram_dm_topic_reply_fallback` (reply_to_mode='off' opt-in). Legacy single-key `thread_id` / `message_thread_id` forwarding is unchanged. --- gateway/platforms/webhook.py | 22 ++++++- tests/gateway/test_webhook_adapter.py | 87 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 32c6e8109bd9..59af2e01966d 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -916,10 +916,28 @@ async def _deliver_cross_platform( error=f"No chat_id or home channel for {platform_name}", ) - # Pass thread_id from deliver_extra so Telegram forum topics work - metadata = None + # Pass thread_id from deliver_extra so Telegram forum topics work. + # Also forward the Telegram DM-topic routing signals the adapter needs + # to satisfy the fail-loud anchor-required guard added in PR #32270: + # without one of telegram_reply_to_message_id, direct_messages_topic_id, + # or telegram_dm_topic_created_for_send, a private DM with a thread_id + # is rejected with "requires a reply anchor". Webhook routes have no + # other channel to express the opt-out, so plumb the keys straight + # through from deliver_extra. + metadata: Optional[Dict[str, Any]] = None thread_id = extra.get("message_thread_id") or extra.get("thread_id") if thread_id: metadata = {"thread_id": thread_id} + for key in ( + "telegram_reply_to_message_id", + "telegram_dm_topic_created_for_send", + "telegram_dm_topic_reply_fallback", + "direct_messages_topic_id", + ): + value = extra.get(key) + if value is not None: + if metadata is None: + metadata = {} + metadata[key] = value 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 606bd80e46e8..e81098294b12 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -956,6 +956,93 @@ async def test_no_thread_id_sends_no_metadata(self): "12345", "hello", metadata=None ) + @pytest.mark.asyncio + async def test_telegram_reply_to_message_id_forwarded(self): + """telegram_reply_to_message_id in deliver_extra reaches metadata so + the Telegram adapter's anchor-required guard accepts a DM-topic send.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "message_thread_id": "888", + "telegram_reply_to_message_id": "42", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", + "hello", + metadata={"thread_id": "888", "telegram_reply_to_message_id": "42"}, + ) + + @pytest.mark.asyncio + async def test_telegram_dm_topic_opt_out_flags_forwarded(self): + """telegram_dm_topic_created_for_send and direct_messages_topic_id are + forwarded so a webhook route can opt out of the reply-anchor guard + when sending to a true Bot-API DM topic.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "message_thread_id": "888", + "telegram_dm_topic_created_for_send": True, + "direct_messages_topic_id": "777", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", + "hello", + metadata={ + "thread_id": "888", + "telegram_dm_topic_created_for_send": True, + "direct_messages_topic_id": "777", + }, + ) + + @pytest.mark.asyncio + async def test_telegram_reply_fallback_marker_forwarded(self): + """telegram_dm_topic_reply_fallback combined with the reply anchor lets + the Telegram adapter pick its reply_to_mode-aware send path.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "message_thread_id": "888", + "telegram_dm_topic_reply_fallback": True, + "telegram_reply_to_message_id": "42", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", + "hello", + metadata={ + "thread_id": "888", + "telegram_dm_topic_reply_fallback": True, + "telegram_reply_to_message_id": "42", + }, + ) + + @pytest.mark.asyncio + async def test_dm_topic_keys_without_thread_id(self): + """deliver_extra may carry the opt-out flags without a thread_id (e.g. + a DM topic created by the route handler upstream). The metadata dict + still contains the forwarded keys.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "telegram_dm_topic_created_for_send": True, + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", + "hello", + metadata={"telegram_dm_topic_created_for_send": True}, + ) + class TestInsecureNoAuthSafetyRail: """connect() refuses to start when INSECURE_NO_AUTH is combined with a