Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Inline-клавиатуры и callback data
inline_keyboards_callbacks

Inline-клавиатуры и callback data

Callback factories, pagination, динамические клавиатуры, обработка нажатий

Inline-клавиатуры и callback data

Проблема: Пользователь не понимает, что делать после команды. Текстовые команды вроде /menu или /profile неудобны на мобильных устройствах. Нужно сделать интерфейс интуитивным.

Решение: Inline-клавиатуры — кнопки прикреплены к сообщению, поддерживают callback для обратной связи без отправки сообщений.

Зачем это нужно: Правильный UX через клавиатуры увеличивает конверсию на 30-50%. Пользователь видит доступные действия и не гадает, какие команды вводить.

#Inline vs Reply клавиатуры

Проблема: Когда использовать 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-клавиатуры можно обновлять вместе с сообщением.

#Callback Data

Проблема: Пользователь нажал кнопку. Как понять, что именно он выбрал, и передать дополнительные данные (ID заказа, действие)?

Решение: callback_data — строка до 64 байт, передаваемая при нажатии кнопки.

#Ограничения

ОграничениеПочемуКак обойти
Максимум 64 байтаОграничение Telegram APICallbackData 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 Factory

Проблема: Нужно передать в 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 — см. ниже.

#CallbackData с 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 поля должны быть в конце. Иначе парсинг сломается.

#CallbackData с enum

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-режим (Inline Query)

Проблема: Пользователь хочет найти товар и отправить его другу в чат. Копировать название и искать в боте неудобно.

Решение: Inline Query — поиск контента прямо в строке ввода Telegram.

#Включение inline-режима

  1. В @BotFather: /setinline → выбрать бота
  2. Настроить placeholder (например, "Введите название товара")

#Обработчик inline-запросов

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

Проблема: Пользователь хочет поделиться товаром с другом, но друг не использует бота.

Решение: 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('Клавиатура удалена')

⚠️ Антипаттерн: Не удаляйте клавиатуру, если пользователь может захотеть повторить действие.

#Best Practices

#Валидация callback_data

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}')

#Rate limiting для callback

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.

#Итоги

#✅ Чек-лист: готовы ли ваши клавиатуры к продакшену?

  • CallbackData используется для типизированных callback
  • Пагинация через LIMIT/OFFSET в БД (не загрузка всех данных)
  • cb.answer() вызывается после обработки callback
  • Клавиатуры обновляются через edit_text/edit_reply_markup
  • Валидация callback_data в обработчиках
  • Rate limiting для частых callback
  • Inline-клавиатуры для контекстных действий
  • Reply-клавиатуры только для главного меню

#🔗 Связь с другими темами

ТемаКак связана
FSMСостояния для многошаговых форм с клавиатурами
АрхитектураКлавиатуры вынесены в bot/keyboards/
БезопасностьВалидация callback_data
МасштабированиеКэширование данных для клавиатур

#📚 Что дальше?

→ FSM — многошаговые формы с навигацией
→ Архитектура — модульная структура keyboards
→ Тестирование — тесты callback-обработчиков

Далее: Webhook и деплой