Приветствую! Благодарю за подписки, лайки и прочий позитивный отклик на мою деятельность. Продолжим.

Мы уже обсудили:

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

  • Объекты Message

  • Возможности по отправке текстовых сообщений (reply, forward, answer)

  • Возможности работы с текстовыми сообщениями (копирование, удаление, замена текста и клавиатур)

  • Форматирование текстовых сообщений (HTML)

  • Трюки работы с текстовыми сообщениями (уверен, что многое из того, что я вам сегодня покажу, вы не знали)

Несмотря на кажущуюся простоту, тема достаточно важная и серьёзная. Даже если вы уже имеете опыт работы с aiogram 2 или aiogram 3, настоятельно рекомендую вам ознакомиться с этим текстом.

До многого из того, что будет далее изложено, я пришёл через кучу ошибок и шишек.

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

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

  • Стикеры (гифки)

  • Видео сообщения

  • Голосовые сообщения

  • Фото сообщения

  • Сообщения с документами

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

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

  • При работе через декораторы записывается как @dp.message или @router.message (ранее запись имела вид @dp.message_handler).

  • При регистрации записывается как dp.message.register.

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

Так как мы работаем с текстовыми сообщениями, наш тип контента будет TEXT. Если вы уже читали мои прошлые статьи по теме aiogram 3, то знаете, что для того чтобы отлавливать такие сообщения, нам необходимо использовать магический фильтр F.text (аналог content_type=ContentType.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. Бот сформирует словарь с такими данными:

    • Телеграмм айди пользователя

    • Полное имя

    • Логин

    • Айди сообщения

    • Время отправки сообщения

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

Я понимаю, что пример может и глуповатый, но когда вы посмотрите на этот код, то сразу поймёте, о чём я тут вам рассказывал.

@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)}
    print(data_task)

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

{'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 (координированное всемирное время).

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

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)

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

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

Давайте разбирать код

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

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

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

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

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

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

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

Давайте по порядку.

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

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

и

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

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

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

  1. Администратор пишет сообщение

  2. Выбирает отправить всем

  3. Бот забирает с базы данных айди сообщений

  4. При помощи bot.send_message произовдит рассылку.

Удобно, правда?

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

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

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

Импортируем:

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.

@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)

В коде оставлены комментарии. Единственное, на что нужно обратить внимание, — строка:

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

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

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

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

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

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

@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)

Получим:

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

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

  • Бот присвоит это сообщение себе (скопирует).

  • Удалит сообщение от пользователя.

  • Перезапишет уже скопированное сообщение и отправит.

Смотрим:

@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. Записывается так:

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

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

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

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

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

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

from aiogram.types import ReplyKeyboardRemove

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

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

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

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

Способ 1:

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

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

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:

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

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

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

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

@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" при инициации бота, советую вам использовать такую конструкцию:

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-теги, которые можно использовать в форматировании:

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

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

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

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

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

from html import escape

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

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

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

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

@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)

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

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

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

Заключение

Сегодня мы сделали максимальный разбор темы работы с текстовыми сообщениями через aiogram 3. Теперь вы можете делать ветер.

На написание подобных статей тратится куча времени, сил и энергии. Поэтому, если хотите, чтобы подобный материал выходил чаще, не забывайте давать отклик через подписки, лайки и комментарии. Расклад такой: по некоторым публикациям у меня закладок переваливает за 200, а карма на уровне фола.

Благодарю вас за внимание. До скорого.

Комментарии (2)


  1. Pol1mus
    16.06.2024 04:58

    Как принять текстовое сообщение размером больше 4к символов. Телеграм клиент их режет на части и отправляет части одну за другой. В обработчик боту прилетают разные части одного сообщения и бот реагирует на них раздельно.

    Как отправить текстовое сообщение с форматированием если оно больше 4к, порезать на части не испортив хтмл теги. В телеграме хтмл не настоящий так что обычный хтмл парсер его не порежет.


  1. Groosha
    16.06.2024 04:58

    Буквально в первом же примере используется необъявленная переменная bot. Откуда она? (Подсказка: должна быть прокинута как параметр хэндлера)