surfaces/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md

13 KiB
Raw Permalink Blame History

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)

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 <name> Переименовать текущую комнату
!archive Вывести комнату из Space (не удалять)
!skills Список скиллов — реакции как тумблеры
!connectors Коннекторы (OAuth заглушки)
!soul Личность агента
!safety Настройки безопасности
!plan Подписка и токены
!status Состояние платформы и чатов
!whoami Текущий аккаунт
!yes / !no Подтверждение / отмена действия агента

Settings room

Создаётся при первом !new вместе со Space. Закреплена вверху Space.

Скиллы — реакции как тумблеры

!skills → бот отправляет список. Каждый скилл пронумерован. Реакция 1N⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через m.replace после каждого переключения.

✅ 1 web-search  — поиск в интернете
✅ 2 fetch-url   — чтение веб-страниц
✅ 3 email       — чтение почты
❌ 4 browser     — управление браузером
❌ 5 image-gen   — генерация изображений
✅ 6 files       — работа с файлами

Реакция 16⃣ = переключить скилл

Остальные настройки

!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_responseconfirm_pendingidle


Долгие задачи — треды

Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения.

🤖 Lambda (основной поток):
Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты.
  🧵 Прогресс (в треде):
  └── ✅ Ищу источники... (12 найдено)
  └── ✅ Анализирую статьи...
  └── ⏳ Формирую отчёт...
  └── ○ Финальная проверка

Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток.


Typing indicator

m.typing — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек).


Converter

adapter/matrix/converter.py — конвертация в обе стороны.

matrix-nio → IncomingEvent

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

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)

БД схема

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 + остальные команды