From 46ce3453c1dd92895b307145b1c6f7b700bdff08 Mon Sep 17 00:00:00 2001 From: Booker Date: Wed, 13 May 2026 18:08:16 +0100 Subject: [PATCH] fix(telegram): gate profile bots by allowed topics --- gateway/config.py | 9 ++++ gateway/platforms/telegram.py | 21 +++++++++ tests/gateway/test_telegram_group_gating.py | 48 ++++++++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/gateway/config.py b/gateway/config.py index 0591503938b..2d9cef2d0da 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -828,6 +828,10 @@ def load_gateway_config() -> GatewayConfig: bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] + if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: + bridged["allowed_chats"] = platform_cfg["allowed_chats"] + if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg: + bridged["allowed_topics"] = platform_cfg["allowed_topics"] if "free_response_channels" in platform_cfg: bridged["free_response_channels"] = platform_cfg["free_response_channels"] if "mention_patterns" in platform_cfg: @@ -1017,6 +1021,11 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ac, list): ac = ",".join(str(v) for v in ac) os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac) + allowed_topics = telegram_cfg.get("allowed_topics") + if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"): + if isinstance(allowed_topics, list): + allowed_topics = ",".join(str(v) for v in allowed_topics) + os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics) ignored_threads = telegram_cfg.get("ignored_threads") if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"): if isinstance(ignored_threads, list): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 628c8ec4ef0..19063b47dc5 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -3974,6 +3974,21 @@ 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_allowed_topics(self) -> set[str]: + """Return the whitelist of Telegram forum topic IDs this bot handles. + + When non-empty, group/supergroup messages from other topics are + silently ignored. DMs are never filtered by topic. Telegram may omit + ``message_thread_id`` for the forum General topic, so ``None`` is + treated as topic ``1`` for matching purposes. + """ + raw = self.config.extra.get("allowed_topics") + if raw is None: + raw = os.getenv("TELEGRAM_ALLOWED_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_ignored_threads(self) -> set[int]: raw = self.config.extra.get("ignored_threads") if raw is None: @@ -4165,6 +4180,12 @@ class TelegramAdapter(BasePlatformAdapter): return True thread_id = getattr(message, "message_thread_id", None) + 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 + if topic_id not in allowed_topics: + return False + if thread_id is not None: try: if int(thread_id) in self._telegram_ignored_threads(): diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 6e9e9edf2e4..1244df1236a 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -10,6 +10,7 @@ def _make_adapter( free_response_chats=None, mention_patterns=None, ignored_threads=None, + allowed_topics=None, allow_from=None, group_allow_from=None, allowed_chats=None, @@ -26,12 +27,24 @@ def _make_adapter( extra["mention_patterns"] = mention_patterns if ignored_threads is not None: extra["ignored_threads"] = ignored_threads + if allowed_topics is not None: + extra["allowed_topics"] = allowed_topics + else: + # Keep unit tests isolated from TELEGRAM_ALLOWED_TOPICS in the parent + # environment; production adapters without this explicit key still fall + # back to the env var. + extra["allowed_topics"] = [] if allow_from is not None: 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 + else: + # Keep unit tests isolated from TELEGRAM_ALLOWED_CHATS in the parent + # environment; production adapters without this explicit key still fall + # back to the env var. + extra["allowed_chats"] = [] if guest_mode is not None: extra["guest_mode"] = guest_mode @@ -216,6 +229,29 @@ def test_ignored_threads_drop_group_messages_before_other_gates(): assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=99)) is True +def test_allowed_topics_drop_other_forum_topics_before_other_gates(): + adapter = _make_adapter(require_mention=False, allowed_chats=["-100"], allowed_topics=["8"]) + + assert adapter._should_process_message(_group_message("hello", chat_id=-100, thread_id=8)) is True + assert adapter._should_process_message(_group_message("hello", chat_id=-100, thread_id=11)) is False + assert adapter._should_process_message( + _group_message("hi @hermes_bot", chat_id=-100, thread_id=11, entities=[_mention_entity("hi @hermes_bot")]) + ) is False + + +def test_allowed_topics_do_not_filter_dms(): + adapter = _make_adapter(require_mention=False, allowed_topics=["8"]) + + assert adapter._should_process_message(_dm_message("hello")) is True + + +def test_allowed_topics_treat_missing_thread_as_general_topic(): + adapter = _make_adapter(require_mention=False, allowed_topics=["1"]) + + assert adapter._should_process_message(_group_message("hello", thread_id=None)) is True + assert adapter._should_process_message(_group_message("hello", thread_id=8)) is False + + def test_regex_mention_patterns_allow_custom_wake_words(): adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"]) @@ -241,7 +277,11 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): " mention_patterns:\n" " - \"^\\\\s*chompy\\\\b\"\n" " free_response_chats:\n" - " - \"-123\"\n", + " - \"-123\"\n" + " allowed_chats:\n" + " - \"-100\"\n" + " allowed_topics:\n" + " - 8\n", encoding="utf-8", ) @@ -250,6 +290,8 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False) monkeypatch.delenv("TELEGRAM_GUEST_MODE", raising=False) monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False) + monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False) + monkeypatch.delenv("TELEGRAM_ALLOWED_TOPICS", raising=False) config = load_gateway_config() @@ -258,9 +300,13 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): 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" + assert __import__("os").environ["TELEGRAM_ALLOWED_CHATS"] == "-100" + assert __import__("os").environ["TELEGRAM_ALLOWED_TOPICS"] == "8" tg_cfg = config.platforms.get(Platform.TELEGRAM) assert tg_cfg is not None assert tg_cfg.extra.get("guest_mode") is True + assert tg_cfg.extra.get("allowed_chats") == ["-100"] + assert tg_cfg.extra.get("allowed_topics") == [8] def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):