feat: add matrix staging list and remove flow
This commit is contained in:
parent
83c9a1513b
commit
f111ed3348
2 changed files with 510 additions and 20 deletions
|
|
@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from dotenv import load_dotenv
|
||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
|
|
@ -15,28 +16,38 @@ from nio import (
|
||||||
RoomMessageText,
|
RoomMessageText,
|
||||||
)
|
)
|
||||||
from nio.responses import SyncResponse
|
from nio.responses import SyncResponse
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from adapter.matrix.converter import from_room_event
|
from adapter.matrix.converter import from_room_event
|
||||||
|
from adapter.matrix.files import (
|
||||||
|
download_matrix_attachment,
|
||||||
|
matrix_msgtype_for_attachment,
|
||||||
|
resolve_workspace_attachment_path,
|
||||||
|
)
|
||||||
from adapter.matrix.handlers import register_matrix_handlers
|
from adapter.matrix.handlers import register_matrix_handlers
|
||||||
|
from adapter.matrix.handlers.auth import handle_invite, provision_workspace_chat
|
||||||
from adapter.matrix.handlers.context_commands import (
|
from adapter.matrix.handlers.context_commands import (
|
||||||
LOAD_PROMPT,
|
LOAD_PROMPT,
|
||||||
)
|
)
|
||||||
from adapter.matrix.handlers.auth import handle_invite, provision_workspace_chat
|
|
||||||
from adapter.matrix.room_router import resolve_chat_id
|
from adapter.matrix.room_router import resolve_chat_id
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
|
add_staged_attachment,
|
||||||
clear_load_pending,
|
clear_load_pending,
|
||||||
|
clear_staged_attachments,
|
||||||
get_load_pending,
|
get_load_pending,
|
||||||
get_room_meta,
|
get_room_meta,
|
||||||
|
get_staged_attachments,
|
||||||
|
remove_staged_attachment_at,
|
||||||
|
set_pending_confirm,
|
||||||
set_platform_chat_id,
|
set_platform_chat_id,
|
||||||
set_room_meta,
|
set_room_meta,
|
||||||
set_pending_confirm,
|
|
||||||
)
|
)
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
from core.chat import ChatManager
|
from core.chat import ChatManager
|
||||||
from core.handler import EventDispatcher
|
from core.handler import EventDispatcher
|
||||||
from core.handlers import register_all
|
from core.handlers import register_all
|
||||||
from core.protocol import (
|
from core.protocol import (
|
||||||
|
IncomingCommand,
|
||||||
|
IncomingMessage,
|
||||||
OutgoingEvent,
|
OutgoingEvent,
|
||||||
OutgoingMessage,
|
OutgoingMessage,
|
||||||
OutgoingNotification,
|
OutgoingNotification,
|
||||||
|
|
@ -197,6 +208,44 @@ class MatrixBot:
|
||||||
incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
|
incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
|
||||||
if incoming is None:
|
if incoming is None:
|
||||||
return
|
return
|
||||||
|
if isinstance(incoming, IncomingCommand) and incoming.command in {
|
||||||
|
"matrix_list_attachments",
|
||||||
|
"matrix_remove_attachment",
|
||||||
|
}:
|
||||||
|
outgoing = await self._handle_staged_attachment_command(
|
||||||
|
room.room_id,
|
||||||
|
sender,
|
||||||
|
incoming,
|
||||||
|
)
|
||||||
|
await self._send_all(room.room_id, outgoing)
|
||||||
|
return
|
||||||
|
if self._is_file_only_event(event, incoming):
|
||||||
|
materialized = await self._materialize_incoming_attachments(
|
||||||
|
room.room_id,
|
||||||
|
sender,
|
||||||
|
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(
|
||||||
|
room.room_id,
|
||||||
|
sender,
|
||||||
|
incoming,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
||||||
except PlatformError as exc:
|
except PlatformError as exc:
|
||||||
|
|
@ -210,11 +259,125 @@ class MatrixBot:
|
||||||
outgoing = [
|
outgoing = [
|
||||||
OutgoingMessage(
|
OutgoingMessage(
|
||||||
chat_id=dispatch_chat_id,
|
chat_id=dispatch_chat_id,
|
||||||
text="Сервис временно недоступен. Попробуйте ещё раз позже."
|
text="Сервис временно недоступен. Попробуйте ещё раз позже.",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
await self._send_all(room.room_id, outgoing)
|
await self._send_all(room.room_id, outgoing)
|
||||||
|
|
||||||
|
def _is_file_only_event(
|
||||||
|
self, event: RoomMessageText, incoming: IncomingMessage | IncomingCommand
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(incoming, IncomingMessage)
|
||||||
|
and bool(incoming.attachments)
|
||||||
|
and getattr(event, "msgtype", None) != "m.text"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _stage_attachments(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
user_id: str,
|
||||||
|
attachments: list,
|
||||||
|
) -> None:
|
||||||
|
for attachment in attachments:
|
||||||
|
await add_staged_attachment(
|
||||||
|
self.runtime.store,
|
||||||
|
room_id,
|
||||||
|
user_id,
|
||||||
|
{
|
||||||
|
"type": attachment.type,
|
||||||
|
"url": attachment.url,
|
||||||
|
"filename": attachment.filename,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"workspace_path": attachment.workspace_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _format_staged_attachments(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
include_hint: bool = False,
|
||||||
|
) -> str:
|
||||||
|
attachments = await get_staged_attachments(self.runtime.store, room_id, user_id)
|
||||||
|
if not attachments:
|
||||||
|
return "Нет сохраненных вложений."
|
||||||
|
|
||||||
|
lines = ["Вложения в очереди:"]
|
||||||
|
for index, attachment in enumerate(attachments, start=1):
|
||||||
|
lines.append(f"{index}. {attachment.get('filename') or 'attachment'}")
|
||||||
|
if include_hint:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"Следующее сообщение отправит файлы агенту.",
|
||||||
|
"Команды: !list, !remove <n>, !remove all",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def _handle_staged_attachment_command(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
user_id: str,
|
||||||
|
incoming: IncomingCommand,
|
||||||
|
) -> list[OutgoingEvent]:
|
||||||
|
if incoming.command == "matrix_list_attachments":
|
||||||
|
return [
|
||||||
|
OutgoingMessage(
|
||||||
|
chat_id=incoming.chat_id,
|
||||||
|
text=await self._format_staged_attachments(room_id, user_id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
arg = incoming.args[0] if incoming.args else ""
|
||||||
|
if arg == "all":
|
||||||
|
await clear_staged_attachments(self.runtime.store, room_id, user_id)
|
||||||
|
return [OutgoingMessage(chat_id=incoming.chat_id, text="Все вложения удалены.")]
|
||||||
|
|
||||||
|
try:
|
||||||
|
index = int(arg) - 1
|
||||||
|
except ValueError:
|
||||||
|
return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")]
|
||||||
|
|
||||||
|
removed = await remove_staged_attachment_at(self.runtime.store, room_id, user_id, index)
|
||||||
|
if removed is None:
|
||||||
|
return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")]
|
||||||
|
return [
|
||||||
|
OutgoingMessage(
|
||||||
|
chat_id=incoming.chat_id,
|
||||||
|
text=await self._format_staged_attachments(room_id, user_id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _materialize_incoming_attachments(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
matrix_user_id: str,
|
||||||
|
incoming: IncomingMessage,
|
||||||
|
) -> IncomingMessage:
|
||||||
|
workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
|
||||||
|
materialized = []
|
||||||
|
for attachment in incoming.attachments:
|
||||||
|
materialized.append(
|
||||||
|
await download_matrix_attachment(
|
||||||
|
client=self.client,
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
matrix_user_id=matrix_user_id,
|
||||||
|
room_id=room_id,
|
||||||
|
attachment=attachment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return IncomingMessage(
|
||||||
|
user_id=incoming.user_id,
|
||||||
|
platform=incoming.platform,
|
||||||
|
chat_id=incoming.chat_id,
|
||||||
|
text=incoming.text,
|
||||||
|
attachments=materialized,
|
||||||
|
reply_to=incoming.reply_to,
|
||||||
|
)
|
||||||
|
|
||||||
async def _bootstrap_unregistered_room(
|
async def _bootstrap_unregistered_room(
|
||||||
self,
|
self,
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
|
|
@ -251,11 +414,6 @@ class MatrixBot:
|
||||||
f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n"
|
f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n"
|
||||||
"Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
|
"Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
|
||||||
)
|
)
|
||||||
await self.client.room_send(
|
|
||||||
created["chat_room_id"],
|
|
||||||
"m.room.message",
|
|
||||||
{"msgtype": "m.text", "body": welcome},
|
|
||||||
)
|
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
self.runtime.store,
|
self.runtime.store,
|
||||||
room.room_id,
|
room.room_id,
|
||||||
|
|
@ -265,12 +423,18 @@ class MatrixBot:
|
||||||
"redirect_chat_id": created["chat_id"],
|
"redirect_chat_id": created["chat_id"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await self.client.room_send(
|
||||||
|
created["chat_room_id"],
|
||||||
|
"m.room.message",
|
||||||
|
{"msgtype": "m.text", "body": welcome},
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
OutgoingMessage(
|
OutgoingMessage(
|
||||||
chat_id=room.room_id,
|
chat_id=room.room_id,
|
||||||
text=(
|
text=(
|
||||||
f"Создал рабочий чат {created['room_name']} ({created['chat_id']}) "
|
f"Создал рабочий чат {created['room_name']} ({created['chat_id']}) "
|
||||||
"и добавил его в пространство Lambda. Открой приглашённую комнату для продолжения."
|
"и добавил его в пространство Lambda. "
|
||||||
|
"Открой приглашённую комнату для продолжения."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
@ -323,7 +487,9 @@ class MatrixBot:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("load_agent_call_failed", error=str(exc))
|
logger.warning("load_agent_call_failed", error=str(exc))
|
||||||
return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")]
|
return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")]
|
||||||
return [OutgoingMessage(chat_id=room_id, text=f"Запрос на загрузку отправлен агенту: {name}")]
|
return [
|
||||||
|
OutgoingMessage(chat_id=room_id, text=f"Запрос на загрузку отправлен агенту: {name}")
|
||||||
|
]
|
||||||
|
|
||||||
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
|
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
|
||||||
if getattr(event, "sender", None) == self.client.user_id:
|
if getattr(event, "sender", None) == self.client.user_id:
|
||||||
|
|
@ -351,6 +517,7 @@ async def prepare_live_sync(client: AsyncClient) -> str | None:
|
||||||
return response.next_batch
|
return response.next_batch
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def send_outgoing(
|
async def send_outgoing(
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|
@ -365,7 +532,37 @@ async def send_outgoing(
|
||||||
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
|
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
|
||||||
return
|
return
|
||||||
if isinstance(event, OutgoingMessage):
|
if isinstance(event, OutgoingMessage):
|
||||||
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
|
if event.text:
|
||||||
|
await client.room_send(
|
||||||
|
room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}
|
||||||
|
)
|
||||||
|
if event.attachments:
|
||||||
|
workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
|
||||||
|
for attachment in event.attachments:
|
||||||
|
if not attachment.workspace_path:
|
||||||
|
continue
|
||||||
|
file_path = resolve_workspace_attachment_path(
|
||||||
|
workspace_root, attachment.workspace_path
|
||||||
|
)
|
||||||
|
with file_path.open("rb") as handle:
|
||||||
|
upload_response, _ = await client.upload(
|
||||||
|
handle,
|
||||||
|
content_type=attachment.mime_type or "application/octet-stream",
|
||||||
|
filename=attachment.filename or file_path.name,
|
||||||
|
filesize=file_path.stat().st_size,
|
||||||
|
)
|
||||||
|
content_uri = getattr(upload_response, "content_uri", None)
|
||||||
|
if not content_uri:
|
||||||
|
raise RuntimeError(f"Matrix upload failed for {file_path}")
|
||||||
|
await client.room_send(
|
||||||
|
room_id,
|
||||||
|
"m.room.message",
|
||||||
|
{
|
||||||
|
"msgtype": matrix_msgtype_for_attachment(attachment),
|
||||||
|
"body": attachment.filename or file_path.name,
|
||||||
|
"url": content_uri,
|
||||||
|
},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, OutgoingUI):
|
if isinstance(event, OutgoingUI):
|
||||||
lines = [event.text]
|
lines = [event.text]
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,29 @@ import importlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from nio.api import RoomVisibility
|
from nio.api import RoomVisibility
|
||||||
from nio.responses import SyncResponse
|
from nio.responses import SyncResponse
|
||||||
|
|
||||||
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
|
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
|
||||||
from adapter.matrix.handlers.auth import handle_invite
|
from adapter.matrix.handlers.auth import handle_invite
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
|
add_staged_attachment,
|
||||||
get_platform_chat_id,
|
get_platform_chat_id,
|
||||||
get_room_meta,
|
get_room_meta,
|
||||||
|
get_staged_attachments,
|
||||||
get_user_meta,
|
get_user_meta,
|
||||||
set_load_pending,
|
set_load_pending,
|
||||||
set_room_meta,
|
set_room_meta,
|
||||||
set_user_meta,
|
set_user_meta,
|
||||||
)
|
)
|
||||||
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
|
from core.protocol import (
|
||||||
|
Attachment,
|
||||||
|
IncomingCallback,
|
||||||
|
IncomingCommand,
|
||||||
|
IncomingMessage,
|
||||||
|
OutgoingMessage,
|
||||||
|
)
|
||||||
from sdk.interface import PlatformError
|
from sdk.interface import PlatformError
|
||||||
from sdk.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
from sdk.real import RealPlatformClient
|
from sdk.real import RealPlatformClient
|
||||||
|
|
@ -27,7 +36,9 @@ async def test_matrix_dispatcher_registers_custom_handlers():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
current_chat_id = "C9"
|
current_chat_id = "C9"
|
||||||
|
|
||||||
start = IncomingCommand(user_id="u1", platform="matrix", chat_id=current_chat_id, command="start")
|
start = IncomingCommand(
|
||||||
|
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||||||
|
)
|
||||||
await runtime.dispatcher.dispatch(start)
|
await runtime.dispatcher.dispatch(start)
|
||||||
|
|
||||||
new = IncomingCommand(
|
new = IncomingCommand(
|
||||||
|
|
@ -93,7 +104,9 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
|
||||||
)
|
)
|
||||||
client.room_put_state.assert_awaited_once()
|
client.room_put_state.assert_awaited_once()
|
||||||
put_call = client.room_put_state.call_args
|
put_call = client.room_put_state.call_args
|
||||||
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
|
assert (
|
||||||
|
put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
|
||||||
|
)
|
||||||
chats = await runtime.chat_mgr.list_active("u1")
|
chats = await runtime.chat_mgr.list_active("u1")
|
||||||
assert [c.chat_id for c in chats] == ["C7"]
|
assert [c.chat_id for c in chats] == ["C7"]
|
||||||
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
||||||
|
|
@ -139,7 +152,10 @@ async def test_invite_event_creates_space_and_chat_room():
|
||||||
|
|
||||||
client.room_put_state.assert_awaited_once()
|
client.room_put_state.assert_awaited_once()
|
||||||
put_state_call = client.room_put_state.call_args
|
put_state_call = client.room_put_state.call_args
|
||||||
assert put_state_call.kwargs.get("event_type") == "m.space.child" or put_state_call.args[1] == "m.space.child"
|
assert (
|
||||||
|
put_state_call.kwargs.get("event_type") == "m.space.child"
|
||||||
|
or put_state_call.args[1] == "m.space.child"
|
||||||
|
)
|
||||||
|
|
||||||
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
|
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
|
||||||
assert user_meta is not None
|
assert user_meta is not None
|
||||||
|
|
@ -249,7 +265,10 @@ async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
|
||||||
|
|
||||||
await bot.on_room_message(room, event)
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "matrix:!chat1:example.org"
|
assert (
|
||||||
|
await get_platform_chat_id(runtime.store, "!chat1:example.org")
|
||||||
|
== "matrix:!chat1:example.org"
|
||||||
|
)
|
||||||
runtime.dispatcher.dispatch.assert_awaited_once()
|
runtime.dispatcher.dispatch.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -278,6 +297,236 @@ async def test_bot_routes_plain_messages_via_platform_chat_id():
|
||||||
assert dispatched.text == "hello"
|
assert dispatched.text == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await set_room_meta(
|
||||||
|
runtime.store,
|
||||||
|
"!chat1:example.org",
|
||||||
|
{
|
||||||
|
"chat_id": "C1",
|
||||||
|
"matrix_user_id": "@alice:example.org",
|
||||||
|
"platform_chat_id": "matrix:ctx-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(
|
||||||
|
user_id="@bot:example.org",
|
||||||
|
download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
|
||||||
|
)
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
bot._send_all = AsyncMock()
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!chat1:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org",
|
||||||
|
body="report.pdf",
|
||||||
|
msgtype="m.file",
|
||||||
|
replyto_event_id=None,
|
||||||
|
url="mxc://server/id",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_only_event_is_staged_and_does_not_dispatch():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
bot._materialize_incoming_attachments = AsyncMock(
|
||||||
|
return_value=IncomingMessage(
|
||||||
|
user_id="@alice:example.org",
|
||||||
|
platform="matrix",
|
||||||
|
chat_id="!r:example.org",
|
||||||
|
text="",
|
||||||
|
attachments=[
|
||||||
|
Attachment(
|
||||||
|
type="document",
|
||||||
|
filename="report.pdf",
|
||||||
|
workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
room = SimpleNamespace(room_id="!r:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org",
|
||||||
|
body="report.pdf",
|
||||||
|
msgtype="m.file",
|
||||||
|
url="mxc://hs/id",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
replyto_event_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_command_returns_current_staged_attachments():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "a.pdf", "workspace_path": "a.pdf"},
|
||||||
|
)
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "b.pdf", "workspace_path": "b.pdf"},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!r:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||||
|
body = client.room_send.await_args.args[2]["body"]
|
||||||
|
assert "1. a.pdf" in body
|
||||||
|
assert "2. b.pdf" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_invalid_index_returns_short_error():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "a.pdf", "workspace_path": "a.pdf"},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!r:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||||
|
assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_attachment_updates_list_and_state():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "a.pdf", "workspace_path": "a.pdf"},
|
||||||
|
)
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "b.pdf", "workspace_path": "b.pdf"},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!r:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org", body="!remove 1", msgtype="m.text", replyto_event_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
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] == ["b.pdf"]
|
||||||
|
body = client.room_send.await_args.args[2]["body"]
|
||||||
|
assert "1. b.pdf" in body
|
||||||
|
assert "a.pdf" not in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remove_all_clears_state():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "a.pdf", "workspace_path": "a.pdf"},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!r:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org",
|
||||||
|
body="!remove all",
|
||||||
|
msgtype="m.text",
|
||||||
|
replyto_event_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||||
|
assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
|
||||||
|
assert client.room_send.await_args.args[2]["body"] == "Все вложения удалены."
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staged_attachment_commands_are_scoped_by_room_and_user():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r-one:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "alice-room-one.pdf", "workspace_path": "alice-room-one.pdf"},
|
||||||
|
)
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r-two:example.org",
|
||||||
|
"@alice:example.org",
|
||||||
|
{"filename": "alice-room-two.pdf", "workspace_path": "alice-room-two.pdf"},
|
||||||
|
)
|
||||||
|
await add_staged_attachment(
|
||||||
|
runtime.store,
|
||||||
|
"!r-one:example.org",
|
||||||
|
"@bob:example.org",
|
||||||
|
{"filename": "bob-room-one.pdf", "workspace_path": "bob-room-one.pdf"},
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||||||
|
room = SimpleNamespace(room_id="!r-one:example.org")
|
||||||
|
event = SimpleNamespace(
|
||||||
|
sender="@alice:example.org",
|
||||||
|
body="!list",
|
||||||
|
msgtype="m.text",
|
||||||
|
replyto_event_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
|
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||||
|
body = client.room_send.await_args.args[2]["body"]
|
||||||
|
assert "alice-room-one.pdf" in body
|
||||||
|
assert "alice-room-two.pdf" not in body
|
||||||
|
assert "bob-room-one.pdf" not in body
|
||||||
|
|
||||||
|
|
||||||
async def test_bot_keeps_commands_on_local_chat_id():
|
async def test_bot_keeps_commands_on_local_chat_id():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
await set_room_meta(
|
await set_room_meta(
|
||||||
|
|
@ -350,7 +599,10 @@ async def test_bot_assigns_platform_chat_id_before_load_selection():
|
||||||
|
|
||||||
await bot.on_room_message(room, event)
|
await bot.on_room_message(room, event)
|
||||||
|
|
||||||
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "matrix:!chat1:example.org"
|
assert (
|
||||||
|
await get_platform_chat_id(runtime.store, "!chat1:example.org")
|
||||||
|
== "matrix:!chat1:example.org"
|
||||||
|
)
|
||||||
client.room_send.assert_awaited_once_with(
|
client.room_send.assert_awaited_once_with(
|
||||||
"!chat1:example.org",
|
"!chat1:example.org",
|
||||||
"m.room.message",
|
"m.room.message",
|
||||||
|
|
@ -415,7 +667,9 @@ async def test_unregistered_room_second_message_reuses_existing_bootstrap():
|
||||||
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
|
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
|
||||||
|
|
||||||
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
|
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
|
||||||
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello again"))
|
await bot.on_room_message(
|
||||||
|
room, SimpleNamespace(sender="@alice:example.org", body="hello again")
|
||||||
|
)
|
||||||
|
|
||||||
assert client.room_create.await_count == 2
|
assert client.room_create.await_count == 2
|
||||||
room_send_calls = client.room_send.await_args_list
|
room_send_calls = client.room_send.await_args_list
|
||||||
|
|
@ -430,6 +684,43 @@ async def test_unregistered_room_second_message_reuses_existing_bootstrap():
|
||||||
assert "platform_chat_id" not in entry_meta
|
assert "platform_chat_id" not in entry_meta
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unregistered_room_welcome_send_failure_does_not_repeat_bootstrap():
|
||||||
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
|
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
|
||||||
|
space_resp = SimpleNamespace(room_id="!space:example.org")
|
||||||
|
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
|
||||||
|
client = SimpleNamespace(
|
||||||
|
user_id="@bot:example.org",
|
||||||
|
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
|
||||||
|
room_put_state=AsyncMock(),
|
||||||
|
room_send=AsyncMock(side_effect=[RuntimeError("welcome failed"), None]),
|
||||||
|
)
|
||||||
|
bot = MatrixBot(client, runtime)
|
||||||
|
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="welcome failed"):
|
||||||
|
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
|
||||||
|
|
||||||
|
entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
|
||||||
|
assert entry_meta == {
|
||||||
|
"matrix_user_id": "@alice:example.org",
|
||||||
|
"redirect_room_id": "!chat1:example.org",
|
||||||
|
"redirect_chat_id": "C1",
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.on_room_message(
|
||||||
|
room, SimpleNamespace(sender="@alice:example.org", body="hello again")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.room_create.await_count == 2
|
||||||
|
room_send_calls = client.room_send.await_args_list
|
||||||
|
assert any(
|
||||||
|
call.args[0] == "!entry:example.org"
|
||||||
|
and "Рабочий чат уже создан: C1" in call.args[2]["body"]
|
||||||
|
for call in room_send_calls
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_unregistered_room_creates_new_chat_in_existing_space():
|
async def test_unregistered_room_creates_new_chat_in_existing_space():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
await set_user_meta(
|
await set_user_meta(
|
||||||
|
|
@ -466,7 +757,9 @@ async def test_mat11_settings_returns_mvp_unavailable_message():
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
runtime = build_runtime(platform=MockPlatformClient())
|
||||||
current_chat_id = "C9"
|
current_chat_id = "C9"
|
||||||
|
|
||||||
start = IncomingCommand(user_id="u1", platform="matrix", chat_id=current_chat_id, command="start")
|
start = IncomingCommand(
|
||||||
|
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||||||
|
)
|
||||||
await runtime.dispatcher.dispatch(start)
|
await runtime.dispatcher.dispatch(start)
|
||||||
|
|
||||||
settings_cmd = IncomingCommand(
|
settings_cmd = IncomingCommand(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue