Редактирование и удаление сообщений

Учимся менять уже отправленные сообщения бота прямо на месте — без новых сообщений и без замусоренного чата.

Редактирование сообщения — это когда бот не присылает новое сообщение, а переписывает то, что уже висит в чате: меняет в нём текст или кнопки. Старое сообщение остаётся на своём месте, просто его содержимое обновляется.

В прошлом уроке про inline-кнопки и callback наш «Цыплёнок-помощник» научился показывать кнопки прямо под сообщением и ловить нажатия. Но была одна неприятность: на каждое нажатие бот слал новое сообщение. Нажал три раза — три сообщения. Чат пухнет, найти нужное невозможно. Сегодня мы это починим.

Зачем вообще что-то редактировать

Вспомни любой нормальный интерфейс, которым ты пользуешься каждый день. Открываешь плеер — нажимаешь «пауза», и кнопка превращается в «play». Она не создаёт вторую кнопку рядом, она меняется на том же месте. Лайкнул пост — сердечко закрасилось прямо там, где было. Это и есть редактирование: один элемент, который обновляет сам себя.

А теперь представь бота-голосовалку для класса: «Куда пойдём после уроков?» с кнопками «Кафе», «Кино», «Домой». Каждый одноклассник нажимает свой вариант. Если бот на каждое нажатие шлёт новое сообщение со счётчиком, то после двадцати голосов в чате двадцать почти одинаковых карточек. Кошмар. А правильный бот держит одну карточку и просто переписывает в ней цифры: «Кафе — 12, Кино — 5, Домой — 3». Голос пришёл — цифра поменялась на месте.

Разница тут не косметическая, а смысловая. Когда бот плодит сообщения, пользователь теряется: какое из них актуальное? Где последний счёт? Приходится листать вверх и сравнивать. А когда есть одна живая карточка, всё внимание собрано в одной точке — ты смотришь на неё и сразу видишь свежие данные. Хороший интерфейс не заставляет искать, он показывает главное там, где ты уже смотришь. Именно это умение — обновлять сообщение на месте — отличает бота-новичка от бота, которым приятно пользоваться.

Вот к чему мы придём к концу урока. Карточка-счётчик, которая живёт в одном сообщении и обновляется по нажатию:

@chick_helper_bot
🍿 Куда пойдём после уроков?

Кафе — 3
Кино — 1
Домой — 0

[ 🍔 Кафе ]  [ 🎬 Кино ]  [ 🏠 Домой ]

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

Три инструмента, три задачи

У aiogram для этого есть ровно три метода, и каждый отвечает за свою задачу. Запомни их как три кнопки на пульте.

МетодЧто делает
edit_textПереписывает текст сообщения (и может заодно поменять кнопки)
edit_reply_markupМеняет только кнопки, текст не трогает
deleteУдаляет сообщение целиком, будто его и не было

Самое важное, что нужно понять сразу: все три метода ты вызываешь у самого сообщения, а не у объекта bot. У тебя на руках всегда есть это сообщение — либо то, что прислал пользователь (message), либо то, под которым он нажал кнопку (callback.message). Вот за него и дёргаешь.

Почему так удобно? Потому что объекту-сообщению уже известно, в каком оно чате и какой у него номер (id). Тебе не нужно вручную передавать «отредактируй сообщение номер такой-то в чате таком-то» — ты просто говоришь конкретному сообщению «изменись», и aiogram сам подставит нужные адреса. Это как в школьном дневнике: тебе не надо описывать словами, на какой странице исправить оценку, — ты просто берёшь ручку и правишь нужную клетку, потому что она у тебя перед глазами.

edit_text: переписываем текст

Начнём с самого частого. Пользователь нажал inline-кнопку, прилетел callback, и мы хотим обновить текст в той же карточке. Помнишь, в callback-хэндлере у нас есть объект callback, а внутри него — callback.message, то самое сообщение с кнопкой. Вот у него и зовём edit_text.

from aiogram import F
from aiogram.types import CallbackQuery

@dp.callback_query(F.data == "show_homework")
async def show_homework(callback: CallbackQuery):
    await callback.message.edit_text(
        "📚 Домашка на завтра:\n\n"
        "• Математика — № 45\n"
        "• История — параграф 7"
    )
    await callback.answer()

Результат: когда пользователь нажмёт кнопку, текст сообщения, под которым она стоит, заменится на список домашки. Новое сообщение не появится — старое просто перепишется.

Разберём по шагам, что тут происходит:

  1. callback.message — это сообщение бота, под которым висела кнопка. Именно его мы и редактируем.
  2. edit_text("...") — отправляет в Telegram запрос «возьми вон то сообщение и поставь туда вот этот текст».
  3. callback.answer() — обязательная мелочь из прошлого урока: говорит Telegram «нажатие принято», чтобы у пользователя на кнопке перестали крутиться часики.

Обрати внимание: edit_text мы пишем с await, как и почти всё в aiogram. Это сетевой запрос на серверы Telegram, бот ждёт ответа — поэтому асинхронно. Если асинхронность всё ещё кажется магией, держи простую картинку: бот — это официант, который относит твой заказ на кухню Telegram и ждёт, пока его выполнят. Пока он ждёт ответа на один запрос, await позволяет ему обслуживать других гостей, а не стоять столбом. Поэтому каждый поход «на кухню» — отправка, редактирование, удаление — помечается этим словом.

Ещё один момент, который часто упускают новички: edit_text заменяет текст целиком, а не дописывает к старому. То есть нельзя сказать «добавь строчку снизу» — ты всегда отдаёшь полный новый текст, который встанет вместо прежнего. Если хочешь сохранить часть старого содержимого, собери новую строку сам, включив в неё то, что нужно оставить. Это та же логика, что и с кнопками: Telegram мыслит не «изменениями», а «новым полным состоянием».

Меняем и текст, и кнопки разом

Часто после нажатия нужно не только переписать текст, но и подменить кнопки. Например, было «Записаться на турнир» — стало «✅ Ты в списке» и кнопка «Отменить» вместо «Записаться». edit_text умеет принять новую клавиатуру параметром reply_markup:

from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton

@dp.callback_query(F.data == "join")
async def join_tournament(callback: CallbackQuery):
    new_kb = InlineKeyboardMarkup(inline_keyboard=[
        [InlineKeyboardButton(text="❌ Отменить запись", callback_data="leave")]
    ])
    await callback.message.edit_text(
        "✅ Ты в списке на турнир! Ждём тебя в субботу.",
        reply_markup=new_kb
    )
    await callback.answer("Записал!")

Результат: текст карточки сменится на подтверждение записи, а единственная кнопка станет «Отменить запись». Вверху всплывёт короткое уведомление «Записал!».

edit_reply_markup: меняем только кнопки

Иногда текст менять не надо — он остаётся прежним, а вот кнопки должны обновиться. Классический пример: лайк под мемом. Текст «Смешной мем дня» не меняется, а на кнопке должна вырасти цифра: было «👍 0», стало «👍 1». Для такого есть edit_reply_markup — он трогает только клавиатуру.

likes = 0

@dp.callback_query(F.data == "like")
async def add_like(callback: CallbackQuery):
    global likes
    likes += 1
    new_kb = InlineKeyboardMarkup(inline_keyboard=[
        [InlineKeyboardButton(text=f"👍 {likes}", callback_data="like")]
    ])
    await callback.message.edit_reply_markup(reply_markup=new_kb)
    await callback.answer()

Результат: текст мема остаётся на месте, а на кнопке-лайке цифра увеличивается на единицу с каждым нажатием. Само сообщение не пересоздаётся.

Здесь мы каждый раз собираем новую клавиатуру с обновлённой цифрой и передаём её в edit_reply_markup. Telegram не умеет «дописать» текст на существующей кнопке — ты всегда отдаёшь целиком новый набор кнопок, который заменяет старый.

Маленькая честность: хранить счётчик в global likes — так делают только для примера. Когда у бота много чатов, одна общая переменная всё перепутает. Запоминать данные «по-взрослому» — в базе SQLite — мы научимся в модуле про хранение. Пока просто держи в голове, что это учебное упрощение.

delete: убираем сообщение совсем

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

@dp.callback_query(F.data == "close")
async def close_menu(callback: CallbackQuery):
    await callback.message.delete()
    await callback.answer("Закрыл меню")

Результат: сообщение с меню исчезнет из чата, а вверху мелькнёт подсказка «Закрыл меню».

Удалять можно не только по нажатию кнопки. Сообщение, которое прислал сам пользователь, тоже доступно — например, чтобы убрать из общего чата команду /start после ответа, чтобы не мусорить:

from aiogram.filters import CommandStart
from aiogram.types import Message

@dp.message(CommandStart())
async def start(message: Message):
    await message.answer("Привет! Я Цыплёнок-помощник 🐤")
    await message.delete()

Результат: бот ответит приветствием, а команду /start, которую написал пользователь, уберёт из чата. Останется только ответ бота.

Важная оговорка про сроки: своё сообщение бот может удалить только в течение 48 часов после отправки. Старше — Telegram уже не даст стереть. А чужие сообщения (которые написали люди) бот вправе удалять лишь там, где он администратор с соответствующим правом, — обычно в группах. В личке удалить сообщение собеседника он не может.

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

Собираем карточку-счётчик

А теперь обещанный пример из начала урока — голосовалка «Куда пойдём после уроков». Это и есть карточка, которая меняется по нажатию: одно сообщение, три кнопки, три счётчика. Соберём её целиком.

from aiogram import F
from aiogram.filters import Command
from aiogram.types import (
    Message, CallbackQuery,
    InlineKeyboardMarkup, InlineKeyboardButton,
)

votes = {"cafe": 0, "cinema": 0, "home": 0}

def build_card():
    text = (
        "🍿 Куда пойдём после уроков?\n\n"
        f"Кафе — {votes['cafe']}\n"
        f"Кино — {votes['cinema']}\n"
        f"Домой — {votes['home']}"
    )
    kb = InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="🍔 Кафе", callback_data="vote_cafe"),
        InlineKeyboardButton(text="🎬 Кино", callback_data="vote_cinema"),
        InlineKeyboardButton(text="🏠 Домой", callback_data="vote_home"),
    ]])
    return text, kb

@dp.message(Command("vote"))
async def open_vote(message: Message):
    text, kb = build_card()
    await message.answer(text, reply_markup=kb)

@dp.callback_query(F.data.startswith("vote_"))
async def add_vote(callback: CallbackQuery):
    option = callback.data.removeprefix("vote_")
    votes[option] += 1
    text, kb = build_card()
    await callback.message.edit_text(text, reply_markup=kb)
    await callback.answer("Голос учтён!")

Результат: по команде /vote бот пришлёт одну карточку с вопросом и тремя кнопками. Каждое нажатие увеличивает нужную цифру прямо в этой карточке, новые сообщения не появляются, а у нажавшего всплывает «Голос учтён!».

Главный приём здесь — функция build_card, которая собирает и текст, и кнопки. Мы вызываем её и при первом показе (message.answer), и при каждом обновлении (edit_text). Один источник правды — никакого дублирования. Представь, что текст карточки ты писал бы в двух местах: в хэндлере open_vote и в хэндлере add_vote. Однажды захочешь поменять формулировку вопроса — поправишь в одном месте, забудешь про второе, и карточка станет противоречить сама себе. А с build_card правка всегда одна, и оба сценария подхватывают её автоматически.

А callback.data.removeprefix("vote_") превращает "vote_cafe" в "cafe", чтобы попасть ключом в словарь votes. Хитрость с общим префиксом vote_ позволяет одним хэндлером (F.data.startswith("vote_")) ловить нажатия всех трёх кнопок сразу — не пришлось писать три почти одинаковых обработчика. Отрезали префикс, получили чистый ключ, прибавили единицу — и перерисовали карточку.

Кстати, разбор префикса — это чистый Python без всякого Telegram, его можно проверить прямо в браузере:

votes = {"cafe": 0, "cinema": 0, "home": 0}
data = "vote_cinema"
option = data.removeprefix("vote_")
votes[option] += 1
print(option)
print(votes)

Вывод:

cinema
{'cafe': 0, 'cinema': 1, 'home': 0}

Частые ошибки и подводные камни

1. «message is not modified»

Самая популярная ошибка новичка. Если ты вызываешь edit_text с тем же самым текстом и теми же кнопками, что уже стоят в сообщении, Telegram возмущается: «а что менять-то? тут всё то же самое» — и кидает ошибку TelegramBadRequest с текстом message is not modified. Лечится просто: редактируй сообщение только когда что-то реально изменилось. В нашей голосовалке цифра растёт каждый раз, поэтому проблемы нет, а вот если бы ты перерисовывал карточку без изменений — поймал бы эту ошибку.

2. Дёргаешь метод не у того объекта

Очень частая путаница: пишут callback.edit_text(...) вместо callback.message.edit_text(...). Запомни: редактируется сообщение, а callback — это «уведомление о нажатии», у него самого текста нет. Всегда идёшь через callback.message.

3. Сообщение слишком старое для удаления

Пытаешься удалить сообщение, которому больше 48 часов, — Telegram откажет. Если твой бот, скажем, по утрам чистит вчерашние напоминания, часть из них может оказаться старше суток с лишним, и delete на них упадёт. Не рассчитывай удалять что угодно и когда угодно.

4. Забыл callback.answer()

Если в callback-хэндлере отредактировал сообщение, но не позвал callback.answer(), у пользователя на кнопке продолжат крутиться часики секунд пять, пока Telegram сам не сдастся. Выглядит как зависший бот. Привычка простая: в конце любого callback-хэндлера — await callback.answer().

5. Не оборачиваешь в try/except там, где сообщение может исчезнуть

Пользователь может удалить карточку сам, прямо пока твой бот пытается её отредактировать. Тогда edit_text упадёт с ошибкой «сообщение не найдено». В простых учебных ботах это не страшно, но в боевом коде такие места оборачивают в try/except TelegramBadRequest, чтобы бот не падал из-за одного капризного пользователя.

Мини-практика: кнопка-переключатель

Теперь твой ход. Сделай боту «Цыплёнок-помощник» переключатель уведомлений — одну inline-кнопку, которая работает как тумблер в настройках телефона.

  • По команде /settings бот шлёт сообщение «⚙️ Настройки» с кнопкой «🔔 Уведомления: ВКЛ».
  • Нажал — кнопка превращается в «🔕 Уведомления: ВЫКЛ», текст сообщения не меняется.
  • Нажал ещё раз — снова «🔔 Уведомления: ВКЛ».

Подсказки: храни состояние в переменной notify = True и переворачивай его строкой notify = not notify. Поскольку меняются только кнопки, используй edit_reply_markup, а не edit_text. Текст на кнопке собирай через тернарник: "🔔 Уведомления: ВКЛ" if notify else "🔕 Уведомления: ВЫКЛ". И не забудь callback.answer() в конце.

Когда заработает — попробуй добавить вторую кнопку «Закрыть», которая удаляет сообщение настроек через delete. Так ты потренируешь сразу два инструмента из урока.

Итоги

Сегодня наш «Цыплёнок-помощник» из болтуна, который сыплет новыми сообщениями, превратился в бота с живым интерфейсом. Что теперь у тебя в арсенале:

  • edit_text — переписать текст сообщения (и при желании заодно кнопки через reply_markup);
  • edit_reply_markup — обновить только кнопки, не трогая текст;
  • delete — стереть сообщение целиком (помни про лимит в 48 часов на свои сообщения);
  • приём «одна функция собирает текст и кнопки» — чтобы рисовать и обновлять карточку из одного места.

Главная мысль урока: хороший бот не плодит сообщения, а аккуратно обновляет одно. Чат остаётся чистым, а интерфейс ощущается живым — прямо как в приложениях, которыми ты пользуешься каждый день.

В следующем уроке мы соберём из этих кнопок настоящее многоуровневое меню: главный экран, разделы, кнопка «назад». Там редактирование сообщений станет твоим основным инструментом — меню будет переключаться прямо внутри одной карточки, как экраны в приложении. До встречи!

Проверьте себя
1. Чем edit_text отличается от обычного message.answer?
Aedit_text переписывает уже отправленное сообщение, а answer присылает новое
Bedit_text работает только в группах, а answer — в личке
CМежду ними нет разницы, это синонимы
Dedit_text удаляет сообщение, а answer редактирует
2. У какого объекта в callback-хэндлере нужно вызывать edit_text, чтобы изменить карточку с кнопкой?
AУ самого callback: callback.edit_text(...)
BУ объекта bot: bot.edit_text(...)
CУ сообщения: callback.message.edit_text(...)
DУ dispatcher: dp.edit_text(...)
3. Когда удобнее использовать edit_reply_markup вместо edit_text?
AКогда нужно поменять текст, но оставить кнопки
BКогда нужно обновить только кнопки, а текст оставить прежним
CКогда нужно полностью удалить сообщение
DКогда сообщению больше 48 часов
4. Почему может появиться ошибка "message is not modified"?
AПотому что бот не админ в группе
BПотому что сообщение старше 48 часов
CПотому что забыли вызвать callback.answer()
DПотому что вызвали edit_text с тем же текстом и теми же кнопками, что уже стоят в сообщении
5. Какое ограничение есть у метода delete для собственных сообщений бота?
AБот может удалить своё сообщение только в течение 48 часов после отправки
BБот не может удалять свои сообщения вообще
CУдалять можно только сообщения с inline-кнопками
DУдаление работает только в личных чатах
6. Зачем в примере с голосовалкой текст и клавиатуру собирает отдельная функция build_card?
AТак требует синтаксис aiogram
BЧтобы один и тот же код рисовал карточку и при первом показе, и при каждом обновлении — без дублирования
CЧтобы обойти ограничение в 48 часов
DПотому что без функции edit_text не работает