surfaces/docs/surface-protocol.md

313 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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.

# Surface Protocol — унификация поверхностей
## Идея
Любая поверхность (Telegram, Matrix, будущий Discord, голос, web) делает одно
и то же: принимает события от пользователя и отправляет ответы обратно.
Разница только в том как событие выглядит снаружи: aiogram `Message`,
matrix-nio `RoomMessageText`, Discord `Interaction`. Внутри — одно и то же.
Surface Protocol — это общий язык между адаптерами поверхностей и ядром.
Адаптер конвертирует нативные события в протокольные структуры и обратно.
Ядро работает только с протокольными структурами и ничего не знает о транспорте.
**Добавить новую поверхность = написать один адаптер-конвертер.**
Ядро не трогается.
---
## Структура проекта
```
surfaces-bot/
core/
protocol.py — все унифицированные структуры данных
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
handlers/ — обработчики по типам событий (start, message, chat, settings, callback)
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
chat.py — ChatManager: метаданные чатов C1/C2/C3
auth.py — AuthManager, AuthFlow
settings.py — SettingsManager, SettingsAction
adapter/
telegram/ — aiogram адаптер
converter.py — aiogram Event → IncomingEvent, OutgoingEvent → aiogram API
bot.py — точка входа, роутер
matrix/ — matrix-nio адаптер
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
bot.py — точка входа, клиент
sdk/
interface.py — Protocol: PlatformClient (контракт к SDK)
real.py — RealPlatformClient (через AgentApi)
mock.py — MockPlatformClient (для локальных тестов)
```
---
## Входящие события (Incoming)
Всё что пользователь делает конвертируется в одно из трёх:
### IncomingMessage
Обычное сообщение — текст, файл, голос.
```python
@dataclass
class IncomingMessage:
user_id: str # "tg_123456" | "@user:matrix.org"
platform: str # "telegram" | "matrix"
chat_id: str # "C1" | "C2" — ID чата в воркспейсе платформы
text: str
attachments: list[Attachment]
reply_to: str | None # message_id если это ответ на сообщение
```
### IncomingCommand
Команда управления — `/start`, `!new`, `/settings`.
```python
@dataclass
class IncomingCommand:
user_id: str
platform: str
chat_id: str
command: str # "start" | "new" | "settings" | "archive" ...
args: list[str] # аргументы после команды
```
### IncomingCallback
Нажатие кнопки или реакции — подтверждение, переключение скилла.
```python
@dataclass
class IncomingCallback:
user_id: str
platform: str
chat_id: str
action: str # "confirm" | "cancel" | "toggle_skill" | "connect" ...
payload: dict # {"skill": "browser"} | {"action_id": "abc123"} ...
```
### Attachment
Вложение, нормализованное из любого источника.
```python
@dataclass
class Attachment:
type: str # "image" | "document" | "audio" | "video"
url: str | None # ссылка если доступна
content: bytes | None # содержимое если скачано
filename: str | None
mime_type: str | None
```
---
## Исходящие события (Outgoing)
Всё что ядро хочет показать пользователю:
### OutgoingMessage
Ответ агента или системное сообщение.
```python
@dataclass
class OutgoingMessage:
chat_id: str
text: str
parse_mode: str # "markdown" | "plain"
attachments: list[Attachment]
reply_to: str | None
```
### OutgoingUI
Интерактивные элементы — кнопки, меню, переключатели.
```python
@dataclass
class OutgoingUI:
chat_id: str
text: str
buttons: list[UIButton]
@dataclass
class UIButton:
label: str
action: str # action для IncomingCallback
payload: dict
style: str # "primary" | "danger" | "secondary"
```
Telegram рендерит это как InlineKeyboard.
Matrix рендерит как текст (в MVP).
### OutgoingNotification
Асинхронное уведомление — агент закончил долгую задачу.
```python
@dataclass
class OutgoingNotification:
chat_id: str
text: str
level: str # "info" | "warning" | "success" | "error"
```
### OutgoingTyping
Индикатор что агент думает.
```python
@dataclass
class OutgoingTyping:
chat_id: str
is_typing: bool
```
---
## Жизненный цикл (Lifecycle)
Унифицированные события для управления чатами и подключением.
### ChatContext
Метаданные чата — общие для всех поверхностей. Хранятся ботом, lifecycle контейнера управляет платформа (Master).
```python
@dataclass
class ChatContext:
chat_id: str # "C1" | "C2" — ID чата в воркспейсе платформы
display_name: str # «Чат 1» | «Анализ рынка»
platform: str
surface_ref: str # room_id в Matrix | topic_id в Telegram
created_at: datetime
is_archived: bool
```
### AuthFlow
Флоу аутентификации — одинаков для всех поверхностей.
```python
@dataclass
class AuthFlow:
user_id: str
platform: str
state: str # "pending" | "code_sent" | "confirmed" | "failed"
platform_user_id: str | None
```
### ConfirmationRequest
Запрос подтверждения опасного действия — от агента к пользователю.
```python
@dataclass
class ConfirmationRequest:
action_id: str
chat_id: str
description: str # «Отправить письмо на vasya@mail.ru»
risk_level: str # "low" | "medium" | "high"
expires_at: datetime
```
Telegram показывает как Inline-кнопки.
Matrix показывает как запрос для `!yes` / `!no`.
Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`.
---
## SettingsAction
Унифицированные действия в настройках — одинаковы для Telegram и Matrix.
```python
@dataclass
class SettingsAction:
action: str # "connect" | "disconnect" | "toggle_skill" | ...
payload: dict
# Примеры:
SettingsAction(action="connect", payload={"service": "gmail"})
SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
SettingsAction(action="set_soul", payload={"field": "name", "value": "Лямбда"})
SettingsAction(action="set_safety", payload={"trigger": "email-send", "enabled": True})
```
Ядро обрабатывает `SettingsAction` одинаково. Откуда пришло — не важно.
---
## PaymentEvent
Заглушка для биллинга — реализует другая команда.
```python
@dataclass
class PaymentRequired:
user_id: str
reason: str # "limit_reached" | "feature_locked"
current_plan: str
```
Поверхность получила `PaymentRequired` → показала заглушку.
Содержимое заглушки потом наполняет команда биллинга.
---
## Как написать новую поверхность
Скопируй `adapter/_template.py` и реализуй три метода:
```python
class MySurfaceAdapter:
def parse_incoming(self, raw_event) -> IncomingMessage | IncomingCommand | IncomingCallback:
"""Конвертировать нативное событие в протокольную структуру."""
...
def render_outgoing(self, event: OutgoingMessage | OutgoingUI | OutgoingNotification) -> any:
"""Конвертировать протокольную структуру в нативный формат."""
...
async def send(self, rendered) -> None:
"""Отправить нативным способом."""
...
```
Дальше подключи адаптер в точку входа и передай `core.handler.handle` как callback.
Всё остальное уже работает.
---
## Что ядро даёт бесплатно
Любая новая поверхность получает без дополнительного кода:
- управление чатами (`ChatContext`, C1/C2/C3)
- аутентификацию (`AuthFlow`)
- подтверждение действий (`ConfirmationRequest`)
- все настройки (коннекторы, скиллы, SOUL, безопасность, подписка)
- интеграцию с платформой через `PlatformClient`
- обработку ошибок платформы
---
## Замена MockPlatformClient на реальный SDK
Вся работа с платформой идёт через `PlatformClient` протокол:
```python
class PlatformClient(Protocol):
async def get_or_create_user(self, external_id: str, platform: str,
display_name: str | None = None) -> User: ...
async def send_message(self, user_id: str, chat_id: str, text: str,
attachments: list | None = None) -> MessageResponse: ...
async def get_settings(self, user_id: str) -> UserSettings: ...
async def update_settings(self, user_id: str, action: Any) -> None: ...
```
Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы.
Бот передаёт `user_id` + `chat_id` + текст.
`MockPlatformClient` реализует этот протокол для локальных тестов.
Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket.
Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`.