Skip to content

Aiogram 3: Message handler и трюки с текстом

https://habr.com/ru/articles/821661/

Из базы остаётся только разобраться с сообщениями и работой с медиа. После этого можно будет переходить к более сложным и серьёзным темам, таким как: оплаты в боте, мидлвари, fsm-состояния, админ-панели и прочее. Но это всё потом, а сегодня мы рассмотрим следующие темы:

  • Объекты Message
  • Возможности по отправке текстовых сообщений (reply, forward, answer)
  • Возможности работы с текстовыми сообщениями (копирование, удаление, замена текста и клавиатур)
  • Форматирование текстовых сообщений (HTML)
  • Трюки работы с текстовыми сообщениями

Несмотря на кажущуюся простоту, тема достаточно важная и серьёзная.

Типы сообщений (контента)

В Telegram ботах предусмотрены следующие типы сообщений:

  • Стикеры (гифки)
  • Видео сообщения
  • Голосовые сообщения
  • Фото сообщения
  • Сообщения с документами
  • Сообщения с медиа-группами (самое неприятное для разработчика).

Каждый из этих типов сообщений обрабатывается с помощью Message-хендлера:

  • При работе через декораторы записывается как @dp.message или @router.message.
  • При регистрации записывается как dp.message.register.

Сегодня будем рассматривать только текстовые сообщения, а в следующий раз закроем тему с медиа-контентом. Многое будет иметь такую же логику и при работе с медиа (за исключением некоторых моментов).

Так как мы работаем с текстовыми сообщениями, наш тип контента будет TEXT. Для того чтобы отлавливать такие сообщения, нам необходимо использовать магический фильтр F.text.

После того как наш магический фильтр вычислил текстовое сообщение, у него появляется достаточно много опций, которые можно делать с объектом сообщения (гораздо больше, чем кажется).

Вот полный список возможностей работы с сообщениями в Telegram ботах на aiogram 3.x:

  • Достать из сообщения данные (данные о пользователе, ID сообщения, ID чата, где оно было отправлено и прочее)
  • Ответить на сообщение при помощи объекта bot и при помощи самого объекта message
  • Скопировать или переслать сообщение
  • Удалить сообщение
  • Имитировать набор ботом текстового сообщения
  • Отформатировать сообщение или достать форматирование сообщения от пользователя
  • Сообщение можно закрепить (принцип такой же как закреп в личном чате)
  • Изменить/удалить клавиатуру из сообщения

И, обратите внимание, что всё это время мы говорим про текстовые сообщения. А теперь давайте к практике.

Какие данные из сообщения наиболее часто используются в практике?

Чаще всего работаю с такими данными из объекта message в контексте текстовых сообщений:

  • Message.message_id (id сообщения)
  • Message.date (дата и время отправки сообщения – полезно для логирования)
  • Message.text (текст сообщения)
  • Message.html_text (забираем текст с htm-тегами)
  • Message.from_user (можно взять данные как: username, first_name, last_name, full_name, is_premium и прочее)
  • Message.chat (id, type, title, username/channel)

Давайте попрактикуемся, чтобы было более понятно, что к чему.

Закрепим тему со значениями из объекта сообщения. Давайте представим, что у нас есть задача написать хендлер, который будет реагировать на текстовое сообщение, содержащее слово «охотник». Если бот будет видеть, что кто-то написал такое сообщение, он будет выполнять 2 действия:

  1. Отвечать на сообщение каким-то текстом (цитатой, обычным ответом, ответом через пересылку сообщения)
  2. Бот сформирует словарь с такими данными:
    • Телеграмм айди пользователя
    • Полное имя
    • Логин
    • id сообщения
    • Время отправки сообщения

Далее просто выведем этот словарь в консоль.

python
@start_router.message(F.text.lower().contains('охотник'))
async def cmd_start(message: Message, state: FSMContext):
    # отправка обычного сообщения
    await message.answer('Я думаю, что ты тут про радугу рассказываешь')

    # то же действие, но через объект бота
    await bot.send_message(chat_id=message.from_user.id, text='Для меня это слишком просто')

    # ответ через цитату
    msg = await message.reply('Ну вот что за глупости!?')

    # ответ через цитату, через объект bot
    await bot.send_message(chat_id=message.from_user.id, text='Хотя, это забавно...',
                           reply_to_message_id=msg.message_id)

    await bot.forward_message(chat_id=message.from_user.id, from_chat_id=message.from_user.id, message_id=msg.message_id)

    data_task = {'user_id': message.from_user.id, 'full_name': message.from_user.full_name,
                 'username': message.from_user.username, 'message_id': message.message_id, 'date': get_msc_date(message.date)}
    pprint(data_task)

Давайте посмотрим на результат В консоли:

python
{'user_id': 0000000, 
 'full_name': 'Alexey Yakovenko', 
 'username': 'yakvenalexx', 
 'message_id': 337, 
 'date': datetime.datetime(2024, 6, 13, 19, 53, 1, tzinfo=TzInfo(UTC))}

Обратите внимание на то, что дата отправки сообщения указывается в формате datetime в часовом поясе UTC (координированное всемирное время).

При необходимости конвертации в московское время можно использовать такой подход:

python
import pytz

def get_msc_date(utc_time):
    # Задаем московский часовой пояс
    moscow_tz = pytz.timezone('Europe/Moscow')
    # Переводим время в московский часовой пояс
    moscow_time = utc_time.astimezone(moscow_tz)
    return moscow_time

'date': get_msc_date(message.date)

В таком случае это значение будет передано в таком формате:

python
datetime.datetime(2024, 6, 13, 22, 57, 10, tzinfo=<DstTzInfo 'Europe/Moscow' MSK+3:00:00 STD>)

img.png

В этом коде мы задействовали несколько методов, но каждый из них выполнил одну задачу: отправку сообщений.

Как вы видели, часть методов использовала за основу объект bot, а другая message.

python
# отправка обычного сообщения
await message.answer('Я думаю, что ты тут про радугу рассказываешь')
    
# ответ через цитату
msg = await message.reply('Ну вот что за глупости!?')

Эти два метода достаточно удобны и лаконичны. Основная их особенность в том, что они не требуют обязательного указания id, кому необходимо сделать отправку и на какое сообщение – это всё уже скрыто в самом методе answer и reply.

Советую там, где это возможно, использовать именно эти методы.

Такие методы, как bot.send_message, bot.send_message с флагом reply_to_message_id и bot.forward_message заслуживают больше внимания, как и эта запись:

python
msg = await message.reply('Ну вот что за глупости!?')

Когда происходит отправка сообщения при помощи bot, всегда обязательным параметром будет указание, кому необходимо отправить сообщение. В контексте моего примера:

python
await message.answer('Я думаю, что ты тут про радугу рассказываешь')

await bot.send_message(chat_id=message.from_user.id, text='Для меня это слишком просто')

Выполнили аналогичные действия с одинаковым результатом, в связи с чем не было смысла выносить это в отдельный объект, но бывают ситуации когда отправку через message.answer сделать невозможно.

Рассмотрим эту ситуацию на примере рассылки в телеграмм боте из админ-панели.

  • Администратор пишет сообщение
  • Выбирает отправить всем
  • Бот забирает с базы данных айди сообщений
  • При помощи bot.send_message производит рассылку.

Дела с методом bot.forward_message и bot.send_message, когда явно указываем на какое сообщение нужно дать ответ, уже чуть сложнее. Основная трудность тут появляется в обязательном указании message_id (идентификатора сообщения, на которое нужно ответить/переслать).

Но тут есть один очень большой плюс. Когда вы знаете ID сообщения, у вас появляются практически неограниченные возможности по работе с ним (когда бот является владельцем этого сообщения - иначе нужно прибегнуть к трюкам о которых расскажу далее). К примеру, вы можете постоянно перезаписывать одно и то же сообщение, имитируя набор текста и создавая непрерывную анимацию (наверняка вы с таким сталкивались).

Думаю, это будет проще объяснить на конкретном примере. Я продемонстрирую вам, как делать ботом имитацию набора текста.

Конструкция для имитации набора текста будет выглядеть так:

python
from aiogram.utils.chat_action import ChatActionSender

async with ChatActionSender(bot=bot, chat_id=message.from_user.id, action="typing"):
    await asyncio.sleep(2)

Пауза нужна для того, чтобы отправка текстового сообщения не прошла мгновенно. Тут есть одна интересная особенность. Конструкция, которую я прописал выше, отвечает только за имитацию набора текста. То есть вам ничего не мешает чередовать имитацию с асинхронными паузами await asyncio.sleep, создавая полноценную имитацию общения с живым человеком (знаете, когда вам печатают, отвлекаются, а потом снова продолжают печатать).

Сейчас я покажу вам, как можно изменить текст сообщения со временем. Тут важно понимать, что бот может менять только те сообщения, которые сам отправил. Но и тут есть один интересный трюк, которым я с вами поделюсь.

Давайте создадим хендлер, который будет реагировать на команду /test_edit_msg.

python
@start_router.message(Command('test_edit_msg'))
async def cmd_start(message: Message, state: FSMContext):
    # Бот делает отправку сообщения с сохранением объекта msg
    msg = await message.answer('Отправляю сообщение')

    # Достаем ID сообщения
    msg_id = msg.message_id

    # Имитируем набор текста 2 секунды и отправляем какое-то сообщение

    async with ChatActionSender(bot=bot, chat_id=message.from_user.id, action="typing"):
        await asyncio.sleep(2)
        await message.answer('Новое сообщение')

    # Делаем паузу ещё на 2 секунды
    await asyncio.sleep(2)

    # Изменяем текст сообщения, ID которого мы сохранили
    await bot.edit_message_text(text='<b>Отправляю сообщение!!!</b>', chat_id=message.from_user.id, message_id=msg_id)

В коде оставлены комментарии. Нужно обратить внимание, на следующую строку:

python
await bot.edit_message_text(text='<b>Отправляю сообщение!!!</b>', chat_id=message.from_user.id, message_id=msg_id)

Во-первых, мы задействовали новый метод — edit_message_text. Он принимает новый текст, чат, в котором нужно изменить сообщение, и, самое главное, ID сообщения. После изменения текста сообщения его ID не меняется. Это говорит о том, что пока сообщение существует, его можно сколько угодно раз заменять. Однако будьте осторожны с этим методом: если пользователь удалит сообщение, бот просто упадет при попытке его изменить.

Без обработки ошибки ловим:

python
aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: message to edit not found

Так что будьте внимательны!

Похожую ошибку мы получим, если попытаемся изменить чужое сообщение. Допустим, мы будем менять сообщение с командой /test_edit_msg:

python
@start_router.message(Command('test_edit_msg'))
async def cmd_start(message: Message, state: FSMContext):
    # Бот пытается изменить сообщение, которое не отправлял
    await bot.edit_message_text(text='<b>Отправляю сообщение!!!</b>', chat_id=message.from_user.id, message_id=message.message_id)

Получим:

python
aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: message can't be edited

Тут бот скажет, что сообщение нельзя изменить, и это правда, ведь бот не является автором этого сообщения. А теперь посмотрите на трюк, который можно тут сделать:

  • Бот присвоит это сообщение себе (скопирует).
  • Удалит сообщение от пользователя.
  • Перезапишет уже скопированное сообщение и отправит.

Смотрим:

python
@start_router.message(Command('test_edit_msg'))
async def cmd_start(message: Message, state: FSMContext):
    new_msg = await bot.copy_message(
        chat_id=message.from_user.id,
        from_chat_id=message.from_user.id, 
        message_id=message.message_id
    )
    await message.delete()
    await bot.edit_message_text(
        text='<b>Отправляю сообщение!!!</b>',
        chat_id=message.from_user.id,
        message_id=new_msg.message_id
    )

Обратите внимание, что тут мы задействовали новые методы new_msg = await bot.copy_message и await message.delete(). У метода await message.delete() есть аналог из метода bot. Записывается так:

python
await bot.delete_message(chat_id=message.from_user.id, message_id=message.message_id)

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

Немного про клавиатуры

Сообщения, в частности текстовые, могут сопровождаться тем или иным типом клавиатур. Посмотрим, как удалить клавиатуру и как заменить клавиатуру в сообщении.

Для начала давайте импортируем метод для удаления клавиатур (работать будет как на текстовые и на инлайн клавиатуры):

python
from aiogram.types import ReplyKeyboardRemove

Этот метод нужно использовать, когда по сценарию пользователь переходит от одного состояния к другому. Например, текстовая клавиатура с выбором пола «Мужской» и «Женский». Он делает выбор, а после его ждет новый вопрос, например, «Укажите год рождения».

Если не удалим клавиатуру (не заменим её на другую в рамках сценария), клавиатура так и будет висеть. У новичков бывает такая проблема, когда пользователь выполнил сценарий, а на одном из этапов был выбор города. После этого клавиатура с выбором города преследует его.

Не допускайте такого. Тут всё просто: вместо указания клавиатуры в методе reply_markup передавайте ReplyKeyboardRemove(). Это ещё обязательно рассмотрим в теме FSM.

Иногда клавиатуру нужно заменить в рамках одного конкретного сообщения, например, со временем. Изменить клавиатуру в сообщении (удалить) можно несколькими способами.

  1. Тут используем знакомый нам edit_message_text. Переписываем всё сообщение и прямо к нему привязываем клавиатуру.

    python
    await bot.edit_message_text(chat_id=message.chat.id, message_id=msg.message_id, text='Пока!', reply_markup=inline_kb())

    Обратите внимание: данный метод ожидает именно инлайн клавиатуру. Передав в этом методе текстовую клавиатуру, вы получите:

    python
    input_value=ReplyKeyboardMarkup(keybo...ню:', selective=None), input_type=ReplyKeyboardMarkup] For further information visit https://errors.pydantic.dev/2.7/v/model_type

    Но заменять инлайн клавиатуры в рамках одного message_id можно до бесконечности.

  2. Тут мы заменили только клавиатуру, оставив текст без изменений.

python
await bot.edit_message_reply_markup(chat_id=message.chat.id, message_id=msg.message_id, reply_markup=inline_kb())

Как же нам добавить текстовую клавиатуру к сообщению?

К сожалению, прямого метода замены нет, но ничего не мешает использовать костыль в виде копирования сообщения, верно?

python
@start_router.message(Command('test_edit_msg'))
async def cmd_start(message: Message, state: FSMContext):
    msg = await message.answer('Привет!')
    await asyncio.sleep(2)
    old_text = msg.text
    await msg.delete()
    await message.answer(old_text, reply_markup=main_kb(message.from_user.id))

Вот такая вышла незамысловатая конструкция. Для того чтобы так не изощряться, стараюсь отдавать преимущество инлайн клавиатурам и вам того советую. Ну а текстовые кнопки учитесь вплетать в сценарии, чтобы они логичным образом появлялись и исчезали (перезаписывались другими текстовыми клавиатурами или удалялись через метод ReplyKeyboardRemove()).

Форматирование текста

Скажу честно, никогда не использую Markdown форматирование в работе с aiogram 3 и сейчас объясню почему.

  • Во-первых, как по мне, синтаксис там неудобный.
  • Во-вторых, на старте aiogram 3 вокруг Markdown форматирования крутилось много багов, и те, кто переводили свои проекты на тройку, сильно страдали из-за того, что выбрали Markdown.

Далее будет говорить про форматирование на примере HTML, но можете использовать тот тип, который более приятен.

Для того чтобы к каждому своему сообщению не передавать parse_mode="HTML" при инициации бота, советую вам использовать такую конструкцию:

python
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode

bot = Bot(token=config('TOKEN'), default=DefaultBotProperties(parse_mode=ParseMode.HTML))

В данном случае конструкция DefaultBotProperties(parse_mode=ParseMode.HTML) установит HTML форматирование по умолчанию к каждому сообщению.

Давайте рассмотрим основные HTML-теги, которые можно использовать в форматировании:

html
<b>Жирный</b>
<i>Курсив</i>
<u>Подчеркнутый</u>
<s>Зачеркнутый</s>
<tg-spoiler>Спойлер (скрытый текст)</tg-spoiler>
<a href="http://www.example.com/">Ссылка в тексте</a>
<code>Код с копированием текста при клике</code>
<pre>Спойлер с копированием текста</pre>

Так форматированный текст выглядит в боте.

img_1.png

Используйте их внимательно, так как есть одна очень неприятная штука. Некоторые пользователи решают использовать < и > в своих логинах или в отправляемых боту сообщениях.

Если в коде включен режим HTML форматирования текста, бот ловит ошибку:

python
aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: can't parse entities: Unsupported start tag "<>"

Используем html.escape для решения этой проблемы. Делаем импорт:

python
from html import escape

А далее те места, где может встретиться некорректный тег, просто прогоняем через метод escape.

Ну и последнее, захват отформатированного текста. Сейчас дам пример, чтобы вы сразу поняли (реальная практика).

В админке добавлял возможность создания постов с отформатированным текстом (с тегами HTML). Клиент перед отправкой форматировал текст (просто через стандартные методы Telegram: выделил мышкой, выбрал «Жирное» и так далее). После этих действий отформатированный текст отправлялся боту, а тот захватывал HTML при помощи message.html_text, а после делал запись в базу данных именно с тегами. Далее, при отображении поста, бот просто брал текст с HTML тегами и отправлял уже отформатированный текст. Такой себе карманный HTML редактор в Telegram.

Вот простой пример:

python
@start_router.message(Command('test_edit_msg'))
async def cmd_start(message: Message, state: FSMContext):
    msg = await message.answer('<b>ПРИВЕТ!</b>')
    print(msg.html_text)

Бот в консоли вывел:

html
<b>ПРИВЕТ!</b>

Хотя мы не сохраняли нигде переменной с HTML тегом.

Contacts: teffal@mail.ru