feat: support shared-workspace file flow for matrix

This commit is contained in:
Mikhail Putilovskij 2026-04-21 00:26:21 +03:00
parent 323a6d3144
commit 6422c7db58
18 changed files with 871 additions and 80 deletions

View file

@ -13,7 +13,12 @@ from nio import (
InviteMemberEvent,
MatrixRoom,
RoomMemberEvent,
RoomMessage,
RoomMessageAudio,
RoomMessageFile,
RoomMessageImage,
RoomMessageText,
RoomMessageVideo,
)
from nio.responses import SyncResponse
@ -227,19 +232,6 @@ class MatrixBot:
incoming,
)
await self._stage_attachments(room.room_id, sender, materialized.attachments)
await self._send_all(
room.room_id,
[
OutgoingMessage(
chat_id=dispatch_chat_id,
text=await self._format_staged_attachments(
room.room_id,
sender,
include_hint=True,
),
)
],
)
return
if isinstance(incoming, IncomingMessage) and incoming.attachments:
incoming = await self._materialize_incoming_attachments(
@ -276,12 +268,12 @@ class MatrixBot:
await self._send_all(room.room_id, outgoing)
def _is_file_only_event(
self, event: RoomMessageText, incoming: IncomingMessage | IncomingCommand
self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand
) -> bool:
return (
isinstance(incoming, IncomingMessage)
and bool(incoming.attachments)
and getattr(event, "msgtype", None) != "m.text"
and not isinstance(event, RoomMessageText)
)
async def _stage_attachments(
@ -669,7 +661,16 @@ async def main() -> None:
since_token = await prepare_live_sync(client)
bot = MatrixBot(client, runtime)
client.add_event_callback(bot.on_room_message, RoomMessageText)
client.add_event_callback(
bot.on_room_message,
(
RoomMessageText,
RoomMessageFile,
RoomMessageImage,
RoomMessageVideo,
RoomMessageAudio,
),
)
client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent))
logger.info(

View file

@ -14,7 +14,8 @@ PLATFORM = "matrix"
def extract_attachments(event: Any) -> list[Attachment]:
content = getattr(event, "content", {}) or {}
source = getattr(event, "source", {}) or {}
content = source.get("content", {}) or getattr(event, "content", {}) or {}
msgtype = getattr(event, "msgtype", None)
if msgtype is None:
msgtype = content.get("msgtype")

103
adapter/matrix/files.py Normal file
View file

@ -0,0 +1,103 @@
from __future__ import annotations
import mimetypes
import re
from datetime import UTC, datetime
from pathlib import Path
from core.protocol import Attachment
def _sanitize_component(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
cleaned = cleaned.strip("._-")
return cleaned or "unknown"
def _default_filename(attachment: Attachment) -> str:
if attachment.filename:
return attachment.filename
extension = mimetypes.guess_extension(attachment.mime_type or "") or ""
base = {
"image": "image",
"audio": "audio",
"video": "video",
"document": "attachment",
}.get(attachment.type, "attachment")
return f"{base}{extension}"
def build_workspace_attachment_path(
*,
workspace_root: Path,
matrix_user_id: str,
room_id: str,
filename: str,
timestamp: str | None = None,
) -> tuple[str, Path]:
stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
safe_room = _sanitize_component(room_id.lstrip("!"))
safe_name = _sanitize_component(filename) or "attachment.bin"
relative_path = (
Path("surfaces")
/ "matrix"
/ safe_user
/ safe_room
/ "inbox"
/ f"{stamp}-{safe_name}"
)
return relative_path.as_posix(), workspace_root / relative_path
async def download_matrix_attachment(
*,
client,
workspace_root: Path,
matrix_user_id: str,
room_id: str,
attachment: Attachment,
timestamp: str | None = None,
) -> Attachment:
if not attachment.url:
return attachment
filename = _default_filename(attachment)
relative_path, absolute_path = build_workspace_attachment_path(
workspace_root=workspace_root,
matrix_user_id=matrix_user_id,
room_id=room_id,
filename=filename,
timestamp=timestamp,
)
absolute_path.parent.mkdir(parents=True, exist_ok=True)
response = await client.download(attachment.url)
body = getattr(response, "body", None)
if body is None:
raise RuntimeError(f"Matrix download response for {attachment.url} has no body")
absolute_path.write_bytes(body)
return Attachment(
type=attachment.type,
url=attachment.url,
filename=filename,
mime_type=attachment.mime_type,
workspace_path=relative_path,
)
def resolve_workspace_attachment_path(workspace_root: Path, workspace_path: str) -> Path:
path = Path(workspace_path)
if path.is_absolute():
return path
return workspace_root / path
def matrix_msgtype_for_attachment(attachment: Attachment) -> str:
return {
"image": "m.image",
"audio": "m.audio",
"video": "m.video",
}.get(attachment.type, "m.file")