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
|
|
@ -4,20 +4,29 @@ import importlib
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
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 IncomingCallback, IncomingCommand, OutgoingMessage
|
||||
from core.protocol import (
|
||||
Attachment,
|
||||
IncomingCallback,
|
||||
IncomingCommand,
|
||||
IncomingMessage,
|
||||
OutgoingMessage,
|
||||
)
|
||||
from sdk.interface import PlatformError
|
||||
from sdk.mock import MockPlatformClient
|
||||
from sdk.real import RealPlatformClient
|
||||
|
|
@ -27,7 +36,9 @@ 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")
|
||||
start = IncomingCommand(
|
||||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||||
)
|
||||
await runtime.dispatcher.dispatch(start)
|
||||
|
||||
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()
|
||||
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")
|
||||
assert [c.chat_id for c in chats] == ["C7"]
|
||||
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()
|
||||
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")
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -278,6 +297,236 @@ async def test_bot_routes_plain_messages_via_platform_chat_id():
|
|||
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():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
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)
|
||||
|
||||
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(
|
||||
"!chat1:example.org",
|
||||
"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")
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
|
|
@ -466,7 +757,9 @@ 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")
|
||||
start = IncomingCommand(
|
||||
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
|
||||
)
|
||||
await runtime.dispatcher.dispatch(start)
|
||||
|
||||
settings_cmd = IncomingCommand(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue