From 69d025e4a744c8e5968e9aab0c1a8679299840a5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 05:58:56 -0700 Subject: [PATCH] feat(gateway): add allowed_{chats,channels,rooms} whitelist to Telegram, Mattermost, Matrix, DingTalk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Slack `allowed_channels` feature (PR #7401) and Discord's `allowed_channels` (PR #7044) across the remaining group-capable platforms. All five platforms (Slack + Discord + the four added here) now follow the same pattern: primary config via config.yaml, env-var fallback as an escape hatch — matching the project policy that .env is for secrets only and behavioral settings belong in config.yaml. Also fixes a duplicate `slack` key in DEFAULT_CONFIG introduced by PR #7401 (the later entry silently overwrote `allowed_channels`, `require_mention`, and `free_response_channels` at dict-literal evaluation time). Platforms added: - Telegram: `telegram.allowed_chats` (env alias: `TELEGRAM_ALLOWED_CHATS`) - Mattermost: `mattermost.allowed_channels` (env alias: `MATTERMOST_ALLOWED_CHANNELS`) - Matrix: `matrix.allowed_rooms` (env alias: `MATRIX_ALLOWED_ROOMS`) - DingTalk: `dingtalk.allowed_chats` (env alias: `DINGTALK_ALLOWED_CHATS`) Mattermost and Matrix previously had NO config.yaml bridging for any of their gating settings; this PR adds `load_gateway_config` bridges for them (Mattermost gets require_mention + free_response_channels + allowed_channels; Matrix gets allowed_rooms on top of its existing bridges for require_mention and free_response_rooms). Semantics identical everywhere: - Empty = no restriction (fully backward compatible). - Non-empty = hard whitelist: non-listed chats are silently ignored, even when the bot is @mentioned. - DMs bypass the check entirely. DEFAULT_CONFIG merges the duplicate `slack` block and adds new `mattermost` and `matrix` blocks so all gating settings surface in defaults. Not included: Feishu (has its own per-chat `chat_rules` system that covers this use case differently), WhatsApp (already has `group_allow_from` via `group_policy: allowlist`), pure-DM platforms (Signal, SMS, BlueBubbles, Yuanbao — no group concept). --- gateway/config.py | 35 ++ gateway/platforms/dingtalk.py | 22 ++ gateway/platforms/matrix.py | 42 +- gateway/platforms/mattermost.py | 26 +- gateway/platforms/telegram.py | 27 +- hermes_cli/config.py | 11 + .../gateway/test_allowed_channels_widening.py | 364 ++++++++++++++++++ 7 files changed, 518 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/test_allowed_channels_widening.py diff --git a/gateway/config.py b/gateway/config.py index ff264888fb..a30bf8a19e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -899,6 +899,12 @@ 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) + # allowed_chats: if set, bot ONLY responds in these group chats (whitelist) + ac = telegram_cfg.get("allowed_chats") + if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac) 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): @@ -982,12 +988,35 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc) + # allowed_chats: if set, bot ONLY responds in these group chats (whitelist) + ac = dingtalk_cfg.get("allowed_chats") + if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac) allowed = dingtalk_cfg.get("allowed_users") if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"): if isinstance(allowed, list): allowed = ",".join(str(v) for v in allowed) os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed) + # Mattermost settings → env vars (env vars take precedence) + mattermost_cfg = yaml_cfg.get("mattermost", {}) + if isinstance(mattermost_cfg, dict): + if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"): + os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower() + frc = mattermost_cfg.get("free_response_channels") + if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc) + # allowed_channels: if set, bot ONLY responds in these channels (whitelist) + ac = mattermost_cfg.get("allowed_channels") + if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac) + # Matrix settings → env vars (env vars take precedence) matrix_cfg = yaml_cfg.get("matrix", {}) if isinstance(matrix_cfg, dict): @@ -998,6 +1027,12 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc) + # allowed_rooms: if set, bot ONLY responds in these rooms (whitelist) + ar = matrix_cfg.get("allowed_rooms") + if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"): + if isinstance(ar, list): + ar = ",".join(str(v) for v in ar) + os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar) if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index f1520e22c6..59913b8b17 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -365,6 +365,20 @@ class DingTalkAdapter(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 _dingtalk_allowed_chats(self) -> Set[str]: + """Return the whitelist of group chat IDs the bot will respond in. + + When non-empty, group messages from chats NOT in this set are silently + ignored — even if the bot is @mentioned. DMs are never filtered. + Empty set means no restriction (fully backward compatible). + """ + raw = self.config.extra.get("allowed_chats") if self.config.extra else None + if raw is None: + raw = os.getenv("DINGTALK_ALLOWED_CHATS", "") + 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 _compile_mention_patterns(self) -> List[re.Pattern]: """Compile optional regex wake-word patterns for group triggers.""" patterns = self.config.extra.get("mention_patterns") if self.config.extra else None @@ -443,13 +457,21 @@ class DingTalkAdapter(BasePlatformAdapter): DMs remain unrestricted (subject to ``allowed_users`` which is enforced earlier). Group messages are accepted when: + - the chat passes the ``allowed_chats`` whitelist (when set) - the chat is explicitly allowlisted in ``free_response_chats`` - ``require_mention`` is disabled - the bot is @mentioned (``is_in_at_list``) - the text matches a configured regex wake-word pattern + + When ``allowed_chats`` is non-empty, it acts as a hard gate — messages + from any group chat not in the list are ignored regardless of the + other rules. """ if not is_group: return True + allowed = self._dingtalk_allowed_chats() + if allowed and chat_id and chat_id not in allowed: + return False if chat_id and chat_id in self._dingtalk_free_response_chats(): return True if not self._dingtalk_require_mention(): diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 021fa8e732..12e840b69c 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -17,7 +17,8 @@ Environment variables: MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions (eyes/checkmark/cross). Default: true MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) - MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement + MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms) + MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms) MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false) MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation @@ -343,10 +344,29 @@ class MatrixAdapter(BasePlatformAdapter): self._require_mention: bool = os.getenv( "MATRIX_REQUIRE_MENTION", "true" ).lower() not in ("false", "0", "no") - free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") - self._free_rooms: Set[str] = { - r.strip() for r in free_rooms_raw.split(",") if r.strip() - } + free_rooms_raw = config.extra.get("free_response_rooms") + if free_rooms_raw is None: + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + if isinstance(free_rooms_raw, list): + self._free_rooms: Set[str] = { + str(r).strip() for r in free_rooms_raw if str(r).strip() + } + else: + self._free_rooms: Set[str] = { + r.strip() for r in str(free_rooms_raw).split(",") if r.strip() + } + # If non-empty, bot ONLY responds in these rooms (whitelist); DMs exempt. + allowed_rooms_raw = config.extra.get("allowed_rooms") + if allowed_rooms_raw is None: + allowed_rooms_raw = os.getenv("MATRIX_ALLOWED_ROOMS", "") + if isinstance(allowed_rooms_raw, list): + self._allowed_rooms: Set[str] = { + str(r).strip() for r in allowed_rooms_raw if str(r).strip() + } + else: + self._allowed_rooms: Set[str] = { + r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip() + } self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ( "true", "1", @@ -1573,6 +1593,18 @@ class MatrixAdapter(BasePlatformAdapter): # Require-mention gating. if not is_dm: + # allowed_rooms check (whitelist — must pass before other gating). + # When set, messages from rooms NOT in this whitelist are silently + # ignored, even if @mentioned. DMs are already excluded above. + if self._allowed_rooms and room_id not in self._allowed_rooms: + logger.debug( + "Matrix: ignoring message %s in %s — room not in " + "MATRIX_ALLOWED_ROOMS whitelist", + event_id, + room_id, + ) + return None + is_free_room = room_id in self._free_rooms in_bot_thread = bool(thread_id and thread_id in self._threads) if self._require_mention and not is_free_room and not in_bot_thread: diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index ef3c134a03..3ffd74326d 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -706,10 +706,30 @@ class MattermostAdapter(BasePlatformAdapter): message_text = post.get("message", "") # Mention-gating for non-DM channels. - # Config (env vars): - # MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true) - # MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention + # Config (config.yaml `mattermost.*` with env-var fallback): + # require_mention / MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true) + # free_response_channels / MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention + # allowed_channels / MATTERMOST_ALLOWED_CHANNELS: If set, bot ONLY responds in these channels (whitelist) if channel_type_raw != "D": + # allowed_channels check (whitelist — must pass before other gating). + # When set, messages from channels NOT in this list are silently + # ignored, even if @mentioned. DMs are already excluded above. + allowed_raw = self.config.extra.get("allowed_channels") if self.config.extra else None + if allowed_raw is None: + allowed_raw = os.getenv("MATTERMOST_ALLOWED_CHANNELS", "") + if isinstance(allowed_raw, list): + allowed_channels = {str(c).strip() for c in allowed_raw if str(c).strip()} + else: + allowed_channels = { + c.strip() for c in str(allowed_raw).split(",") if c.strip() + } + if allowed_channels and channel_id not in allowed_channels: + logger.debug( + "Mattermost: ignoring message in non-allowed channel: %s", + channel_id, + ) + return + require_mention = os.getenv( "MATTERMOST_REQUIRE_MENTION", "true" ).lower() not in ("false", "0", "no") diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0f0f568c10..ec50822673 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2771,6 +2771,20 @@ 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_chats(self) -> set[str]: + """Return the whitelist of group/supergroup chat IDs the bot will respond in. + + When non-empty, group messages from chats NOT in this set are silently + ignored — even if the bot is @mentioned. DMs are never filtered. + Empty set means no restriction (fully backward compatible). + """ + raw = self.config.extra.get("allowed_chats") + if raw is None: + raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "") + 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: @@ -2919,13 +2933,16 @@ 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 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 ``require_mention`` is enabled, slash commands are not given + 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 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 @@ -2934,6 +2951,14 @@ 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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 01c116336a..7b484c96b6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1144,13 +1144,24 @@ DEFAULT_CONFIG = { "telegram": { "reactions": False, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) + "allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist) }, # Mattermost platform settings (gateway mode) "mattermost": { + "require_mention": True, # Require @mention to respond in channels + "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention + "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "channel_prompts": {}, # Per-channel ephemeral system prompts }, + # Matrix platform settings (gateway mode) + "matrix": { + "require_mention": True, # Require @mention to respond in rooms + "free_response_rooms": "", # Comma-separated room IDs where bot responds without mention + "allowed_rooms": "", # If set, bot ONLY responds in these room IDs (whitelist) + }, + # Approval mode for dangerous commands: # manual — always prompt the user (default) # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk diff --git a/tests/gateway/test_allowed_channels_widening.py b/tests/gateway/test_allowed_channels_widening.py new file mode 100644 index 0000000000..47296e5c7e --- /dev/null +++ b/tests/gateway/test_allowed_channels_widening.py @@ -0,0 +1,364 @@ +"""Tests for the allowed_{channels,chats,rooms} whitelist extension +added alongside PR #7401 (Slack). + +Covers: Telegram, Matrix, Mattermost, DingTalk. + +For each platform: +- Empty = no restriction (fully backward compatible). +- When set, messages from non-listed chats/rooms are silently ignored. +- DMs are never filtered. +- @mention does NOT bypass the whitelist. +- config.yaml → env var bridging (via load_gateway_config) where applicable. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Telegram +# --------------------------------------------------------------------------- + +def _make_telegram_adapter(*, allowed_chats=None, require_mention=None): + from gateway.platforms.telegram import TelegramAdapter + + extra = {} + if allowed_chats is not None: + extra["allowed_chats"] = allowed_chats + if require_mention is not None: + extra["require_mention"] = require_mention + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter.config = PlatformConfig(enabled=True, token="***", extra=extra) + adapter._bot = SimpleNamespace(id=999, username="hermes_bot") + adapter._message_handler = AsyncMock() + adapter._mention_patterns = adapter._compile_mention_patterns() + return adapter + + +def _tg_group_message(chat_id=-100, text="hello"): + return SimpleNamespace( + text=text, + caption=None, + entities=[], + caption_entities=[], + message_thread_id=None, + chat=SimpleNamespace(id=chat_id, type="group"), + from_user=SimpleNamespace(id=111), + reply_to_message=None, + ) + + +def _tg_dm_message(text="hello"): + return SimpleNamespace( + text=text, + caption=None, + entities=[], + caption_entities=[], + message_thread_id=None, + chat=SimpleNamespace(id=111, type="private"), + from_user=SimpleNamespace(id=111), + reply_to_message=None, + ) + + +class TestTelegramAllowedChats: + def test_empty_is_no_restriction(self, monkeypatch): + monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False) + adapter = _make_telegram_adapter() + assert adapter._telegram_allowed_chats() == set() + assert adapter._should_process_message(_tg_group_message(-100)) is True + + def test_list_form(self): + adapter = _make_telegram_adapter(allowed_chats=[-100, -200]) + assert adapter._telegram_allowed_chats() == {"-100", "-200"} + + def test_csv_form(self): + adapter = _make_telegram_adapter(allowed_chats="-100, -200") + assert adapter._telegram_allowed_chats() == {"-100", "-200"} + + def test_env_var_fallback(self, monkeypatch): + monkeypatch.setenv("TELEGRAM_ALLOWED_CHATS", "-100,-200") + adapter = _make_telegram_adapter() # no extra → falls back to env + assert adapter._telegram_allowed_chats() == {"-100", "-200"} + + def test_blocks_non_whitelisted_group(self): + adapter = _make_telegram_adapter(allowed_chats=["-100"]) + assert adapter._should_process_message(_tg_group_message(-999)) is False + + def test_permits_whitelisted_group(self): + adapter = _make_telegram_adapter( + allowed_chats=["-100"], require_mention=False, + ) + assert adapter._should_process_message(_tg_group_message(-100)) is True + + def test_mention_cannot_bypass_whitelist(self): + """@mention in a non-allowed chat is still ignored.""" + adapter = _make_telegram_adapter(allowed_chats=["-100"]) + msg = _tg_group_message(-999, text="@hermes_bot hello") + msg.entities = [SimpleNamespace( + type="mention", offset=0, length=len("@hermes_bot"), + )] + assert adapter._should_process_message(msg) is False + + def test_dms_unaffected(self): + """DMs bypass the allowed_chats whitelist entirely.""" + adapter = _make_telegram_adapter(allowed_chats=["-100"]) + assert adapter._should_process_message(_tg_dm_message()) is True + + def test_config_bridge(self, monkeypatch, tmp_path): + """slack-style config.yaml → env var bridge works.""" + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " allowed_chats:\n" + " - -100\n" + " - -200\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_ALLOWED_CHATS", "__sentinel__") + monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS") + + load_gateway_config() + + import os as _os + assert _os.environ["TELEGRAM_ALLOWED_CHATS"] == "-100,-200" + + def test_config_bridge_env_takes_precedence(self, monkeypatch, tmp_path): + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " allowed_chats: -100\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_ALLOWED_CHATS", "-999") + + load_gateway_config() + + import os as _os + assert _os.environ["TELEGRAM_ALLOWED_CHATS"] == "-999" + + +# --------------------------------------------------------------------------- +# DingTalk +# --------------------------------------------------------------------------- + +def _make_dingtalk_adapter(*, allowed_chats=None, require_mention=None): + # Import lazily — DingTalk SDK may not be installed. + pytest.importorskip("gateway.platforms.dingtalk", reason="DingTalk adapter not importable") + from gateway.platforms.dingtalk import DingTalkAdapter + + extra = {} + if allowed_chats is not None: + extra["allowed_chats"] = allowed_chats + if require_mention is not None: + extra["require_mention"] = require_mention + + adapter = object.__new__(DingTalkAdapter) + adapter.platform = Platform.DINGTALK + adapter.config = PlatformConfig(enabled=True, extra=extra) + return adapter + + +class TestDingTalkAllowedChats: + def test_empty_is_no_restriction(self, monkeypatch): + monkeypatch.delenv("DINGTALK_ALLOWED_CHATS", raising=False) + adapter = _make_dingtalk_adapter() + assert adapter._dingtalk_allowed_chats() == set() + + def test_list_form(self): + adapter = _make_dingtalk_adapter(allowed_chats=["cidABC", "cidDEF"]) + assert adapter._dingtalk_allowed_chats() == {"cidABC", "cidDEF"} + + def test_csv_form(self): + adapter = _make_dingtalk_adapter(allowed_chats="cidABC, cidDEF") + assert adapter._dingtalk_allowed_chats() == {"cidABC", "cidDEF"} + + def test_env_var_fallback(self, monkeypatch): + monkeypatch.setenv("DINGTALK_ALLOWED_CHATS", "cidABC,cidDEF") + adapter = _make_dingtalk_adapter() + assert adapter._dingtalk_allowed_chats() == {"cidABC", "cidDEF"} + + def test_blocks_non_whitelisted_group(self): + adapter = _make_dingtalk_adapter(allowed_chats=["cidABC"]) + assert adapter._should_process_message( + message=None, text="hello", is_group=True, chat_id="cidXYZ", + ) is False + + def test_dm_unaffected(self): + """DMs (is_group=False) bypass the whitelist.""" + adapter = _make_dingtalk_adapter(allowed_chats=["cidABC"]) + assert adapter._should_process_message( + message=None, text="hello", is_group=False, chat_id="cidXYZ", + ) is True + + def test_config_bridge(self, monkeypatch, tmp_path): + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "dingtalk:\n" + " allowed_chats:\n" + " - cidABC\n" + " - cidDEF\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("DINGTALK_ALLOWED_CHATS", "__sentinel__") + monkeypatch.delenv("DINGTALK_ALLOWED_CHATS") + + load_gateway_config() + + import os as _os + assert _os.environ["DINGTALK_ALLOWED_CHATS"] == "cidABC,cidDEF" + + +# --------------------------------------------------------------------------- +# Mattermost (env-var only — no config.yaml bridge) +# --------------------------------------------------------------------------- + +class TestMattermostAllowedChannels: + """Mattermost whitelist logic — replicated since the adapter reads config + with env-var fallback inline inside _handle_post rather than through a + helper method.""" + + @staticmethod + def _would_process(channel_id, channel_type="O", allowed_cfg=None, allowed_env=""): + """Replicate the whitelist gate from gateway/platforms/mattermost.py.""" + import os as _os + if channel_type == "D": + return True + # config-first, env-var fallback (matching the adapter) + allowed_raw = allowed_cfg + if allowed_raw is None: + allowed_raw = allowed_env + if isinstance(allowed_raw, list): + allowed = {str(c).strip() for c in allowed_raw if str(c).strip()} + else: + allowed = {c.strip() for c in str(allowed_raw).split(",") if c.strip()} + if allowed and channel_id not in allowed: + return False + return True + + def test_empty_config_is_no_restriction(self): + assert self._would_process("chan123", allowed_cfg=None, allowed_env="") is True + + def test_config_list_blocks_non_whitelisted_channel(self): + assert self._would_process( + "chanXYZ", allowed_cfg=["chanABC", "chanDEF"], + ) is False + + def test_config_list_permits_whitelisted_channel(self): + assert self._would_process( + "chanABC", allowed_cfg=["chanABC", "chanDEF"], + ) is True + + def test_env_var_fallback_when_no_config(self): + assert self._would_process( + "chanXYZ", allowed_cfg=None, allowed_env="chanABC,chanDEF", + ) is False + + def test_dm_unaffected(self): + assert self._would_process( + "chanXYZ", channel_type="D", allowed_cfg=["chanABC"], + ) is True + + def test_config_bridge(self, monkeypatch, tmp_path): + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "mattermost:\n" + " allowed_channels:\n" + " - chanABC\n" + " - chanDEF\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Pre-register the key with monkeypatch so teardown cleans it up + # even though load_gateway_config mutates os.environ directly + # (monkeypatch only restores keys it's touched via setenv/delenv; + # delenv on an absent key is a no-op for teardown purposes). + monkeypatch.setenv("MATTERMOST_ALLOWED_CHANNELS", "__sentinel__") + monkeypatch.delenv("MATTERMOST_ALLOWED_CHANNELS") + + load_gateway_config() + + import os as _os + assert _os.environ["MATTERMOST_ALLOWED_CHANNELS"] == "chanABC,chanDEF" + + +# --------------------------------------------------------------------------- +# Matrix +# --------------------------------------------------------------------------- + +class TestMatrixAllowedRooms: + """Matrix whitelist behavior — tested via the env-var-initialized + instance attribute _allowed_rooms.""" + + def test_empty_env_empty_set(self, monkeypatch): + monkeypatch.delenv("MATRIX_ALLOWED_ROOMS", raising=False) + # Replicate __init__ parsing without needing the real adapter. + raw = "" or "" + allowed = {r.strip() for r in raw.split(",") if r.strip()} + assert allowed == set() + + def test_env_var_parsed_to_set(self, monkeypatch): + monkeypatch.setenv("MATRIX_ALLOWED_ROOMS", "!room1:srv,!room2:srv") + import os as _os + raw = _os.environ["MATRIX_ALLOWED_ROOMS"] + allowed = {r.strip() for r in raw.split(",") if r.strip()} + assert allowed == {"!room1:srv", "!room2:srv"} + + def test_block_logic(self): + """Replicates the matrix.py gate: if allowed non-empty and room not in it, drop.""" + allowed = {"!allowed:srv"} + + # Non-allowed room in group (is_dm=False) → blocked + def would_process(room_id, is_dm): + if is_dm: + return True + if allowed and room_id not in allowed: + return False + return True + + assert would_process("!blocked:srv", is_dm=False) is False + assert would_process("!allowed:srv", is_dm=False) is True + # DM always allowed + assert would_process("!blocked:srv", is_dm=True) is True + + def test_config_bridge(self, monkeypatch, tmp_path): + from gateway.config import load_gateway_config + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "matrix:\n" + " allowed_rooms:\n" + " - '!room1:srv'\n" + " - '!room2:srv'\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("MATRIX_ALLOWED_ROOMS", "__sentinel__") + monkeypatch.delenv("MATRIX_ALLOWED_ROOMS") + + load_gateway_config() + + import os as _os + assert _os.environ["MATRIX_ALLOWED_ROOMS"] == "!room1:srv,!room2:srv"