Содержание урока · 9 разделов
Меню бота-дневника из урока про inline-клавиатуры устроено честно, но расточительно: нажатие кнопки «Статистика» отправляет новое сообщение с цифрами, нажатие «Настройки» — ещё одно, справка — третье. Через неделю диалог выглядит как стопка карточек, где актуальна только верхняя, а пользователь скроллит назад в поисках кнопок, которые уже утонули в истории. Так ведут себя боты, которые не умеют редактировать: каждое действие порождает новый объект в чате вместо перерисовки старого. Telegram давно придумал механизм получше — правку уже отправленного сообщения на месте. Чат с таким ботом занимает один экран: кнопки при каждом нажатии перерисовывают тот же блок текста, как страницы приложения. Так работают меню самого BotFather, пагинация каталогов и настройки аккуратных ботов. На Go механика держится на трёх вещах: адресе сообщения из двух полей, конструкторе NewEditMessageText и схеме callback_data, которая понимает, какую страницу рисовать. Ниже — правка из callback-нажатия, многостраничное меню без серверного состояния, кейс со списком записей и границы метода.
EditMessageText: правка по chatID и messageID#
У каждого сообщения, отправленного ботом, есть два адресных поля: chatID — куда, и messageID — номер сообщения внутри этого чата. Метод editMessageText принимает оба и новый текст, после чего сервер Telegram заменяет содержимое исходного сообщения у всех его читателей. Сроков для правки собственных сообщений у бота нет: аккаунт человека в официальных клиентах ограничен окном в 48 часов, а бот может переписать своё сообщение и через месяц — на этом строятся живые дашборды и длинные меню. Ограничение в другом: редактировать можно только то, что отправил сам бот, плюс инлайн-сообщения по их inlineMessageID. Чужой трафик — сообщения людей — недоступен в принципе, и это правильно. В callback-нажатии оба адресных поля уже лежат готовые: update.CallbackQuery.Message содержит Chat.ID и MessageID того сообщения, к которому прикреплена клавиатура. Отдельный поиск по истории чата не нужен: нажатие кнопки само говорит, какой объект перерисовать. Если сообщение инлайн-результата из пятнадцатого урока, у него вместо пары chatID и messageID отдельный идентификатор inlineMessageID, и правка идёт через тот же метод.
Правка из callback-нажатия
GOif update.CallbackQuery != nil {
cq := update.CallbackQuery
if cq.Data == "menu:stats" {
edit := tgbotapi.NewEditMessageText(
cq.Message.Chat.ID,
cq.Message.MessageID,
"Записей за неделю: 12\nСерия: 4 дня",
)
bot.Send(edit)
}
bot.AnswerCallbackQuery(tgbotapi.NewCallback(cq.ID, ""))
}Данные нажатия приходят в cq.Data, а адрес правки — в cq.Message: это то же сообщение с клавиатурой, которое надо переписать. NewEditMessageText собирает конфигурацию правки, bot.Send отдаёт её API, и текст заменяется без создания новых сообщений. AnswerCallbackQuery закрывает запрос, чтобы на кнопке не крутились часики — правило урока про inline-клавиатуры действует и здесь.
Дизайн callback_data для многостраничного меню#
Меню с несколькими экранами сводится к двум вопросам: какое действие выбрал человек и на какой странице он находится. Ответ живёт в строке callback_data, и от её схемы зависит сложность обработчика. Рабочий формат — путь с разделителем: menu:stats, help:2, settings:notifications. В обработчике строка разбирается strings.Split по двоеточию, первый сегмент выбирает экран, второй — параметр. Лимит поля — 64 байта, поэтому в callback_data кладут только адрес, а не содержимое страницы: текст и клавиатура генерируются в момент нажатия из ваших данных. Из этой разведённости следует приятное следствие — многостраничное меню обходится вообще без серверного состояния. Страница help:2 рендерится функцией от номера и клавиатурой, где Назад и Вперёд несут в callback_data адреса соседних экранов. Нажатие пришло — бот понял адрес, отрисовал страницу и перезаписал сообщение; хранить что-либо между нажатиями не требуется, и состояние диалога из десятого урока тут не нужно. Это та же логика, что и у URL в вебе: адрес описывает экран, а не результат.
Справка из трёх страниц
GOvar helpPages = map[int]string{
1: "Команды\n/today, /stats, /export",
2: "Формат записи\nбег 5 км, темп 6:10",
3: "Приватность\nданные только у вас",
}
func renderHelp(page int) (string, tgbotapi.InlineKeyboardMarkup) {
text := helpPages[page]
row := tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Назад",
fmt.Sprintf("help:%d", page-1)),
tgbotapi.NewInlineKeyboardButtonData("Вперёд",
fmt.Sprintf("help:%d", page+1)),
)
return text, tgbotapi.NewInlineKeyboardMarkup(row)
}Содержимое страниц — обычная карта, клавиатура собирается из двух кнопок, в callback_data которых зашиты адреса соседних страниц. fmt.Sprintf подставляет номер в строку адреса, и обработчик help:N понимает, что рендерить. Границы проверяются до сборки кнопок: на первой странице Назад не рисуется, на последней — Вперёд, иначе клик уведёт в пустую страницу.
Пользователь второй раз нажал кнопку, а текст и клавиатура не изменились — Telegram вернёт ошибку message is not modified: specified new message content and reply markup are exactly the same as the current content and reply markup of the message. Это не сбой, а защита от бессмысленных правок, но в логах новичка она множится десятками: клик по «Назад» на первой странице, повторное нажатие открытой вкладки, двойной тап. Лечение — сравнение нового текста с текущим до отправки либо тихое проглатывание именно этой ошибки по её описанию. Ретраить бессмысленно: содержание не станет другим от повтора. Проверка сравнения — двухстрочная: держите в обработчике последнюю отрисованную страницу в переменной и сверяйте адрес нажатия с ней, тогда запрос вообще не уходит на сервер.
Что ещё умеет правка и где её границы#
Кроме текста, у сообщения можно переписать подпись медиа — editMessageCaption через NewEditMessageCaption — и клавиатуру — editMessageReplyMarkup через NewEditMessageReplyMarkup. Для замены вложения целиком есть editMessageMedia с NewEditMessageMediaConfig: карточка товара меняет фото, не теряя позиции в чате. Все варианты адресуются той же парой chatID и messageID и упираются в то же правило: правится только исходящий трафик бота. Разметка нового текста включается отдельно: у конфигурации правки есть поле ParseMode, и для жирного с кодом ставится tgbotapi.ModeHTML из урока про форматирование, а пользовательские вставки по-прежнему проходят html.EscapeString. Живую перерисовку проверяет только настоящий Telegram: локальный Go с реальным токеном, сообщение с кнопками и два нажатия подряд покажут то, чего песочница не покажет, — скорость правки и поведение часиков на кнопке.
Спорное: не всё стоит рисовать в одном сообщении#
Освоив правку, новички тащат в неё весь трафик: статистика перерисовывается на месте, список записей тоже, история диалога исчезает. Я считаю это перебором, и граница у меня такая: правка — для интерфейсов, новое сообщение — для контента. Меню, настройки, карточка записи с кнопками действий — правильные кандидаты: следующий экран отменяет предыдущий, и оставлять старый в истории незачем. А список записей за неделю человек через месяц захочет перечитать — и найдёт его в чате, только если бот отправил его отдельным сообщением: правка затирает прежнее состояние без следа, и лента диалога остаётся единственным архивом. Практическое правило: если следующий экран отменяет предыдущий — редактируем; если дополняет — отправляем новое сообщение. Тогда диалог выглядит как лента результатов, поверх которой живёт одно перерисовываемое меню, и жалобы не приходят ни на стену сообщений, ни на потерянную историю. Правило вкусовое, но после трёх ботов закрепляется намертво.
Кейс: статистика с кнопкой «Ещё»#
Бот-дневник печатает записи за неделю, а записей пятнадцать — в одно сообщение они не помещаются красиво. Решение через правку: команда /stats отправляет первые пять записей и inline-клавиатуру с кнопкой «Ещё», несущей callback_data вида stats:2. Нажатие рендерит вторую страницу — следующие пять записей — и правит сообщение, дописывая их к тексту. Третий клик дополняет третью страницу, после чего кнопка исчезает: записей больше нет. Состояние живёт в самой callback_data — там только номер страницы, — и между нажатиями запоминать нечего: каждое нажатие самодостаточно. Подвох один: если пользователь в это время добавит новую запись, страницы поедут, и кнопка «Ещё» покажет запись дважды — для учебного бота терпимо, а для товарного каталога страницы стоит фиксировать на момент первого рендера. Вариант с новыми сообщениями здесь проигрывает сразу: пять кликов — пять сообщений-заготовок, чат захламлён, а правка держит весь список в одном блоке, который скроллится пальцем.
Методы редактирования в tgbotapi#
| Метод API | Конструктор | Что правит |
|---|---|---|
| editMessageText | NewEditMessageText | текст сообщения |
| editMessageCaption | NewEditMessageCaption | подпись медиа |
| editMessageReplyMarkup | NewEditMessageReplyMarkup | клавиатуру под сообщением |
| editMessageMedia | NewEditMessageMediaConfig | вложение целиком |
| stopPoll | NewStopPoll | останавливает голосование |
Проверка: адрес правки
Что нужно, чтобы переписать текст уже отправленного ботом сообщения?
Проверка: повторное нажатие
Пользователь второй раз нажал кнопку, текст и клавиатура не изменились. Что вернёт API?
Что выведет разбор callback_data
Прочитайте код и выберите, что он выведет:
data := "help:2"
parts := strings.Split(data, ":")
fmt.Println(parts[0], parts[1])Соберите правку сообщения
Впишите поле callback-запроса, конструктор правки и метод отправки.
Шесть строк перерисовки меню
Расставьте обработку нажатия: от чтения callback до ответа на запрос, как в меню из кейса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: редактирование сообщений
Меню научилось перерисовывать само себя — следующий шаг вынести бота из цикла обновлений: урок про webhook превращает бота в HTTP-сервер, а перед этим стоит привести в порядок работу с ошибками API — урок про лимиты и 429 рассказывает, что вернёт Send, когда скорость выше дозволенной. Механику callback-запросов напомнит урок про inline-клавиатуры, а правка подписей пригодится в медиа-уроке, когда карточка с фото перерисовывает описание. Работа с картами и срезами разобрана в уроке базового курса. Полный маршрут курса — на его странице.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Правка строится на EditMessageText: адрес — пара chatID и messageID, новый текст приходит третьим аргументом конструктора.
- В callback-нажатии адрес уже готов: CallbackQuery.Message несёт Chat.ID и MessageID того сообщения, где кнопка, а инлайн-сообщения адресуются inlineMessageID.
- callback_data — это адрес экрана вида help:2, а не содержимое страницы: лимит 64 байта, рендер в момент нажатия.
- Многостраничное меню обходится без серверного состояния: страница и её клавиатура — чистые функции от номера, тесты пишутся без Telegram.
- message is not modified — защита от бессмысленной правки; сравнивайте тексты до отправки или глотайте ошибку по описанию.
- Правится только исходящий трафик бота; у аккаунтов людей окно правки 48 часов, у бота срока нет.
- Правка — для интерфейсов, новое сообщение — для контента: следующий экран отменяет предыдущий или дополняет его.
Домашнее задание
Соберите в локальном боте трёхстраничную справку: страницы в карте, кнопки Назад и Вперёд с callback_data вида help:N, правка через NewEditMessageText по адресу из CallbackQuery.Message. Проверьте три вещи: переход по страницам перерисовывает одно и то же сообщение, повторное нажатие кнопки текущей страницы возвращает message is not modified, и эта ошибка обрабатывается в логе без шума. Затем добавьте пагинацию со списком: пять записей за раз и кнопка «Ещё» с адресом stats:N, которая дописывает следующую порцию к тексту.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли править сообщение пользователя, а не своё?
Нет: editMessageText работает только с исходящими сообщениями бота и инлайн-сообщениями. Чужие сообщения недоступны для правки — это ограничение платформы, а не библиотеки. Если нужно отреагировать на реплику человека, бот отвечает отдельным сообщением или реакцией из двадцать второго урока.
Куда девать содержимое страниц, если callback_data маленькая?
В саму callback_data кладётся только адрес: номер страницы или ключ экрана. Содержимое хранится в карте, базе или Redis и рендерится функцией в момент нажатия — так меню не боится длинных текстов и остаётся совместимым с любым хранилищем из одиннадцатого урока.
Почему при правке теряется ParseMode, если его не указать?
Правка заменяет содержимое целиком, включая оформление: у конфигурации свой ParseMode, и без него теги уйдут как текст. Каждый вызов правки выставляет tgbotapi.ModeHTML заново, если текст с разметкой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Мониторинг Go с Prometheus: метрики, Grafana, алерты
Курс по метрикам в Go: Counter, Gauge, Histogram, лейблы, PromQL, Grafana-дашборды, алерты и бизнес-метрики. 25 уроков с примерами client_golang.
- Понимать четыре типа метрик и выбирать правильный
- Экспортировать метрики из Go через promhttp
- Инструментировать HTTP-запросы и бизнес-события
- Писать PromQL-запросы: rate, sum, p99