1009 lines
36 KiB
Python
1009 lines
36 KiB
Python
from __future__ import annotations
|
||
|
||
import importlib
|
||
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
|
||
|
||
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
|
||
from adapter.matrix.handlers.auth import handle_invite
|
||
from adapter.matrix.store import (
|
||
add_staged_attachment,
|
||
get_platform_chat_id,
|
||
get_room_meta,
|
||
get_staged_attachments,
|
||
get_user_meta,
|
||
set_load_pending,
|
||
set_room_meta,
|
||
set_user_meta,
|
||
)
|
||
from core.protocol import (
|
||
Attachment,
|
||
IncomingCallback,
|
||
IncomingCommand,
|
||
IncomingMessage,
|
||
OutgoingMessage,
|
||
)
|
||
from sdk.interface import PlatformError
|
||
from sdk.mock import MockPlatformClient
|
||
from sdk.real import RealPlatformClient
|
||
|
||
|
||
async def test_matrix_dispatcher_registers_custom_handlers():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
current_chat_id = "C9"
|
||
|
||
start = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||
)
|
||
await runtime.dispatcher.dispatch(start)
|
||
|
||
new = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"]
|
||
)
|
||
result = await runtime.dispatcher.dispatch(new)
|
||
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
|
||
|
||
chats = await runtime.chat_mgr.list_active("u1")
|
||
assert [c.chat_id for c in chats] == ["C1"]
|
||
assert [c.surface_ref for c in chats] == [current_chat_id]
|
||
|
||
new2 = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"]
|
||
)
|
||
await runtime.dispatcher.dispatch(new2)
|
||
chats = await runtime.chat_mgr.list_active("u1")
|
||
assert [c.chat_id for c in chats] == ["C1", "C2"]
|
||
|
||
skills = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills"
|
||
)
|
||
result = await runtime.dispatcher.dispatch(skills)
|
||
assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
|
||
|
||
toggle = IncomingCallback(
|
||
user_id="u1",
|
||
platform="matrix",
|
||
chat_id="C1",
|
||
action="toggle_skill",
|
||
payload={"skill_index": 2},
|
||
)
|
||
result = await runtime.dispatcher.dispatch(toggle)
|
||
assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
|
||
|
||
|
||
async def test_new_chat_creates_real_matrix_room_when_client_available():
|
||
client = SimpleNamespace(
|
||
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
|
||
room_put_state=AsyncMock(),
|
||
room_invite=AsyncMock(),
|
||
)
|
||
runtime = build_runtime(platform=MockPlatformClient(), client=client)
|
||
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
|
||
|
||
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start")
|
||
await runtime.dispatcher.dispatch(start)
|
||
|
||
new = IncomingCommand(
|
||
user_id="u1",
|
||
platform="matrix",
|
||
chat_id="C3",
|
||
command="new",
|
||
args=["Research"],
|
||
)
|
||
result = await runtime.dispatcher.dispatch(new)
|
||
|
||
client.room_create.assert_awaited_once_with(
|
||
name="Research",
|
||
visibility=RoomVisibility.private,
|
||
is_direct=False,
|
||
invite=["u1"],
|
||
)
|
||
client.room_put_state.assert_awaited_once()
|
||
put_call = client.room_put_state.call_args
|
||
assert (
|
||
put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
|
||
)
|
||
chats = await runtime.chat_mgr.list_active("u1")
|
||
assert [c.chat_id for c in chats] == ["C7"]
|
||
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
||
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
|
||
|
||
|
||
async def test_invite_event_creates_space_and_chat_room():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
|
||
space_resp = SimpleNamespace(room_id="!space:example.org")
|
||
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
|
||
client = SimpleNamespace(
|
||
join=AsyncMock(),
|
||
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
|
||
room_put_state=AsyncMock(),
|
||
room_invite=AsyncMock(),
|
||
room_send=AsyncMock(),
|
||
)
|
||
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
|
||
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
|
||
|
||
await handle_invite(
|
||
client,
|
||
room,
|
||
event,
|
||
runtime.platform,
|
||
runtime.store,
|
||
runtime.auth_mgr,
|
||
runtime.chat_mgr,
|
||
)
|
||
|
||
assert client.room_create.await_count == 2
|
||
first_call = client.room_create.call_args_list[0]
|
||
assert first_call.kwargs.get("space") is True or (
|
||
len(first_call.args) > 0 and first_call.kwargs.get("space") is True
|
||
)
|
||
assert first_call.kwargs.get("visibility") is RoomVisibility.private
|
||
assert first_call.kwargs.get("invite") == ["@alice:example.org"]
|
||
second_call = client.room_create.call_args_list[1]
|
||
assert second_call.kwargs.get("visibility") is RoomVisibility.private
|
||
assert second_call.kwargs.get("invite") == ["@alice:example.org"]
|
||
client.room_invite.assert_not_awaited()
|
||
|
||
client.room_put_state.assert_awaited_once()
|
||
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"
|
||
)
|
||
|
||
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
|
||
assert user_meta is not None
|
||
assert user_meta.get("space_id") == "!space:example.org"
|
||
|
||
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
|
||
assert room_meta is not None
|
||
assert room_meta["chat_id"] == "C4"
|
||
assert room_meta["space_id"] == "!space:example.org"
|
||
|
||
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
|
||
assert user_meta.get("next_chat_index") == 5
|
||
client.room_send.assert_awaited_once()
|
||
|
||
|
||
async def test_invite_event_is_idempotent_per_user():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
space_resp = SimpleNamespace(room_id="!space:example.org")
|
||
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
|
||
client = SimpleNamespace(
|
||
join=AsyncMock(),
|
||
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
|
||
room_put_state=AsyncMock(),
|
||
room_invite=AsyncMock(),
|
||
room_send=AsyncMock(),
|
||
)
|
||
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
|
||
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
|
||
|
||
await handle_invite(
|
||
client,
|
||
room,
|
||
event,
|
||
runtime.platform,
|
||
runtime.store,
|
||
runtime.auth_mgr,
|
||
runtime.chat_mgr,
|
||
)
|
||
await handle_invite(
|
||
client,
|
||
room,
|
||
event,
|
||
runtime.platform,
|
||
runtime.store,
|
||
runtime.auth_mgr,
|
||
runtime.chat_mgr,
|
||
)
|
||
|
||
assert client.join.await_count == 2
|
||
assert client.room_create.await_count == 2
|
||
client.room_send.assert_awaited_once()
|
||
|
||
|
||
async def test_bot_ignores_its_own_messages():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
client = SimpleNamespace(user_id="@bot:example.org")
|
||
bot = MatrixBot(client, runtime)
|
||
bot._send_all = AsyncMock()
|
||
runtime.dispatcher.dispatch = AsyncMock()
|
||
room = SimpleNamespace(room_id="!dm:example.org")
|
||
event = SimpleNamespace(sender="@bot:example.org", body="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
runtime.dispatcher.dispatch.assert_not_awaited()
|
||
bot._send_all.assert_not_awaited()
|
||
|
||
|
||
async def test_bot_degrades_platform_errors_to_user_reply():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
client = SimpleNamespace(
|
||
user_id="@bot:example.org",
|
||
room_send=AsyncMock(),
|
||
)
|
||
bot = MatrixBot(client, runtime)
|
||
runtime.dispatcher.dispatch = AsyncMock(
|
||
side_effect=PlatformError("Missing Authentication header", code="401")
|
||
)
|
||
room = SimpleNamespace(room_id="!dm:example.org")
|
||
event = SimpleNamespace(sender="@alice:example.org", body="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
client.room_send.assert_awaited_once_with(
|
||
"!dm:example.org",
|
||
"m.room.message",
|
||
{
|
||
"msgtype": "m.text",
|
||
"body": "Сервис временно недоступен. Попробуйте ещё раз позже.",
|
||
},
|
||
)
|
||
|
||
|
||
async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_room_meta(
|
||
runtime.store,
|
||
"!chat1:example.org",
|
||
{"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
|
||
)
|
||
client = SimpleNamespace(user_id="@bot:example.org")
|
||
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="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
assert (
|
||
await get_platform_chat_id(runtime.store, "!chat1:example.org")
|
||
== "matrix:!chat1:example.org"
|
||
)
|
||
runtime.dispatcher.dispatch.assert_awaited_once()
|
||
|
||
|
||
async def test_bot_routes_plain_messages_via_platform_chat_id():
|
||
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")
|
||
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="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
|
||
assert dispatched.chat_id == "matrix:ctx-1"
|
||
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_not_awaited()
|
||
|
||
|
||
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_not_awaited()
|
||
|
||
|
||
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_next_normal_message_commits_staged_attachments():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_room_meta(
|
||
runtime.store,
|
||
"!r:example.org",
|
||
{
|
||
"chat_id": "C1",
|
||
"matrix_user_id": "@alice:example.org",
|
||
"platform_chat_id": "matrix:ctx-1",
|
||
},
|
||
)
|
||
await add_staged_attachment(
|
||
runtime.store,
|
||
"!r:example.org",
|
||
"@alice:example.org",
|
||
{
|
||
"type": "document",
|
||
"filename": "report.pdf",
|
||
"workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
|
||
"mime_type": "application/pdf",
|
||
},
|
||
)
|
||
client = SimpleNamespace(user_id="@bot:example.org")
|
||
bot = MatrixBot(client, runtime)
|
||
bot._send_all = AsyncMock()
|
||
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
|
||
room = SimpleNamespace(room_id="!r:example.org")
|
||
event = SimpleNamespace(
|
||
sender="@alice:example.org",
|
||
body="Проанализируй",
|
||
msgtype="m.text",
|
||
replyto_event_id=None,
|
||
)
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
|
||
assert isinstance(dispatched, IncomingMessage)
|
||
assert dispatched.text == "Проанализируй"
|
||
assert [a.workspace_path for a in dispatched.attachments] == [
|
||
"surfaces/matrix/alice/r/inbox/report.pdf"
|
||
]
|
||
assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
|
||
|
||
|
||
async def test_failed_commit_preserves_staged_attachments():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_room_meta(
|
||
runtime.store,
|
||
"!r:example.org",
|
||
{
|
||
"chat_id": "C1",
|
||
"matrix_user_id": "@alice:example.org",
|
||
"platform_chat_id": "matrix:ctx-1",
|
||
},
|
||
)
|
||
await add_staged_attachment(
|
||
runtime.store,
|
||
"!r:example.org",
|
||
"@alice:example.org",
|
||
{
|
||
"type": "document",
|
||
"filename": "report.pdf",
|
||
"workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
|
||
},
|
||
)
|
||
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
|
||
bot = MatrixBot(client, runtime)
|
||
runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
|
||
room = SimpleNamespace(room_id="!r:example.org")
|
||
event = SimpleNamespace(
|
||
sender="@alice:example.org",
|
||
body="Проанализируй",
|
||
msgtype="m.text",
|
||
replyto_event_id=None,
|
||
)
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
|
||
assert [item["filename"] for item in staged] == ["report.pdf"]
|
||
|
||
|
||
async def test_bot_keeps_commands_on_local_chat_id():
|
||
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")
|
||
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="!rename New")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
|
||
assert dispatched.chat_id == "C1"
|
||
assert dispatched.command == "rename"
|
||
|
||
|
||
async def test_bot_leaves_existing_platform_chat_id_unchanged():
|
||
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:existing",
|
||
},
|
||
)
|
||
client = SimpleNamespace(user_id="@bot:example.org")
|
||
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="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "matrix:existing"
|
||
runtime.dispatcher.dispatch.assert_awaited_once()
|
||
|
||
|
||
async def test_bot_assigns_platform_chat_id_before_load_selection():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_room_meta(
|
||
runtime.store,
|
||
"!chat1:example.org",
|
||
{"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
|
||
)
|
||
client = SimpleNamespace(
|
||
user_id="@bot:example.org",
|
||
room_send=AsyncMock(),
|
||
)
|
||
bot = MatrixBot(client, runtime)
|
||
await set_load_pending(
|
||
runtime.store,
|
||
"@alice:example.org",
|
||
"!chat1:example.org",
|
||
{"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
|
||
)
|
||
room = SimpleNamespace(room_id="!chat1:example.org")
|
||
event = SimpleNamespace(sender="@alice:example.org", body="0")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
assert (
|
||
await get_platform_chat_id(runtime.store, "!chat1:example.org")
|
||
== "matrix:!chat1:example.org"
|
||
)
|
||
client.room_send.assert_awaited_once_with(
|
||
"!chat1:example.org",
|
||
"m.room.message",
|
||
{"msgtype": "m.text", "body": "Отменено."},
|
||
)
|
||
|
||
|
||
async def test_unregistered_room_bootstraps_space_and_chat_on_first_message():
|
||
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(),
|
||
)
|
||
bot = MatrixBot(client, runtime)
|
||
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
|
||
event = SimpleNamespace(sender="@alice:example.org", body="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
assert client.room_create.await_count == 2
|
||
first_call = client.room_create.call_args_list[0]
|
||
second_call = client.room_create.call_args_list[1]
|
||
assert first_call.kwargs.get("space") is True
|
||
assert first_call.kwargs.get("invite") == ["@alice:example.org"]
|
||
assert second_call.kwargs.get("name") == "Чат 1"
|
||
assert second_call.kwargs.get("invite") == ["@alice:example.org"]
|
||
client.room_put_state.assert_awaited_once()
|
||
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
|
||
assert room_meta is not None
|
||
assert room_meta["chat_id"] == "C1"
|
||
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
|
||
assert user_meta is not None
|
||
assert user_meta["space_id"] == "!space:example.org"
|
||
room_send_calls = client.room_send.await_args_list
|
||
assert any(call.args[0] == "!chat1:example.org" for call in room_send_calls)
|
||
assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
|
||
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",
|
||
}
|
||
|
||
|
||
async def test_unregistered_room_second_message_reuses_existing_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(),
|
||
)
|
||
bot = MatrixBot(client, runtime)
|
||
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 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" for call in room_send_calls)
|
||
assert any(
|
||
call.args[0] == "!entry:example.org"
|
||
and "Рабочий чат уже создан: C1" in call.args[2]["body"]
|
||
for call in room_send_calls
|
||
)
|
||
entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
|
||
assert entry_meta is not None
|
||
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():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
await set_user_meta(
|
||
runtime.store,
|
||
"@alice:example.org",
|
||
{"space_id": "!space:example.org", "next_chat_index": 4},
|
||
)
|
||
chat_resp = SimpleNamespace(room_id="!chat4:example.org")
|
||
client = SimpleNamespace(
|
||
user_id="@bot:example.org",
|
||
room_create=AsyncMock(return_value=chat_resp),
|
||
room_put_state=AsyncMock(),
|
||
room_send=AsyncMock(),
|
||
)
|
||
bot = MatrixBot(client, runtime)
|
||
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
|
||
event = SimpleNamespace(sender="@alice:example.org", body="hello")
|
||
|
||
await bot.on_room_message(room, event)
|
||
|
||
client.room_create.assert_awaited_once_with(
|
||
name="Чат 4",
|
||
visibility=RoomVisibility.private,
|
||
is_direct=False,
|
||
invite=["@alice:example.org"],
|
||
)
|
||
client.room_put_state.assert_awaited_once()
|
||
room_meta = await get_room_meta(runtime.store, "!chat4:example.org")
|
||
assert room_meta is not None
|
||
assert room_meta["chat_id"] == "C4"
|
||
|
||
|
||
async def test_mat11_settings_returns_mvp_unavailable_message():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
current_chat_id = "C9"
|
||
|
||
start = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||
)
|
||
await runtime.dispatcher.dispatch(start)
|
||
|
||
settings_cmd = IncomingCommand(
|
||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings"
|
||
)
|
||
result = await runtime.dispatcher.dispatch(settings_cmd)
|
||
|
||
assert len(result) == 1
|
||
text = result[0].text
|
||
assert "недоступна" in text.lower()
|
||
assert "mvp" in text.lower()
|
||
|
||
|
||
async def test_mat12_help_returns_command_reference():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
|
||
result = await runtime.dispatcher.dispatch(
|
||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help")
|
||
)
|
||
|
||
assert len(result) == 1
|
||
text = result[0].text
|
||
assert "!new" in text
|
||
assert "!chats" in text
|
||
assert "!rename" in text
|
||
assert "!archive" in text
|
||
assert "!context" in text
|
||
assert "!save" in text
|
||
assert "!load" in text
|
||
assert "!reset" not in text
|
||
assert "!settings" not in text
|
||
assert "!skills" not in text
|
||
|
||
|
||
async def test_unknown_command_returns_helpful_message():
|
||
runtime = build_runtime(platform=MockPlatformClient())
|
||
|
||
result = await runtime.dispatcher.dispatch(
|
||
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="clear")
|
||
)
|
||
|
||
assert len(result) == 1
|
||
assert "неизвестная команда" in result[0].text.lower()
|
||
|
||
|
||
async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
|
||
client = SimpleNamespace(
|
||
sync=AsyncMock(
|
||
return_value=SyncResponse(
|
||
next_batch="s123",
|
||
rooms={},
|
||
device_key_count={},
|
||
device_list=SimpleNamespace(changed=[], left=[]),
|
||
to_device_events=[],
|
||
presence_events=[],
|
||
account_data_events=[],
|
||
)
|
||
)
|
||
)
|
||
|
||
since = await prepare_live_sync(client)
|
||
|
||
client.sync.assert_awaited_once_with(timeout=0, full_state=True)
|
||
assert since == "s123"
|
||
|
||
|
||
async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
|
||
bot_module = importlib.import_module("adapter.matrix.bot")
|
||
|
||
class FakeAgentApiWrapper:
|
||
def __init__(self, agent_id: str, url: str) -> None:
|
||
self.agent_id = agent_id
|
||
self.url = url
|
||
|
||
monkeypatch.setattr(bot_module, "AgentApiWrapper", FakeAgentApiWrapper)
|
||
monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
|
||
monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
|
||
|
||
runtime = build_runtime()
|
||
|
||
assert isinstance(runtime.platform, RealPlatformClient)
|
||
assert runtime.platform.agent_api.url == "ws://agent.example/agent_ws/"
|
||
|
||
|
||
async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):
|
||
bot_module = importlib.import_module("adapter.matrix.bot")
|
||
|
||
platform_close = AsyncMock()
|
||
agent_connect = AsyncMock()
|
||
runtime = SimpleNamespace(
|
||
platform=SimpleNamespace(
|
||
close=platform_close,
|
||
agent_api=SimpleNamespace(connect=agent_connect),
|
||
)
|
||
)
|
||
|
||
class FakeAsyncClient:
|
||
def __init__(self, *args, **kwargs):
|
||
self.access_token = None
|
||
self.callbacks = []
|
||
self.sync_forever = AsyncMock()
|
||
self.close = AsyncMock()
|
||
|
||
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()
|
||
|
||
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
|