docs: add core design spec (Registry + Handlers approach)

This commit is contained in:
Mikhail Putilovskij 2026-03-28 23:52:05 +03:00
parent 5aa0ae9e25
commit 8e955045b2

View file

@ -0,0 +1,239 @@
# 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.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 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`)
```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` (прод).
### SessionManager (`core/session.py`)
```python
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`)
```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`, всё остальное не меняется