mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
a79b818360
commit
13906cd4de
2 changed files with 101 additions and 1 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue