From 55f518e5216a576b95ef9a5e8851e4dcf99e2b27 Mon Sep 17 00:00:00 2001 From: Nikita Nosov <20nik.nosov21@gmail.com> Date: Fri, 8 May 2026 11:49:55 +0000 Subject: [PATCH] feat(gateway): add Telegram guest mention mode --- cli-config.yaml.example | 4 ++ gateway/config.py | 23 +++--- gateway/platforms/telegram.py | 50 +++++++++---- .../gateway/test_allowed_channels_widening.py | 4 +- tests/gateway/test_telegram_format.py | 70 +++++++++++++++++++ tests/gateway/test_telegram_group_gating.py | 42 +++++++++++ 6 files changed, 168 insertions(+), 25 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 07d00add21a..b611b395755 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -657,6 +657,10 @@ platform_toolsets: # platforms: # telegram: # reply_to_mode: "first" # off | first | all +# # guest_mode lets explicit @mentions from non-allowlisted groups through. +# # Default false; ordinary messages, replies, and regex wake words stay blocked. +# guest_mode: false +# # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages diff --git a/gateway/config.py b/gateway/config.py index 6b09b34d18b..6756755c3a9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -896,6 +896,8 @@ def load_gateway_config() -> GatewayConfig: os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower() if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"): os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"]) + if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"): + os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower() frc = telegram_cfg.get("free_response_chats") if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): if isinstance(frc, list): @@ -941,16 +943,17 @@ def load_gateway_config() -> GatewayConfig: 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) - if "disable_link_previews" in telegram_cfg: - plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) - if not isinstance(plat_data, dict): - plat_data = {} - platforms_data[Platform.TELEGRAM.value] = plat_data - extra = plat_data.setdefault("extra", {}) - if not isinstance(extra, dict): - extra = {} - plat_data["extra"] = extra - extra["disable_link_previews"] = telegram_cfg["disable_link_previews"] + for _telegram_extra_key in ("guest_mode", "disable_link_previews"): + if _telegram_extra_key in telegram_cfg: + plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[Platform.TELEGRAM.value] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + extra[_telegram_extra_key] = telegram_cfg[_telegram_extra_key] whatsapp_cfg = yaml_cfg.get("whatsapp", {}) if isinstance(whatsapp_cfg, dict): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0017edb8472..2aac6c706cf 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -3127,6 +3127,15 @@ class TelegramAdapter(BasePlatformAdapter): return bool(configured) return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _telegram_guest_mode(self) -> bool: + """Return whether non-allowlisted groups may trigger via direct @mention.""" + configured = self.config.extra.get("guest_mode") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in ("true", "1", "yes", "on") + def _telegram_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") if raw is None: @@ -3286,6 +3295,14 @@ class TelegramAdapter(BasePlatformAdapter): return True return False + def _is_guest_mention(self, message: Message) -> bool: + """Return True for the narrow guest-mode bypass: group + explicit bot mention.""" + return ( + self._telegram_guest_mode() + and self._is_group_chat(message) + and self._message_mentions_bot(message) + ) + def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: if not text or not self._bot or not getattr(self._bot, "username", None): return text @@ -3297,16 +3314,18 @@ class TelegramAdapter(BasePlatformAdapter): """Apply Telegram group trigger rules. DMs remain unrestricted. Group/supergroup messages are accepted when: - - the chat passes the ``allowed_chats`` whitelist (when set) + - the chat passes the ``allowed_chats`` whitelist (when set), or + ``guest_mode`` is enabled and the bot is explicitly mentioned - the chat is explicitly allowlisted in ``free_response_chats`` - ``require_mention`` is disabled - the message replies to the bot - the bot is @mentioned - the text/caption matches a configured regex wake-word pattern - When ``allowed_chats`` is non-empty, it acts as a hard gate — messages - from any chat not in the list are ignored regardless of the other - rules. When ``require_mention`` is enabled, slash commands are not given + When ``allowed_chats`` is non-empty, it remains a hard gate except for + the narrow ``guest_mode`` bypass: group/supergroup messages that + explicitly @mention this bot. Replies and regex wake words do not bypass + ``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given special treatment — they must pass the same mention/reply checks as any other group message. Users can still trigger commands via the Telegram bot menu (``/command@botname``) or by explicitly @@ -3315,14 +3334,7 @@ class TelegramAdapter(BasePlatformAdapter): """ if not self._is_group_chat(message): return True - # allowed_chats check (whitelist — must pass before other gating). - # When set, group messages from chats NOT in this whitelist are - # silently ignored, even if @mentioned. DMs are already excluded above. - allowed = self._telegram_allowed_chats() - if allowed: - chat_id_str = str(getattr(getattr(message, "chat", None), "id", "")) - if chat_id_str not in allowed: - return False + thread_id = getattr(message, "message_thread_id", None) if thread_id is not None: try: @@ -3330,7 +3342,19 @@ class TelegramAdapter(BasePlatformAdapter): return False except (TypeError, ValueError): logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id) - if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats(): + + chat_id_str = str(getattr(getattr(message, "chat", None), "id", "")) + guest_mention = self._is_guest_mention(message) + + # allowed_chats check (whitelist). When set, group messages from chats + # outside the whitelist are ignored unless guest_mode permits this + # exact message as an explicit direct mention. DMs are excluded above. + allowed = self._telegram_allowed_chats() + if allowed and chat_id_str not in allowed: + return guest_mention + if guest_mention: + return True + if chat_id_str in self._telegram_free_response_chats(): return True if not self._telegram_require_mention(): return True diff --git a/tests/gateway/test_allowed_channels_widening.py b/tests/gateway/test_allowed_channels_widening.py index 47296e5c7e0..73c69f248ee 100644 --- a/tests/gateway/test_allowed_channels_widening.py +++ b/tests/gateway/test_allowed_channels_widening.py @@ -23,10 +23,10 @@ from gateway.config import Platform, PlatformConfig # Telegram # --------------------------------------------------------------------------- -def _make_telegram_adapter(*, allowed_chats=None, require_mention=None): +def _make_telegram_adapter(*, allowed_chats=None, require_mention=None, guest_mode=False): from gateway.platforms.telegram import TelegramAdapter - extra = {} + extra = {"guest_mode": guest_mode} if allowed_chats is not None: extra["allowed_chats"] = allowed_chats if require_mention is not None: diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 5ca3e21e1af..dcb6568ecb7 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -7,6 +7,7 @@ or corrupt user-visible content. import re import sys +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -757,3 +758,72 @@ class TestEditMessageStreamingSafety: "message_id": 456, "text": "final **bold**", } + +# ========================================================================= +# Telegram guest mention gating +# ========================================================================= + + +def _guest_test_adapter(*, guest_mode=True, require_mention=True, allowed_chats=None): + config = PlatformConfig( + enabled=True, + token="fake-token", + extra={ + "guest_mode": guest_mode, + "require_mention": require_mention, + "allowed_chats": allowed_chats or ["-100200"], + }, + ) + adapter = object.__new__(TelegramAdapter) + adapter.config = config + adapter._bot = SimpleNamespace(id=999, username="hermes_bot") + adapter._mention_patterns = adapter._compile_mention_patterns() + return adapter + + +def _guest_group_message(text, *, chat_id=-100201, entities=None, reply_to_bot=False): + reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) if reply_to_bot else None + return SimpleNamespace( + text=text, + caption=None, + entities=entities or [], + caption_entities=[], + message_thread_id=None, + chat=SimpleNamespace(id=chat_id, type="group"), + from_user=SimpleNamespace(id=111), + reply_to_message=reply_to_message, + ) + + +def _guest_mention_entity(text, mention="@hermes_bot"): + return SimpleNamespace(type="mention", offset=text.index(mention), length=len(mention)) + + +class TestTelegramGuestMentionGating: + def test_guest_mode_allows_explicit_mention_outside_allowed_chats(self): + adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"]) + text = "please help @hermes_bot" + message = _guest_group_message( + text, + chat_id=-100201, + entities=[_guest_mention_entity(text)], + ) + + assert adapter._should_process_message(message) is True + + def test_guest_mode_does_not_allow_reply_outside_allowed_chats(self): + adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"]) + message = _guest_group_message("replying without mention", chat_id=-100201, reply_to_bot=True) + + assert adapter._should_process_message(message) is False + + def test_guest_mode_disabled_keeps_allowed_chats_as_hard_gate_for_mentions(self): + adapter = _guest_test_adapter(guest_mode=False, allowed_chats=["-100200"]) + text = "please help @hermes_bot" + message = _guest_group_message( + text, + chat_id=-100201, + entities=[_guest_mention_entity(text)], + ) + + assert adapter._should_process_message(message) is False diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 52e4a5e6d3d..ebf77d3ad14 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -12,6 +12,8 @@ def _make_adapter( ignored_threads=None, allow_from=None, group_allow_from=None, + allowed_chats=None, + guest_mode=None, ): from gateway.platforms.telegram import TelegramAdapter @@ -28,6 +30,10 @@ def _make_adapter( extra["allow_from"] = allow_from if group_allow_from is not None: extra["group_allow_from"] = group_allow_from + if allowed_chats is not None: + extra["allowed_chats"] = allowed_chats + if guest_mode is not None: + extra["guest_mode"] = guest_mode adapter = object.__new__(TelegramAdapter) adapter.platform = Platform.TELEGRAM @@ -150,6 +156,36 @@ def test_free_response_chats_bypass_mention_requirement(): assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False +def test_guest_mode_allows_only_direct_mentions_outside_allowed_chats(): + adapter = _make_adapter( + require_mention=True, + allowed_chats=["-200"], + guest_mode=True, + mention_patterns=[r"^\s*chompy\b"], + ) + + mentioned = _group_message( + "hi @hermes_bot", + chat_id=-201, + entities=[_mention_entity("hi @hermes_bot")], + ) + assert adapter._should_process_message(mentioned) is True + assert adapter._should_process_message(_group_message("reply", chat_id=-201, reply_to_bot=True)) is False + assert adapter._should_process_message(_group_message("chompy status", chat_id=-201)) is False + assert adapter._should_process_message(_group_message("hello", chat_id=-201)) is False + + +def test_guest_mode_defaults_to_false_for_allowed_chat_bypass(): + adapter = _make_adapter(require_mention=True, allowed_chats=["-200"], guest_mode=False) + + mentioned = _group_message( + "hi @hermes_bot", + chat_id=-201, + entities=[_mention_entity("hi @hermes_bot")], + ) + assert adapter._should_process_message(mentioned) is False + + def test_ignored_threads_drop_group_messages_before_other_gates(): adapter = _make_adapter(require_mention=False, free_response_chats=["-200"], ignored_threads=[31, "42"]) @@ -179,6 +215,7 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): (hermes_home / "config.yaml").write_text( "telegram:\n" " require_mention: true\n" + " guest_mode: true\n" " mention_patterns:\n" " - \"^\\\\s*chompy\\\\b\"\n" " free_response_chats:\n" @@ -189,14 +226,19 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False) monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False) + monkeypatch.delenv("TELEGRAM_GUEST_MODE", raising=False) monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False) config = load_gateway_config() assert config is not None assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true" + assert __import__("os").environ["TELEGRAM_GUEST_MODE"] == "true" assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"] assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123" + tg_cfg = config.platforms.get(Platform.TELEGRAM) + assert tg_cfg is not None + assert tg_cfg.extra.get("guest_mode") is True def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):