docs: add core design spec (Registry + Handlers approach)
This commit is contained in:
parent
5aa0ae9e25
commit
8e955045b2
1 changed files with 239 additions and 0 deletions
239
docs/superpowers/specs/2026-03-28-core-design.md
Normal file
239
docs/superpowers/specs/2026-03-28-core-design.md
Normal 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`, всё остальное не меняется
|
||||||
Loading…
Add table
Add a link
Reference in a new issue