from __future__ import annotations import importlib from types import SimpleNamespace from unittest.mock import AsyncMock 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 ( get_platform_chat_id, get_room_meta, get_user_meta, set_load_pending, set_room_meta, set_user_meta, ) from core.protocol import IncomingCallback, IncomingCommand, 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_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_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()