Перейти к содержимому

UI-движок

В простом боте 3–5 кнопок легко обработать в одном on_callback. Но когда экранов десятки, появляется спагетти:

# ❌ Не масштабируется
@bot.on_callback
async 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 elif

UI-движок решает эту проблему через компоненты-экраны, каждый из которых отвечает только за свой набор кнопок.

Модель адаптирована под stateless Cloudflare Workers:

РольРеализацияОписание
Modelctx.users, KVStoreДанные в KV
ViewPromptRegistry, KeyboardRegistryФабрики текстов и клавиатур
ControllerUIComponentЭкраны с маршрутизацией callback
{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) │
└──────────────────┘ └──────────────────┘

Один и тот же 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 — фильтр отключается.

Фабрики клавиатур пишут относительные пути:

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)