UI-движок
Зачем UI-движок
Заголовок раздела «Зачем UI-движок»В простом боте 3–5 кнопок легко обработать в одном on_callback. Но когда экранов десятки, появляется спагетти:
# ❌ Не масштабируется@bot.on_callbackasync def on_callback(ctx): data = ctx.callback_data if data == "profile:toggle": ... elif data == "profile:delete": ... elif data == "settings:theme": ... elif data == "settings:notify": ... elif data.startswith("admin:ban:"): ... # ...и ещё 50 elifUI-движок решает эту проблему через компоненты-экраны, каждый из которых отвечает только за свой набор кнопок.
MVC-подобная архитектура
Заголовок раздела «MVC-подобная архитектура»Модель адаптирована под stateless Cloudflare Workers:
| Роль | Реализация | Описание |
|---|---|---|
| Model | ctx.users, KVStore | Данные в KV |
| View | PromptRegistry, KeyboardRegistry | Фабрики текстов и клавиатур |
| Controller | UIComponent | Экраны с маршрутизацией callback |
Маршрутизация callback_data
Заголовок раздела «Маршрутизация callback_data»Структура callback_data
Заголовок раздела «Структура callback_data»{prefix}:{action}{prefix}:{action}:{param1}:{param2}Примеры:
"profile:toggle"— экранprofile, действиеtoggle"admin:ban:12345"— экранadmin, действиеbanс параметром12345
Компиляция паттернов
Заголовок раздела «Компиляция паттернов»При объявлении класса (__init_subclass__) SDK собирает все методы с @UIComponent.callback(pattern) и компилирует их в regex:
| Паттерн | prefix | Результат regex |
|---|---|---|
"toggle" | "prof" | ^prof:toggle$ |
"del:{user_id}" | "prof" | ^prof:del:(?P<user_id>[^:]+)$ |
"*" | "prof" | ^prof:.+$ |
Переменные {name} превращаются в именованные группы regex. Двоеточие внутри значения запрещено — это разделитель.
Порядок обработки
Заголовок раздела «Порядок обработки»callback_data = "profile:toggle" │ ▼Bot.process_update │ ▼ for component in _ui: component.handle_callback(ctx) │ ├── startswith("profile:") ✓ │ ├── route[0]: ^profile:toggle$ → MATCH │ └── вызов on_toggle(ctx) │ return True │ └── (если бы не подошло → fallback "*")Если ни один UIComponent не перехватил callback, он падает в классический @bot.on_callback.
Реестры
Заголовок раздела «Реестры»Зачем реестры
Заголовок раздела «Зачем реестры»Реестры отделяют что показать (View) от как реагировать (Controller):
┌──────────────────────┐ │ UIComponent │ │ (Controller) │ │ │ │ prompt_kwargs=kw ──────┐ │ markup_kwargs=kw ───┐ │ └──────────────────────┘ │ │ │ ┌───────┘ └──────┐ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ KeyboardRegistry │ │ PromptRegistry │ │ (фабрика → │ │ (фабрика → │ │ InlineKeyboard) │ │ Prompt) │ └──────────────────┘ └──────────────────┘Автофильтрация kwargs
Заголовок раздела «Автофильтрация kwargs»Один и тот же dict kw передаётся и в фабрику промпта, и в фабрику клавиатуры. Реестр автоматически фильтрует kwargs по сигнатуре фабрики:
# Промпт принимает: user_id, username, is_active@prompts.register("profile")def p_profile(user_id, username, is_active): ...
# Клавиатура принимает: is_active@keyboards.register("profile")def kb_profile(is_active): ...
# В экране передаём всё одним dict:kw = {"user_id": 123, "username": "john", "is_active": True}await self.send(ctx, prompt_kwargs=kw, markup_kwargs=kw)# Промпт получит: user_id, username, is_active# Клавиатура получит: is_activeЭто работает через inspect.signature с кэшированием. Если фабрика объявляет **kwargs — фильтр отключается.
Относительные и абсолютные callback_data
Заголовок раздела «Относительные и абсолютные callback_data»Фабрики клавиатур пишут относительные пути:
kb.button("Переключить", callback_data="toggle") # относительныйkb.button("🏠 Меню", callback_data="/menu:home") # абсолютныйПри build(prefix="profile"):
"toggle"→"profile:toggle"(относительный → добавляется prefix)"/menu:home"→"menu:home"(абсолютный → слэш срезается, prefix игнорируется)
Это позволяет:
- Кнопкам экрана маршрутизироваться на свой компонент без hardcode-а префикса
- Навигационным кнопкам указывать на другие экраны через абсолютный путь
Жизненный цикл экрана
Заголовок раздела «Жизненный цикл экрана» open(ctx) │ ▼ Загрузить данные из KV │ ▼ self.send(ctx, prompt_kwargs=kw, markup_kwargs=kw) │ ├── _resolve_prompt → Prompt ├── _resolve_markup → InlineKeyboard ├── _finalize → (text, reply_markup) └── ctx.send(text, reply_markup)
↓ Пользователь нажимает кнопку ↓
handle_callback(ctx) │ ▼ Определить метод по паттерну │ ▼ @UIComponent.callback("toggle") on_toggle(ctx) │ ▼ Изменить данные в KV │ ▼ self.update(ctx, prompt_kwargs=new_kw, markup_kwargs=new_kw) │ └── ctx.edit_message(new_text, new_reply_markup)