Compare commits

..

7 Commits

Author SHA1 Message Date
Ben Barclay
2a7a7c509d Install whatsapp bridge deps in container 2026-03-29 14:30:09 +11:00
Ben Barclay
034edf4ffa Remove git submodules from container 2026-03-29 14:29:29 +11:00
Ben Barclay
d9e8d857e8 apt -> apt-get 2026-03-29 13:55:18 +11:00
Ben Barclay
c09f81bd33 Add .dockerignore file 2026-03-29 13:55:18 +11:00
Ben Barclay
a6debb0c53 Fix incorrect Dockerfile reference in GitHub action 2026-03-29 13:55:18 +11:00
Ben Barclay
ec1e66b6f2 Pin Docker version 2026-03-29 13:55:18 +11:00
Ben Barclay
bc78b2ef29 feat(docker): Add a docker container for the agent 2026-03-29 13:55:18 +11:00
9 changed files with 14 additions and 153 deletions

View File

@@ -5,11 +5,8 @@ set -e
HERMES_HOME="/opt/data"
INSTALL_DIR="/opt/hermes"
# Create essential directory structure. Cache and platform directories
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
# demand by the application — don't pre-create them here so new installs
# get the consolidated layout from get_hermes_dir().
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills}
# Create directory structure
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills,whatsapp/session}
# .env
if [ ! -f "$HERMES_HOME/.env" ]; then

View File

@@ -1005,7 +1005,7 @@ class BasePlatformAdapter(ABC):
# simultaneous messages. Queue them without interrupting the active run,
# then process them immediately after the current task finishes.
if event.message_type == MessageType.PHOTO:
logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
print(f"[{self.name}] 🖼️ Queuing photo follow-up for session {session_key} without interrupt")
existing = self._pending_messages.get(session_key)
if existing and existing.message_type == MessageType.PHOTO:
existing.media_urls.extend(event.media_urls)
@@ -1020,7 +1020,7 @@ class BasePlatformAdapter(ABC):
return # Don't interrupt now - will run after current task completes
# Default behavior for non-photo follow-ups: interrupt the running agent
logger.debug("[%s] New message while session %s is active triggering interrupt", self.name, session_key)
print(f"[{self.name}] New message while session {session_key} is active - triggering interrupt")
self._pending_messages[session_key] = event
# Signal the interrupt (the processing task checks this)
self._active_sessions[session_key].set()
@@ -1206,9 +1206,9 @@ class BasePlatformAdapter(ABC):
)
if not media_result.success:
logger.warning("[%s] Failed to send media (%s): %s", self.name, ext, media_result.error)
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
except Exception as media_err:
logger.warning("[%s] Error sending media: %s", self.name, media_err)
print(f"[{self.name}] Error sending media: {media_err}")
# Send auto-detected local files as native attachments
for file_path in local_files:
@@ -1240,7 +1240,7 @@ class BasePlatformAdapter(ABC):
# Check if there's a pending message that was queued during our processing
if session_key in self._pending_messages:
pending_event = self._pending_messages.pop(session_key)
logger.debug("[%s] Processing queued message from interrupt", self.name)
print(f"[{self.name}] 📨 Processing queued message from interrupt")
# Clean up current session before processing pending
if session_key in self._active_sessions:
del self._active_sessions[session_key]
@@ -1254,7 +1254,9 @@ class BasePlatformAdapter(ABC):
return # Already cleaned up
except Exception as e:
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
print(f"[{self.name}] Error handling message: {e}")
import traceback
traceback.print_exc()
# Send the error to the user so they aren't left with radio silence
try:
error_type = type(e).__name__

View File

@@ -603,19 +603,9 @@ class MattermostAdapter(BasePlatformAdapter):
# For DMs, user_id is sufficient. For channels, check for @mention.
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
# Mention-only mode: skip channel messages that don't @mention the bot.
# DMs (type "D") are always processed.
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}",
@@ -624,21 +614,13 @@ class MattermostAdapter(BasePlatformAdapter):
pattern.lower() in message_text.lower()
for pattern in mention_patterns
)
if require_mention and not is_free_channel and not has_mention:
if 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

View File

@@ -817,20 +817,6 @@ 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",

View File

@@ -1,6 +1,5 @@
"""Tests for Mattermost platform adapter."""
import json
import os
import time
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
@@ -270,7 +269,6 @@ 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()
@@ -295,8 +293,7 @@ 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]
# @mention is stripped from the message text
assert msg_event.text == "Hello from Matrix!"
assert msg_event.text == "@bot_user_id Hello from Matrix!"
assert msg_event.message_id == "post_abc"
@pytest.mark.asyncio
@@ -413,87 +410,6 @@ 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)
# ---------------------------------------------------------------------------

View File

@@ -200,8 +200,6 @@ 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 |

View File

@@ -149,12 +149,6 @@ 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`:
@@ -212,19 +206,6 @@ 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

View File

@@ -37,7 +37,6 @@ const sidebars: SidebarsConfig = {
'user-guide/configuration',
'user-guide/sessions',
'user-guide/security',
'user-guide/docker',
{
type: 'category',
label: 'Messaging Gateway',