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