diff --git a/gateway/config.py b/gateway/config.py index fdf92fc09..7ce105f33 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -625,6 +625,11 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) + 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): + ignored_threads = ",".join(str(v) for v in ignored_threads) + os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"): os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 439367b7d..8ff929961 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1991,6 +1991,27 @@ 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_ignored_threads(self) -> set[int]: + raw = self.config.extra.get("ignored_threads") + if raw is None: + raw = os.getenv("TELEGRAM_IGNORED_THREADS", "") + + if isinstance(raw, list): + values = raw + else: + values = str(raw).split(",") + + ignored: set[int] = set() + for value in values: + text = str(value).strip() + if not text: + continue + try: + ignored.add(int(text)) + except (TypeError, ValueError): + logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value) + return ignored + def _compile_mention_patterns(self) -> List[re.Pattern]: """Compile optional regex wake-word patterns for group triggers.""" patterns = self.config.extra.get("mention_patterns") @@ -2102,6 +2123,13 @@ class TelegramAdapter(BasePlatformAdapter): """ if not self._is_group_chat(message): return True + thread_id = getattr(message, "message_thread_id", None) + if thread_id is not None: + try: + if int(thread_id) in self._telegram_ignored_threads(): + 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(): return True if not self._telegram_require_mention(): diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 99675605d..15ffca9ec 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from gateway.config import Platform, PlatformConfig, load_gateway_config -def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None): +def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None, ignored_threads=None): from gateway.platforms.telegram import TelegramAdapter extra = {} @@ -15,6 +15,8 @@ def _make_adapter(require_mention=None, free_response_chats=None, mention_patter extra["free_response_chats"] = free_response_chats if mention_patterns is not None: extra["mention_patterns"] = mention_patterns + if ignored_threads is not None: + extra["ignored_threads"] = ignored_threads adapter = object.__new__(TelegramAdapter) adapter.platform = Platform.TELEGRAM @@ -28,7 +30,16 @@ def _make_adapter(require_mention=None, free_response_chats=None, mention_patter return adapter -def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None): +def _group_message( + text="hello", + *, + chat_id=-100, + thread_id=None, + reply_to_bot=False, + entities=None, + caption=None, + caption_entities=None, +): reply_to_message = None if reply_to_bot: reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) @@ -37,6 +48,7 @@ def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=N caption=caption, entities=entities or [], caption_entities=caption_entities or [], + message_thread_id=thread_id, chat=SimpleNamespace(id=chat_id, type="group"), reply_to_message=reply_to_message, ) @@ -69,6 +81,14 @@ def test_free_response_chats_bypass_mention_requirement(): assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) 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"]) + + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=31)) is False + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=42)) is False + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200, thread_id=99)) is True + + def test_regex_mention_patterns_allow_custom_wake_words(): adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"]) @@ -108,3 +128,23 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true" assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"] assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123" + + +def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " ignored_threads:\n" + " - 31\n" + " - \"42\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_IGNORED_THREADS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert __import__("os").environ["TELEGRAM_IGNORED_THREADS"] == "31,42"