From 13906cd4de7771bf7101b764445532c3d663c330 Mon Sep 17 00:00:00 2001 From: Weslei ON <205466933+wesleion@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:37:17 -0700 Subject: [PATCH] fix(telegram): support free-response topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a telegram.free_response_topics config list of ':' entries (plus the TELEGRAM_FREE_RESPONSE_TOPICS env bridge) so a single forum topic can be free-response — the bot replies without a mention — without opening the whole chat via free_response_chats. A missing message_thread_id is normalized to the General topic ('1') via _effective_message_thread_id. Re-ported from PR #36049 (by @wesleion): the original patched gateway/platforms/telegram.py, which has since moved to plugins/platforms/telegram/adapter.py with a second gating site (_should_observe_unmentioned_group_message) and plugin-hook config bridging (_apply_yaml_config). Both gating sites now honor free_response_topics. Salvaged-from: #36049 --- plugins/platforms/telegram/adapter.py | 37 +++++++++++- tests/gateway/test_telegram_group_gating.py | 65 +++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 902cd6ce2ff7..1af072adea76 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -7118,6 +7118,32 @@ class TelegramAdapter(BasePlatformAdapter): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} + def _telegram_free_response_topics(self) -> set[str]: + """Return topic-level free-response allowlist entries as ``:``. + + Unlike ``free_response_chats`` (whole-chat), each entry opens a single + forum topic for free-response. A missing/omitted thread id on incoming + messages is normalized to the General topic (``1``). + """ + raw = self.config.extra.get("free_response_topics") + if raw is None: + raw = os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _telegram_is_free_response_topic(self, message: Message) -> bool: + """True when the message's chat/topic pair is in ``free_response_topics``.""" + topics = self._telegram_free_response_topics() + if not topics: + return False + chat_id = str(getattr(getattr(message, "chat", None), "id", "")) + if not chat_id: + return False + thread_id = self._effective_message_thread_id(message) + topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID + return f"{chat_id}:{topic_id}" in topics + def _telegram_allowed_chats(self) -> set[str]: """Return the whitelist of group/supergroup chat IDs the bot will respond in. @@ -7472,6 +7498,8 @@ class TelegramAdapter(BasePlatformAdapter): # if require_mention is disabled, every group message is a request. if chat_id_str in self._telegram_free_response_chats(): return False + if self._telegram_is_free_response_topic(message): + return False if not self._telegram_require_mention(): return False if self._is_reply_to_bot(message): @@ -7837,6 +7865,8 @@ class TelegramAdapter(BasePlatformAdapter): return True if chat_id_str in self._telegram_free_response_chats(): return True + if self._telegram_is_free_response_topic(message): + return True if not self._telegram_require_mention(): return True if self._is_reply_to_bot(message): @@ -9098,6 +9128,11 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) + frt = telegram_cfg.get("free_response_topics") + if frt is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS"): + if isinstance(frt, list): + frt = ",".join(str(v) for v in frt) + os.environ["TELEGRAM_FREE_RESPONSE_TOPICS"] = str(frt) ac = telegram_cfg.get("allowed_chats") if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"): if isinstance(ac, list): @@ -9140,7 +9175,7 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: if isinstance(group_allowed_chats, list): group_allowed_chats = ",".join(str(v) for v in group_allowed_chats) os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats) - for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"): + for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages", "free_response_topics"): if _key in telegram_cfg: extras.setdefault(_key, telegram_cfg[_key]) # Pass through telegram-specific extra keys (e.g. base_url proxy override), diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 20596e854fca..9f69132d86eb 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -11,6 +11,7 @@ from gateway.session import SessionSource def _make_adapter( require_mention=None, free_response_chats=None, + free_response_topics=None, mention_patterns=None, exclusive_bot_mentions=None, ignored_threads=None, @@ -30,6 +31,8 @@ def _make_adapter( extra["require_mention"] = require_mention if free_response_chats is not None: extra["free_response_chats"] = free_response_chats + if free_response_topics is not None: + extra["free_response_topics"] = free_response_topics if mention_patterns is not None: extra["mention_patterns"] = mention_patterns if exclusive_bot_mentions is not None: @@ -543,6 +546,41 @@ def test_free_response_chats_bypass_mention_requirement(): assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False +def test_free_response_topics_bypass_mention_requirement_only_for_topic(): + adapter = _make_adapter(require_mention=True, free_response_topics=["-200:31"]) + + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=31)) is True + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=32)) is False + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201, thread_id=31)) is False + + +def test_free_response_topics_treat_missing_thread_as_general_topic(): + adapter = _make_adapter(require_mention=True, free_response_topics=["-200:1"]) + + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=None)) is True + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=31)) is False + + +def test_free_response_topic_messages_are_dispatched_not_observed(): + """A free-response topic message must go to the dispatcher, not the observe path.""" + adapter = _make_adapter( + require_mention=True, + allowed_chats=["-200"], + group_allowed_chats=["-200"], + observe_unmentioned_group_messages=True, + free_response_topics=["-200:31"], + ) + + in_topic = _group_message("hello everyone", chat_id=-200, thread_id=31) + assert adapter._should_process_message(in_topic) is True + assert adapter._should_observe_unmentioned_group_message(in_topic) is False + + # Same chat, different topic: not dispatched, but still observable. + other_topic = _group_message("side chatter", chat_id=-200, thread_id=32) + assert adapter._should_process_message(other_topic) is False + assert adapter._should_observe_unmentioned_group_message(other_topic) is True + + def test_guest_mode_allows_only_direct_mentions_outside_allowed_chats(): adapter = _make_adapter( require_mention=True, @@ -938,6 +976,33 @@ def test_top_level_require_mention_does_not_override_telegram_section(monkeypatc assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "false" +def test_config_bridges_telegram_free_response_topics(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " free_response_topics:\n" + ' - "-1001234567:3"\n' + ' - "-1001234567:9"\n', + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_TOPICS", raising=False) + + config = load_gateway_config() + + assert config is not None + tg_cfg = config.platforms.get(Platform.TELEGRAM) + assert tg_cfg is not None + # free_response_topics is carried in PlatformConfig.extra (like guest_mode) + # AND bridged to the env var the adapter reads at runtime. The env var is + # not a key that appears in developer .env files, so asserting it via + # os.environ stays deterministic. + assert tg_cfg.extra.get("free_response_topics") == ["-1001234567:3", "-1001234567:9"] + assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_TOPICS"] == "-1001234567:3,-1001234567:9" + + def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path): hermes_home = tmp_path / ".hermes" hermes_home.mkdir()