From 97cb85ee0b4851db446b1c6ce3ae38c48b40d450 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:45:28 +0100 Subject: [PATCH] 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())