---
title: "Редактирование сообщений и многостраничное меню"
description: "Редактирование сообщений telegram-бота на Go: EditMessageText по chatID и messageID, многостраничное меню на callback_data и ошибка message is not modified."
course: "Telegram-боты на Go: от эха до полноценного сервиса"
url: "https://golangmaster.ru/courses/telegram-bot/lessons/16-redaktirovanie-soobscheniy/"
published: "2026-09-19"
updated: "2026-09-25"
minutes: 18
---

# Редактирование сообщений и многостраничное меню

*Курс «Telegram-боты на Go: от эха до полноценного сервиса», урок 16 из 25. ~18 минут. Обновлено 25.09.2026. Источник: https://golangmaster.ru/courses/telegram-bot/lessons/16-redaktirovanie-soobscheniy/*

Редактирование сообщений в telegram-боте на Go строится на EditMessageText: бот не шлёт новую порцию текста в чат, а переписывает уже отправленное сообщение по паре chatID и messageID. Здесь разберём правку текста из callback-нажатия, дизайн callback_data для многостраничного меню, пагинацию списка по кнопке «Ещё» и ошибку message is not modified, которая ловит каждого, кто делает меню с кнопками.

Меню бота-дневника из урока про 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-нажатия

```go
if 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 в вебе: адрес описывает экран, а не результат.

### Справка из трёх страниц

```go
var 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 понимает, что рендерить. Границы проверяются до сборки кнопок: на первой странице Назад не рисуется, на последней — Вперёд, иначе клик уведёт в пустую страницу.

> **message is not modified: ловушка повторного нажатия.** Пользователь второй раз нажал кнопку, а текст и клавиатура не изменились — 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 | останавливает голосование |

### Проверка: адрес правки

**Вопрос:** Что нужно, чтобы переписать текст уже отправленного ботом сообщения?

- Только новый текст
- ✅ chatID, messageID и новый текст
- Номер бота из BotFather
- Сообщение бота редактировать нельзя

editMessageText адресуется парой chatID и messageID: при правке из нажатия оба лежат в CallbackQuery.Message. Без messageID править нечего — Telegram заменяет содержимое конкретного сообщения, а не последнего в чате. Конструктор возвращает конфигурацию, которую бот отдаёт методом Send, как обычное сообщение.

### Проверка: повторное нажатие

**Вопрос:** Пользователь второй раз нажал кнопку, текст и клавиатура не изменились. Что вернёт API?

- Дубликат сообщения
- ✅ Ошибку message is not modified
- Пустой ответ без ошибки
- Тайм-аут соединения

Telegram отказывается выполнять правку, если новое содержимое совпадает с текущим, и возвращает message is not modified. Ошибку сравнивают по описанию или предотвращают, сверив тексты до отправки; ретрай не поможет — повтор не меняет содержание. В телеметрии её удобно считать отдельным счётчиком: всплеск говорит о кнопке, которая ничего не меняет.

### Что выведет разбор callback_data

```go
data := "help:2"
parts := strings.Split(data, ":")
fmt.Println(parts[0], parts[1])
```

**Что выведет программа?**

- ✅ help 2
- help:2
- паника: индекс за границей
- 2 help

strings.Split режет строку по разделителю и возвращает срез из двух элементов: parts[0] равен help, parts[1] — 2. Такой же разбор стоит в обработчике меню: первый сегмент выбирает экран, второй — страницу. Число приходит строкой, и для сравнения с лимитами или арифметики нужен strconv.Atoi, который вернёт ошибку на мусорном сегменте — проверяйте её.

### Соберите правку сообщения

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

```go
cq := update.___
edit := tgbotapi.___(cq.Message.Chat.ID, cq.Message.MessageID, text)
bot.___(edit)
```

Подсказка: Апдейт несёт CallbackQuery, правка собирается NewEditMessageText с адресом из cq.Message, отправка — bot.Send.

Правильные ответы в пропусках: `CallbackQuery`, `NewEditMessageText`, `Send`

### Шесть строк перерисовки меню

Расставьте обработку нажатия: от чтения callback до ответа на запрос, как в меню из кейса.

Правильный порядок строк:

1. `cq := update.CallbackQuery`
2. `text, kb := renderHelp(2)`
3. `edit := tgbotapi.NewEditMessageText(chatID, msgID, text)`
4. `edit.ReplyMarkup = &kb`
5. `bot.Send(edit)`
6. `bot.AnswerCallbackQuery(tgbotapi.NewCallback(cq.ID, ""))`

### Карточки: редактирование сообщений

- **EditMessageText** — Метод Bot API: переписывает текст отправленного ботом сообщения по паре chatID и messageID. Возвращает обновлённое сообщение.
- **NewEditMessageText** — Конструктор tgbotapi: принимает chatID, messageID и новый текст, возвращает готовую конфигурацию правки для bot.Send.
- **message is not modified** — Ошибка API при повторной правке тем же содержимым: текст и клавиатура совпадают с текущими. Не ретраится, лечится сравнением до отправки.
- **Схема callback_data** — Путь с разделителем: menu:stats, help:2. Лимит 64 байта, хранится адрес экрана, а не содержимое — страница рендерится в момент нажатия.
- **EditMessageReplyMarkup** — Правка клавиатуры под сообщением без изменения текста: меню меняет кнопки, не перерисовывая весь блок.
- **48 часов** — Окно правки сообщений у аккаунтов людей в клиентах. Бот свои сообщения может переписывать без ограничения по сроку.

Меню научилось перерисовывать само себя — следующий шаг вынести бота из цикла обновлений: [урок про webhook](/courses/telegram-bot/lessons/18-webhook-bot/) превращает бота в HTTP-сервер, а перед этим стоит привести в порядок работу с ошибками API — [урок про лимиты и 429](/courses/telegram-bot/lessons/17-oshibki-i-limity/) рассказывает, что вернёт Send, когда скорость выше дозволенной. Механику callback-запросов напомнит [урок про inline-клавиатуры](/courses/telegram-bot/lessons/09-inline-klaviatura/), а правка подписей пригодится в [медиа-уроке](/courses/telegram-bot/lessons/12-media-i-fajly/), когда карточка с фото перерисовывает описание. Работа с картами и срезами разобрана в [уроке базового курса](/courses/go-basics/lessons/19-karty/). Полный маршрут курса — на [его странице](/courses/telegram-bot/).

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

- Правка строится на 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 заново, если текст с разметкой.
