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

283 lines
13 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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