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