mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(gateway): add allowed_{chats,channels,rooms} whitelist to Telegram, Mattermost, Matrix, DingTalk
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).
This commit is contained in:
parent
f5c9bb582c
commit
69d025e4a7
7 changed files with 518 additions and 9 deletions
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
364
tests/gateway/test_allowed_channels_widening.py
Normal file
364
tests/gateway/test_allowed_channels_widening.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue