# Telegram Rich Text Editor: почему текст сообщения приходит пустым

> Новый редактор Telegram шлёт не привычные entities, а дерево блоков в отдельном поле. Разбираем на живом примере, как это читать и почему старый код молча получает пустоту.

Источник: https://blog.uspeshnyy.ru/blog/telegram-rich-text-editor-pochemu-msg-text-pustoy/
Автор: Александр Успешный · blog.uspeshnyy.ru
Опубликовано: 2026-07-03
Теги: Telegram, автоматизация

---

Telegram выкатил Rich Text Editor: заголовки, таблицы, вложенные цитаты, сноски, формулы — до 32 768 символов в одном сообщении. Для тех, кто просто пишет в мессенджере, это приятное обновление. Для тех, у кого на входящих сообщениях висит [автоматизация](/blog/25-loop-workflow-fable/), это тихая поломка: код продолжает работать, ошибок не выдаёт, а текста не видит.

Мы столкнулись с этим на своём [пайплайне публикации в блог](/blog/otdel-marketinga-na-claude-code/) и разобрались, что происходит. Рассказываем по шагам — не потому, что путь был красивый, а потому, что почти каждая гипотеза по дороге оказалась неверной.

## 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`:

```python
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` склеивает их в цепочку.

И вот тут хорошая новость, которая переворачивает всю задачу.

## 4. Почему это лучше, чем было

Разметка через `entities` — это боль по двум причинам. Во-первых, смещения считаются в UTF-16, а Python считает в символах: на кириллице и эмодзи всё разъезжается. Во-вторых, заголовков в этой модели нет вовсе — их приходится угадывать по контексту.

Дерево блоков снимает обе проблемы. **Структура приходит структурой.** Не нужно вычислять границы, не нужно догадываться, где был заголовок, — он приезжает типом `PageBlockHeading1`.

Обход дерева в Markdown — прямолинейный маппинг:

```python
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'ом, совпадающим с тем, что видно на экране:

```markdown
# Тест RichMessage Builder v2

Поддерживаемое **форматирование**: *курсив*, ~~зачёркивание~~, `код`

> Это обычная цитата — для ключевых тезисов

## Сравнение моделей

| Модель | Вход $/1M | Выход $/1M | Контекст |
|---|---|---|---|
| Haiku | $0.80 | $4.00 | 200k |
| Sonnet | $3.00 | $15.00 | 200k |
```

Для пайплайна это означает конкретную вещь: модель, которая оформляет статью, получает **готовую структуру вместо простыни текста**. Её работа сужается с «восстанови по догадке, где тут были заголовки» до «причеши и добавь метаданные». Меньше расхождений с задуманным, дешевле по токенам.

## 6. Картинки внутри сообщения

Первая версия конвертера картинки просто выбрасывала — и это отдельная история о том, как легко ошибиться, когда смотришь не туда.

Мы проверили тестовое сообщение, увидели пустые поля `documents` и `photos` и решили, что медиа приезжает как-то иначе. Ошибка была в том, что сообщений было два: в одном картинка есть, в другом нет. Вывод сделали по второму, а применили к первому.

На самом деле механика простая и разнесена по двум местам. В дереве блоков лежит `PageBlockPhoto` — но не сама картинка, а только ссылка на неё:

```python
PageBlockPhoto:
    .photo_id = 5391211807038444906
    .caption  = PageCaption(text=..., credit=...)
    .spoiler  = False
```

А сам объект фото — в поле `photos` рядом с блоками. Связываются они по идентификатору:

```python
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. **Если нужны картинки** — качайте их, не закрывая соединение, и делайте так, чтобы упавшая иллюстрация не уносила с собой весь текст.

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

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