mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(slack): add allowed_channels whitelist config
This commit is contained in:
parent
6a4ecc0a9f
commit
cd3ef685c4
4 changed files with 173 additions and 7 deletions
|
|
@ -809,6 +809,12 @@ def load_gateway_config() -> GatewayConfig:
|
|||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
|
||||
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = slack_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
|
|
|
|||
|
|
@ -1887,6 +1887,12 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
|
||||
|
||||
if not is_dm and bot_uid:
|
||||
# Check allowed channels — if set, only respond in these channels (whitelist)
|
||||
allowed_channels = self._slack_allowed_channels()
|
||||
if allowed_channels and channel_id not in allowed_channels:
|
||||
logger.debug("[Slack] Ignoring message in non-allowed channel: %s", channel_id)
|
||||
return
|
||||
|
||||
if channel_id in self._slack_free_response_channels():
|
||||
pass # Free-response channel — always process
|
||||
elif not self._slack_require_mention():
|
||||
|
|
@ -2924,3 +2930,19 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if s:
|
||||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
def _slack_allowed_channels(self) -> set:
|
||||
"""Return the whitelist of channel IDs the bot will respond in.
|
||||
|
||||
When non-empty, messages from channels 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_channels")
|
||||
if raw is None:
|
||||
raw = os.getenv("SLACK_ALLOWED_CHANNELS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
return set()
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,14 @@ DEFAULT_CONFIG = {
|
|||
# Empty string means use server-local time.
|
||||
"timezone": "",
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"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
|
||||
},
|
||||
|
||||
# Discord platform settings (gateway mode)
|
||||
"discord": {
|
||||
"require_mention": True, # Require @mention to respond in server channels
|
||||
|
|
@ -1138,11 +1146,6 @@ DEFAULT_CONFIG = {
|
|||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
},
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Mattermost platform settings (gateway mode)
|
||||
"mattermost": {
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ CHANNEL_ID = "C0AQWDLHY9M"
|
|||
OTHER_CHANNEL_ID = "C9999999999"
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None):
|
||||
def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None, allowed_channels=None):
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
extra["require_mention"] = require_mention
|
||||
|
|
@ -63,6 +63,8 @@ def _make_adapter(require_mention=None, strict_mention=None, free_response_chann
|
|||
extra["strict_mention"] = strict_mention
|
||||
if free_response_channels is not None:
|
||||
extra["free_response_channels"] = free_response_channels
|
||||
if allowed_channels is not None:
|
||||
extra["allowed_channels"] = allowed_channels
|
||||
|
||||
adapter = object.__new__(SlackAdapter)
|
||||
adapter.platform = Platform.SLACK
|
||||
|
|
@ -249,7 +251,12 @@ def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
|
|||
text = f"<@{bot_uid}> {text}"
|
||||
is_mentioned = bot_uid and f"<@{bot_uid}>" in text
|
||||
|
||||
if not is_dm:
|
||||
if not is_dm and bot_uid:
|
||||
# allowed_channels check (whitelist — must pass before other gating)
|
||||
allowed = adapter._slack_allowed_channels()
|
||||
if allowed and channel_id not in allowed:
|
||||
return False
|
||||
|
||||
if channel_id in adapter._slack_free_response_channels():
|
||||
return True
|
||||
elif not adapter._slack_require_mention():
|
||||
|
|
@ -552,3 +559,131 @@ def test_mention_outside_strict_mode_still_registers_thread():
|
|||
adapter._mentioned_threads.add(event_thread_ts)
|
||||
|
||||
assert thread_ts in adapter._mentioned_threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_allowed_channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_allowed_channels_default_empty(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_ALLOWED_CHANNELS", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_allowed_channels() == set()
|
||||
|
||||
|
||||
def test_allowed_channels_list():
|
||||
adapter = _make_adapter(allowed_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
|
||||
result = adapter._slack_allowed_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_allowed_channels_csv_string():
|
||||
adapter = _make_adapter(allowed_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
|
||||
result = adapter._slack_allowed_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_allowed_channels_empty_string():
|
||||
adapter = _make_adapter(allowed_channels="")
|
||||
assert adapter._slack_allowed_channels() == set()
|
||||
|
||||
|
||||
def test_allowed_channels_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
|
||||
adapter = _make_adapter() # no config value → falls back to env
|
||||
result = adapter._slack_allowed_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: allowed_channels gating integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_allowed_channels_blocks_non_whitelisted_channel():
|
||||
"""Messages in channels not in allowed_channels are silently ignored."""
|
||||
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
|
||||
|
||||
|
||||
def test_allowed_channels_permits_whitelisted_channel():
|
||||
"""Messages in the allowed channel are processed normally."""
|
||||
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
|
||||
assert _would_process(adapter, channel_id=CHANNEL_ID, mentioned=True) is True
|
||||
|
||||
|
||||
def test_allowed_channels_empty_no_restriction():
|
||||
"""Empty allowed_channels imposes no restriction (fully backward compatible)."""
|
||||
adapter = _make_adapter(allowed_channels="")
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, mentioned=True) is True
|
||||
|
||||
|
||||
def test_allowed_channels_blocks_even_when_mentioned():
|
||||
"""Whitelist takes precedence — @mention in a non-allowed channel is ignored."""
|
||||
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, mentioned=True) is False
|
||||
|
||||
|
||||
def test_allowed_channels_dm_unaffected():
|
||||
"""DMs bypass the allowed_channels check entirely."""
|
||||
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
|
||||
# DM channel IDs typically start with D; the check is guarded by `not is_dm`
|
||||
assert _would_process(adapter, is_dm=True, channel_id="DDMCHANNEL") is True
|
||||
|
||||
|
||||
def test_allowed_channels_env_var_blocks_channel(monkeypatch):
|
||||
"""SLACK_ALLOWED_CHANNELS env var (no config) also gates messages."""
|
||||
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", CHANNEL_ID)
|
||||
adapter = _make_adapter() # no config value → falls back to env
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
|
||||
assert _would_process(adapter, channel_id=CHANNEL_ID, mentioned=True) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: config bridging for allowed_channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_config_bridges_slack_allowed_channels(monkeypatch, tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
" allowed_channels:\n"
|
||||
f" - {CHANNEL_ID}\n"
|
||||
f" - {OTHER_CHANNEL_ID}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("SLACK_ALLOWED_CHANNELS", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
import os as _os
|
||||
assert _os.environ["SLACK_ALLOWED_CHANNELS"] == f"{CHANNEL_ID},{OTHER_CHANNEL_ID}"
|
||||
|
||||
|
||||
def test_config_bridges_slack_allowed_channels_env_takes_precedence(monkeypatch, tmp_path):
|
||||
"""Env var set before load_gateway_config() should not be overwritten."""
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
f" allowed_channels: {CHANNEL_ID}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", OTHER_CHANNEL_ID) # already set
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
import os as _os
|
||||
# env var must not be overwritten by config.yaml
|
||||
assert _os.environ["SLACK_ALLOWED_CHANNELS"] == OTHER_CHANNEL_ID
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue