11 KiB
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.pysrc/mock_platform.py→platform/mock.pysrc/удалить
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.commandIncomingCallback→ ключ =event.actionIncomingMessage→ ключ = тип первого вложения если есть ("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 с текстом-заглушкой. После регистрации — маршрутизируется корректно.
Принципы расширяемости
- Новая команда — новый файл
handlers/xxx.py+dispatcher.register(...) - Новый тип вложения — новый ключ в register, остальное не меняется
- Новая поверхность — новый
adapter/xxx/с конвертером, core/ не трогается - Замена mock на SDK — только
platform/mock.py, всё остальное не меняется