surfaces/docs/superpowers/specs/2026-03-28-core-design.md

11 KiB
Raw Blame History

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.pyplatform/mock.py
  • src/ удалить

EventDispatcher

handler.py — тупой роутер. Маршрутизирует по двум ключам: тип события + команда/действие.

# Сигнатура регистрации
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) проверяет тип вложения и возвращает заглушку:

OutgoingMessage(chat_id=..., text="Голосовые сообщения скоро поддержим.", parse_mode="plain")

Когда будет готова транскрипция — регистрируем voice_handler для ключа "audio". Dispatcher перестаёт отдавать audio в catch-all. message_handler не меняется.


protocol.py — модели данных

Используем dataclass (не Pydantic) — это внутренний язык ядра, валидация не нужна:

@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 где валидация нужна:

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)

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'а.

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)

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)

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, всё остальное не меняется