diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5899843aa1e..b2d5f6e22ab 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -45,10 +45,10 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) Most platforms route threaded sends with a generic ``thread_id`` metadata value. Telegram private-chat topics created through Hermes' DM-topic helper - are exposed in updates as ``message_thread_id`` plus a reply anchor, but - outbound sends only render in the correct Telegram lane when the adapter - supplies both ``message_thread_id`` and ``reply_to_message_id``. Mark those - lanes so the Telegram adapter can avoid the known-bad partial routes. + are exposed in updates as ``message_thread_id`` plus a reply anchor. Live + user-message replies route with ``message_thread_id`` + ``reply_to_message_id``; + synthetic/resumed sends that have no reply anchor fall back to Telegram's + ``direct_messages_topic_id`` when the Bot API supports it. """ thread_id = getattr(source, "thread_id", None) if thread_id is None: @@ -56,6 +56,9 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) metadata = {"thread_id": thread_id} if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm": metadata["telegram_dm_topic_reply_fallback"] = True + tid = str(thread_id) + if tid and tid not in {"", "1"}: + metadata["direct_messages_topic_id"] = tid anchor = reply_to_message_id or getattr(source, "message_id", None) if anchor is not None: metadata["telegram_reply_to_message_id"] = str(anchor) @@ -67,10 +70,9 @@ def _reply_anchor_for_event(event) -> str | None: Telegram forum/supergroup topics should be routed by topic metadata, not by replying to the triggering message. Hermes-created Telegram private-chat - topic lanes are different: Bot API sends reject their ``message_thread_id`` - and do not route with ``direct_messages_topic_id``. Those lanes only remain - visible when sent with both the private topic thread id and a reply to the - triggering user message. + topic lanes prefer replying to the triggering user message so the answer + stays attached to the active lane; synthetic/resumed sends fall back to + ``direct_messages_topic_id`` metadata when no message id is available. """ source = getattr(event, "source", None) platform = _platform_name(getattr(source, "platform", None)) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 49d96bbb145..f68b4ffbd73 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -560,9 +560,10 @@ class TelegramAdapter(BasePlatformAdapter): Supergroup/forum topics use ``message_thread_id``. True Bot API Direct Messages topics can opt in with explicit ``direct_messages_topic_id`` metadata. Hermes-created private-chat topic lanes are marked with - ``telegram_dm_topic_reply_fallback`` and must send the private topic - thread id together with a reply anchor. Live testing showed that either - parameter alone can render outside the visible lane. + ``telegram_dm_topic_reply_fallback``. Live replies send the private + topic thread id together with a reply anchor; synthetic/resumed sends + without an anchor use ``direct_messages_topic_id`` when metadata has it. + ``message_thread_id`` alone can render outside the visible lane. When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for DM topic fallback sends while preserving the ``message_thread_id`` so @@ -574,6 +575,12 @@ class TelegramAdapter(BasePlatformAdapter): if reply_to_message_id is None: reply_to_message_id = cls._metadata_reply_to_message_id(metadata) if reply_to_message_id is None: + direct_topic_id = cls._metadata_direct_messages_topic_id(metadata) + if direct_topic_id is not None: + return { + "message_thread_id": None, + "direct_messages_topic_id": int(direct_topic_id), + } return {} return {"message_thread_id": cls._message_thread_id_for_send(thread_id)} direct_topic_id = cls._metadata_direct_messages_topic_id(metadata) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index f46997f0b92..20dc8c67b31 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -331,10 +331,28 @@ def test_base_gateway_metadata_marks_telegram_dm_topics_as_reply_fallback(): assert metadata == { "thread_id": "20189", "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", "telegram_reply_to_message_id": "462", } +def test_base_gateway_metadata_for_resumed_telegram_dm_topic_uses_direct_topic(): + """Resumed/synthetic DM-topic events may have no reply anchor.""" + source = SimpleNamespace( + platform=Platform.TELEGRAM, + chat_type="dm", + thread_id="20189", + ) + + metadata = _thread_metadata_for_source(source) + + assert metadata == { + "thread_id": "20189", + "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", + } + + def test_base_gateway_replies_to_triggering_message_for_telegram_dm_topic(): """Private DM topic lanes should anchor replies to the active user message.""" event = SimpleNamespace( @@ -533,7 +551,7 @@ async def test_send_model_picker_uses_metadata_reply_fallback_for_dm_topics(): @pytest.mark.asyncio async def test_send_dm_topic_fallback_without_anchor_does_not_crash(): - """DM-topic fallback without an anchor must not use message_thread_id alone.""" + """DM-topic fallback without an anchor uses direct topic routing.""" adapter = _make_adapter() call_log = [] @@ -549,13 +567,14 @@ async def test_send_dm_topic_fallback_without_anchor_does_not_crash(): metadata={ "thread_id": "20197", "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20197", }, ) assert result.success is True assert call_log[0]["reply_to_message_id"] is None - assert "message_thread_id" not in call_log[0] - assert "direct_messages_topic_id" not in call_log[0] + assert call_log[0]["message_thread_id"] is None + assert call_log[0]["direct_messages_topic_id"] == 20197 @pytest.mark.asyncio