13 KiB
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.
Флоу — новый пользователь
- Пользователь инвайтит бота в личные сообщения
- Бот принимает инвайт, вызывает
platform.get_or_create_user(matrix_user_id, "matrix", display_name) - Бот регистрирует DM-комнату как
chat_roomсchat_id = C1в SQLite - Бот пишет приветствие в DM — пользователь сразу пишет
- При первом
!new— бот создаёт SpaceLambda — {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 → бот отправляет список. Каждый скилл пронумерован. Реакция 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
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— добавить после основного флоу
Порядок реализации
bot.py— AsyncClient, sync loop, middleware для platform clientstates.py— RoomStateroom_router.py— определение типа комнатыconverter.py— from_room_message, from_reaction, from_commandhandlers/auth.py— invite → onboardinghandlers/chat.py— сообщения + !new + !chatsreactions.py— helpers для работы с реакциямиhandlers/confirm.py— реакции 👍/❌ + !yes/!nohandlers/settings.py— !skills с m.replace + остальные команды