diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 8e8cd4db..c134bb35 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -603,9 +603,19 @@ class MattermostAdapter(BasePlatformAdapter): # For DMs, user_id is sufficient. For channels, check for @mention. message_text = post.get("message", "") - # Mention-only mode: skip channel messages that don't @mention the bot. - # DMs (type "D") are always processed. + # 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 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()} + is_free_channel = channel_id in free_channels + mention_patterns = [ f"@{self._bot_username}", f"@{self._bot_user_id}", @@ -614,13 +624,21 @@ class MattermostAdapter(BasePlatformAdapter): pattern.lower() in message_text.lower() for pattern in mention_patterns ) - if not has_mention: + + if require_mention and not is_free_channel and not has_mention: logger.debug( "Mattermost: skipping non-DM message without @mention (channel=%s)", channel_id, ) return + # Strip @mention from the message text so the agent sees clean input. + if has_mention: + for pattern in mention_patterns: + message_text = re.sub( + re.escape(pattern), "", message_text, flags=re.IGNORECASE + ).strip() + # Resolve sender info. sender_id = post.get("user_id", "") sender_name = data.get("sender_name", "").lstrip("@") or sender_id diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 881d796d..a8843321 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -817,6 +817,20 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "messaging", }, + "MATTERMOST_REQUIRE_MENTION": { + "description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.", + "prompt": "Require @mention in channels", + "url": None, + "password": False, + "category": "messaging", + }, + "MATTERMOST_FREE_RESPONSE_CHANNELS": { + "description": "Comma-separated Mattermost channel IDs where bot responds without @mention", + "prompt": "Free-response 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", diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index 238506b0..a7a586ff 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -1,5 +1,6 @@ """Tests for Mattermost platform adapter.""" import json +import os import time import pytest from unittest.mock import MagicMock, patch, AsyncMock @@ -269,6 +270,7 @@ class TestMattermostWebSocketParsing: def setup_method(self): self.adapter = _make_adapter() self.adapter._bot_user_id = "bot_user_id" + self.adapter._bot_username = "hermes-bot" # Mock handle_message to capture the MessageEvent without processing self.adapter.handle_message = AsyncMock() @@ -293,7 +295,8 @@ class TestMattermostWebSocketParsing: await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.called msg_event = self.adapter.handle_message.call_args[0][0] - assert msg_event.text == "@bot_user_id Hello from Matrix!" + # @mention is stripped from the message text + assert msg_event.text == "Hello from Matrix!" assert msg_event.message_id == "post_abc" @pytest.mark.asyncio @@ -410,6 +413,87 @@ class TestMattermostWebSocketParsing: assert not self.adapter.handle_message.called +# --------------------------------------------------------------------------- +# Mention behavior (require_mention + free_response_channels) +# --------------------------------------------------------------------------- + +class TestMattermostMentionBehavior: + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._bot_user_id = "bot_user_id" + self.adapter._bot_username = "hermes-bot" + self.adapter.handle_message = AsyncMock() + + def _make_event(self, message, channel_type="O", channel_id="chan_456"): + post_data = { + "id": "post_mention", + "user_id": "user_123", + "channel_id": channel_id, + "message": message, + } + return { + "event": "posted", + "data": { + "post": json.dumps(post_data), + "channel_type": channel_type, + "sender_name": "@alice", + }, + } + + @pytest.mark.asyncio + async def test_require_mention_true_skips_without_mention(self): + """Default: messages without @mention in channels are skipped.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) + os.environ.pop("MATTERMOST_FREE_RESPONSE_CHANNELS", None) + await self.adapter._handle_ws_event(self._make_event("hello")) + assert not self.adapter.handle_message.called + + @pytest.mark.asyncio + async def test_require_mention_false_responds_to_all(self): + """MATTERMOST_REQUIRE_MENTION=false: respond to all channel messages.""" + with patch.dict(os.environ, {"MATTERMOST_REQUIRE_MENTION": "false"}): + await self.adapter._handle_ws_event(self._make_event("hello")) + assert self.adapter.handle_message.called + + @pytest.mark.asyncio + async def test_free_response_channel_responds_without_mention(self): + """Messages in free-response channels don't need @mention.""" + with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_456,chan_789"}): + os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) + await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456")) + assert self.adapter.handle_message.called + + @pytest.mark.asyncio + async def test_non_free_channel_still_requires_mention(self): + """Channels NOT in free-response list still require @mention.""" + with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_789"}): + os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) + await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456")) + assert not self.adapter.handle_message.called + + @pytest.mark.asyncio + async def test_dm_always_responds(self): + """DMs (channel_type=D) always respond regardless of mention settings.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) + await self.adapter._handle_ws_event(self._make_event("hello", channel_type="D")) + assert self.adapter.handle_message.called + + @pytest.mark.asyncio + async def test_mention_stripped_from_text(self): + """@mention is stripped from message text.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) + await self.adapter._handle_ws_event( + self._make_event("@hermes-bot what is 2+2") + ) + assert self.adapter.handle_message.called + msg = self.adapter.handle_message.call_args[0][0] + assert "@hermes-bot" not in msg.text + assert "2+2" in msg.text + + # --------------------------------------------------------------------------- # File upload (send_image) # --------------------------------------------------------------------------- diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 315b6a39..493412e1 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -200,6 +200,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost | | `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_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`) | | `MATRIX_ACCESS_TOKEN` | Matrix access token for bot authentication | diff --git a/website/docs/user-guide/messaging/mattermost.md b/website/docs/user-guide/messaging/mattermost.md index f959bb87..cff50e94 100644 --- a/website/docs/user-guide/messaging/mattermost.md +++ b/website/docs/user-guide/messaging/mattermost.md @@ -149,6 +149,12 @@ MATTERMOST_ALLOWED_USERS=3uo8dkh1p7g1mfk49ear5fzs5c # Optional: reply mode (thread or off, default: off) # MATTERMOST_REPLY_MODE=thread + +# Optional: respond without @mention (default: true = require mention) +# MATTERMOST_REQUIRE_MENTION=false + +# Optional: channels where bot responds without @mention (comma-separated channel IDs) +# MATTERMOST_FREE_RESPONSE_CHANNELS=channel_id_1,channel_id_2 ``` Optional behavior settings in `~/.hermes/config.yaml`: @@ -206,6 +212,19 @@ Set it in your `~/.hermes/.env`: MATTERMOST_REPLY_MODE=thread ``` +## Mention Behavior + +By default, the bot only responds in channels when `@mentioned`. You can change this: + +| Variable | Default | Description | +|----------|---------|-------------| +| `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. | + +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. + ## Troubleshooting ### Bot is not responding to messages