From cc1e4c32c0227e808821822a4f6206d317973528 Mon Sep 17 00:00:00 2001 From: nocturnum91 <50326054+nocturnum91@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:36:22 -0700 Subject: [PATCH] fix(telegram): normalize thread id in group gating via shared helper Group gating (_should_process_message) read the raw message_thread_id, while event routing (_build_message_event) normalized it. A plain non-forum group reply's message_thread_id is a reply-UI anchor, not a topic, so an anchor id matching an ignored_threads entry wrongly dropped the message, and the anchor was treated as a routable topic under allowed_topics. Extract _effective_message_thread_id and route both gating and event-building through it, so gating and session routing agree on one normalized value: real topic/forum messages keep their thread id, reply anchors are dropped, and forum General-topic messages normalize to the General-topic id. --- plugins/platforms/telegram/adapter.py | 60 ++++++++++++--------- tests/gateway/test_telegram_group_gating.py | 56 +++++++++++++++++++ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index e816bbcbf2c..851aa513510 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6189,6 +6189,33 @@ class TelegramAdapter(BasePlatformAdapter): chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() return chat_type in {"group", "supergroup"} + @classmethod + def _effective_message_thread_id(cls, message: Message) -> Optional[str]: + """Return the routable thread id for a Telegram message. + + Forum supergroup messages posted in the General topic arrive with + ``message_thread_id=None`` while Telegram itself addresses that topic + as thread id ``1``. Ordinary replies are the opposite footgun: + Telegram populates ``message_thread_id`` with a reply-UI anchor id on + plain group/DM replies, but those ids are not topic/session routing + ids and must not be treated as such. Gating, skill binding, and + outbound routing must all agree on the same normalized value. + """ + chat = getattr(message, "chat", None) + chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() if chat else "" + raw = getattr(message, "message_thread_id", None) + is_topic_message = bool(getattr(message, "is_topic_message", False)) + is_forum_group = chat_type in ("group", "supergroup") and getattr(chat, "is_forum", False) is True + if raw is not None: + if is_forum_group or (chat_type in ("group", "supergroup") and is_topic_message): + return str(raw) + if chat_type == "private" and is_topic_message: + return str(raw) + return None + if is_forum_group: + return cls._GENERAL_TOPIC_THREAD_ID + return None + def _is_reply_to_bot(self, message: Message) -> bool: if not self._bot or not getattr(message, "reply_to_message", None): return False @@ -6718,7 +6745,7 @@ class TelegramAdapter(BasePlatformAdapter): if not self._is_group_chat(message): return True - thread_id = getattr(message, "message_thread_id", None) + thread_id = self._effective_message_thread_id(message) allowed_topics = self._telegram_allowed_topics() if allowed_topics: topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID @@ -7654,29 +7681,14 @@ class TelegramAdapter(BasePlatformAdapter): elif telegram_chat_type == "channel": chat_type = "channel" - # 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" 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 is_forum_group: - thread_id_str = self._GENERAL_TOPIC_THREAD_ID + # Resolve routable thread id for DM topics and forum group topics via + # the shared normalizer, so gating and session routing agree on one + # value. Only real topic/forum messages keep a thread id; ordinary + # reply-UI anchors are dropped (they are not durable session threads + # and sends against them hit 'Message thread not found', #3206), while + # forum General-topic messages (message_thread_id=None) normalize to + # the General-topic id so replies route back to General (#22423). + thread_id_str = self._effective_message_thread_id(message) chat_topic = None topic_skill = None diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 175c34e3ff6..20596e854fc 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -621,6 +621,62 @@ def test_allowed_topics_treat_missing_thread_as_general_topic(): assert adapter._should_process_message(_group_message("hello", thread_id=8)) is False +def _forum_message(*, chat_id, thread_id, is_topic_message, is_forum, chat_type="supergroup"): + """Build a message with independently-controlled topic/forum flags. + + The shared ``_group_message`` fixture couples ``is_topic_message`` and + ``is_forum`` to ``thread_id is not None``, which cannot express a plain + reply-UI anchor (``message_thread_id`` set, ``is_topic_message=False``, + ``is_forum=False``). This helper decouples them for gating regressions. + """ + return SimpleNamespace( + message_id=42, + text="hello", + caption=None, + entities=[], + caption_entities=[], + message_thread_id=thread_id, + is_topic_message=is_topic_message, + chat=SimpleNamespace(id=chat_id, type=chat_type, title="T", is_forum=is_forum), + from_user=SimpleNamespace(id=111, full_name="Alice", first_name="Alice"), + reply_to_message=None, + date=None, + ) + + +def test_gating_ignores_non_forum_reply_anchor_thread_id(): + """A plain group reply's ``message_thread_id`` is a UI anchor, not a topic. + + Before the shared ``_effective_message_thread_id`` normalizer, gating read + the raw ``message_thread_id`` — so a non-forum group reply whose anchor id + happened to match an ``ignored_threads`` entry was wrongly dropped, and its + anchor id was treated as a routable topic under ``allowed_topics``. The + normalizer drops reply anchors (non-forum, ``is_topic_message=False``), so + such a reply gates as the General topic instead. + """ + # ignored_threads: reply anchor 55 must NOT be treated as thread 55. + adapter = _make_adapter(require_mention=False, free_response_chats=["-200"], ignored_threads=[55]) + reply_anchor = _forum_message( + chat_id=-200, thread_id=55, is_topic_message=False, is_forum=False, chat_type="group" + ) + assert adapter._should_process_message(reply_anchor) is True + + # allowed_topics: reply anchor 55 normalizes to General ("1"), so a group + # that only allows topic "1" still processes the reply. + adapter2 = _make_adapter(require_mention=False, allowed_chats=["-200"], allowed_topics=["1"]) + assert adapter2._should_process_message(reply_anchor) is True + + +def test_gating_forum_general_topic_normalizes_to_one(): + """Forum General-topic messages (thread_id=None) gate as topic "1".""" + adapter = _make_adapter(require_mention=False, allowed_chats=["-100"], allowed_topics=["1"]) + general = _forum_message(chat_id=-100, thread_id=None, is_topic_message=False, is_forum=True) + assert adapter._should_process_message(general) is True + + adapter2 = _make_adapter(require_mention=False, allowed_chats=["-100"], allowed_topics=["8"]) + assert adapter2._should_process_message(general) is False + + def test_regex_mention_patterns_allow_custom_wake_words(): adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])