# Core Design — surfaces-bot > **Статус:** approved > **Дата:** 2026-03-28 > **Подход:** Registry + Handlers (Подход 2) --- ## Контекст Два бота (Telegram, Matrix) разделяют общую бизнес-логику через `core/`. Цель — написать логику один раз, легко добавлять новые поверхности и новый функционал без правки центральных файлов. ### Архитектура платформы (важно для дизайна) Lambda Lab 3.0 выделяет каждому пользователю **один LXC-контейнер** с workspace 10 ГБ. Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — каждый чат хранит свои файлы и `history.db`. **Master** — управляющий процесс платформы — сам решает когда поднять, заморозить или разбудить контейнер. Бот не управляет lifecycle контейнера — он только передаёт сообщение (`user_id`, `chat_id`, `text`). Следствие: **"сессия" и "чат" — разные понятия.** - Чат (C1/C2/C3) — наша забота, храним метаданные в `StateStore` - Контейнер (сессия платформы) — забота Master'а, бот об этом не знает --- ## Структура файлов ``` 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 chat.py — ChatManager (бывший 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 PlatformClient(Protocol): async def get_or_create_user(self, external_id: str, platform: str, display_name: str | None) -> User: ... # Master сам решает: поднять контейнер, разбудить, смонтировать нужный чат. # Бот передаёт только user_id + chat_id — платформа делает остальное. async def send_message(self, user_id: str, chat_id: str, text: str, attachments: list) -> MessageResponse: ... async def get_settings(self, user_id: str) -> UserSettings: ... async def update_settings(self, user_id: str, action: SettingsAction) -> None: ... ``` **Нет явных create/close session** — lifecycle контейнера управляется Master'ом, не ботом. Когда будет готов реальный SDK Азамата — контракт уточняется, меняется только `platform/mock.py`. --- ## 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` (прод). ### ChatManager (`core/chat.py`) Управляет метаданными чатов (C1/C2/C3). НЕ управляет lifecycle контейнера — это дело Master'а. ```python class ChatManager: def __init__(self, platform: PlatformClient, store: StateStore): ... async def get_or_create(self, user_id: str, chat_id: str, name: str | None) -> ChatContext: ... async def rename(self, chat_id: str, name: str) -> ChatContext: ... async def archive(self, chat_id: str) -> None: ... async def list_active(self, user_id: str) -> list[ChatContext]: ... ``` Метаданные (display_name, platform, surface_ref, is_archived) хранятся в `StateStore`. Файлы чата и history.db живут в workspace контейнера на стороне платформы — бот их не хранит. ### 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`, всё остальное не меняется