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

9.4 KiB
Raw Blame History

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

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)

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)

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