diff --git a/gateway/config.py b/gateway/config.py index 1896db9ff8..fec050b92d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -575,6 +575,20 @@ 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) + + # Matrix settings → env vars (env vars take precedence) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower() + frc = matrix_cfg.get("free_response_rooms") + if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc) + if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): + os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() + except Exception as e: logger.warning( "Failed to process config.yaml — falling back to .env / gateway.json values. " diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index c9bcd945a0..9216d5f2d5 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -5,13 +5,16 @@ matrix-nio Python SDK. Supports optional end-to-end encryption (E2EE) when installed with ``pip install "matrix-nio[e2e]"``. Environment variables: - MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org) - MATRIX_ACCESS_TOKEN Access token (preferred auth method) - MATRIX_USER_ID Full user ID (@bot:server) — required for password login - MATRIX_PASSWORD Password (alternative to access token) - MATRIX_ENCRYPTION Set "true" to enable E2EE - MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) - MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org) + MATRIX_ACCESS_TOKEN Access token (preferred auth method) + MATRIX_USER_ID Full user ID (@bot:server) — required for password login + MATRIX_PASSWORD Password (alternative to access token) + MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) + MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) + MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement + MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) """ from __future__ import annotations @@ -123,6 +126,10 @@ class MatrixAdapter(BasePlatformAdapter): # Each entry: (room, event, timestamp) self._pending_megolm: list = [] + # Thread participation tracking (for require_mention bypass) + self._bot_participated_threads: set = self._load_participated_threads() + self._MAX_TRACKED_THREADS = 500 + def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" if not event_id: @@ -902,6 +909,32 @@ class MatrixAdapter(BasePlatformAdapter): if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # Require-mention gating. + if not is_dm: + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()} + require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + is_free_room = room.room_id in free_rooms + in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads) + + formatted_body = source_content.get("formatted_body") + if require_mention and not is_free_room and not in_bot_thread: + if not self._is_bot_mentioned(body, formatted_body): + return + + # Strip mention from body when present. + if self._is_bot_mentioned(body, source_content.get("formatted_body")): + body = self._strip_mention(body) + if not body: + return + + # Auto-thread: create a thread for non-DM, non-threaded messages. + if not is_dm and not thread_id: + auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + if auto_thread: + thread_id = event.event_id + self._track_thread(thread_id) + # Reply-to detection. reply_to = None in_reply_to = relates_to.get("m.in_reply_to", {}) @@ -946,6 +979,9 @@ class MatrixAdapter(BasePlatformAdapter): reply_to_message_id=reply_to, ) + if thread_id: + self._track_thread(thread_id) + await self.handle_message(msg_event) async def _on_room_message_media(self, room: Any, event: Any) -> None: @@ -1031,6 +1067,27 @@ class MatrixAdapter(BasePlatformAdapter): if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # Require-mention gating (media messages). + if not is_dm: + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()} + require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + is_free_room = room.room_id in free_rooms + in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads) + + if require_mention and not is_free_room and not in_bot_thread: + # Media messages have no formatted_body; check plain body only. + formatted_body = source_content.get("formatted_body") + if not self._is_bot_mentioned(body, formatted_body): + return + + # Auto-thread: create a thread for non-DM, non-threaded messages. + if not is_dm and not thread_id: + auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + if auto_thread: + thread_id = event.event_id + self._track_thread(thread_id) + # For voice messages, cache audio locally for transcription tools. # Use the authenticated nio client to download (Matrix requires auth for media). media_urls = [http_url] if http_url else None @@ -1079,6 +1136,9 @@ class MatrixAdapter(BasePlatformAdapter): media_types=media_types, ) + if thread_id: + self._track_thread(thread_id) + await self.handle_message(msg_event) async def _on_invite(self, room: Any, event: Any) -> None: @@ -1166,6 +1226,82 @@ class MatrixAdapter(BasePlatformAdapter): for rid in self._joined_rooms } + # ------------------------------------------------------------------ + # Thread participation tracking + # ------------------------------------------------------------------ + + @staticmethod + def _thread_state_path() -> Path: + """Path to the persisted thread participation set.""" + from hermes_cli.config import get_hermes_home + return get_hermes_home() / "matrix_threads.json" + + @classmethod + def _load_participated_threads(cls) -> set: + """Load persisted thread IDs from disk.""" + path = cls._thread_state_path() + try: + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return set(data) + except Exception as e: + logger.debug("Could not load matrix thread state: %s", e) + return set() + + def _save_participated_threads(self) -> None: + """Persist the current thread set to disk (best-effort).""" + path = self._thread_state_path() + try: + thread_list = list(self._bot_participated_threads) + if len(thread_list) > self._MAX_TRACKED_THREADS: + thread_list = thread_list[-self._MAX_TRACKED_THREADS:] + self._bot_participated_threads = set(thread_list) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(thread_list), encoding="utf-8") + except Exception as e: + logger.debug("Could not save matrix thread state: %s", e) + + def _track_thread(self, thread_id: str) -> None: + """Add a thread to the participation set and persist.""" + if thread_id not in self._bot_participated_threads: + self._bot_participated_threads.add(thread_id) + self._save_participated_threads() + + # ------------------------------------------------------------------ + # Mention detection helpers + # ------------------------------------------------------------------ + + def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool: + """Return True if the bot is mentioned in the message.""" + if not body and not formatted_body: + return False + # Check for full @user:server in body + if self._user_id and self._user_id in body: + return True + # Check for localpart with word boundaries (case-insensitive) + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart and re.search(r'\b' + re.escape(localpart) + r'\b', body, re.IGNORECASE): + return True + # Check formatted_body for Matrix pill + if formatted_body and self._user_id: + if f"matrix.to/#/{self._user_id}" in formatted_body: + return True + return False + + def _strip_mention(self, body: str) -> str: + """Remove bot mention from message body.""" + # Remove full @user:server + if self._user_id: + body = body.replace(self._user_id, "") + # If still contains localpart mention, remove it + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart: + body = re.sub(r'\b' + re.escape(localpart) + r'\b', '', body, flags=re.IGNORECASE) + return body.strip() + def _get_display_name(self, room: Any, user_id: str) -> str: """Get a user's display name in a room, falling back to user_id.""" if room and hasattr(room, "users"): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 491995e17e..00d0923d2f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -42,6 +42,7 @@ _EXTRA_ENV_KEYS = frozenset({ "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", + "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", }) import yaml @@ -1008,6 +1009,30 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "messaging", }, + "MATRIX_REQUIRE_MENTION": { + "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.", + "prompt": "Require @mention in rooms (true/false)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, + "MATRIX_FREE_RESPONSE_ROOMS": { + "description": "Comma-separated Matrix room IDs where bot responds without @mention", + "prompt": "Free-response 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)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "GATEWAY_ALLOW_ALL_USERS": { "description": "Allow all users to interact with messaging bots (true/false). Default: false.", "prompt": "Allow all users (true/false)", diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py new file mode 100644 index 0000000000..f8d90f281e --- /dev/null +++ b/tests/gateway/test_matrix_mention.py @@ -0,0 +1,458 @@ +"""Tests for Matrix require-mention gating and auto-thread features.""" + +import json +import sys +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_nio_mock(): + """Install a mock nio module when matrix-nio isn't available.""" + if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"): + return + nio_mod = MagicMock() + nio_mod.MegolmEvent = type("MegolmEvent", (), {}) + nio_mod.RoomMessageText = type("RoomMessageText", (), {}) + nio_mod.RoomMessageImage = type("RoomMessageImage", (), {}) + nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {}) + nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {}) + nio_mod.RoomMessageFile = type("RoomMessageFile", (), {}) + nio_mod.DownloadResponse = type("DownloadResponse", (), {}) + nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {}) + nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {}) + sys.modules.setdefault("nio", nio_mod) + + +_ensure_nio_mock() + + +def _make_adapter(tmp_path=None): + """Create a MatrixAdapter with mocked config.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@hermes:example.org", + }, + ) + adapter = MatrixAdapter(config) + adapter.handle_message = AsyncMock() + adapter._startup_ts = time.time() - 10 # avoid startup grace filter + return adapter + + +def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False): + """Create a fake Matrix room.""" + room = SimpleNamespace( + room_id=room_id, + member_count=member_count, + users={}, + ) + return room + + +def _make_event( + body, + sender="@alice:example.org", + event_id="$evt1", + formatted_body=None, + thread_id=None, +): + """Create a fake RoomMessageText event.""" + content = {"body": body, "msgtype": "m.text"} + if formatted_body: + content["formatted_body"] = formatted_body + content["format"] = "org.matrix.custom.html" + + relates_to = {} + if thread_id: + relates_to["rel_type"] = "m.thread" + relates_to["event_id"] = thread_id + if relates_to: + content["m.relates_to"] = relates_to + + return SimpleNamespace( + sender=sender, + event_id=event_id, + server_timestamp=int(time.time() * 1000), + body=body, + source={"content": content}, + ) + + +# --------------------------------------------------------------------------- +# Mention detection helpers +# --------------------------------------------------------------------------- + + +class TestIsBotMentioned: + def setup_method(self): + self.adapter = _make_adapter() + + def test_full_user_id_in_body(self): + assert self.adapter._is_bot_mentioned("hey @hermes:example.org help") + + def test_localpart_in_body(self): + assert self.adapter._is_bot_mentioned("hermes can you help?") + + def test_localpart_case_insensitive(self): + assert self.adapter._is_bot_mentioned("HERMES can you help?") + + def test_matrix_pill_in_formatted_body(self): + html = 'Hermes help' + assert self.adapter._is_bot_mentioned("Hermes help", html) + + def test_no_mention(self): + assert not self.adapter._is_bot_mentioned("hello everyone") + + def test_empty_body(self): + assert not self.adapter._is_bot_mentioned("") + + def test_partial_localpart_no_match(self): + # "hermesbot" should not match word-boundary check for "hermes" + assert not self.adapter._is_bot_mentioned("hermesbot is here") + + +class TestStripMention: + def setup_method(self): + self.adapter = _make_adapter() + + def test_strip_full_user_id(self): + result = self.adapter._strip_mention("@hermes:example.org help me") + assert result == "help me" + + def test_strip_localpart(self): + result = self.adapter._strip_mention("hermes help me") + assert result == "help me" + + def test_strip_returns_empty_for_mention_only(self): + result = self.adapter._strip_mention("@hermes:example.org") + assert result == "" + + +# --------------------------------------------------------------------------- +# Require-mention gating in _on_room_message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_require_mention_default_ignores_unmentioned(monkeypatch): + """Default (require_mention=true): messages without mention are ignored.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello everyone") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_require_mention_default_processes_mentioned(monkeypatch): + """Default: messages with mention are processed, mention stripped.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("@hermes:example.org help me") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "help me" + + +@pytest.mark.asyncio +async def test_require_mention_html_pill(monkeypatch): + """Bot mentioned via HTML pill should be processed.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + formatted = 'Hermes help' + event = _make_event("Hermes help", formatted_body=formatted) + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_dm_always_responds(monkeypatch): + """DMs always respond regardless of mention setting.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + # member_count=2 triggers DM detection + room = _make_room(member_count=2) + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_free_response_room(monkeypatch): + """Free-response rooms bypass mention requirement.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(room_id="!room1:example.org") + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_bot_participated_thread(monkeypatch): + """Threads with prior bot participation bypass mention requirement.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + adapter._bot_participated_threads.add("$thread1") + + room = _make_room() + event = _make_event("hello without mention", thread_id="$thread1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_disabled(monkeypatch): + """MATRIX_REQUIRE_MENTION=false: all messages processed.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "hello without mention" + + +# --------------------------------------------------------------------------- +# Auto-thread in _on_room_message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_thread_default_creates_thread(monkeypatch): + """Default (auto_thread=true): sets thread_id to event.event_id.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id == "$msg1" + + +@pytest.mark.asyncio +async def test_auto_thread_preserves_existing_thread(monkeypatch): + """If message is already in a thread, thread_id is not overridden.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + adapter._bot_participated_threads.add("$thread_root") + room = _make_room() + event = _make_event("reply in thread", thread_id="$thread_root") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id == "$thread_root" + + +@pytest.mark.asyncio +async def test_auto_thread_skips_dm(monkeypatch): + """DMs should not get auto-threaded.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("hello dm", event_id="$dm1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id is None + + +@pytest.mark.asyncio +async def test_auto_thread_disabled(monkeypatch): + """MATRIX_AUTO_THREAD=false: thread_id stays None.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id is None + + +@pytest.mark.asyncio +async def test_auto_thread_tracks_participation(monkeypatch): + """Auto-created threads are tracked in _bot_participated_threads.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + with patch.object(adapter, "_save_participated_threads"): + await adapter._on_room_message(room, event) + + assert "$msg1" in adapter._bot_participated_threads + + +# --------------------------------------------------------------------------- +# Thread persistence +# --------------------------------------------------------------------------- + + +class TestThreadPersistence: + def test_empty_state_file(self, tmp_path, monkeypatch): + """No state file → empty set.""" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: tmp_path / "matrix_threads.json"), + ) + adapter = _make_adapter() + loaded = adapter._load_participated_threads() + assert loaded == set() + + def test_track_thread_persists(self, tmp_path, monkeypatch): + """_track_thread writes to disk.""" + state_path = tmp_path / "matrix_threads.json" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + adapter._track_thread("$thread_abc") + + data = json.loads(state_path.read_text()) + assert "$thread_abc" in data + + def test_threads_survive_reload(self, tmp_path, monkeypatch): + """Persisted threads are loaded by a new adapter instance.""" + state_path = tmp_path / "matrix_threads.json" + state_path.write_text(json.dumps(["$t1", "$t2"])) + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + assert "$t1" in adapter._bot_participated_threads + assert "$t2" in adapter._bot_participated_threads + + def test_cap_max_tracked_threads(self, tmp_path, monkeypatch): + """Thread set is trimmed to _MAX_TRACKED_THREADS.""" + state_path = tmp_path / "matrix_threads.json" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + adapter._MAX_TRACKED_THREADS = 5 + + for i in range(10): + adapter._bot_participated_threads.add(f"$t{i}") + adapter._save_participated_threads() + + data = json.loads(state_path.read_text()) + assert len(data) == 5 + + +# --------------------------------------------------------------------------- +# YAML config bridge +# --------------------------------------------------------------------------- + + +class TestMatrixConfigBridge: + def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path): + """Matrix YAML config should bridge to env vars.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + yaml_content = { + "matrix": { + "require_mention": False, + "free_response_rooms": ["!room1:example.org", "!room2:example.org"], + "auto_thread": False, + } + } + + import os + import yaml + + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump(yaml_content)) + + # Simulate the bridge logic from gateway/config.py + yaml_cfg = yaml.safe_load(config_file.read_text()) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()) + frc = matrix_cfg.get("free_response_rooms") + if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc)) + if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): + monkeypatch.setenv("MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower()) + + assert os.getenv("MATRIX_REQUIRE_MENTION") == "false" + assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org" + assert os.getenv("MATRIX_AUTO_THREAD") == "false" + + def test_env_vars_take_precedence_over_yaml(self, monkeypatch): + """Env vars should not be overwritten by YAML values.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true") + + import os + yaml_cfg = {"matrix": {"require_mention": False}} + matrix_cfg = yaml_cfg.get("matrix", {}) + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()) + + assert os.getenv("MATRIX_REQUIRE_MENTION") == "true" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 2b0a842112..8917072a49 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -232,6 +232,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) | | `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) | | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) | +| `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. | +| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` | +| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) | diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 70b8855a24..943751c126 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -17,8 +17,9 @@ Before setup, here's the part most people want to know: how Hermes behaves once | Context | Behavior | |---------|----------| | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. | -| **Rooms** | Hermes responds to all messages in rooms it has joined. Room invites are auto-accepted. | -| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. | +| **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. | +| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. | +| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. | | **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. | :::tip @@ -51,6 +52,30 @@ Shared sessions can be useful for a collaborative room, but they also mean: - one person's long tool-heavy task can bloat everyone else's context - one person's in-flight run can interrupt another person's follow-up in the same room +### Mention and Threading Configuration + +You can configure mention and auto-threading behavior via environment variables or `config.yaml`: + +```yaml +matrix: + require_mention: true # Require @mention in rooms (default: true) + free_response_rooms: # Rooms exempt from mention requirement + - "!abc123:matrix.org" + auto_thread: true # Auto-create threads for responses (default: true) +``` + +Or via environment variables: + +```bash +MATRIX_REQUIRE_MENTION=true +MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org +MATRIX_AUTO_THREAD=true +``` + +:::note +If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`. +::: + This guide walks you through the full setup process — from creating your bot account to sending your first message. ## Step 1: Create a Bot Account