test_matrix_mention.py
1 """Tests for Matrix require-mention gating and auto-thread features.""" 2 3 import json 4 import sys 5 import time 6 from types import SimpleNamespace 7 from unittest.mock import AsyncMock, MagicMock, patch 8 9 import pytest 10 11 from gateway.config import PlatformConfig 12 13 # The matrix adapter module is importable without mautrix installed 14 # (module-level imports use try/except with stubs). No need for 15 # module-level mock installation — tests that call adapter methods 16 # needing real mautrix APIs mock them individually. 17 18 19 def _make_adapter(tmp_path=None): 20 """Create a MatrixAdapter with mocked config.""" 21 from gateway.platforms.matrix import MatrixAdapter 22 23 config = PlatformConfig( 24 enabled=True, 25 token="syt_test_token", 26 extra={ 27 "homeserver": "https://matrix.example.org", 28 "user_id": "@hermes:example.org", 29 }, 30 ) 31 adapter = MatrixAdapter(config) 32 adapter._text_batch_delay_seconds = 0 # disable batching for tests 33 adapter.handle_message = AsyncMock() 34 adapter._startup_ts = time.time() - 10 # avoid startup grace filter 35 return adapter 36 37 38 def _set_dm(adapter, room_id="!room1:example.org", is_dm=True): 39 """Mark a room as DM (or not) in the adapter's cache.""" 40 adapter._dm_rooms[room_id] = is_dm 41 42 43 def _make_event( 44 body, 45 sender="@alice:example.org", 46 event_id="$evt1", 47 room_id="!room1:example.org", 48 formatted_body=None, 49 thread_id=None, 50 mention_user_ids=None, 51 ): 52 """Create a fake room message event. 53 54 The mautrix adapter reads ``event.room_id``, ``event.sender``, 55 ``event.event_id``, ``event.timestamp``, and ``event.content`` 56 (a dict with ``msgtype``, ``body``, etc.). 57 """ 58 content = {"body": body, "msgtype": "m.text"} 59 if formatted_body: 60 content["formatted_body"] = formatted_body 61 content["format"] = "org.matrix.custom.html" 62 63 if mention_user_ids is not None: 64 content["m.mentions"] = {"user_ids": mention_user_ids} 65 66 relates_to = {} 67 if thread_id: 68 relates_to["rel_type"] = "m.thread" 69 relates_to["event_id"] = thread_id 70 if relates_to: 71 content["m.relates_to"] = relates_to 72 73 return SimpleNamespace( 74 sender=sender, 75 event_id=event_id, 76 room_id=room_id, 77 timestamp=int(time.time() * 1000), 78 content=content, 79 ) 80 81 82 # --------------------------------------------------------------------------- 83 # Mention detection helpers 84 # --------------------------------------------------------------------------- 85 86 87 class TestIsBotMentioned: 88 def setup_method(self): 89 self.adapter = _make_adapter() 90 91 def test_full_user_id_in_body(self): 92 assert self.adapter._is_bot_mentioned("hey @hermes:example.org help") 93 94 def test_localpart_in_body(self): 95 assert self.adapter._is_bot_mentioned("hermes can you help?") 96 97 def test_localpart_case_insensitive(self): 98 assert self.adapter._is_bot_mentioned("HERMES can you help?") 99 100 def test_matrix_pill_in_formatted_body(self): 101 html = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help' 102 assert self.adapter._is_bot_mentioned("Hermes help", html) 103 104 def test_no_mention(self): 105 assert not self.adapter._is_bot_mentioned("hello everyone") 106 107 def test_empty_body(self): 108 assert not self.adapter._is_bot_mentioned("") 109 110 def test_partial_localpart_no_match(self): 111 # "hermesbot" should not match word-boundary check for "hermes" 112 assert not self.adapter._is_bot_mentioned("hermesbot is here") 113 114 # m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions 115 # Ported from openclaw/openclaw#64796 116 117 def test_m_mentions_user_ids_authoritative(self): 118 """m.mentions.user_ids alone is sufficient — no body text needed.""" 119 assert self.adapter._is_bot_mentioned( 120 "please reply", # no @hermes anywhere in body 121 mention_user_ids=["@hermes:example.org"], 122 ) 123 124 def test_m_mentions_user_ids_with_body_mention(self): 125 """Both m.mentions and body mention — should still be True.""" 126 assert self.adapter._is_bot_mentioned( 127 "hey @hermes:example.org help", 128 mention_user_ids=["@hermes:example.org"], 129 ) 130 131 def test_m_mentions_user_ids_other_user_only(self): 132 """m.mentions with a different user — bot is NOT mentioned.""" 133 assert not self.adapter._is_bot_mentioned( 134 "hello", 135 mention_user_ids=["@alice:example.org"], 136 ) 137 138 def test_m_mentions_user_ids_empty_list(self): 139 """Empty user_ids list — falls through to text detection.""" 140 assert not self.adapter._is_bot_mentioned( 141 "hello everyone", 142 mention_user_ids=[], 143 ) 144 145 def test_m_mentions_user_ids_none(self): 146 """None mention_user_ids — falls through to text detection.""" 147 assert not self.adapter._is_bot_mentioned( 148 "hello everyone", 149 mention_user_ids=None, 150 ) 151 152 153 class TestStripMention: 154 def setup_method(self): 155 self.adapter = _make_adapter() 156 157 def test_strip_full_user_id(self): 158 result = self.adapter._strip_mention("@hermes:example.org help me") 159 assert result == "help me" 160 161 def test_localpart_preserved(self): 162 """Bare localpart (no @) is preserved — avoids false positives in paths.""" 163 result = self.adapter._strip_mention("hermes help me") 164 assert result == "hermes help me" 165 166 def test_localpart_in_path_preserved(self): 167 """Localpart inside a file path must not be damaged.""" 168 result = self.adapter._strip_mention("read /home/hermes/config.yaml") 169 assert result == "read /home/hermes/config.yaml" 170 171 def test_strip_localpart_when_explicit_at_mention(self): 172 result = self.adapter._strip_mention("@hermes help me") 173 assert result == "help me" 174 175 def test_does_not_strip_bare_localpart_word(self): 176 # Regression: plain words like "Hermes Agent" should not be mutated. 177 result = self.adapter._strip_mention("Hermes Agent") 178 assert result == "Hermes Agent" 179 180 def test_strip_returns_empty_for_mention_only(self): 181 result = self.adapter._strip_mention("@hermes:example.org") 182 assert result == "" 183 184 185 # --------------------------------------------------------------------------- 186 # Outbound mention payloads 187 # --------------------------------------------------------------------------- 188 189 190 class TestOutboundMentions: 191 def setup_method(self): 192 self.adapter = _make_adapter() 193 self.mock_client = MagicMock() 194 self.mock_client.send_message_event = AsyncMock(return_value="$evt1") 195 self.adapter._client = self.mock_client 196 197 @staticmethod 198 def _sent_content(mock_client): 199 call_args = mock_client.send_message_event.call_args 200 return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"] 201 202 @pytest.mark.asyncio 203 async def test_send_adds_matrix_mentions_and_formatted_body(self): 204 result = await self.adapter.send( 205 "!room1:example.org", 206 "Hello @alice:example.org, please check this.", 207 ) 208 209 assert result.success is True 210 content = self._sent_content(self.mock_client) 211 assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]} 212 assert content["formatted_body"] == ( 213 'Hello <a href="https://matrix.to/#/@alice:example.org">' 214 "@alice:example.org</a>, please check this." 215 ) 216 217 @pytest.mark.asyncio 218 async def test_send_dedupes_mentions_and_ignores_code_spans(self): 219 await self.adapter.send( 220 "!room1:example.org", 221 "Ping @alice:example.org and @alice:example.org, not `@code:example.org`.", 222 ) 223 224 content = self._sent_content(self.mock_client) 225 assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]} 226 assert "@code:example.org</a>" not in content["formatted_body"] 227 228 @pytest.mark.asyncio 229 async def test_edit_message_preserves_mentions(self): 230 result = await self.adapter.edit_message( 231 "!room1:example.org", 232 "$original", 233 "Updated for @alice:example.org", 234 ) 235 236 assert result.success is True 237 content = self._sent_content(self.mock_client) 238 assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]} 239 assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]} 240 assert content["m.new_content"]["formatted_body"] == ( 241 'Updated for <a href="https://matrix.to/#/@alice:example.org">' 242 "@alice:example.org</a>" 243 ) 244 assert content["formatted_body"] == ( 245 '* Updated for <a href="https://matrix.to/#/@alice:example.org">' 246 "@alice:example.org</a>" 247 ) 248 249 @pytest.mark.asyncio 250 async def test_send_simple_notice_adds_mentions(self): 251 result = await self.adapter._send_simple_message( 252 "!room1:example.org", 253 "Heads up @alice:example.org", 254 msgtype="m.notice", 255 ) 256 257 assert result.success is True 258 content = self._sent_content(self.mock_client) 259 assert content["msgtype"] == "m.notice" 260 assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]} 261 262 263 # --------------------------------------------------------------------------- 264 # Require-mention gating in _on_room_message 265 # --------------------------------------------------------------------------- 266 267 268 @pytest.mark.asyncio 269 async def test_require_mention_default_ignores_unmentioned(monkeypatch): 270 """Default (require_mention=true): messages without mention are ignored.""" 271 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 272 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 273 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 274 275 adapter = _make_adapter() 276 event = _make_event("hello everyone") 277 278 await adapter._on_room_message(event) 279 adapter.handle_message.assert_not_awaited() 280 281 282 @pytest.mark.asyncio 283 async def test_require_mention_default_processes_mentioned(monkeypatch): 284 """Default: messages with mention are processed, mention stripped.""" 285 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 286 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 287 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 288 289 adapter = _make_adapter() 290 event = _make_event("@hermes:example.org help me") 291 292 await adapter._on_room_message(event) 293 adapter.handle_message.assert_awaited_once() 294 msg = adapter.handle_message.await_args.args[0] 295 assert msg.text == "help me" 296 297 298 @pytest.mark.asyncio 299 async def test_require_mention_html_pill(monkeypatch): 300 """Bot mentioned via HTML pill should be processed.""" 301 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 302 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 303 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 304 305 adapter = _make_adapter() 306 formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help' 307 event = _make_event("Hermes help", formatted_body=formatted) 308 309 await adapter._on_room_message(event) 310 adapter.handle_message.assert_awaited_once() 311 312 313 @pytest.mark.asyncio 314 async def test_require_mention_m_mentions_user_ids(monkeypatch): 315 """m.mentions.user_ids is authoritative per MSC3952 — no body mention needed. 316 317 Ported from openclaw/openclaw#64796. 318 """ 319 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 320 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 321 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 322 323 adapter = _make_adapter() 324 # Body has NO mention, but m.mentions.user_ids includes the bot. 325 event = _make_event( 326 "please reply", 327 mention_user_ids=["@hermes:example.org"], 328 ) 329 330 await adapter._on_room_message(event) 331 adapter.handle_message.assert_awaited_once() 332 333 334 @pytest.mark.asyncio 335 async def test_require_mention_m_mentions_other_user_ignored(monkeypatch): 336 """m.mentions.user_ids mentioning another user should NOT activate the bot.""" 337 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 338 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 339 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 340 341 adapter = _make_adapter() 342 event = _make_event( 343 "hey alice check this", 344 mention_user_ids=["@alice:example.org"], 345 ) 346 347 await adapter._on_room_message(event) 348 adapter.handle_message.assert_not_awaited() 349 350 351 @pytest.mark.asyncio 352 async def test_require_mention_dm_always_responds(monkeypatch): 353 """DMs always respond regardless of mention setting.""" 354 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 355 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 356 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 357 358 adapter = _make_adapter() 359 # Mark the room as a DM via the adapter's cache. 360 _set_dm(adapter) 361 event = _make_event("hello without mention") 362 363 await adapter._on_room_message(event) 364 adapter.handle_message.assert_awaited_once() 365 366 367 @pytest.mark.asyncio 368 async def test_dm_strips_full_mxid(monkeypatch): 369 """DMs strip the full MXID from body when require_mention is on (default).""" 370 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 371 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 372 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 373 374 adapter = _make_adapter() 375 _set_dm(adapter) 376 event = _make_event("@hermes:example.org help me") 377 378 await adapter._on_room_message(event) 379 adapter.handle_message.assert_awaited_once() 380 msg = adapter.handle_message.await_args.args[0] 381 assert msg.text == "help me" 382 383 384 @pytest.mark.asyncio 385 async def test_dm_preserves_localpart_in_body(monkeypatch): 386 """DMs no longer strip bare localpart — only the full MXID is removed.""" 387 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 388 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 389 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 390 391 adapter = _make_adapter() 392 _set_dm(adapter) 393 event = _make_event("hermes help me") 394 395 await adapter._on_room_message(event) 396 adapter.handle_message.assert_awaited_once() 397 msg = adapter.handle_message.await_args.args[0] 398 assert msg.text == "hermes help me" 399 400 401 @pytest.mark.asyncio 402 async def test_bare_mention_passes_empty_string(monkeypatch): 403 """A message that is only a mention should pass through as empty, not be dropped.""" 404 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 405 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 406 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 407 408 adapter = _make_adapter() 409 event = _make_event("@hermes:example.org") 410 411 await adapter._on_room_message(event) 412 adapter.handle_message.assert_awaited_once() 413 msg = adapter.handle_message.await_args.args[0] 414 assert msg.text == "" 415 416 417 @pytest.mark.asyncio 418 async def test_require_mention_free_response_room(monkeypatch): 419 """Free-response rooms bypass mention requirement.""" 420 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 421 monkeypatch.setenv( 422 "MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org" 423 ) 424 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 425 426 adapter = _make_adapter() 427 event = _make_event("hello without mention", room_id="!room1:example.org") 428 429 await adapter._on_room_message(event) 430 adapter.handle_message.assert_awaited_once() 431 432 433 @pytest.mark.asyncio 434 async def test_require_mention_bot_participated_thread(monkeypatch): 435 """Threads with prior bot participation bypass mention requirement.""" 436 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 437 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 438 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 439 440 adapter = _make_adapter() 441 adapter._threads.mark("$thread1") 442 443 event = _make_event("hello without mention", thread_id="$thread1") 444 445 await adapter._on_room_message(event) 446 adapter.handle_message.assert_awaited_once() 447 448 449 @pytest.mark.asyncio 450 async def test_require_mention_disabled(monkeypatch): 451 """MATRIX_REQUIRE_MENTION=false: all messages processed.""" 452 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 453 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 454 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 455 456 adapter = _make_adapter() 457 event = _make_event("hello without mention") 458 459 await adapter._on_room_message(event) 460 adapter.handle_message.assert_awaited_once() 461 msg = adapter.handle_message.await_args.args[0] 462 assert msg.text == "hello without mention" 463 464 465 @pytest.mark.asyncio 466 async def test_require_mention_disabled_skips_stripping(monkeypatch): 467 """MATRIX_REQUIRE_MENTION=false: mention text is NOT stripped from body.""" 468 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 469 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 470 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 471 472 adapter = _make_adapter() 473 event = _make_event("@hermes:example.org help me") 474 475 await adapter._on_room_message(event) 476 adapter.handle_message.assert_awaited_once() 477 msg = adapter.handle_message.await_args.args[0] 478 assert msg.text == "@hermes:example.org help me" 479 480 481 # --------------------------------------------------------------------------- 482 # Auto-thread in _on_room_message 483 # --------------------------------------------------------------------------- 484 485 486 @pytest.mark.asyncio 487 async def test_auto_thread_default_creates_thread(monkeypatch): 488 """Default (auto_thread=true): sets thread_id to event.event_id.""" 489 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 490 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 491 492 adapter = _make_adapter() 493 event = _make_event("hello", event_id="$msg1") 494 495 await adapter._on_room_message(event) 496 adapter.handle_message.assert_awaited_once() 497 msg = adapter.handle_message.await_args.args[0] 498 assert msg.source.thread_id == "$msg1" 499 500 501 @pytest.mark.asyncio 502 async def test_auto_thread_preserves_existing_thread(monkeypatch): 503 """If message is already in a thread, thread_id is not overridden.""" 504 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 505 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 506 507 adapter = _make_adapter() 508 adapter._threads.mark("$thread_root") 509 event = _make_event("reply in thread", thread_id="$thread_root") 510 511 await adapter._on_room_message(event) 512 adapter.handle_message.assert_awaited_once() 513 msg = adapter.handle_message.await_args.args[0] 514 assert msg.source.thread_id == "$thread_root" 515 516 517 @pytest.mark.asyncio 518 async def test_auto_thread_skips_dm(monkeypatch): 519 """DMs should not get auto-threaded.""" 520 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 521 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 522 523 adapter = _make_adapter() 524 _set_dm(adapter) 525 event = _make_event("hello dm", event_id="$dm1") 526 527 await adapter._on_room_message(event) 528 adapter.handle_message.assert_awaited_once() 529 msg = adapter.handle_message.await_args.args[0] 530 assert msg.source.thread_id is None 531 532 533 @pytest.mark.asyncio 534 async def test_auto_thread_disabled(monkeypatch): 535 """MATRIX_AUTO_THREAD=false: thread_id stays None.""" 536 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 537 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 538 539 adapter = _make_adapter() 540 event = _make_event("hello", event_id="$msg1") 541 542 await adapter._on_room_message(event) 543 adapter.handle_message.assert_awaited_once() 544 msg = adapter.handle_message.await_args.args[0] 545 assert msg.source.thread_id is None 546 547 548 @pytest.mark.asyncio 549 async def test_auto_thread_tracks_participation(monkeypatch): 550 """Auto-created threads are tracked in _threads.""" 551 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") 552 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 553 554 adapter = _make_adapter() 555 event = _make_event("hello", event_id="$msg1") 556 557 with patch.object(adapter._threads, "_save"): 558 await adapter._on_room_message(event) 559 560 assert "$msg1" in adapter._threads 561 562 563 # --------------------------------------------------------------------------- 564 # Thread persistence 565 # --------------------------------------------------------------------------- 566 567 568 class TestThreadPersistence: 569 def test_empty_state_file(self, tmp_path, monkeypatch): 570 """No state file → empty set.""" 571 from gateway.platforms.helpers import ThreadParticipationTracker 572 573 monkeypatch.setattr( 574 ThreadParticipationTracker, 575 "_state_path", 576 lambda self: tmp_path / "matrix_threads.json", 577 ) 578 adapter = _make_adapter() 579 assert "$nonexistent" not in adapter._threads 580 581 def test_track_thread_persists(self, tmp_path, monkeypatch): 582 """mark() writes to disk.""" 583 from gateway.platforms.helpers import ThreadParticipationTracker 584 585 state_path = tmp_path / "matrix_threads.json" 586 monkeypatch.setattr( 587 ThreadParticipationTracker, 588 "_state_path", 589 lambda self: state_path, 590 ) 591 adapter = _make_adapter() 592 adapter._threads.mark("$thread_abc") 593 594 data = json.loads(state_path.read_text()) 595 assert "$thread_abc" in data 596 597 def test_threads_survive_reload(self, tmp_path, monkeypatch): 598 """Persisted threads are loaded by a new adapter instance.""" 599 from gateway.platforms.helpers import ThreadParticipationTracker 600 601 state_path = tmp_path / "matrix_threads.json" 602 state_path.write_text(json.dumps(["$t1", "$t2"])) 603 monkeypatch.setattr( 604 ThreadParticipationTracker, 605 "_state_path", 606 lambda self: state_path, 607 ) 608 adapter = _make_adapter() 609 assert "$t1" in adapter._threads 610 assert "$t2" in adapter._threads 611 612 def test_cap_max_tracked_threads(self, tmp_path, monkeypatch): 613 """Thread set is trimmed to max_tracked.""" 614 from gateway.platforms.helpers import ThreadParticipationTracker 615 616 state_path = tmp_path / "matrix_threads.json" 617 monkeypatch.setattr( 618 ThreadParticipationTracker, 619 "_state_path", 620 lambda self: state_path, 621 ) 622 adapter = _make_adapter() 623 adapter._threads._max_tracked = 5 624 625 for i in range(10): 626 adapter._threads.mark(f"$t{i}") 627 628 data = json.loads(state_path.read_text()) 629 assert len(data) == 5 630 631 632 # --------------------------------------------------------------------------- 633 # DM mention-thread feature 634 # --------------------------------------------------------------------------- 635 636 637 @pytest.mark.asyncio 638 async def test_dm_mention_thread_disabled_by_default(monkeypatch): 639 """Default (dm_mention_threads=false): DM with mention should NOT create a thread.""" 640 monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False) 641 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 642 643 adapter = _make_adapter() 644 _set_dm(adapter) 645 event = _make_event("@hermes:example.org help me", event_id="$dm1") 646 647 await adapter._on_room_message(event) 648 adapter.handle_message.assert_awaited_once() 649 msg = adapter.handle_message.await_args.args[0] 650 assert msg.source.thread_id is None 651 652 653 @pytest.mark.asyncio 654 async def test_dm_mention_thread_creates_thread(monkeypatch): 655 """MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread.""" 656 monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") 657 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 658 659 adapter = _make_adapter() 660 _set_dm(adapter) 661 event = _make_event("@hermes:example.org help me", event_id="$dm1") 662 663 with patch.object(adapter._threads, "_save"): 664 await adapter._on_room_message(event) 665 666 adapter.handle_message.assert_awaited_once() 667 msg = adapter.handle_message.await_args.args[0] 668 assert msg.source.thread_id == "$dm1" 669 assert msg.text == "help me" 670 671 672 @pytest.mark.asyncio 673 async def test_dm_mention_thread_no_mention_no_thread(monkeypatch): 674 """MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread.""" 675 monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") 676 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 677 678 adapter = _make_adapter() 679 _set_dm(adapter) 680 event = _make_event("hello without mention", event_id="$dm1") 681 682 await adapter._on_room_message(event) 683 adapter.handle_message.assert_awaited_once() 684 msg = adapter.handle_message.await_args.args[0] 685 assert msg.source.thread_id is None 686 687 688 @pytest.mark.asyncio 689 async def test_dm_mention_thread_preserves_existing_thread(monkeypatch): 690 """MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id.""" 691 monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") 692 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 693 694 adapter = _make_adapter() 695 _set_dm(adapter) 696 adapter._threads.mark("$existing_thread") 697 event = _make_event("@hermes:example.org help me", thread_id="$existing_thread") 698 699 await adapter._on_room_message(event) 700 adapter.handle_message.assert_awaited_once() 701 msg = adapter.handle_message.await_args.args[0] 702 assert msg.source.thread_id == "$existing_thread" 703 704 705 @pytest.mark.asyncio 706 async def test_dm_mention_thread_tracks_participation(monkeypatch): 707 """DM mention-thread tracks the thread in _threads.""" 708 monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true") 709 monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") 710 711 adapter = _make_adapter() 712 _set_dm(adapter) 713 event = _make_event("@hermes:example.org help", event_id="$dm1") 714 715 with patch.object(adapter._threads, "_save"): 716 await adapter._on_room_message(event) 717 718 assert "$dm1" in adapter._threads 719 720 721 # --------------------------------------------------------------------------- 722 # YAML config bridge 723 # --------------------------------------------------------------------------- 724 725 726 class TestMatrixConfigBridge: 727 def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path): 728 """Matrix YAML config should bridge to env vars.""" 729 monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) 730 monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) 731 monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) 732 733 yaml_content = { 734 "matrix": { 735 "require_mention": False, 736 "free_response_rooms": ["!room1:example.org", "!room2:example.org"], 737 "auto_thread": False, 738 } 739 } 740 741 import os 742 743 import yaml 744 745 config_file = tmp_path / "config.yaml" 746 config_file.write_text(yaml.dump(yaml_content)) 747 748 # Simulate the bridge logic from gateway/config.py 749 yaml_cfg = yaml.safe_load(config_file.read_text()) 750 matrix_cfg = yaml_cfg.get("matrix", {}) 751 if isinstance(matrix_cfg, dict): 752 if "require_mention" in matrix_cfg and not os.getenv( 753 "MATRIX_REQUIRE_MENTION" 754 ): 755 monkeypatch.setenv( 756 "MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower() 757 ) 758 frc = matrix_cfg.get("free_response_rooms") 759 if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): 760 if isinstance(frc, list): 761 frc = ",".join(str(v) for v in frc) 762 monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc)) 763 if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): 764 monkeypatch.setenv( 765 "MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower() 766 ) 767 768 assert os.getenv("MATRIX_REQUIRE_MENTION") == "false" 769 assert ( 770 os.getenv("MATRIX_FREE_RESPONSE_ROOMS") 771 == "!room1:example.org,!room2:example.org" 772 ) 773 assert os.getenv("MATRIX_AUTO_THREAD") == "false" 774 775 def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path): 776 """Matrix YAML dm_mention_threads should bridge to env var.""" 777 monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False) 778 779 import os 780 781 import yaml 782 783 yaml_content = {"matrix": {"dm_mention_threads": True}} 784 config_file = tmp_path / "config.yaml" 785 config_file.write_text(yaml.dump(yaml_content)) 786 787 yaml_cfg = yaml.safe_load(config_file.read_text()) 788 matrix_cfg = yaml_cfg.get("matrix", {}) 789 if isinstance(matrix_cfg, dict): 790 if "dm_mention_threads" in matrix_cfg and not os.getenv( 791 "MATRIX_DM_MENTION_THREADS" 792 ): 793 monkeypatch.setenv( 794 "MATRIX_DM_MENTION_THREADS", 795 str(matrix_cfg["dm_mention_threads"]).lower(), 796 ) 797 798 assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true" 799 800 def test_env_vars_take_precedence_over_yaml(self, monkeypatch): 801 """Env vars should not be overwritten by YAML values.""" 802 monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true") 803 804 import os 805 806 yaml_cfg = {"matrix": {"require_mention": False}} 807 matrix_cfg = yaml_cfg.get("matrix", {}) 808 if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): 809 monkeypatch.setenv( 810 "MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower() 811 ) 812 813 assert os.getenv("MATRIX_REQUIRE_MENTION") == "true"