surfaces/docs/surface-protocol.md
Mikhail Putilovskij b6df29bd9b init: surfaces-bot — Telegram & Matrix prototype
- Surface Protocol: unified IncomingMessage/OutgoingUI/ChatContext
- Telegram: Forum Topics (group + topics per chat)
- Matrix: Space + rooms per chat
- MockPlatformClient with PlatformClient Protocol
- docs: surface-protocol, telegram/matrix specs, api-contract, claude-code-guide
- project scaffold: src/, tests/, pyproject.toml

Co-Authored-By: Claude Sonnet 4-6 <noreply@anthropic.com>
2026-03-27 00:35:42 +03:00

11 KiB
Raw Blame History

Surface Protocol — унификация поверхностей

Идея

Любая поверхность (Telegram, Matrix, будущий Discord, голос, web) делает одно и то же: принимает события от пользователя и отправляет ответы обратно.

Разница только в том как событие выглядит снаружи: aiogram Message, matrix-nio RoomMessageText, Discord Interaction. Внутри — одно и то же.

Surface Protocol — это общий язык между адаптерами поверхностей и ядром. Адаптер конвертирует нативные события в протокольные структуры и обратно. Ядро работает только с протокольными структурами и ничего не знает о транспорте.

Добавить новую поверхность = написать один адаптер-конвертер. Ядро не трогается.


Структура проекта

surfaces-bot/
  core/
    protocol.py      — все унифицированные структуры данных
    handler.py       — логика: IncomingEvent → OutgoingEvent
    session.py       — управление сессиями и чатами
    auth.py          — AuthFlow
    settings.py      — 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         — точка входа, клиент
    _template.py     — шаблон для новой поверхности

  platform/
    interface.py     — Protocol: PlatformClient
    mock.py          — MockPlatformClient

Входящие события (Incoming)

Всё что пользователь делает конвертируется в одно из трёх:

IncomingMessage

Обычное сообщение — текст, файл, голос.

@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.

@dataclass
class IncomingCommand:
    user_id: str
    platform: str
    chat_id: str
    command: str           # "start" | "new" | "settings" | "archive" ...
    args: list[str]        # аргументы после команды

IncomingCallback

Нажатие кнопки или реакции — подтверждение, переключение скилла.

@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

Вложение, нормализованное из любого источника.

@dataclass
class Attachment:
    type: str              # "image" | "document" | "audio" | "video"
    url: str | None        # ссылка если доступна
    content: bytes | None  # содержимое если скачано
    filename: str | None
    mime_type: str | None

Исходящие события (Outgoing)

Всё что ядро хочет показать пользователю:

OutgoingMessage

Ответ агента или системное сообщение.

@dataclass
class OutgoingMessage:
    chat_id: str
    text: str
    parse_mode: str        # "markdown" | "plain"
    attachments: list[Attachment]
    reply_to: str | None

OutgoingUI

Интерактивные элементы — кнопки, меню, переключатели.

@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 рендерит как текст с описанием реакций или HTML-кнопки.

OutgoingNotification

Асинхронное уведомление — агент закончил долгую задачу.

@dataclass
class OutgoingNotification:
    chat_id: str
    text: str
    level: str             # "info" | "warning" | "success" | "error"

OutgoingTyping

Индикатор что агент думает.

@dataclass
class OutgoingTyping:
    chat_id: str
    is_typing: bool

Жизненный цикл (Lifecycle)

Унифицированные события для управления чатами и подключением.

ChatContext

Состояние чата — общее для всех поверхностей.

@dataclass
class ChatContext:
    chat_id: str           # "C1" | "C2" — ID в воркспейсе платформы
    display_name: str      # «Чат 1» | «Анализ рынка»
    platform: str
    surface_ref: str       # room_id в Matrix | topic_id в Telegram
    session_id: str | None # активная сессия платформы
    created_at: datetime
    is_archived: bool

AuthFlow

Флоу аутентификации — одинаков для всех поверхностей.

@dataclass
class AuthFlow:
    user_id: str
    platform: str
    state: str             # "pending" | "code_sent" | "confirmed" | "failed"
    platform_user_id: str | None

ConfirmationRequest

Запрос подтверждения опасного действия — от агента к пользователю.

@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 показывает как реакции 👍 / . Ядро не знает как именно — только получает IncomingCallback с action: "confirm".


SettingsAction

Унифицированные действия в настройках — одинаковы для Telegram и Matrix.

@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

Заглушка для биллинга — реализует другая команда.

@dataclass
class PaymentRequired:
    user_id: str
    reason: str            # "limit_reached" | "feature_locked"
    current_plan: str

Поверхность получила PaymentRequired → показала заглушку. Содержимое заглушки потом наполняет команда биллинга.


Как написать новую поверхность

Скопируй adapter/_template.py и реализуй три метода:

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)
  • аутентификацию (AuthFlow)
  • подтверждение действий (ConfirmationRequest)
  • все настройки (коннекторы, скиллы, SOUL, безопасность, подписка)
  • интеграцию с платформой через PlatformClient
  • обработку ошибок платформы

Замена MockPlatformClient на реальный SDK

Вся работа с платформой идёт через PlatformClient протокол:

class PlatformClient(Protocol):
    async def get_or_create_user(self, user_id: str, platform: str) -> User: ...
    async def create_session(self, user_id: str, chat_id: str) -> Session: ...
    async def send_message(self, session_id: str, text: str, attachments: list) -> AgentResponse: ...
    async def close_session(self, session_id: str) -> None: ...
    async def get_chat_history(self, user_id: str, chat_id: str) -> list[Message]: ...
    async def get_settings(self, user_id: str) -> UserSettings: ...
    async def update_settings(self, user_id: str, action: SettingsAction) -> None: ...

MockPlatformClient реализует этот протокол сейчас. Реальный SDK — тоже реализует этот протокол, заменяя один файл. Адаптеры поверхностей и ядро не меняются вообще.