Клавиатуры в телеграм-боте: reply и inline кнопки в aiogram
Строим кнопки, которыми приятно пользоваться: ReplyKeyboardMarkup против InlineKeyboardMarkup, ряды, callback_data и обработка нажатий в aiogram 3.
Редакция Питоники
Бот из урока про обработчики уже различает команды, но чтобы им пользоваться, человек должен помнить и печатать эти команды руками. Телеграм-боты, которыми приятно пользоваться, работают иначе: варианты ответа лежат прямо на кнопках, и пользователь просто тычет в них пальцем. Сегодня добавим боту кнопки — и заодно поймём, что под капотом клавиатура оказывается не интерфейсом, а самыми обычными данными.
Как устроена клавиатура в Telegram?
Любая клавиатура — и reply, и inline — это список рядов. В каждом ряду — список кнопок. Хочешь кнопку на всю ширину — сделай ряд из одной кнопки; хочешь два в ряд — положи в ряд две. Никаких пикселей и координат: Telegram сам рисует сетку по данным. Соберём клавиатуру как список списков и «напечатаем» её:
# Reply-клавиатура - это данные: список рядов, в каждом ряду - кнопки
keyboard = [
["Расписание", "Цены"],
["Записаться", "Контакты"],
]
for i, row in enumerate(keyboard, start=1):
print(f"Ряд {i}: {' | '.join(row)}")
print("Рядов:", len(keyboard))
print("Кнопок всего:", sum(len(r) for r in keyboard))
Ряд 1: Расписание | Цены Ряд 2: Записаться | Контакты Рядов: 2 Кнопок всего: 4
В настоящем aiogram структура та же самая, только вместо строк — объекты KeyboardButton, а весь список оборачивается в объект-обёртку. Список списков — не упрощение для статьи, а буквальное устройство API: когда бот отправляет клавиатуру, в Telegram улетает JSON со вложенными массивами, ровно как этот список списков. Порядок рядов и кнопок внутри ряда — тот, что ты задал в коде, поэтому главное меню обычно выстраивают по частоте использования: самое ходовое — в первый ряд.
И ещё одна вещь, которая удивляет после веба: в Telegram нет CSS. Нельзя сделать кнопку красной, круглой или прижать её к правому краю. Вся «верстка» — это разбиение на ряды. Хочешь визуальной иерархии — играй количеством кнопок в ряду: одиночная кнопка смотрится как главная, ряд из трёх мелких — как второстепенные вкладки.
Reply-клавиатура: кнопки вместо набора текста
Reply-клавиатура (ReplyKeyboardMarkup) заменяет пользователю обычную клавиатуру: кнопки появляются там, где он обычно печатает сообщения. Создаётся она из рядов с KeyboardButton, а показывается командой, у которой в message.answer передан параметр reply_markup:
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
kb = ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Расписание"), KeyboardButton(text="Цены")],
[KeyboardButton(text="Записаться"), KeyboardButton(text="Контакты")],
],
resize_keyboard=True,
input_field_placeholder="Выбери кнопку или напиши вопрос",
)
@router.message(Command("menu"))
async def menu(message: Message):
await message.answer("Вот что я умею:", reply_markup=kb)
А теперь то, чего не ждут новички. Пользователь жмёт кнопку «Цены» — и что получает бот? Не «нажатие», не событие «клик». Боту приходит обычное текстовое сообщение со словом «Цены», как будто пользователь набрал его руками. Reply-кнопка — не кнопка, а короткий путь набрать текст: нажатие отправляет за пользователя обычное сообщение. Обработать её — значит поймать текст, мы это уже умеем:
@router.message(F.text == "Цены")
async def prices(message: Message):
await message.answer("Консультация - 2000 руб., разбор кода - 3500 руб.")
@router.message(F.text == "Контакты")
async def contacts(message: Message):
await message.answer("Пиши на hello@example.com")
Из этого следует практический вывод: тексты reply-кнопок надо выбирать как команды бота — короткие, уникальные, без пунктуации. Кнопка «Хочу узнать цены, пожалуйста» — это сообщение из шести слов, которое тебе же придётся ловить фильтром по всей строке. И держи в голове уязвимость этой схемы: пользователь может не нажать кнопку, а напечатать «Цены» руками — и это правильно, бот отреагирует тем же хендлером. Reply-меню — не защита, а удобство: оно подсказывает варианты, но не требует их.
Практический штрих: клавиатуру не обязательно строить в каждом хендлере заново. Статические меню объявляют один раз на уровне модуля — как константу рядом с роутером — и передают в message.answer по ссылке. Динамические, например inline-кнопки с номерами страниц, собирают в момент отправки. А ещё reply-клавиатуру можно убрать вовсе: отправь сообщение с reply_markup=ReplyKeyboardRemove(), и кнопки исчезнут, у пользователя останется обычная раскладка:
from aiogram.types import ReplyKeyboardRemove
@router.message(F.text == "Расписание")
async def schedule(message: Message):
await message.answer(
"Ближайшее занятие - завтра в 19:00",
reply_markup=ReplyKeyboardRemove(),
)
Inline-клавиатура и callback_data
Inline-кнопки (InlineKeyboardMarkup) ведут себя совсем иначе: они прикрепляются не к клавиатуре пользователя, а к конкретному сообщению — как подпись под карточкой товара или вопросом с вариантами. И нажатие на них приходит боту не текстом, а специальным апдейтом — callback query — с секретной меткой, которую ты сам придумываешь: callback_data.
# Inline-клавиатура тоже данные: ряды кнопок, у каждой - payload
inline = [
[("Цены", "menu_prices"), ("Записаться", "menu_book")],
[("Наш сайт", "url:example.com")],
]
for row in inline:
cells = [text + " -> " + payload for text, payload in row]
print("[ " + " ; ".join(cells) + " ]")
[ Цены -> menu_prices ; Записаться -> menu_book ] [ Наш сайт -> url:example.com ]
Обрати внимание на кнопку «Наш сайт»: у неё вместо callback_data — адрес. Кроме callback-кнопок существуют кнопки-ссылки (url=...), которые просто открывают страницу и боту вообще ничего не присылают. Правила выбора метки простые: callback_data — короткая строка до 64 байт, латиницей, в стиле что_где: menu_prices, book_time_1400. Это не сообщение пользователю, а адрес действия внутри твоего кода.
Сколько весит кнопка: лимит callback_data
У метки жёсткий потолок — 64 байта. Байта, а не символа, и это не занудство: кириллица в UTF-8 весит два байта на букву, так что русские метки расходуют лимит вдвое быстрее. Посчитай сам:
# callback_data - не длиннее 64 байт, а кириллица весит 2 байта
label = "cat_" + "Музыка"
print(label, "символов:", len(label), "байт:", len(label.encode("utf-8")))
latin = "cat_music"
print(latin, "символов:", len(latin), "байт:", len(latin.encode("utf-8")))
cat_Музыка символов: 10 байт: 16 cat_music символов: 9 байт: 9
Одна метка никогда лимит не переполнит, а вот меню из полусотни кнопок вида category_товар_артикул_12345 — запросто. Практическое правило: метку держи короткой и на латинице, а данные храни у себя — в словаре или в базе данных, где по метке book_42 лежит всё остальное. Ошибка BUTTON_DATA_INVALID от Telegram почти всегда означает, что лимит превышен именно из-за кириллицы.
Как бот обрабатывает нажатия? Самый прозрачный способ — словарь «метка → действие»: пришёл callback — посмотрели в словаре, что делать. Точно так же устроены кнопки в веб-приложениях, и этот же словарь через минуту станет хендлерами с F.data:
callbacks = {
"menu_prices": "Показываю цены: консультация 2000 руб.",
"menu_book": "Открываю запись на вторник, 14:00",
}
incoming = ["menu_prices", "menu_unknown", "menu_book"]
for data in incoming:
if data in callbacks:
print(f"[{data}] -> {callbacks[data]}")
else:
print(f"[{data}] -> нет такой кнопки, покажу alert")
[menu_prices] -> Показываю цены: консультация 2000 руб. [menu_unknown] -> нет такой кнопки, покажу alert [menu_book] -> Открываю запись на вторник, 14:00
Ветка «нет такой кнопки» — не паранойя: пользователь может жать кнопку из старого сообщения после обновления бота, когда метки уже изменились. В настоящем aiogram роль словаря играют хендлеры с фильтром F.data, а сам апдейт — объект CallbackQuery:
from aiogram import F
from aiogram.types import (
CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup,
)
inline_kb = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="Цены", callback_data="menu_prices"),
InlineKeyboardButton(text="Записаться", callback_data="menu_book"),
],
[InlineKeyboardButton(text="Наш сайт", url="https://example.com")],
])
@router.callback_query(F.data == "menu_prices")
async def show_prices(callback: CallbackQuery):
await callback.message.answer("Консультация - 2000 руб.")
await callback.answer() # убрать "часики" на кнопке
Reply или inline: что выбрать?
Обе клавиатуры делают меню, но смысл у них разный. Reply — это «режим разговора»: постоянные варианты, что печатать (главное меню, да/нет, выбор категории). Inline — это «интерфейс под сообщением»: действия с конкретным сообщением (купить, записаться, открыть), выбор из вариантов, пагинация. Сводим в таблицу:
| Reply-клавиатура | Inline-клавиатура | |
|---|---|---|
| Где живёт | на месте клавиатуры пользователя | под конкретным сообщением |
| Что приходит боту | обычный текст | callback_query с callback_data |
| Кнопки-ссылки (url) | нет | есть |
| Сколько живёт | пока пользователь не свернёт | всегда, вместе с сообщением |
| Типичное применение | главное меню, режимы | действия, выбор, пагинация |
Проверь разницу на тренажёре — он показывает, что именно «слышит» бот при нажатии разных кнопок:
keyboard = [["Да", "Нет"], ["Не знаю"]]
def draw(kb):
for row in kb:
print("[ " + " | ".join(row) + " ]")
draw(keyboard)
clicks = [
("reply", "Да"),
("inline", "vote_yes"),
("reply", "Не знаю"),
]
for kind, value in clicks:
if kind == "reply":
print("Боту пришло сообщение с текстом: '" + value + "'")
else:
print("Боту пришёл callback_query с данными: '" + value + "'")
[ Да | Нет ] [ Не знаю ] Боту пришло сообщение с текстом: 'Да' Боту пришёл callback_query с данными: 'vote_yes' Боту пришло сообщение с текстом: 'Не знаю'
Одна оговорка про reply: кнопки не защищены от ручного ввода. Пользователь может и просто напечатать «Да» — и бот не отличит это от нажатия кнопки, да и не должен. Для reply-меню это нормально, но если нажатие должно быть гарантированным событием (голосование, оплата) — бери inline: callback_data уходит только по настоящему нажатию. И наоборот: если меню должно жить постоянно, «прилипая» к клавиатуре пользователя на недели, — reply вне конкуренции, потому что inline-кнопки нельзя вытащить из-под их сообщения.
Почему бот не реагирует на нажатие inline-кнопки?
Кнопка нажимается, часики крутятся, бот молчит — знакомая сцена. Хорошая новость: поломаться здесь почти негде, поэтому разбор всегда короткий. Идём по списку:
- Хендлер вообще есть? Нажатие ловит
@router.callback_query(...), а не@router.message(...)— перепутать декоратор проще, чем кажется. - Совпадает ли callback_data? Фильтр
F.data == "menu_prices"сравнивает посимвольно: опечатка, лишний пробел или регистр — и совпадения нет. - Не спрятан ли хендлер за широким фильтром? Как и с сообщениями, работает правило «первый подошедший»: общий
@router.callback_query()выше — он заберёт все нажатия. - Подтверждено ли нажатие? В конце хендлера —
await callback.answer(), иначе пользователю видны часики, а не результат.
Если все четыре пункта в порядке, а реакции всё равно нет — напечатай пришедший callback в консоли: print(callback.data) в ловце @router.callback_query(). Смотришь в консоль, жмёшь кнопку — и видишь, что реально прислал Telegram. В половине случаев там лежит не совсем то, что написано в коде: метка из старой версии бота или кнопка с другим префиксом.
И приятный шаблон на закуску: клавиатуры часто меняются по ходу диалога. Reply-меню с «Записаться» может уводить пользователя в inline-выбор времени — обе клавиатуры это обычные объекты, и отправлять их можно в любом message.answer. А в следующем уроке кнопки станут частью пошагового диалога: бот спрашивает, показывает варианты и переключает клавиатуры по состояниям.
@router.message(F.text == "Записаться")
async def book(message: Message):
await message.answer(
"Выбери время:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="14:00", callback_data="time_1400")],
[InlineKeyboardButton(text="16:00", callback_data="time_1600")],
]),
)
@router.callback_query(F.data.startswith("time_"))
async def take_time(callback: CallbackQuery):
await callback.message.answer("Записал на " + callback.data.split("_")[1])
await callback.answer()
Что дальше
Бот оброс интерфейсом: reply-меню для частых действий, inline-кнопки для точных, с callback_data вместо текстовых загадок. Осталась последняя ступень базового курса — память: диалоги, где бот задаёт вопросы по очереди и ждёт ответов. Это машина состояний FSM в следующем уроке — там кнопки «Записаться» из этого урока превратятся в полноценный пошаговый диалог. А если хочется понять, куда уходит текст «Записал на 14:00» надолго, — загляни в урок 6 про базу данных SQLite: кнопки отлично дружат с хранилищем.
Сначала предскажи ответ в голове — это главный навык программиста.
keyboard = [["Да", "Нет"], ["Не знаю"]]
counts = [len(row) for row in keyboard]
print(sum(counts), counts)
callbacks = {"menu_prices": "цены"}
data = "menu_book"
print(callbacks.get(data, "нет такой кнопки"))
1. Как организованы кнопки внутри ReplyKeyboardMarkup?
2. Что приходит боту, когда пользователь нажимает reply-кнопку «Цены»?
3. Зачем callback_data у inline-кнопки?
4. Пользователь жмёт inline-кнопку, а на ней крутятся «часики». Что забыл разработчик?
5. Какой фильтр ловит нажатия inline-кнопок по их меткам?
Собери inline-меню магазина: два ряда — [«Книги», «Игры»] и [«Помощь»], у каждой кнопки callback_data вида cat_что-то. Напечатай клавиатуру рядами, затем обработай два нажатия: cat_games и cat_help. Для cat_books и cat_games в словаре есть действия, а всё остальное — строка «Нет такой кнопки — покажу alert».
Чем inline кнопки отличаются от reply кнопок в aiogram?
Reply-клавиатура появляется на месте клавиатуры пользователя, и нажатие просто отправляет текст сообщения. Inline-кнопки прикреплены к сообщению и присылают callback_query с меткой callback_data, которую бот ловит фильтром F.data. Reply — для меню и режимов, inline — для действий и выбора.
Почему бот не видит нажатие inline-кнопки?
Проверь четыре пункта: хендлер зарегистрирован через @router.callback_query, а не @router.message; фильтр F.data совпадает с callback_data посимвольно; выше нет широкого callback-хендлера-ловца; после обработки вызывается await callback.answer(). Чаще всего не совпадает строка метки — опечатка или лишний пробел.
Как изменить размер кнопок reply-клавиатуры?
Передай resize_keyboard=True при создании ReplyKeyboardMarkup: без него Telegram рисует крупные плитки на полэкрана. Дополнительно полезен input_field_placeholder — подсказка в пустой строке ввода, и one_time_keyboard=True, чтобы клавиатура сворачивалась после нажатия.
Можно ли сделать inline-кнопку ссылкой на сайт?
Да: InlineKeyboardButton(text="Наш сайт", url="https://...") — вместо callback_data передаётся url. Такие кнопки открывают страницу силами Telegram и боту ничего не присылают, поэтому хендлер им не нужен.
Понравился урок? Сошлитесь на него
«Reply-кнопка — не кнопка, а короткий путь набрать текст: нажатие отправляет за пользователя обычное сообщение.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
aiogram · Урок 2
Обработчики сообщений в aiogram: команды, текст и фильтры
Учим бота различать сообщения: Router, команды Command и CommandStart, аргументы через CommandObject и магический фильтр F — и главное правило, что порядок хендлеров решает всё.
aiogram · Урок 4
FSM в aiogram: машина состояний для пошаговых диалогов
Учим бот вести диалог по шагам: состояния как таблица переходов, анкета «имя — возраст — город» на тренажёре, а потом по-настоящему — StatesGroup, MemoryStorage и FSMContext.
aiogram · Урок 6
База данных в телеграм-боте: SQLite от первого лица
Даём боту настоящую память: таблица пользователей с chat_id, запись через INSERT OR IGNORE, чтение по chat_id и параметр ? против SQL-инъекций.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
pytest проверка статус кода api
pytest · Урок 18
Что тестировать: стратегия, границы и покрытие
Механику pytest ты знаешь — осталось ответить на главный вопрос: какие тесты писать. Поведение вместо реализации, границы диапазонов, ветки ошибок, покрытие и пирамида.
pytest покрытие кода
aiogram · Урок 9
Обработка ошибок в телеграм-боте: try/except, лимиты и логи
Учим бот переживать ошибки: try/except в хендлерах, лимиты Telegram с RetryAfter, модуль logging вместо print и перезапуск polling после падения.
ошибки телеграм ботаflood control телеграм