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)
This commit is contained in:
Alexander Minges
2026-02-11 10:45:28 +01:00
parent bfd2018095
commit 97cb85ee0b
5 changed files with 482 additions and 64 deletions

View File

@@ -304,7 +304,7 @@ nanobot gateway
<details>
<summary><b>Matrix (Element)</b></summary>
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**

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())