mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
cdd553945e
commit
cc1e4c32c0
2 changed files with 92 additions and 24 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue