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>
This commit is contained in:
Mikhail Putilovskij 2026-03-27 00:35:42 +03:00
commit b6df29bd9b
29 changed files with 2504 additions and 0 deletions

311
docs/surface-protocol.md Normal file
View file

@ -0,0 +1,311 @@
# 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
Обычное сообщение — текст, файл, голос.
```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 рендерит как текст с описанием реакций или HTML-кнопки.
### 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
Состояние чата — общее для всех поверхностей.
```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
session_id: str | None # активная сессия платформы
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 показывает как реакции 👍 / ❌.
Ядро не знает как именно — только получает `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`)
- аутентификацию (`AuthFlow`)
- подтверждение действий (`ConfirmationRequest`)
- все настройки (коннекторы, скиллы, SOUL, безопасность, подписка)
- интеграцию с платформой через `PlatformClient`
- обработку ошибок платформы
---
## Замена MockPlatformClient на реальный SDK
Вся работа с платформой идёт через `PlatformClient` протокол:
```python
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 — тоже реализует этот протокол, заменяя один файл.
Адаптеры поверхностей и ядро не меняются вообще.