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:

  1. Проверьте версию библиотеки и слой протокола. Старый слой — гарантированная пустота на новых сообщениях.
  2. Проверьте, что код делает при пустом тексте. Молча пропускает? Отвечает «нет текста»? Это ваш будущий баг-репорт от растерянного пользователя.
  3. Добавьте чтение нового поля — обход дерева блоков в нужный вам формат. Это два десятка строк, а не переписывание пайплайна.
  4. Если нужны картинки — качайте их, не закрывая соединение, и делайте так, чтобы упавшая иллюстрация не уносила с собой весь текст.

Главный урок этой истории не технический. Мы потратили несколько итераций на гипотезы, каждая из которых звучала правдоподобно — и разбилась о первую же проверку на живых данных. Рабочим оказался только один подход: взять реальное сообщение и посмотреть, что в нём лежит на самом деле.

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

Комментарии

Войдите через Telegram, чтобы оставить комментарий:

Пока нет комментариев. Будьте первым.