fix(telegram): support free-response topics

Add a telegram.free_response_topics config list of '<chat_id>:<thread_id>'
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
This commit is contained in:
Weslei ON 2026-07-16 01:37:17 -07:00 committed by Teknium
parent a79b818360
commit 13906cd4de
2 changed files with 101 additions and 1 deletions

View file

@ -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 ``<chat_id>:<thread_id>``.
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),

View file

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