From 0d3dc57a65e54bd8223110da4e7d5ac06b5aa924 Mon Sep 17 00:00:00 2001 From: Tanish Rajput Date: Mon, 9 Feb 2026 22:57:38 +0530 Subject: [PATCH 01/42] feat: add matrix (Element) chat channel support --- nanobot/channels/matrix.py | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 nanobot/channels/matrix.py diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py new file mode 100644 index 0000000..0ea4ae1 --- /dev/null +++ b/nanobot/channels/matrix.py @@ -0,0 +1,79 @@ +import asyncio +from typing import Any + +from nio import AsyncClient, MatrixRoom, RoomMessageText + +from nanobot.channels.base import BaseChannel +from nanobot.bus.events import OutboundMessage + + +class MatrixChannel(BaseChannel): + """ + Matrix (Element) channel using long-polling sync. + """ + + name = "matrix" + + def __init__(self, config: Any, bus): + super().__init__(config, bus) + self.client: AsyncClient | None = None + self._sync_task: asyncio.Task | None = None + + async def start(self) -> None: + self._running = True + + self.client = AsyncClient( + homeserver=self.config.homeserver, + user=self.config.user_id, + ) + + self.client.access_token = self.config.access_token + + self.client.add_event_callback( + self._on_message, + RoomMessageText + ) + + self._sync_task = asyncio.create_task(self._sync_loop()) + + async def stop(self) -> None: + self._running = False + if self._sync_task: + self._sync_task.cancel() + if self.client: + await self.client.close() + + async def send(self, msg: OutboundMessage) -> None: + if not self.client: + return + + await self.client.room_send( + room_id=msg.chat_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": msg.content}, + ) + + async def _sync_loop(self) -> None: + while self._running: + try: + await self.client.sync(timeout=30000) + except asyncio.CancelledError: + break + except Exception: + await asyncio.sleep(2) + + async def _on_message( + self, + room: MatrixRoom, + event: RoomMessageText + ) -> None: + # Ignore self messages + if event.sender == self.config.user_id: + return + + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) \ No newline at end of file From d3ddeb30671e7a5b33e5758bf6e33191662bc1e3 Mon Sep 17 00:00:00 2001 From: djmaze <7229+djmaze@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:01:15 +0100 Subject: [PATCH 02/42] fix: activate E2E and accept room invites in Matrix channels --- nanobot/channels/matrix.py | 48 ++++++++++++++++++++++++-------------- nanobot/config/schema.py | 12 ++++++++++ pyproject.toml | 1 + 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0ea4ae1..f3b8468 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,10 +1,11 @@ import asyncio from typing import Any -from nio import AsyncClient, MatrixRoom, RoomMessageText +from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText -from nanobot.channels.base import BaseChannel from nanobot.bus.events import OutboundMessage +from nanobot.channels.base import BaseChannel +from nanobot.config.loader import get_data_dir class MatrixChannel(BaseChannel): @@ -22,18 +23,28 @@ class MatrixChannel(BaseChannel): async def start(self) -> None: self._running = True - self.client = AsyncClient( - homeserver=self.config.homeserver, - user=self.config.user_id, - ) - - self.client.access_token = self.config.access_token + store_path = get_data_dir() / "matrix-store" + store_path.mkdir(parents=True, exist_ok=True) - self.client.add_event_callback( - self._on_message, - RoomMessageText + self.client = AsyncClient( + homeserver=self.config.homeserver, + user=self.config.user_id, + store_path=store_path, # Where tokens are saved + config=AsyncClientConfig( + store_sync_tokens=True, # Auto-persists next_batch tokens + encryption_enabled=True, + ), ) + self.client.user_id = self.config.user_id + self.client.access_token = self.config.access_token + self.client.device_id = self.config.device_id + + self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_room_invite, InviteEvent) + + self.client.load_store() + self._sync_task = asyncio.create_task(self._sync_loop()) async def stop(self) -> None: @@ -51,22 +62,23 @@ class MatrixChannel(BaseChannel): room_id=msg.chat_id, message_type="m.room.message", content={"msgtype": "m.text", "body": msg.content}, + ignore_unverified_devices=True, ) async def _sync_loop(self) -> None: while self._running: try: - await self.client.sync(timeout=30000) + await self.client.sync_forever(timeout=30000, full_state=True) except asyncio.CancelledError: break except Exception: await asyncio.sleep(2) - async def _on_message( - self, - room: MatrixRoom, - event: RoomMessageText - ) -> None: + async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None: + if event.sender in self.config.allow_from: + await self.client.join(room.room_id) + + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: return @@ -76,4 +88,4 @@ class MatrixChannel(BaseChannel): chat_id=room.room_id, content=event.body, metadata={"room": room.display_name}, - ) \ No newline at end of file + ) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6a1257e..8413f0a 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -60,6 +60,17 @@ class DiscordConfig(Base): intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT +class MatrixConfig(Base): + """Matrix (Element) channel configuration.""" + + enabled: bool = False + homeserver: str = "https://matrix.org" + access_token: str = "" + user_id: str = "" # @bot:matrix.org + device_id: str = "" + allow_from: list[str] = Field(default_factory=list) + + class EmailConfig(Base): """Email channel configuration (IMAP inbound + SMTP outbound).""" @@ -176,6 +187,7 @@ class ChannelsConfig(Base): email: EmailConfig = Field(default_factory=EmailConfig) slack: SlackConfig = Field(default_factory=SlackConfig) qq: QQConfig = Field(default_factory=QQConfig) + matrix: MatrixConfig = Field(default_factory=MatrixConfig) class AgentDefaults(Base): diff --git a/pyproject.toml b/pyproject.toml index 64a884d..18bbe70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "matrix-nio[e2e]>=0.25.2", ] [project.optional-dependencies] From c926569033bce8f52d49d54bf23c0d77439c1936 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 09:05:20 +0100 Subject: [PATCH 03/42] fix(matrix): guard store load without device id and allow invites by default --- nanobot/channels/matrix.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index f3b8468..340ec2a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -43,7 +43,8 @@ class MatrixChannel(BaseChannel): self.client.add_event_callback(self._on_message, RoomMessageText) self.client.add_event_callback(self._on_room_invite, InviteEvent) - self.client.load_store() + if self.config.device_id: + self.client.load_store() self._sync_task = asyncio.create_task(self._sync_loop()) @@ -74,9 +75,12 @@ class MatrixChannel(BaseChannel): except Exception: await asyncio.sleep(2) - async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None: - if event.sender in self.config.allow_from: - await self.client.join(room.room_id) + async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: + allow_from = self.config.allow_from or [] + if allow_from and event.sender not in allow_from: + return + + await self.client.join(room.room_id) async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages From 988b75624c872c5351d92c59ec9be35284007c75 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 09:22:32 +0100 Subject: [PATCH 04/42] test(matrix): add matrix channel behavior test --- tests/test_matrix_channel.py | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_matrix_channel.py diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py new file mode 100644 index 0000000..bc39097 --- /dev/null +++ b/tests/test_matrix_channel.py @@ -0,0 +1,113 @@ +from types import SimpleNamespace + +import pytest + +from nanobot.bus.queue import MessageBus +from nanobot.channels.matrix import MatrixChannel +from nanobot.config.schema import MatrixConfig + + +class _DummyTask: + def __init__(self) -> None: + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + +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.join_calls: list[str] = [] + self.callbacks: list[tuple[object, object]] = [] + + def add_event_callback(self, callback, event_type) -> None: + self.callbacks.append((callback, event_type)) + + def load_store(self) -> None: + self.load_store_called = True + + async def join(self, room_id: str) -> None: + self.join_calls.append(room_id) + + async def close(self) -> None: + return None + + +def _make_config(**kwargs) -> MatrixConfig: + 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=""), MessageBus()) + await channel.start() + + assert len(clients) == 1 + assert clients[0].load_store_called is False + + await channel.stop() + + +@pytest.mark.asyncio +async def test_room_invite_joins_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 == ["!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 == [] From 7c33d3cbe241012eff5f5337912a2e994f0ceba1 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:20:55 +0100 Subject: [PATCH 05/42] feat(matrix): add configurable graceful sync shutdown --- nanobot/channels/matrix.py | 21 ++++++++++++++++++++- nanobot/config/schema.py | 22 +++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 340ec2a..7e84626 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -49,9 +49,28 @@ class MatrixChannel(BaseChannel): self._sync_task = asyncio.create_task(self._sync_loop()) async def stop(self) -> None: + """Stop the Matrix channel with graceful sync shutdown.""" self._running = False + + if self.client: + # Request sync_forever loop to exit cleanly. + self.client.stop_sync_forever() + if self._sync_task: - self._sync_task.cancel() + try: + await asyncio.wait_for( + asyncio.shield(self._sync_task), + timeout=self.config.sync_stop_grace_seconds, + ) + except asyncio.TimeoutError: + self._sync_task.cancel() + try: + await self._sync_task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + pass + if self.client: await self.client.close() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 8413f0a..f8d251b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -27,7 +27,9 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) class FeishuConfig(Base): @@ -68,6 +70,8 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" + # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + sync_stop_grace_seconds: int = 2 allow_from: list[str] = Field(default_factory=list) @@ -95,7 +99,9 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -172,7 +178,9 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -231,7 +239,9 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (硅基流动) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -294,7 +304,9 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From 9d8539322657f35f22c84036536f088a995d4b4c Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:24:36 +0100 Subject: [PATCH 06/42] feat(matrix): add startup warnings and response error logging --- nanobot/channels/matrix.py | 71 +++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 7e84626..89e7616 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,7 +1,17 @@ import asyncio from typing import Any -from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText +from loguru import logger +from nio import ( + AsyncClient, + AsyncClientConfig, + InviteEvent, + JoinError, + MatrixRoom, + RoomMessageText, + RoomSendError, + SyncError, +) from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel @@ -21,6 +31,7 @@ class MatrixChannel(BaseChannel): self._sync_task: asyncio.Task | None = None async def start(self) -> None: + """Start Matrix client and begin sync loop.""" self._running = True store_path = get_data_dir() / "matrix-store" @@ -40,11 +51,24 @@ class MatrixChannel(BaseChannel): self.client.access_token = self.config.access_token self.client.device_id = self.config.device_id - self.client.add_event_callback(self._on_message, RoomMessageText) - self.client.add_event_callback(self._on_room_invite, InviteEvent) + self._register_event_callbacks() + self._register_response_callbacks() if self.config.device_id: - self.client.load_store() + try: + self.client.load_store() + except Exception as e: + logger.warning( + "Matrix store load failed ({}: {}); sync token restore is disabled and " + "restart may replay recent messages.", + type(e).__name__, + str(e), + ) + else: + logger.warning( + "Matrix device_id is empty; sync token restore is disabled and restart may " + "replay recent messages." + ) self._sync_task = asyncio.create_task(self._sync_loop()) @@ -85,9 +109,48 @@ class MatrixChannel(BaseChannel): ignore_unverified_devices=True, ) + def _register_event_callbacks(self) -> None: + """Register Matrix event callbacks used by this channel.""" + self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_room_invite, InviteEvent) + + def _register_response_callbacks(self) -> None: + """Register response callbacks for operational error observability.""" + self.client.add_response_callback(self._on_sync_error, SyncError) + self.client.add_response_callback(self._on_join_error, JoinError) + self.client.add_response_callback(self._on_send_error, RoomSendError) + + @staticmethod + def _is_auth_error(errcode: str | None) -> bool: + """Return True if the Matrix errcode indicates auth/token problems.""" + return errcode in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} + + async def _on_sync_error(self, response: SyncError) -> None: + """Log sync errors with clear severity.""" + if self._is_auth_error(response.status_code) or response.soft_logout: + logger.error("Matrix sync failed: {}", response) + return + logger.warning("Matrix sync warning: {}", response) + + async def _on_join_error(self, response: JoinError) -> None: + """Log room-join errors from invite handling.""" + if self._is_auth_error(response.status_code): + logger.error("Matrix join failed: {}", response) + return + logger.warning("Matrix join warning: {}", response) + + async def _on_send_error(self, response: RoomSendError) -> None: + """Log message send failures.""" + if self._is_auth_error(response.status_code): + logger.error("Matrix send failed: {}", response) + return + logger.warning("Matrix send warning: {}", response) + async def _sync_loop(self) -> None: while self._running: try: + # full_state applies only to the first sync inside sync_forever and helps + # rebuild room state when restoring from stored sync tokens. await self.client.sync_forever(timeout=30000, full_state=True) except asyncio.CancelledError: break From b721f9f37dc295d30d45e81746335ae34b54f5e1 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:24:54 +0100 Subject: [PATCH 07/42] test(matrix): cover response callbacks and graceful shutdown --- tests/test_matrix_channel.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index bc39097..f86543b 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -14,6 +14,12 @@ class _DummyTask: 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: @@ -25,15 +31,23 @@ class _FakeAsyncClient: 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.response_callbacks: list[tuple[object, object]] = [] def add_event_callback(self, callback, event_type) -> None: self.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) @@ -81,10 +95,28 @@ async def test_start_skips_load_store_when_device_id_missing( assert len(clients) == 1 assert clients[0].load_store_called is False + assert len(clients[0].response_callbacks) == 3 await channel.stop() +@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_joins_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) From b294a682a86631452e6f15e809e7933d7007bb03 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:03:13 +0100 Subject: [PATCH 08/42] chore(matrix): route matrix-nio logs through loguru --- nanobot/channels/matrix.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 89e7616..d73a849 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,4 +1,5 @@ import asyncio +import logging from typing import Any from loguru import logger @@ -18,6 +19,34 @@ from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +class _NioLoguruHandler(logging.Handler): + """Route stdlib logging records from matrix-nio into Loguru output.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame = logging.currentframe() + depth = 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def _configure_nio_logging_bridge() -> None: + """Ensure matrix-nio logs are emitted through the project's Loguru format.""" + nio_logger = logging.getLogger("nio") + if any(isinstance(handler, _NioLoguruHandler) for handler in nio_logger.handlers): + return + + nio_logger.handlers = [_NioLoguruHandler()] + nio_logger.propagate = False + + class MatrixChannel(BaseChannel): """ Matrix (Element) channel using long-polling sync. @@ -33,6 +62,7 @@ class MatrixChannel(BaseChannel): async def start(self) -> None: """Start Matrix client and begin sync loop.""" self._running = True + _configure_nio_logging_bridge() store_path = get_data_dir() / "matrix-store" store_path.mkdir(parents=True, exist_ok=True) From ffac42f9e5d1d3ebac690f29f0354b06acb961c8 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:12:45 +0100 Subject: [PATCH 09/42] refactor(matrix): replace logging depth magic number --- nanobot/channels/matrix.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index d73a849..63a07e0 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -18,6 +18,8 @@ from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +LOGGING_STACK_BASE_DEPTH = 2 + class _NioLoguruHandler(logging.Handler): """Route stdlib logging records from matrix-nio into Loguru output.""" @@ -29,7 +31,8 @@ class _NioLoguruHandler(logging.Handler): level = record.levelno frame = logging.currentframe() - depth = 2 + # Skip logging internals plus this handler frame when forwarding to Loguru. + depth = LOGGING_STACK_BASE_DEPTH while frame and frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 From 45267b0730b9a2b07b4b0ad1676fa9ba85f88898 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:25:12 +0100 Subject: [PATCH 10/42] feat(matrix): show typing while processing messages --- nanobot/channels/matrix.py | 65 +++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 63a07e0..60a86fc 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -11,6 +11,7 @@ from nio import ( MatrixRoom, RoomMessageText, RoomSendError, + RoomTypingError, SyncError, ) @@ -19,6 +20,7 @@ from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir LOGGING_STACK_BASE_DEPTH = 2 +TYPING_NOTICE_TIMEOUT_MS = 30_000 class _NioLoguruHandler(logging.Handler): @@ -135,12 +137,15 @@ class MatrixChannel(BaseChannel): if not self.client: return - await self.client.room_send( - room_id=msg.chat_id, - message_type="m.room.message", - content={"msgtype": "m.text", "body": msg.content}, - ignore_unverified_devices=True, - ) + try: + await self.client.room_send( + room_id=msg.chat_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": msg.content}, + ignore_unverified_devices=True, + ) + finally: + await self._set_typing(msg.chat_id, False) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" @@ -179,6 +184,28 @@ class MatrixChannel(BaseChannel): return logger.warning("Matrix send warning: {}", response) + async def _set_typing(self, room_id: str, typing: bool) -> None: + """Best-effort typing indicator update that never blocks message flow.""" + if not self.client: + return + + try: + response = await self.client.room_typing( + room_id=room_id, + typing_state=typing, + timeout=TYPING_NOTICE_TIMEOUT_MS, + ) + if isinstance(response, RoomTypingError): + logger.debug("Matrix typing update failed for room {}: {}", room_id, response) + except Exception as e: + logger.debug( + "Matrix typing update failed for room {} (typing={}): {}: {}", + room_id, + typing, + type(e).__name__, + str(e), + ) + async def _sync_loop(self) -> None: while self._running: try: @@ -202,9 +229,23 @@ class MatrixChannel(BaseChannel): if event.sender == self.config.user_id: return - await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content=event.body, - metadata={"room": room.display_name}, - ) + if not self.is_allowed(event.sender): + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) + return + + await self._set_typing(room.room_id, True) + try: + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) + except Exception: + await self._set_typing(room.room_id, False) + raise From 840ef7363f3c51d1d9746abdbe82989a41f4193d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:25:33 +0100 Subject: [PATCH 11/42] test(matrix): cover typing indicator lifecycle --- tests/test_matrix_channel.py | 116 ++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f86543b..b2accbb 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -2,8 +2,9 @@ from types import SimpleNamespace import pytest +from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.matrix import MatrixChannel +from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel from nanobot.config.schema import MatrixConfig @@ -35,6 +36,10 @@ class _FakeAsyncClient: self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] + self.room_send_calls: list[dict[str, object]] = [] + self.typing_calls: list[tuple[str, bool, int]] = [] + self.raise_on_send = False + self.raise_on_typing = False def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) @@ -51,6 +56,34 @@ class _FakeAsyncClient: async def join(self, room_id: str) -> None: self.join_calls.append(room_id) + async def room_send( + self, + room_id: str, + message_type: str, + content: dict[str, object], + ignore_unverified_devices: bool, + ) -> None: + self.room_send_calls.append( + { + "room_id": room_id, + "message_type": message_type, + "content": content, + "ignore_unverified_devices": ignore_unverified_devices, + } + ) + if self.raise_on_send: + raise RuntimeError("send failed") + + 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 close(self) -> None: return None @@ -143,3 +176,84 @@ async def test_room_invite_respects_allow_list_when_configured() -> None: 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") + + 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_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") + + await channel._on_message(room, event) + + 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 + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello") + + await channel._on_message(room, event) + + assert client.typing_calls == [] + + +@pytest.mark.asyncio +async def test_send_clears_typing_after_send() -> None: + channel = MatrixChannel(_make_config(), 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.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + + +@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) From e716c9caaccde37e74e2ae4f22f1e3086b4b9c0d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:02:03 +0100 Subject: [PATCH 12/42] feat(matrix): send markdown as formatted html messages --- nanobot/channels/matrix.py | 44 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 60a86fc..0cf354c 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -3,6 +3,7 @@ import logging from typing import Any from loguru import logger +from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, @@ -21,6 +22,47 @@ from nanobot.config.loader import get_data_dir LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 +MATRIX_HTML_FORMAT = "org.matrix.custom.html" + +MATRIX_MARKDOWN = create_markdown( + escape=True, + plugins=["table", "strikethrough", "task_lists"], +) + + +def _render_markdown_html(text: str) -> str | None: + """Render markdown to HTML for Matrix formatted messages.""" + try: + formatted = MATRIX_MARKDOWN(text).strip() + except Exception as e: + logger.debug( + "Matrix markdown rendering failed ({}): {}", + type(e).__name__, + str(e), + ) + return None + + if not formatted: + return None + + # Skip formatted_body for plain output (

...

) to keep payload minimal. + stripped = formatted.strip() + if stripped.startswith("

") and stripped.endswith("

") and "

" not in stripped[3:-4]: + return None + + return formatted + + +def _build_matrix_text_content(text: str) -> dict[str, str]: + """Build Matrix m.text payload with plaintext fallback and optional HTML.""" + content: dict[str, str] = {"msgtype": "m.text", "body": text} + formatted_html = _render_markdown_html(text) + if not formatted_html: + return content + + content["format"] = MATRIX_HTML_FORMAT + content["formatted_body"] = formatted_html + return content class _NioLoguruHandler(logging.Handler): @@ -141,7 +183,7 @@ class MatrixChannel(BaseChannel): await self.client.room_send( room_id=msg.chat_id, message_type="m.room.message", - content={"msgtype": "m.text", "body": msg.content}, + content=_build_matrix_text_content(msg.content), ignore_unverified_devices=True, ) finally: diff --git a/pyproject.toml b/pyproject.toml index 18bbe70..82b37a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0", ] [project.optional-dependencies] From 3200135f4b849a9326087de6e151e5e0dd086d9a Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:02:29 +0100 Subject: [PATCH 13/42] test(matrix): cover formatted body and markdown fallback --- tests/test_matrix_channel.py | 61 +++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index b2accbb..e3bea9e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -2,9 +2,14 @@ from types import SimpleNamespace import pytest +import nanobot.channels.matrix as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel +from nanobot.channels.matrix import ( + MATRIX_HTML_FORMAT, + TYPING_NOTICE_TIMEOUT_MS, + MatrixChannel, +) from nanobot.config.schema import MatrixConfig @@ -241,6 +246,7 @@ async def test_send_clears_typing_after_send() -> None: ) assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": "Hi"} assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @@ -257,3 +263,56 @@ async def test_send_clears_typing_when_send_fails() -> None: ) 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["format"] == MATRIX_HTML_FORMAT + assert "

Headline

" in str(content["formatted_body"]) + assert "" in str(content["formatted_body"]) + assert "task-list-item-checkbox" in str(content["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} + + +@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} From fa2049fc602865868f3e5b06fea0d32d02aeeb4d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:30:39 +0100 Subject: [PATCH 14/42] feat(matrix): add group policy and strict mention gating --- nanobot/channels/matrix.py | 53 ++++++++++++++++++++++++++++++++------ nanobot/config/schema.py | 5 ++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0cf354c..fbe511b 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -266,18 +266,55 @@ class MatrixChannel(BaseChannel): await self.client.join(room.room_id) + def _is_direct_room(self, room: MatrixRoom) -> bool: + """Return True if the room behaves like a DM (2 or fewer members).""" + member_count = getattr(room, "member_count", None) + return isinstance(member_count, int) and member_count <= 2 + + def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessageText) -> bool: + """Resolve mentions strictly from Matrix-native m.mentions payload.""" + source = getattr(event, "source", None) + if not isinstance(source, dict): + return False + + content = source.get("content") + if not isinstance(content, dict): + return False + + mentions = content.get("m.mentions") + if not isinstance(mentions, dict): + return False + + user_ids = mentions.get("user_ids") + if isinstance(user_ids, list) and self.config.user_id in user_ids: + return True + + return bool(self.config.allow_room_mentions and mentions.get("room") is True) + + def _should_process_message(self, room: MatrixRoom, event: RoomMessageText) -> bool: + """Apply sender and room policy checks before processing Matrix messages.""" + if not self.is_allowed(event.sender): + return False + + if self._is_direct_room(room): + return True + + policy = self.config.group_policy + if policy == "open": + return True + if policy == "allowlist": + return room.room_id in (self.config.group_allow_from or []) + if policy == "mention": + return self._is_bot_mentioned_from_mx_mentions(event) + + return False + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: return - if not self.is_allowed(event.sender): - await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content=event.body, - metadata={"room": room.display_name}, - ) + if not self._should_process_message(room, event): return await self._set_typing(room.room_id, True) @@ -286,7 +323,7 @@ class MatrixChannel(BaseChannel): sender_id=event.sender, chat_id=room.room_id, content=event.body, - metadata={"room": room.display_name}, + metadata={"room": getattr(room, "display_name", room.room_id)}, ) except Exception: await self._set_typing(room.room_id, False) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f8d251b..d442104 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,6 +1,8 @@ """Configuration schema using Pydantic.""" from pathlib import Path +from typing import Literal + from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings @@ -73,6 +75,9 @@ class MatrixConfig(Base): # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 allow_from: list[str] = Field(default_factory=list) + group_policy: Literal["open", "mention", "allowlist"] = "open" + group_allow_from: list[str] = Field(default_factory=list) + allow_room_mentions: bool = False class EmailConfig(Base): From cc5cfe68477ce793b8f121cfe3de000b67530d8e Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:30:54 +0100 Subject: [PATCH 15/42] test(matrix): cover mention policy and sender filtering --- tests/test_matrix_channel.py | 145 ++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index e3bea9e..cc834c3 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -197,7 +197,7 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None: 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") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={}) await channel._on_message(room, event) @@ -214,7 +214,7 @@ async def test_on_message_skips_typing_for_self_message() -> None: channel.client = client room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") - event = SimpleNamespace(sender="@bot:matrix.org", body="Hello") + event = SimpleNamespace(sender="@bot:matrix.org", body="Hello", source={}) await channel._on_message(room, event) @@ -227,14 +227,153 @@ async def test_on_message_skips_typing_for_denied_sender() -> None: 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") + 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}}}, + ) + + 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_send_clears_typing_after_send() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 9b14869cb10daa09a65d0f4e321dc63cbd812363 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:56:21 +0100 Subject: [PATCH 16/42] feat(matrix): support inline markdown html for url and super/subscript --- nanobot/channels/matrix.py | 16 +++++++++++++--- tests/test_matrix_channel.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index fbe511b..61113ac 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -24,9 +24,16 @@ LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" +# Keep plugin output aligned with Matrix recommended HTML tags: +# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes +# - table/strikethrough/task_lists are already used in replies. +# - url, superscript, and subscript map to common tags (, , ) +# that Matrix clients (e.g. Element/FluffyChat) can render consistently. +# We intentionally avoid plugins that emit less-portable tags to keep output +# predictable across clients. MATRIX_MARKDOWN = create_markdown( escape=True, - plugins=["table", "strikethrough", "task_lists"], + plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"], ) @@ -47,8 +54,11 @@ def _render_markdown_html(text: str) -> str | None: # Skip formatted_body for plain output (

...

) to keep payload minimal. stripped = formatted.strip() - if stripped.startswith("

") and stripped.endswith("

") and "

" not in stripped[3:-4]: - return None + if stripped.startswith("

") and stripped.endswith("

"): + paragraph_inner = stripped[3:-4] + # Keep plaintext-only paragraphs minimal, but preserve inline markup/links. + if "<" not in paragraph_inner and ">" not in paragraph_inner: + return None return formatted diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index cc834c3..2e3dad2 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -424,6 +424,26 @@ async def test_send_adds_formatted_body_for_markdown() -> None: assert "task-list-item-checkbox" 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["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_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 6be7368a38e8c1fe9bafc2b2517453040ce83ac6 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 16:18:47 +0100 Subject: [PATCH 17/42] fix(matrix): sanitize formatted html with nh3 --- nanobot/channels/matrix.py | 92 ++++++++++++++++++++++++++++++++++-- pyproject.toml | 3 +- tests/test_matrix_channel.py | 48 ++++++++++++++++++- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 61113ac..8240b51 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,6 +2,7 @@ import asyncio import logging from typing import Any +import nh3 from loguru import logger from mistune import create_markdown from nio import ( @@ -26,21 +27,106 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" # Keep plugin output aligned with Matrix recommended HTML tags: # https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - table/strikethrough/task_lists are already used in replies. +# - table/strikethrough are already used in replies. # - url, superscript, and subscript map to common tags (, , ) # that Matrix clients (e.g. Element/FluffyChat) can render consistently. # We intentionally avoid plugins that emit less-portable tags to keep output # predictable across clients. MATRIX_MARKDOWN = create_markdown( escape=True, - plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"], + plugins=["table", "strikethrough", "url", "superscript", "subscript"], +) + +# Sanitizer policy rationale: +# - Baseline follows Matrix formatted message guidance: +# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes +# - We intentionally use a tighter subset than the full spec to keep behavior +# predictable across clients and reduce risk from LLM-generated content. +# - URLs are restricted to common safe schemes for links, and image sources are +# additionally constrained to mxc:// for Matrix-native media handling. +# - Spec items intentionally NOT enabled yet: +# - href schemes ftp/magnet (we keep link schemes smaller for now). +# - a[target] (clients already control link-opening behavior). +# - span[data-mx-bg-color|data-mx-color|data-mx-spoiler|data-mx-maths] +# - div[data-mx-maths] +# These can be added later when we explicitly support those Matrix features. +MATRIX_ALLOWED_HTML_TAGS = { + "p", + "a", + "strong", + "em", + "del", + "code", + "pre", + "blockquote", + "ul", + "ol", + "li", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "br", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "caption", + "sup", + "sub", + "img", +} +MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = { + "a": {"href"}, + "code": {"class"}, + "ol": {"start"}, + "img": {"src", "alt", "title", "width", "height"}, +} +MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"} + + +def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None: + """Filter attribute values to a safe Matrix-compatible subset.""" + if tag == "a" and attr == "href": + lower_value = value.lower() + if lower_value.startswith(("https://", "http://", "matrix:", "mailto:")): + return value + return None + + if tag == "img" and attr == "src": + return value if value.lower().startswith("mxc://") else None + + if tag == "code" and attr == "class": + classes = [ + cls + for cls in value.split() + if cls.startswith("language-") and not cls.startswith("language-_") + ] + return " ".join(classes) if classes else None + + return value + + +MATRIX_HTML_CLEANER = nh3.Cleaner( + tags=MATRIX_ALLOWED_HTML_TAGS, + attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES, + attribute_filter=_filter_matrix_html_attribute, + url_schemes=MATRIX_ALLOWED_URL_SCHEMES, + strip_comments=True, + link_rel="noopener noreferrer", ) def _render_markdown_html(text: str) -> str | None: """Render markdown to HTML for Matrix formatted messages.""" try: - formatted = MATRIX_MARKDOWN(text).strip() + rendered = MATRIX_MARKDOWN(text) + formatted = MATRIX_HTML_CLEANER.clean(rendered).strip() except Exception as e: logger.debug( "Matrix markdown rendering failed ({}): {}", diff --git a/pyproject.toml b/pyproject.toml index 82b37a3..12a1ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "matrix-nio[e2e]>=0.25.2", - "mistune>=3.0.0", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.optional-dependencies] diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 2e3dad2..616b0bc 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -421,7 +421,7 @@ async def test_send_adds_formatted_body_for_markdown() -> None: assert content["format"] == MATRIX_HTML_FORMAT assert "

Headline

" in str(content["formatted_body"]) assert "
" in str(content["formatted_body"]) - assert "task-list-item-checkbox" in str(content["formatted_body"]) + assert "
  • [x] done
  • " in str(content["formatted_body"]) @pytest.mark.asyncio @@ -439,11 +439,55 @@ async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() - assert content["msgtype"] == "m.text" assert content["body"] == markdown_text assert content["format"] == MATRIX_HTML_FORMAT - assert '
    ' in str(content["formatted_body"]) + 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()) From 7b2adf9d9dab138341daedc3bfab52e4e495b1fa Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 16:29:31 +0100 Subject: [PATCH 18/42] docs(matrix): document raw html escaping in markdown renderer --- nanobot/channels/matrix.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 8240b51..f00f321 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -32,6 +32,10 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" # that Matrix clients (e.g. Element/FluffyChat) can render consistently. # We intentionally avoid plugins that emit less-portable tags to keep output # predictable across clients. +# escape=True is intentional: raw HTML from model output is rendered as text, +# not as live HTML. This includes Matrix-specific raw snippets such as +# and
    , unless we later add explicit +# structured support for those features. MATRIX_MARKDOWN = create_markdown( escape=True, plugins=["table", "strikethrough", "url", "superscript", "subscript"], From a482a89df6b25e03a89b535c7af542b0bc766ab9 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:09:06 +0100 Subject: [PATCH 19/42] feat(matrix): support inbound media attachments --- nanobot/channels/matrix.py | 357 ++++++++++++++++++++++++++++++++--- nanobot/config/schema.py | 2 + tests/test_matrix_channel.py | 223 ++++++++++++++++++++++ 3 files changed, 556 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index f00f321..3edcf63 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,5 +1,7 @@ import asyncio import logging +import mimetypes +from pathlib import Path from typing import Any import nh3 @@ -8,52 +10,69 @@ from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, + DownloadError, InviteEvent, JoinError, MatrixRoom, + MemoryDownloadResponse, + RoomEncryptedAudio, + RoomEncryptedFile, + RoomEncryptedImage, + RoomEncryptedVideo, + RoomMessageAudio, + RoomMessageFile, + RoomMessageImage, RoomMessageText, + RoomMessageVideo, RoomSendError, RoomTypingError, SyncError, ) +from nio.crypto.attachments import decrypt_attachment +from nio.exceptions import EncryptionError from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" +MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" +MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" +MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" +MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" -# Keep plugin output aligned with Matrix recommended HTML tags: -# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - table/strikethrough are already used in replies. -# - url, superscript, and subscript map to common tags (, , ) -# that Matrix clients (e.g. Element/FluffyChat) can render consistently. -# We intentionally avoid plugins that emit less-portable tags to keep output -# predictable across clients. -# escape=True is intentional: raw HTML from model output is rendered as text, -# not as live HTML. This includes Matrix-specific raw snippets such as -# and
    , unless we later add explicit -# structured support for those features. +MATRIX_MEDIA_EVENT_TYPES = ( + RoomMessageImage, + RoomMessageFile, + RoomMessageAudio, + RoomMessageVideo, + RoomEncryptedImage, + RoomEncryptedFile, + RoomEncryptedAudio, + RoomEncryptedVideo, +) + +# Markdown renderer policy: +# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes +# - Only enable portable features that map cleanly to Matrix-compatible HTML. +# - escape=True ensures raw model HTML is treated as text unless we explicitly +# add structured support for Matrix-specific HTML features later. MATRIX_MARKDOWN = create_markdown( escape=True, plugins=["table", "strikethrough", "url", "superscript", "subscript"], ) -# Sanitizer policy rationale: -# - Baseline follows Matrix formatted message guidance: -# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - We intentionally use a tighter subset than the full spec to keep behavior -# predictable across clients and reduce risk from LLM-generated content. -# - URLs are restricted to common safe schemes for links, and image sources are -# additionally constrained to mxc:// for Matrix-native media handling. -# - Spec items intentionally NOT enabled yet: -# - href schemes ftp/magnet (we keep link schemes smaller for now). -# - a[target] (clients already control link-opening behavior). -# - span[data-mx-bg-color|data-mx-color|data-mx-spoiler|data-mx-maths] -# - div[data-mx-maths] -# These can be added later when we explicitly support those Matrix features. +# Sanitizer policy: +# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes +# - Start from Matrix formatted-message guidance, but keep a smaller allowlist +# to reduce risk and keep client behavior predictable for LLM output. +# - Enforce mxc:// for img src to align media rendering with Matrix content +# repository semantics. +# - Unused spec-permitted features (e.g. some href schemes and data-mx-* attrs) +# are intentionally deferred until explicitly needed. MATRIX_ALLOWED_HTML_TAGS = { "p", "a", @@ -292,6 +311,7 @@ class MatrixChannel(BaseChannel): def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_TYPES) self.client.add_event_callback(self._on_room_invite, InviteEvent) def _register_response_callbacks(self) -> None: @@ -371,7 +391,7 @@ class MatrixChannel(BaseChannel): member_count = getattr(room, "member_count", None) return isinstance(member_count, int) and member_count <= 2 - def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessageText) -> bool: + def _is_bot_mentioned_from_mx_mentions(self, event: Any) -> bool: """Resolve mentions strictly from Matrix-native m.mentions payload.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -391,7 +411,7 @@ class MatrixChannel(BaseChannel): return bool(self.config.allow_room_mentions and mentions.get("room") is True) - def _should_process_message(self, room: MatrixRoom, event: RoomMessageText) -> bool: + def _should_process_message(self, room: MatrixRoom, event: Any) -> bool: """Apply sender and room policy checks before processing Matrix messages.""" if not self.is_allowed(event.sender): return False @@ -409,6 +429,253 @@ class MatrixChannel(BaseChannel): return False + def _media_dir(self) -> Path: + """Return directory used to persist downloaded Matrix attachments.""" + media_dir = get_data_dir() / "media" / "matrix" + media_dir.mkdir(parents=True, exist_ok=True) + return media_dir + + @staticmethod + def _event_source_content(event: Any) -> dict[str, Any]: + """Extract Matrix event content payload when available.""" + source = getattr(event, "source", None) + if not isinstance(source, dict): + return {} + content = source.get("content") + return content if isinstance(content, dict) else {} + + def _event_attachment_type(self, event: Any) -> str: + """Map Matrix event payload/type to a stable attachment kind.""" + msgtype = self._event_source_content(event).get("msgtype") + if msgtype == "m.image": + return "image" + if msgtype == "m.audio": + return "audio" + if msgtype == "m.video": + return "video" + if msgtype == "m.file": + return "file" + + class_name = type(event).__name__.lower() + if "image" in class_name: + return "image" + if "audio" in class_name: + return "audio" + if "video" in class_name: + return "video" + return "file" + + @staticmethod + def _is_encrypted_media_event(event: Any) -> bool: + """Return True for encrypted Matrix media events.""" + return ( + isinstance(getattr(event, "key", None), dict) + and isinstance(getattr(event, "hashes", None), dict) + and isinstance(getattr(event, "iv", None), str) + ) + + def _event_declared_size_bytes(self, event: Any) -> int | None: + """Return declared media size from Matrix event info, if present.""" + info = self._event_source_content(event).get("info") + if not isinstance(info, dict): + return None + size = info.get("size") + if isinstance(size, int) and size >= 0: + return size + return None + + def _event_mime(self, event: Any) -> str | None: + """Best-effort MIME extraction from Matrix media event.""" + info = self._event_source_content(event).get("info") + if isinstance(info, dict): + mime = info.get("mimetype") + if isinstance(mime, str) and mime: + return mime + + mime = getattr(event, "mimetype", None) + if isinstance(mime, str) and mime: + return mime + return None + + def _event_filename(self, event: Any, attachment_type: str) -> str: + """Build a safe filename for a Matrix attachment.""" + body = getattr(event, "body", None) + if isinstance(body, str) and body.strip(): + candidate = safe_filename(Path(body).name) + if candidate: + return candidate + return MATRIX_DEFAULT_ATTACHMENT_NAME if attachment_type == "file" else attachment_type + + def _build_attachment_path( + self, + event: Any, + attachment_type: str, + filename: str, + mime: str | None, + ) -> Path: + """Compute a deterministic local file path for a downloaded attachment.""" + safe_name = safe_filename(Path(filename).name) or MATRIX_DEFAULT_ATTACHMENT_NAME + suffix = Path(safe_name).suffix + if not suffix and mime: + guessed = mimetypes.guess_extension(mime, strict=False) or "" + if guessed: + safe_name = f"{safe_name}{guessed}" + suffix = guessed + + stem = Path(safe_name).stem or attachment_type + stem = stem[:72] + suffix = suffix[:16] + + event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$")) + event_prefix = (event_id[:24] or "evt").strip("_") + return self._media_dir() / f"{event_prefix}_{stem}{suffix}" + + async def _download_media_bytes(self, mxc_url: str) -> bytes | None: + """Download media bytes from Matrix content repository.""" + if not self.client: + return None + + response = await self.client.download(mxc=mxc_url) + if isinstance(response, DownloadError): + logger.warning("Matrix attachment download failed for {}: {}", mxc_url, response) + return None + + body = getattr(response, "body", None) + if isinstance(body, (bytes, bytearray)): + return bytes(body) + + if isinstance(response, MemoryDownloadResponse): + return bytes(response.body) + + if isinstance(body, (str, Path)): + path = Path(body) + if path.is_file(): + try: + return path.read_bytes() + except OSError as e: + logger.warning( + "Matrix attachment read failed for {} ({}): {}", + mxc_url, + type(e).__name__, + str(e), + ) + return None + + logger.warning( + "Matrix attachment download failed for {}: unexpected response type {}", + mxc_url, + type(response).__name__, + ) + return None + + def _decrypt_media_bytes(self, event: Any, ciphertext: bytes) -> bytes | None: + """Decrypt encrypted Matrix attachment bytes.""" + key_obj = getattr(event, "key", None) + hashes = getattr(event, "hashes", None) + iv = getattr(event, "iv", None) + + key = key_obj.get("k") if isinstance(key_obj, dict) else None + sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None + if not isinstance(key, str) or not isinstance(sha256, str) or not isinstance(iv, str): + logger.warning( + "Matrix encrypted attachment missing key material for event {}", + getattr(event, "event_id", ""), + ) + return None + + try: + return decrypt_attachment(ciphertext, key, sha256, iv) + except (EncryptionError, ValueError, TypeError) as e: + logger.warning( + "Matrix encrypted attachment decryption failed for event {} ({}): {}", + getattr(event, "event_id", ""), + type(e).__name__, + str(e), + ) + return None + + async def _fetch_media_attachment( + self, + room: MatrixRoom, + event: Any, + ) -> tuple[dict[str, Any] | None, str]: + """Download and prepare a Matrix attachment for inbound processing.""" + attachment_type = self._event_attachment_type(event) + mime = self._event_mime(event) + filename = self._event_filename(event, attachment_type) + mxc_url = getattr(event, "url", None) + + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + logger.warning( + "Matrix attachment skipped in room {}: invalid mxc URL {}", + room.room_id, + mxc_url, + ) + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + declared_size = self._event_declared_size_bytes(event) + if ( + declared_size is not None + and declared_size > self.config.max_inbound_media_bytes + ): + logger.warning( + "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", + room.room_id, + declared_size, + self.config.max_inbound_media_bytes, + ) + return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + downloaded = await self._download_media_bytes(mxc_url) + if downloaded is None: + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + encrypted = self._is_encrypted_media_event(event) + data = downloaded + if encrypted: + decrypted = self._decrypt_media_bytes(event, downloaded) + if decrypted is None: + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + data = decrypted + + if len(data) > self.config.max_inbound_media_bytes: + logger.warning( + "Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}", + room.room_id, + len(data), + self.config.max_inbound_media_bytes, + ) + return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + path = self._build_attachment_path( + event, + attachment_type, + filename, + mime, + ) + try: + path.write_bytes(data) + except OSError as e: + logger.warning( + "Matrix attachment persist failed for room {} ({}): {}", + room.room_id, + type(e).__name__, + str(e), + ) + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + attachment = { + "type": attachment_type, + "mime": mime, + "filename": filename, + "event_id": str(getattr(event, "event_id", "") or ""), + "encrypted": encrypted, + "size_bytes": len(data), + "path": str(path), + "mxc_url": mxc_url, + } + return attachment, MATRIX_ATTACHMENT_MARKER_TEMPLATE.format(path) + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: @@ -428,3 +695,41 @@ class MatrixChannel(BaseChannel): except Exception: await self._set_typing(room.room_id, False) raise + + async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: + """Handle inbound Matrix media events and forward local attachment paths.""" + if event.sender == self.config.user_id: + return + + if not self._should_process_message(room, event): + return + + attachment, marker = await self._fetch_media_attachment(room, event) + attachments = [attachment] if attachment else [] + markers = [marker] + media_paths = [a["path"] for a in attachments] + + body = getattr(event, "body", None) + content_parts: list[str] = [] + if isinstance(body, str) and body.strip(): + content_parts.append(body.strip()) + content_parts.extend(markers) + + # TODO: Optionally add audio transcription support for Matrix attachments, + # similar to Telegram's voice/audio flow, behind explicit config. + + await self._set_typing(room.room_id, True) + try: + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content="\n".join(content_parts), + media=media_paths, + metadata={ + "room": getattr(room, "display_name", room.room_id), + "attachments": attachments, + }, + ) + except Exception: + await self._set_typing(room.room_id, False) + raise diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index d442104..f0ee410 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -74,6 +74,8 @@ class MatrixConfig(Base): device_id: str = "" # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 + # Max attachment size accepted from inbound Matrix media events. + max_inbound_media_bytes: int = 20 * 1024 * 1024 allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 616b0bc..932e612 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1,3 +1,4 @@ +from pathlib import Path from types import SimpleNamespace import pytest @@ -43,6 +44,11 @@ class _FakeAsyncClient: self.response_callbacks: list[tuple[object, 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.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.raise_on_send = False self.raise_on_typing = False @@ -89,6 +95,16 @@ class _FakeAsyncClient: 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 close(self) -> None: return None @@ -133,6 +149,7 @@ async def test_start_skips_load_store_when_device_id_missing( assert len(clients) == 1 assert clients[0].load_store_called is False + assert len(clients[0].callbacks) == 3 assert len(clients[0].response_callbacks) == 3 await channel.stop() @@ -374,6 +391,212 @@ async def test_on_message_room_mention_requires_opt_in() -> None: assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] +@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 + + 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_respects_declared_size_limit( + monkeypatch, tmp_path +) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(max_inbound_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_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) + client.download_response = matrix_module.DownloadError("download failed") + 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="$event3", + source={"content": {"msgtype": "m.image"}}, + ) + + 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 + + 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 + + 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"}}, + ) + + 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(), MessageBus()) From ca66ddb0bf9886a9663f41defc7ffc942fd7131c Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:21:13 +0100 Subject: [PATCH 20/42] feat(matrix): refresh typing indicator while processing --- nanobot/channels/matrix.py | 50 ++++++++++++++++++++++++++++++++---- tests/test_matrix_channel.py | 37 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 3edcf63..5893bb2 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -38,6 +38,7 @@ from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 +TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" @@ -224,6 +225,7 @@ class MatrixChannel(BaseChannel): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} async def start(self) -> None: """Start Matrix client and begin sync loop.""" @@ -272,6 +274,9 @@ class MatrixChannel(BaseChannel): """Stop the Matrix channel with graceful sync shutdown.""" self._running = False + for room_id in list(self._typing_tasks): + await self._stop_typing_keepalive(room_id, clear_typing=False) + if self.client: # Request sync_forever loop to exit cleanly. self.client.stop_sync_forever() @@ -306,7 +311,7 @@ class MatrixChannel(BaseChannel): ignore_unverified_devices=True, ) finally: - await self._set_typing(msg.chat_id, False) + await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" @@ -368,6 +373,41 @@ class MatrixChannel(BaseChannel): str(e), ) + async def _start_typing_keepalive(self, room_id: str) -> None: + """Start periodic Matrix typing refresh for a room.""" + await self._stop_typing_keepalive(room_id, clear_typing=False) + await self._set_typing(room_id, True) + if not self._running: + return + + async def _typing_loop() -> None: + try: + while self._running: + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_SECONDS) + await self._set_typing(room_id, True) + except asyncio.CancelledError: + pass + + self._typing_tasks[room_id] = asyncio.create_task(_typing_loop()) + + async def _stop_typing_keepalive( + self, + room_id: str, + *, + clear_typing: bool, + ) -> None: + """Stop periodic Matrix typing refresh for a room.""" + task = self._typing_tasks.pop(room_id, None) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + if clear_typing: + await self._set_typing(room_id, False) + async def _sync_loop(self) -> None: while self._running: try: @@ -684,7 +724,7 @@ class MatrixChannel(BaseChannel): if not self._should_process_message(room, event): return - await self._set_typing(room.room_id, True) + await self._start_typing_keepalive(room.room_id) try: await self._handle_message( sender_id=event.sender, @@ -693,7 +733,7 @@ class MatrixChannel(BaseChannel): metadata={"room": getattr(room, "display_name", room.room_id)}, ) except Exception: - await self._set_typing(room.room_id, False) + await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: @@ -718,7 +758,7 @@ class MatrixChannel(BaseChannel): # TODO: Optionally add audio transcription support for Matrix attachments, # similar to Telegram's voice/audio flow, behind explicit config. - await self._set_typing(room.room_id, True) + await self._start_typing_keepalive(room.room_id) try: await self._handle_message( sender_id=event.sender, @@ -731,5 +771,5 @@ class MatrixChannel(BaseChannel): }, ) except Exception: - await self._set_typing(room.room_id, False) + await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 932e612..6a33a5e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from types import SimpleNamespace @@ -224,6 +225,24 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None: ] +@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_SECONDS", 0.01) + + 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()) @@ -612,6 +631,24 @@ async def test_send_clears_typing_after_send() -> None: assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@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_clears_typing_when_send_fails() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 8b3171ca2b59001499a7744d15caf9fd740f86ca Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:21:52 +0100 Subject: [PATCH 21/42] fix(matrix): include empty m.mentions in outgoing messages --- nanobot/channels/matrix.py | 11 +++++++++-- tests/test_matrix_channel.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 5893bb2..504e11f 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -173,9 +173,16 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str) -> dict[str, str]: +def _build_matrix_text_content(text: str) -> dict[str, Any]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" - content: dict[str, str] = {"msgtype": "m.text", "body": text} + content: dict[str, Any] = { + "msgtype": "m.text", + "body": text, + # Matrix spec recommends always including m.mentions for message + # semantics/interoperability, even when no mentions are present. + # https://spec.matrix.org/v1.17/client-server-api/#mmentions + "m.mentions": {}, + } formatted_html = _render_markdown_html(text) if not formatted_html: return content diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 6a33a5e..f55fd0f 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -627,7 +627,11 @@ async def test_send_clears_typing_after_send() -> None: ) assert len(client.room_send_calls) == 1 - assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": "Hi"} + assert client.room_send_calls[0]["content"] == { + "msgtype": "m.text", + "body": "Hi", + "m.mentions": {}, + } assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @@ -678,6 +682,7 @@ async def test_send_adds_formatted_body_for_markdown() -> None: 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"]) @@ -698,6 +703,7 @@ async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() - 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"] @@ -764,7 +770,7 @@ async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypat ) content = client.room_send_calls[0]["content"] - assert content == {"msgtype": "m.text", "body": markdown_text} + assert content == {"msgtype": "m.text", "body": markdown_text, "m.mentions": {}} @pytest.mark.asyncio @@ -778,4 +784,8 @@ async def test_send_keeps_plaintext_only_for_plain_text() -> None: OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text) ) - assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": text} + assert client.room_send_calls[0]["content"] == { + "msgtype": "m.text", + "body": text, + "m.mentions": {}, + } From 085a311d4ba9d15bfc5185d926667eed5f38c729 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:25:01 +0100 Subject: [PATCH 22/42] docs(matrix): clarify typing keepalive spec notes --- nanobot/channels/matrix.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 504e11f..a7ff851 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -37,7 +37,13 @@ from nanobot.config.loader import get_data_dir from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 +# Typing state lifetime advertised to Matrix clients/servers. TYPING_NOTICE_TIMEOUT_MS = 30_000 +# Matrix typing notifications are ephemeral; spec guidance is to keep +# refreshing while work is ongoing (practically ~20-30s cadence). +# https://spec.matrix.org/v1.17/client-server-api/#typing-notifications +# Keepalive interval must stay below TYPING_NOTICE_TIMEOUT_MS so the typing +# indicator does not expire while the agent is still processing. TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" @@ -173,9 +179,9 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str) -> dict[str, Any]: +def _build_matrix_text_content(text: str) -> dict[str, object]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" - content: dict[str, Any] = { + content: dict[str, object] = { "msgtype": "m.text", "body": text, # Matrix spec recommends always including m.mentions for message @@ -381,7 +387,7 @@ class MatrixChannel(BaseChannel): ) async def _start_typing_keepalive(self, room_id: str) -> None: - """Start periodic Matrix typing refresh for a room.""" + """Start periodic Matrix typing refresh for a room (spec-recommended keepalive).""" await self._stop_typing_keepalive(room_id, clear_typing=False) await self._set_typing(room_id, True) if not self._running: From 566ad1dfc793a1c48dcd1687e9ea8369f7f82ba3 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:50:27 +0100 Subject: [PATCH 23/42] feat(matrix): make e2ee configurable with enabled default --- nanobot/channels/matrix.py | 26 ++++++++++---- nanobot/config/schema.py | 2 ++ tests/test_matrix_channel.py | 70 +++++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a7ff851..35c4000 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -254,7 +254,7 @@ class MatrixChannel(BaseChannel): store_path=store_path, # Where tokens are saved config=AsyncClientConfig( store_sync_tokens=True, # Auto-persists next_batch tokens - encryption_enabled=True, + encryption_enabled=self.config.e2ee_enabled, ), ) @@ -265,6 +265,14 @@ class MatrixChannel(BaseChannel): self._register_event_callbacks() self._register_response_callbacks() + if self.config.e2ee_enabled: + logger.info("Matrix E2EE is enabled.") + else: + logger.warning( + "Matrix E2EE is disabled; encrypted room messages may be undecryptable and " + "encrypted-device verification is not applied on send." + ) + if self.config.device_id: try: self.client.load_store() @@ -316,13 +324,17 @@ class MatrixChannel(BaseChannel): if not self.client: return + room_send_kwargs: dict[str, Any] = { + "room_id": msg.chat_id, + "message_type": "m.room.message", + "content": _build_matrix_text_content(msg.content), + } + if self.config.e2ee_enabled: + # TODO(matrix): Add explicit config for strict verified-device sending mode. + room_send_kwargs["ignore_unverified_devices"] = True + try: - await self.client.room_send( - room_id=msg.chat_id, - message_type="m.room.message", - content=_build_matrix_text_content(msg.content), - ignore_unverified_devices=True, - ) + await self.client.room_send(**room_send_kwargs) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f0ee410..0861073 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -72,6 +72,8 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" + # Enable Matrix E2EE support (encryption + encrypted room handling). + e2ee_enabled: bool = True # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 # Max attachment size accepted from inbound Matrix media events. diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f55fd0f..6ea955d 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -14,6 +14,8 @@ from nanobot.channels.matrix import ( ) from nanobot.config.schema import MatrixConfig +_ROOM_SEND_UNSET = object() + class _DummyTask: def __init__(self) -> None: @@ -73,16 +75,16 @@ class _FakeAsyncClient: room_id: str, message_type: str, content: dict[str, object], - ignore_unverified_devices: bool, + ignore_unverified_devices: object = _ROOM_SEND_UNSET, ) -> None: - self.room_send_calls.append( - { - "room_id": room_id, - "message_type": message_type, - "content": content, - "ignore_unverified_devices": ignore_unverified_devices, - } - ) + 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") @@ -149,6 +151,7 @@ async def test_start_skips_load_store_when_device_id_missing( 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 len(clients[0].response_callbacks) == 3 @@ -156,6 +159,40 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.stop() +@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_stop_stops_sync_forever_before_close(monkeypatch) -> None: channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus()) @@ -632,9 +669,24 @@ async def test_send_clears_typing_after_send() -> None: "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_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()) From 9b06f682c317698e1a53c7ea36dafa24105816bb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:50:34 +0100 Subject: [PATCH 24/42] docs(readme): document matrix e2eeEnabled option --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index a474367..45de967 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,70 @@ nanobot gateway +
    +Matrix (Element) + +Uses Matrix sync via `matrix-nio` (including inbound media support). + +**1. Create/choose a Matrix account** + +- Create or reuse a Matrix account on your homeserver (for example `matrix.org`). +- Confirm you can log in with Element. + +**2. Get credentials** + +- You need: + - `userId` (example: `@nanobot:matrix.org`) + - `accessToken` + - `deviceId` (recommended so sync tokens can be restored across restarts) +- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings. + +**3. Configure** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "userId": "@nanobot:matrix.org", + "accessToken": "syt_xxx", + "deviceId": "NANOBOT01", + "e2eeEnabled": true, + "allowFrom": [], + "groupPolicy": "open", + "groupAllowFrom": [], + "allowRoomMentions": false, + "maxInboundMediaBytes": 20971520 + } + } +} +``` + +> `allowFrom`: Empty allows all senders; set user IDs to restrict access. +> `groupPolicy`: `open`, `mention`, or `allowlist`. +> `groupAllowFrom`: Room allowlist used when `groupPolicy` is `allowlist`. +> `allowRoomMentions`: If `true`, accepts `@room` (`m.mentions.room`) in mention mode. +> `e2eeEnabled`: Enables Matrix E2EE support (default `true`); set `false` only for plaintext-only setups. +> `maxInboundMediaBytes`: Max inbound attachment size in bytes (default `20MB`). + +> [!NOTE] +> Matrix E2EE implications: +> +> - Keep a persistent `matrix-store` and stable `deviceId`; otherwise encrypted session state can be lost after restart. +> - In newly joined encrypted rooms, initial messages may fail until Olm/Megolm sessions are established. +> - With `e2eeEnabled=false`, encrypted room messages may be undecryptable and E2EE send safeguards are not applied. +> - With `e2eeEnabled=true`, the bot sends with `ignore_unverified_devices=true` (more compatible, less strict than verified-only sending). +> - Changing `accessToken`/`deviceId` effectively creates a new device and may require session re-establishment. + +**4. Run** + +```bash +nanobot gateway +``` + +
    +
    WhatsApp From 1103f000fc803918f70eb180573e5eb35ed95ae6 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 22:41:29 +0100 Subject: [PATCH 25/42] docs(matrix): clarify m.text body plaintext fallback note --- nanobot/channels/matrix.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 35c4000..fcff534 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -183,6 +183,10 @@ def _build_matrix_text_content(text: str) -> dict[str, object]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" content: dict[str, object] = { "msgtype": "m.text", + # Note: When `formatted_body` is present, Matrix spec expects `body` to + # be its plaintext representation (fallback for clients without HTML). + # We currently keep raw text (often markdown) for simplicity. + # https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes "body": text, # Matrix spec recommends always including m.mentions for message # semantics/interoperability, even when no mentions are present. From 10de3bf329bc5e19a7fbe995b28bb8ed59b9fe85 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Thu, 12 Feb 2026 22:58:53 +0100 Subject: [PATCH 26/42] refactor(matrix): use base media event filter for callbacks - Replaces the explicit media event tuple with MATRIX_MEDIA_EVENT_FILTER based on media base classes: (RoomMessageMedia, RoomEncryptedMedia). - Keeps MatrixMediaEvent as the static typing alias for media-specific handlers. - Removes MatrixInboundEvent and uses RoomMessage in mention-related logic. - Adds regression tests for: - callback registration using MATRIX_MEDIA_EVENT_FILTER - ensuring RoomMessageText is not matched by the media filter. --- nanobot/channels/matrix.py | 98 +++++++++++++++++++++++------------- tests/test_matrix_channel.py | 17 +++++++ 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index fcff534..51df4e8 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,7 +2,7 @@ import asyncio import logging import mimetypes from pathlib import Path -from typing import Any +from typing import Any, TypeAlias import nh3 from loguru import logger @@ -15,15 +15,10 @@ from nio import ( JoinError, MatrixRoom, MemoryDownloadResponse, - RoomEncryptedAudio, - RoomEncryptedFile, - RoomEncryptedImage, - RoomEncryptedVideo, - RoomMessageAudio, - RoomMessageFile, - RoomMessageImage, + RoomEncryptedMedia, + RoomMessage, + RoomMessageMedia, RoomMessageText, - RoomMessageVideo, RoomSendError, RoomTypingError, SyncError, @@ -51,16 +46,10 @@ MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" -MATRIX_MEDIA_EVENT_TYPES = ( - RoomMessageImage, - RoomMessageFile, - RoomMessageAudio, - RoomMessageVideo, - RoomEncryptedImage, - RoomEncryptedFile, - RoomEncryptedAudio, - RoomEncryptedVideo, -) +# Runtime callback filter for nio event dispatch (checked via isinstance). +MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) +# Static typing alias for media-specific handlers/helpers. +MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia # Markdown renderer policy: # https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes @@ -345,7 +334,7 @@ class MatrixChannel(BaseChannel): def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" self.client.add_event_callback(self._on_message, RoomMessageText) - self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_TYPES) + self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) self.client.add_event_callback(self._on_room_invite, InviteEvent) def _register_response_callbacks(self) -> None: @@ -460,7 +449,7 @@ class MatrixChannel(BaseChannel): member_count = getattr(room, "member_count", None) return isinstance(member_count, int) and member_count <= 2 - def _is_bot_mentioned_from_mx_mentions(self, event: Any) -> bool: + def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessage) -> bool: """Resolve mentions strictly from Matrix-native m.mentions payload.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -480,7 +469,7 @@ class MatrixChannel(BaseChannel): return bool(self.config.allow_room_mentions and mentions.get("room") is True) - def _should_process_message(self, room: MatrixRoom, event: Any) -> bool: + def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool: """Apply sender and room policy checks before processing Matrix messages.""" if not self.is_allowed(event.sender): return False @@ -505,7 +494,7 @@ class MatrixChannel(BaseChannel): return media_dir @staticmethod - def _event_source_content(event: Any) -> dict[str, Any]: + def _event_source_content(event: RoomMessage) -> dict[str, Any]: """Extract Matrix event content payload when available.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -513,7 +502,47 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_attachment_type(self, event: Any) -> str: + def _event_thread_root_id(self, event: RoomMessage) -> str | None: + """Return thread root event_id if this message is inside a thread.""" + content = self._event_source_content(event) + relates_to = content.get("m.relates_to") + if not isinstance(relates_to, dict): + return None + if relates_to.get("rel_type") != "m.thread": + return None + root_id = relates_to.get("event_id") + return root_id if isinstance(root_id, str) and root_id else None + + def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: + """Build metadata used to reply within a thread.""" + root_id = self._event_thread_root_id(event) + if not root_id: + return None + reply_to = getattr(event, "event_id", None) + meta: dict[str, str] = {"thread_root_event_id": root_id} + if isinstance(reply_to, str) and reply_to: + meta["thread_reply_to_event_id"] = reply_to + return meta + + @staticmethod + def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None: + """Build m.relates_to payload for Matrix thread replies.""" + if not metadata: + return None + root_id = metadata.get("thread_root_event_id") + if not isinstance(root_id, str) or not root_id: + return None + reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id") + if not isinstance(reply_to, str) or not reply_to: + return None + return { + "rel_type": "m.thread", + "event_id": root_id, + "m.in_reply_to": {"event_id": reply_to}, + "is_falling_back": True, + } + + def _event_attachment_type(self, event: MatrixMediaEvent) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -535,7 +564,7 @@ class MatrixChannel(BaseChannel): return "file" @staticmethod - def _is_encrypted_media_event(event: Any) -> bool: + def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool: """Return True for encrypted Matrix media events.""" return ( isinstance(getattr(event, "key", None), dict) @@ -543,7 +572,7 @@ class MatrixChannel(BaseChannel): and isinstance(getattr(event, "iv", None), str) ) - def _event_declared_size_bytes(self, event: Any) -> int | None: + def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: """Return declared media size from Matrix event info, if present.""" info = self._event_source_content(event).get("info") if not isinstance(info, dict): @@ -553,7 +582,7 @@ class MatrixChannel(BaseChannel): return size return None - def _event_mime(self, event: Any) -> str | None: + def _event_mime(self, event: MatrixMediaEvent) -> str | None: """Best-effort MIME extraction from Matrix media event.""" info = self._event_source_content(event).get("info") if isinstance(info, dict): @@ -566,7 +595,7 @@ class MatrixChannel(BaseChannel): return mime return None - def _event_filename(self, event: Any, attachment_type: str) -> str: + def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str: """Build a safe filename for a Matrix attachment.""" body = getattr(event, "body", None) if isinstance(body, str) and body.strip(): @@ -577,7 +606,7 @@ class MatrixChannel(BaseChannel): def _build_attachment_path( self, - event: Any, + event: MatrixMediaEvent, attachment_type: str, filename: str, mime: str | None, @@ -637,7 +666,7 @@ class MatrixChannel(BaseChannel): ) return None - def _decrypt_media_bytes(self, event: Any, ciphertext: bytes) -> bytes | None: + def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: """Decrypt encrypted Matrix attachment bytes.""" key_obj = getattr(event, "key", None) hashes = getattr(event, "hashes", None) @@ -666,7 +695,7 @@ class MatrixChannel(BaseChannel): async def _fetch_media_attachment( self, room: MatrixRoom, - event: Any, + event: MatrixMediaEvent, ) -> tuple[dict[str, Any] | None, str]: """Download and prepare a Matrix attachment for inbound processing.""" attachment_type = self._event_attachment_type(event) @@ -683,10 +712,7 @@ class MatrixChannel(BaseChannel): return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) declared_size = self._event_declared_size_bytes(event) - if ( - declared_size is not None - and declared_size > self.config.max_inbound_media_bytes - ): + if declared_size is not None and declared_size > self.config.max_inbound_media_bytes: logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, @@ -765,7 +791,7 @@ class MatrixChannel(BaseChannel): await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise - async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: + async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None: """Handle inbound Matrix media events and forward local attachment paths.""" if event.sender == self.config.user_id: return diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 6ea955d..164ec2e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,23 @@ async def test_start_skips_load_store_when_device_id_missing( 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_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 From bfd2018095874f96ce9374b2fbecbc6af06a723d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 11:06:35 +0100 Subject: [PATCH 27/42] docs: update maxMediaBytes documentation to include blocking option Add clarification that setting to 0 blocks all attachments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 45de967..2d988c4 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). > `groupAllowFrom`: Room allowlist used when `groupPolicy` is `allowlist`. > `allowRoomMentions`: If `true`, accepts `@room` (`m.mentions.room`) in mention mode. > `e2eeEnabled`: Enables Matrix E2EE support (default `true`); set `false` only for plaintext-only setups. -> `maxInboundMediaBytes`: Max inbound attachment size in bytes (default `20MB`). +> `maxMediaBytes`: Max attachment size in bytes (default `20MB`) for inbound and outbound media handling; set to `0` to block all inbound and outbound attachment uploads. > [!NOTE] > Matrix E2EE implications: From 97cb85ee0b4851db446b1c6ce3ae38c48b40d450 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:45:28 +0100 Subject: [PATCH 28/42] feat(matrix): add outbound media uploads and unify media limits with maxMediaBytes - Use OutboundMessage.media for Matrix file/image/audio/video sends - Apply effective media limit as min(m.upload.size, maxMediaBytes) - Rename matrix config key maxInboundMediaBytes -> maxMediaBytes (no legacy fallback) --- README.md | 7 +- nanobot/channels/manager.py | 88 +++++------ nanobot/channels/matrix.py | 278 +++++++++++++++++++++++++++++++++-- nanobot/config/schema.py | 4 +- tests/test_matrix_channel.py | 169 ++++++++++++++++++++- 5 files changed, 482 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 2d988c4..ed7bdec 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ nanobot gateway
    Matrix (Element) -Uses Matrix sync via `matrix-nio` (including inbound media support). +Uses Matrix sync via `matrix-nio` (inbound media + outbound file attachments). **1. Create/choose a Matrix account** @@ -335,7 +335,7 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). "groupPolicy": "open", "groupAllowFrom": [], "allowRoomMentions": false, - "maxInboundMediaBytes": 20971520 + "maxMediaBytes": 20971520 } } } @@ -356,6 +356,9 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). > - With `e2eeEnabled=false`, encrypted room messages may be undecryptable and E2EE send safeguards are not applied. > - With `e2eeEnabled=true`, the bot sends with `ignore_unverified_devices=true` (more compatible, less strict than verified-only sending). > - Changing `accessToken`/`deviceId` effectively creates a new device and may require session re-establishment. +> - Outbound attachments are sent from `OutboundMessage.media`. +> - Effective media limit (inbound + outbound) uses the stricter value of local `maxMediaBytes` and homeserver `m.upload.size` (if advertised). +> - If `tools.restrictToWorkspace=true`, Matrix outbound attachments are limited to files inside the workspace. **4. Run** diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e860d26..998d90c 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,28 +16,29 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel + self.channels["telegram"] = TelegramChannel( self.config.channels.telegram, self.bus, @@ -46,14 +47,13 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning(f"Telegram channel not available: {e}") - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel - self.channels["whatsapp"] = WhatsAppChannel( - self.config.channels.whatsapp, self.bus - ) + + self.channels["whatsapp"] = WhatsAppChannel(self.config.channels.whatsapp, self.bus) logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") @@ -62,20 +62,18 @@ class ChannelManager: if self.config.channels.discord.enabled: try: from nanobot.channels.discord import DiscordChannel - self.channels["discord"] = DiscordChannel( - self.config.channels.discord, self.bus - ) + + self.channels["discord"] = DiscordChannel(self.config.channels.discord, self.bus) logger.info("Discord channel enabled") except ImportError as e: logger.warning(f"Discord channel not available: {e}") - + # Feishu channel if self.config.channels.feishu.enabled: try: from nanobot.channels.feishu import FeishuChannel - self.channels["feishu"] = FeishuChannel( - self.config.channels.feishu, self.bus - ) + + self.channels["feishu"] = FeishuChannel(self.config.channels.feishu, self.bus) logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu channel not available: {e}") @@ -85,9 +83,7 @@ class ChannelManager: try: from nanobot.channels.mochat import MochatChannel - self.channels["mochat"] = MochatChannel( - self.config.channels.mochat, self.bus - ) + self.channels["mochat"] = MochatChannel(self.config.channels.mochat, self.bus) logger.info("Mochat channel enabled") except ImportError as e: logger.warning(f"Mochat channel not available: {e}") @@ -96,9 +92,8 @@ class ChannelManager: if self.config.channels.dingtalk.enabled: try: from nanobot.channels.dingtalk import DingTalkChannel - self.channels["dingtalk"] = DingTalkChannel( - self.config.channels.dingtalk, self.bus - ) + + self.channels["dingtalk"] = DingTalkChannel(self.config.channels.dingtalk, self.bus) logger.info("DingTalk channel enabled") except ImportError as e: logger.warning(f"DingTalk channel not available: {e}") @@ -107,9 +102,8 @@ class ChannelManager: if self.config.channels.email.enabled: try: from nanobot.channels.email import EmailChannel - self.channels["email"] = EmailChannel( - self.config.channels.email, self.bus - ) + + self.channels["email"] = EmailChannel(self.config.channels.email, self.bus) logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email channel not available: {e}") @@ -118,9 +112,8 @@ class ChannelManager: if self.config.channels.slack.enabled: try: from nanobot.channels.slack import SlackChannel - self.channels["slack"] = SlackChannel( - self.config.channels.slack, self.bus - ) + + self.channels["slack"] = SlackChannel(self.config.channels.slack, self.bus) logger.info("Slack channel enabled") except ImportError as e: logger.warning(f"Slack channel not available: {e}") @@ -129,6 +122,7 @@ class ChannelManager: if self.config.channels.qq.enabled: try: from nanobot.channels.qq import QQChannel + self.channels["qq"] = QQChannel( self.config.channels.qq, self.bus, @@ -136,7 +130,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning(f"QQ channel not available: {e}") - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -149,23 +143,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -173,7 +167,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -181,18 +175,15 @@ class ChannelManager: logger.info(f"Stopped {name} channel") except Exception as e: logger.error(f"Error stopping {name}: {e}") - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: - msg = await asyncio.wait_for( - self.bus.consume_outbound(), - timeout=1.0 - ) - + msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0) + channel = self.channels.get(msg.channel) if channel: try: @@ -201,26 +192,23 @@ class ChannelManager: logger.error(f"Error sending to {msg.channel}: {e}") else: logger.warning(f"Unknown channel: {msg.channel}") - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { - name: { - "enabled": True, - "running": channel.is_running - } + name: {"enabled": True, "running": channel.is_running} for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 51df4e8..28c3924 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -10,6 +10,7 @@ from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, + ContentRepositoryConfigError, DownloadError, InviteEvent, JoinError, @@ -22,6 +23,7 @@ from nio import ( RoomSendError, RoomTypingError, SyncError, + UploadError, ) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError @@ -44,6 +46,7 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" +MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE = "[attachment: {} - upload failed]" MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" # Runtime callback filter for nio event dispatch (checked via isinstance). @@ -227,11 +230,22 @@ class MatrixChannel(BaseChannel): name = "matrix" - def __init__(self, config: Any, bus): + def __init__( + self, + config: Any, + bus, + *, + restrict_to_workspace: bool = False, + workspace: Path | None = None, + ): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} + self._restrict_to_workspace = restrict_to_workspace + self._workspace = workspace.expanduser().resolve() if workspace else None + self._server_upload_limit_bytes: int | None = None + self._server_upload_limit_checked = False async def start(self) -> None: """Start Matrix client and begin sync loop.""" @@ -313,21 +327,266 @@ class MatrixChannel(BaseChannel): if self.client: await self.client.close() - async def send(self, msg: OutboundMessage) -> None: + @staticmethod + def _path_dedupe_key(path: Path) -> str: + """Return a stable deduplication key for attachment paths.""" + expanded = path.expanduser() + try: + return str(expanded.resolve(strict=False)) + except OSError: + return str(expanded) + + def _is_workspace_path_allowed(self, path: Path) -> bool: + """Enforce optional workspace-only outbound attachment policy.""" + if not self._restrict_to_workspace: + return True + + if self._workspace is None: + return False + + try: + path.resolve(strict=False).relative_to(self._workspace) + return True + except ValueError: + return False + + def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]: + """Collect unique outbound attachment paths from OutboundMessage.media.""" + candidates: list[Path] = [] + seen: set[str] = set() + + for raw in media: + if not isinstance(raw, str) or not raw.strip(): + continue + path = Path(raw.strip()).expanduser() + key = self._path_dedupe_key(path) + if key in seen: + continue + seen.add(key) + candidates.append(path) + + return candidates + + @staticmethod + def _build_outbound_attachment_content( + *, + filename: str, + mime: str, + size_bytes: int, + mxc_url: str, + ) -> dict[str, Any]: + """Build Matrix content payload for an uploaded file/image/audio/video.""" + msgtype = "m.file" + if mime.startswith("image/"): + msgtype = "m.image" + elif mime.startswith("audio/"): + msgtype = "m.audio" + elif mime.startswith("video/"): + msgtype = "m.video" + + return { + "msgtype": msgtype, + "body": filename, + "filename": filename, + "url": mxc_url, + "info": { + "mimetype": mime, + "size": size_bytes, + }, + "m.mentions": {}, + } + + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: + """Send Matrix m.room.message content with configured E2EE send options.""" if not self.client: return room_send_kwargs: dict[str, Any] = { - "room_id": msg.chat_id, + "room_id": room_id, "message_type": "m.room.message", - "content": _build_matrix_text_content(msg.content), + "content": content, } if self.config.e2ee_enabled: # TODO(matrix): Add explicit config for strict verified-device sending mode. room_send_kwargs["ignore_unverified_devices"] = True + await self.client.room_send(**room_send_kwargs) + + async def _resolve_server_upload_limit_bytes(self) -> int | None: + """Resolve homeserver-advertised upload limit once per channel lifecycle.""" + if self._server_upload_limit_checked: + return self._server_upload_limit_bytes + + self._server_upload_limit_checked = True + if not self.client: + return None + try: - await self.client.room_send(**room_send_kwargs) + response = await self.client.content_repository_config() + except Exception as e: + logger.debug( + "Matrix media config lookup failed ({}): {}", + type(e).__name__, + str(e), + ) + return None + + upload_size = getattr(response, "upload_size", None) + if isinstance(upload_size, int) and upload_size > 0: + self._server_upload_limit_bytes = upload_size + return self._server_upload_limit_bytes + + if isinstance(response, ContentRepositoryConfigError): + logger.debug("Matrix media config lookup failed: {}", response) + return None + + logger.debug( + "Matrix media config lookup returned unexpected response {}", + type(response).__name__, + ) + return None + + async def _effective_media_limit_bytes(self) -> int: + """ + Compute effective Matrix media size cap. + + `m.upload.size` (if advertised) is treated as the homeserver-side cap. + `maxMediaBytes` is a local hard limit/fallback. Using the stricter value + keeps resource usage predictable while honoring server constraints. + """ + local_limit = max(int(self.config.max_media_bytes), 0) + server_limit = await self._resolve_server_upload_limit_bytes() + if server_limit is None: + return local_limit + if local_limit == 0: + return 0 + return min(local_limit, server_limit) + + async def _upload_and_send_attachment( + self, room_id: str, path: Path, limit_bytes: int + ) -> str | None: + """Upload one local file to Matrix and send it as a media message.""" + if not self.client: + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format( + path.name or MATRIX_DEFAULT_ATTACHMENT_NAME + ) + + resolved = path.expanduser().resolve(strict=False) + filename = safe_filename(resolved.name) or MATRIX_DEFAULT_ATTACHMENT_NAME + + if not resolved.is_file(): + logger.warning("Matrix outbound attachment missing file: {}", resolved) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + if not self._is_workspace_path_allowed(resolved): + logger.warning( + "Matrix outbound attachment denied by workspace restriction: {}", + resolved, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + try: + size_bytes = resolved.stat().st_size + except OSError as e: + logger.warning( + "Matrix outbound attachment stat failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + if limit_bytes and size_bytes > limit_bytes: + logger.warning( + "Matrix outbound attachment skipped: {} bytes exceeds limit {} for {}", + size_bytes, + limit_bytes, + resolved, + ) + return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + try: + data = resolved.read_bytes() + except OSError as e: + logger.warning( + "Matrix outbound attachment read failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + upload_response = await self.client.upload( + data, + content_type=mime, + filename=filename, + filesize=len(data), + ) + if isinstance(upload_response, UploadError): + logger.warning( + "Matrix outbound attachment upload failed for {}: {}", + resolved, + upload_response, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + mxc_url = getattr(upload_response, "content_uri", None) + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + logger.warning( + "Matrix outbound attachment upload returned unexpected response {} for {}", + type(upload_response).__name__, + resolved, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + content = self._build_outbound_attachment_content( + filename=filename, + mime=mime, + size_bytes=len(data), + mxc_url=mxc_url, + ) + try: + await self._send_room_content(room_id, content) + except Exception as e: + logger.warning( + "Matrix outbound attachment send failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + return None + + async def send(self, msg: OutboundMessage) -> None: + if not self.client: + return + + text = msg.content or "" + candidates = self._collect_outbound_media_candidates(msg.media) + + try: + failures: list[str] = [] + + if candidates: + limit_bytes = await self._effective_media_limit_bytes() + for path in candidates: + failure_marker = await self._upload_and_send_attachment( + room_id=msg.chat_id, + path=path, + limit_bytes=limit_bytes, + ) + if failure_marker: + failures.append(failure_marker) + + if failures: + if text.strip(): + text = f"{text.rstrip()}\n" + "\n".join(failures) + else: + text = "\n".join(failures) + + if text or not candidates: + await self._send_room_content(msg.chat_id, _build_matrix_text_content(text)) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) @@ -711,13 +970,14 @@ class MatrixChannel(BaseChannel): ) return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + limit_bytes = await self._effective_media_limit_bytes() declared_size = self._event_declared_size_bytes(event) - if declared_size is not None and declared_size > self.config.max_inbound_media_bytes: + if declared_size is not None and declared_size > limit_bytes: logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, declared_size, - self.config.max_inbound_media_bytes, + limit_bytes, ) return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) @@ -733,12 +993,12 @@ class MatrixChannel(BaseChannel): return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) data = decrypted - if len(data) > self.config.max_inbound_media_bytes: + if len(data) > limit_bytes: logger.warning( "Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}", room.room_id, len(data), - self.config.max_inbound_media_bytes, + limit_bytes, ) return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0861073..690b9b2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -76,8 +76,8 @@ class MatrixConfig(Base): e2ee_enabled: bool = True # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 - # Max attachment size accepted from inbound Matrix media events. - max_inbound_media_bytes: int = 20 * 1024 * 1024 + # Max attachment size accepted for Matrix media handling (inbound + outbound). + max_media_bytes: int = 20 * 1024 * 1024 allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 164ec2e..d625aca 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -48,12 +48,16 @@ class _FakeAsyncClient: 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 def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) @@ -108,6 +112,32 @@ class _FakeAsyncClient: 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") + 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 + return SimpleNamespace(content_uri="mxc://example.org/uploaded") + + async def content_repository_config(self): + return self.content_repository_config_response + async def close(self) -> None: return None @@ -523,7 +553,7 @@ async def test_on_media_message_respects_declared_size_limit( ) -> None: monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) - channel = MatrixChannel(_make_config(max_inbound_media_bytes=3), MessageBus()) + channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -552,6 +582,42 @@ async def test_on_media_message_respects_declared_size_limit( 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) @@ -690,6 +756,107 @@ async def test_send_clears_typing_after_send() -> None: 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 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_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_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_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_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) From a28ae51ce9acaff5be585a43390d4b81c4d360a7 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:55:51 +0100 Subject: [PATCH 29/42] fix(matrix): handle matrix-nio upload tuple response --- nanobot/channels/matrix.py | 3 ++- tests/test_matrix_channel.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 28c3924..720aef7 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -517,12 +517,13 @@ class MatrixChannel(BaseChannel): return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - upload_response = await self.client.upload( + upload_result = await self.client.upload( data, content_type=mime, filename=filename, filesize=len(data), ) + upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result if isinstance(upload_response, UploadError): logger.warning( "Matrix outbound attachment upload failed for {}: {}", diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index d625aca..533d615 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -133,7 +133,7 @@ class _FakeAsyncClient: ) if self.upload_response is not None: return self.upload_response - return SimpleNamespace(content_uri="mxc://example.org/uploaded") + return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None async def content_repository_config(self): return self.content_repository_config_response From d4d87bb4e523d7a9f3691689478b6e9adbf763b8 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:57:00 +0100 Subject: [PATCH 30/42] fix(matrix): block outbound media when maxMediaBytes is zero --- nanobot/channels/matrix.py | 10 +++++++++- tests/test_matrix_channel.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 720aef7..eb921da 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -496,7 +496,15 @@ class MatrixChannel(BaseChannel): ) return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - if limit_bytes and size_bytes > limit_bytes: + if limit_bytes <= 0: + logger.warning( + "Matrix outbound attachment skipped: media limit {} blocks all uploads for {}", + limit_bytes, + resolved, + ) + return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + if size_bytes > limit_bytes: logger.warning( "Matrix outbound attachment skipped: {} bytes exceeds limit {} for {}", size_bytes, diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 533d615..222f14e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -857,6 +857,29 @@ async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_p 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()) From 6a4066575313980c519fb7966f56b5a450973fcb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 11:50:36 +0100 Subject: [PATCH 31/42] feat(matrix): support outbound attachments via message tool - extend message tool with optional media paths for channel delivery - switch Matrix uploads to stream providers and handle encrypted-room payloads - add/expand tests for message tool media forwarding and Matrix upload edge cases --- nanobot/agent/context.py | 7 +++- nanobot/agent/tools/message.py | 77 ++++++++++++++++++---------------- nanobot/channels/matrix.py | 51 +++++++++++++++------- tests/test_matrix_channel.py | 75 +++++++++++++++++++++++++++++++++ tests/test_message_tool.py | 37 ++++++++++++++++ 5 files changed, 195 insertions(+), 52 deletions(-) create mode 100644 tests/test_message_tool.py diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 876d43d..0253415 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -102,8 +102,11 @@ Your workspace is at: {workspace_path} - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +Use the 'message' tool only when you need explicit channel delivery behavior: +- Send to a different channel/chat than the current session +- Send one or more file attachments via `media` (local file paths) +For normal conversation text, respond directly without calling the message tool. +Do not claim that attachments are impossible if a channel supports file send and you can provide local paths. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3853725..c5efbf3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,84 +8,89 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "" + default_chat_id: str = "", ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: - return "Send a message to the user. Use this when you want to communicate something." - + return ( + "Send a message to the user. Supports optional media/attachment " + "paths for channels that can send files." + ) + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "content": { - "type": "string", - "description": "The message content to send" - }, + "content": {"type": "string", "description": "The message content to send"}, "channel": { "type": "string", - "description": "Optional: target channel (telegram, discord, etc.)" + "description": "Optional: target channel (telegram, discord, etc.)", }, - "chat_id": { - "type": "string", - "description": "Optional: target chat/user ID" + "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, + "media": { + "type": "array", + "description": "Optional: local file paths to send as attachments", + "items": {"type": "string"}, }, + "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, "media": { "type": "array", "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)" - } + "description": "Optional: list of file paths to attach (images, audio, documents)", + }, }, - "required": ["content"] + "required": ["content"], } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, media: list[str] | None = None, - **kwargs: Any + **kwargs: Any, ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - - msg = OutboundMessage( - channel=channel, - chat_id=chat_id, - content=content, - media=media or [] - ) - + + media_paths: list[str] = [] + for item in media or []: + if isinstance(item, str): + candidate = item.strip() + if candidate: + media_paths.append(candidate) + + msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media or []) + try: await self._send_callback(msg) media_info = f" with {len(media)} attachments" if media else "" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index eb921da..14d897b 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -374,6 +374,7 @@ class MatrixChannel(BaseChannel): mime: str, size_bytes: int, mxc_url: str, + encryption_info: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build Matrix content payload for an uploaded file/image/audio/video.""" msgtype = "m.file" @@ -384,11 +385,10 @@ class MatrixChannel(BaseChannel): elif mime.startswith("video/"): msgtype = "m.video" - return { + content: dict[str, Any] = { "msgtype": msgtype, "body": filename, "filename": filename, - "url": mxc_url, "info": { "mimetype": mime, "size": size_bytes, @@ -396,6 +396,24 @@ class MatrixChannel(BaseChannel): "m.mentions": {}, } + if encryption_info: + # Encrypted media events use `file` metadata (with url/hash/key/iv), + # while unencrypted media events use top-level `url`. + file_info = dict(encryption_info) + file_info["url"] = mxc_url + content["file"] = file_info + else: + content["url"] = mxc_url + + return content + + def _is_encrypted_room(self, room_id: str) -> bool: + """Return True if the Matrix room is known as encrypted.""" + if not self.client: + return False + room = getattr(self.client, "rooms", {}).get(room_id) + return bool(getattr(room, "encrypted", False)) + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: """Send Matrix m.room.message content with configured E2EE send options.""" if not self.client: @@ -513,25 +531,29 @@ class MatrixChannel(BaseChannel): ) return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + encrypt_upload = self.config.e2ee_enabled and self._is_encrypted_room(room_id) try: - data = resolved.read_bytes() - except OSError as e: + with resolved.open("rb") as data_provider: + upload_result = await self.client.upload( + data_provider, + content_type=mime, + filename=filename, + encrypt=encrypt_upload, + filesize=size_bytes, + ) + except Exception as e: logger.warning( - "Matrix outbound attachment read failed for {} ({}): {}", + "Matrix outbound attachment upload failed for {} ({}): {}", resolved, type(e).__name__, str(e), ) return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - - mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - upload_result = await self.client.upload( - data, - content_type=mime, - filename=filename, - filesize=len(data), - ) upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result + encryption_info: dict[str, Any] | None = None + if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict): + encryption_info = upload_result[1] if isinstance(upload_response, UploadError): logger.warning( "Matrix outbound attachment upload failed for {}: {}", @@ -552,8 +574,9 @@ class MatrixChannel(BaseChannel): content = self._build_outbound_attachment_content( filename=filename, mime=mime, - size_bytes=len(data), + size_bytes=size_bytes, mxc_url=mxc_url, + encryption_info=encryption_info, ) try: await self._send_room_content(room_id, content) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 222f14e..c71bc52 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -45,6 +45,7 @@ class _FakeAsyncClient: self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] + 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]] = [] @@ -122,6 +123,11 @@ class _FakeAsyncClient: ): 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, @@ -133,6 +139,16 @@ class _FakeAsyncClient: ) 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): @@ -775,6 +791,8 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: ) 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 @@ -783,6 +801,36 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: assert client.room_send_calls[1]["content"]["body"] == "Please review." +@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()) @@ -833,6 +881,33 @@ async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) - 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()) diff --git a/tests/test_message_tool.py b/tests/test_message_tool.py new file mode 100644 index 0000000..f7bfad9 --- /dev/null +++ b/tests/test_message_tool.py @@ -0,0 +1,37 @@ +import pytest + +from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import OutboundMessage + + +@pytest.mark.asyncio +async def test_message_tool_sends_media_paths_with_default_context() -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool( + send_callback=_send, + default_channel="test-channel", + default_chat_id="!room:example.org", + ) + + result = await tool.execute( + content="Here is the file.", + media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"], + ) + + assert result == "Message sent to test-channel:!room:example.org" + assert len(sent) == 1 + assert sent[0].channel == "test-channel" + assert sent[0].chat_id == "!room:example.org" + assert sent[0].content == "Here is the file." + assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"] + + +@pytest.mark.asyncio +async def test_message_tool_returns_error_when_no_target_context() -> None: + tool = MessageTool() + result = await tool.execute(content="test") + assert result == "Error: No target channel/chat specified" From 705d5738e3086470075e53427d43e5eaee2eaaf4 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Thu, 12 Feb 2026 17:39:17 +0100 Subject: [PATCH 32/42] feat(matrix): reply in threads with fallback relations Propagate Matrix thread metadata from inbound events and attach m.relates_to (rel_type=m.thread, m.in_reply_to, is_falling_back=true) to outbound messages including attachments. Add tests for thread metadata and thread replies. --- nanobot/channels/matrix.py | 55 ++++++++++--- tests/test_matrix_channel.py | 154 +++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 14d897b..1a6146a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -480,8 +480,20 @@ class MatrixChannel(BaseChannel): return 0 return min(local_limit, server_limit) + def _configured_media_limit_bytes(self) -> int: + """Resolve the configured local media limit with backward compatibility.""" + for name in ("max_inbound_media_bytes", "max_media_bytes"): + value = getattr(self.config, name, None) + if isinstance(value, int): + return value + return 0 + async def _upload_and_send_attachment( - self, room_id: str, path: Path, limit_bytes: int + self, + room_id: str, + path: Path, + limit_bytes: int, + relates_to: dict[str, Any] | None = None, ) -> str | None: """Upload one local file to Matrix and send it as a media message.""" if not self.client: @@ -578,6 +590,8 @@ class MatrixChannel(BaseChannel): mxc_url=mxc_url, encryption_info=encryption_info, ) + if relates_to: + content["m.relates_to"] = relates_to try: await self._send_room_content(room_id, content) except Exception as e: @@ -596,6 +610,7 @@ class MatrixChannel(BaseChannel): text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) + relates_to = self._build_thread_relates_to(msg.metadata) try: failures: list[str] = [] @@ -607,6 +622,7 @@ class MatrixChannel(BaseChannel): room_id=msg.chat_id, path=path, limit_bytes=limit_bytes, + relates_to=relates_to, ) if failure_marker: failures.append(failure_marker) @@ -618,7 +634,10 @@ class MatrixChannel(BaseChannel): text = "\n".join(failures) if text or not candidates: - await self._send_room_content(msg.chat_id, _build_matrix_text_content(text)) + content = _build_matrix_text_content(text) + if relates_to: + content["m.relates_to"] = relates_to + await self._send_room_content(msg.chat_id, content) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) @@ -793,7 +812,7 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_thread_root_id(self, event: RoomMessage) -> str | None: + def _event_thread_root_id(self, event: Any) -> str | None: """Return thread root event_id if this message is inside a thread.""" content = self._event_source_content(event) relates_to = content.get("m.relates_to") @@ -804,7 +823,7 @@ class MatrixChannel(BaseChannel): root_id = relates_to.get("event_id") return root_id if isinstance(root_id, str) and root_id else None - def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: + def _thread_metadata(self, event: Any) -> dict[str, str] | None: """Build metadata used to reply within a thread.""" root_id = self._event_thread_root_id(event) if not root_id: @@ -833,7 +852,7 @@ class MatrixChannel(BaseChannel): "is_falling_back": True, } - def _event_attachment_type(self, event: MatrixMediaEvent) -> str: + def _event_attachment_type(self, event: Any) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -1073,11 +1092,20 @@ class MatrixChannel(BaseChannel): await self._start_typing_keepalive(room.room_id) try: + metadata: dict[str, Any] = { + "room": getattr(room, "display_name", room.room_id), + } + event_id = getattr(event, "event_id", None) + if isinstance(event_id, str) and event_id: + metadata["event_id"] = event_id + thread_meta = self._thread_metadata(event) + if thread_meta: + metadata.update(thread_meta) await self._handle_message( sender_id=event.sender, chat_id=room.room_id, content=event.body, - metadata={"room": getattr(room, "display_name", room.room_id)}, + metadata=metadata, ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) @@ -1107,15 +1135,22 @@ class MatrixChannel(BaseChannel): await self._start_typing_keepalive(room.room_id) try: + metadata: dict[str, Any] = { + "room": getattr(room, "display_name", room.room_id), + "attachments": attachments, + } + event_id = getattr(event, "event_id", None) + if isinstance(event_id, str) and event_id: + metadata["event_id"] = event_id + thread_meta = self._thread_metadata(event) + if thread_meta: + metadata.update(thread_meta) await self._handle_message( sender_id=event.sender, chat_id=room.room_id, content="\n".join(content_parts), media=media_paths, - metadata={ - "room": getattr(room, "display_name", room.room_id), - "attachments": attachments, - }, + metadata=metadata, ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c71bc52..47d7ec4 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -510,6 +510,43 @@ async def test_on_message_room_mention_requires_opt_in() -> None: 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 @@ -563,6 +600,51 @@ async def test_on_media_message_downloads_attachment_and_sets_metadata( 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 @@ -801,6 +883,34 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: 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()) @@ -851,6 +961,50 @@ async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> 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" From 334078e242d246cf25ef97e5f052de3745e9b655 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:04:11 +0100 Subject: [PATCH 33/42] fix(message): apply media path filtering and drop attachment count from return value Conflict resolution correction: HEAD's message.py retained raw media list and attachment count in return string, but tests from 3de30bb require stripped/filtered media_paths and a plain return message. Aligns HEAD behavior with cherry-picked tests. --- nanobot/agent/tools/message.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index c5efbf3..7cac933 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -89,11 +89,10 @@ class MessageTool(Tool): if candidate: media_paths.append(candidate) - msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media or []) + msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media_paths) try: await self._send_callback(msg) - media_info = f" with {len(media)} attachments" if media else "" - return f"Message sent to {channel}:{chat_id}{media_info}" + return f"Message sent to {channel}:{chat_id}" except Exception as e: return f"Error sending message: {str(e)}" From 36d650e47560b223f4d693ab9fe3ecb53355f751 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:05:00 +0100 Subject: [PATCH 34/42] revert: restore message.py to tanishra baseline (out of scope) --- nanobot/agent/tools/message.py | 80 ++++++++++++++++------------------ 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 7cac933..3853725 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Awaitable, Callable +from typing import Any, Callable, Awaitable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,91 +8,87 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "", + default_chat_id: str = "" ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: - return ( - "Send a message to the user. Supports optional media/attachment " - "paths for channels that can send files." - ) - + return "Send a message to the user. Use this when you want to communicate something." + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "content": {"type": "string", "description": "The message content to send"}, + "content": { + "type": "string", + "description": "The message content to send" + }, "channel": { "type": "string", - "description": "Optional: target channel (telegram, discord, etc.)", + "description": "Optional: target channel (telegram, discord, etc.)" }, - "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, - "media": { - "type": "array", - "description": "Optional: local file paths to send as attachments", - "items": {"type": "string"}, + "chat_id": { + "type": "string", + "description": "Optional: target chat/user ID" }, - "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, "media": { "type": "array", "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)", - }, + "description": "Optional: list of file paths to attach (images, audio, documents)" + } }, - "required": ["content"], + "required": ["content"] } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, media: list[str] | None = None, - **kwargs: Any, + **kwargs: Any ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - - media_paths: list[str] = [] - for item in media or []: - if isinstance(item, str): - candidate = item.strip() - if candidate: - media_paths.append(candidate) - - msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media_paths) - + + msg = OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content, + media=media or [] + ) + try: await self._send_callback(msg) - return f"Message sent to {channel}:{chat_id}" + media_info = f" with {len(media)} attachments" if media else "" + return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: return f"Error sending message: {str(e)}" From e8a4671565cf19bc46e3beb5ff32ada0ee4035eb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:06:13 +0100 Subject: [PATCH 35/42] test: remove message tool media test (message.py changes out of scope) --- tests/test_message_tool.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/test_message_tool.py b/tests/test_message_tool.py index f7bfad9..dc8e11d 100644 --- a/tests/test_message_tool.py +++ b/tests/test_message_tool.py @@ -1,33 +1,6 @@ import pytest from nanobot.agent.tools.message import MessageTool -from nanobot.bus.events import OutboundMessage - - -@pytest.mark.asyncio -async def test_message_tool_sends_media_paths_with_default_context() -> None: - sent: list[OutboundMessage] = [] - - async def _send(msg: OutboundMessage) -> None: - sent.append(msg) - - tool = MessageTool( - send_callback=_send, - default_channel="test-channel", - default_chat_id="!room:example.org", - ) - - result = await tool.execute( - content="Here is the file.", - media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"], - ) - - assert result == "Message sent to test-channel:!room:example.org" - assert len(sent) == 1 - assert sent[0].channel == "test-channel" - assert sent[0].chat_id == "!room:example.org" - assert sent[0].content == "Here is the file." - assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"] @pytest.mark.asyncio From 52d086d46abfbc1eb8b83676136a67f7fb61c491 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:08:04 +0100 Subject: [PATCH 36/42] revert: restore context.py and manager.py to tanishra baseline (out of scope) --- nanobot/agent/context.py | 7 +-- nanobot/channels/manager.py | 88 +++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 0253415..876d43d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -102,11 +102,8 @@ Your workspace is at: {workspace_path} - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Use the 'message' tool only when you need explicit channel delivery behavior: -- Send to a different channel/chat than the current session -- Send one or more file attachments via `media` (local file paths) -For normal conversation text, respond directly without calling the message tool. -Do not claim that attachments are impossible if a channel supports file send and you can provide local paths. +Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). +For normal conversation, just respond with text - do not call the message tool. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 998d90c..e860d26 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,29 +16,28 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel - self.channels["telegram"] = TelegramChannel( self.config.channels.telegram, self.bus, @@ -47,13 +46,14 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning(f"Telegram channel not available: {e}") - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel - - self.channels["whatsapp"] = WhatsAppChannel(self.config.channels.whatsapp, self.bus) + self.channels["whatsapp"] = WhatsAppChannel( + self.config.channels.whatsapp, self.bus + ) logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") @@ -62,18 +62,20 @@ class ChannelManager: if self.config.channels.discord.enabled: try: from nanobot.channels.discord import DiscordChannel - - self.channels["discord"] = DiscordChannel(self.config.channels.discord, self.bus) + self.channels["discord"] = DiscordChannel( + self.config.channels.discord, self.bus + ) logger.info("Discord channel enabled") except ImportError as e: logger.warning(f"Discord channel not available: {e}") - + # Feishu channel if self.config.channels.feishu.enabled: try: from nanobot.channels.feishu import FeishuChannel - - self.channels["feishu"] = FeishuChannel(self.config.channels.feishu, self.bus) + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu channel not available: {e}") @@ -83,7 +85,9 @@ class ChannelManager: try: from nanobot.channels.mochat import MochatChannel - self.channels["mochat"] = MochatChannel(self.config.channels.mochat, self.bus) + self.channels["mochat"] = MochatChannel( + self.config.channels.mochat, self.bus + ) logger.info("Mochat channel enabled") except ImportError as e: logger.warning(f"Mochat channel not available: {e}") @@ -92,8 +96,9 @@ class ChannelManager: if self.config.channels.dingtalk.enabled: try: from nanobot.channels.dingtalk import DingTalkChannel - - self.channels["dingtalk"] = DingTalkChannel(self.config.channels.dingtalk, self.bus) + self.channels["dingtalk"] = DingTalkChannel( + self.config.channels.dingtalk, self.bus + ) logger.info("DingTalk channel enabled") except ImportError as e: logger.warning(f"DingTalk channel not available: {e}") @@ -102,8 +107,9 @@ class ChannelManager: if self.config.channels.email.enabled: try: from nanobot.channels.email import EmailChannel - - self.channels["email"] = EmailChannel(self.config.channels.email, self.bus) + self.channels["email"] = EmailChannel( + self.config.channels.email, self.bus + ) logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email channel not available: {e}") @@ -112,8 +118,9 @@ class ChannelManager: if self.config.channels.slack.enabled: try: from nanobot.channels.slack import SlackChannel - - self.channels["slack"] = SlackChannel(self.config.channels.slack, self.bus) + self.channels["slack"] = SlackChannel( + self.config.channels.slack, self.bus + ) logger.info("Slack channel enabled") except ImportError as e: logger.warning(f"Slack channel not available: {e}") @@ -122,7 +129,6 @@ class ChannelManager: if self.config.channels.qq.enabled: try: from nanobot.channels.qq import QQChannel - self.channels["qq"] = QQChannel( self.config.channels.qq, self.bus, @@ -130,7 +136,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning(f"QQ channel not available: {e}") - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -143,23 +149,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -167,7 +173,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -175,15 +181,18 @@ class ChannelManager: logger.info(f"Stopped {name} channel") except Exception as e: logger.error(f"Error stopping {name}: {e}") - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: - msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0) - + msg = await asyncio.wait_for( + self.bus.consume_outbound(), + timeout=1.0 + ) + channel = self.channels.get(msg.channel) if channel: try: @@ -192,23 +201,26 @@ class ChannelManager: logger.error(f"Error sending to {msg.channel}: {e}") else: logger.warning(f"Unknown channel: {msg.channel}") - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { - name: {"enabled": True, "running": channel.is_running} + name: { + "enabled": True, + "running": channel.is_running + } for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" From dd61a9143aa8b97bb15742c192c400f26240d400 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:11:29 +0100 Subject: [PATCH 37/42] fix: remove accidental whitespace-only formatting changes from schema.py --- nanobot/config/schema.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 690b9b2..27bba4d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,9 +29,7 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = ( - None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" - ) + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" class FeishuConfig(Base): @@ -108,9 +106,7 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = ( - True # If false, inbound email is read but no automatic reply is sent - ) + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -187,9 +183,7 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field( - default_factory=list - ) # Allowed user openids (empty = public access) + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -248,9 +242,7 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field( - default_factory=ProviderConfig - ) # SiliconFlow (硅基流动) API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -313,9 +305,7 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider( - self, model: str | None = None - ) -> tuple["ProviderConfig | None", str | None]: + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From 13561772ad684a33b5de22822363273b961ecfde Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:15:32 +0100 Subject: [PATCH 38/42] fix(matrix): align with fork/main (docstrings, type annotations, formatting) --- nanobot/channels/matrix.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 1a6146a..a3bf482 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,3 +1,5 @@ +"""Matrix channel implementation for inbound sync and outbound message/media delivery.""" + import asyncio import logging import mimetypes @@ -238,6 +240,7 @@ class MatrixChannel(BaseChannel): restrict_to_workspace: bool = False, workspace: Path | None = None, ): + """Store Matrix client settings, task handles, and outbound media policy flags.""" super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None @@ -605,6 +608,7 @@ class MatrixChannel(BaseChannel): return None async def send(self, msg: OutboundMessage) -> None: + """Send message text and optional attachments to a Matrix room, then clear typing state.""" if not self.client: return @@ -812,7 +816,7 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_thread_root_id(self, event: Any) -> str | None: + def _event_thread_root_id(self, event: RoomMessage) -> str | None: """Return thread root event_id if this message is inside a thread.""" content = self._event_source_content(event) relates_to = content.get("m.relates_to") @@ -823,7 +827,7 @@ class MatrixChannel(BaseChannel): root_id = relates_to.get("event_id") return root_id if isinstance(root_id, str) and root_id else None - def _thread_metadata(self, event: Any) -> dict[str, str] | None: + def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: """Build metadata used to reply within a thread.""" root_id = self._event_thread_root_id(event) if not root_id: @@ -852,7 +856,7 @@ class MatrixChannel(BaseChannel): "is_falling_back": True, } - def _event_attachment_type(self, event: Any) -> str: + def _event_attachment_type(self, event: MatrixMediaEvent) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -1131,7 +1135,7 @@ class MatrixChannel(BaseChannel): content_parts.extend(markers) # TODO: Optionally add audio transcription support for Matrix attachments, - # similar to Telegram's voice/audio flow, behind explicit config. + # behind explicit config. await self._start_typing_keepalive(room.room_id) try: From fcece3ec62afee58e27fa199c4caa9f68a29a787 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:17:27 +0100 Subject: [PATCH 39/42] fix(matrix): match fork/main formatting exactly --- nanobot/channels/matrix.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a3bf482..1ace6ca 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -500,9 +500,7 @@ class MatrixChannel(BaseChannel): ) -> str | None: """Upload one local file to Matrix and send it as a media message.""" if not self.client: - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format( - path.name or MATRIX_DEFAULT_ATTACHMENT_NAME - ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(path.name or MATRIX_DEFAULT_ATTACHMENT_NAME) resolved = path.expanduser().resolve(strict=False) filename = safe_filename(resolved.name) or MATRIX_DEFAULT_ATTACHMENT_NAME @@ -1027,7 +1025,10 @@ class MatrixChannel(BaseChannel): limit_bytes = await self._effective_media_limit_bytes() declared_size = self._event_declared_size_bytes(event) - if declared_size is not None and declared_size > limit_bytes: + if ( + declared_size is not None + and declared_size > limit_bytes + ): logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, From de5104ab2a91bda425fee0990b5e3d8b72c58239 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 20:24:46 +0100 Subject: [PATCH 40/42] fix(matrix): keep typing indicator during progress updates --- nanobot/channels/matrix.py | 6 ++++-- tests/test_matrix_channel.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 1ace6ca..794cc51 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -606,13 +606,14 @@ class MatrixChannel(BaseChannel): return None async def send(self, msg: OutboundMessage) -> None: - """Send message text and optional attachments to a Matrix room, then clear typing state.""" + """Send Matrix outbound content and clear typing only for non-progress messages.""" if not self.client: return text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) relates_to = self._build_thread_relates_to(msg.metadata) + is_progress = bool((msg.metadata or {}).get("_progress")) try: failures: list[str] = [] @@ -641,7 +642,8 @@ class MatrixChannel(BaseChannel): content["m.relates_to"] = relates_to await self._send_room_content(msg.chat_id, content) finally: - await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) + if not is_progress: + await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 47d7ec4..f475aac 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1141,6 +1141,29 @@ async def test_send_stops_typing_keepalive_task() -> None: 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...", + metadata={"_progress": True, "_progress_kind": "reasoning"}, + ) + ) + + assert "!room:matrix.org" in channel._typing_tasks + assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 494fa8966a91cb7793dc0d92944a5c226d2d2e13 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 20:29:47 +0100 Subject: [PATCH 41/42] refactor(matrix): use milliseconds for typing timing constants --- nanobot/channels/matrix.py | 4 ++-- tests/test_matrix_channel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 794cc51..f85aab5 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -43,7 +43,7 @@ TYPING_NOTICE_TIMEOUT_MS = 30_000 # https://spec.matrix.org/v1.17/client-server-api/#typing-notifications # Keepalive interval must stay below TYPING_NOTICE_TIMEOUT_MS so the typing # indicator does not expire while the agent is still processing. -TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 +TYPING_KEEPALIVE_INTERVAL_MS = 20_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" @@ -715,7 +715,7 @@ class MatrixChannel(BaseChannel): async def _typing_loop() -> None: try: while self._running: - await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_SECONDS) + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000) await self._set_typing(room_id, True) except asyncio.CancelledError: pass diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f475aac..c6714c2 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -332,7 +332,7 @@ async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None: channel.client = client channel._running = True - monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_SECONDS", 0.01) + monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_MS", 10) await channel._start_typing_keepalive("!room:matrix.org") await asyncio.sleep(0.03) From 988a85d8de2bcb5dcef9356f933343774f898b1e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 26 Feb 2026 03:04:01 +0000 Subject: [PATCH 42/42] =?UTF-8?q?refactor:=20optimize=20matrix=20channel?= =?UTF-8?q?=20=E2=80=94=20optional=20deps,=20trim=20comments,=20simplify?= =?UTF-8?q?=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- nanobot/agent/loop.py | 2 - nanobot/channels/matrix.py | 936 +++++++++---------------------------- pyproject.toml | 6 +- 4 files changed, 236 insertions(+), 714 deletions(-) diff --git a/README.md b/README.md index 00ffdc4..4ddfc33 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,11 @@ nanobot gateway
    Matrix (Element) -Uses Matrix sync via `matrix-nio` (inbound media + outbound file attachments). +Install Matrix dependencies first: + +```bash +pip install nanobot-ai[matrix] +``` **1. Create/choose a Matrix account** diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b402ea0..3e513cb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -229,8 +229,6 @@ class AgentLoop: ) else: clean = self._strip_think(response.content) - if on_progress and clean: - await on_progress(clean) messages = self.context.add_assistant_message( messages, clean, reasoning_content=response.reasoning_content, ) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index f85aab5..21192e9 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,4 +1,4 @@ -"""Matrix channel implementation for inbound sync and outbound message/media delivery.""" +"""Matrix (Element) channel — inbound sync + outbound message/media delivery.""" import asyncio import logging @@ -6,109 +6,56 @@ import mimetypes from pathlib import Path from typing import Any, TypeAlias -import nh3 from loguru import logger -from mistune import create_markdown -from nio import ( - AsyncClient, - AsyncClientConfig, - ContentRepositoryConfigError, - DownloadError, - InviteEvent, - JoinError, - MatrixRoom, - MemoryDownloadResponse, - RoomEncryptedMedia, - RoomMessage, - RoomMessageMedia, - RoomMessageText, - RoomSendError, - RoomTypingError, - SyncError, - UploadError, -) -from nio.crypto.attachments import decrypt_attachment -from nio.exceptions import EncryptionError + +try: + import nh3 + from mistune import create_markdown + from nio import ( + AsyncClient, AsyncClientConfig, ContentRepositoryConfigError, + DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse, + RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText, + RoomSendError, RoomTypingError, SyncError, UploadError, + ) + from nio.crypto.attachments import decrypt_attachment + from nio.exceptions import EncryptionError +except ImportError as e: + raise ImportError( + "Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]" + ) from e from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir from nanobot.utils.helpers import safe_filename -LOGGING_STACK_BASE_DEPTH = 2 -# Typing state lifetime advertised to Matrix clients/servers. TYPING_NOTICE_TIMEOUT_MS = 30_000 -# Matrix typing notifications are ephemeral; spec guidance is to keep -# refreshing while work is ongoing (practically ~20-30s cadence). -# https://spec.matrix.org/v1.17/client-server-api/#typing-notifications -# Keepalive interval must stay below TYPING_NOTICE_TIMEOUT_MS so the typing -# indicator does not expire while the agent is still processing. +# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing. TYPING_KEEPALIVE_INTERVAL_MS = 20_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" -MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" -MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" -MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" -MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE = "[attachment: {} - upload failed]" -MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" +_ATTACH_MARKER = "[attachment: {}]" +_ATTACH_TOO_LARGE = "[attachment: {} - too large]" +_ATTACH_FAILED = "[attachment: {} - download failed]" +_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]" +_DEFAULT_ATTACH_NAME = "attachment" +_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"} -# Runtime callback filter for nio event dispatch (checked via isinstance). MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) -# Static typing alias for media-specific handlers/helpers. MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia -# Markdown renderer policy: -# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes -# - Only enable portable features that map cleanly to Matrix-compatible HTML. -# - escape=True ensures raw model HTML is treated as text unless we explicitly -# add structured support for Matrix-specific HTML features later. MATRIX_MARKDOWN = create_markdown( escape=True, plugins=["table", "strikethrough", "url", "superscript", "subscript"], ) -# Sanitizer policy: -# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes -# - Start from Matrix formatted-message guidance, but keep a smaller allowlist -# to reduce risk and keep client behavior predictable for LLM output. -# - Enforce mxc:// for img src to align media rendering with Matrix content -# repository semantics. -# - Unused spec-permitted features (e.g. some href schemes and data-mx-* attrs) -# are intentionally deferred until explicitly needed. MATRIX_ALLOWED_HTML_TAGS = { - "p", - "a", - "strong", - "em", - "del", - "code", - "pre", - "blockquote", - "ul", - "ol", - "li", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "br", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "caption", - "sup", - "sub", - "img", + "p", "a", "strong", "em", "del", "code", "pre", "blockquote", + "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "hr", "br", "table", "thead", "tbody", "tr", "th", "td", + "caption", "sup", "sub", "img", } MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = { - "a": {"href"}, - "code": {"class"}, - "ol": {"start"}, + "a": {"href"}, "code": {"class"}, "ol": {"start"}, "img": {"src", "alt", "title", "width", "height"}, } MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"} @@ -117,22 +64,12 @@ MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"} def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None: """Filter attribute values to a safe Matrix-compatible subset.""" if tag == "a" and attr == "href": - lower_value = value.lower() - if lower_value.startswith(("https://", "http://", "matrix:", "mailto:")): - return value - return None - + return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None if tag == "img" and attr == "src": return value if value.lower().startswith("mxc://") else None - if tag == "code" and attr == "class": - classes = [ - cls - for cls in value.split() - if cls.startswith("language-") and not cls.startswith("language-_") - ] + classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")] return " ".join(classes) if classes else None - return value @@ -147,100 +84,59 @@ MATRIX_HTML_CLEANER = nh3.Cleaner( def _render_markdown_html(text: str) -> str | None: - """Render markdown to HTML for Matrix formatted messages.""" + """Render markdown to sanitized HTML; returns None for plain text.""" try: - rendered = MATRIX_MARKDOWN(text) - formatted = MATRIX_HTML_CLEANER.clean(rendered).strip() - except Exception as e: - logger.debug( - "Matrix markdown rendering failed ({}): {}", - type(e).__name__, - str(e), - ) + formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip() + except Exception: return None - if not formatted: return None - - # Skip formatted_body for plain output (

    ...

    ) to keep payload minimal. - stripped = formatted.strip() - if stripped.startswith("

    ") and stripped.endswith("

    "): - paragraph_inner = stripped[3:-4] - # Keep plaintext-only paragraphs minimal, but preserve inline markup/links. - if "<" not in paragraph_inner and ">" not in paragraph_inner: + # Skip formatted_body for plain

    text

    to keep payload minimal. + if formatted.startswith("

    ") and formatted.endswith("

    "): + inner = formatted[3:-4] + if "<" not in inner and ">" not in inner: return None - return formatted def _build_matrix_text_content(text: str) -> dict[str, object]: - """Build Matrix m.text payload with plaintext fallback and optional HTML.""" - content: dict[str, object] = { - "msgtype": "m.text", - # Note: When `formatted_body` is present, Matrix spec expects `body` to - # be its plaintext representation (fallback for clients without HTML). - # We currently keep raw text (often markdown) for simplicity. - # https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes - "body": text, - # Matrix spec recommends always including m.mentions for message - # semantics/interoperability, even when no mentions are present. - # https://spec.matrix.org/v1.17/client-server-api/#mmentions - "m.mentions": {}, - } - formatted_html = _render_markdown_html(text) - if not formatted_html: - return content - - content["format"] = MATRIX_HTML_FORMAT - content["formatted_body"] = formatted_html + """Build Matrix m.text payload with optional HTML formatted_body.""" + content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}} + if html := _render_markdown_html(text): + content["format"] = MATRIX_HTML_FORMAT + content["formatted_body"] = html return content class _NioLoguruHandler(logging.Handler): - """Route stdlib logging records from matrix-nio into Loguru output.""" + """Route matrix-nio stdlib logs into Loguru.""" def emit(self, record: logging.LogRecord) -> None: try: level = logger.level(record.levelname).name except ValueError: level = record.levelno - - frame = logging.currentframe() - # Skip logging internals plus this handler frame when forwarding to Loguru. - depth = LOGGING_STACK_BASE_DEPTH + frame, depth = logging.currentframe(), 2 while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - + frame, depth = frame.f_back, depth + 1 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) def _configure_nio_logging_bridge() -> None: - """Ensure matrix-nio logs are emitted through the project's Loguru format.""" + """Bridge matrix-nio logs to Loguru (idempotent).""" nio_logger = logging.getLogger("nio") - if any(isinstance(handler, _NioLoguruHandler) for handler in nio_logger.handlers): - return - - nio_logger.handlers = [_NioLoguruHandler()] - nio_logger.propagate = False + if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers): + nio_logger.handlers = [_NioLoguruHandler()] + nio_logger.propagate = False class MatrixChannel(BaseChannel): - """ - Matrix (Element) channel using long-polling sync. - """ + """Matrix (Element) channel using long-polling sync.""" name = "matrix" - def __init__( - self, - config: Any, - bus, - *, - restrict_to_workspace: bool = False, - workspace: Path | None = None, - ): - """Store Matrix client settings, task handles, and outbound media policy flags.""" + def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False, + workspace: Path | None = None): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None @@ -259,15 +155,10 @@ class MatrixChannel(BaseChannel): store_path.mkdir(parents=True, exist_ok=True) self.client = AsyncClient( - homeserver=self.config.homeserver, - user=self.config.user_id, - store_path=store_path, # Where tokens are saved - config=AsyncClientConfig( - store_sync_tokens=True, # Auto-persists next_batch tokens - encryption_enabled=self.config.e2ee_enabled, - ), + homeserver=self.config.homeserver, user=self.config.user_id, + store_path=store_path, + config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled), ) - self.client.user_id = self.config.user_id self.client.access_token = self.config.access_token self.client.device_id = self.config.device_id @@ -275,78 +166,43 @@ class MatrixChannel(BaseChannel): self._register_event_callbacks() self._register_response_callbacks() - if self.config.e2ee_enabled: - logger.info("Matrix E2EE is enabled.") - else: - logger.warning( - "Matrix E2EE is disabled; encrypted room messages may be undecryptable and " - "encrypted-device verification is not applied on send." - ) + if not self.config.e2ee_enabled: + logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") if self.config.device_id: try: self.client.load_store() - except Exception as e: - logger.warning( - "Matrix store load failed ({}: {}); sync token restore is disabled and " - "restart may replay recent messages.", - type(e).__name__, - str(e), - ) + except Exception: + logger.exception("Matrix store load failed; restart may replay recent messages.") else: - logger.warning( - "Matrix device_id is empty; sync token restore is disabled and restart may " - "replay recent messages." - ) + logger.warning("Matrix device_id empty; restart may replay recent messages.") self._sync_task = asyncio.create_task(self._sync_loop()) async def stop(self) -> None: """Stop the Matrix channel with graceful sync shutdown.""" self._running = False - for room_id in list(self._typing_tasks): await self._stop_typing_keepalive(room_id, clear_typing=False) - if self.client: - # Request sync_forever loop to exit cleanly. self.client.stop_sync_forever() - if self._sync_task: try: - await asyncio.wait_for( - asyncio.shield(self._sync_task), - timeout=self.config.sync_stop_grace_seconds, - ) - except asyncio.TimeoutError: + await asyncio.wait_for(asyncio.shield(self._sync_task), + timeout=self.config.sync_stop_grace_seconds) + except (asyncio.TimeoutError, asyncio.CancelledError): self._sync_task.cancel() try: await self._sync_task except asyncio.CancelledError: pass - except asyncio.CancelledError: - pass - if self.client: await self.client.close() - @staticmethod - def _path_dedupe_key(path: Path) -> str: - """Return a stable deduplication key for attachment paths.""" - expanded = path.expanduser() - try: - return str(expanded.resolve(strict=False)) - except OSError: - return str(expanded) - def _is_workspace_path_allowed(self, path: Path) -> bool: - """Enforce optional workspace-only outbound attachment policy.""" - if not self._restrict_to_workspace: + """Check path is inside workspace (when restriction enabled).""" + if not self._restrict_to_workspace or not self._workspace: return True - - if self._workspace is None: - return False - try: path.resolve(strict=False).relative_to(self._workspace) return True @@ -354,288 +210,150 @@ class MatrixChannel(BaseChannel): return False def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]: - """Collect unique outbound attachment paths from OutboundMessage.media.""" - candidates: list[Path] = [] + """Deduplicate and resolve outbound attachment paths.""" seen: set[str] = set() - + candidates: list[Path] = [] for raw in media: if not isinstance(raw, str) or not raw.strip(): continue path = Path(raw.strip()).expanduser() - key = self._path_dedupe_key(path) - if key in seen: - continue - seen.add(key) - candidates.append(path) - + try: + key = str(path.resolve(strict=False)) + except OSError: + key = str(path) + if key not in seen: + seen.add(key) + candidates.append(path) return candidates @staticmethod def _build_outbound_attachment_content( - *, - filename: str, - mime: str, - size_bytes: int, - mxc_url: str, - encryption_info: dict[str, Any] | None = None, + *, filename: str, mime: str, size_bytes: int, + mxc_url: str, encryption_info: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build Matrix content payload for an uploaded file/image/audio/video.""" - msgtype = "m.file" - if mime.startswith("image/"): - msgtype = "m.image" - elif mime.startswith("audio/"): - msgtype = "m.audio" - elif mime.startswith("video/"): - msgtype = "m.video" - + prefix = mime.split("/")[0] + msgtype = {"image": "m.image", "audio": "m.audio", "video": "m.video"}.get(prefix, "m.file") content: dict[str, Any] = { - "msgtype": msgtype, - "body": filename, - "filename": filename, - "info": { - "mimetype": mime, - "size": size_bytes, - }, - "m.mentions": {}, + "msgtype": msgtype, "body": filename, "filename": filename, + "info": {"mimetype": mime, "size": size_bytes}, "m.mentions": {}, } - if encryption_info: - # Encrypted media events use `file` metadata (with url/hash/key/iv), - # while unencrypted media events use top-level `url`. - file_info = dict(encryption_info) - file_info["url"] = mxc_url - content["file"] = file_info + content["file"] = {**encryption_info, "url": mxc_url} else: content["url"] = mxc_url - return content def _is_encrypted_room(self, room_id: str) -> bool: - """Return True if the Matrix room is known as encrypted.""" if not self.client: return False room = getattr(self.client, "rooms", {}).get(room_id) return bool(getattr(room, "encrypted", False)) async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: - """Send Matrix m.room.message content with configured E2EE send options.""" + """Send m.room.message with E2EE options.""" if not self.client: return - - room_send_kwargs: dict[str, Any] = { - "room_id": room_id, - "message_type": "m.room.message", - "content": content, - } + kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content} if self.config.e2ee_enabled: - # TODO(matrix): Add explicit config for strict verified-device sending mode. - room_send_kwargs["ignore_unverified_devices"] = True - - await self.client.room_send(**room_send_kwargs) + kwargs["ignore_unverified_devices"] = True + await self.client.room_send(**kwargs) async def _resolve_server_upload_limit_bytes(self) -> int | None: - """Resolve homeserver-advertised upload limit once per channel lifecycle.""" + """Query homeserver upload limit once per channel lifecycle.""" if self._server_upload_limit_checked: return self._server_upload_limit_bytes - self._server_upload_limit_checked = True if not self.client: return None - try: response = await self.client.content_repository_config() - except Exception as e: - logger.debug( - "Matrix media config lookup failed ({}): {}", - type(e).__name__, - str(e), - ) + except Exception: return None - upload_size = getattr(response, "upload_size", None) if isinstance(upload_size, int) and upload_size > 0: self._server_upload_limit_bytes = upload_size - return self._server_upload_limit_bytes - - if isinstance(response, ContentRepositoryConfigError): - logger.debug("Matrix media config lookup failed: {}", response) - return None - - logger.debug( - "Matrix media config lookup returned unexpected response {}", - type(response).__name__, - ) + return upload_size return None async def _effective_media_limit_bytes(self) -> int: - """ - Compute effective Matrix media size cap. - - `m.upload.size` (if advertised) is treated as the homeserver-side cap. - `maxMediaBytes` is a local hard limit/fallback. Using the stricter value - keeps resource usage predictable while honoring server constraints. - """ + """min(local config, server advertised) — 0 blocks all uploads.""" local_limit = max(int(self.config.max_media_bytes), 0) server_limit = await self._resolve_server_upload_limit_bytes() if server_limit is None: return local_limit - if local_limit == 0: - return 0 - return min(local_limit, server_limit) - - def _configured_media_limit_bytes(self) -> int: - """Resolve the configured local media limit with backward compatibility.""" - for name in ("max_inbound_media_bytes", "max_media_bytes"): - value = getattr(self.config, name, None) - if isinstance(value, int): - return value - return 0 + return min(local_limit, server_limit) if local_limit else 0 async def _upload_and_send_attachment( - self, - room_id: str, - path: Path, - limit_bytes: int, + self, room_id: str, path: Path, limit_bytes: int, relates_to: dict[str, Any] | None = None, ) -> str | None: - """Upload one local file to Matrix and send it as a media message.""" + """Upload one local file to Matrix and send it as a media message. Returns failure marker or None.""" if not self.client: - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(path.name or MATRIX_DEFAULT_ATTACHMENT_NAME) + return _ATTACH_UPLOAD_FAILED.format(path.name or _DEFAULT_ATTACH_NAME) resolved = path.expanduser().resolve(strict=False) - filename = safe_filename(resolved.name) or MATRIX_DEFAULT_ATTACHMENT_NAME - - if not resolved.is_file(): - logger.warning("Matrix outbound attachment missing file: {}", resolved) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - - if not self._is_workspace_path_allowed(resolved): - logger.warning( - "Matrix outbound attachment denied by workspace restriction: {}", - resolved, - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + filename = safe_filename(resolved.name) or _DEFAULT_ATTACH_NAME + fail = _ATTACH_UPLOAD_FAILED.format(filename) + if not resolved.is_file() or not self._is_workspace_path_allowed(resolved): + return fail try: size_bytes = resolved.stat().st_size - except OSError as e: - logger.warning( - "Matrix outbound attachment stat failed for {} ({}): {}", - resolved, - type(e).__name__, - str(e), - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - - if limit_bytes <= 0: - logger.warning( - "Matrix outbound attachment skipped: media limit {} blocks all uploads for {}", - limit_bytes, - resolved, - ) - return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) - - if size_bytes > limit_bytes: - logger.warning( - "Matrix outbound attachment skipped: {} bytes exceeds limit {} for {}", - size_bytes, - limit_bytes, - resolved, - ) - return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + except OSError: + return fail + if limit_bytes <= 0 or size_bytes > limit_bytes: + return _ATTACH_TOO_LARGE.format(filename) mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - encrypt_upload = self.config.e2ee_enabled and self._is_encrypted_room(room_id) try: - with resolved.open("rb") as data_provider: + with resolved.open("rb") as f: upload_result = await self.client.upload( - data_provider, - content_type=mime, - filename=filename, - encrypt=encrypt_upload, + f, content_type=mime, filename=filename, + encrypt=self.config.e2ee_enabled and self._is_encrypted_room(room_id), filesize=size_bytes, ) - except Exception as e: - logger.warning( - "Matrix outbound attachment upload failed for {} ({}): {}", - resolved, - type(e).__name__, - str(e), - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result - encryption_info: dict[str, Any] | None = None - if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict): - encryption_info = upload_result[1] - if isinstance(upload_response, UploadError): - logger.warning( - "Matrix outbound attachment upload failed for {}: {}", - resolved, - upload_response, - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + except Exception: + return fail + upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result + encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None + if isinstance(upload_response, UploadError): + return fail mxc_url = getattr(upload_response, "content_uri", None) if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): - logger.warning( - "Matrix outbound attachment upload returned unexpected response {} for {}", - type(upload_response).__name__, - resolved, - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + return fail content = self._build_outbound_attachment_content( - filename=filename, - mime=mime, - size_bytes=size_bytes, - mxc_url=mxc_url, - encryption_info=encryption_info, + filename=filename, mime=mime, size_bytes=size_bytes, + mxc_url=mxc_url, encryption_info=encryption_info, ) if relates_to: content["m.relates_to"] = relates_to try: await self._send_room_content(room_id, content) - except Exception as e: - logger.warning( - "Matrix outbound attachment send failed for {} ({}): {}", - resolved, - type(e).__name__, - str(e), - ) - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + except Exception: + return fail return None async def send(self, msg: OutboundMessage) -> None: - """Send Matrix outbound content and clear typing only for non-progress messages.""" + """Send outbound content; clear typing for non-progress messages.""" if not self.client: return - text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) relates_to = self._build_thread_relates_to(msg.metadata) is_progress = bool((msg.metadata or {}).get("_progress")) - try: failures: list[str] = [] - if candidates: limit_bytes = await self._effective_media_limit_bytes() for path in candidates: - failure_marker = await self._upload_and_send_attachment( - room_id=msg.chat_id, - path=path, - limit_bytes=limit_bytes, - relates_to=relates_to, - ) - if failure_marker: - failures.append(failure_marker) - + if fail := await self._upload_and_send_attachment( + msg.chat_id, path, limit_bytes, relates_to): + failures.append(fail) if failures: - if text.strip(): - text = f"{text.rstrip()}\n" + "\n".join(failures) - else: - text = "\n".join(failures) - + text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures) if text or not candidates: content = _build_matrix_text_content(text) if relates_to: @@ -646,73 +364,51 @@ class MatrixChannel(BaseChannel): await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) def _register_event_callbacks(self) -> None: - """Register Matrix event callbacks used by this channel.""" self.client.add_event_callback(self._on_message, RoomMessageText) self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) self.client.add_event_callback(self._on_room_invite, InviteEvent) def _register_response_callbacks(self) -> None: - """Register response callbacks for operational error observability.""" self.client.add_response_callback(self._on_sync_error, SyncError) self.client.add_response_callback(self._on_join_error, JoinError) self.client.add_response_callback(self._on_send_error, RoomSendError) - @staticmethod - def _is_auth_error(errcode: str | None) -> bool: - """Return True if the Matrix errcode indicates auth/token problems.""" - return errcode in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} + def _log_response_error(self, label: str, response: Any) -> None: + """Log Matrix response errors — auth errors at ERROR level, rest at WARNING.""" + code = getattr(response, "status_code", None) + is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} + is_fatal = is_auth or getattr(response, "soft_logout", False) + (logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response) async def _on_sync_error(self, response: SyncError) -> None: - """Log sync errors with clear severity.""" - if self._is_auth_error(response.status_code) or response.soft_logout: - logger.error("Matrix sync failed: {}", response) - return - logger.warning("Matrix sync warning: {}", response) + self._log_response_error("sync", response) async def _on_join_error(self, response: JoinError) -> None: - """Log room-join errors from invite handling.""" - if self._is_auth_error(response.status_code): - logger.error("Matrix join failed: {}", response) - return - logger.warning("Matrix join warning: {}", response) + self._log_response_error("join", response) async def _on_send_error(self, response: RoomSendError) -> None: - """Log message send failures.""" - if self._is_auth_error(response.status_code): - logger.error("Matrix send failed: {}", response) - return - logger.warning("Matrix send warning: {}", response) + self._log_response_error("send", response) async def _set_typing(self, room_id: str, typing: bool) -> None: - """Best-effort typing indicator update that never blocks message flow.""" + """Best-effort typing indicator update.""" if not self.client: return - try: - response = await self.client.room_typing( - room_id=room_id, - typing_state=typing, - timeout=TYPING_NOTICE_TIMEOUT_MS, - ) + response = await self.client.room_typing(room_id=room_id, typing_state=typing, + timeout=TYPING_NOTICE_TIMEOUT_MS) if isinstance(response, RoomTypingError): - logger.debug("Matrix typing update failed for room {}: {}", room_id, response) - except Exception as e: - logger.debug( - "Matrix typing update failed for room {} (typing={}): {}: {}", - room_id, - typing, - type(e).__name__, - str(e), - ) + logger.debug("Matrix typing failed for {}: {}", room_id, response) + except Exception: + pass async def _start_typing_keepalive(self, room_id: str) -> None: - """Start periodic Matrix typing refresh for a room (spec-recommended keepalive).""" + """Start periodic typing refresh (spec-recommended keepalive).""" await self._stop_typing_keepalive(room_id, clear_typing=False) await self._set_typing(room_id, True) if not self._running: return - async def _typing_loop() -> None: + async def loop() -> None: try: while self._running: await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000) @@ -720,31 +416,21 @@ class MatrixChannel(BaseChannel): except asyncio.CancelledError: pass - self._typing_tasks[room_id] = asyncio.create_task(_typing_loop()) + self._typing_tasks[room_id] = asyncio.create_task(loop()) - async def _stop_typing_keepalive( - self, - room_id: str, - *, - clear_typing: bool, - ) -> None: - """Stop periodic Matrix typing refresh for a room.""" - task = self._typing_tasks.pop(room_id, None) - if task: + async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None: + if task := self._typing_tasks.pop(room_id, None): task.cancel() try: await task except asyncio.CancelledError: pass - if clear_typing: await self._set_typing(room_id, False) async def _sync_loop(self) -> None: while self._running: try: - # full_state applies only to the first sync inside sync_forever and helps - # rebuild room state when restoring from stored sync tokens. await self.client.sync_forever(timeout=30000, full_state=True) except asyncio.CancelledError: break @@ -753,63 +439,48 @@ class MatrixChannel(BaseChannel): async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: allow_from = self.config.allow_from or [] - if allow_from and event.sender not in allow_from: - return - - await self.client.join(room.room_id) + if not allow_from or event.sender in allow_from: + await self.client.join(room.room_id) def _is_direct_room(self, room: MatrixRoom) -> bool: - """Return True if the room behaves like a DM (2 or fewer members).""" - member_count = getattr(room, "member_count", None) - return isinstance(member_count, int) and member_count <= 2 + count = getattr(room, "member_count", None) + return isinstance(count, int) and count <= 2 - def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessage) -> bool: - """Resolve mentions strictly from Matrix-native m.mentions payload.""" + def _is_bot_mentioned(self, event: RoomMessage) -> bool: + """Check m.mentions payload for bot mention.""" source = getattr(event, "source", None) if not isinstance(source, dict): return False - - content = source.get("content") - if not isinstance(content, dict): - return False - - mentions = content.get("m.mentions") + mentions = (source.get("content") or {}).get("m.mentions") if not isinstance(mentions, dict): return False - user_ids = mentions.get("user_ids") if isinstance(user_ids, list) and self.config.user_id in user_ids: return True - return bool(self.config.allow_room_mentions and mentions.get("room") is True) def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool: - """Apply sender and room policy checks before processing Matrix messages.""" + """Apply sender and room policy checks.""" if not self.is_allowed(event.sender): return False - if self._is_direct_room(room): return True - policy = self.config.group_policy if policy == "open": return True if policy == "allowlist": return room.room_id in (self.config.group_allow_from or []) if policy == "mention": - return self._is_bot_mentioned_from_mx_mentions(event) - + return self._is_bot_mentioned(event) return False def _media_dir(self) -> Path: - """Return directory used to persist downloaded Matrix attachments.""" - media_dir = get_data_dir() / "media" / "matrix" - media_dir.mkdir(parents=True, exist_ok=True) - return media_dir + d = get_data_dir() / "media" / "matrix" + d.mkdir(parents=True, exist_ok=True) + return d @staticmethod def _event_source_content(event: RoomMessage) -> dict[str, Any]: - """Extract Matrix event content payload when available.""" source = getattr(event, "source", None) if not isinstance(source, dict): return {} @@ -817,30 +488,22 @@ class MatrixChannel(BaseChannel): return content if isinstance(content, dict) else {} def _event_thread_root_id(self, event: RoomMessage) -> str | None: - """Return thread root event_id if this message is inside a thread.""" - content = self._event_source_content(event) - relates_to = content.get("m.relates_to") - if not isinstance(relates_to, dict): - return None - if relates_to.get("rel_type") != "m.thread": + relates_to = self._event_source_content(event).get("m.relates_to") + if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread": return None root_id = relates_to.get("event_id") return root_id if isinstance(root_id, str) and root_id else None def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: - """Build metadata used to reply within a thread.""" - root_id = self._event_thread_root_id(event) - if not root_id: + if not (root_id := self._event_thread_root_id(event)): return None - reply_to = getattr(event, "event_id", None) meta: dict[str, str] = {"thread_root_event_id": root_id} - if isinstance(reply_to, str) and reply_to: + if isinstance(reply_to := getattr(event, "event_id", None), str) and reply_to: meta["thread_reply_to_event_id"] = reply_to return meta @staticmethod def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None: - """Build m.relates_to payload for Matrix thread replies.""" if not metadata: return None root_id = metadata.get("thread_root_event_id") @@ -849,315 +512,170 @@ class MatrixChannel(BaseChannel): reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id") if not isinstance(reply_to, str) or not reply_to: return None - return { - "rel_type": "m.thread", - "event_id": root_id, - "m.in_reply_to": {"event_id": reply_to}, - "is_falling_back": True, - } + return {"rel_type": "m.thread", "event_id": root_id, + "m.in_reply_to": {"event_id": reply_to}, "is_falling_back": True} def _event_attachment_type(self, event: MatrixMediaEvent) -> str: - """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") - if msgtype == "m.image": - return "image" - if msgtype == "m.audio": - return "audio" - if msgtype == "m.video": - return "video" - if msgtype == "m.file": - return "file" - - class_name = type(event).__name__.lower() - if "image" in class_name: - return "image" - if "audio" in class_name: - return "audio" - if "video" in class_name: - return "video" - return "file" + return _MSGTYPE_MAP.get(msgtype, "file") @staticmethod def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool: - """Return True for encrypted Matrix media events.""" - return ( - isinstance(getattr(event, "key", None), dict) - and isinstance(getattr(event, "hashes", None), dict) - and isinstance(getattr(event, "iv", None), str) - ) + return (isinstance(getattr(event, "key", None), dict) + and isinstance(getattr(event, "hashes", None), dict) + and isinstance(getattr(event, "iv", None), str)) def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: - """Return declared media size from Matrix event info, if present.""" info = self._event_source_content(event).get("info") - if not isinstance(info, dict): - return None - size = info.get("size") - if isinstance(size, int) and size >= 0: - return size - return None + size = info.get("size") if isinstance(info, dict) else None + return size if isinstance(size, int) and size >= 0 else None def _event_mime(self, event: MatrixMediaEvent) -> str | None: - """Best-effort MIME extraction from Matrix media event.""" info = self._event_source_content(event).get("info") - if isinstance(info, dict): - mime = info.get("mimetype") - if isinstance(mime, str) and mime: - return mime - - mime = getattr(event, "mimetype", None) - if isinstance(mime, str) and mime: - return mime - return None + if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m: + return m + m = getattr(event, "mimetype", None) + return m if isinstance(m, str) and m else None def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str: - """Build a safe filename for a Matrix attachment.""" body = getattr(event, "body", None) if isinstance(body, str) and body.strip(): - candidate = safe_filename(Path(body).name) - if candidate: + if candidate := safe_filename(Path(body).name): return candidate - return MATRIX_DEFAULT_ATTACHMENT_NAME if attachment_type == "file" else attachment_type + return _DEFAULT_ATTACH_NAME if attachment_type == "file" else attachment_type - def _build_attachment_path( - self, - event: MatrixMediaEvent, - attachment_type: str, - filename: str, - mime: str | None, - ) -> Path: - """Compute a deterministic local file path for a downloaded attachment.""" - safe_name = safe_filename(Path(filename).name) or MATRIX_DEFAULT_ATTACHMENT_NAME + def _build_attachment_path(self, event: MatrixMediaEvent, attachment_type: str, + filename: str, mime: str | None) -> Path: + safe_name = safe_filename(Path(filename).name) or _DEFAULT_ATTACH_NAME suffix = Path(safe_name).suffix if not suffix and mime: - guessed = mimetypes.guess_extension(mime, strict=False) or "" - if guessed: - safe_name = f"{safe_name}{guessed}" - suffix = guessed - - stem = Path(safe_name).stem or attachment_type - stem = stem[:72] + if guessed := mimetypes.guess_extension(mime, strict=False): + safe_name, suffix = f"{safe_name}{guessed}", guessed + stem = (Path(safe_name).stem or attachment_type)[:72] suffix = suffix[:16] - event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$")) event_prefix = (event_id[:24] or "evt").strip("_") return self._media_dir() / f"{event_prefix}_{stem}{suffix}" async def _download_media_bytes(self, mxc_url: str) -> bytes | None: - """Download media bytes from Matrix content repository.""" if not self.client: return None - response = await self.client.download(mxc=mxc_url) if isinstance(response, DownloadError): - logger.warning("Matrix attachment download failed for {}: {}", mxc_url, response) + logger.warning("Matrix download failed for {}: {}", mxc_url, response) return None - body = getattr(response, "body", None) if isinstance(body, (bytes, bytearray)): return bytes(body) - if isinstance(response, MemoryDownloadResponse): return bytes(response.body) - if isinstance(body, (str, Path)): path = Path(body) if path.is_file(): try: return path.read_bytes() - except OSError as e: - logger.warning( - "Matrix attachment read failed for {} ({}): {}", - mxc_url, - type(e).__name__, - str(e), - ) + except OSError: return None - - logger.warning( - "Matrix attachment download failed for {}: unexpected response type {}", - mxc_url, - type(response).__name__, - ) return None def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: - """Decrypt encrypted Matrix attachment bytes.""" - key_obj = getattr(event, "key", None) - hashes = getattr(event, "hashes", None) - iv = getattr(event, "iv", None) - + key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None) key = key_obj.get("k") if isinstance(key_obj, dict) else None sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None - if not isinstance(key, str) or not isinstance(sha256, str) or not isinstance(iv, str): - logger.warning( - "Matrix encrypted attachment missing key material for event {}", - getattr(event, "event_id", ""), - ) + if not all(isinstance(v, str) for v in (key, sha256, iv)): return None - try: return decrypt_attachment(ciphertext, key, sha256, iv) - except (EncryptionError, ValueError, TypeError) as e: - logger.warning( - "Matrix encrypted attachment decryption failed for event {} ({}): {}", - getattr(event, "event_id", ""), - type(e).__name__, - str(e), - ) + except (EncryptionError, ValueError, TypeError): + logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", "")) return None async def _fetch_media_attachment( - self, - room: MatrixRoom, - event: MatrixMediaEvent, + self, room: MatrixRoom, event: MatrixMediaEvent, ) -> tuple[dict[str, Any] | None, str]: - """Download and prepare a Matrix attachment for inbound processing.""" - attachment_type = self._event_attachment_type(event) + """Download, decrypt if needed, and persist a Matrix attachment.""" + atype = self._event_attachment_type(event) mime = self._event_mime(event) - filename = self._event_filename(event, attachment_type) + filename = self._event_filename(event, atype) mxc_url = getattr(event, "url", None) + fail = _ATTACH_FAILED.format(filename) if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): - logger.warning( - "Matrix attachment skipped in room {}: invalid mxc URL {}", - room.room_id, - mxc_url, - ) - return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + return None, fail limit_bytes = await self._effective_media_limit_bytes() - declared_size = self._event_declared_size_bytes(event) - if ( - declared_size is not None - and declared_size > limit_bytes - ): - logger.warning( - "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", - room.room_id, - declared_size, - limit_bytes, - ) - return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + declared = self._event_declared_size_bytes(event) + if declared is not None and declared > limit_bytes: + return None, _ATTACH_TOO_LARGE.format(filename) downloaded = await self._download_media_bytes(mxc_url) if downloaded is None: - return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + return None, fail encrypted = self._is_encrypted_media_event(event) data = downloaded if encrypted: - decrypted = self._decrypt_media_bytes(event, downloaded) - if decrypted is None: - return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) - data = decrypted + if (data := self._decrypt_media_bytes(event, downloaded)) is None: + return None, fail if len(data) > limit_bytes: - logger.warning( - "Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}", - room.room_id, - len(data), - limit_bytes, - ) - return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + return None, _ATTACH_TOO_LARGE.format(filename) - path = self._build_attachment_path( - event, - attachment_type, - filename, - mime, - ) + path = self._build_attachment_path(event, atype, filename, mime) try: path.write_bytes(data) - except OSError as e: - logger.warning( - "Matrix attachment persist failed for room {} ({}): {}", - room.room_id, - type(e).__name__, - str(e), - ) - return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + except OSError: + return None, fail attachment = { - "type": attachment_type, - "mime": mime, - "filename": filename, + "type": atype, "mime": mime, "filename": filename, "event_id": str(getattr(event, "event_id", "") or ""), - "encrypted": encrypted, - "size_bytes": len(data), - "path": str(path), - "mxc_url": mxc_url, + "encrypted": encrypted, "size_bytes": len(data), + "path": str(path), "mxc_url": mxc_url, } - return attachment, MATRIX_ATTACHMENT_MARKER_TEMPLATE.format(path) + return attachment, _ATTACH_MARKER.format(path) + + def _base_metadata(self, room: MatrixRoom, event: RoomMessage) -> dict[str, Any]: + """Build common metadata for text and media handlers.""" + meta: dict[str, Any] = {"room": getattr(room, "display_name", room.room_id)} + if isinstance(eid := getattr(event, "event_id", None), str) and eid: + meta["event_id"] = eid + if thread := self._thread_metadata(event): + meta.update(thread) + return meta async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: - # Ignore self messages - if event.sender == self.config.user_id: + if event.sender == self.config.user_id or not self._should_process_message(room, event): return - - if not self._should_process_message(room, event): - return - await self._start_typing_keepalive(room.room_id) try: - metadata: dict[str, Any] = { - "room": getattr(room, "display_name", room.room_id), - } - event_id = getattr(event, "event_id", None) - if isinstance(event_id, str) and event_id: - metadata["event_id"] = event_id - thread_meta = self._thread_metadata(event) - if thread_meta: - metadata.update(thread_meta) await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content=event.body, - metadata=metadata, + sender_id=event.sender, chat_id=room.room_id, + content=event.body, metadata=self._base_metadata(room, event), ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None: - """Handle inbound Matrix media events and forward local attachment paths.""" - if event.sender == self.config.user_id: + if event.sender == self.config.user_id or not self._should_process_message(room, event): return - - if not self._should_process_message(room, event): - return - attachment, marker = await self._fetch_media_attachment(room, event) - attachments = [attachment] if attachment else [] - markers = [marker] - media_paths = [a["path"] for a in attachments] - - body = getattr(event, "body", None) - content_parts: list[str] = [] - if isinstance(body, str) and body.strip(): - content_parts.append(body.strip()) - content_parts.extend(markers) - - # TODO: Optionally add audio transcription support for Matrix attachments, - # behind explicit config. + parts: list[str] = [] + if isinstance(body := getattr(event, "body", None), str) and body.strip(): + parts.append(body.strip()) + parts.append(marker) await self._start_typing_keepalive(room.room_id) try: - metadata: dict[str, Any] = { - "room": getattr(room, "display_name", room.room_id), - "attachments": attachments, - } - event_id = getattr(event, "event_id", None) - if isinstance(event_id, str) and event_id: - metadata["event_id"] = event_id - thread_meta = self._thread_metadata(event) - if thread_meta: - metadata.update(thread_meta) + meta = self._base_metadata(room, event) + if attachment: + meta["attachments"] = [attachment] await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content="\n".join(content_parts), - media=media_paths, - metadata=metadata, + sender_id=event.sender, chat_id=room.room_id, + content="\n".join(parts), + media=[attachment["path"]] if attachment else [], + metadata=meta, ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) diff --git a/pyproject.toml b/pyproject.toml index fc5ecc6..20dcb1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,12 +42,14 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", +] + +[project.optional-dependencies] +matrix = [ "matrix-nio[e2e]>=0.25.2", "mistune>=3.0.0,<4.0.0", "nh3>=0.2.17,<1.0.0", ] - -[project.optional-dependencies] dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0",