UI-компоненты
UI-компоненты — это способ организовать сложных ботов в изолированные «экраны», каждый из которых отвечает за свой набор кнопок.
Зачем нужны UI-компоненты
Заголовок раздела «Зачем нужны UI-компоненты»В простых ботах хватает @bot.on_callback, но когда экранов становится много, начинаются проблемы:
- Все callback handler’ы в одном месте
- Ручной парсинг
callback_dataс if/elif - Нет изоляции между экранами
UIComponent решает это:
- Автоматическая маршрутизация — callback_data
"profile:toggle"попадает вProfileScreen, а"settings:theme"— вSettingsScreen - Изоляция — каждый экран знает только о своих кнопках
- Реестры — промпты и клавиатуры регистрируются декораторами
Минимальный пример
Заголовок раздела «Минимальный пример»from edgebot import UIComponent, Context
class HelloScreen(UIComponent): __prefix__ = "hello" text = "Привет! Нажми кнопку 👇" markup = "hello_main"
async def open(self, ctx: Context) -> None: await self.send(ctx)
@UIComponent.callback("greet") async def on_greet(self, ctx: Context) -> None: await ctx.answer_callback("Привет! 👋")Структура компонента
Заголовок раздела «Структура компонента»Каждый UIComponent задаёт:
| Атрибут | Назначение |
|---|---|
__prefix__ | Уникальный префикс для callback_data (обязательно) |
prompt | Ключ в PromptRegistry для текста сообщения |
markup | Ключ в KeyboardRegistry для клавиатуры |
text | Inline-шаблон текста (альтернатива prompt) |
Полный пример: экран профиля
Заголовок раздела «Полный пример: экран профиля»1. Промпт (текст экрана)
Заголовок раздела «1. Промпт (текст экрана)»from edgebot import Promptfrom . import registry
@registry.register("profile_main")def p_profile_main(user_id: int, username: str, is_active: bool) -> Prompt: status = "✅" if is_active else "❌" username_str = f"@{username}" if username else "—" return Prompt( "👤 Профиль\n\n" f"ID: {user_id}\n" f"Username: {username_str}\n" f"Активен: {status}" )2. Клавиатура
Заголовок раздела «2. Клавиатура»from edgebot import InlineKeyboardfrom . import registry
@registry.register("profile_main")def kb_profile_main(is_active: bool) -> InlineKeyboard: toggle_text = "❌ Выключить" if is_active else "✅ Включить" kb = InlineKeyboard() kb.button(toggle_text, callback_data="toggle") kb.row() kb.button("↺ Сбросить", callback_data="reset") kb.button("🗑 Удалить", callback_data="delete") return kb3. Экран (контроллер)
Заголовок раздела «3. Экран (контроллер)»from edgebot import Context, UIComponent
class ProfileScreen(UIComponent): __prefix__ = "profile" prompt = "profile_main" markup = "profile_main"
async def open(self, ctx: Context) -> None: user_id = ctx.from_user["id"] user = await ctx.users.get(user_id) if user is None: user = await ctx.users.create(user_id, {"is_active": True}) kw = {"user_id": user_id, **user} await self.send(ctx, prompt_kwargs=kw, markup_kwargs=kw)
@UIComponent.callback("toggle") async def on_toggle(self, ctx: Context) -> None: user_id = ctx.from_user["id"] user = await ctx.users.get(user_id) updated = await ctx.users.update( user_id, {"is_active": not user["is_active"]} ) kw = {"user_id": user_id, **updated} await self.update(ctx, prompt_kwargs=kw, markup_kwargs=kw) await ctx.answer_callback("Статус обновлён")
@UIComponent.callback("delete") async def on_delete(self, ctx: Context) -> None: await ctx.users.delete(ctx.from_user["id"]) await ctx.edit_message("Запись удалена. /start для регистрации.") await ctx.answer_callback("Удалено")4. Сборка бота
Заголовок раздела «4. Сборка бота»from edgebot import Bot, Context, UserRegistry, KeyboardRegistry, PromptRegistryfrom .keyboards import registry as kb_registryfrom .prompts import registry as pr_registryfrom .screens import ProfileScreen
def build_bot(env) -> Bot: profile = ProfileScreen() return ( Bot(env.BOT_TOKEN, parse_mode="Markdown") .with_users(UserRegistry(env.USERS)) .with_keyboards(kb_registry) .with_prompts(pr_registry) .register_ui(profile) )Маршрутизация callback_data
Заголовок раздела «Маршрутизация callback_data»Паттерны пишутся БЕЗ префикса. Поддерживаются параметры через {name}:
class AdminScreen(UIComponent): __prefix__ = "admin"
@UIComponent.callback("ban:{user_id}") async def on_ban(self, ctx: Context, user_id: str) -> None: # callback_data = "admin:ban:12345" → user_id = "12345" await ctx.answer_callback(f"Пользователь {user_id} заблокирован")
@UIComponent.callback("page:{num}") async def on_page(self, ctx: Context, num: str) -> None: # callback_data = "admin:page:2" → num = "2" ...Fallback-обработчик
Заголовок раздела «Fallback-обработчик»Паттерн "*" ловит все callback_data с нужным префиксом, не подошедшие под другие паттерны:
class MenuScreen(UIComponent): __prefix__ = "menu"
@UIComponent.callback("home") async def on_home(self, ctx: Context) -> None: ...
@UIComponent.callback("*") async def on_unknown(self, ctx: Context) -> None: # Сработает для "menu:xyz", "menu:foo:bar" и т.д., # но НЕ для "menu:home" (перехвачен выше) await ctx.answer_callback("Неизвестная команда")send vs update
Заголовок раздела «send vs update»| Метод | Действие | Когда использовать |
|---|---|---|
self.send() | Отправляет новое сообщение | open(), команды |
self.update() | Редактирует текущее сообщение | Callback-обработчики |
update() тихо проглатывает ошибку "message is not modified" — если текст и клавиатура не изменились, Telegram не жалуется.
Автофильтрация kwargs
Заголовок раздела «Автофильтрация kwargs»Один и тот же 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 (только он объявлен в её сигнатуре)