diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index d68296e12cb..a129c87bc74 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -4795,25 +4795,28 @@ class TelegramAdapter(BasePlatformAdapter): elif chat.type == ChatType.CHANNEL: chat_type = "channel" - # Resolve DM topic name and skill binding. - # In private chats, only preserve thread ids for real topic messages - # (is_topic_message=True). Telegram puts message_thread_id on every - # DM that is a reply, even when the user is just replying to a - # previous message in the same DM — that bogus id then routes to a - # nonexistent thread and Telegram returns 'Message thread not found' - # on send (#3206). + # Resolve Telegram topic name and skill binding. + # Only preserve message_thread_id when Telegram marks the message as + # a real topic/forum message. Telegram can also populate + # message_thread_id for ordinary reply UI anchors; treating those as + # durable session threads fragments workflows such as CAPTCHA/login + # handoffs where the user later replies "done" in the same group. + # Private chats have the same pitfall: only real DM topic messages + # (is_topic_message=True) should keep the thread id, otherwise sends + # can hit Telegram's 'Message thread not found' error (#3206). thread_id_raw = message.message_thread_id is_topic_message = bool(getattr(message, "is_topic_message", False)) + is_forum_group = getattr(chat, "is_forum", False) is True thread_id_str = None if thread_id_raw is not None: - if chat_type == "group": + if chat_type == "group" and (is_topic_message or is_forum_group): thread_id_str = str(thread_id_raw) elif chat_type == "dm" and is_topic_message: thread_id_str = str(thread_id_raw) # For forum groups without an explicit topic, default to the # General-topic id so the gateway routes back to the General topic # rather than dropping into the bot's main channel (#22423). - if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False): + if chat_type == "group" and thread_id_str is None and is_forum_group: thread_id_str = self._GENERAL_TOPIC_THREAD_ID chat_topic = None topic_skill = None diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 1d1cf365e0e..cf89fcaacab 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -449,13 +449,15 @@ def test_cache_dm_topic_from_message_no_overwrite(): def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None, user_id=42, user_name="Test User", forum_topic_created=None, - is_topic_message=None): + is_topic_message=None, is_forum=None): """Create a mock Telegram Message for _build_message_event tests.""" chat = SimpleNamespace( id=chat_id, type=chat_type, title=None, ) + if is_forum is not None: + chat.is_forum = is_forum # Add full_name attribute for DM chats if not hasattr(chat, "full_name"): chat.full_name = user_name @@ -594,7 +596,12 @@ def test_group_topic_skill_binding(): ]) msg = _make_mock_message( - chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="hello" + chat_id=-1001234567890, + chat_type=_ChatType.SUPERGROUP, + thread_id=5, + text="hello", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) @@ -617,7 +624,12 @@ def test_group_topic_skill_binding_second_topic(): ]) msg = _make_mock_message( - chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=12, text="deal update" + chat_id=-1001234567890, + chat_type=_ChatType.SUPERGROUP, + thread_id=12, + text="deal update", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) @@ -639,7 +651,12 @@ def test_group_topic_no_skill_binding(): ]) msg = _make_mock_message( - chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=1, text="hey" + chat_id=-1001234567890, + chat_type=_ChatType.SUPERGROUP, + thread_id=1, + text="hey", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) @@ -661,7 +678,12 @@ def test_group_topic_unmapped_thread_id(): ]) msg = _make_mock_message( - chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=999, text="random" + chat_id=-1001234567890, + chat_type=_ChatType.SUPERGROUP, + thread_id=999, + text="random", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) @@ -683,7 +705,12 @@ def test_group_topic_unmapped_chat_id(): ]) msg = _make_mock_message( - chat_id=-1009999999999, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="wrong group" + chat_id=-1009999999999, + chat_type=_ChatType.SUPERGROUP, + thread_id=5, + text="wrong group", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) @@ -720,7 +747,12 @@ def test_group_topic_chat_id_int_string_coercion(): ]) msg = _make_mock_message( - chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=7, text="test" + chat_id=-1001234567890, + chat_type=_ChatType.SUPERGROUP, + thread_id=7, + text="test", + is_topic_message=True, + is_forum=True, ) event = adapter._build_message_event(msg, MessageType.TEXT) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index 20dc8c67b31..643588cac6a 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -134,6 +134,70 @@ def _make_adapter(): return adapter +def test_non_forum_group_reply_thread_id_does_not_fork_session_key(): + """Reply-derived thread ids in ordinary groups must not create topic lanes.""" + from gateway.platforms import telegram as telegram_mod + + adapter = _make_adapter() + message = SimpleNamespace( + text="Done", + caption=None, + chat=SimpleNamespace( + id=-100123, + type=telegram_mod.ChatType.SUPERGROUP, + is_forum=False, + title="Regular group", + ), + from_user=SimpleNamespace(id=456, full_name="Alice"), + message_thread_id=461, + is_topic_message=False, + reply_to_message=SimpleNamespace( + message_id=460, + text="Please complete the CAPTCHA/login, then reply done.", + caption=None, + ), + message_id=462, + date=None, + ) + + event = adapter._build_message_event(message, msg_type=MessageType.TEXT) + + assert event.source.chat_id == "-100123" + assert event.source.chat_type == "group" + assert event.source.thread_id is None + assert build_session_key(event.source) == "agent:main:telegram:group:-100123:456" + + +def test_forum_group_topic_message_preserves_thread_session_key(): + """Real Telegram forum-topic messages should still route by topic id.""" + from gateway.platforms import telegram as telegram_mod + + adapter = _make_adapter() + message = SimpleNamespace( + text="hello from topic", + caption=None, + chat=SimpleNamespace( + id=-100123, + type=telegram_mod.ChatType.SUPERGROUP, + is_forum=True, + title="Forum group", + ), + from_user=SimpleNamespace(id=456, full_name="Alice"), + message_thread_id=17585, + is_topic_message=True, + reply_to_message=None, + message_id=10, + date=None, + ) + + event = adapter._build_message_event(message, msg_type=MessageType.TEXT) + + assert event.source.chat_id == "-100123" + assert event.source.chat_type == "group" + assert event.source.thread_id == "17585" + assert build_session_key(event.source) == "agent:main:telegram:group:-100123:17585" + + def test_forum_general_topic_without_message_thread_id_keeps_thread_context(): """Forum General-topic messages should keep synthetic thread context.""" from gateway.platforms import telegram as telegram_mod