Содержание урока · 11 разделов
- InlineKeyboardMarkup: кнопки с данными
- CallbackQuery: обновление без Message
- AnswerCallbackQuery: почему на кнопке часы
- Кейс: кнопка «Купить», которую нажали дважды
- Дизайн callback_data: короткий машинный код
- Что лежит в CallbackQuery
- Типичная ошибка: доверие callback_data без проверки владельца
- Кнопка отмены: обязательная часть меню
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 и падает в логи при отладке, поэтому секретов в ней быть не должно.
Голосование по кнопкам
GOkeyboard := 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-проверкам расползаться по всему коду.
Цикл с двумя ветками
GOfor 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.
Обработка нажатия с гарантированным ответом
GOfunc 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 не одноразовый, пока ваш код не сделал его одноразовым.
Симптом: кнопка нажимается, сообщение бот отправил, а часы на кнопке крутятся полминуты. Диагноз: AnswerCallbackQuery не вызван. Telegram ждёт ответа и рисует спиннер, пока не сдастся по таймауту. Лечение из урока: defer с NewCallback первым делом — и часы исчезают навсегда, при любом исходе обработки.
Дизайн callback_data: короткий машинный код#
Data — не место для текста, а адрес действия. Шестьдесят четыре байта сверху, но реальный потолок диктует здравый смысл: чем короче, тем меньше соблазн зашить лишнее. Формат «сущность:значение» — rsvp:yes, page:3, delete:101 — разбирается strings.Split-ом и читается в логах без документации. Динамические данные — идентификатор голосования, номер страницы — кладутся честно, но помните: при сбое бот логирует Data как есть, и всё, что вы туда положили, окажется в логе. Вторая половина дизайна — что бот делает после нажатия. Часто правильный ответ не новое сообщение, а правка исходного: убрать кнопки, показать «Записано», перерисовать меню на следующую страницу. Редактирование сообщений — отдельная техника шестнадцатого урока, а память для многошаговых кнопок появляется в уроке про состояние диалога.
В личке кнопка одна на диалог, в группе на неё может нажать любой участник — и это меняет дизайн. Опрос «идёте на встречу?» в группе работает прекрасно: From каждого нажатия подскажет, кто голосовал, а клики разных людей — разные CallbackQuery. Но кнопки действий — «Удалить», «Забанить», «Открыть дверь» — в общем чате превращаются в игрушку: нажать может любой, включая тех, кто сообщение не читал. Защита та же, что у команд: сверяйте cb.From.ID со списком разрешённых до выполнения действия. Второй нюанс групп — живучесть кнопок: сообщение с клавиатурой лежит в истории, и через месяц кто-то нажмёт «Принять» в мёртвой ветке. Времени жизни у кнопок нет — старение становится вашей работой, обычно правкой старого сообщения при новом состоянии.
Что лежит в CallbackQuery#
| Поле | Тип в v5 | Что содержит |
|---|---|---|
| ID | string | Идентификатор запроса — нужен для AnswerCallbackQuery |
| From | *User | Кто нажал: ID, имя, username |
| Message | *Message | Сообщение с кнопкой: отсюда Chat.ID и MessageID |
| Data | string | То, что вы положили в 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")Соедините кнопку, ответ и отправку
Впишите код кнопки, поле с идентификатором запроса и метод отправки.
Шесть строк inline-сообщения
Расставьте строки отправки сообщения с inline-клавиатурой: от кнопки до Send.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Inline-кнопки и callback-запросы существуют только в клиенте Telegram: локальный Go, реальный токен из урока про BotFather и телефон в руках — минимальный стенд. Логику разбора data можно проверять и без сети — это обычные строки и switch.
Карточки: inline-клавиатуры
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 и посмотрите, сколько крутятся часы на кнопке.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли узнать, на каком сообщении нажата кнопка?
Да: cb.Message — это сообщение с клавиатурой, из него доступны Chat.ID и MessageID. Нюанс: старые сообщения в группах могут приходить с частичными данными, а в новых версиях библиотеки тип этого поля отличается — сверяйтесь с доками своей версии.
Чем AnswerCallbackQuery отличается от обычного ответа сообщением?
Это служебный ответ на нажатие: он снимает часы и может показать короткий тост, но ничего не пишет в чат. Обычно делают оба: тост как подтверждение и бизнес-действие — правку или сообщение.
Что делать, если кнопок много и они динамические?
Класть в callback_data только идентификаторы — page:3, item:42 — и строить раскладку из данных. Полные тексты не влезут в лимит 64 байта и утекут в логи при отладке.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Состояние диалога: анкета по шагам
- Хранение данных бота: память и Redis
- Медиа: фото, документы и voice
- Форматирование: HTML в сообщениях
- Reply-клавиатуры: кнопки вместо ввода
- Команды: /start, /help и SetMyCommands
- Отправка сообщений: NewMessage и ChatID
- Long polling: как бот слышит сообщения
- Все курсы по Go
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99