Telegram Rich Text Editor: почему текст сообщения приходит пустым
Новый редактор Telegram шлёт не привычные entities, а дерево блоков в отдельном поле. Разбираем на живом примере, как это читать и почему старый код молча получает пустоту.
Telegram выкатил Rich Text Editor: заголовки, таблицы, вложенные цитаты, сноски, формулы — до 32 768 символов в одном сообщении. Для тех, кто просто пишет в мессенджере, это приятное обновление. Для тех, у кого на входящих сообщениях висит автоматизация, это тихая поломка: код продолжает работать, ошибок не выдаёт, а текста не видит.
Мы столкнулись с этим на своём пайплайне публикации в блог и разобрались, что происходит. Рассказываем по шагам — не потому, что путь был красивый, а потому, что почти каждая гипотеза по дороге оказалась неверной.
1. Симптом: сообщение есть, текста нет
Пайплайн простой: реакция 🔥 на сообщение в рабочем чате → скрипт читает текст → модель оформляет в статью → публикация. Работал месяцами.
После обновления сообщение, набранное новым редактором, приезжает так:
media: MessageMediaUnsupported
len(msg.message): 0
entities: []
Библиотека честно говорит: в этом сообщении есть тип, которого я не знаю. Текста при этом нет ни в одном из привычных полей.
Первая гипотеза была очевидной и неверной: раз есть форматирование — значит, разметка лежит в entities, надо просто её собрать. В Telegram форматирование традиционно передаётся именно так: плоская строка плюс список «с 10-го символа 5 символов жирным». Мы уже прикидывали, как писать конвертер с пересчётом смещений.
Проверка эту идею похоронила: поле entities пустое. Собирать нечего.
2. Причина: устаревшая схема протокола
Telegram общается по MTProto, и у протокола есть версия — слой (layer). Библиотека собрана под конкретный слой и типы из более новых просто не понимает.
| Версия библиотеки | Слой | Что приходит |
|---|---|---|
| Telethon 1.43.2 | 224 | MessageMediaUnsupported, пусто |
| Telethon 1.44.0 | 227 | RichMessage с деревом блоков |
В слое 227 появился набор новых типов: RichMessage, InputRichMessageHTML, InputRichMessageMarkdown. Это и есть новый редактор на уровне протокола.
Обновление библиотеки — обязательный первый шаг. Без него дальше идти некуда: поля с содержимым просто не существует.
3. Что приходит на самом деле
После обновления выясняется главное: rich-сообщение — это не текст с разметкой, а отдельная структура. Лежит она в своём поле, рядом с обычным (и пустым) message:
msg.rich_message.blocks
# [PageBlockHeading1, PageBlockParagraph, PageBlockBlockquote,
# PageBlockHeading2, PageBlockTable, PageBlockDivider,
# PageBlockHeading3, PageBlockPreformatted, PageBlockParagraph]
Это знакомые блоки Instant View — той самой технологии статей, которую Telegram использует много лет. Заголовок приходит как PageBlockHeading1, таблица — как PageBlockTable со строками и ячейками, где у ячеек шапки стоит флаг header.
Внутри каждого блока — дерево inline-разметки: TextPlain, TextBold, TextItalic, TextUrl, TextFixed, TextStrike, TextSpoiler, TextUnderline, а TextConcat склеивает их в цепочку.
flowchart TD
A["RichMessage"] --> B["blocks: список"]
B --> C["PageBlockHeading1"]
B --> D["PageBlockTable"]
B --> E["PageBlockParagraph"]
C --> F["TextPlain"]
E --> G["TextConcat"]
G --> H["TextBold + TextUrl + TextPlain"]
И вот тут хорошая новость, которая переворачивает всю задачу.
4. Почему это лучше, чем было
Разметка через entities — это боль по двум причинам. Во-первых, смещения считаются в UTF-16, а Python считает в символах: на кириллице и эмодзи всё разъезжается. Во-вторых, заголовков в этой модели нет вовсе — их приходится угадывать по контексту.
Дерево блоков снимает обе проблемы. Структура приходит структурой. Не нужно вычислять границы, не нужно догадываться, где был заголовок, — он приезжает типом PageBlockHeading1.
Обход дерева в Markdown — прямолинейный маппинг:
def block(b) -> str:
k = type(b).__name__
if k == "PageBlockHeading1":
return f"# {rich_text(b.text)}"
if k == "PageBlockHeading2":
return f"## {rich_text(b.text)}"
if k == "PageBlockBlockquote":
return "\n".join(f"> {ln}" for ln in rich_text(b.text).split("\n"))
if k == "PageBlockPreformatted":
return f"```{b.language or ''}\n{rich_text(b.text)}\n```"
if k == "PageBlockDivider":
return "---"
if k == "PageBlockTable":
return table_to_markdown(b)
return rich_text(getattr(b, "text", None))
Никаких эвристик — просто перевод одного формата в другой.
5. Результат
Тестовое сообщение с заголовком, таблицей цен, цитатой, блоком кода и ссылкой прошло конвертер и вернулось Markdown'ом, совпадающим с тем, что видно на экране:
# Тест RichMessage Builder v2
Поддерживаемое **форматирование**: *курсив*, ~~зачёркивание~~, `код`
> Это обычная цитата — для ключевых тезисов
## Сравнение моделей
| Модель | Вход $/1M | Выход $/1M | Контекст |
|---|---|---|---|
| Haiku | $0.80 | $4.00 | 200k |
| Sonnet | $3.00 | $15.00 | 200k |
Для пайплайна это означает конкретную вещь: модель, которая оформляет статью, получает готовую структуру вместо простыни текста. Её работа сужается с «восстанови по догадке, где тут были заголовки» до «причеши и добавь метаданные». Меньше расхождений с задуманным, дешевле по токенам.
6. Картинки внутри сообщения
Первая версия конвертера картинки просто выбрасывала — и это отдельная история о том, как легко ошибиться, когда смотришь не туда.
Мы проверили тестовое сообщение, увидели пустые поля documents и photos и решили, что медиа приезжает как-то иначе. Ошибка была в том, что сообщений было два: в одном картинка есть, в другом нет. Вывод сделали по второму, а применили к первому.
На самом деле механика простая и разнесена по двум местам. В дереве блоков лежит PageBlockPhoto — но не сама картинка, а только ссылка на неё:
PageBlockPhoto:
.photo_id = 5391211807038444906
.caption = PageCaption(text=..., credit=...)
.spoiler = False
А сам объект фото — в поле photos рядом с блоками. Связываются они по идентификатору:
by_id = {p.id: p for p in rm.photos}
photo = by_id[block.photo_id] # → скачиваем через клиент
Подпись под картинкой приезжает в PageCaption — её удобно класть в alt, а не терять.
Есть нюанс, который выясняется только на практике. Скачивать медиа нужно пока клиент подключён: если конвертер отработал, соединение закрылось, а у вас на руках остались одни идентификаторы — качать уже нечем. Поэтому обход дерева ставит на месте картинки плейсхолдер, а подмена на реальную ссылку происходит внутри той же сессии.
И главное правило для такого кода: упавшая картинка не должна ронять весь текст. У нас это выяснилось само собой — при локальном запуске не оказалось ключа доступа к хранилищу, заливка упала. Результат оказался правильным: иллюстрация выпала, причина ушла в лог, а статья со всей структурой доехала целиком. Пост важнее иллюстрации.
7. Грабли, о которых стоит знать
- Подчёркивание теряется. В Markdown его нет — либо отдавать текстом, либо городить HTML.
- Спойлер (
||текст||) — конструкция самого Telegram, обычный Markdown-рендер покажет её буквально. - Пустой
message— не признак пустого сообщения. Проверять надо оба поля, иначе автоматика будет отвечать «текста нет» на сообщение с текстом. Дезинформация хуже честной ошибки. - Похожих блоков больше, чем кажется. Обычная цитата и выносная — разные типы (
PageBlockBlockquoteиPageBlockPullquote). Мелкий текст внизу — третий (PageBlockFooter). Мы нашли их только потому, что в тестовом сообщении они были рядом и один из них молча пропал. - Покрытие блоков всегда неполное. Видео, коллажи, формулы, сворачиваемые секции — типов много, все не предусмотреть. Разумно оборачивать разбор блока в
tryи писать в лог имя неизвестного типа: тогда вы увидите пробел в покрытии по имени, а не по пустому результату.
8. Что это открывает
Обновление работает в обе стороны. Боты теперь тоже умеют отправлять rich text — таблицы, заголовки, вложенные цитаты, коллажи. Это значит, что отчёты, которые раньше приходилось рендерить в HTML и отдавать ссылкой, часть аудитории может получать прямо в чате: таблица со сравнением — таблицей, а не картинкой и не ссылкой.
Для сценариев «бот присылает структурированный результат» это заметно меняет расклад.
С чего начать
Если у вас есть автоматизация, читающая сообщения из Telegram:
- Проверьте версию библиотеки и слой протокола. Старый слой — гарантированная пустота на новых сообщениях.
- Проверьте, что код делает при пустом тексте. Молча пропускает? Отвечает «нет текста»? Это ваш будущий баг-репорт от растерянного пользователя.
- Добавьте чтение нового поля — обход дерева блоков в нужный вам формат. Это два десятка строк, а не переписывание пайплайна.
- Если нужны картинки — качайте их, не закрывая соединение, и делайте так, чтобы упавшая иллюстрация не уносила с собой весь текст.
Главный урок этой истории не технический. Мы потратили несколько итераций на гипотезы, каждая из которых звучала правдоподобно — и разбилась о первую же проверку на живых данных. Рабочим оказался только один подход: взять реальное сообщение и посмотреть, что в нём лежит на самом деле.
Следующий шаг: возьмите одно тестовое сообщение, набранное новым редактором, и распечатайте все непустые поля. Минута работы — и вы точно знаете, с чем имеете дело.








Комментарии
Войдите через Telegram, чтобы оставить комментарий:
Пока нет комментариев. Будьте первым.