Callback factories, pagination, динамические клавиатуры, обработка нажатий
Проблема: Пользователь не понимает, что делать после команды. Текстовые команды вроде
/menuили/profileнеудобны на мобильных устройствах. Нужно сделать интерфейс интуитивным.Решение: Inline-клавиатуры — кнопки прикреплены к сообщению, поддерживают callback для обратной связи без отправки сообщений.
Зачем это нужно: Правильный UX через клавиатуры увеличивает конверсию на 30-50%. Пользователь видит доступные действия и не гадает, какие команды вводить.
Проблема: Когда использовать inline, а когда reply?
| Критерий | Reply-клавиатуры | Inline-клавиатуры |
|---|---|---|
| Расположение | Над полем ввода | Прикреплены к сообщению |
| Callback | Нет (отправляют текст) | Да (скрытый callback_data) |
| URL-ссылки | Нет | Да |
| Когда использовать | Главное меню, быстрые команды | Меню действий, навигация, выбор |
| Видимость | Всегда видны | Только с сообщением |
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
# Reply — для главного меню
# 💡 Зачем: Пользователь всегда видит основные команды
reply_kb = ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text='Профиль'), KeyboardButton(text='Заказы')],
[KeyboardButton(text='Помощь')]
],
resize_keyboard=True # 💡 Зачем: Клавиатура не занимает пол-экрана
)
# Inline — для действий в контексте
# 💡 Зачем: Кнопки релевантны текущему сообщению
inline_kb = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='Профиль', callback_data='profile')],
[InlineKeyboardButton(text='Заказы', callback_data='orders')],
[InlineKeyboardButton(text='Помощь', url='https://t.me/bot')]
]
)⚠️ Антипаттерн: Не используйте reply-клавиатуры для действий, которые меняются в зависимости от контекста. Inline-клавиатуры можно обновлять вместе с сообщением.
Проблема: Пользователь нажал кнопку. Как понять, что именно он выбрал, и передать дополнительные данные (ID заказа, действие)?
Решение: callback_data — строка до 64 байт, передаваемая при нажатии кнопки.
| Ограничение | Почему | Как обойти |
|---|---|---|
| Максимум 64 байта | Ограничение Telegram API | CallbackData factory |
| Только строка | Протокол Telegram | Сериализация в строку |
| Нужно парсить самостоятельно | Telegram не интерпретирует | CallbackData классы |
from aiogram import Router, F
from aiogram.types import CallbackQuery, Message
router = Router()
@router.message(F.text == 'Меню')
async def show_menu(msg: Message):
"""
Показать меню с inline-кнопками.
💡 Зачем: Inline-клавиатура не засоряет чат —
кнопки исчезают после выбора.
"""
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='Заказы', callback_data='menu_orders')],
[InlineKeyboardButton(text='Профиль', callback_data='menu_profile')],
[InlineKeyboardButton(text='Настройки', callback_data='menu_settings')]
]
)
await msg.answer('Выберите раздел:', reply_markup=keyboard)
@router.callback_query(F.data == 'menu_orders')
async def orders_callback(cb: CallbackQuery):
"""
Обработчик нажатия кнопки 'Заказы'.
⚠️ Важно: Всегда вызывайте cb.answer() после обработки callback.
Иначе пользователь увидит бесконечный "часы" в кнопке.
"""
await cb.message.edit_text('📦 Раздел заказов')
await cb.answer() # 💡 Зачем: Уведомление Telegram об обработке
@router.callback_query(F.data == 'menu_profile')
async def profile_callback(cb: CallbackQuery):
await cb.message.edit_text('👤 Ваш профиль')
await cb.answer()⚠️ Антипаттерн: Не используйте
cb.message.answer()для отправки нового сообщения. Это отправит уведомление, а не сообщение. Для нового сообщения используйтеcb.message.answer('текст')илиcb.answer('текст', show_alert=True).
Проблема: Нужно передать в callback_data ID товара (123) и действие (view). Строка 'view_123' требует ручного парсинга. Ошибка в парсинге = баг.
Решение: CallbackData — типизированный способ генерации и парсинга callback_data.
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
class ProductCallback(CallbackData, prefix='product'):
"""
Factory для callback данных товара.
💡 Зачем:
- Автоматическая генерация строки
- Типизированный парсинг
- Валидация данных
"""
id: int
action: str # 'view', 'edit', 'delete'
# Генерация callback_data
callback = ProductCallback(id=123, action='view')
data = callback.pack() # 'product:123:view'
# Парсинг
parsed = ProductCallback.unpack(data) # ProductCallback(id=123, action='view')
# Создание клавиатуры
def build_product_keyboard(product_id: int) -> InlineKeyboardMarkup:
"""
Построить клавиатуру для товара.
💡 Зачем: Функция переиспользуется в разных обработчиках.
"""
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='👁 Просмотр',
callback_data=ProductCallback(id=product_id, action='view').pack()
),
InlineKeyboardButton(
text='✏️ Редактировать',
callback_data=ProductCallback(id=product_id, action='edit').pack()
)
],
[
InlineKeyboardButton(
text='🗑 Удалить',
callback_data=ProductCallback(id=product_id, action='delete').pack()
)
]
]
)
# Обработчик
@router.callback_query(ProductCallback.filter())
async def product_action(cb: CallbackQuery, callback_data: ProductCallback):
"""
Обработчик действий с товаром.
💡 Зачем filter(): aiogram автоматически распарсит callback_data
и передаст в callback_data параметр.
"""
product_id = callback_data.id
action = callback_data.action
if action == 'view':
await cb.message.edit_text(f'Просмотр продукта {product_id}')
elif action == 'edit':
await cb.message.edit_text(f'Редактирование продукта {product_id}')
elif action == 'delete':
await cb.message.edit_text(f'Удаление продукта {product_id}')
await cb.answer()⚠️ Антипаттерн: Не используйте сложные типы в CallbackData. Только int, str, float. Для enum и optional — см. ниже.
from typing import Optional
class OrderCallback(CallbackData, prefix='order'):
"""
Callback с необязательными полями.
💡 Зачем: page нужен только для пагинации.
"""
id: int
action: str
page: Optional[int] = None # Необязательное поле
# С page
callback = OrderCallback(id=456, action='items', page=3).pack()
# 'order:456:items:3'
# Без page
callback = OrderCallback(id=456, action='items').pack()
# 'order:456:items:'
@router.callback_query(OrderCallback.filter())
async def order_handler(cb: CallbackQuery, callback_data: OrderCallback):
page = callback_data.page # Может быть None
...⚠️ Важно: Optional поля должны быть в конце. Иначе парсинг сломается.
from enum import Enum, auto
class ProductAction(str, Enum):
"""
Enum для действий с товаром.
💡 Зачем:
- Автодополнение в IDE
- Валидация значений
- Защита от опечаток
"""
VIEW = 'view'
EDIT = 'edit'
DELETE = 'delete'
ADD_TO_CART = 'add_to_cart'
class ProductCallback(CallbackData, prefix='product'):
id: int
action: ProductAction
# Использование
callback = ProductCallback(id=123, action=ProductAction.VIEW).pack()
@router.callback_query(ProductCallback.filter())
async def product_handler(cb: CallbackQuery, callback_data: ProductCallback):
action = callback_data.action # ProductAction.VIEW
if action == ProductAction.VIEW:
...💡 Совет: Используйте
str, Enumдля совместимости с сериализацией CallbackData.
Проблема: Список товаров не помещается в одно сообщение. Нужно разбить на страницы с навигацией.
Решение: CallbackData для навигации + перерисовка сообщения.
from math import ceil
class PaginationCallback(CallbackData, prefix='page'):
"""
Callback для навигации по страницам.
💡 Зачем: Сохраняем контекст (entity, per_page) для восстановления состояния.
"""
entity: str # 'products', 'orders', 'users'
page: int
per_page: int = 10
def build_paginated_keyboard(
current_page: int,
total_items: int,
per_page: int = 10,
entity: str = 'items'
) -> InlineKeyboardMarkup:
"""
Построить клавиатуру с пагинацией.
💡 Зачем:
- Кнопки назад/вперёд только когда нужны
- Номер страницы для ориентации
"""
total_pages = ceil(total_items / per_page)
keyboard = []
nav_row = []
# Кнопка "Назад"
# 💡 Зачем: Не показываем, если первая страница
if current_page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=PaginationCallback(
entity=entity,
page=current_page - 1,
per_page=per_page
).pack()
)
)
# Номер страницы
# 💡 Зачем: Пользователь видит, где находится
nav_row.append(
InlineKeyboardButton(
text=f'{current_page}/{total_pages}',
callback_data='page_info' # Инфо, не навигация
)
)
# Кнопка "Вперёд"
# 💡 Зачем: Не показываем, если последняя страница
if current_page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=PaginationCallback(
entity=entity,
page=current_page + 1,
per_page=per_page
).pack()
)
)
keyboard.append(nav_row)
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@router.callback_query(PaginationCallback.filter())
async def paginate_handler(cb: CallbackQuery, callback_data: PaginationCallback):
"""
Обработчик навигации по страницам.
💡 Зачем: edit_text обновляет сообщение вместо нового —
не засоряет чат.
"""
page = callback_data.page
per_page = callback_data.per_page
entity = callback_data.entity
# Получаем данные из БД
offset = (page - 1) * per_page
items = await get_items(entity, limit=per_page, offset=offset)
total = await get_total_count(entity)
text = f'📄 Страница {page}\n\n'
for item in items:
text += f'• {item.name}\n'
keyboard = build_paginated_keyboard(
current_page=page,
total_items=total,
per_page=per_page,
entity=entity
)
await cb.message.edit_text(text, reply_markup=keyboard)
await cb.answer()⚠️ Антипаттерн: Не загружайте все данные сразу для пагинации. Используйте LIMIT/OFFSET в БД.
from aiogram.utils.keyboard import InlineKeyboardBuilder
class ItemCallback(CallbackData, prefix='item'):
"""Callback для выбора элемента."""
id: int
def build_items_keyboard(items: list, page: int = 1, per_page: int = 10) -> InlineKeyboardMarkup:
"""
Построить клавиатуру с элементами и пагинацией.
💡 Зачем InlineKeyboardBuilder:
- Динамическое количество кнопок
- Удобное управление рядами
"""
builder = InlineKeyboardBuilder()
# Кнопки элементов
for item in items:
builder.button(
text=item.name,
callback_data=ItemCallback(id=item.id).pack()
)
# Настройка сетки
# 💡 Зачем: per_page кнопок в ряду
builder.adjust(per_page)
# Навигация
nav_buttons = []
if page > 1:
nav_buttons.append(
InlineKeyboardButton(
text='⬅️ Назад',
callback_data=PaginationCallback(entity='items', page=page - 1).pack()
)
)
if has_next_page(page):
nav_buttons.append(
InlineKeyboardButton(
text='Вперёд ➡️',
callback_data=PaginationCallback(entity='items', page=page + 1).pack()
)
)
if nav_buttons:
builder.row(*nav_buttons)
return builder.as_markup()
@router.callback_query(ItemCallback.filter())
async def item_selected(cb: CallbackQuery, callback_data: ItemCallback):
"""
Обработчик выбора элемента.
💡 Зачем: Детальный просмотр после выбора из списка.
"""
item_id = callback_data.id
item = await get_item_by_id(item_id)
await cb.message.edit_text(f'Выбран: {item.name}\n{item.description}')
await cb.answer()💡 Совет: Используйте
builder.adjust()для адаптивной сетки кнопок.
Проблема: Клавиатура должна меняться в зависимости от состояния (статус заказа, фильтры).
Решение: Функции строят клавиатуру на основе данных.
from bot.repositories.order import OrderRepository
from bot.models.order import OrderStatus
async def build_order_status_keyboard(order_id: int, current_status: str) -> InlineKeyboardMarkup:
"""
Клавиатура со статусами заказа.
💡 Зачем:
- Не показываем текущий статус
- Только доступные переходы
"""
builder = InlineKeyboardBuilder()
statuses = [
(OrderStatus.PENDING.value, '⏳ Ожидает'),
(OrderStatus.PAID.value, '💳 Оплачен'),
(OrderStatus.SHIPPED.value, '📦 Отправлен'),
(OrderStatus.DELIVERED.value, '✅ Доставлен'),
(OrderStatus.CANCELLED.value, '❌ Отменён')
]
for status_value, status_label in statuses:
# ⚠️ Важно: Не показываем текущий статус
if status_value == current_status:
continue
builder.button(
text=status_label,
callback_data=OrderCallback(id=order_id, action='set_status', new_status=status_value).pack()
)
builder.adjust(2) # 💡 Зачем: 2 кнопки в ряду для компактности
return builder.as_markup()
class OrderCallback(CallbackData, prefix='order'):
id: int
action: str
new_status: Optional[str] = None
@router.callback_query(OrderCallback.filter(F.action == 'set_status'))
async def set_order_status(
cb: CallbackQuery,
callback_data: OrderCallback,
order_repo: OrderRepository
):
"""
Обработчик изменения статуса заказа.
💡 Зачем: edit_reply_markup обновляет только клавиатуру —
быстрее и экономит трафик.
"""
order_id = callback_data.id
new_status = callback_data.new_status
order = await order_repo.get_by_id(order_id)
if not order:
await cb.answer('Заказ не найден', show_alert=True)
return
await order_repo.update(order, status=new_status)
# Обновляем клавиатуру
keyboard = await build_order_status_keyboard(order_id, new_status)
await cb.message.edit_reply_markup(reply_markup=keyboard)
await cb.answer(f'Статус изменён на {new_status}')class FilterCallback(CallbackData, prefix='filter'):
"""
Callback для фильтров.
💡 Зачем: Сохраняем состояние фильтров для восстановления.
"""
category: Optional[str] = None
min_price: Optional[float] = None
max_price: Optional[float] = None
sort: str = 'popular' # 'popular', 'price_asc', 'price_desc'
async def build_filter_keyboard(current_filter: FilterCallback) -> InlineKeyboardMarkup:
"""
Клавиатура с фильтрами.
💡 Зачем:
- Показываем активный фильтр (✅)
- Быстрое переключение
"""
builder = InlineKeyboardBuilder()
# Категории
categories = ['Электроника', 'Одежда', 'Дом', 'Спорт']
for cat in categories:
is_active = current_filter.category == cat
text = f'✅ {cat}' if is_active else cat
builder.button(
text=text,
callback_data=FilterCallback(
category=cat if not is_active else None,
sort=current_filter.sort
).pack()
)
builder.adjust(2)
# Сортировка
sort_buttons = [
('popular', '🔥 Популярное'),
('price_asc', '💰 Дешёвые'),
('price_desc', '💰 Дорогие')
]
for sort_value, sort_label in sort_buttons:
is_active = current_filter.sort == sort_value
text = f'✅ {sort_label}' if is_active else sort_label
builder.button(
text=text,
callback_data=FilterCallback(
category=current_filter.category,
sort=sort_value
).pack()
)
builder.adjust(3)
# Кнопка "Применить"
builder.row(
InlineKeyboardButton(
text='Применить фильтры',
callback_data='apply_filters'
)
)
return builder.as_markup()⚠️ Антипаттерн: Не храните состояние фильтров только в callback_data. Для сложных фильтров используйте FSM или кэш в Redis.
Проблема: Пользователь хочет найти товар и отправить его другу в чат. Копировать название и искать в боте неудобно.
Решение: Inline Query — поиск контента прямо в строке ввода Telegram.
/setinline → выбрать ботаfrom aiogram.types import (
InlineQuery,
InputTextMessageContent,
InlineQueryResultArticle
)
@router.inline_query()
async def inline_query_handler(query: InlineQuery):
"""
Обработчик inline-запроса.
💡 Зачем:
- Пользователь ищет, не заходя в бота
- Может отправить результат в любой чат
"""
search_text = query.query
# Поиск товаров
products = await search_products(search_text)
results = []
for product in products[:50]: # ⚠️ Максимум 50 результатов
results.append(
InlineQueryResultArticle(
id=str(product.id),
title=product.name,
description=f'{product.price} ₽',
input_message_content=InputTextMessageContent(
message_text=f'🛒 {product.name}\nЦена: {product.price} ₽'
),
thumbnail_url=product.image_url
)
)
await query.answer(results, cache_time=60, is_personal=True)💡 Зачем cache_time: Кэширование результатов снижает нагрузку на БД.
is_personal=True— кэш для конкретного пользователя.
from aiogram.types import InlineQueryResultPhoto
@router.inline_query(F.query.func(lambda x: x.startswith('photo')))
async def inline_photo_handler(query: InlineQuery):
"""
Inline-результаты с фото.
💡 Зачем: Визуальный контент лучше продаёт.
"""
photos = await get_photos(query.query.replace('photo ', ''))
results = []
for photo in photos[:10]:
results.append(
InlineQueryResultPhoto(
id=str(photo.id),
photo_url=photo.url,
thumb_url=photo.thumb_url,
caption=photo.caption,
parse_mode='HTML'
)
)
await query.answer(results, cache_time=300)Проблема: Пользователь хочет поделиться товаром с другом, но друг не использует бота.
Решение: Switch Inline Query — кнопка "Поделиться" переключает на inline-режим.
from aiogram.types import InlineQueryResultArticle, InputTextMessageContent
@router.inline_query()
async def shareable_query(query: InlineQuery):
"""
Inline-запрос с возможностью поделиться.
💡 Зачем:
- viral-эффект (друг видит бота)
- упрощённый онбординг
"""
user_id = query.from_user.id
user = await get_user(user_id)
results = [
InlineQueryResultArticle(
id='share_profile',
title='Поделиться профилем',
input_message_content=InputTextMessageContent(
message_text=f'👤 Мой профиль: {user.username}'
),
description='Отправить в чат',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[
InlineKeyboardButton(
text='Открыть бот',
url=f'https://t.me/{bot_username}'
)
]]
)
)
]
await query.answer(
results,
cache_time=0,
switch_pm_text='Нажмите чтобы поделиться',
switch_pm_parameter='share'
)import asyncio
@router.callback_query(F.data == 'refresh_data')
async def refresh_data(cb: CallbackQuery):
"""
Обновление данных по кнопке.
💡 Зачем: Пользователь видит актуальные данные без новой команды.
"""
# Имитация загрузки
await cb.answer('Обновляю...')
await asyncio.sleep(1)
# Получаем свежие данные
data = await get_fresh_data()
# Обновляем сообщение и клавиатуру
await cb.message.edit_text(
text=f'📊 Данные обновлены\n{data}',
reply_markup=await build_data_keyboard(data)
)@router.callback_query(F.data == 'remove_keyboard')
async def remove_keyboard(cb: CallbackQuery):
"""
Удаление клавиатуры после выбора.
💡 Зачем:
- Кнопки не мешают
- Чистый интерфейс
"""
await cb.message.edit_reply_markup(reply_markup=None)
await cb.answer('Клавиатура удалена')⚠️ Антипаттерн: Не удаляйте клавиатуру, если пользователь может захотеть повторить действие.
from aiogram.exceptions import TelegramBadRequest
@router.callback_query(ItemCallback.filter())
async def safe_item_handler(cb: CallbackQuery, callback_data: ItemCallback):
"""
Безопасная обработка callback.
⚠️ Проблема: Сообщение может быть удалено пользователем
пока обрабатывается callback.
"""
try:
item = await get_item(callback_data.id)
if not item:
await cb.answer('Товар не найден', show_alert=True)
return
await cb.message.edit_text(f'{item.name}')
await cb.answer()
except TelegramBadRequest as e:
# 💡 Зачем: Логирование без падения бота
logger.warning(f'Cannot edit message: {e}')from collections import defaultdict
import asyncio
callback_locks = defaultdict(asyncio.Lock)
@router.callback_query(PaginationCallback.filter())
async def rate_limited_paginate(cb: CallbackQuery, callback_data: PaginationCallback):
"""
Защита от спама кликами по пагинации.
💡 Зачем: Пользователь не может нажать 10 раз за секунду.
"""
user_id = cb.from_user.id
async with callback_locks[user_id]:
await paginate_handler(cb, callback_data)⚠️ Антипаттерн: Не обрабатывайте callback без валидации. Пользователь может отправить произвольный callback_data.
| Тема | Как связана |
|---|---|
| FSM | Состояния для многошаговых форм с клавиатурами |
| Архитектура | Клавиатуры вынесены в bot/keyboards/ |
| Безопасность | Валидация callback_data |
| Масштабирование | Кэширование данных для клавиатур |
→ FSM — многошаговые формы с навигацией
→ Архитектура — модульная структура keyboards
→ Тестирование — тесты callback-обработчиков
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.