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