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.
This commit is contained in:
nocturnum91 2026-06-30 23:36:22 -07:00 committed by Teknium
parent cdd553945e
commit cc1e4c32c0
2 changed files with 92 additions and 24 deletions

View file

@ -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

View file

@ -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"])