# ruff: noqa: E402 import asyncio import sys from pathlib import Path from types import SimpleNamespace from urllib.parse import unquote import pytest pytest.importorskip("nio") pytest.importorskip("nh3") pytest.importorskip("mistune") from nio import JoinResponse, RoomSendResponse, SyncError import nanobot.channels.matrix.runtime as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus from nanobot.channels.matrix.runtime import ( MATRIX_HTML_FORMAT, TYPING_NOTICE_TIMEOUT_MS, MatrixChannel, MatrixConfig, _build_matrix_text_content, ) _ROOM_SEND_UNSET = object() def test_default_e2ee_matches_platform_support() -> None: assert MatrixConfig().e2ee_enabled is (sys.platform != "win32") class _DummyTask: def __init__(self) -> None: self.cancelled = False def cancel(self) -> None: self.cancelled = True def __await__(self): async def _done(): return None return _done().__await__() class _FakeAsyncClient: def __init__(self, homeserver, user, store_path, config) -> None: self.homeserver = homeserver self.user = user self.store_path = store_path self.config = config self.user_id: str | None = None self.access_token: str | None = None self.device_id: str | None = None self.load_store_called = False self.stop_sync_forever_called = False self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.to_device_callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] self.key_verifications: dict[str, object] = {} self.operation_calls: list[str] = [] self.accept_key_verification_calls: list[str] = [] self.confirm_short_auth_string_calls: list[str] = [] self.send_to_device_messages_calls = 0 self.to_device_calls: list[object] = [] self.accept_key_verification_response: object | None = None self.confirm_short_auth_string_response: object | None = None self.send_to_device_messages_response: list[object] = [] self.to_device_response: object | None = None self.rooms: dict[str, object] = {} self.room_send_calls: list[dict[str, object]] = [] self.typing_calls: list[tuple[str, bool, int]] = [] self.download_calls: list[dict[str, object]] = [] self.upload_calls: list[dict[str, object]] = [] self.download_response: object | None = None self.download_bytes: bytes = b"media" self.download_content_type: str = "application/octet-stream" self.download_filename: str | None = None self.upload_response: object | None = None self.content_repository_config_response: object = SimpleNamespace(upload_size=None) self.raise_on_send = False self.raise_on_typing = False self.raise_on_upload = False self.room_send_response: RoomSendResponse | None = RoomSendResponse(event_id="", room_id="") def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) def add_to_device_callback(self, callback, event_type) -> None: self.to_device_callbacks.append((callback, event_type)) def add_response_callback(self, callback, response_type) -> None: self.response_callbacks.append((callback, response_type)) def load_store(self) -> None: self.load_store_called = True def stop_sync_forever(self) -> None: self.stop_sync_forever_called = True async def join(self, room_id: str) -> None: self.join_calls.append(room_id) async def _send(self, response_class, method, path, data=None, **kwargs): """Minimal mock for nio's ``_send`` used by ``_join_room_safe``.""" if response_class is JoinResponse and method == "POST" and "/join/" in path: encoded = path.split("/join/")[1].split("?")[0] room_id = unquote(encoded) self.join_calls.append(room_id) return JoinResponse(room_id=room_id) return response_class() async def accept_key_verification(self, transaction_id: str): self.operation_calls.append(f"accept:{transaction_id}") self.accept_key_verification_calls.append(transaction_id) return self.accept_key_verification_response async def confirm_short_auth_string(self, transaction_id: str): self.operation_calls.append(f"confirm:{transaction_id}") self.confirm_short_auth_string_calls.append(transaction_id) return self.confirm_short_auth_string_response async def send_to_device_messages(self): self.operation_calls.append("send_pending") self.send_to_device_messages_calls += 1 return self.send_to_device_messages_response async def to_device(self, message): self.operation_calls.append("to_device") self.to_device_calls.append(message) return self.to_device_response async def room_send( self, room_id: str, message_type: str, content: dict[str, object], ignore_unverified_devices: object = _ROOM_SEND_UNSET, ) -> RoomSendResponse: call: dict[str, object] = { "room_id": room_id, "message_type": message_type, "content": content, } if ignore_unverified_devices is not _ROOM_SEND_UNSET: call["ignore_unverified_devices"] = ignore_unverified_devices self.room_send_calls.append(call) if self.raise_on_send: raise RuntimeError("send failed") return self.room_send_response async def room_typing( self, room_id: str, typing_state: bool = True, timeout: int = 30_000, ) -> None: self.typing_calls.append((room_id, typing_state, timeout)) if self.raise_on_typing: raise RuntimeError("typing failed") async def download(self, **kwargs): self.download_calls.append(kwargs) if self.download_response is not None: return self.download_response return matrix_module.MemoryDownloadResponse( body=self.download_bytes, content_type=self.download_content_type, filename=self.download_filename, ) async def upload( self, data_provider, content_type: str | None = None, filename: str | None = None, filesize: int | None = None, encrypt: bool = False, ): if self.raise_on_upload: raise RuntimeError("upload failed") if isinstance(data_provider, (bytes, bytearray)): raise TypeError( f"data_provider type {type(data_provider)!r} is not of a usable type " "(Callable, IOBase)" ) self.upload_calls.append( { "data_provider": data_provider, "content_type": content_type, "filename": filename, "filesize": filesize, "encrypt": encrypt, } ) if self.upload_response is not None: return self.upload_response if encrypt: return ( SimpleNamespace(content_uri="mxc://example.org/uploaded"), { "v": "v2", "iv": "iv", "hashes": {"sha256": "hash"}, "key": {"alg": "A256CTR", "k": "key"}, }, ) return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None async def content_repository_config(self): return self.content_repository_config_response async def close(self) -> None: return None class _FakeSas: def __init__(self, *, verified: bool = False) -> None: self.share_key_called = False self.get_mac_called = False self.verified = verified def share_key(self): self.share_key_called = True return {"type": "share_key"} def get_mac(self): self.get_mac_called = True return {"type": "mac"} class _FakeKeyVerificationStart: def __init__( self, *, sender: str = "@alice:matrix.org", transaction_id: str = "tx1", short_authentication_string: list[str] | None = None, ) -> None: self.sender = sender self.transaction_id = transaction_id self.short_authentication_string = short_authentication_string or ["emoji"] class _FakeKeyVerificationKey: def __init__( self, *, sender: str = "@alice:matrix.org", transaction_id: str = "tx1", ) -> None: self.sender = sender self.transaction_id = transaction_id class _FakeKeyVerificationMac: def __init__( self, *, sender: str = "@alice:matrix.org", transaction_id: str = "tx1", ) -> None: self.sender = sender self.transaction_id = transaction_id def _patch_key_verification_events(monkeypatch) -> None: monkeypatch.setattr(matrix_module, "KeyVerificationStart", _FakeKeyVerificationStart) monkeypatch.setattr(matrix_module, "KeyVerificationKey", _FakeKeyVerificationKey) monkeypatch.setattr(matrix_module, "KeyVerificationMac", _FakeKeyVerificationMac) def _make_config(**kwargs) -> MatrixConfig: kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", access_token="token", user_id="@bot:matrix.org", **kwargs, ) @pytest.mark.asyncio async def test_start_skips_load_store_when_device_id_missing( monkeypatch, tmp_path ) -> None: clients: list[_FakeAsyncClient] = [] def _fake_client(*args, **kwargs) -> _FakeAsyncClient: client = _FakeAsyncClient(*args, **kwargs) clients.append(client) return client def _fake_create_task(coro): coro.close() return _DummyTask() monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( "nanobot.channels.matrix.runtime.AsyncClientConfig", lambda **kwargs: SimpleNamespace(**kwargs), ) monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client) monkeypatch.setattr( "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task ) channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus()) await channel.start() assert len(clients) == 1 assert clients[0].config.encryption_enabled is True assert clients[0].load_store_called is False assert len(clients[0].callbacks) == 3 assert clients[0].to_device_callbacks == [] assert len(clients[0].response_callbacks) == 4 await channel.stop() @pytest.mark.asyncio async def test_register_event_callbacks_uses_media_base_filter() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._register_event_callbacks() assert len(client.callbacks) == 3 assert client.callbacks[1][0] == channel._on_media_message assert client.callbacks[1][1] == matrix_module.MATRIX_MEDIA_EVENT_FILTER def test_register_to_device_callbacks_when_sas_verification_enabled() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=True, sas_verification=True), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._register_to_device_callbacks() assert client.to_device_callbacks == [ (channel._on_key_verification_event, (matrix_module.KeyVerificationEvent,)) ] def test_register_to_device_callbacks_skips_when_e2ee_disabled() -> None: channel = MatrixChannel( _make_config(e2ee_enabled=False, sas_verification=True), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._register_to_device_callbacks() assert client.to_device_callbacks == [] @pytest.mark.asyncio async def test_sas_verification_start_accepts_allowed_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( _make_config( allow_from=["@alice:matrix.org"], e2ee_enabled=True, sas_verification=True, ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) sas = _FakeSas() client.key_verifications["tx1"] = sas channel.client = client await channel._handle_key_verification_event(_FakeKeyVerificationStart()) assert client.accept_key_verification_calls == ["tx1"] assert sas.share_key_called is False assert client.to_device_calls == [] @pytest.mark.asyncio async def test_sas_verification_ignores_denied_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( _make_config( allow_from=["@alice:matrix.org"], e2ee_enabled=True, sas_verification=True, ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) client.key_verifications["tx1"] = _FakeSas() channel.client = client await channel._handle_key_verification_event( _FakeKeyVerificationStart(sender="@mallory:matrix.org") ) assert client.accept_key_verification_calls == [] assert client.to_device_calls == [] @pytest.mark.asyncio async def test_sas_verification_ignores_when_disabled(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( _make_config(allow_from=["@alice:matrix.org"], sas_verification=False), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) client.key_verifications["tx1"] = _FakeSas() channel.client = client await channel._handle_key_verification_event(_FakeKeyVerificationStart()) assert client.accept_key_verification_calls == [] assert client.to_device_calls == [] @pytest.mark.asyncio async def test_sas_verification_key_confirms_allowed_sender(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( _make_config( allow_from=["@alice:matrix.org"], e2ee_enabled=True, sas_verification=True, ), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel._handle_key_verification_event(_FakeKeyVerificationKey()) assert client.send_to_device_messages_calls == 1 assert client.confirm_short_auth_string_calls == ["tx1"] assert client.operation_calls == ["send_pending", "confirm:tx1"] @pytest.mark.asyncio async def test_sas_verification_mac_does_not_resend_mac(monkeypatch) -> None: _patch_key_verification_events(monkeypatch) channel = MatrixChannel( _make_config(allow_from=["@alice:matrix.org"], sas_verification=True), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) sas = _FakeSas(verified=True) client.key_verifications["tx1"] = sas channel.client = client await channel._handle_key_verification_event(_FakeKeyVerificationMac()) assert sas.get_mac_called is False assert client.to_device_calls == [] def test_media_event_filter_does_not_match_text_events() -> None: assert not issubclass(matrix_module.RoomMessageText, matrix_module.MATRIX_MEDIA_EVENT_FILTER) @pytest.mark.asyncio async def test_start_disables_e2ee_when_configured( monkeypatch, tmp_path ) -> None: clients: list[_FakeAsyncClient] = [] def _fake_client(*args, **kwargs) -> _FakeAsyncClient: client = _FakeAsyncClient(*args, **kwargs) clients.append(client) return client def _fake_create_task(coro): coro.close() return _DummyTask() monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( "nanobot.channels.matrix.runtime.AsyncClientConfig", lambda **kwargs: SimpleNamespace(**kwargs), ) monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client) monkeypatch.setattr( "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task ) channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=False), MessageBus()) await channel.start() assert len(clients) == 1 assert clients[0].config.encryption_enabled is False await channel.stop() @pytest.mark.asyncio async def test_on_sync_error_stops_loop_on_unknown_token() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._running = True await channel._on_sync_error(SyncError(message="bad", status_code="M_UNKNOWN_TOKEN")) assert channel._running is False assert client.stop_sync_forever_called is True @pytest.mark.asyncio async def test_on_sync_error_keeps_running_on_transient_error() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._running = True await channel._on_sync_error(SyncError(message="oops", status_code="M_LIMIT_EXCEEDED")) assert channel._running is True assert client.stop_sync_forever_called is False @pytest.mark.asyncio async def test_sync_loop_backs_off_on_repeated_errors(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) sleeps: list[float] = [] async def _fake_sleep(delay: float) -> None: sleeps.append(delay) monkeypatch.setattr(matrix_module.asyncio, "sleep", _fake_sleep) call_count = {"n": 0} class _BoomClient: async def sync_forever(self, **_kwargs) -> None: call_count["n"] += 1 if call_count["n"] > 4: channel._running = False return raise RuntimeError("boom") channel.client = _BoomClient() channel._running = True await channel._sync_loop() assert sleeps == [2.0, 4.0, 8.0, 16.0] @pytest.mark.asyncio async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus()) client = _FakeAsyncClient("", "", "", None) task = _DummyTask() channel.client = client channel._sync_task = task channel._running = True await channel.stop() assert channel._running is False assert client.stop_sync_forever_called is True assert task.cancelled is False @pytest.mark.asyncio async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client room = SimpleNamespace(room_id="!room:matrix.org") event = SimpleNamespace(sender="@alice:matrix.org") await channel._on_room_invite(room, event) assert client.join_calls == [] @pytest.mark.asyncio async def test_room_invite_joins_when_sender_allowed() -> None: channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client room = SimpleNamespace(room_id="!room:matrix.org") event = SimpleNamespace(sender="@alice:matrix.org") await channel._on_room_invite(room, event) assert client.join_calls == ["!room:matrix.org"] @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client room = SimpleNamespace(room_id="!room:matrix.org") event = SimpleNamespace(sender="@alice:matrix.org") await channel._on_room_invite(room, event) assert client.join_calls == [] @pytest.mark.asyncio async def test_on_sync_invite_fallback_joins_pending_invites() -> None: """_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders.""" channel = MatrixChannel( _make_config(allow_from=["@alice:matrix.org"]), MessageBus() ) client = _FakeAsyncClient("", "", "", None) channel.client = client invite_event = SimpleNamespace(sender="@alice:matrix.org") invite_info = SimpleNamespace(invite_state=[invite_event]) rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info}) response = SimpleNamespace(rooms=rooms) await channel._on_sync_invite_fallback(response) assert client.join_calls == ["!room:matrix.org"] @pytest.mark.asyncio async def test_on_sync_invite_fallback_skips_when_no_invites() -> None: """_on_sync_invite_fallback is a no-op when sync has no invites.""" channel = MatrixChannel( _make_config(allow_from=["@alice:matrix.org"]), MessageBus() ) client = _FakeAsyncClient("", "", "", None) channel.client = client rooms = SimpleNamespace(invite={}) response = SimpleNamespace(rooms=rooms) await channel._on_sync_invite_fallback(response) assert client.join_calls == [] @pytest.mark.asyncio async def test_on_sync_invite_fallback_skips_denied_sender() -> None: """_on_sync_invite_fallback respects the allow list.""" channel = MatrixChannel( _make_config(allow_from=["@bob:matrix.org"]), MessageBus() ) client = _FakeAsyncClient("", "", "", None) channel.client = client invite_event = SimpleNamespace(sender="@alice:matrix.org") invite_info = SimpleNamespace(invite_state=[invite_event]) rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info}) response = SimpleNamespace(rooms=rooms) await channel._on_sync_invite_fallback(response) assert client.join_calls == [] @pytest.mark.asyncio async def test_on_message_sets_typing_for_allowed_sender() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={}) await channel._on_message(room, event) assert handled == ["@alice:matrix.org"] assert client.typing_calls == [ ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS), ] @pytest.mark.asyncio async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._running = True monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_MS", 10) await channel._start_typing_keepalive("!room:matrix.org") await asyncio.sleep(0.03) await channel._stop_typing_keepalive("!room:matrix.org", clear_typing=True) true_updates = [call for call in client.typing_calls if call[1] is True] assert len(true_updates) >= 2 assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @pytest.mark.asyncio async def test_on_message_skips_typing_for_self_message() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") event = SimpleNamespace(sender="@bot:matrix.org", body="Hello", source={}) await channel._on_message(room, event) assert client.typing_calls == [] @pytest.mark.asyncio async def test_on_message_skips_pre_startup_event() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._started_at_ms = 1_000_000 handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") old_event = SimpleNamespace( sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999 ) fresh_event = SimpleNamespace( sender="@alice:matrix.org", body="fresh", source={}, server_timestamp=1_000_001 ) await channel._on_message(room, old_event) await channel._on_message(room, fresh_event) assert handled == ["@alice:matrix.org"] assert client.typing_calls == [ ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS), ] @pytest.mark.asyncio async def test_on_media_message_skips_pre_startup_event() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._started_at_ms = 1_000_000 handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") old_event = SimpleNamespace( sender="@alice:matrix.org", body="old", source={}, server_timestamp=999_999 ) await channel._on_media_message(room, old_event) assert handled == [] assert client.typing_calls == [] @pytest.mark.asyncio async def test_on_message_skips_typing_for_denied_sender() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={}) await channel._on_message(room, event) assert handled == [] assert client.typing_calls == [] @pytest.mark.asyncio async def test_on_message_mention_policy_requires_mx_mentions() -> None: channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) await channel._on_message(room, event) assert handled == [] assert client.typing_calls == [] @pytest.mark.asyncio async def test_on_message_mention_policy_accepts_bot_user_mentions() -> None: channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) event = SimpleNamespace( sender="@alice:matrix.org", body="Hello", source={"content": {"m.mentions": {"user_ids": ["@bot:matrix.org"]}}}, ) await channel._on_message(room, event) assert handled == ["@alice:matrix.org"] assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] @pytest.mark.asyncio async def test_on_message_mention_policy_allows_direct_room_without_mentions() -> None: channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!dm:matrix.org", display_name="DM", member_count=2) event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) await channel._on_message(room, event) assert handled == ["@alice:matrix.org"] assert client.typing_calls == [("!dm:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] @pytest.mark.asyncio async def test_on_message_allowlist_policy_requires_room_id() -> None: channel = MatrixChannel( _make_config(group_policy="allowlist", group_allow_from=["!allowed:matrix.org"]), MessageBus(), ) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["chat_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] denied_room = SimpleNamespace(room_id="!denied:matrix.org", display_name="Denied", member_count=3) event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) await channel._on_message(denied_room, event) allowed_room = SimpleNamespace( room_id="!allowed:matrix.org", display_name="Allowed", member_count=3, ) await channel._on_message(allowed_room, event) assert handled == ["!allowed:matrix.org"] assert client.typing_calls == [("!allowed:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] @pytest.mark.asyncio async def test_on_message_room_mention_requires_opt_in() -> None: channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[str] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs["sender_id"]) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) room_mention_event = SimpleNamespace( sender="@alice:matrix.org", body="Hello everyone", source={"content": {"m.mentions": {"room": True}}}, ) channel.config.allow_room_mentions = False await channel._on_message(room, room_mention_event) assert handled == [] assert client.typing_calls == [] channel.config.allow_room_mentions = True await channel._on_message(room, room_mention_event) assert handled == ["@alice:matrix.org"] assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] @pytest.mark.asyncio async def test_on_message_sets_thread_metadata_when_threaded_event() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) event = SimpleNamespace( sender="@alice:matrix.org", body="Hello", event_id="$reply1", source={ "content": { "m.relates_to": { "rel_type": "m.thread", "event_id": "$root1", } } }, ) await channel._on_message(room, event) assert len(handled) == 1 metadata = handled[0]["metadata"] assert metadata["thread_root_event_id"] == "$root1" assert metadata["thread_reply_to_event_id"] == "$reply1" assert metadata["event_id"] == "$reply1" @pytest.mark.asyncio async def test_on_media_message_downloads_attachment_and_sets_metadata( monkeypatch, tmp_path ) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.download_bytes = b"image" channel.client = client async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes: client.download_calls.append(mxc_url) assert limit_bytes >= len(client.download_bytes) return client.download_bytes monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes) handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="photo.png", url="mxc://example.org/mediaid", event_id="$event1", source={ "content": { "msgtype": "m.image", "info": {"mimetype": "image/png", "size": 5}, } }, ) await channel._on_media_message(room, event) assert len(client.download_calls) == 1 assert len(handled) == 1 assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] media_paths = handled[0]["media"] assert isinstance(media_paths, list) and len(media_paths) == 1 media_path = Path(media_paths[0]) assert media_path.is_file() assert media_path.read_bytes() == b"image" metadata = handled[0]["metadata"] attachments = metadata["attachments"] assert isinstance(attachments, list) and len(attachments) == 1 assert attachments[0]["type"] == "image" assert attachments[0]["mxc_url"] == "mxc://example.org/mediaid" assert attachments[0]["path"] == str(media_path) assert "[attachment: " in handled[0]["content"] @pytest.mark.asyncio async def test_on_media_message_sets_thread_metadata_when_threaded_event( monkeypatch, tmp_path ) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.download_bytes = b"image" channel.client = client handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="photo.png", url="mxc://example.org/mediaid", event_id="$event1", source={ "content": { "msgtype": "m.image", "info": {"mimetype": "image/png", "size": 5}, "m.relates_to": { "rel_type": "m.thread", "event_id": "$root1", }, } }, ) await channel._on_media_message(room, event) assert len(handled) == 1 metadata = handled[0]["metadata"] assert metadata["thread_root_event_id"] == "$root1" assert metadata["thread_reply_to_event_id"] == "$event1" assert metadata["event_id"] == "$event1" @pytest.mark.asyncio async def test_on_media_message_respects_declared_size_limit( monkeypatch, tmp_path ) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="large.bin", url="mxc://example.org/large", event_id="$event2", source={"content": {"msgtype": "m.file", "info": {"size": 10}}}, ) await channel._on_media_message(room, event) assert client.download_calls == [] assert len(handled) == 1 assert handled[0]["media"] == [] assert handled[0]["metadata"]["attachments"] == [] assert "[attachment: large.bin - too large]" in handled[0]["content"] @pytest.mark.asyncio async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit( monkeypatch, tmp_path ) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.content_repository_config_response = SimpleNamespace(upload_size=3) channel.client = client handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="large.bin", url="mxc://example.org/large", event_id="$event2_server", source={"content": {"msgtype": "m.file", "info": {"size": 5}}}, ) await channel._on_media_message(room, event) assert client.download_calls == [] assert len(handled) == 1 assert handled[0]["media"] == [] assert handled[0]["metadata"]["attachments"] == [] assert "[attachment: large.bin - too large]" in handled[0]["content"] @pytest.mark.asyncio async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client async def _download_media_bytes(mxc_url: str, _limit_bytes: int): client.download_calls.append(mxc_url) return None monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes) handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="photo.png", url="mxc://example.org/mediaid", event_id="$event3", source={"content": {"msgtype": "m.image", "info": {"size": 5}}}, ) await channel._on_media_message(room, event) assert len(client.download_calls) == 1 assert len(handled) == 1 assert handled[0]["media"] == [] assert handled[0]["metadata"]["attachments"] == [] assert "[attachment: photo.png - download failed]" in handled[0]["content"] @pytest.mark.asyncio async def test_on_media_message_decrypts_encrypted_media(monkeypatch, tmp_path) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( matrix_module, "decrypt_attachment", lambda ciphertext, key, sha256, iv: b"plain", ) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.download_bytes = b"cipher" channel.client = client async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes: client.download_calls.append(mxc_url) assert limit_bytes >= len(client.download_bytes) return client.download_bytes monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes) handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="secret.txt", url="mxc://example.org/encrypted", event_id="$event4", key={"k": "key"}, hashes={"sha256": "hash"}, iv="iv", source={"content": {"msgtype": "m.file", "info": {"size": 6}}}, ) await channel._on_media_message(room, event) assert len(handled) == 1 media_path = Path(handled[0]["media"][0]) assert media_path.read_bytes() == b"plain" attachment = handled[0]["metadata"]["attachments"][0] assert attachment["encrypted"] is True assert attachment["size_bytes"] == 5 @pytest.mark.asyncio async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) -> None: monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) def _raise(*args, **kwargs): raise matrix_module.EncryptionError("boom") monkeypatch.setattr(matrix_module, "decrypt_attachment", _raise) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.download_bytes = b"cipher" channel.client = client async def _download_media_bytes(mxc_url: str, limit_bytes: int) -> bytes: client.download_calls.append(mxc_url) assert limit_bytes >= len(client.download_bytes) return client.download_bytes monkeypatch.setattr(channel, "_download_media_bytes", _download_media_bytes) handled: list[dict[str, object]] = [] async def _fake_handle_message(**kwargs) -> None: handled.append(kwargs) channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) event = SimpleNamespace( sender="@alice:matrix.org", body="secret.txt", url="mxc://example.org/encrypted", event_id="$event5", key={"k": "key"}, hashes={"sha256": "hash"}, iv="iv", source={"content": {"msgtype": "m.file", "info": {"size": 6}}}, ) await channel._on_media_message(room, event) assert len(handled) == 1 assert handled[0]["media"] == [] assert handled[0]["metadata"]["attachments"] == [] assert "[attachment: secret.txt - download failed]" in handled[0]["content"] @pytest.mark.asyncio async def test_send_clears_typing_after_send() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") ) assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"] == { "msgtype": "m.text", "body": "Hi", "m.mentions": {}, } assert client.room_send_calls[0]["ignore_unverified_devices"] is True assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @pytest.mark.asyncio async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client file_path = tmp_path / "test.txt" file_path.write_text("hello", encoding="utf-8") await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="Please review.", media=[str(file_path)], ) ) assert len(client.upload_calls) == 1 assert not isinstance(client.upload_calls[0]["data_provider"], (bytes, bytearray)) assert hasattr(client.upload_calls[0]["data_provider"], "read") assert client.upload_calls[0]["filename"] == "test.txt" assert client.upload_calls[0]["filesize"] == 5 assert len(client.room_send_calls) == 2 assert client.room_send_calls[0]["content"]["msgtype"] == "m.file" assert client.room_send_calls[0]["content"]["url"] == "mxc://example.org/uploaded" assert client.room_send_calls[1]["content"]["body"] == "Please review." @pytest.mark.asyncio async def test_send_adds_thread_relates_to_for_thread_metadata() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client metadata = { "thread_root_event_id": "$root1", "thread_reply_to_event_id": "$reply1", } await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="Hi", metadata=metadata, ) ) content = client.room_send_calls[0]["content"] assert content["m.relates_to"] == { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } @pytest.mark.asyncio async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None: channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.rooms["!encrypted:matrix.org"] = SimpleNamespace(encrypted=True) channel.client = client file_path = tmp_path / "secret.txt" file_path.write_text("topsecret", encoding="utf-8") await channel.send( OutboundMessage( channel="matrix", chat_id="!encrypted:matrix.org", content="", media=[str(file_path)], ) ) assert len(client.upload_calls) == 1 assert client.upload_calls[0]["encrypt"] is True assert len(client.room_send_calls) == 1 content = client.room_send_calls[0]["content"] assert content["msgtype"] == "m.file" assert "file" in content assert "url" not in content assert content["file"]["url"] == "mxc://example.org/uploaded" assert content["file"]["hashes"]["sha256"] == "hash" @pytest.mark.asyncio async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client missing_path = tmp_path / "missing.txt" await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content=f"[attachment: {missing_path}]", ) ) assert client.upload_calls == [] assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]" @pytest.mark.asyncio async def test_send_passes_thread_relates_to_to_attachment_upload(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._server_upload_limit_checked = True channel._server_upload_limit_bytes = None captured: dict[str, object] = {} async def _fake_upload_and_send_attachment( *, room_id: str, path: Path, limit_bytes: int, relates_to: dict[str, object] | None = None, ) -> str | None: captured["relates_to"] = relates_to return None monkeypatch.setattr(channel, "_upload_and_send_attachment", _fake_upload_and_send_attachment) metadata = { "thread_root_event_id": "$root1", "thread_reply_to_event_id": "$reply1", } await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="Hi", media=["/tmp/fake.txt"], metadata=metadata, ) ) assert captured["relates_to"] == { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } @pytest.mark.asyncio async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() file_path = tmp_path / "external.txt" file_path.write_text("outside", encoding="utf-8") channel = MatrixChannel( _make_config(), MessageBus(), restrict_to_workspace=True, workspace=workspace, ) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="", media=[str(file_path)], ) ) assert client.upload_calls == [] assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]" @pytest.mark.asyncio async def test_send_handles_upload_exception_and_reports_failure(tmp_path) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.raise_on_upload = True channel.client = client file_path = tmp_path / "broken.txt" file_path.write_text("hello", encoding="utf-8") await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="Please review.", media=[str(file_path)], ) ) assert len(client.upload_calls) == 0 assert len(client.room_send_calls) == 1 assert ( client.room_send_calls[0]["content"]["body"] == "Please review.\n[attachment: broken.txt - upload failed]" ) @pytest.mark.asyncio async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.content_repository_config_response = SimpleNamespace(upload_size=3) channel.client = client file_path = tmp_path / "tiny.txt" file_path.write_text("hello", encoding="utf-8") await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="", media=[str(file_path)], ) ) assert client.upload_calls == [] assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == "[attachment: tiny.txt - too large]" @pytest.mark.asyncio async def test_send_blocks_all_outbound_media_when_limit_is_zero(tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=0), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client file_path = tmp_path / "empty.txt" file_path.write_bytes(b"") await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="", media=[str(file_path)], ) ) assert client.upload_calls == [] assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == "[attachment: empty.txt - too large]" @pytest.mark.asyncio async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") ) assert len(client.room_send_calls) == 1 assert "ignore_unverified_devices" not in client.room_send_calls[0] @pytest.mark.asyncio async def test_send_stops_typing_keepalive_task() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._running = True await channel._start_typing_keepalive("!room:matrix.org") assert "!room:matrix.org" in channel._typing_tasks await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") ) assert "!room:matrix.org" not in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @pytest.mark.asyncio async def test_send_progress_keeps_typing_keepalive_running() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._running = True await channel._start_typing_keepalive("!room:matrix.org") assert "!room:matrix.org" in channel._typing_tasks await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="working...", event=ProgressEvent(content="working..."), ) ) assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) await channel.stop() @pytest.mark.asyncio async def test_send_empty_content_does_not_call_room_send() -> None: """Progress messages with empty content must not produce an empty body: '' event.""" channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="", event=ProgressEvent(), ) ) assert client.room_send_calls == [] @pytest.mark.asyncio async def test_send_whitespace_only_content_does_not_call_room_send() -> None: """Progress messages with whitespace-only content must not produce an empty message.""" channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content=" \n\n ", event=ProgressEvent(content=" \n\n "), ) ) assert client.room_send_calls == [] @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.raise_on_send = True channel.client = client with pytest.raises(RuntimeError, match="send failed"): await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") ) assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @pytest.mark.asyncio async def test_send_adds_formatted_body_for_markdown() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client markdown_text = "# Headline\n\n- [x] done\n\n| A | B |\n| - | - |\n| 1 | 2 |" await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) ) content = client.room_send_calls[0]["content"] assert content["msgtype"] == "m.text" assert content["body"] == markdown_text assert content["m.mentions"] == {} assert content["format"] == MATRIX_HTML_FORMAT assert "

Headline

" in str(content["formatted_body"]) assert "" in str(content["formatted_body"]) assert "
  • [x] done
  • " in str(content["formatted_body"]) @pytest.mark.asyncio async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client markdown_text = "Visit https://example.com and x^2^ plus H~2~O." await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) ) content = client.room_send_calls[0]["content"] assert content["msgtype"] == "m.text" assert content["body"] == markdown_text assert content["m.mentions"] == {} assert content["format"] == MATRIX_HTML_FORMAT assert '' in str( content["formatted_body"] ) assert "2" in str(content["formatted_body"]) assert "2" in str(content["formatted_body"]) @pytest.mark.asyncio async def test_send_sanitizes_disallowed_link_scheme() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client markdown_text = "[click](javascript:alert(1))" await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) ) formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"]) assert "javascript:" not in formatted_body assert "x' cleaned_html = matrix_module.MATRIX_HTML_CLEANER.clean(dirty_html) assert " None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client markdown_text = "![ok](mxc://example.org/mediaid) ![no](https://example.com/a.png)" await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) ) formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"]) assert 'src="mxc://example.org/mediaid"' in formatted_body assert 'src="https://example.com/a.png"' not in formatted_body @pytest.mark.asyncio async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client def _raise(text: str) -> str: raise RuntimeError("boom") monkeypatch.setattr(matrix_module, "MATRIX_MARKDOWN", _raise) markdown_text = "# Headline" await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) ) content = client.room_send_calls[0]["content"] assert content == {"msgtype": "m.text", "body": markdown_text, "m.mentions": {}} @pytest.mark.asyncio async def test_send_keeps_plaintext_only_for_plain_text() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client text = "just a normal sentence without markdown markers" await channel.send( OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text) ) assert client.room_send_calls[0]["content"] == { "msgtype": "m.text", "body": text, "m.mentions": {}, } def test_build_matrix_text_content_basic_text() -> None: """Test basic text content without HTML formatting.""" result = _build_matrix_text_content("Hello, World!") expected = { "msgtype": "m.text", "body": "Hello, World!", "m.mentions": {} } assert expected == result def test_build_matrix_text_content_with_markdown() -> None: """Test text content with markdown that renders to HTML.""" text = "*Hello* **World**" result = _build_matrix_text_content(text) assert "msgtype" in result assert "body" in result assert result["body"] == text assert "format" in result assert result["format"] == "org.matrix.custom.html" assert "formatted_body" in result assert isinstance(result["formatted_body"], str) assert len(result["formatted_body"]) > 0 def test_build_matrix_text_content_with_event_id() -> None: """Test text content with event_id for message replacement.""" event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" result = _build_matrix_text_content("Updated message", event_id) assert "msgtype" in result assert "body" in result assert result["m.new_content"] assert result["m.new_content"]["body"] == "Updated message" assert result["m.relates_to"]["rel_type"] == "m.replace" assert result["m.relates_to"]["event_id"] == event_id def test_build_matrix_text_content_with_event_id_preserves_thread_relation() -> None: """Thread relations for edits should stay inside m.new_content.""" relates_to = { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } result = _build_matrix_text_content("Updated message", "event-1", relates_to) assert result["m.relates_to"] == { "rel_type": "m.replace", "event_id": "event-1", } assert result["m.new_content"]["m.relates_to"] == relates_to def test_build_matrix_text_content_no_event_id() -> None: """Test that when event_id is not provided, no extra properties are added.""" result = _build_matrix_text_content("Regular message") # Basic required properties should be present assert "msgtype" in result assert "body" in result assert result["body"] == "Regular message" # Extra properties for replacement should NOT be present assert "m.relates_to" not in result assert "m.new_content" not in result assert "format" not in result assert "formatted_body" not in result def test_build_matrix_text_content_plain_text_no_html() -> None: """Test plain text that should not include HTML formatting.""" result = _build_matrix_text_content("Simple plain text") assert "msgtype" in result assert "body" in result assert "format" not in result assert "formatted_body" not in result @pytest.mark.asyncio async def test_send_room_content_returns_room_send_response(): """Test that _send_room_content returns the response from client.room_send.""" client = _FakeAsyncClient("", "", "", None) channel = MatrixChannel(_make_config(), MessageBus()) channel.client = client room_id = "!test_room:matrix.org" content = {"msgtype": "m.text", "body": "Hello World"} result = await channel._send_room_content(room_id, content) assert result is client.room_send_response @pytest.mark.asyncio async def test_send_raises_when_room_send_returns_error(monkeypatch) -> None: class _FakeRoomSendError: def __str__(self) -> str: return "temporary homeserver failure" client = _FakeAsyncClient("", "", "", None) client.room_send_response = _FakeRoomSendError() channel = MatrixChannel(_make_config(), MessageBus()) channel.client = client monkeypatch.setattr(matrix_module, "RoomSendError", _FakeRoomSendError) with pytest.raises(RuntimeError, match="temporary homeserver failure"): await channel.send( OutboundMessage( channel="matrix", chat_id="!room:matrix.org", content="hello", ) ) @pytest.mark.asyncio async def test_send_delta_creates_stream_buffer_and_sends_initial_message() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" await channel.send_delta("!room:matrix.org", "Hello") assert "!room:matrix.org" in channel._stream_bufs buf = channel._stream_bufs["!room:matrix.org"] assert buf.text == "Hello" assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == "Hello" @pytest.mark.asyncio async def test_send_delta_appends_without_sending_before_edit_interval(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" now = 100.0 monkeypatch.setattr(channel, "monotonic_time", lambda: now) await channel.send_delta("!room:matrix.org", "Hello") assert len(client.room_send_calls) == 1 await channel.send_delta("!room:matrix.org", " world") assert len(client.room_send_calls) == 1 buf = channel._stream_bufs["!room:matrix.org"] assert buf.text == "Hello world" assert buf.event_id == "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" @pytest.mark.asyncio async def test_send_delta_edits_again_after_interval(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client client.room_send_response.event_id = "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo" times = [100.0, 102.0, 104.0, 106.0, 108.0] times.reverse() monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop()) await channel.send_delta("!room:matrix.org", "Hello") await channel.send_delta("!room:matrix.org", " world") assert len(client.room_send_calls) == 2 first_content = client.room_send_calls[0]["content"] second_content = client.room_send_calls[1]["content"] assert "body" in first_content assert first_content["body"] == "Hello" assert "m.relates_to" not in first_content assert "body" in second_content assert "m.relates_to" in second_content assert second_content["body"] == "Hello world" assert second_content["m.relates_to"] == { "rel_type": "m.replace", "event_id": "$8E2XVyINbEhcuAxvxd1d9JhQosNPzkVoU8TrbCAvyHo", } @pytest.mark.asyncio async def test_send_delta_stream_end_replaces_existing_message() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf( text="Final text", event_id="event-1", last_edit=100.0, ) await channel.send_delta("!room:matrix.org", "", stream_end=True) assert "!room:matrix.org" not in channel._stream_bufs assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) assert len(client.room_send_calls) == 1 assert client.room_send_calls[0]["content"]["body"] == "Final text" assert client.room_send_calls[0]["content"]["m.relates_to"] == { "rel_type": "m.replace", "event_id": "event-1", } @pytest.mark.asyncio async def test_send_delta_merge_next_preserves_buffer() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client channel._stream_bufs["!room:matrix.org"] = matrix_module._StreamBuf( text="first-", event_id="event-1", last_edit=100.0, ) channel.monotonic_time = lambda: 100.1 await channel.send_delta( "!room:matrix.org", "boundary", stream_end=True, merge_next=True, ) assert channel._stream_bufs["!room:matrix.org"].text == "first-boundary" assert client.room_send_calls == [] @pytest.mark.asyncio async def test_send_delta_keeps_same_room_stream_ids_independent(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client event_ids = ["event-a", "event-b"] async def _send_room_content(room_id, content): client.room_send_calls.append({"room_id": room_id, "content": content}) return SimpleNamespace(event_id=event_ids.pop(0) if event_ids else "event-final") monkeypatch.setattr(channel, "_send_room_content", _send_room_content) await channel.send_delta("!room:matrix.org", "A", stream_id="stream-a") await channel.send_delta("!room:matrix.org", "B", stream_id="stream-b") await channel.send_delta("!room:matrix.org", "1", stream_id="stream-a") await channel.send_delta("!room:matrix.org", "2", stream_id="stream-b") await channel.send_delta("!room:matrix.org", "", stream_id="stream-a", stream_end=True) await channel.send_delta("!room:matrix.org", "", stream_id="stream-b", stream_end=True) final_a = client.room_send_calls[-2]["content"] final_b = client.room_send_calls[-1]["content"] assert final_a["body"] == "A1" assert final_a["m.relates_to"]["event_id"] == "event-a" assert final_b["body"] == "B2" assert final_b["m.relates_to"]["event_id"] == "event-b" @pytest.mark.asyncio async def test_send_delta_starts_threaded_stream_inside_thread() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client client.room_send_response.event_id = "event-1" metadata = { "thread_root_event_id": "$root1", "thread_reply_to_event_id": "$reply1", } await channel.send_delta("!room:matrix.org", "Hello", metadata) assert client.room_send_calls[0]["content"]["m.relates_to"] == { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } @pytest.mark.asyncio async def test_send_delta_threaded_edit_keeps_replace_and_thread_relation(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client client.room_send_response.event_id = "event-1" times = [100.0, 102.0, 104.0] times.reverse() monkeypatch.setattr(channel, "monotonic_time", lambda: times and times.pop()) metadata = { "thread_root_event_id": "$root1", "thread_reply_to_event_id": "$reply1", } await channel.send_delta("!room:matrix.org", "Hello", metadata) await channel.send_delta("!room:matrix.org", " world", metadata) await channel.send_delta("!room:matrix.org", "", metadata, stream_end=True) edit_content = client.room_send_calls[1]["content"] final_content = client.room_send_calls[2]["content"] assert edit_content["m.relates_to"] == { "rel_type": "m.replace", "event_id": "event-1", } assert edit_content["m.new_content"]["m.relates_to"] == { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } assert final_content["m.relates_to"] == { "rel_type": "m.replace", "event_id": "event-1", } assert final_content["m.new_content"]["m.relates_to"] == { "rel_type": "m.thread", "event_id": "$root1", "m.in_reply_to": {"event_id": "$reply1"}, "is_falling_back": True, } @pytest.mark.asyncio async def test_send_delta_stream_end_noop_when_buffer_missing() -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client await channel.send_delta("!room:matrix.org", "", stream_end=True) assert client.room_send_calls == [] assert client.typing_calls == [] @pytest.mark.asyncio async def test_send_delta_on_error_stops_typing(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) client.raise_on_send = True channel.client = client now = 100.0 monkeypatch.setattr(channel, "monotonic_time", lambda: now) await channel.send_delta("!room:matrix.org", "Hello", {"room_id": "!room:matrix.org"}) assert "!room:matrix.org" in channel._stream_bufs assert channel._stream_bufs["!room:matrix.org"].text == "Hello" assert len(client.room_send_calls) == 1 assert len(client.typing_calls) == 1 @pytest.mark.asyncio async def test_send_delta_ignores_whitespace_only_delta(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client now = 100.0 monkeypatch.setattr(channel, "monotonic_time", lambda: now) await channel.send_delta("!room:matrix.org", " ") assert "!room:matrix.org" in channel._stream_bufs assert channel._stream_bufs["!room:matrix.org"].text == " " @pytest.mark.asyncio async def test_fetch_media_rejects_missing_declared_size(monkeypatch, tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("download should be rejected before fetching bytes") monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run) event = SimpleNamespace( sender="@alice:matrix.org", event_id="$event1", body="payload.bin", url="mxc://example.org/media", source={"content": {"msgtype": "m.file"}}, ) attachment, marker = await channel._fetch_media_attachment( SimpleNamespace(room_id="!room:matrix.org"), event, ) assert attachment is None assert marker == "[attachment: payload.bin - too large]" @pytest.mark.asyncio async def test_fetch_media_rejects_bool_declared_size(monkeypatch, tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("bool size should be rejected before fetching bytes") monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run) event = SimpleNamespace( sender="@alice:matrix.org", event_id="$event1", body="payload.bin", url="mxc://example.org/media", source={"content": {"msgtype": "m.file", "info": {"size": True}}}, ) attachment, marker = await channel._fetch_media_attachment( SimpleNamespace(room_id="!room:matrix.org"), event, ) assert attachment is None assert marker == "[attachment: payload.bin - too large]" @pytest.mark.asyncio async def test_fetch_media_rejects_declared_oversized_before_download(monkeypatch, tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("download should be rejected before fetching bytes") monkeypatch.setattr(channel, "_download_media_bytes", _download_should_not_run) event = SimpleNamespace( sender="@alice:matrix.org", event_id="$event1", body="payload.bin", url="mxc://example.org/media", source={"content": {"msgtype": "m.file", "info": {"size": 9}}}, ) attachment, marker = await channel._fetch_media_attachment( SimpleNamespace(room_id="!room:matrix.org"), event, ) assert attachment is None assert marker == "[attachment: payload.bin - too large]" @pytest.mark.asyncio async def test_fetch_media_maps_streaming_cap_to_too_large(monkeypatch, tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_too_large(_mxc_url: str, _limit_bytes: int): raise matrix_module._MediaTooLargeError monkeypatch.setattr(channel, "_download_media_bytes", _download_too_large) event = SimpleNamespace( sender="@alice:matrix.org", event_id="$event1", body="payload.bin", url="mxc://example.org/media", source={"content": {"msgtype": "m.file", "info": {"size": 8}}}, ) attachment, marker = await channel._fetch_media_attachment( SimpleNamespace(room_id="!room:matrix.org"), event, ) assert attachment is None assert marker == "[attachment: payload.bin - too large]" assert client.room_send_calls == []