# Matrix Adapter Design **Date:** 2026-03-31 **Status:** Approved — ready for implementation **Scope:** `adapter/matrix/` --- ## Контекст Matrix-адаптер — внутренняя поверхность для команды Lambda Lab: разработчики, тестировщики, авторы скиллов. UX ориентирован на удобство работы, не на онбординг. Адаптер конвертирует matrix-nio события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Matrix API. Клиент: Element (web/desktop). Стек: matrix-nio (async), Python 3.11+, SQLite. --- ## Онбординг — DM как первый чат (ленивый Space) **Решение:** DM-комната с ботом = Чат #1. Space создаётся только при первом `!new`. ### Флоу — новый пользователь 1. Пользователь инвайтит бота в личные сообщения 2. Бот принимает инвайт, вызывает `platform.get_or_create_user(matrix_user_id, "matrix", display_name)` 3. Бот регистрирует DM-комнату как `chat_room` с `chat_id = C1` в SQLite 4. Бот пишет приветствие в DM — пользователь сразу пишет 5. При первом `!new` — бот создаёт Space `Lambda — {display_name}`, добавляет DM-комнату в Space через `m.space.child`, создаёт новую комнату-чат ### Флоу — возвращающийся пользователь Если `matrix_user_id` уже есть в БД (бот перезапустился, или пользователь пишет повторно) — `get_or_create_user` возвращает `is_new=False`. Бот не создаёт ничего заново, просто обрабатывает сообщение в контексте существующей комнаты. ### Почему не Space сразу Создание Space при инвайте порождает 3 инвайта подряд (Space + Settings + Чат 1) до первого сообщения. DM-first убирает этот шум, сохраняя такой же UX как Telegram. ### Приветствие ``` Привет, {display_name}! Пиши — я здесь. Команды: !new · !chats · !rename · !archive · !skills ``` --- ## Архитектура — Room-type routing При получении события адаптер сначала определяет тип комнаты (`chat` / `settings`), затем маршрутизирует в соответствующий обработчик. ``` adapter/matrix/ bot.py — matrix-nio клиент, sync loop converter.py — RoomEvent → IncomingEvent, OutgoingEvent → Matrix API room_router.py — определяет тип комнаты: chat | settings states.py — FSM состояния (per room_id, SQLite) handlers/ auth.py — invite → onboarding chat.py — сообщения, !new, !chats, !rename, !archive settings.py — !skills, !connectors, !soul, !safety, !plan, !status, !whoami confirm.py — реакции 👍/❌ и команды !yes / !no reactions.py — helpers: add_reaction, remove_reactions, parse_reaction_event ``` --- ## FSM состояния (per room_id) ```python class RoomState(StatesGroup): idle = State() # ждём сообщения waiting_response = State() # запрос ушёл на платформу confirm_pending = State() # ждём !yes/!no или реакцию 👍/❌ settings_active = State() # Settings-комната (не чат) ``` `room_type` хранится в SQLite. `room_router.py` читает его при каждом событии. --- ## Команды Все команды на английском. Работают в любой комнате Space. | Команда | Действие | |---------|---------| | `!new [name]` | Создать чат. При первом вызове — создаёт Space, переносит DM | | `!chats` | Список чатов с текущим активным | | `!rename ` | Переименовать текущую комнату | | `!archive` | Вывести комнату из Space (не удалять) | | `!skills` | Список скиллов — реакции как тумблеры | | `!connectors` | Коннекторы (OAuth заглушки) | | `!soul` | Личность агента | | `!safety` | Настройки безопасности | | `!plan` | Подписка и токены | | `!status` | Состояние платформы и чатов | | `!whoami` | Текущий аккаунт | | `!yes` / `!no` | Подтверждение / отмена действия агента | --- ## Settings room Создаётся при первом `!new` вместе со Space. Закреплена вверху Space. ### Скиллы — реакции как тумблеры `!skills` → бот отправляет список. Каждый скилл пронумерован. Реакция 1️⃣–N️⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через `m.replace` после каждого переключения. ``` ✅ 1 web-search — поиск в интернете ✅ 2 fetch-url — чтение веб-страниц ✅ 3 email — чтение почты ❌ 4 browser — управление браузером ❌ 5 image-gen — генерация изображений ✅ 6 files — работа с файлами Реакция 1️⃣–6️⃣ = переключить скилл ``` ### Остальные настройки `!connectors`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` — текстовые ответы, без интерактивных элементов. Поля задаются аргументами команды: `!soul name Lambda`, `!soul style brief`, `!safety on email-send`. --- ## Подтверждение действий агента Агент запрашивает подтверждение → бот отправляет сообщение с описанием действия. Пользователь подтверждает **реакцией или командой** — оба способа работают. ``` 🤖 Lambda: Отправить письмо azamat@lambda.lab? Тема: «Отчёт за неделю» 👍 подтвердить · ❌ отменить !yes — подтвердить · !no — отменить Истекает через 5 минут ``` После ответа: бот убирает реакции с сообщения, редактирует статус (`m.replace`), переходит в `idle`. FSM: `waiting_response` → `confirm_pending` → `idle` --- ## Долгие задачи — треды Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения. ``` 🤖 Lambda (основной поток): Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты. 🧵 Прогресс (в треде): └── ✅ Ищу источники... (12 найдено) └── ✅ Анализирую статьи... └── ⏳ Формирую отчёт... └── ○ Финальная проверка ``` Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток. --- ## Typing indicator `m.typing` — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек). --- ## Converter `adapter/matrix/converter.py` — конвертация в обе стороны. ### matrix-nio → IncomingEvent ```python def from_room_message(event: RoomMessageText, room_id: str, chat_id: str) -> IncomingMessage: return IncomingMessage( user_id=event.sender, # @user:matrix.org platform="matrix", chat_id=chat_id, # C1, C2... из rooms таблицы text=event.body, attachments=extract_attachments(event), reply_to=event.replyto_event_id, ) def extract_attachments(event: RoomMessageText) -> list[Attachment]: # m.image → Attachment(type="image", url=mxc_url, mime_type=...) # m.file → Attachment(type="document", url=mxc_url, filename=..., mime_type=...) # m.audio → Attachment(type="audio", url=mxc_url, mime_type=...) # m.text → [] msgtype = getattr(event, "msgtype", "m.text") if msgtype == "m.image": return [Attachment(type="image", url=event.url, mime_type=event.mimetype)] elif msgtype == "m.file": return [Attachment(type="document", url=event.url, filename=event.body, mime_type=event.mimetype)] elif msgtype == "m.audio": return [Attachment(type="audio", url=event.url, mime_type=event.mimetype)] return [] def from_reaction(event: ReactionEvent, room_id: str) -> IncomingCallback | None: # Парсит m.reaction → IncomingCallback(action="toggle_skill" | "confirm" | "cancel") ... def from_command(body: str, sender: str, room_id: str, chat_id: str) -> IncomingCommand | None: # Парсит !new, !skills, !yes, !no и т.д. → IncomingCommand ... ``` ### OutgoingEvent → Matrix ```python async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None: if isinstance(event, OutgoingMessage): await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}) elif isinstance(event, OutgoingUI): # Confirmation request — текст + подсказка по реакциям/командам body = f"{event.text}\n\n👍 подтвердить · ❌ отменить\n!yes — подтвердить · !no — отменить" await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) await client.room_send(room_id, "m.reaction", {...}) # добавить 👍 и ❌ на сообщение elif isinstance(event, OutgoingTyping): await client.room_typing(room_id, event.is_typing, timeout=25000) ``` --- ## БД схема ```sql CREATE TABLE matrix_users ( matrix_user_id TEXT PRIMARY KEY, -- @user:matrix.org platform_user_id TEXT NOT NULL, -- из MockPlatformClient display_name TEXT, space_id TEXT, -- NULL до первого !new settings_room_id TEXT, -- NULL до первого !new created_at TIMESTAMP ); CREATE TABLE rooms ( room_id TEXT PRIMARY KEY, -- room_id Matrix matrix_user_id TEXT NOT NULL, room_type TEXT NOT NULL, -- 'chat' | 'settings' chat_id TEXT, -- C1, C2... (NULL для settings) display_name TEXT, created_at TIMESTAMP, archived_at TIMESTAMP, FOREIGN KEY(matrix_user_id) REFERENCES matrix_users(matrix_user_id) ); ``` `StateStore` из `core/store.py` (`SQLiteStore`) — для FSM per room_id. --- ## Что НЕ реализуем в прототипе - Webhook от платформы (используем sync `send_message`) - E2E encryption (nio поддерживает, но усложняет прототип) - Экспорт истории - `!rename`, `!archive` — добавить после основного флоу --- ## Порядок реализации 1. `bot.py` — AsyncClient, sync loop, middleware для platform client 2. `states.py` — RoomState 3. `room_router.py` — определение типа комнаты 4. `converter.py` — from_room_message, from_reaction, from_command 5. `handlers/auth.py` — invite → onboarding 6. `handlers/chat.py` — сообщения + !new + !chats 7. `reactions.py` — helpers для работы с реакциями 8. `handlers/confirm.py` — реакции 👍/❌ + !yes/!no 9. `handlers/settings.py` — !skills с m.replace + остальные команды