feat(mattermost): configurable mention behavior — respond without @mention (#3664)
Adds MATTERMOST_REQUIRE_MENTION and MATTERMOST_FREE_RESPONSE_CHANNELS env vars, matching Discord's existing mention gating pattern. - MATTERMOST_REQUIRE_MENTION=false: respond to all channel messages - MATTERMOST_FREE_RESPONSE_CHANNELS=id1,id2: specific channels where bot responds without @mention even when require_mention is true - DMs always respond regardless of mention settings - @mention is now stripped from message text (clean agent input) 7 new tests for mention gating, free-response channels, DM bypass, and mention stripping. Updated existing test for mention stripping. Docs: updated mattermost.md with Mention Behavior section, environment-variables.md with new vars, config.py with metadata.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user