# Matrix Adapter Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement `adapter/matrix/` — Matrix bot using matrix-nio that connects to the Lambda platform via `EventDispatcher` and `MockPlatformClient`. **Architecture:** Room-type routing — each incoming event is classified by room type (chat/settings) then dispatched. DM room = C1 (first chat). Space and Settings room created lazily on first `!new`. Core business logic lives in `EventDispatcher`; the adapter converts nio events ↔ protocol events. **Tech Stack:** matrix-nio 0.21+, Python 3.11+, `SQLiteStore` (key-value), `MockPlatformClient`, pytest-asyncio --- ## File map | File | Responsibility | |------|---------------| | `adapter/matrix/store.py` | Key-prefix helpers for room/user metadata in `StateStore` | | `adapter/matrix/converter.py` | nio event → `IncomingEvent`, `extract_attachments` | | `adapter/matrix/reactions.py` | `add_reaction`, `edit_message`, `build_skills_text` | | `adapter/matrix/handlers/auth.py` | Invite → join + register room + welcome message | | `adapter/matrix/handlers/chat.py` | Text messages, `!new`, `!chats` | | `adapter/matrix/handlers/confirm.py` | 👍/❌ reactions + `!yes`/`!no` | | `adapter/matrix/handlers/settings.py` | `!skills` (m.replace), `!soul`, `!safety`, `!plan`, `!status`, `!whoami`, `!connectors` | | `adapter/matrix/bot.py` | `AsyncClient`, sync loop, event routing | Store key conventions (all via `StateStore` KV): - `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}` - `matrix_user:{matrix_user_id}` → `{platform_user_id, display_name, space_id, settings_room_id, next_chat_index}` - `matrix_state:{room_id}` → `{state}` — one of `idle | waiting_response | confirm_pending | settings_active` - `matrix_skills_msg:{room_id}` → `{event_id}` — event_id of the last `!skills` message (for m.replace) --- ### Task 1: Store helpers **Files:** - Create: `adapter/matrix/__init__.py` - Create: `adapter/matrix/store.py` - Create: `tests/adapter/__init__.py` - Create: `tests/adapter/matrix/__init__.py` - Create: `tests/adapter/matrix/test_store.py` - [ ] **Step 1: Write failing test** ```python # tests/adapter/matrix/test_store.py import pytest from core.store import InMemoryStore from adapter.matrix.store import ( get_room_meta, set_room_meta, get_user_meta, set_user_meta, get_room_state, set_room_state, next_chat_id, ) @pytest.fixture def store(): return InMemoryStore() async def test_room_meta_roundtrip(store): meta = {"room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": "@alice:m.org"} await set_room_meta(store, "!r:m.org", meta) assert await get_room_meta(store, "!r:m.org") == meta async def test_room_meta_missing(store): assert await get_room_meta(store, "!nonexistent:m.org") is None async def test_user_meta_roundtrip(store): meta = {"platform_user_id": "usr-1", "display_name": "Alice", "space_id": None, "settings_room_id": None, "next_chat_index": 1} await set_user_meta(store, "@alice:m.org", meta) assert await get_user_meta(store, "@alice:m.org") == meta async def test_room_state_roundtrip(store): await set_room_state(store, "!r:m.org", "idle") assert await get_room_state(store, "!r:m.org") == "idle" await set_room_state(store, "!r:m.org", "waiting_response") assert await get_room_state(store, "!r:m.org") == "waiting_response" async def test_room_state_default_idle(store): assert await get_room_state(store, "!unknown:m.org") == "idle" async def test_next_chat_id_increments(store): uid = "@alice:m.org" await set_user_meta(store, uid, {"next_chat_index": 1}) assert await next_chat_id(store, uid) == "C1" assert await next_chat_id(store, uid) == "C2" assert await next_chat_id(store, uid) == "C3" ``` - [ ] **Step 2: Run — expect ImportError** ```bash cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v ``` - [ ] **Step 3: Create `__init__.py` files** ```bash touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py ``` - [ ] **Step 4: Implement store.py** ```python # adapter/matrix/store.py from __future__ import annotations from core.store import StateStore async def get_room_meta(store: StateStore, room_id: str) -> dict | None: return await store.get(f"matrix_room:{room_id}") async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: await store.set(f"matrix_room:{room_id}", meta) async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: return await store.get(f"matrix_user:{matrix_user_id}") async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: await store.set(f"matrix_user:{matrix_user_id}", meta) async def get_room_state(store: StateStore, room_id: str) -> str: data = await store.get(f"matrix_state:{room_id}") return data["state"] if data else "idle" async def set_room_state(store: StateStore, room_id: str, state: str) -> None: await store.set(f"matrix_state:{room_id}", {"state": state}) async def next_chat_id(store: StateStore, matrix_user_id: str) -> str: """Allocate next chat_id (C1, C2, ...) and increment counter in user meta.""" meta = await get_user_meta(store, matrix_user_id) or {} index = meta.get("next_chat_index", 1) meta["next_chat_index"] = index + 1 await set_user_meta(store, matrix_user_id, meta) return f"C{index}" ``` - [ ] **Step 5: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_store.py -v ``` Expected: 6 tests PASS. - [ ] **Step 6: Commit** ```bash git add adapter/__init__.py adapter/matrix/__init__.py adapter/matrix/store.py \ tests/adapter/__init__.py tests/adapter/matrix/__init__.py tests/adapter/matrix/test_store.py git commit -m "feat(matrix): room/user store helpers" ``` --- ### Task 2: Converter **Files:** - Create: `adapter/matrix/converter.py` - Create: `tests/adapter/matrix/test_converter.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_converter.py from types import SimpleNamespace from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage from adapter.matrix.converter import from_room_event def text_event(body, sender="@a:m.org", event_id="$e1"): return SimpleNamespace(sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None) def file_event(url="mxc://x/y", filename="doc.pdf", mime="application/pdf"): return SimpleNamespace(sender="@a:m.org", body=filename, event_id="$e2", msgtype="m.file", replyto_event_id=None, url=url, mimetype=mime) def image_event(url="mxc://x/img", mime="image/jpeg"): return SimpleNamespace(sender="@a:m.org", body="img.jpg", event_id="$e3", msgtype="m.image", replyto_event_id=None, url=url, mimetype=mime) def audio_event(url="mxc://x/audio", mime="audio/ogg"): return SimpleNamespace(sender="@a:m.org", body="voice.ogg", event_id="$e4", msgtype="m.audio", replyto_event_id=None, url=url, mimetype=mime) def reaction_event(key, reacted_to="$orig"): return SimpleNamespace(sender="@a:m.org", key=key, reacted_to_id=reacted_to, event_id="$r1") async def test_plain_text_to_incoming_message(): result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingMessage) assert result.text == "Hello" assert result.platform == "matrix" assert result.chat_id == "C1" assert result.attachments == [] async def test_bang_command_to_incoming_command(): result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingCommand) assert result.command == "new" assert result.args == ["Analysis"] async def test_bang_command_no_args(): result = from_room_event(text_event("!skills"), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingCommand) assert result.command == "skills" assert result.args == [] async def test_yes_to_callback(): result = from_room_event(text_event("!yes"), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingCallback) assert result.action == "confirm" async def test_no_to_callback(): result = from_room_event(text_event("!no"), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingCallback) assert result.action == "cancel" async def test_file_attachment(): result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1") assert isinstance(result, IncomingMessage) assert len(result.attachments) == 1 a = result.attachments[0] assert a.type == "document" assert a.url == "mxc://x/y" assert a.filename == "doc.pdf" assert a.mime_type == "application/pdf" async def test_image_attachment(): result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1") assert result.attachments[0].type == "image" assert result.attachments[0].mime_type == "image/jpeg" async def test_audio_attachment(): result = from_room_event(audio_event(), room_id="!r:m.org", chat_id="C1") assert result.attachments[0].type == "audio" async def test_confirm_reaction(): result = from_room_event(reaction_event("👍"), room_id="!r:m.org", chat_id="C1", is_reaction=True) assert isinstance(result, IncomingCallback) assert result.action == "confirm" async def test_cancel_reaction(): result = from_room_event(reaction_event("❌"), room_id="!r:m.org", chat_id="C1", is_reaction=True) assert isinstance(result, IncomingCallback) assert result.action == "cancel" async def test_skill_reaction_index(): result = from_room_event(reaction_event("4️⃣"), room_id="!r:m.org", chat_id="C1", is_reaction=True) assert isinstance(result, IncomingCallback) assert result.action == "toggle_skill" assert result.payload["skill_index"] == 3 # 0-based async def test_unknown_reaction_returns_none(): result = from_room_event(reaction_event("🎉"), room_id="!r:m.org", chat_id="C1", is_reaction=True) assert result is None ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_converter.py -v ``` - [ ] **Step 3: Implement converter.py** ```python # adapter/matrix/converter.py from __future__ import annotations from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingEvent, IncomingMessage SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"] CONFIRM_REACTIONS = {"👍": "confirm", "❌": "cancel"} _CALLBACK_COMMANDS = {"yes": "confirm", "no": "cancel"} def from_room_event( event, room_id: str, chat_id: str, is_reaction: bool = False, ) -> IncomingEvent | None: """Convert a nio event object to an IncomingEvent. Returns None if unrecognised.""" if is_reaction: return _from_reaction(event, chat_id) body: str = event.body if body.startswith("!"): parts = body[1:].split(maxsplit=1) cmd = parts[0].lower() args = parts[1].split() if len(parts) > 1 else [] if cmd in _CALLBACK_COMMANDS: return IncomingCallback( user_id=event.sender, platform="matrix", chat_id=chat_id, action=_CALLBACK_COMMANDS[cmd], payload={}, ) return IncomingCommand( user_id=event.sender, platform="matrix", chat_id=chat_id, command=cmd, args=args, ) return IncomingMessage( user_id=event.sender, platform="matrix", chat_id=chat_id, text=body if event.msgtype == "m.text" else "", attachments=extract_attachments(event), reply_to=getattr(event, "replyto_event_id", None), ) def extract_attachments(event) -> list[Attachment]: msgtype = getattr(event, "msgtype", "m.text") url = getattr(event, "url", None) mime = getattr(event, "mimetype", None) if msgtype == "m.image": return [Attachment(type="image", url=url, mime_type=mime)] if msgtype == "m.file": return [Attachment(type="document", url=url, filename=event.body, mime_type=mime)] if msgtype == "m.audio": return [Attachment(type="audio", url=url, mime_type=mime)] return [] def _from_reaction(event, chat_id: str) -> IncomingCallback | None: key = event.key if key in CONFIRM_REACTIONS: return IncomingCallback( user_id=event.sender, platform="matrix", chat_id=chat_id, action=CONFIRM_REACTIONS[key], payload={"reacted_to_id": event.reacted_to_id}, ) if key in SKILL_REACTIONS: return IncomingCallback( user_id=event.sender, platform="matrix", chat_id=chat_id, action="toggle_skill", payload={"skill_index": SKILL_REACTIONS.index(key), "reacted_to_id": event.reacted_to_id}, ) return None ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_converter.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py git commit -m "feat(matrix): event converter" ``` --- ### Task 3: Reactions helpers **Files:** - Create: `adapter/matrix/reactions.py` - Create: `tests/adapter/matrix/test_reactions.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_reactions.py from unittest.mock import AsyncMock from adapter.matrix.reactions import add_reaction, edit_message, build_skills_text from sdk.interface import UserSettings async def test_add_reaction(): client = AsyncMock() await add_reaction(client, "!r:m.org", "$evt", "👍") client.room_send.assert_called_once_with( "!r:m.org", "m.reaction", {"m.relates_to": {"rel_type": "m.annotation", "event_id": "$evt", "key": "👍"}}, ) async def test_edit_message(): client = AsyncMock() await edit_message(client, "!r:m.org", "$orig", "new text") client.room_send.assert_called_once_with( "!r:m.org", "m.room.message", { "msgtype": "m.text", "body": "* new text", "m.new_content": {"msgtype": "m.text", "body": "new text"}, "m.relates_to": {"rel_type": "m.replace", "event_id": "$orig"}, }, ) def test_build_skills_text_shows_status(): settings = UserSettings(skills={"web-search": True, "browser": False}) text = build_skills_text(settings) assert "✅ 1 web-search" in text assert "❌ 2 browser" in text def test_build_skills_text_has_reaction_hint(): settings = UserSettings(skills={"web-search": True, "browser": False}) text = build_skills_text(settings) assert "1️⃣" in text assert "Реакция" in text ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_reactions.py -v ``` - [ ] **Step 3: Implement reactions.py** ```python # adapter/matrix/reactions.py from __future__ import annotations from adapter.matrix.converter import SKILL_REACTIONS from sdk.interface import UserSettings _SKILL_DESCRIPTIONS: dict[str, str] = { "web-search": "поиск в интернете", "fetch-url": "чтение веб-страниц", "email": "чтение почты", "browser": "управление браузером", "image-gen": "генерация изображений", "video-gen": "генерация видео", "files": "работа с файлами", "calendar": "календарь", } async def add_reaction(client, room_id: str, event_id: str, key: str) -> None: await client.room_send( room_id, "m.reaction", {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}}, ) async def edit_message(client, room_id: str, original_event_id: str, new_body: str) -> None: await client.room_send( room_id, "m.room.message", { "msgtype": "m.text", "body": f"* {new_body}", "m.new_content": {"msgtype": "m.text", "body": new_body}, "m.relates_to": {"rel_type": "m.replace", "event_id": original_event_id}, }, ) def build_skills_text(settings: UserSettings) -> str: skill_names = list(settings.skills.keys()) lines = [] for i, name in enumerate(skill_names): enabled = settings.skills[name] emoji = "✅" if enabled else "❌" desc = _SKILL_DESCRIPTIONS.get(name, name) lines.append(f"{emoji} {i + 1} {name} — {desc}") hint = " ".join(SKILL_REACTIONS[i] for i in range(min(len(skill_names), len(SKILL_REACTIONS)))) lines += ["", f"Реакция {hint} = переключить скилл"] return "\n".join(lines) ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_reactions.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/reactions.py tests/adapter/matrix/test_reactions.py git commit -m "feat(matrix): reactions and edit helpers" ``` --- ### Task 4: Auth handler — invite → onboarding **Files:** - Create: `adapter/matrix/handlers/__init__.py` - Create: `adapter/matrix/handlers/auth.py` - Create: `tests/adapter/matrix/test_auth.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_auth.py import pytest from unittest.mock import AsyncMock from core.store import InMemoryStore from core.auth import AuthManager from sdk.mock import MockPlatformClient from adapter.matrix.handlers.auth import handle_invite from adapter.matrix.store import get_room_meta, get_room_state, get_user_meta @pytest.fixture def store(): return InMemoryStore() @pytest.fixture def platform(): return MockPlatformClient() @pytest.fixture def client(): c = AsyncMock() c.join = AsyncMock() c.room_send = AsyncMock() return c async def test_invite_joins_room(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") client.join.assert_called_once_with("!dm:m.org") async def test_invite_sends_welcome_with_name(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") body = client.room_send.call_args[0][2]["body"] assert "Alice" in body assert "!new" in body async def test_invite_registers_room_as_c1(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) meta = await get_room_meta(store, "!dm:m.org") assert meta["room_type"] == "chat" assert meta["chat_id"] == "C1" assert meta["matrix_user_id"] == "@alice:m.org" async def test_invite_creates_platform_user(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") user_meta = await get_user_meta(store, "@alice:m.org") assert user_meta is not None assert "platform_user_id" in user_meta async def test_invite_authenticates_user(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) auth_mgr = AuthManager(platform, store) assert await auth_mgr.is_authenticated("@alice:m.org") async def test_invite_room_state_idle(client, store, platform): await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) assert await get_room_state(store, "!dm:m.org") == "idle" async def test_second_invite_gets_c2(client, store, platform): await handle_invite(client, "!dm1:m.org", "@alice:m.org", store, platform) await handle_invite(client, "!dm2:m.org", "@alice:m.org", store, platform) meta = await get_room_meta(store, "!dm2:m.org") assert meta["chat_id"] == "C2" ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_auth.py -v ``` - [ ] **Step 3: Create `__init__.py` and implement auth.py** ```python # adapter/matrix/handlers/__init__.py # (empty) ``` ```python # adapter/matrix/handlers/auth.py from __future__ import annotations import structlog from adapter.matrix.store import ( get_user_meta, next_chat_id, set_room_meta, set_room_state, set_user_meta, ) from core.auth import AuthManager from sdk.interface import PlatformClient logger = structlog.get_logger(__name__) async def handle_invite( client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, display_name: str | None = None, ) -> None: """Accept invite, register DM room as first chat, authenticate user, send welcome.""" await client.join(room_id) logger.info("Joined room", room_id=room_id, user=matrix_user_id) user = await platform.get_or_create_user(matrix_user_id, "matrix", display_name) user_meta = await get_user_meta(store, matrix_user_id) if user_meta is None: user_meta = { "platform_user_id": user.user_id, "display_name": display_name, "space_id": None, "settings_room_id": None, "next_chat_index": 1, } await set_user_meta(store, matrix_user_id, user_meta) auth_mgr = AuthManager(platform, store) await auth_mgr.confirm(matrix_user_id) chat_id = await next_chat_id(store, matrix_user_id) chat_num = chat_id[1:] await set_room_meta(store, room_id, { "room_type": "chat", "chat_id": chat_id, "display_name": f"Чат {chat_num}", "matrix_user_id": matrix_user_id, }) await set_room_state(store, room_id, "idle") name = display_name or matrix_user_id.split(":")[0].lstrip("@") welcome = ( f"Привет, {name}! Пиши — я здесь.\n\n" "Команды: !new · !chats · !rename · !archive · !skills" ) await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": welcome}) ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_auth.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/handlers/__init__.py adapter/matrix/handlers/auth.py tests/adapter/matrix/test_auth.py git commit -m "feat(matrix): invite handler + onboarding" ``` --- ### Task 5: Chat handler — messages + !new + !chats **Files:** - Create: `adapter/matrix/handlers/chat.py` - Create: `tests/adapter/matrix/test_chat_handler.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_chat_handler.py import pytest from types import SimpleNamespace from unittest.mock import AsyncMock from core.store import InMemoryStore from core.auth import AuthManager from core.chat import ChatManager from core.settings import SettingsManager from core.handler import EventDispatcher from core.handlers import register_all from sdk.mock import MockPlatformClient from adapter.matrix.store import get_room_meta, set_room_meta, set_room_state, set_user_meta from adapter.matrix.handlers.chat import handle_message, handle_new_chat, handle_list_chats @pytest.fixture def store(): return InMemoryStore() @pytest.fixture def platform(): return MockPlatformClient() @pytest.fixture def dispatcher(platform, store): d = EventDispatcher( platform=platform, chat_mgr=ChatManager(platform, store), auth_mgr=AuthManager(platform, store), settings_mgr=SettingsManager(platform, store), ) register_all(d) return d @pytest.fixture def client(): c = AsyncMock() c.room_send = AsyncMock() c.room_typing = AsyncMock() c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org")) c.room_invite = AsyncMock() c.room_put_state = AsyncMock() return c async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): user = await platform.get_or_create_user(uid, "matrix", "Alice") await set_user_meta(store, uid, { "platform_user_id": user.user_id, "display_name": "Alice", "space_id": None, "settings_room_id": None, "next_chat_index": 2, }) await set_room_meta(store, room_id, { "room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": uid, }) await set_room_state(store, room_id, "idle") auth = AuthManager(platform, store) await auth.confirm(uid) def _text_event(body, sender="@alice:m.org"): return SimpleNamespace(sender=sender, body=body, event_id="$e1", msgtype="m.text", replyto_event_id=None) async def test_message_gets_response(client, store, platform, dispatcher): await _setup(store, platform) await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher) texts = [str(c) for c in client.room_send.call_args_list] assert any("[MOCK]" in t for t in texts) async def test_message_sends_typing(client, store, platform, dispatcher): await _setup(store, platform) await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher) client.room_typing.assert_called() async def test_new_creates_matrix_room(client, store, platform, dispatcher): await _setup(store, platform) await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher) client.room_create.assert_called() client.room_invite.assert_called() async def test_new_registers_room_meta(client, store, platform, dispatcher): await _setup(store, platform) await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher) meta = await get_room_meta(store, "!new:m.org") assert meta is not None assert meta["room_type"] == "chat" assert meta["display_name"] == "Analysis" async def test_list_chats_includes_room_name(client, store, platform, dispatcher): await _setup(store, platform) await handle_list_chats(client, "!dm:m.org", "@alice:m.org", store) body = client.room_send.call_args[0][2]["body"] assert "Чат 1" in body ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_chat_handler.py -v ``` - [ ] **Step 3: Implement handlers/chat.py** ```python # adapter/matrix/handlers/chat.py from __future__ import annotations import asyncio import structlog from adapter.matrix.converter import from_room_event from adapter.matrix.store import ( get_room_meta, get_user_meta, next_chat_id, set_room_meta, set_room_state, set_user_meta, ) from core.protocol import OutgoingMessage, OutgoingTyping from sdk.interface import PlatformClient logger = structlog.get_logger(__name__) _TYPING_INTERVAL = 25 # nio typing expires ~30s async def handle_message(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None: room_meta = await get_room_meta(store, room_id) if room_meta is None: return incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"]) if incoming is None: return await set_room_state(store, room_id, "waiting_response") await client.room_typing(room_id, True, timeout=_TYPING_INTERVAL * 1000) typing_task = asyncio.create_task(_keep_typing(client, room_id, _TYPING_INTERVAL)) try: outgoing_events = await dispatcher.dispatch(incoming) finally: typing_task.cancel() await client.room_typing(room_id, False, timeout=0) await set_room_state(store, room_id, "idle") for out in outgoing_events: await _send(client, room_id, out) async def handle_new_chat(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None: room_meta = await get_room_meta(store, room_id) if room_meta is None: return matrix_user_id = room_meta["matrix_user_id"] parts = event.body[1:].split(maxsplit=1) # "!new Analysis" → ["new", "Analysis"] display_name_arg = parts[1] if len(parts) > 1 else None chat_id = await next_chat_id(store, matrix_user_id) chat_num = chat_id[1:] display_name = display_name_arg or f"Чат {chat_num}" response = await client.room_create(name=display_name) new_room_id = response.room_id await client.room_invite(new_room_id, matrix_user_id) user_meta = await get_user_meta(store, matrix_user_id) or {} space_id = user_meta.get("space_id") if space_id is None: space_id = await _create_space(client, store, matrix_user_id, user_meta) await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=new_room_id) await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=room_id) await set_room_meta(store, new_room_id, { "room_type": "chat", "chat_id": chat_id, "display_name": display_name, "matrix_user_id": matrix_user_id, }) await set_room_state(store, new_room_id, "idle") await client.room_send( room_id, "m.room.message", {"msgtype": "m.text", "body": f"✅ [{display_name}] создан. Перейди в комнату."}, ) async def handle_list_chats(client, room_id: str, matrix_user_id: str, store) -> None: all_keys = await store.keys("matrix_room:") chats = [] for key in all_keys: meta = await store.get(key) if (meta and meta.get("matrix_user_id") == matrix_user_id and meta.get("room_type") == "chat"): chats.append(meta) if not chats: body = "Нет активных чатов. Напиши !new чтобы создать." else: lines = ["Твои чаты:"] for chat in chats: lines.append(f" {chat['display_name']} ({chat['chat_id']})") body = "\n".join(lines) await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) async def _create_space(client, store, matrix_user_id: str, user_meta: dict) -> str: name = user_meta.get("display_name") or matrix_user_id.split(":")[0].lstrip("@") space_resp = await client.room_create( name=f"Lambda — {name}", initial_state=[{"type": "m.room.create", "content": {"type": "m.space"}}], ) space_id = space_resp.room_id await client.room_invite(space_id, matrix_user_id) settings_resp = await client.room_create(name="⚙️ Настройки") settings_room_id = settings_resp.room_id await client.room_invite(settings_room_id, matrix_user_id) await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=settings_room_id) await set_room_meta(store, settings_room_id, { "room_type": "settings", "chat_id": None, "display_name": "Настройки", "matrix_user_id": matrix_user_id, }) await set_room_state(store, settings_room_id, "settings_active") user_meta["space_id"] = space_id user_meta["settings_room_id"] = settings_room_id await set_user_meta(store, matrix_user_id, user_meta) return space_id async def _keep_typing(client, room_id: str, interval: int) -> None: try: while True: await asyncio.sleep(interval) await client.room_typing(room_id, True, timeout=interval * 1000) except asyncio.CancelledError: pass async def _send(client, room_id: str, event) -> None: if isinstance(event, OutgoingMessage): await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}) elif isinstance(event, OutgoingTyping): await client.room_typing(room_id, event.is_typing, timeout=0) ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_chat_handler.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/handlers/chat.py tests/adapter/matrix/test_chat_handler.py git commit -m "feat(matrix): chat handler — messages, !new, !chats" ``` --- ### Task 6: Confirm handler — 👍/❌ + !yes/!no **Files:** - Create: `adapter/matrix/handlers/confirm.py` - Create: `tests/adapter/matrix/test_confirm.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_confirm.py import pytest from types import SimpleNamespace from unittest.mock import AsyncMock from core.store import InMemoryStore from core.auth import AuthManager from core.chat import ChatManager from core.settings import SettingsManager from core.handler import EventDispatcher from core.handlers import register_all from sdk.mock import MockPlatformClient from adapter.matrix.store import get_room_state, set_room_meta, set_room_state from adapter.matrix.handlers.confirm import handle_confirm_callback @pytest.fixture def store(): return InMemoryStore() @pytest.fixture def platform(): return MockPlatformClient() @pytest.fixture def dispatcher(platform, store): d = EventDispatcher( platform=platform, chat_mgr=ChatManager(platform, store), auth_mgr=AuthManager(platform, store), settings_mgr=SettingsManager(platform, store), ) register_all(d) return d @pytest.fixture def client(): return AsyncMock() async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): await platform.get_or_create_user(uid, "matrix", "Alice") await set_room_meta(store, room_id, { "room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": uid, }) await set_room_state(store, room_id, "confirm_pending") await AuthManager(platform, store).confirm(uid) async def test_yes_command_transitions_to_idle(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", msgtype="m.text", replyto_event_id=None) await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) assert await get_room_state(store, "!dm:m.org") == "idle" async def test_no_command_transitions_to_idle(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="!no", event_id="$e1", msgtype="m.text", replyto_event_id=None) await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) assert await get_room_state(store, "!dm:m.org") == "idle" async def test_thumbs_up_reaction_transitions_to_idle(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", key="👍", reacted_to_id="$orig", event_id="$r1") await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=True) assert await get_room_state(store, "!dm:m.org") == "idle" async def test_confirm_sends_response(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", msgtype="m.text", replyto_event_id=None) await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) client.room_send.assert_called() async def test_noop_when_state_not_confirm_pending(client, store, platform, dispatcher): await _setup(store, platform) await set_room_state(store, "!dm:m.org", "idle") # wrong state event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", msgtype="m.text", replyto_event_id=None) await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) client.room_send.assert_not_called() ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_confirm.py -v ``` - [ ] **Step 3: Implement handlers/confirm.py** ```python # adapter/matrix/handlers/confirm.py from __future__ import annotations import structlog from adapter.matrix.converter import from_room_event from adapter.matrix.store import get_room_meta, get_room_state, set_room_state from core.protocol import OutgoingMessage from sdk.interface import PlatformClient logger = structlog.get_logger(__name__) async def handle_confirm_callback( client, room_id: str, event, store, platform: PlatformClient, dispatcher, is_reaction: bool = False, ) -> None: if await get_room_state(store, room_id) != "confirm_pending": return room_meta = await get_room_meta(store, room_id) if room_meta is None: return incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"], is_reaction=is_reaction) if incoming is None or getattr(incoming, "action", None) not in ("confirm", "cancel"): return await set_room_state(store, room_id, "idle") outgoing_events = await dispatcher.dispatch(incoming) for out in outgoing_events: if isinstance(out, OutgoingMessage): await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": out.text}) ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_confirm.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/handlers/confirm.py tests/adapter/matrix/test_confirm.py git commit -m "feat(matrix): confirm handler — reactions and !yes/!no" ``` --- ### Task 7: Settings handler — !skills (m.replace) + other commands **Files:** - Create: `adapter/matrix/handlers/settings.py` - Create: `tests/adapter/matrix/test_settings_handler.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_settings_handler.py import pytest from unittest.mock import AsyncMock from core.store import InMemoryStore from core.auth import AuthManager from core.chat import ChatManager from core.settings import SettingsManager from core.handler import EventDispatcher from core.handlers import register_all from sdk.mock import MockPlatformClient from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta from adapter.matrix.handlers.settings import handle_skills, handle_skill_toggle, handle_text_setting @pytest.fixture def store(): return InMemoryStore() @pytest.fixture def platform(): return MockPlatformClient() @pytest.fixture def dispatcher(platform, store): d = EventDispatcher( platform=platform, chat_mgr=ChatManager(platform, store), auth_mgr=AuthManager(platform, store), settings_mgr=SettingsManager(platform, store), ) register_all(d) return d @pytest.fixture def client(): c = AsyncMock() c.room_send = AsyncMock(return_value=AsyncMock(event_id="$skills_msg")) return c async def _setup(store, platform, uid="@alice:m.org", room_id="!s:m.org"): user = await platform.get_or_create_user(uid, "matrix", "Alice") await set_user_meta(store, uid, { "platform_user_id": user.user_id, "display_name": "Alice", "space_id": None, "settings_room_id": room_id, "next_chat_index": 2, }) await set_room_meta(store, room_id, { "room_type": "settings", "chat_id": None, "display_name": "Настройки", "matrix_user_id": uid, }) await set_room_state(store, room_id, "settings_active") await AuthManager(platform, store).confirm(uid) async def test_skills_sends_list(client, store, platform, dispatcher): await _setup(store, platform) await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher) body = client.room_send.call_args[0][2]["body"] assert "web-search" in body assert "Реакция" in body async def test_skills_stores_event_id(client, store, platform, dispatcher): await _setup(store, platform) await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher) stored = await store.get("matrix_skills_msg:!s:m.org") assert stored is not None assert stored["event_id"] == "$skills_msg" async def test_skill_toggle_edits_message(client, store, platform, dispatcher): await _setup(store, platform) await store.set("matrix_skills_msg:!s:m.org", {"event_id": "$skills_msg"}) from types import SimpleNamespace reaction = SimpleNamespace(sender="@alice:m.org", key="1️⃣", reacted_to_id="$skills_msg", event_id="$r1") await handle_skill_toggle(client, "!s:m.org", reaction, store, platform, dispatcher) content = client.room_send.call_args[0][2] assert content.get("m.relates_to", {}).get("rel_type") == "m.replace" async def test_whoami_contains_user_id(client, store, platform, dispatcher): await _setup(store, platform) await handle_text_setting(client, "!s:m.org", "@alice:m.org", "whoami", [], store, platform) body = client.room_send.call_args[0][2]["body"] assert "@alice:m.org" in body async def test_status_response(client, store, platform, dispatcher): await _setup(store, platform) await handle_text_setting(client, "!s:m.org", "@alice:m.org", "status", [], store, platform) body = client.room_send.call_args[0][2]["body"] assert "Статус" in body async def test_plan_shows_tokens(client, store, platform, dispatcher): await _setup(store, platform) await handle_text_setting(client, "!s:m.org", "@alice:m.org", "plan", [], store, platform) body = client.room_send.call_args[0][2]["body"] assert "Beta" in body assert "/" in body # "0 / 1000" ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_settings_handler.py -v ``` - [ ] **Step 3: Implement handlers/settings.py** ```python # adapter/matrix/handlers/settings.py from __future__ import annotations import structlog from adapter.matrix.converter import SKILL_REACTIONS from adapter.matrix.reactions import build_skills_text, edit_message from adapter.matrix.store import get_room_meta, get_user_meta from core.protocol import SettingsAction from sdk.interface import PlatformClient logger = structlog.get_logger(__name__) _SKILL_NAMES_ORDER = ["web-search", "fetch-url", "email", "browser", "image-gen", "video-gen", "files", "calendar"] async def handle_skills( client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, dispatcher, ) -> None: """Send skills list and store its event_id for later m.replace edits.""" user_meta = await get_user_meta(store, matrix_user_id) or {} platform_user_id = user_meta.get("platform_user_id", matrix_user_id) settings = await platform.get_settings(platform_user_id) body = build_skills_text(settings) response = await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) event_id = getattr(response, "event_id", None) if event_id: await store.set(f"matrix_skills_msg:{room_id}", {"event_id": event_id}) async def handle_skill_toggle( client, room_id: str, reaction_event, store, platform: PlatformClient, dispatcher, ) -> None: """Toggle a skill based on numbered reaction, then edit the skills message.""" key = reaction_event.key if key not in SKILL_REACTIONS: return skill_index = SKILL_REACTIONS.index(key) if skill_index >= len(_SKILL_NAMES_ORDER): return skill_name = _SKILL_NAMES_ORDER[skill_index] room_meta = await get_room_meta(store, room_id) if room_meta is None: return matrix_user_id = room_meta["matrix_user_id"] user_meta = await get_user_meta(store, matrix_user_id) or {} platform_user_id = user_meta.get("platform_user_id", matrix_user_id) settings = await platform.get_settings(platform_user_id) current = settings.skills.get(skill_name, False) action = SettingsAction(action="toggle_skill", payload={"skill": skill_name, "enabled": not current}) await platform.update_settings(platform_user_id, action) updated = await platform.get_settings(platform_user_id) new_body = build_skills_text(updated) msg_data = await store.get(f"matrix_skills_msg:{room_id}") if msg_data: await edit_message(client, room_id, msg_data["event_id"], new_body) else: await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": new_body}) async def handle_text_setting( client, room_id: str, matrix_user_id: str, command: str, args: list[str], store, platform: PlatformClient, ) -> None: """Handle !connectors, !soul, !safety, !plan, !status, !whoami.""" user_meta = await get_user_meta(store, matrix_user_id) or {} platform_user_id = user_meta.get("platform_user_id", matrix_user_id) if command == "whoami": name = user_meta.get("display_name") or matrix_user_id body = f"Аккаунт: {matrix_user_id}\nПлатформа: {platform_user_id}\nИмя: {name}" elif command == "status": body = f"Статус платформы: ✅ доступна\nАккаунт: {matrix_user_id}" elif command == "plan": settings = await platform.get_settings(platform_user_id) plan = settings.plan name_plan = plan.get("name", "Beta") used = plan.get("tokens_used", 0) limit = plan.get("tokens_limit", 1000) pct = used * 10 // limit if limit else 0 bar = "━" * pct + "░" * (10 - pct) body = f"Подписка: {name_plan}\nТокены: {used} / {limit}\n{bar} {used * 100 // limit if limit else 0}%" elif command == "soul": if len(args) >= 2: field, value = args[0], " ".join(args[1:]) await platform.update_settings(platform_user_id, SettingsAction(action="set_soul", payload={"field": field, "value": value})) body = f"✅ soul.{field} = «{value}»" else: settings = await platform.get_settings(platform_user_id) lines = [f"{k}: {v}" for k, v in settings.soul.items()] if settings.soul else ["(пусто)"] body = "Soul:\n" + "\n".join(lines) elif command == "safety": if args and args[0] in ("on", "off"): enabled = args[0] == "on" trigger = " ".join(args[1:]) await platform.update_settings(platform_user_id, SettingsAction(action="set_safety", payload={"trigger": trigger, "enabled": enabled})) body = f"✅ safety.{trigger} = {'включено' if enabled else 'выключено'}" else: settings = await platform.get_settings(platform_user_id) lines = [f"{'✅' if v else '❌'} {k}" for k, v in settings.safety.items()] body = "Безопасность:\n" + ("\n".join(lines) if lines else "(пусто)") elif command == "connectors": settings = await platform.get_settings(platform_user_id) if settings.connectors: lines = [f"✅ {k}" for k in settings.connectors] body = "Коннекторы:\n" + "\n".join(lines) else: body = "Коннекторы:\n❌ Нет подключённых сервисов\n\n!connect gmail — подключить Gmail" else: body = f"Неизвестная команда: !{command}" await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) ``` - [ ] **Step 4: Run — expect all PASS** ```bash pytest tests/adapter/matrix/test_settings_handler.py -v ``` - [ ] **Step 5: Commit** ```bash git add adapter/matrix/handlers/settings.py tests/adapter/matrix/test_settings_handler.py git commit -m "feat(matrix): settings handler — !skills m.replace + commands" ``` --- ### Task 8: Bot entry point — sync loop + event routing **Files:** - Create: `adapter/matrix/bot.py` - Create: `tests/adapter/matrix/test_bot.py` - [ ] **Step 1: Write failing tests** ```python # tests/adapter/matrix/test_bot.py import pytest from types import SimpleNamespace from unittest.mock import AsyncMock from core.store import InMemoryStore from sdk.mock import MockPlatformClient from adapter.matrix.bot import create_dispatcher, route_message_event, route_reaction_event from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta from core.auth import AuthManager from core.handler import EventDispatcher @pytest.fixture def store(): return InMemoryStore() @pytest.fixture def platform(): return MockPlatformClient() @pytest.fixture def dispatcher(platform, store): return create_dispatcher(platform, store) @pytest.fixture def client(): c = AsyncMock() c.user_id = "@bot:m.org" c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org")) c.room_invite = AsyncMock() c.room_put_state = AsyncMock() return c async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): user = await platform.get_or_create_user(uid, "matrix", "Alice") await set_user_meta(store, uid, { "platform_user_id": user.user_id, "display_name": "Alice", "space_id": None, "settings_room_id": None, "next_chat_index": 2, }) await set_room_meta(store, room_id, { "room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": uid, }) await set_room_state(store, room_id, "idle") await AuthManager(platform, store).confirm(uid) async def test_create_dispatcher_returns_event_dispatcher(platform, store): d = create_dispatcher(platform, store) assert isinstance(d, EventDispatcher) async def test_route_text_message(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="Hello", event_id="$e1", msgtype="m.text", replyto_event_id=None) room = SimpleNamespace(room_id="!dm:m.org") await route_message_event(client, room, event, store, platform, dispatcher) client.room_send.assert_called() body_calls = [str(c) for c in client.room_send.call_args_list] assert any("[MOCK]" in c for c in body_calls) async def test_route_new_command(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="!new Test", event_id="$e2", msgtype="m.text", replyto_event_id=None) room = SimpleNamespace(room_id="!dm:m.org") await route_message_event(client, room, event, store, platform, dispatcher) client.room_create.assert_called() async def test_route_skills_command(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@alice:m.org", body="!skills", event_id="$e3", msgtype="m.text", replyto_event_id=None) room = SimpleNamespace(room_id="!dm:m.org") await route_message_event(client, room, event, store, platform, dispatcher) body = client.room_send.call_args[0][2]["body"] assert "web-search" in body async def test_bot_ignores_own_messages(client, store, platform, dispatcher): await _setup(store, platform) event = SimpleNamespace(sender="@bot:m.org", body="Hello", event_id="$e4", msgtype="m.text", replyto_event_id=None) room = SimpleNamespace(room_id="!dm:m.org") await route_message_event(client, room, event, store, platform, dispatcher) client.room_send.assert_not_called() async def test_route_confirm_reaction(client, store, platform, dispatcher): await _setup(store, platform) await set_room_state(store, "!dm:m.org", "confirm_pending") event = SimpleNamespace(sender="@alice:m.org", key="👍", reacted_to_id="$orig", event_id="$r1", source={"content": {"m.relates_to": {"key": "👍", "event_id": "$orig"}}}) room = SimpleNamespace(room_id="!dm:m.org") await route_reaction_event(client, room, event, store, platform, dispatcher) client.room_send.assert_called() ``` - [ ] **Step 2: Run — expect ImportError** ```bash pytest tests/adapter/matrix/test_bot.py -v ``` - [ ] **Step 3: Implement bot.py** ```python # adapter/matrix/bot.py from __future__ import annotations import os import structlog from nio import AsyncClient, InviteMemberEvent, RoomMessageText, UnknownEvent from adapter.matrix.converter import CONFIRM_REACTIONS, SKILL_REACTIONS from adapter.matrix.handlers.auth import handle_invite from adapter.matrix.handlers.chat import handle_list_chats, handle_message, handle_new_chat from adapter.matrix.handlers.confirm import handle_confirm_callback from adapter.matrix.handlers.settings import handle_skill_toggle, handle_skills, handle_text_setting from adapter.matrix.store import get_room_meta, get_room_state from core.auth import AuthManager from core.chat import ChatManager from core.handler import EventDispatcher from core.handlers import register_all from core.settings import SettingsManager from core.store import SQLiteStore from sdk.interface import PlatformClient from sdk.mock import MockPlatformClient logger = structlog.get_logger(__name__) _SETTINGS_COMMANDS = {"connectors", "soul", "safety", "plan", "status", "whoami"} def create_dispatcher(platform: PlatformClient, store) -> EventDispatcher: d = EventDispatcher( platform=platform, chat_mgr=ChatManager(platform, store), auth_mgr=AuthManager(platform, store), settings_mgr=SettingsManager(platform, store), ) register_all(d) return d async def route_message_event(client, room, event, store, platform, dispatcher) -> None: room_id = room.room_id sender = event.sender if sender == client.user_id: return room_meta = await get_room_meta(store, room_id) if room_meta is None: return body: str = event.body or "" state = await get_room_state(store, room_id) if state == "confirm_pending" and body.startswith("!") and body[1:].split()[0] in ("yes", "no"): await handle_confirm_callback(client, room_id, event, store, platform, dispatcher, is_reaction=False) return if body.startswith("!"): parts = body[1:].split(maxsplit=1) cmd = parts[0].lower() args = parts[1].split() if len(parts) > 1 else [] if cmd == "new": await handle_new_chat(client, room_id, event, store, platform, dispatcher) elif cmd == "chats": await handle_list_chats(client, room_id, sender, store) elif cmd == "skills": await handle_skills(client, room_id, sender, store, platform, dispatcher) elif cmd in _SETTINGS_COMMANDS: await handle_text_setting(client, room_id, sender, cmd, args, store, platform) else: # Unknown command — treat as regular message await handle_message(client, room_id, event, store, platform, dispatcher) else: await handle_message(client, room_id, event, store, platform, dispatcher) async def route_reaction_event(client, room, event, store, platform, dispatcher) -> None: room_id = room.room_id sender = getattr(event, "sender", None) if sender == client.user_id: return # nio may give us a ReactionEvent or UnknownEvent; normalise key access key = getattr(event, "key", None) reacted_to_id = getattr(event, "reacted_to_id", None) if key is None: relates = event.source.get("content", {}).get("m.relates_to", {}) key = relates.get("key", "") reacted_to_id = relates.get("event_id", "") from types import SimpleNamespace norm = SimpleNamespace(sender=sender, key=key, reacted_to_id=reacted_to_id, event_id=event.event_id) state = await get_room_state(store, room_id) if state == "confirm_pending" and key in CONFIRM_REACTIONS: await handle_confirm_callback(client, room_id, norm, store, platform, dispatcher, is_reaction=True) elif key in SKILL_REACTIONS: await handle_skill_toggle(client, room_id, norm, store, platform, dispatcher) async def main() -> None: homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org") user_id = os.getenv("MATRIX_USER_ID", "@lambda-bot:matrix.org") password = os.getenv("MATRIX_PASSWORD", "") store = SQLiteStore("matrix_bot.db") platform = MockPlatformClient() dispatcher = create_dispatcher(platform, store) client = AsyncClient(homeserver, user_id) await client.login(password) logger.info("Logged in", user_id=user_id) async def on_message(room, event: RoomMessageText) -> None: await route_message_event(client, room, event, store, platform, dispatcher) async def on_invite(room, event: InviteMemberEvent) -> None: if event.membership == "invite" and event.state_key == client.user_id: display_name = getattr(event, "display_name", None) await handle_invite(client, room.room_id, event.sender, store, platform, display_name) async def on_unknown(room, event: UnknownEvent) -> None: if event.type == "m.reaction": await route_reaction_event(client, room, event, store, platform, dispatcher) client.add_event_callback(on_message, RoomMessageText) client.add_event_callback(on_invite, InviteMemberEvent) client.add_event_callback(on_unknown, UnknownEvent) logger.info("Starting sync loop") await client.sync_forever(timeout=30000) if __name__ == "__main__": import asyncio asyncio.run(main()) ``` - [ ] **Step 4: Run matrix tests** ```bash pytest tests/adapter/matrix/ -v ``` Expected: all PASS. - [ ] **Step 5: Run full suite — verify no regressions** ```bash pytest tests/ -v ``` Expected: all tests PASS including pre-existing `tests/core/` and `tests/platform/`. - [ ] **Step 6: Commit** ```bash git add adapter/matrix/bot.py tests/adapter/matrix/test_bot.py git commit -m "feat(matrix): bot entry point — sync loop and event routing" ```