diff --git a/docs/plans/2026-04-22-require-mention-channels.md b/docs/plans/2026-04-22-require-mention-channels.md new file mode 100644 index 0000000000..5399d2469e --- /dev/null +++ b/docs/plans/2026-04-22-require-mention-channels.md @@ -0,0 +1,639 @@ +# Cross-Platform `require_mention_channels` Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add `require_mention_channels` config key across all 7 gateway platforms that have mention-gating, enabling per-channel mention overrides when `require_mention=false`. Also fix Mattermost and Matrix to use the `config.extra` pattern instead of raw `os.getenv()`. + +**Origin:** Community request from neeldhara on PR #3664 — they want most channels to respond freely but a few "agent group" channels to require @mention. + +**Architecture:** Each platform gets a new `__require_mention_channels()` helper that returns a set of channel/chat/room IDs. The mention gate logic adds one new check: if `require_mention` is false but the channel is in `require_mention_channels`, treat it as requiring mention for that channel. Mattermost and Matrix are additionally refactored to use helper methods matching the Discord/Slack/Telegram pattern. + +**Priority order of logic (highest wins):** +1. DMs → always respond +2. Channel in `free_response_channels` → never require mention +3. Channel in `require_mention_channels` → always require mention +4. Global `require_mention` setting → fallback + +--- + +## Phase 0: Mattermost + Matrix config.extra alignment + +Before adding the new feature, bring Mattermost and Matrix up to par with the other platforms by extracting their inline `os.getenv()` calls into proper helper methods that check `config.extra` first. + +### Task 0A: Extract Mattermost mention helpers + +**Objective:** Replace inline `os.getenv()` in Mattermost's `_handle_ws_event` with proper helper methods matching the Discord/Slack pattern. + +**Files:** +- Modify: `gateway/platforms/mattermost.py` (lines 619-626) + +**Implementation:** + +Add three helper methods to `MattermostAdapter`: + +```python +def _mattermost_require_mention(self) -> bool: + """Return whether Mattermost channel messages require a bot mention.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("MATTERMOST_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + +def _mattermost_free_response_channels(self) -> set: + """Return Mattermost channel IDs where no bot mention is required.""" + raw = self.config.extra.get("free_response_channels") + if raw is None: + raw = os.getenv("MATTERMOST_FREE_RESPONSE_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() +``` + +Then replace the inline code in `_handle_ws_event` (lines 620-626): +```python +# Before: +require_mention = os.getenv("MATTERMOST_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") +free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "") +free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} +is_free_channel = channel_id in free_channels + +# After: +require_mention = self._mattermost_require_mention() +free_channels = self._mattermost_free_response_channels() +is_free_channel = channel_id in free_channels +``` + +**Tests:** +- Modify: `tests/gateway/test_mattermost.py` +- Add unit tests for `_mattermost_require_mention()` and `_mattermost_free_response_channels()` that test both `config.extra` and env var fallback, matching the pattern in `tests/gateway/test_slack_mention.py`. +- Update existing tests in `TestMattermostMentionBehavior` to also test `config.extra` path. + +### Task 0B: Extract Matrix mention helpers + +**Objective:** Replace inline `os.getenv()` in Matrix's `__init__` with proper helper methods. + +**Files:** +- Modify: `gateway/platforms/matrix.py` + +**Implementation:** + +Matrix currently reads mention config in `__init__` and stores as instance vars: +```python +self._require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() in ("true", "1", "yes") +self._free_rooms = ... +``` + +Refactor to use helper methods (called from the gate logic, not __init__): +```python +def _matrix_require_mention(self) -> bool: + """Return whether Matrix room messages require a bot mention.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + +def _matrix_free_response_rooms(self) -> set: + """Return Matrix room IDs where no bot mention is required.""" + raw = self.config.extra.get("free_response_rooms") + if raw is None: + raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + 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() +``` + +Then replace `self._require_mention` and `self._free_rooms` usage in the gate logic with calls to these methods. + +**Note:** Keep the `__init__` assignments for backward compat if anything else references `self._require_mention` — search the codebase first. If nothing else reads them, remove them entirely. + +**Tests:** +- Modify: `tests/gateway/test_matrix_mention.py` +- Add unit tests for the new helper methods. +- Update existing tests to verify config.extra path. + +--- + +## Phase 1: Add `require_mention_channels` to all 7 platforms + +Each platform needs: (1) a new helper method, (2) updated gate logic, (3) tests, (4) docs. + +### Task 1A: Discord `require_mention_channels` + +**Objective:** Add `_discord_require_mention_channels()` helper and update gate logic. + +**Files:** +- Modify: `gateway/platforms/discord.py` + - Add `_discord_require_mention_channels()` method (near line 2489, after `_discord_free_response_channels`) + - Update gate logic at line 3010 + +**Helper method:** +```python +def _discord_require_mention_channels(self) -> set: + """Return Discord channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("DISCORD_REQUIRE_MENTION_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() +``` + +**Gate logic change** (discord.py around line 2994-3012): +```python +# Current: +if require_mention and not is_free_channel and not in_bot_thread: + if self._client.user not in message.mentions and not mention_prefix: + return + +# New: +require_mention_chs = self._discord_require_mention_channels() +is_force_mention_channel = bool(channel_ids & require_mention_chs) + +if is_free_channel: + pass # Free-response always wins +elif is_force_mention_channel or require_mention: + if not in_bot_thread: + if self._client.user not in message.mentions and not mention_prefix: + return +``` + +**Tests:** +- Modify: `tests/gateway/test_discord_free_response.py` +- Add: + - `test_discord_require_mention_channels_forces_mention_when_global_disabled` + - `test_discord_require_mention_channels_from_config_extra` + - `test_discord_require_mention_channels_from_env_var` + - `test_discord_free_response_overrides_require_mention_channels` + +### Task 1B: Slack `require_mention_channels` + +**Objective:** Add `_slack_require_mention_channels()` and update gate logic. + +**Files:** +- Modify: `gateway/platforms/slack.py` (near line 1685, after `_slack_free_response_channels`) +- Modify gate logic at line 1043-1065 + +**Helper method:** +```python +def _slack_require_mention_channels(self) -> set: + """Return Slack channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("SLACK_REQUIRE_MENTION_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() +``` + +**Gate logic change** (slack.py around line 1043-1065): +```python +# Current: +if not is_dm and bot_uid: + if channel_id in self._slack_free_response_channels(): + pass # Free-response channel + elif not self._slack_require_mention(): + pass # Mention requirement disabled + elif not is_mentioned: + ...check threads... + if not any_thread_bypass: + return + +# New: +if not is_dm and bot_uid: + if channel_id in self._slack_free_response_channels(): + pass # Free-response channel — always process + elif channel_id in self._slack_require_mention_channels(): + # Force-mention channel — require mention even if global is off + if not is_mentioned: + ...check threads... + if not any_thread_bypass: + return + elif not self._slack_require_mention(): + pass # Mention requirement disabled globally + elif not is_mentioned: + ...check threads... + if not any_thread_bypass: + return +``` + +**Tests:** +- Modify: `tests/gateway/test_slack_mention.py` +- Add: + - `test_require_mention_channels_forces_mention_when_global_disabled` + - `test_require_mention_channels_from_config_extra` + - `test_require_mention_channels_from_env_var` + - `test_free_response_overrides_require_mention_channels` + +### Task 1C: Telegram `require_mention_channels` + +**Objective:** Add `_telegram_require_mention_chats()` and update `_should_process_message`. + +**Files:** +- Modify: `gateway/platforms/telegram.py` + +**Helper method:** +```python +def _telegram_require_mention_chats(self) -> set[str]: + """Return Telegram chat IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_chats") + if raw is None: + raw = os.getenv("TELEGRAM_REQUIRE_MENTION_CHATS", "") + 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() +``` + +**Gate logic change** in `_should_process_message`: +```python +# Current order: +# free_response_chats -> bypass +# require_mention=False -> bypass +# reply-to-bot, mentions, patterns -> accept + +# New order: +# free_response_chats -> bypass +# require_mention_chats -> force mention (even when global=False) +# require_mention=False -> bypass +# reply-to-bot, mentions, patterns -> accept +``` + +Insert after the `free_response_chats` check and before the `require_mention` check: +```python +if str(chat.id) in self._telegram_require_mention_chats(): + # Force mention required in this chat even when global require_mention is off + if self._is_reply_to_bot(message): + return True + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) +``` + +**Tests:** +- Modify: `tests/gateway/test_telegram_group_gating.py` +- Add: + - `test_require_mention_chats_forces_mention_when_global_disabled` + - `test_free_response_overrides_require_mention_chats` + +### Task 1D: WhatsApp `require_mention_channels` + +**Objective:** Add `_whatsapp_require_mention_chats()` and update `_should_process_message`. + +**Files:** +- Modify: `gateway/platforms/whatsapp.py` + +**Helper method:** +```python +def _whatsapp_require_mention_chats(self) -> set[str]: + """Return WhatsApp chat IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_chats") + if raw is None: + raw = os.getenv("WHATSAPP_REQUIRE_MENTION_CHATS", "") + 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() +``` + +**Gate logic change** — same pattern as Telegram: insert force-mention check after `free_response_chats` and before the global `require_mention` check. + +**Tests:** +- Modify: `tests/gateway/test_whatsapp_group_gating.py` + +### Task 1E: DingTalk `require_mention_channels` + +**Objective:** Add `_dingtalk_require_mention_chats()` and update `_should_process_message`. + +**Files:** +- Modify: `gateway/platforms/dingtalk.py` + +**Helper method:** Same pattern as WhatsApp/Telegram. + +**Gate logic change:** Same insertion point — after `free_response_chats`, before global `require_mention`. + +**Tests:** +- Modify: `tests/gateway/test_dingtalk.py` + +### Task 1F: Mattermost `require_mention_channels` + +**Objective:** Add `_mattermost_require_mention_channels()` and update gate logic (uses helpers from Task 0A). + +**Files:** +- Modify: `gateway/platforms/mattermost.py` + +**Helper method:** +```python +def _mattermost_require_mention_channels(self) -> set: + """Return Mattermost channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("MATTERMOST_REQUIRE_MENTION_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() +``` + +**Gate logic change** in `_handle_ws_event`: +```python +# After Task 0A, the logic already uses helper methods. Now add: +require_mention_chs = self._mattermost_require_mention_channels() +is_force_mention = channel_id in require_mention_chs + +if is_free_channel: + pass # Free-response always wins +elif is_force_mention or require_mention: + if not has_mention: + logger.debug(...) + return +``` + +**Tests:** +- Modify: `tests/gateway/test_mattermost.py` +- Add: + - `test_require_mention_channels_forces_mention_when_global_disabled` + - `test_require_mention_channels_from_config_extra` + - `test_require_mention_channels_from_env_var` + - `test_free_response_overrides_require_mention_channels` + +### Task 1G: Matrix `require_mention_channels` + +**Objective:** Add `_matrix_require_mention_rooms()` and update gate logic (uses helpers from Task 0B). + +**Files:** +- Modify: `gateway/platforms/matrix.py` + +**Helper method:** +```python +def _matrix_require_mention_rooms(self) -> set: + """Return Matrix room IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_rooms") + if raw is None: + raw = os.getenv("MATRIX_REQUIRE_MENTION_ROOMS", "") + 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() +``` + +**Gate logic change:** +```python +if not is_dm: + is_free_room = room_id in self._matrix_free_response_rooms() + is_force_mention = room_id in self._matrix_require_mention_rooms() + in_bot_thread = bool(thread_id and thread_id in self._threads) + if is_free_room: + pass # Free room — always respond + elif (is_force_mention or self._matrix_require_mention()) and not in_bot_thread: + if not is_mentioned: + return None +``` + +**Tests:** +- Modify: `tests/gateway/test_matrix_mention.py` + +--- + +## Phase 2: Config bridging in `gateway/config.py` + +### Task 2-pre: Add config.yaml → env var bridging for `require_mention_channels` + +**Objective:** Bridge the new config.yaml keys to env vars in `gateway/config.py`'s `load_gateway_config()`, following the exact pattern used for `free_response_channels`. + +**Files:** +- Modify: `gateway/config.py` + +**Implementation:** For EACH platform section, add the bridging block right after the existing `free_response_channels` bridge: + +```python +# Slack (after line 618): +rmc = slack_cfg.get("require_mention_channels") +if rmc is not None and not os.getenv("SLACK_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["SLACK_REQUIRE_MENTION_CHANNELS"] = str(rmc) + +# Discord (after line 629): +rmc = discord_cfg.get("require_mention_channels") +if rmc is not None and not os.getenv("DISCORD_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["DISCORD_REQUIRE_MENTION_CHANNELS"] = str(rmc) + +# Telegram (after line 678): +rmc = telegram_cfg.get("require_mention_chats") +if rmc is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["TELEGRAM_REQUIRE_MENTION_CHATS"] = str(rmc) + +# WhatsApp (after line 709): +rmc = whatsapp_cfg.get("require_mention_chats") +if rmc is not None and not os.getenv("WHATSAPP_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["WHATSAPP_REQUIRE_MENTION_CHATS"] = str(rmc) + +# DingTalk (after line 736): +rmc = dingtalk_cfg.get("require_mention_chats") +if rmc is not None and not os.getenv("DINGTALK_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["DINGTALK_REQUIRE_MENTION_CHATS"] = str(rmc) + +# Mattermost (new section — mattermost currently has NO bridging at all): +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) + rmc = mattermost_cfg.get("require_mention_channels") + if rmc is not None and not os.getenv("MATTERMOST_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["MATTERMOST_REQUIRE_MENTION_CHANNELS"] = str(rmc) + +# Matrix (after line 752): +rmc = matrix_cfg.get("require_mention_rooms") +if rmc is not None and not os.getenv("MATRIX_REQUIRE_MENTION_ROOMS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["MATRIX_REQUIRE_MENTION_ROOMS"] = str(rmc) +``` + +**Note:** Mattermost currently has ZERO config bridging in `gateway/config.py` — this is an existing gap. We add a full bridging block for Mattermost while we're here (require_mention, free_response_channels, AND the new require_mention_channels). + +--- + +## Phase 3: Config, env vars, and docs + +### Task 3A: Add env vars to `hermes_cli/config.py` + +**Objective:** Register the new env vars in `OPTIONAL_ENV_VARS` for platforms that already have entries there (Mattermost, Matrix). Add DEFAULT_CONFIG entries where appropriate. + +**Files:** +- Modify: `hermes_cli/config.py` + +**Add to OPTIONAL_ENV_VARS:** +```python +"MATTERMOST_REQUIRE_MENTION_CHANNELS": { + "description": "Comma-separated Mattermost channel IDs where bot always requires @mention (even when require_mention is false)", + "prompt": "Require-mention channel IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", +}, +"MATRIX_REQUIRE_MENTION_ROOMS": { + "description": "Comma-separated Matrix room IDs where bot always requires @mention (even when require_mention is false)", + "prompt": "Require-mention room IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, +}, +``` + +**Add to DEFAULT_CONFIG discord section (if it already has require_mention/free_response_channels):** +```python +"discord": { + "require_mention": True, + "free_response_channels": "", + "require_mention_channels": "", # <-- new +} +``` + +### Task 3B: Update docs — Mattermost + +**Objective:** Update Mattermost docs with new config key and env var. + +**Files:** +- Modify: `website/docs/user-guide/messaging/mattermost.md` + +**Changes:** +1. Add `MATTERMOST_REQUIRE_MENTION_CHANNELS` to the Mention Behavior table +2. Add config.yaml example showing the new key +3. Add a use case example matching neeldhara's scenario + +### Task 3C: Update docs — Discord + +**Files:** +- Modify: `website/docs/user-guide/messaging/discord.md` + +Add `require_mention_channels` to the config.yaml example and env var table. + +### Task 3D: Update docs — Slack + +**Files:** +- Modify: `website/docs/user-guide/messaging/slack.md` + +### Task 3E: Update docs — Telegram + +**Files:** +- Modify: `website/docs/user-guide/messaging/telegram.md` + +### Task 3F: Update docs — Matrix + +**Files:** +- Modify: `website/docs/user-guide/messaging/matrix.md` + +### Task 3G: Update docs — environment-variables.md + +**Files:** +- Modify: `website/docs/reference/environment-variables.md` + +Add entries for all new env vars: +- `DISCORD_REQUIRE_MENTION_CHANNELS` +- `SLACK_REQUIRE_MENTION_CHANNELS` +- `TELEGRAM_REQUIRE_MENTION_CHATS` +- `WHATSAPP_REQUIRE_MENTION_CHATS` +- `DINGTALK_REQUIRE_MENTION_CHATS` +- `MATTERMOST_REQUIRE_MENTION_CHANNELS` +- `MATRIX_REQUIRE_MENTION_ROOMS` + +### Task 3H: Update docs — configuration.md + +**Files:** +- Modify: `website/docs/user-guide/configuration.md` + +Add `require_mention_channels` to the generic config example near lines 1234-1240. + +--- + +## Phase 4: Config bridging tests + +### Task 4A: Config bridging tests for platforms that have them + +**Objective:** Ensure config.yaml `require_mention_channels` key is bridged to the env var correctly for platforms that use the config bridging pattern. + +**Files:** +- Modify tests that already have `test_config_bridges_*` functions + +For platforms using config.yaml bridging (Telegram, WhatsApp, Slack, DingTalk): +```python +def test_config_bridges_require_mention_channels(monkeypatch, tmp_path): + """config.yaml require_mention_chats bridges to env var.""" + # Write config with require_mention_chats list + # Load config + # Assert the env var is set correctly +``` + +--- + +## Naming Convention Summary + +Each platform uses its own terminology for channels/chats/rooms. The new key follows the same convention: + +| Platform | free_response key | NEW require_mention key | Env var | +|------------|------------------------------|-------------------------------------|--------------------------------------| +| Discord | `free_response_channels` | `require_mention_channels` | `DISCORD_REQUIRE_MENTION_CHANNELS` | +| Slack | `free_response_channels` | `require_mention_channels` | `SLACK_REQUIRE_MENTION_CHANNELS` | +| Telegram | `free_response_chats` | `require_mention_chats` | `TELEGRAM_REQUIRE_MENTION_CHATS` | +| WhatsApp | `free_response_chats` | `require_mention_chats` | `WHATSAPP_REQUIRE_MENTION_CHATS` | +| DingTalk | `free_response_chats` | `require_mention_chats` | `DINGTALK_REQUIRE_MENTION_CHATS` | +| Mattermost | `free_response_channels` | `require_mention_channels` | `MATTERMOST_REQUIRE_MENTION_CHANNELS`| +| Matrix | `free_response_rooms` | `require_mention_rooms` | `MATRIX_REQUIRE_MENTION_ROOMS` | + +--- + +## Priority Logic (all platforms, documented for consistency) + +``` +1. Is DM? → ALWAYS respond (no mention check) +2. Channel in free_response_*? → RESPOND (no mention needed) +3. Channel in require_mention_*? → REQUIRE MENTION (even if global require_mention=false) +4. Global require_mention=true? → REQUIRE MENTION +5. Global require_mention=false? → RESPOND (no mention needed) +``` + +`free_response` takes priority over `require_mention_channels` because explicitly marking a channel as "free response" is a stronger signal than the channel appearing in both lists (which would be a config error, but we fail-open for free_response). + +--- + +## Verification + +After all tasks, run: +```bash +bash scripts/run_tests.sh tests/gateway/ +``` + +All existing tests must pass (backward compat), plus the new tests for `require_mention_channels`. diff --git a/gateway/config.py b/gateway/config.py index d1d84da106..3ab07c31e1 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -616,6 +616,11 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) + rmc = slack_cfg.get("require_mention_channels") + if rmc is not None and not os.getenv("SLACK_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["SLACK_REQUIRE_MENTION_CHANNELS"] = str(rmc) # Discord settings → env vars (env vars take precedence) discord_cfg = yaml_cfg.get("discord", {}) @@ -627,6 +632,11 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + rmc = discord_cfg.get("require_mention_channels") + if rmc is not None and not os.getenv("DISCORD_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["DISCORD_REQUIRE_MENTION_CHANNELS"] = str(rmc) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): @@ -676,6 +686,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) + rmc = telegram_cfg.get("require_mention_chats") + if rmc is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["TELEGRAM_REQUIRE_MENTION_CHATS"] = str(rmc) 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): @@ -707,6 +722,11 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) + rmc = whatsapp_cfg.get("require_mention_chats") + if rmc is not None and not os.getenv("WHATSAPP_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["WHATSAPP_REQUIRE_MENTION_CHATS"] = str(rmc) if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"): os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower() af = whatsapp_cfg.get("allow_from") @@ -734,12 +754,33 @@ 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) + rmc = dingtalk_cfg.get("require_mention_chats") + if rmc is not None and not os.getenv("DINGTALK_REQUIRE_MENTION_CHATS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["DINGTALK_REQUIRE_MENTION_CHATS"] = str(rmc) 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) + rmc = mattermost_cfg.get("require_mention_channels") + if rmc is not None and not os.getenv("MATTERMOST_REQUIRE_MENTION_CHANNELS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["MATTERMOST_REQUIRE_MENTION_CHANNELS"] = str(rmc) + # Matrix settings → env vars (env vars take precedence) matrix_cfg = yaml_cfg.get("matrix", {}) if isinstance(matrix_cfg, dict): @@ -750,6 +791,11 @@ 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) + rmc = matrix_cfg.get("require_mention_rooms") + if rmc is not None and not os.getenv("MATRIX_REQUIRE_MENTION_ROOMS"): + if isinstance(rmc, list): + rmc = ",".join(str(v) for v in rmc) + os.environ["MATRIX_REQUIRE_MENTION_ROOMS"] = str(rmc) 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 3037e402b2..2f8c786c99 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -361,6 +361,15 @@ 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_require_mention_chats(self) -> Set[str]: + """Return DingTalk chat IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_chats") + if raw is None: + raw = os.getenv("DINGTALK_REQUIRE_MENTION_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 @@ -448,6 +457,11 @@ class DingTalkAdapter(BasePlatformAdapter): return True if chat_id and chat_id in self._dingtalk_free_response_chats(): return True + if chat_id and chat_id in self._dingtalk_require_mention_chats(): + # Force mention in this chat even when global require_mention is off + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(text) if not self._dingtalk_require_mention(): return True if self._message_mentions_bot(message): diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index d43e18d73d..d430540463 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2497,6 +2497,17 @@ class DiscordAdapter(BasePlatformAdapter): return {part.strip() for part in raw.split(",") if part.strip()} return set() + def _discord_require_mention_channels(self) -> set: + """Return Discord channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("DISCORD_REQUIRE_MENTION_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() + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -2996,18 +3007,22 @@ class DiscordAdapter(BasePlatformAdapter): channel_ids.add(parent_channel_id) require_mention = self._discord_require_mention() + require_mention_chs = self._discord_require_mention_channels() # Voice-linked text channels act as free-response while voice is active. # Only the exact bound channel gets the exemption, not sibling threads. voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} current_channel_id = str(message.channel.id) is_voice_linked_channel = current_channel_id in voice_linked_ids is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel + is_force_mention = bool(channel_ids & require_mention_chs) # Skip the mention check if the message is in a thread where # the bot has previously participated (auto-created or replied in). in_bot_thread = is_thread and thread_id in self._threads - if require_mention and not is_free_channel and not in_bot_thread: + if is_free_channel: + pass # Free-response channel — always respond + elif (is_force_mention or require_mention) and not in_bot_thread: if self._client.user not in message.mentions and not mention_prefix: return # Auto-thread: when enabled, automatically create a thread for every diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index a5f9352b55..79721adfde 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -247,14 +247,9 @@ class MatrixAdapter(BasePlatformAdapter): # Thread participation tracking (for require_mention bypass) self._threads = ThreadParticipationTracker("matrix") - # Mention/thread gating — parsed once from env vars. - 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() - } + # Mention/thread gating — legacy init-time parsing kept for backward + # compat; runtime code uses the helper methods below which also + # check config.extra (config.yaml) first. self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ( "true", "1", @@ -1262,9 +1257,13 @@ class MatrixAdapter(BasePlatformAdapter): # Require-mention gating. if not is_dm: - is_free_room = room_id in self._free_rooms + is_free_room = room_id in self._matrix_free_response_rooms() + is_force_mention = room_id in self._matrix_require_mention_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: + require_mention = self._matrix_require_mention() + if is_free_room: + pass # Free-response room — always respond + elif (is_force_mention or require_mention) and not in_bot_thread: if not is_mentioned: return None @@ -1274,7 +1273,7 @@ class MatrixAdapter(BasePlatformAdapter): self._threads.mark(thread_id) # Strip mention from body (only when mention-gating is active). - if is_mentioned and self._require_mention: + if is_mentioned and self._matrix_require_mention(): body = self._strip_mention(body) # Auto-thread. @@ -1958,6 +1957,41 @@ class MatrixAdapter(BasePlatformAdapter): self._dm_rooms = {rid: (rid in dm_room_ids) for rid in self._joined_rooms} + # ------------------------------------------------------------------ + # Mention-gating helpers + # ------------------------------------------------------------------ + + def _matrix_require_mention(self) -> bool: + """Return whether Matrix room messages require a bot mention.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + + def _matrix_free_response_rooms(self) -> set: + """Return Matrix room IDs where no bot mention is required.""" + raw = self.config.extra.get("free_response_rooms") + if raw is None: + raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + 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() + + def _matrix_require_mention_rooms(self) -> set: + """Return Matrix room IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_rooms") + if raw is None: + raw = os.getenv("MATRIX_REQUIRE_MENTION_ROOMS", "") + 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() + # ------------------------------------------------------------------ # Mention detection helpers # ------------------------------------------------------------------ diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 0e6c9631d7..5e45da8f32 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -497,6 +497,41 @@ class MattermostAdapter(BasePlatformAdapter): return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) + # ------------------------------------------------------------------ + # Mention-gating helpers + # ------------------------------------------------------------------ + + def _mattermost_require_mention(self) -> bool: + """Return whether Mattermost channel messages require a bot mention.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("MATTERMOST_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + + def _mattermost_free_response_channels(self) -> set: + """Return Mattermost channel IDs where no bot mention is required.""" + raw = self.config.extra.get("free_response_channels") + if raw is None: + raw = os.getenv("MATTERMOST_FREE_RESPONSE_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() + + def _mattermost_require_mention_channels(self) -> set: + """Return Mattermost channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("MATTERMOST_REQUIRE_MENTION_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() + # ------------------------------------------------------------------ # WebSocket # ------------------------------------------------------------------ @@ -613,17 +648,16 @@ 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.* keys or env vars): + # 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 + # require_mention_channels / MATTERMOST_REQUIRE_MENTION_CHANNELS: Channel IDs that always require mention if channel_type_raw != "D": - require_mention = os.getenv( - "MATTERMOST_REQUIRE_MENTION", "true" - ).lower() not in ("false", "0", "no") - - free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "") - free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} + require_mention = self._mattermost_require_mention() + free_channels = self._mattermost_free_response_channels() + require_mention_chs = self._mattermost_require_mention_channels() is_free_channel = channel_id in free_channels + is_force_mention = channel_id in require_mention_chs mention_patterns = [ f"@{self._bot_username}", @@ -634,7 +668,9 @@ class MattermostAdapter(BasePlatformAdapter): for pattern in mention_patterns ) - if require_mention and not is_free_channel and not has_mention: + if is_free_channel: + pass # Free-response channel — always respond + elif (is_force_mention or require_mention) and not has_mention: logger.debug( "Mattermost: skipping non-DM message without @mention (channel=%s)", channel_id, diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 6a08f04666..2e0e02cefd 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1043,6 +1043,26 @@ class SlackAdapter(BasePlatformAdapter): if not is_dm and bot_uid: if channel_id in self._slack_free_response_channels(): pass # Free-response channel — always process + elif channel_id in self._slack_require_mention_channels(): + # Force-mention channel — require mention even if global is off + if not is_mentioned: + reply_to_bot_thread = ( + is_thread_reply and event_thread_ts in self._bot_message_ts + ) + in_mentioned_thread = ( + event_thread_ts is not None + and event_thread_ts in self._mentioned_threads + ) + has_session = ( + is_thread_reply + and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ) + ) + if not reply_to_bot_thread and not in_mentioned_thread and not has_session: + return elif not self._slack_require_mention(): pass # Mention requirement disabled globally for Slack elif not is_mentioned: @@ -1692,3 +1712,14 @@ class SlackAdapter(BasePlatformAdapter): if isinstance(raw, str) and raw.strip(): return {part.strip() for part in raw.split(",") if part.strip()} return set() + + def _slack_require_mention_channels(self) -> set: + """Return channel IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_channels") + if raw is None: + raw = os.getenv("SLACK_REQUIRE_MENTION_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() diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index bec0d690a3..be419033d8 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2223,6 +2223,15 @@ 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_require_mention_chats(self) -> set[str]: + """Return Telegram chat IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_chats") + if raw is None: + raw = os.getenv("TELEGRAM_REQUIRE_MENTION_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: @@ -2375,6 +2384,13 @@ class TelegramAdapter(BasePlatformAdapter): 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 str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_require_mention_chats(): + # Force mention in this chat even when global require_mention is off + if self._is_reply_to_bot(message): + return True + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) if not self._telegram_require_mention(): return True if self._is_reply_to_bot(message): diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a82417a601..0860020a5a 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -202,6 +202,15 @@ class WhatsAppAdapter(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 _whatsapp_require_mention_chats(self) -> set[str]: + """Return WhatsApp chat IDs where bot mention is always required.""" + raw = self.config.extra.get("require_mention_chats") + if raw is None: + raw = os.getenv("WHATSAPP_REQUIRE_MENTION_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()} + @staticmethod def _coerce_allow_list(raw) -> set[str]: """Parse allow_from / group_allow_from from config or env var.""" @@ -336,6 +345,16 @@ class WhatsAppAdapter(BasePlatformAdapter): chat_id = str(data.get("chatId") or "") if chat_id in self._whatsapp_free_response_chats(): return True + if chat_id in self._whatsapp_require_mention_chats(): + # Force mention in this chat even when global require_mention is off + body = str(data.get("body") or "").strip() + if body.startswith("/"): + return True + if self._message_is_reply_to_bot(data): + return True + if self._message_mentions_bot(data): + return True + return self._message_matches_mention_patterns(data) if not self._whatsapp_require_mention(): return True body = str(data.get("body") or "").strip() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c87b9f5a93..0904acbfaa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -763,6 +763,7 @@ DEFAULT_CONFIG = { "discord": { "require_mention": True, # Require @mention to respond in server channels "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention + "require_mention_channels": "", # Comma-separated channel IDs where bot requires @mention (overrides free_response) "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing @@ -1530,6 +1531,13 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "messaging", }, + "MATTERMOST_REQUIRE_MENTION_CHANNELS": { + "description": "Comma-separated Mattermost channel IDs where bot always requires @mention (even when require_mention is false)", + "prompt": "Require-mention channel IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + }, "MATRIX_HOMESERVER": { "description": "Matrix homeserver URL (e.g. https://matrix.example.org)", "prompt": "Matrix homeserver URL", @@ -1574,6 +1582,14 @@ OPTIONAL_ENV_VARS = { "category": "messaging", "advanced": True, }, + "MATRIX_REQUIRE_MENTION_ROOMS": { + "description": "Comma-separated Matrix room IDs where bot always requires @mention (even when require_mention is false)", + "prompt": "Require-mention room IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "MATRIX_AUTO_THREAD": { "description": "Auto-create threads for messages in Matrix rooms (default: true)", "prompt": "Auto-create threads in rooms (true/false)", diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 886db482c4..c4574c45d4 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -198,6 +198,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) | | `TELEGRAM_REPLY_TO_MODE` | Reply-reference behavior: `off`, `first` (default), or `all`. Matches the Discord pattern. | | `TELEGRAM_IGNORED_THREADS` | Comma-separated Telegram forum topic/thread IDs where the bot never responds | +| `TELEGRAM_REQUIRE_MENTION_CHATS` | Comma-separated chat IDs where bot always requires `@mention`, even when the global `require_mention` is `false`. Useful for noisy group chats. | | `TELEGRAM_PROXY` | Proxy URL for Telegram connections — overrides `HTTPS_PROXY`. Supports `http://`, `https://`, `socks5://` | | `DISCORD_BOT_TOKEN` | Discord bot token | | `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot | @@ -207,6 +208,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery | | `DISCORD_HOME_CHANNEL_NAME` | Display name for the Discord home channel | | `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels | +| `DISCORD_REQUIRE_MENTION_CHANNELS` | Comma-separated channel IDs where bot always requires `@mention`, even when `DISCORD_REQUIRE_MENTION` is `false`. Useful for noisy channels where you want mention-only behavior without changing the server-wide default. | | `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required | | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported | | `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) | @@ -222,10 +224,12 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs | | `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery | | `SLACK_HOME_CHANNEL_NAME` | Display name for the Slack home channel | +| `SLACK_REQUIRE_MENTION_CHANNELS` | Comma-separated channel IDs where bot always requires `@mention`, even when the global `require_mention` is `false`. Useful for high-traffic channels. | | `WHATSAPP_ENABLED` | Enable the WhatsApp bridge (`true`/`false`) | | `WHATSAPP_MODE` | `bot` (separate number) or `self-chat` (message yourself) | | `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`), or `*` to allow all senders | | `WHATSAPP_ALLOW_ALL_USERS` | Allow all WhatsApp senders without an allowlist (`true`/`false`) | +| `WHATSAPP_REQUIRE_MENTION_CHATS` | Comma-separated chat IDs where bot always requires `@mention`, even when the global `require_mention` is `false`. Useful for busy group chats. | | `WHATSAPP_DEBUG` | Log raw message events in the bridge for troubleshooting (`true`/`false`) | | `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (for example `http://127.0.0.1:8080`) | | `SIGNAL_ACCOUNT` | Bot phone number in E.164 format | @@ -259,6 +263,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `DINGTALK_CLIENT_ID` | DingTalk bot AppKey from developer portal ([open.dingtalk.com](https://open.dingtalk.com)) | | `DINGTALK_CLIENT_SECRET` | DingTalk bot AppSecret from developer portal | | `DINGTALK_ALLOWED_USERS` | Comma-separated DingTalk user IDs allowed to message the bot | +| `DINGTALK_REQUIRE_MENTION_CHATS` | Comma-separated chat IDs where bot always requires `@mention`, even when the global `require_mention` is `false`. Useful for busy group chats. | | `FEISHU_APP_ID` | Feishu/Lark bot App ID from [open.feishu.cn](https://open.feishu.cn/) | | `FEISHU_APP_SECRET` | Feishu/Lark bot App Secret | | `FEISHU_DOMAIN` | `feishu` (China) or `lark` (international). Default: `feishu` | @@ -315,6 +320,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot | | `MATTERMOST_HOME_CHANNEL` | Channel ID for proactive message delivery (cron, notifications) | | `MATTERMOST_REQUIRE_MENTION` | Require `@mention` in channels (default: `true`). Set to `false` to respond to all messages. | +| `MATTERMOST_REQUIRE_MENTION_CHANNELS` | Comma-separated channel IDs where bot always requires `@mention`, even when `MATTERMOST_REQUIRE_MENTION` is `false`. Useful for noisy channels where you want mention-only behavior without changing the server-wide default. | | `MATTERMOST_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where bot responds without `@mention` | | `MATTERMOST_REPLY_MODE` | Reply style: `thread` (threaded replies) or `off` (flat messages, default) | | `MATRIX_HOMESERVER` | Matrix homeserver URL (e.g. `https://matrix.org`) | @@ -327,6 +333,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATRIX_DEVICE_ID` | Stable Matrix device ID for E2EE persistence across restarts (e.g. `HERMES_BOT`). Without this, E2EE keys rotate every startup and historic-room decrypt breaks. | | `MATRIX_REACTIONS` | Enable processing-lifecycle emoji reactions on inbound messages (default: `true`). Set to `false` to disable. | | `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. | +| `MATRIX_REQUIRE_MENTION_ROOMS` | Comma-separated room IDs where bot always requires `@mention`, even when `MATRIX_REQUIRE_MENTION` is `false`. Useful for noisy rooms where you want mention-only behavior without changing the server-wide default. | | `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` | | `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) | | `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 1c491a48ce..9a0713f3b0 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1232,11 +1232,13 @@ Configure Discord-specific behavior for the messaging gateway: ```yaml discord: require_mention: true # Require @mention to respond in server channels + require_mention_channels: "" # Comma-separated channel IDs where @mention is always required (even when require_mention is false) free_response_channels: "" # Comma-separated channel IDs where bot responds without @mention auto_thread: true # Auto-create threads on @mention in channels ``` - `require_mention` — when `true` (default), the bot only responds in server channels when mentioned with `@BotName`. DMs always work without mention. +- `require_mention_channels` — comma-separated list of channel IDs where the bot always requires a mention, even when `require_mention` is `false`. This is the inverse of `free_response_channels` — use it to enforce mention-only behavior in specific noisy channels without changing the server-wide default. Available on all platforms: `require_mention_channels` (Discord, Slack, Mattermost), `require_mention_chats` (Telegram, WhatsApp, DingTalk), `require_mention_rooms` (Matrix). - `free_response_channels` — comma-separated list of channel IDs where the bot responds to every message without requiring a mention. - `auto_thread` — when `true` (default), mentions in channels automatically create a thread for the conversation, keeping channels clean (similar to Slack threading). diff --git a/website/docs/user-guide/messaging/dingtalk.md b/website/docs/user-guide/messaging/dingtalk.md index 9e8e74ee26..e18f8ef98b 100644 --- a/website/docs/user-guide/messaging/dingtalk.md +++ b/website/docs/user-guide/messaging/dingtalk.md @@ -198,6 +198,27 @@ display: interim_assistant_messages: false ``` +## Mention Behavior + +By default, the bot only responds in group chats when `@mentioned`. You can change this: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DINGTALK_REQUIRE_MENTION` | `true` | Set to `false` to respond to all messages in group chats (DMs always work). | +| `DINGTALK_FREE_RESPONSE_CHATS` | _(none)_ | Comma-separated chat IDs where the bot responds without `@mention`, even when require_mention is true. | +| `DINGTALK_REQUIRE_MENTION_CHATS` | _(none)_ | Comma-separated chat IDs where the bot **always** requires `@mention`, even when require_mention is false. The inverse of free_response_chats. | + +### config.yaml + +You can also configure mention behavior in `~/.hermes/config.yaml`: + +```yaml +dingtalk: + require_mention: true + free_response_chats: "" + require_mention_chats: "chat_id_1,chat_id_2" +``` + ## Troubleshooting ### Bot is not responding to messages diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 2a38b9798c..d77ef81601 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -277,6 +277,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. | | `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. | | `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. | +| `DISCORD_REQUIRE_MENTION_CHANNELS` | No | — | Comma-separated channel IDs where the bot **always** requires an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `false`. The inverse of `DISCORD_FREE_RESPONSE_CHANNELS`. | | `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. | | `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. | | `DISCORD_ALLOW_BOTS` | No | `"none"` | Controls how the bot handles messages from other Discord bots. `"none"` — ignore all other bots. `"mentions"` — only accept bot messages that `@mention` Hermes. `"all"` — accept all bot messages. | @@ -302,6 +303,7 @@ The `discord` section in `~/.hermes/config.yaml` mirrors the env vars above. Con discord: require_mention: true # Require @mention in server channels free_response_channels: "" # Comma-separated channel IDs (or YAML list) + require_mention_channels: "" # Channel IDs that always require @mention (or YAML list) auto_thread: true # Auto-create threads on @mention reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds @@ -345,6 +347,26 @@ If a thread's parent channel is in this list, the thread also becomes mention-fr Free-response channels also **skip auto-threading** — the bot replies inline rather than spinning off a new thread per message. This keeps the channel usable as a lightweight chat surface. If you want threading behavior, don't list the channel as free-response (use normal `@mention` flow instead). +#### `discord.require_mention_channels` + +**Type:** string or list — **Default:** `""` + +Channel IDs where the bot **always** requires an `@mention` to respond, even when `require_mention` is globally set to `false`. This is the inverse of `free_response_channels` — use it to lock down specific channels while keeping the rest of the server mention-free. + +```yaml +# String format +discord: + require_mention_channels: "1234567890,9876543210" + +# List format +discord: + require_mention_channels: + - 1234567890 + - 9876543210 +``` + +If a thread's parent channel is in this list, the thread also requires a mention. + #### `discord.auto_thread` **Type:** boolean — **Default:** `true` diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 255806c01b..890e2182b2 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -61,6 +61,8 @@ matrix: require_mention: true # Require @mention in rooms (default: true) free_response_rooms: # Rooms exempt from mention requirement - "!abc123:matrix.org" + require_mention_rooms: # Rooms that always require @mention + - "!locked789:matrix.org" auto_thread: true # Auto-create threads for responses (default: true) dm_mention_threads: false # Create thread when @mentioned in DM (default: false) ``` @@ -70,6 +72,7 @@ Or via environment variables: ```bash MATRIX_REQUIRE_MENTION=true MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org +MATRIX_REQUIRE_MENTION_ROOMS=!locked789:matrix.org,!secure012:matrix.org MATRIX_AUTO_THREAD=true MATRIX_DM_MENTION_THREADS=false MATRIX_REACTIONS=true # default: true — emoji reactions during processing diff --git a/website/docs/user-guide/messaging/mattermost.md b/website/docs/user-guide/messaging/mattermost.md index 6d45401549..ee0220d030 100644 --- a/website/docs/user-guide/messaging/mattermost.md +++ b/website/docs/user-guide/messaging/mattermost.md @@ -155,6 +155,9 @@ MATTERMOST_ALLOWED_USERS=3uo8dkh1p7g1mfk49ear5fzs5c # Optional: channels where bot responds without @mention (comma-separated channel IDs) # MATTERMOST_FREE_RESPONSE_CHANNELS=channel_id_1,channel_id_2 + +# Optional: channels where bot always requires @mention (comma-separated channel IDs) +# MATTERMOST_REQUIRE_MENTION_CHANNELS=channel_id_3,channel_id_4 ``` Optional behavior settings in `~/.hermes/config.yaml`: @@ -220,11 +223,23 @@ By default, the bot only responds in channels when `@mentioned`. You can change |----------|---------|-------------| | `MATTERMOST_REQUIRE_MENTION` | `true` | Set to `false` to respond to all messages in channels (DMs always work). | | `MATTERMOST_FREE_RESPONSE_CHANNELS` | _(none)_ | Comma-separated channel IDs where the bot responds without `@mention`, even when require_mention is true. | +| `MATTERMOST_REQUIRE_MENTION_CHANNELS` | _(none)_ | Comma-separated channel IDs where the bot **always** requires `@mention`, even when require_mention is false. The inverse of free_response_channels. | To find a channel ID in Mattermost: open the channel, click the channel name header, and look for the ID in the URL or channel details. When the bot is `@mentioned`, the mention is automatically stripped from the message before processing. +### config.yaml + +You can also configure mention behavior in `~/.hermes/config.yaml`: + +```yaml +mattermost: + require_mention: true + free_response_channels: "channel_id_1,channel_id_2" + require_mention_channels: "channel_id_3,channel_id_4" +``` + ## Troubleshooting ### Bot is not responding to messages diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index a7eff683da..7ebcc98558 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -283,9 +283,25 @@ slack: ``` :::info -Slack supports both patterns: `@mention` required to start a conversation by default, but you can opt specific channels out via `SLACK_FREE_RESPONSE_CHANNELS` (comma-separated channel IDs) or `slack.free_response_channels` in `config.yaml`. Once the bot has an active session in a thread, subsequent thread replies do not require a mention. In DMs the bot always responds without needing a mention. +Slack supports both patterns: `@mention` required to start a conversation by default, but you can opt specific channels out via `SLACK_FREE_RESPONSE_CHANNELS` (comma-separated channel IDs) or `slack.free_response_channels` in `config.yaml`. Conversely, you can force specific channels to always require `@mention` — even when `require_mention` is globally `false` — with `SLACK_REQUIRE_MENTION_CHANNELS` or `slack.require_mention_channels`. Once the bot has an active session in a thread, subsequent thread replies do not require a mention. In DMs the bot always responds without needing a mention. ::: +#### Channel-Level Mention Overrides + +| Variable | Default | Description | +|----------|---------|-------------| +| `SLACK_FREE_RESPONSE_CHANNELS` | _(none)_ | Comma-separated channel IDs where the bot responds without `@mention`, even when `require_mention` is `true`. | +| `SLACK_REQUIRE_MENTION_CHANNELS` | _(none)_ | Comma-separated channel IDs where the bot **always** requires `@mention`, even when `require_mention` is `false`. The inverse of free_response_channels. | + +In `config.yaml`: + +```yaml +slack: + require_mention: true + free_response_channels: "C01CHANNEL1,C02CHANNEL2" + require_mention_channels: "C03CHANNEL3,C04CHANNEL4" +``` + ### Unauthorized User Handling ```yaml @@ -324,6 +340,7 @@ stt_enabled: true # Slack-specific settings slack: require_mention: true + require_mention_channels: [] # Channel IDs that always require @mention unauthorized_dm_behavior: "pair" # Platform config diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index dbdfc3f4ac..861c16fd48 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -291,6 +291,7 @@ Add this to `~/.hermes/config.yaml`: ```yaml telegram: require_mention: true + require_mention_chats: [] # Chat IDs that always require @mention, even when require_mention is false mention_patterns: - "^\\s*chompy\\b" ignored_threads: diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index e4a8def077..855dbe029a 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -201,6 +201,29 @@ When the agent calls tools (web search, file operations, etc.), WhatsApp display --- +## Mention Behavior + +By default, the bot responds to all messages in WhatsApp chats (both 1:1 and group). You can restrict specific group chats to require an `@mention` before the bot responds: + +| Variable | Default | Description | +|----------|---------|-------------| +| `WHATSAPP_REQUIRE_MENTION` | `true` | When `true`, the bot only responds in group chats when `@mentioned`. DMs always get a response. | +| `WHATSAPP_FREE_RESPONSE_CHATS` | _(none)_ | Comma-separated chat IDs where the bot responds without `@mention`, even when require_mention is true. | +| `WHATSAPP_REQUIRE_MENTION_CHATS` | _(none)_ | Comma-separated chat IDs where the bot **always** requires `@mention`, even when require_mention is false. The inverse of free_response_chats. | + +### config.yaml + +You can also configure mention behavior in `~/.hermes/config.yaml`: + +```yaml +whatsapp: + require_mention: true + free_response_chats: "" + require_mention_chats: "chat_id_1,chat_id_2" +``` + +--- + ## Troubleshooting | Problem | Solution |