Files
nanobot/tests/channels/test_matrix_channel.py
T

2190 lines
73 KiB
Python

import asyncio
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
pytest.importorskip("nio")
pytest.importorskip("nh3")
pytest.importorskip("mistune")
from nio import RoomSendResponse, SyncError
import nanobot.channels.matrix 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 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 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.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.channels.matrix.AsyncClientConfig",
lambda **kwargs: SimpleNamespace(**kwargs),
)
monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client)
monkeypatch.setattr(
"nanobot.channels.matrix.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) == 3
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.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.channels.matrix.AsyncClientConfig",
lambda **kwargs: SimpleNamespace(**kwargs),
)
monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client)
monkeypatch.setattr(
"nanobot.channels.matrix.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_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.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.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.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.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.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.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.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 "<h1>Headline</h1>" in str(content["formatted_body"])
assert "<table>" in str(content["formatted_body"])
assert "<li>[x] done</li>" 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 '<a href="https://example.com" rel="noopener noreferrer">' in str(
content["formatted_body"]
)
assert "<sup>2</sup>" in str(content["formatted_body"])
assert "<sub>2</sub>" 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 "<a" in formatted_body
assert "href=" not in formatted_body
def test_matrix_html_cleaner_strips_event_handlers_and_script_tags() -> None:
dirty_html = '<a href="https://example.com" onclick="evil()">x</a><script>alert(1)</script>'
cleaned_html = matrix_module.MATRIX_HTML_CLEANER.clean(dirty_html)
assert "<script" not in cleaned_html
assert "onclick=" not in cleaned_html
assert '<a href="https://example.com"' in cleaned_html
@pytest.mark.asyncio
async def test_send_keeps_only_mxc_image_sources() -> 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_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.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.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.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.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 == []