Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 9 из 25 ~20 мин +20 XP

Inline-клавиатуры и callback-запросы

Inline-клавиатура прикрепляет кнопки к самому сообщению, а нажатие приходит боту объектом CallbackQuery: в tgbotapi это NewInlineKeyboardMarkup с NewInlineKeyboardButtonData и AnswerCallbackQuery для снятия «часов». Разберём данные кнопок, обработку нажатий и ошибку, из-за которой пользователь смотрит на спиннер.

Содержание урока · 11 разделов

InlineKeyboardMarkup: кнопки с данными#

У reply-клавиатуры из прошлого урока два ограничения: она одна на чат и при нажатии пишет текст в общий поток. Inline-клавиатура телеграм бота устроена иначе: кнопки висят под конкретным сообщением, раскладок может быть сколько угодно — у каждого сообщения своя, — и нажатие не порождает нового сообщения в чате. Именно так работают опросы с голосованием по кнопке, меню с каталогом и подтверждения «Принять/Отклонить». Цена — чуть больше механики: нажатие приходит отдельным объектом CallbackQuery, у него свои поля и, главное, обязательный ответ AnswerCallbackQuery, без которого клиент показывает часы на кнопке. В этом уроке соберём клавиатуру с данными, обработаем нажатие в цикле обновлений и разберём дизайн callback_data — короткого машинного поля, которое бот читает при нажатии и которое видно в логах даже чаще, чем сами нажатия.

Структура та же по духу, что у reply: ряды и кнопки, только у кнопки два значения. NewInlineKeyboardButtonData(text, data) делает кнопку, где text — подпись, которую видит пользователь, а data — машинная строка, которую увидит только бот. Рядом живёт NewInlineKeyboardButtonURL для кнопок-ссылок: она открывает адрес и боту ничего не присылает. NewInlineKeyboardRow собирает ряд, NewInlineKeyboardMarkup(rows ...) — разметку. Главное отличие от reply здесь в разведении подписи и нагрузки: в reply подпись и полезные данные совпадают, тут они разные. Пользователь видит «Да», бот получает rsvp:yes. Ограничение Bot API: callback_data — от одного до шестидесяти четырёх байт, и это требование к дизайну: в data кладут идентификаторы и команды, а не тексты. Формат выбираете сами — «сущность:значение» надёжнее всего: vote:42, page:3, delete:101. Пользователь data не видит никогда, но она уходит в открытый API и падает в логи при отладке, поэтому секретов в ней быть не должно.

Голосование по кнопкам

GO
keyboard := tgbotapi.NewInlineKeyboardMarkup(
        tgbotapi.NewInlineKeyboardRow(
                tgbotapi.NewInlineKeyboardButtonData("Да, приду", "rsvp:yes"),
                tgbotapi.NewInlineKeyboardButtonData("Не смогу", "rsvp:no"),
        ),
        tgbotapi.NewInlineKeyboardRow(
                tgbotapi.NewInlineKeyboardButtonURL(
                        "Устав встречи", "https://example.com/rules"),
        ),
)

msg := tgbotapi.NewMessage(chatID, "Идёте на встречу в субботу?")
msg.ReplyMarkup = keyboard
bot.Send(msg)

Две строки: с кнопками данных и URL-кнопкой. У каждой кнопки данных подпись и машинный код: пользователь читает «Да, приду», бот получит rsvp:yes. URL-кнопка открывает ссылку и callback не порождает. Разметка уезжает в тот же ReplyMarkup, что и у reply-клавиатуры, — различие только в типе структуры.

CallbackQuery: обновление без Message#

Нажатие приходит в тот же цикл обновлений, но это не Message. Update содержит поле CallbackQuery, а update.Message при этом nil — и здесь любимая паника новичка: код, который без проверки лезет в update.Message.Text, падает с nil pointer dereference на первом же нажатии. Внутри CallbackQuery четыре рабочих поля. ID — строка-идентификатор запроса, она нужна для обязательного ответа. From — пользователь, который нажал: из него берут ID для персональной логики. Message — сообщение с кнопкой: отсюда достают Chat.ID, чтобы отвечать в тот же чат. Data — то, что вы положили в callback_data. Обработку стоит строить по схеме «сначала callback, потом сообщение»: update.CallbackQuery != nil — отдельная ветка, update.Message != nil — другая. Такое разделение держит цикл плоским и не даёт nil-проверкам расползаться по всему коду.

Цикл с двумя ветками

GO
for update := range updates {
        if update.CallbackQuery != nil {
                handleCallback(bot, update.CallbackQuery)
                continue
        }
        if update.Message == nil {
                continue
        }
        handleCommand(bot, update.Message)
}

Две ветки цикла: нажатия уходят в handleCallback, сообщения — в handleCommand, всё прочее — редактирования, реакции — молча пропускается. Проверка на nil здесь не паранойя, а условие жизни: одно нажатие inline-кнопки без неё валит весь цикл. Ветку callback проверяют первой — её чаще всего забывают.

AnswerCallbackQuery: почему на кнопке часы#

Пока бот не вызовет AnswerCallbackQuery, клиент Telegram считает запрос незавершённым: на кнопке крутятся часы, пользователь думает, что бот завис. Отвечать нужно на каждое нажатие, и делать это быстро. В библиотеке — NewCallback(update.CallbackQuery.ID, text): текст показывается тостом над клавиатурой, лёгкое подтверждение вроде «Голос учтён». Для заметного окна есть NewCallbackWithAlert — модальное окно, которое пользователь обязан закрыть; уместно для ошибок и важных решений. Ответ уходит через bot.Request — в ответе ожидается подтверждение, а не сообщение. Практика простая: первым делом после чтения Data отвечаем на callback и только потом думаем над обработкой. Даже если бизнес-логика упала на базе, пользователь должен получить ответ нажатия, а не часы: лучше «Что-то сломалось, попробуйте позже» в тосте, чем вечный спиннер. Забытый AnswerCallbackQuery — ошибка номер один в первых inline-ботах, и лечится она раз и навсегда через defer.

Обработка нажатия с гарантированным ответом

GO
func handleCallback(bot *tgbotapi.BotAPI, cb *tgbotapi.CallbackQuery) {
        defer func() {
                cfg := tgbotapi.NewCallback(cb.ID, "")
                bot.Request(cfg)
        }()

        switch cb.Data {
        case "rsvp:yes":
                reply(bot, cb.Message.Chat.ID, "Записал: "+cb.From.FirstName)
        case "rsvp:no":
                reply(bot, cb.Message.Chat.ID, "Жаль, в другой раз!")
        }
}

AnswerCallbackQuery вызывается в defer — ответ уходит при любом исходе обработки, даже если Data не совпала ни с одной веткой. Пустой текст только снимает часы; тост «Голос учтён» получился бы непустой строкой в NewCallback. cb.Message.Chat.ID возвращает ответ в чат с кнопкой, а From.FirstName персонализирует подтверждение.

Кейс: кнопка «Купить», которую нажали дважды#

Магазин стикеров на учебном боте жил неделю до первого двойного заказа. Схема была честная: inline-кнопка «Купить», в data — buy:42, по нажатию бот списывал внутренний баланс и писал «Готово». Пользователь не увидел мгновенной реакции, нажал второй раз — и списание произошло дважды: каждое нажатие это отдельный CallbackQuery, даже если кнопка на вид не изменилась. Лечение свелось к двум приёмам. Первый — EditMessageText после обработки: кнопки убираются, на их месте «Оплачено», повторное нажатие физически невозможно. Второй — идемпотентность: действие по buy:42 проверяет, не выполнено ли оно уже, и повторный callback отвечает тостом «Уже оплачено». Редактирование разобрано в шестнадцатом уроке, а принцип стоит усвоить сейчас: callback не одноразовый, пока ваш код не сделал его одноразовым.

Дизайн callback_data: короткий машинный код#

Data — не место для текста, а адрес действия. Шестьдесят четыре байта сверху, но реальный потолок диктует здравый смысл: чем короче, тем меньше соблазн зашить лишнее. Формат «сущность:значение» — rsvp:yes, page:3, delete:101 — разбирается strings.Split-ом и читается в логах без документации. Динамические данные — идентификатор голосования, номер страницы — кладутся честно, но помните: при сбое бот логирует Data как есть, и всё, что вы туда положили, окажется в логе. Вторая половина дизайна — что бот делает после нажатия. Часто правильный ответ не новое сообщение, а правка исходного: убрать кнопки, показать «Записано», перерисовать меню на следующую страницу. Редактирование сообщений — отдельная техника шестнадцатого урока, а память для многошаговых кнопок появляется в уроке про состояние диалога.

В личке кнопка одна на диалог, в группе на неё может нажать любой участник — и это меняет дизайн. Опрос «идёте на встречу?» в группе работает прекрасно: From каждого нажатия подскажет, кто голосовал, а клики разных людей — разные CallbackQuery. Но кнопки действий — «Удалить», «Забанить», «Открыть дверь» — в общем чате превращаются в игрушку: нажать может любой, включая тех, кто сообщение не читал. Защита та же, что у команд: сверяйте cb.From.ID со списком разрешённых до выполнения действия. Второй нюанс групп — живучесть кнопок: сообщение с клавиатурой лежит в истории, и через месяц кто-то нажмёт «Принять» в мёртвой ветке. Времени жизни у кнопок нет — старение становится вашей работой, обычно правкой старого сообщения при новом состоянии.

Что лежит в CallbackQuery#

ПолеТип в v5Что содержит
IDstringИдентификатор запроса — нужен для AnswerCallbackQuery
From*UserКто нажал: ID, имя, username
Message*MessageСообщение с кнопкой: отсюда Chat.ID и MessageID
DatastringТо, что вы положили в callback_data

Типичная ошибка: доверие callback_data без проверки владельца#

Data — строка из вашего кода, и кажется, что доверять ей можно. Но сообщение с кнопками доступно всем, кто видит чат, и data при этом приходит любому нажавшему. Учебный бот модерации принимал delete:101 и удалял запись по номеру, не глядя, кто нажал, — пока один из студентов не открыл историю и не подчистил чужие заявки кнопкой. Формат «сущность:значение» не защита: parse:inbox в data ничего не удостоверяет. Проверка владельца обязательна на каждом действии с последствиями: cb.From.ID сверяется с автором объекта или со списком модераторов, а при отказе — тост «Это не ваша кнопка» через AnswerCallbackQuery. Заметьте: отказ тоже требует ответа на callback, иначе вместо честного отказа пользователь снова увидит часы.

Кнопка отмены: обязательная часть меню#

У каждой inline-раскладки с выбором есть невидимая кнопка, которую забывают: отмена. Пользователь открыл меню, передумал — и застрял: кнопки живут на сообщении, а закрыть их, кроме как нажать что-то, нельзя. Спасение — кнопка «Отмена» с кодом вида menu:cancel в нижнем ряду: обработчик сбрасывает состояние из десятого урока и правит сообщение, убирая раскладку. Дешёвая привычка, которая экономит поддержку: сценарии с выбором без отмены порождают вопросы «как выйти», а вопросы — это люди, не дошедшие до цели. Исключение — опросы: там отмена не нужна, пока голосование открыто, но и оно кончается дедлайном, который вы же и задаёте.

Проверка: спиннер на кнопке

Пользователь нажал inline-кнопку, но бот не вызвал AnswerCallbackQuery. Что видит пользователь?

Проверка: лимит данных

Сколько байт максимум в callback_data?

Что напечатает обработчик

Прочитайте код и выберите, что он выведет:

func handleCallback(bot *tgbotapi.BotAPI, cb *tgbotapi.CallbackQuery) {
        fmt.Println("нажато:", cb.Data)
}

// пользователь нажал кнопку, созданную как
// tgbotapi.NewInlineKeyboardButtonData("Не смогу", "rsvp:no")

Соедините кнопку, ответ и отправку

Впишите код кнопки, поле с идентификатором запроса и метод отправки.

btn := tgbotapi.NewInlineKeyboardButtonData("Голос за кота", "") cfg := tgbotapi.NewCallback(update.CallbackQuery., "Голос учтён") _, err := bot.(cfg)

Шесть строк inline-сообщения

Расставьте строки отправки сообщения с inline-клавиатурой: от кнопки до Send.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: inline-клавиатуры

Термин

InlineKeyboardMarkup

нажмите, чтобы перевернуть
Определение

Разметка кнопок, прикреплённых к сообщению: ряды InlineKeyboardRow, присваивается полю ReplyMarkup. Кнопок может быть много — по одной раскладке на сообщение.

1 / 6

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

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Inline-клавиатура прикрепляется к сообщению: по одной раскладке на сообщение, кнопки не пишут текст в чат.
  • NewInlineKeyboardButtonData разводит подпись для человека и машинный код data для бота.
  • callback_data — до 64 байт; формат «сущность:значение» вроде vote:42 читается в логах и разбирается strings.Split-ом.
  • Нажатие приходит как CallbackQuery, при этом update.Message равен nil — проверяйте ветки цикла.
  • AnswerCallbackQuery обязателен на каждое нажатие: без него клиент показывает часы на кнопке.
  • Ответ строится по ID запроса; NewCallback — тост, NewCallbackWithAlert — модальное окно.
  • Проверка кнопок — только в настоящем клиенте: локальный Go и реальный токен бота.

Домашнее задание

Добавьте к локальному боту голосование: сообщение с inline-клавиатурой из двух кнопок с кодами rsvp:yes и rsvp:no. Обработайте нажатия в отдельной ветке цикла, первым делом отвечая AnswerCallbackQuery с тостом, и проверьте два сценария: корректное нажатие и забытый ответ — закомментируйте AnswerCallbackQuery и посмотрите, сколько крутятся часы на кнопке.

Личные заметкиПусто
Хранятся только в вашем браузере (Telegram · Inline-клавиатуры и callback-запросы)

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

Можно ли узнать, на каком сообщении нажата кнопка?

Да: cb.Message — это сообщение с клавиатурой, из него доступны Chat.ID и MessageID. Нюанс: старые сообщения в группах могут приходить с частичными данными, а в новых версиях библиотеки тип этого поля отличается — сверяйтесь с доками своей версии.

Чем AnswerCallbackQuery отличается от обычного ответа сообщением?

Это служебный ответ на нажатие: он снимает часы и может показать короткий тост, но ничего не пишет в чат. Обычно делают оба: тост как подтверждение и бизнес-действие — правку или сообщение.

Что делать, если кнопок много и они динамические?

Класть в callback_data только идентификаторы — page:3, item:42 — и строить раскладку из данных. Полные тексты не влезут в лимит 64 байта и утекут в логи при отладке.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Prometheus25 уроков~12 чпрактика25 уроков уже доступны

Мониторинг Go с Prometheus: метрики, Grafana, алерты

Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.

  • Понимать четыре типа метрик и выбирать правильный
  • Экспортировать метрики из Go через promhttp
  • Инструментировать HTTP-запросы и бизнес-события
  • Писать PromQL-запросы: rate, sum, p99