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:
@@ -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**
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user