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

@ -53,6 +53,24 @@ def content_file_event():
)
def source_only_content_file_event():
return SimpleNamespace(
sender="@a:m.org",
body="doc.pdf",
event_id="$e5",
msgtype=None,
replyto_event_id=None,
source={
"content": {
"msgtype": "m.file",
"body": "source-only.pdf",
"url": "mxc://x/source-only",
"info": {"mimetype": "application/pdf"},
}
},
)
def test_plain_text_to_incoming_message():
result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
@ -147,5 +165,15 @@ def test_attachment_falls_back_to_content_payload():
assert a.mime_type == "application/pdf"
def test_attachment_falls_back_to_source_content_payload():
result = from_room_event(source_only_content_file_event(), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
a = result.attachments[0]
assert a.type == "document"
assert a.url == "mxc://x/source-only"
assert a.filename == "source-only.pdf"
assert a.mime_type == "application/pdf"
def test_converter_module_does_not_expose_reaction_callbacks():
assert not hasattr(converter, "from_reaction")

View file

@ -5,6 +5,13 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from nio import (
RoomMessageAudio,
RoomMessageFile,
RoomMessageImage,
RoomMessageText,
RoomMessageVideo,
)
from nio.api import RoomVisibility
from nio.responses import SyncResponse
@ -332,7 +339,7 @@ async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, m
staged = await get_staged_attachments(runtime.store, "!chat1:example.org", "@alice:example.org")
assert staged[0]["workspace_path"] is not None
assert (tmp_path / staged[0]["workspace_path"]).read_bytes() == b"%PDF-1.7"
bot._send_all.assert_awaited_once()
bot._send_all.assert_not_awaited()
async def test_file_only_event_is_staged_and_does_not_dispatch():
@ -371,10 +378,7 @@ async def test_file_only_event_is_staged_and_does_not_dispatch():
runtime.dispatcher.dispatch.assert_not_awaited()
staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
assert [item["filename"] for item in staged] == ["report.pdf"]
client.room_send.assert_awaited_once()
assert (
"Следующее сообщение отправит файлы агенту." in client.room_send.await_args.args[2]["body"]
)
client.room_send.assert_not_awaited()
async def test_list_command_returns_current_staged_attachments():
@ -963,3 +967,43 @@ async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeyp
agent_connect.assert_not_awaited()
platform_close.assert_awaited_once()
async def test_matrix_main_registers_media_message_callbacks(monkeypatch):
bot_module = importlib.import_module("adapter.matrix.bot")
runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
created_clients = []
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self.access_token = None
self.callbacks = []
self.sync_forever = AsyncMock()
self.close = AsyncMock()
created_clients.append(self)
async def login(self, *args, **kwargs):
raise AssertionError("login should not be called when access token is provided")
def add_event_callback(self, callback, event_type):
self.callbacks.append((callback, event_type))
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
await bot_module.main()
assert len(created_clients) == 1
registered_types = [event_type for _, event_type in created_clients[0].callbacks]
assert (
RoomMessageText,
RoomMessageFile,
RoomMessageImage,
RoomMessageVideo,
RoomMessageAudio,
) in registered_types

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from adapter.matrix.files import build_workspace_attachment_path, download_matrix_attachment
from core.protocol import Attachment
def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path: Path):
rel_path, abs_path = build_workspace_attachment_path(
workspace_root=tmp_path,
matrix_user_id="@alice:example.org",
room_id="!room:example.org",
filename="report.pdf",
timestamp="20260420-153000",
)
assert (
rel_path
== "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
)
assert abs_path == tmp_path / rel_path
async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
async def download(url: str):
assert url == "mxc://server/id"
return SimpleNamespace(body=b"%PDF-1.7")
client = SimpleNamespace(download=download)
attachment = Attachment(
type="document",
url="mxc://server/id",
filename="report.pdf",
mime_type="application/pdf",
)
saved = await download_matrix_attachment(
client=client,
workspace_root=tmp_path,
matrix_user_id="@alice:example.org",
room_id="!room:example.org",
attachment=attachment,
timestamp="20260420-153000",
)
assert saved.workspace_path is not None
assert saved.workspace_path.endswith("20260420-153000-report.pdf")
assert (tmp_path / saved.workspace_path).read_bytes() == b"%PDF-1.7"

View file

@ -9,7 +9,7 @@ from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_conf
from adapter.matrix.store import get_pending_confirm, set_room_meta
from core.auth import AuthManager
from core.chat import ChatManager
from core.protocol import OutgoingUI, UIButton
from core.protocol import Attachment, OutgoingMessage, OutgoingUI, UIButton
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
@ -156,3 +156,39 @@ async def test_outgoing_ui_no_round_trip_uses_user_and_room_scope():
assert "отменено" in result[0].text.lower()
assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
async def test_send_outgoing_uploads_workspace_file_attachment(tmp_path, monkeypatch):
workspace_file = tmp_path / "surfaces" / "matrix" / "alice" / "room" / "inbox" / "result.txt"
workspace_file.parent.mkdir(parents=True, exist_ok=True)
workspace_file.write_text("ready")
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
client = SimpleNamespace(
upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
room_send=AsyncMock(),
)
await send_outgoing(
client,
"!room:example.org",
OutgoingMessage(
chat_id="!room:example.org",
text="Файл готов",
attachments=[
Attachment(
type="document",
filename="result.txt",
mime_type="text/plain",
workspace_path="surfaces/matrix/alice/room/inbox/result.txt",
)
],
),
)
client.upload.assert_awaited_once()
client.room_send.assert_awaited()
assert client.room_send.await_args_list[0].args[2]["body"] == "Файл готов"
file_call = client.room_send.await_args_list[1]
assert file_call.args[2]["msgtype"] == "m.file"
assert file_call.args[2]["url"] == "mxc://server/file"