diff --git a/docs/superpowers/specs/2026-03-28-core-design.md b/docs/superpowers/specs/2026-03-28-core-design.md new file mode 100644 index 0000000..e279489 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-core-design.md @@ -0,0 +1,239 @@ +# Core Design — surfaces-bot + +> **Статус:** approved +> **Дата:** 2026-03-28 +> **Подход:** Registry + Handlers (Подход 2) + +--- + +## Контекст + +Два бота (Telegram, Matrix) разделяют общую бизнес-логику через `core/`. +Цель — написать логику один раз, легко добавлять новые поверхности и новый функционал +без правки центральных файлов. + +--- + +## Структура файлов + +``` +core/ + __init__.py + protocol.py — dataclasses: IncomingMessage, IncomingCommand, IncomingCallback, + OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping, + Attachment, ChatContext, AuthFlow, ConfirmationRequest, + SettingsAction, PaymentRequired + handler.py — EventDispatcher (только маршрутизация, без бизнес-логики) + store.py — StateStore Protocol + InMemoryStore + SQLiteStore + session.py — SessionManager + auth.py — AuthManager + settings.py — SettingsManager + handlers/ + __init__.py — регистрация всех обработчиков в dispatcher + start.py — /start, !start → AuthFlow + приветствие + chat.py — /new, /rename, /archive, /chats + message.py — текстовое сообщение + voice slot + callback.py — confirm/cancel ConfirmationRequest, toggle_skill + settings.py — все SettingsAction (connectors, skills, soul, safety, plan) + auth.py — auth state machine (pending → confirmed → failed) + +platform/ + interface.py — PlatformClient Protocol + Pydantic модели (User, Session, MessageResponse) + mock.py — MockPlatformClient + +tests/ + core/ + test_dispatcher.py + test_session.py + test_auth.py + test_settings.py + test_voice_slot.py + adapter/ + telegram/ + matrix/ + platform/ + test_mock_platform.py — переезжает из tests/test_mock_platform.py +``` + +**Миграция `src/`:** при реализации — первый шаг: +- `src/shared/models.py` → объединить с `platform/interface.py` +- `src/mock_platform.py` → `platform/mock.py` +- `src/` удалить + +--- + +## EventDispatcher + +`handler.py` — тупой роутер. Маршрутизирует по двум ключам: тип события + команда/действие. + +```python +# Сигнатура регистрации +dispatcher.register(IncomingCommand, "start", start_handler) +dispatcher.register(IncomingCommand, "new", chat_handler.new_chat) +dispatcher.register(IncomingCommand, "archive", chat_handler.archive) +dispatcher.register(IncomingMessage, "*", message_handler) # catch-all +dispatcher.register(IncomingMessage, "audio", voice_handler) # voice slot +dispatcher.register(IncomingCallback, "confirm", callback_handler.confirm) +dispatcher.register(IncomingCallback, "toggle_skill",callback_handler.toggle_skill) + +# Сигнатура любого обработчика +async def handle_xxx( + event: IncomingEvent, + session_mgr: SessionManager, + auth_mgr: AuthManager, + settings_mgr: SettingsManager, + platform: PlatformClient, +) -> list[OutgoingEvent]: + ... +``` + +**Правила маршрутизации:** +- `IncomingCommand` → ключ = `event.command` +- `IncomingCallback` → ключ = `event.action` +- `IncomingMessage` → ключ = тип первого вложения если есть (`"audio"`, `"image"`, ...), иначе `"*"` + +**Добавить новый обработчик** = новый файл в `handlers/` + одна строка `dispatcher.register(...)`. +`handler.py` не трогается. + +--- + +## Голосовые сообщения (voice slot) + +`IncomingMessage` несёт `Attachment(type="audio", ...)`. +Dispatcher маршрутизирует `IncomingMessage` с audio-вложением на `"audio"` ключ. + +**Fallback:** если ключ `"audio"` не зарегистрирован — dispatcher падает на `"*"` catch-all. +`message_handler` (catch-all) проверяет тип вложения и возвращает заглушку: +```python +OutgoingMessage(chat_id=..., text="Голосовые сообщения скоро поддержим.", parse_mode="plain") +``` + +Когда будет готова транскрипция — регистрируем `voice_handler` для ключа `"audio"`. +Dispatcher перестаёт отдавать audio в catch-all. `message_handler` не меняется. + +--- + +## protocol.py — модели данных + +Используем `dataclass` (не Pydantic) — это внутренний язык ядра, валидация не нужна: + +```python +@dataclass +class IncomingMessage: + user_id: str + platform: str + chat_id: str + text: str + attachments: list[Attachment] = field(default_factory=list) + reply_to: str | None = None + +@dataclass +class Attachment: + type: str # "image" | "document" | "audio" | "video" + url: str | None = None + content: bytes | None = None + filename: str | None = None + mime_type: str | None = None +``` + +Полный список структур: IncomingMessage, IncomingCommand, IncomingCallback, +OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping, UIButton, +Attachment, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired. + +--- + +## platform/interface.py — модели платформы + +Используем Pydantic — это граница с внешним API где валидация нужна: + +```python +class User(BaseModel): + user_id: str + external_id: str + platform: str + display_name: str | None = None + created_at: datetime + is_new: bool = False + +class Session(BaseModel): + session_id: str + agent_id: str + created_at: datetime + expires_at: datetime + +class PlatformClient(Protocol): + async def get_or_create_user(self, external_id: str, platform: str, display_name: str | None) -> User: ... + async def create_session(self, user_id: str, platform: str, context: dict | None) -> Session: ... + async def send_message(self, session_id: str, text: str, attachments: list) -> MessageResponse: ... + async def close_session(self, session_id: str) -> bool: ... + async def get_message_history(self, session_id: str, limit: int, offset: int) -> list[dict]: ... +``` + +--- + +## Managers + +Все три менеджера принимают зависимости через конструктор (DI), разделяют один `StateStore`. + +### StateStore (`core/store.py`) + +```python +class StateStore(Protocol): + async def get(self, key: str) -> dict | None + async def set(self, key: str, value: dict) -> None + async def delete(self, key: str) -> None +``` + +Реализации: `InMemoryStore` (тесты), `SQLiteStore` (прод). + +### SessionManager (`core/session.py`) + +```python +class SessionManager: + def __init__(self, platform: PlatformClient, store: StateStore): ... + async def get_or_create(self, user_id: str, chat_id: str) -> ChatContext: ... + async def close(self, chat_id: str) -> None: ... + async def list_active(self, user_id: str) -> list[ChatContext]: ... +``` + +### AuthManager (`core/auth.py`) + +```python +class AuthManager: + def __init__(self, platform: PlatformClient, store: StateStore): ... + async def start_flow(self, user_id: str, platform: str) -> AuthFlow: ... + async def confirm(self, user_id: str) -> AuthFlow: ... # в моке — автоматически + async def is_authenticated(self, user_id: str) -> bool: ... +``` + +### SettingsManager (`core/settings.py`) + +```python +class SettingsManager: + def __init__(self, platform: PlatformClient, store: StateStore): ... + async def apply(self, user_id: str, action: SettingsAction) -> None: ... + async def get(self, user_id: str) -> UserSettings: ... +``` + +--- + +## Тестирование + +| Слой | Что тестируем | Зависимости | +|------|--------------|-------------| +| `tests/core/` | обработчики, менеджеры, dispatcher | MockPlatformClient + InMemoryStore | +| `tests/adapter/telegram/` | конвертацию aiogram → IncomingEvent | без платформы | +| `tests/adapter/matrix/` | конвертацию matrix-nio → IncomingEvent | без платформы | +| `tests/platform/` | MockPlatformClient | без ядра | + +**Voice slot test:** `IncomingMessage` с `audio`-вложением при незарегистрированном `voice_handler` +возвращает `OutgoingMessage` с текстом-заглушкой. После регистрации — маршрутизируется корректно. + +--- + +## Принципы расширяемости + +1. **Новая команда** — новый файл `handlers/xxx.py` + `dispatcher.register(...)` +2. **Новый тип вложения** — новый ключ в register, остальное не меняется +3. **Новая поверхность** — новый `adapter/xxx/` с конвертером, core/ не трогается +4. **Замена mock на SDK** — только `platform/mock.py`, всё остальное не меняется