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

Разберём по шагам:

  1. builder = InlineKeyboardBuilder() — берём пустой «поднос» для кнопок.
  2. builder.button(text=..., callback_data=...) — кладём кнопку. text — что пользователь видит на кнопке, callback_data — та самая скрытая метка, которая придёт боту при нажатии. Каждая метка уникальна: genre_rock, genre_rap и так далее.
  3. builder.adjust(2) — раскладываем кнопки по 2 в ряд. Без этой строки aiogram поставит каждую кнопку на отдельную строку — получится длинный столбик.
  4. builder.as_markup() — говорим «поднос готов» и получаем объект клавиатуры.
  5. 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()  # подтверждаем нажатие

Результат: в чате после нажатия любой из кнопок жанра бот пришлёт сообщение вроде «Отличный выбор — рок 🤘 Записал твой голос.», а «часики» на кнопке погаснут. Если нажать «🎸 Рок», подставится «рок», если «🎤 Рэп» — «рэп», и так далее.

Что здесь происходит:

  1. @router.callback_query(...) — декоратор для нажатий inline-кнопок. Работает по той же логике, что и @router.message(...), только ловит не сообщения, а callback'и.
  2. F.data.startswith("genre_") — фильтр. У нажатия inline-кнопки текст лежит не в F.text, а в F.data — это и есть наша callback_data. Фильтр срабатывает на все метки, начинающиеся с genre_. Вот зачем нужен был префикс: одним хэндлером ловим сразу все четыре кнопки.
  3. callback: CallbackQuery — в хэндлер прилетает не Message, а объект CallbackQuery. Внутри него: callback.data (метка нажатой кнопки), callback.from_user (кто нажал) и callback.message (то сообщение, под которым была кнопка).
  4. callback.message.answer(...) — отправляем новое сообщение в тот же чат. Обрати внимание: пишем callback.message.answer, а не просто callback.answer — это разные вещи (про вторую — ниже).
  5. 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-меню из трёх кнопок:

  1. «ℹ️ О боте» с меткой menu_about — при нажатии бот через edit_text меняет сообщение на рассказ о себе.
  2. «🎵 Музыка» с меткой menu_music — при нажатии бот пишет «Скоро тут будет музыкальный опрос!».
  3. «👋 Поздороваться» с меткой 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() — иначе на кнопке вечно крутятся часики.

В следующем уроке мы научим Цыплёнка строить целые многоэтажные меню и листать страницы прямо в одном сообщении — те самые «‹ Назад» и «Дальше ›», к которым ты привык в больших ботах. А пока поиграйся с кнопками: они делают бота по-настоящему живым. До встречи! 🐣

Проверьте себя
1. Чем inline-кнопка отличается от reply-кнопки при нажатии?
AInline-кнопка отправляет в чат обычный текст, как и reply-кнопка
BInline-кнопка шлёт боту скрытый callback и не засоряет чат текстом
CInline-кнопка вообще ничего не отправляет боту
DInline-кнопка работает только в личных чатах
2. Что нужно передать в каждую inline-кнопку, чтобы бот понял, какую именно нажали?
AУникальную метку в параметре callback_data
BНомер кнопки в параметре reply_markup
CТекст сообщения через F.text
DИмя функции-хэндлера
3. Каким декоратором ловят нажатие inline-кнопки в aiogram 3.x?
A@router.message(...)
B@router.inline(...)
C@router.callback_query(...)
D@router.button(...)
4. По какому полю фильтровать нажатие inline-кнопки?
AПо F.text — это текст кнопки
BПо F.data — там лежит callback_data нажатой кнопки
CПо F.photo
DПо имени builder
5. Что произойдёт, если в callback-хэндлере не вызвать await callback.answer()?
AБот вообще не запустится
BСообщение не отправится в чат
CНа кнопке будут долго крутиться «часики», как будто бот завис
DКнопка исчезнет навсегда
6. Чем отличается callback.message.edit_text(...) от callback.message.answer(...)?
AНичем, это синонимы
Bedit_text переписывает то же сообщение на месте, а answer шлёт новое сообщение в чат
Cedit_text гасит часики, а answer показывает всплывашку
Danswer работает только с reply-кнопками