Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 3 из 10 Средний 40 мин 130 XP

Клавиатуры в телеграм-боте: 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:

Reply-клавиатура в aiogram 3
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)
Настоящий код aiogram с сетью — запускается на твоём компьютере. Структура keyboard — тот же список рядов, что в тренажёре выше; pydantic-обёртка превращает его в JSON для Telegram.

А теперь то, чего не ждут новички. Пользователь жмёт кнопку «Цены» — и что получает бот? Не «нажатие», не событие «клик». Боту приходит обычное текстовое сообщение со словом «Цены», как будто пользователь набрал его руками. Reply-кнопка — не кнопка, а короткий путь набрать текст: нажатие отправляет за пользователя обычное сообщение. Обработать её — значит поймать текст, мы это уже умеем:

Нажатие 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")
Фрагмент того же роутера: сеть в песочнице недоступна. Никаких отдельных «хендлеров кнопок» не нужно — работает фильтр по тексту из урока 2.

Из этого следует практический вывод: тексты reply-кнопок надо выбирать как команды бота — короткие, уникальные, без пунктуации. Кнопка «Хочу узнать цены, пожалуйста» — это сообщение из шести слов, которое тебе же придётся ловить фильтром по всей строке. И держи в голове уязвимость этой схемы: пользователь может не нажать кнопку, а напечатать «Цены» руками — и это правильно, бот отреагирует тем же хендлером. Reply-меню — не защита, а удобство: оно подсказывает варианты, но не требует их.

Практический штрих: клавиатуру не обязательно строить в каждом хендлере заново. Статические меню объявляют один раз на уровне модуля — как константу рядом с роутером — и передают в message.answer по ссылке. Динамические, например inline-кнопки с номерами страниц, собирают в момент отправки. А ещё reply-клавиатуру можно убрать вовсе: отправь сообщение с reply_markup=ReplyKeyboardRemove(), и кнопки исчезнут, у пользователя останется обычная раскладка:

Убираем reply-клавиатуру (aiogram 3)
from aiogram.types import ReplyKeyboardRemove

@router.message(F.text == "Расписание")
async def schedule(message: Message):
    await message.answer(
        "Ближайшее занятие - завтра в 19:00",
        reply_markup=ReplyKeyboardRemove(),
    )
Фрагмент настоящего роутера: сеть в песочнице недоступна. ReplyKeyboardRemove не имеет настроек — это команда «спрячь мою reply-клавиатуру»; inline-кнопки она не трогает.

Inline-клавиатура и callback_data

Inline-кнопки (InlineKeyboardMarkup) ведут себя совсем иначе: они прикрепляются не к клавиатуре пользователя, а к конкретному сообщению — как подпись под карточкой товара или вопросом с вариантами. И нажатие на них приходит боту не текстом, а специальным апдейтом — callback query — с секретной меткой, которую ты сам придумываешь: callback_data.

Inline-кнопки: текст и 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: считаем байты
# 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:

Обработка нажатий: словарь callback_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:

Inline-клавиатура и callback-хендлер (aiogram 3)
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()          # убрать "часики" на кнопке
Настоящий код aiogram с сетью: выполняется на твоём компьютере. Обрати внимание на две разные вещи: callback.message.answer() отправляет новое сообщение, а callback.answer() подтверждает само нажатие.

Reply или inline: что выбрать?

Обе клавиатуры делают меню, но смысл у них разный. Reply — это «режим разговора»: постоянные варианты, что печатать (главное меню, да/нет, выбор категории). Inline — это «интерфейс под сообщением»: действия с конкретным сообщением (купить, записаться, открыть), выбор из вариантов, пагинация. Сводим в таблицу:

Reply-клавиатураInline-клавиатура
Где живётна месте клавиатуры пользователяпод конкретным сообщением
Что приходит ботуобычный текстcallback_query с callback_data
Кнопки-ссылки (url)нетесть
Сколько живётпока пользователь не свернётвсегда, вместе с сообщением
Типичное применениеглавное меню, режимыдействия, выбор, пагинация

Проверь разницу на тренажёре — он показывает, что именно «слышит» бот при нажатии разных кнопок:

Что приходит боту: reply-клик против inline-клика
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-кнопки?

Кнопка нажимается, часики крутятся, бот молчит — знакомая сцена. Хорошая новость: поломаться здесь почти негде, поэтому разбор всегда короткий. Идём по списку:

  1. Хендлер вообще есть? Нажатие ловит @router.callback_query(...), а не @router.message(...) — перепутать декоратор проще, чем кажется.
  2. Совпадает ли callback_data? Фильтр F.data == "menu_prices" сравнивает посимвольно: опечатка, лишний пробел или регистр — и совпадения нет.
  3. Не спрятан ли хендлер за широким фильтром? Как и с сообщениями, работает правило «первый подошедший»: общий @router.callback_query() выше — он заберёт все нажатия.
  4. Подтверждено ли нажатие? В конце хендлера — await callback.answer(), иначе пользователю видны часики, а не результат.

Если все четыре пункта в порядке, а реакции всё равно нет — напечатай пришедший callback в консоли: print(callback.data) в ловце @router.callback_query(). Смотришь в консоль, жмёшь кнопку — и видишь, что реально прислал Telegram. В половине случаев там лежит не совсем то, что написано в коде: метка из старой версии бота или кнопка с другим префиксом.

И приятный шаблон на закуску: клавиатуры часто меняются по ходу диалога. Reply-меню с «Записаться» может уводить пользователя в inline-выбор времени — обе клавиатуры это обычные объекты, и отправлять их можно в любом message.answer. А в следующем уроке кнопки станут частью пошагового диалога: бот спрашивает, показывает варианты и переключает клавиатуры по состояниям.

Комбинируем: reply-меню открывает inline-выбор
@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()
Настоящий код aiogram с сетью: запускай на своём компьютере. Фильтр F.data.startswith ловит все метки с префиксом time_ — так одна кнопка-хендлер обрабатывает целый ряд вариантов.

Что дальше

Бот оброс интерфейсом: 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, "нет такой кнопки"))
Проверь себя
0 / 5

1. Как организованы кнопки внутри ReplyKeyboardMarkup?

2. Что приходит боту, когда пользователь нажимает reply-кнопку «Цены»?

3. Зачем callback_data у inline-кнопки?

4. Пользователь жмёт inline-кнопку, а на ней крутятся «часики». Что забыл разработчик?

5. Какой фильтр ловит нажатия inline-кнопок по их меткам?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери inline-меню магазина: два ряда — [«Книги», «Игры»] и [«Помощь»], у каждой кнопки callback_data вида cat_что-то. Напечатай клавиатуру рядами, затем обработай два нажатия: cat_games и cat_help. Для cat_books и cat_games в словаре есть действия, а всё остальное — строка «Нет такой кнопки — покажу alert».

practice.py
Вопросы и ответы по уроку

Чем 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.