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>
|
<details>
|
||||||
<summary><b>Matrix (Element)</b></summary>
|
<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**
|
**1. Create/choose a Matrix account**
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ Uses Matrix sync via `matrix-nio` (including inbound media support).
|
|||||||
"groupPolicy": "open",
|
"groupPolicy": "open",
|
||||||
"groupAllowFrom": [],
|
"groupAllowFrom": [],
|
||||||
"allowRoomMentions": false,
|
"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=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).
|
> - 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.
|
> - 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**
|
**4. Run**
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class ChannelManager:
|
|||||||
if self.config.channels.telegram.enabled:
|
if self.config.channels.telegram.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.telegram import TelegramChannel
|
from nanobot.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
self.channels["telegram"] = TelegramChannel(
|
self.channels["telegram"] = TelegramChannel(
|
||||||
self.config.channels.telegram,
|
self.config.channels.telegram,
|
||||||
self.bus,
|
self.bus,
|
||||||
@@ -51,9 +52,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.whatsapp.enabled:
|
if self.config.channels.whatsapp.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.whatsapp import WhatsAppChannel
|
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")
|
logger.info("WhatsApp channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"WhatsApp channel not available: {e}")
|
logger.warning(f"WhatsApp channel not available: {e}")
|
||||||
@@ -62,9 +62,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.discord.enabled:
|
if self.config.channels.discord.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.discord import DiscordChannel
|
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")
|
logger.info("Discord channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Discord channel not available: {e}")
|
logger.warning(f"Discord channel not available: {e}")
|
||||||
@@ -73,9 +72,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.feishu.enabled:
|
if self.config.channels.feishu.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.feishu import FeishuChannel
|
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")
|
logger.info("Feishu channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Feishu channel not available: {e}")
|
logger.warning(f"Feishu channel not available: {e}")
|
||||||
@@ -85,9 +83,7 @@ class ChannelManager:
|
|||||||
try:
|
try:
|
||||||
from nanobot.channels.mochat import MochatChannel
|
from nanobot.channels.mochat import MochatChannel
|
||||||
|
|
||||||
self.channels["mochat"] = MochatChannel(
|
self.channels["mochat"] = MochatChannel(self.config.channels.mochat, self.bus)
|
||||||
self.config.channels.mochat, self.bus
|
|
||||||
)
|
|
||||||
logger.info("Mochat channel enabled")
|
logger.info("Mochat channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Mochat channel not available: {e}")
|
logger.warning(f"Mochat channel not available: {e}")
|
||||||
@@ -96,9 +92,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.dingtalk.enabled:
|
if self.config.channels.dingtalk.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.dingtalk import DingTalkChannel
|
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")
|
logger.info("DingTalk channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"DingTalk channel not available: {e}")
|
logger.warning(f"DingTalk channel not available: {e}")
|
||||||
@@ -107,9 +102,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.email.enabled:
|
if self.config.channels.email.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.email import EmailChannel
|
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")
|
logger.info("Email channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Email channel not available: {e}")
|
logger.warning(f"Email channel not available: {e}")
|
||||||
@@ -118,9 +112,8 @@ class ChannelManager:
|
|||||||
if self.config.channels.slack.enabled:
|
if self.config.channels.slack.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.slack import SlackChannel
|
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")
|
logger.info("Slack channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Slack channel not available: {e}")
|
logger.warning(f"Slack channel not available: {e}")
|
||||||
@@ -129,6 +122,7 @@ class ChannelManager:
|
|||||||
if self.config.channels.qq.enabled:
|
if self.config.channels.qq.enabled:
|
||||||
try:
|
try:
|
||||||
from nanobot.channels.qq import QQChannel
|
from nanobot.channels.qq import QQChannel
|
||||||
|
|
||||||
self.channels["qq"] = QQChannel(
|
self.channels["qq"] = QQChannel(
|
||||||
self.config.channels.qq,
|
self.config.channels.qq,
|
||||||
self.bus,
|
self.bus,
|
||||||
@@ -188,10 +182,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(
|
msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0)
|
||||||
self.bus.consume_outbound(),
|
|
||||||
timeout=1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
channel = self.channels.get(msg.channel)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel:
|
if channel:
|
||||||
@@ -214,10 +205,7 @@ class ChannelManager:
|
|||||||
def get_status(self) -> dict[str, Any]:
|
def get_status(self) -> dict[str, Any]:
|
||||||
"""Get status of all channels."""
|
"""Get status of all channels."""
|
||||||
return {
|
return {
|
||||||
name: {
|
name: {"enabled": True, "running": channel.is_running}
|
||||||
"enabled": True,
|
|
||||||
"running": channel.is_running
|
|
||||||
}
|
|
||||||
for name, channel in self.channels.items()
|
for name, channel in self.channels.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from mistune import create_markdown
|
|||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
|
ContentRepositoryConfigError,
|
||||||
DownloadError,
|
DownloadError,
|
||||||
InviteEvent,
|
InviteEvent,
|
||||||
JoinError,
|
JoinError,
|
||||||
@@ -22,6 +23,7 @@ from nio import (
|
|||||||
RoomSendError,
|
RoomSendError,
|
||||||
RoomTypingError,
|
RoomTypingError,
|
||||||
SyncError,
|
SyncError,
|
||||||
|
UploadError,
|
||||||
)
|
)
|
||||||
from nio.crypto.attachments import decrypt_attachment
|
from nio.crypto.attachments import decrypt_attachment
|
||||||
from nio.exceptions import EncryptionError
|
from nio.exceptions import EncryptionError
|
||||||
@@ -44,6 +46,7 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html"
|
|||||||
MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]"
|
MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]"
|
||||||
MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]"
|
MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]"
|
||||||
MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]"
|
MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]"
|
||||||
|
MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE = "[attachment: {} - upload failed]"
|
||||||
MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment"
|
MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment"
|
||||||
|
|
||||||
# Runtime callback filter for nio event dispatch (checked via isinstance).
|
# Runtime callback filter for nio event dispatch (checked via isinstance).
|
||||||
@@ -227,11 +230,22 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
name = "matrix"
|
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)
|
super().__init__(config, bus)
|
||||||
self.client: AsyncClient | None = None
|
self.client: AsyncClient | None = None
|
||||||
self._sync_task: asyncio.Task | None = None
|
self._sync_task: asyncio.Task | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
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:
|
async def start(self) -> None:
|
||||||
"""Start Matrix client and begin sync loop."""
|
"""Start Matrix client and begin sync loop."""
|
||||||
@@ -313,21 +327,266 @@ class MatrixChannel(BaseChannel):
|
|||||||
if self.client:
|
if self.client:
|
||||||
await self.client.close()
|
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:
|
if not self.client:
|
||||||
return
|
return
|
||||||
|
|
||||||
room_send_kwargs: dict[str, Any] = {
|
room_send_kwargs: dict[str, Any] = {
|
||||||
"room_id": msg.chat_id,
|
"room_id": room_id,
|
||||||
"message_type": "m.room.message",
|
"message_type": "m.room.message",
|
||||||
"content": _build_matrix_text_content(msg.content),
|
"content": content,
|
||||||
}
|
}
|
||||||
if self.config.e2ee_enabled:
|
if self.config.e2ee_enabled:
|
||||||
# TODO(matrix): Add explicit config for strict verified-device sending mode.
|
# TODO(matrix): Add explicit config for strict verified-device sending mode.
|
||||||
room_send_kwargs["ignore_unverified_devices"] = True
|
room_send_kwargs["ignore_unverified_devices"] = True
|
||||||
|
|
||||||
try:
|
|
||||||
await self.client.room_send(**room_send_kwargs)
|
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:
|
||||||
|
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:
|
finally:
|
||||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
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)
|
return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename)
|
||||||
|
|
||||||
|
limit_bytes = await self._effective_media_limit_bytes()
|
||||||
declared_size = self._event_declared_size_bytes(event)
|
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(
|
logger.warning(
|
||||||
"Matrix attachment skipped in room {}: declared size {} exceeds limit {}",
|
"Matrix attachment skipped in room {}: declared size {} exceeds limit {}",
|
||||||
room.room_id,
|
room.room_id,
|
||||||
declared_size,
|
declared_size,
|
||||||
self.config.max_inbound_media_bytes,
|
limit_bytes,
|
||||||
)
|
)
|
||||||
return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename)
|
return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename)
|
||||||
|
|
||||||
@@ -733,12 +993,12 @@ class MatrixChannel(BaseChannel):
|
|||||||
return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename)
|
return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename)
|
||||||
data = decrypted
|
data = decrypted
|
||||||
|
|
||||||
if len(data) > self.config.max_inbound_media_bytes:
|
if len(data) > limit_bytes:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}",
|
"Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}",
|
||||||
room.room_id,
|
room.room_id,
|
||||||
len(data),
|
len(data),
|
||||||
self.config.max_inbound_media_bytes,
|
limit_bytes,
|
||||||
)
|
)
|
||||||
return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename)
|
return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename)
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ class MatrixConfig(Base):
|
|||||||
e2ee_enabled: bool = True
|
e2ee_enabled: bool = True
|
||||||
# Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
# Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
||||||
sync_stop_grace_seconds: int = 2
|
sync_stop_grace_seconds: int = 2
|
||||||
# Max attachment size accepted from inbound Matrix media events.
|
# Max attachment size accepted for Matrix media handling (inbound + outbound).
|
||||||
max_inbound_media_bytes: int = 20 * 1024 * 1024
|
max_media_bytes: int = 20 * 1024 * 1024
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ class _FakeAsyncClient:
|
|||||||
self.room_send_calls: list[dict[str, object]] = []
|
self.room_send_calls: list[dict[str, object]] = []
|
||||||
self.typing_calls: list[tuple[str, bool, int]] = []
|
self.typing_calls: list[tuple[str, bool, int]] = []
|
||||||
self.download_calls: list[dict[str, object]] = []
|
self.download_calls: list[dict[str, object]] = []
|
||||||
|
self.upload_calls: list[dict[str, object]] = []
|
||||||
self.download_response: object | None = None
|
self.download_response: object | None = None
|
||||||
self.download_bytes: bytes = b"media"
|
self.download_bytes: bytes = b"media"
|
||||||
self.download_content_type: str = "application/octet-stream"
|
self.download_content_type: str = "application/octet-stream"
|
||||||
self.download_filename: str | None = None
|
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_send = False
|
||||||
self.raise_on_typing = False
|
self.raise_on_typing = False
|
||||||
|
self.raise_on_upload = False
|
||||||
|
|
||||||
def add_event_callback(self, callback, event_type) -> None:
|
def add_event_callback(self, callback, event_type) -> None:
|
||||||
self.callbacks.append((callback, event_type))
|
self.callbacks.append((callback, event_type))
|
||||||
@@ -108,6 +112,32 @@ class _FakeAsyncClient:
|
|||||||
filename=self.download_filename,
|
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:
|
async def close(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -523,7 +553,7 @@ async def test_on_media_message_respects_declared_size_limit(
|
|||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
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)
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
channel.client = client
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None:
|
async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None:
|
||||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None:
|
async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None:
|
||||||
channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus())
|
channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus())
|
||||||
|
|||||||
Reference in New Issue
Block a user