feat(gateway): add require_mention_channels for per-channel mention overrides

Adds a new `require_mention_channels` config key (and corresponding env
vars) across all 7 gateway platforms that have mention-gating. This is
the inverse of `free_response_channels` — channels listed here always
require @mention even when the global `require_mention` setting is false.

Use case: a user runs multiple channels, most with a single agent where
no mention is needed, but a few 'agent group' channels where mentions
make sense to avoid noise. Previously this required require_mention=true
globally and listing every non-group channel in free_response_channels.

Priority 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

Also refactors:
- Mattermost: inline os.getenv() → proper helper methods matching
  Discord/Slack pattern, adds config.yaml support via config.extra
- Matrix: cached __init__ vars → helper methods with config.extra
  support
- Mattermost config bridging: adds missing YAML→env bridging in
  gateway/config.py (was completely absent)

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

Naming follows each platform's convention (channels/chats/rooms).
Fully backward compatible — empty by default, no behavior change.

Requested by community member neeldhara on PR #3664.
This commit is contained in:
kshitijk4poor 2026-04-22 10:27:46 +05:30
parent ff9752410a
commit d4178e0977
19 changed files with 1000 additions and 23 deletions

View file

@ -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 `_<platform>_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`.

View file

@ -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"):

View file

@ -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):

View file

@ -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

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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,

View file

@ -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()

View file

@ -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):

View file

@ -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()

View file

@ -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)",

View file

@ -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`) |

View file

@ -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).

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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 |