diff --git a/gateway/config.py b/gateway/config.py index d0cc2a2c24..bde52eb559 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -642,6 +642,8 @@ def load_gateway_config() -> GatewayConfig: 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() + if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): + os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower() except Exception as e: logger.warning( diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 7683683541..053a5e6199 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -18,6 +18,7 @@ Environment variables: 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) + MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) """ from __future__ import annotations @@ -1043,6 +1044,13 @@ class MatrixAdapter(BasePlatformAdapter): if not self._is_bot_mentioned(body, formatted_body): return + # DM mention-thread: when enabled, @mentioning bot in a DM creates a thread. + if is_dm and not thread_id: + dm_mention_threads = os.getenv("MATRIX_DM_MENTION_THREADS", "false").lower() in ("true", "1", "yes") + if dm_mention_threads and self._is_bot_mentioned(body, source_content.get("formatted_body")): + thread_id = event.event_id + self._track_thread(thread_id) + # Strip mention from body when present (including in DMs). if self._is_bot_mentioned(body, source_content.get("formatted_body")): body = self._strip_mention(body) @@ -1360,6 +1368,13 @@ class MatrixAdapter(BasePlatformAdapter): if not self._is_bot_mentioned(body, formatted_body): return + # DM mention-thread: when enabled, @mentioning bot in a DM creates a thread. + if is_dm and not thread_id: + dm_mention_threads = os.getenv("MATRIX_DM_MENTION_THREADS", "false").lower() in ("true", "1", "yes") + if dm_mention_threads and self._is_bot_mentioned(body, source_content.get("formatted_body")): + thread_id = event.event_id + self._track_thread(thread_id) + # Strip mention from body when present (including in DMs). if self._is_bot_mentioned(body, source_content.get("formatted_body")): body = self._strip_mention(body) diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 4c689fa10a..215d8ab521 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -436,6 +436,95 @@ class TestThreadPersistence: assert len(data) == 5 +# --------------------------------------------------------------------------- +# DM mention-thread feature +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dm_mention_thread_disabled_by_default(monkeypatch): + """Default (dm_mention_threads=false): DM with mention should NOT create a thread.""" + monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("@hermes:example.org help me", 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_dm_mention_thread_creates_thread(monkeypatch): + """MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread.""" + monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("@hermes:example.org help me", event_id="$dm1") + + with patch.object(adapter, "_save_participated_threads"): + 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 == "$dm1" + assert msg.text == "help me" + + +@pytest.mark.asyncio +async def test_dm_mention_thread_no_mention_no_thread(monkeypatch): + """MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread.""" + monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("hello without mention", 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_dm_mention_thread_preserves_existing_thread(monkeypatch): + """MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id.""" + monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + adapter._bot_participated_threads.add("$existing_thread") + room = _make_room(member_count=2) + event = _make_event("@hermes:example.org help me", thread_id="$existing_thread") + + 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 == "$existing_thread" + + +@pytest.mark.asyncio +async def test_dm_mention_thread_tracks_participation(monkeypatch): + """DM mention-thread tracks the thread in _bot_participated_threads.""" + monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("@hermes:example.org help", event_id="$dm1") + + with patch.object(adapter, "_save_participated_threads"): + await adapter._on_room_message(room, event) + + assert "$dm1" in adapter._bot_participated_threads + + # --------------------------------------------------------------------------- # YAML config bridge # --------------------------------------------------------------------------- @@ -480,6 +569,25 @@ class TestMatrixConfigBridge: assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org" assert os.getenv("MATRIX_AUTO_THREAD") == "false" + def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path): + """Matrix YAML dm_mention_threads should bridge to env var.""" + monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False) + + import os + import yaml + + yaml_content = {"matrix": {"dm_mention_threads": True}} + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump(yaml_content)) + + yaml_cfg = yaml.safe_load(config_file.read_text()) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): + monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", str(matrix_cfg["dm_mention_threads"]).lower()) + + assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true" + 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") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index e5d005f9ad..34d266dac0 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -262,6 +262,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `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`) | +| `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) | | `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 6f47640550..1f6afd6bbb 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -16,7 +16,7 @@ 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. | +| **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. Set `MATRIX_DM_MENTION_THREADS=true` to start a thread when the bot is `@mentioned` in a DM. | | **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. | @@ -62,6 +62,7 @@ matrix: free_response_rooms: # Rooms exempt from mention requirement - "!abc123:matrix.org" auto_thread: true # Auto-create threads for responses (default: true) + dm_mention_threads: false # Create thread when @mentioned in DM (default: false) ``` Or via environment variables: @@ -70,6 +71,7 @@ Or via environment variables: MATRIX_REQUIRE_MENTION=true MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org MATRIX_AUTO_THREAD=true +MATRIX_DM_MENTION_THREADS=false ``` :::note