Inline-кнопки и callback
Inline-кнопки живут прямо под сообщением бота и при нажатии шлют ему скрытый сигнал — callback, который ты ловишь отдельным хэндлером и обязательно подтверждаешь через answer().
Inline-кнопка — кнопка прямо под сообщением; при нажатии она шлёт боту callback, а не текст в чат.
Зачем тебе это нужно
В прошлом уроке «Reply-клавиатуры» наш «Цыплёнок-помощник» научился показывать кнопки внизу экрана. Но у reply-кнопок есть слабое место: при нажатии они просто отправляют в чат обычный текст. Представь опросник «Какой жанр музыки тебе нравится?» — после нажатия чат заполняется сообщениями «Рок», «Рэп», «Поп», и переписка превращается в кашу. А ещё reply-кнопка не умеет жить под конкретным сообщением: она висит внизу для всего чата сразу.
А теперь вспомни любого нормального бота — например, бота с викторинами или голосовалкой в чате клана. Кнопки там прикреплены прямо к сообщению, нажатие не засоряет чат, а само сообщение может на лету меняться: «Голосов за рок: 12». Это и есть inline-кнопки. Вот к чему мы придём в этом уроке:
Бот: Какая музыка тебе по душе? 🎧
[ 🎸 Рок ] [ 🎤 Рэп ]
[ 🎹 Поп ] [ 🎼 Классика ]
(ты нажимаешь «🎸 Рок» — в чат ничего не падает,
вверху всплывает короткое уведомление)
Бот: Отличный выбор — рок! 🤘 Записал твой голос.Результат: в чате под сообщением бота появятся четыре кнопки в две строки. При нажатии на любую из них чат не засоряется текстом, а бот реагирует: показывает всплывающее уведомление и/или меняет своё сообщение.
Чтобы так сделать, нужно понять четыре вещи: как собрать inline-клавиатуру, что писать в callback_data каждой кнопки, как поймать нажатие отдельным хэндлером и зачем в конце обязательно звать callback.answer(). Разберём всё по порядку.
Inline-кнопка — это не записка, а нажатие звонка
Сравни две ситуации. Reply-кнопка — как если бы ты на стикере написал слово «Рок» и кинул официанту на стол: на столе теперь лежит твоя записка, её все видят, и таких записок может накопиться гора. Именно так reply-кнопка засоряет чат обычным текстом.
Inline-кнопка работает иначе. Это как кнопка вызова официанта на столике в кафе: ты её нажал, на кухне у официанта тихо загорелась лампочка с номером твоего столика — и всё. В зале ничего не изменилось, никаких записок. Этот тихий сигнал «нажали кнопку номер такой-то за столиком таким-то» в Telegram называется callback (или callback_query).
Callback — скрытый сигнал, который бот получает при нажатии inline-кнопки и обрабатывает отдельным хэндлером.
А что за «номер кнопки» приходит вместе с сигналом? Это callback_data — короткая строка-метка, которую ты сам заранее привязываешь к кнопке. Когда пользователь жмёт кнопку, Telegram присылает боту этот callback вместе с твоей меткой. Бот смотрит на метку и понимает, что именно нажали. То есть callback_data — это как номер на лампочке официанта: «нажали столик 7», «нажали кнопку „рок“».
Пример 1: собираем клавиатуру через InlineKeyboardBuilder
Кнопки можно описывать вручную, но в aiogram 3.x для этого есть удобный помощник — InlineKeyboardBuilder (строитель клавиатуры). Думай о нём как о подносе, на который ты по одной выкладываешь кнопки, а в конце говоришь «готово» и получаешь собранную клавиатуру.
Добавим нашему Цыплёнку команду /music, которая показывает опрос про жанры. Имена оставляем прежние: объект bot, диспетчер dp, файл bot.py, токен из переменной окружения.
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
router = Router()
@router.message(Command("music"))
async def cmd_music(message: Message):
builder = InlineKeyboardBuilder()
builder.button(text="🎸 Рок", callback_data="genre_rock")
builder.button(text="🎤 Рэп", callback_data="genre_rap")
builder.button(text="🎹 Поп", callback_data="genre_pop")
builder.button(text="🎼 Классика", callback_data="genre_classic")
builder.adjust(2) # по 2 кнопки в ряд
await message.answer(
"Какая музыка тебе по душе? 🎧",
reply_markup=builder.as_markup(),
)Результат: в чате на команду /music бот пришлёт сообщение «Какая музыка тебе по душе? 🎧», а под ним — четыре inline-кнопки, разложенные по две в ряд. Нажатия пока ничего не делают: ловить их мы научимся в следующем примере.
Разберём по шагам:
builder = InlineKeyboardBuilder()— берём пустой «поднос» для кнопок.builder.button(text=..., callback_data=...)— кладём кнопку.text— что пользователь видит на кнопке,callback_data— та самая скрытая метка, которая придёт боту при нажатии. Каждая метка уникальна:genre_rock,genre_rapи так далее.builder.adjust(2)— раскладываем кнопки по 2 в ряд. Без этой строки aiogram поставит каждую кнопку на отдельную строку — получится длинный столбик.builder.as_markup()— говорим «поднос готов» и получаем объект клавиатуры.reply_markup=builder.as_markup()— прикрепляем клавиатуру к сообщению. Именноreply_markupотвечает за то, чтобы кнопки появились под текстом.
Запомни главное правило про callback_data: это строка, она не должна быть длиннее 64 байт, и придумываешь её ты сам. Удобно делать метки «говорящими» и с префиксом: genre_rock, page_2, delete_5. Префикс (genre_) потом поможет понять, к какой группе кнопок относится нажатие.
Пример 2: ловим нажатие хэндлером на callback_query
Клавиатуру показали — теперь научимся реагировать на нажатия. Помнишь, в уроке про хэндлеры мы ловили сообщения через @router.message(...)? Для нажатий inline-кнопок есть отдельный декоратор — @router.callback_query(...). Это как нанять отдельного официанта, который следит только за лампочками вызова, а не за столиками с едой.
from aiogram import F, Router
from aiogram.types import CallbackQuery
router = Router()
GENRE_NAMES = {
"genre_rock": "рок 🤘",
"genre_rap": "рэп 🎤",
"genre_pop": "поп 🎹",
"genre_classic": "классику 🎼",
}
@router.callback_query(F.data.startswith("genre_"))
async def on_genre(callback: CallbackQuery):
genre = GENRE_NAMES.get(callback.data, "что-то загадочное")
await callback.message.answer(f"Отличный выбор — {genre} Записал твой голос.")
await callback.answer() # подтверждаем нажатиеРезультат: в чате после нажатия любой из кнопок жанра бот пришлёт сообщение вроде «Отличный выбор — рок 🤘 Записал твой голос.», а «часики» на кнопке погаснут. Если нажать «🎸 Рок», подставится «рок», если «🎤 Рэп» — «рэп», и так далее.
Что здесь происходит:
@router.callback_query(...)— декоратор для нажатий inline-кнопок. Работает по той же логике, что и@router.message(...), только ловит не сообщения, а callback'и.F.data.startswith("genre_")— фильтр. У нажатия inline-кнопки текст лежит не вF.text, а вF.data— это и есть нашаcallback_data. Фильтр срабатывает на все метки, начинающиеся сgenre_. Вот зачем нужен был префикс: одним хэндлером ловим сразу все четыре кнопки.callback: CallbackQuery— в хэндлер прилетает неMessage, а объектCallbackQuery. Внутри него:callback.data(метка нажатой кнопки),callback.from_user(кто нажал) иcallback.message(то сообщение, под которым была кнопка).callback.message.answer(...)— отправляем новое сообщение в тот же чат. Обрати внимание: пишемcallback.message.answer, а не простоcallback.answer— это разные вещи (про вторую — ниже).await callback.answer()— подтверждаем, что нажатие обработано. Без этой строки на кнопке будут крутиться «часики». О ней — отдельный, очень важный разговор.
Зачем нужен callback.answer()
Это та деталь, на которой спотыкаются почти все новички, поэтому остановимся подробно.
Когда пользователь жмёт inline-кнопку, Telegram показывает на ней маленький индикатор загрузки — крутящиеся «часики». Telegram как бы спрашивает у бота: «Я передал тебе нажатие, ты его принял?» Пока бот не ответит, часики крутятся, и у пользователя ощущение, будто бот завис. Через несколько секунд они погаснут сами, но осадочек останется.
Метод callback.answer() — это и есть ответ боту: «да, принял, всё хорошо». Часики тут же гаснут. Поэтому правило железное: в каждом хэндлере на callback_query в конце вызывай await callback.answer().
А ещё answer() умеет показывать пользователю короткое уведомление — то самое, что всплывает вверху экрана или серым облачком:
@router.callback_query(F.data == "genre_rock")
async def rock_fans(callback: CallbackQuery):
# show_alert=True — покажет окошко с кнопкой «ОК»
await callback.answer("🤘 Рок-банда, привет!", show_alert=True)Результат: в чате при нажатии «🎸 Рок» поверх экрана всплывёт окошко с текстом «🤘 Рок-банда, привет!» и кнопкой «ОК», а часики на кнопке погаснут. В чат при этом ничего не добавляется.
Итого у callback.answer() две роли: обязательная (погасить часики) и приятная (показать всплывашку). Текст внутри — необязательный; await callback.answer() без аргументов тоже гасит часики, просто молча. По умолчанию уведомление всплывает маленьким серым облачком вверху и само исчезает через пару секунд — это хорошо для коротких подтверждений вроде «Голос учтён». А вот show_alert=True делает из него полноценное окошко с кнопкой «ОК», которое пользователь должен закрыть сам, — так показывают что-то важное, что нельзя пропустить. Выбирай по ситуации: для лёгкого «ок, принято» хватит обычного облачка, для предупреждения «ты уже голосовал» лучше алерт.
Пример 3: меняем сообщение прямо на месте
Самое крутое в inline-кнопках — сообщение можно переписать на лету, прямо там, где оно есть. Никаких новых сообщений, чат остаётся чистым. Для этого у callback.message есть метод edit_text. Сделаем так, чтобы после выбора жанра кнопки исчезали, а текст менялся на подтверждение:
@router.callback_query(F.data.startswith("genre_"))
async def on_genre(callback: CallbackQuery):
genre = GENRE_NAMES.get(callback.data, "что-то загадочное")
await callback.message.edit_text(f"Ты выбрал: {genre}\nСпасибо за голос! 🐤")
await callback.answer()Результат: в чате после нажатия кнопки исходное сообщение «Какая музыка тебе по душе? 🎧» вместе с кнопками превратится в «Ты выбрал: рок 🤘\nСпасибо за голос! 🐤». Кнопки пропадут (мы передали новый текст без клавиатуры), и весь опрос уместится в одно аккуратное сообщение.
Разница между двумя подходами:
| Метод | Что делает |
callback.message.answer("...") | шлёт новое сообщение в чат, старое с кнопками остаётся |
callback.message.edit_text("...") | переписывает то же самое сообщение, кнопки можно убрать или заменить |
Для опросов, меню и «листалок» (когда жмёшь «Дальше» и контент сообщения меняется) почти всегда используют edit_text — так чат не разрастается.
Маленький разбор: как маршрутизировать callback на чистом Python
В хэндлере мы доставали название жанра из словаря GENRE_NAMES по метке кнопки. Это обычная работа со словарём, которую ты уже умеешь. Вот тот же приём отдельно — этот сниппет можно запустить:
GENRE_NAMES = {
"genre_rock": "рок",
"genre_rap": "рэп",
"genre_pop": "поп",
}
data = "genre_rap" # такая метка пришла от кнопки
genre = GENRE_NAMES.get(data, "неизвестно")
print("Пользователь выбрал:", genre)Вывод:
Пользователь выбрал: рэп
Метод .get(data, "неизвестно") берёт из словаря значение по ключу, а если такого ключа нет — возвращает запасное «неизвестно» вместо ошибки. Именно поэтому в хэндлере удобно держать соответствие «метка → понятный текст» в одном словаре: код хэндлера остаётся коротким, а добавить новый жанр — это одна строчка и в клавиатуре, и в словаре.
Частые ошибки новичков
- Забыл
await callback.answer(). Самая частая ошибка. Бот вроде работает, но на кнопке бесконечно крутятся часики, и кажется, что он завис. Лекарство — всегда завершать callback-хэндлер вызовомawait callback.answer(). - Ловишь нажатие через
F.textвместоF.data. У inline-кнопки нет текста сообщения — естьcallback_data. ФильтрF.text == "..."на callback не сработает никогда. НужноF.data == "..."илиF.data.startswith("..."). - Используешь
@router.messageвместо@router.callback_query. Нажатия inline-кнопок — это не сообщения. Если повесить хэндлер через@router.message(...), он нажатие просто не увидит. Для кнопок — только@router.callback_query(...). - Слишком длинная callback_data. Метка ограничена 64 байтами. Если попытаться засунуть туда целый текст или длинный JSON, Telegram отклонит кнопку. Держи метки короткими:
genre_rock,page_2, а тяжёлые данные храни у себя (в словаре или базе) и клади в метку только ключ. - Путаешь
callback.answer()иcallback.message.answer(). Первый — гасит часики и показывает всплывашку, ничего не пишет в чат. Второй — отправляет новое сообщение в чат. Их легко перепутать по названию, но делают они совершенно разное.
Мини-практика: меню Цыплёнка на inline-кнопках
Теперь твоя очередь. Возьми текущий bot.py и добавь Цыплёнку команду /menu, которая показывает inline-меню из трёх кнопок:
- «ℹ️ О боте» с меткой
menu_about— при нажатии бот черезedit_textменяет сообщение на рассказ о себе. - «🎵 Музыка» с меткой
menu_music— при нажатии бот пишет «Скоро тут будет музыкальный опрос!». - «👋 Поздороваться» с меткой
menu_hello— при нажатии бот отвечает тёплым приветствием.
Условия задачи: используй InlineKeyboardBuilder и builder.adjust(...), чтобы кнопки легли красиво (например, по одной в ряд). Сделай один хэндлер на @router.callback_query(F.data.startswith("menu_")) и внутри по callback.data реши, что ответить. Не забудь в каждом случае вызвать await callback.answer() в конце.
Проверь себя по вопросам: какой фильтр поймает все три кнопки одним хэндлером? Где у нажатия лежит метка — в F.text или в F.data? Что случится, если убрать callback.answer()?
Задание для смелых: сделай у кнопки «🎵 Музыка» не текстовый ответ, а вызов уже знакомого опроса из примера 1 — пусть edit_text поменяет сообщение и подставит ту самую клавиатуру с жанрами (передай reply_markup=builder.as_markup() вторым аргументом в edit_text). Так одна кнопка будет открывать целое подменю — ровно как в настоящих ботах.
Итоги
Сегодня «Цыплёнок-помощник» научился показывать настоящие inline-кнопки и реагировать на нажатия, не засоряя чат. Главное, что стоит унести с собой:
- Inline-кнопки живут под сообщением и при нажатии шлют боту скрытый callback, а не текст в чат.
- Клавиатуру удобно собирать InlineKeyboardBuilder'ом:
button(...)добавляет кнопку,adjust(n)раскладывает по рядам,as_markup()завершает сборку. - У каждой кнопки своя callback_data — короткая (до 64 байт) строка-метка, которую придумываешь ты; удобно давать ей префикс.
- Нажатия ловит
@router.callback_query(...), а фильтр работает поF.data, а неF.text. callback.message.edit_text(...)переписывает сообщение на месте, аcallback.message.answer(...)шлёт новое.- В конце каждого callback-хэндлера обязателен
await callback.answer()— иначе на кнопке вечно крутятся часики.
В следующем уроке мы научим Цыплёнка строить целые многоэтажные меню и листать страницы прямо в одном сообщении — те самые «‹ Назад» и «Дальше ›», к которым ты привык в больших ботах. А пока поиграйся с кнопками: они делают бота по-настоящему живым. До встречи! 🐣