Русский
У Telegram отросло дерево блоков
О чём это: новые Rich Messages в Telegram — посты в форме документа, а не чат-сообщения, — что они дают тому, кто публикует длинные тексты, и что trip2g собирается построить, чтобы ими пользоваться. Выводы сразу. Rich messages несут настоящие заголовки, медиа стоит рядом с тем абзацем, к которому относится, и места в восемь раз больше: документ, а не сообщение. Бот умеет отправлять их уже сегодня, бесплатно и без замка — и в личные чаты, и в каналы: за этой фразой стоит около 250 живых вызовов API, и замка по типу чата не нашлось ни в одном. И они отрисовываются: я открыл тестовый канал в настоящем клиенте — всё выглядит так, как и задумано. Работы оказалось куда меньше, чем выглядело: без обновления библиотеки, без MTProto, без новой зависимости — просто второй рендерер поверх goldmark AST, по которому мы и так ходим. Цена — включение по каждому чату, проверка после каждой отправки, кастомные эмодзи, теряющиеся по дороге, и одно продуктовое решение: каналы, куда мы публикуем через аккаунт пользователя, остаются на классике.
Сообщение в чате и документ — разные формы
В Telegram мне попались посты, не похожие на сообщения. Настоящие заголовки. Таблица. Видео между двумя абзацами, а не пришпиленное сверху к подписи. Telegram называет это Rich Messages; метод Bot API — sendRichMessage, по changelog он вышел 11 июня 2026 в Bot API 10.1, а типизированное представление blocks добавили 14 июля в 10.2.
Годами Telegram держал любого отправителя в форме чат-сообщения: 4096 символов, одна подпись на альбом, никакой сущности заголовка ни за какие деньги. И вот боту выдали формат документа.
Что там внутри на самом деле
Структура переживает дорогу. Шесть уровней заголовков, и уровень сохраняется: #…###### приезжают как size: 1..6, а не расплющиваются в жирный. Вложенные списки держат глубину. Списки задач держат состояние чекбокса. Разделители есть. Для путевой заметки, собранной из ## Day 1 и ## Day 2, это разница между опубликованной статьёй и стеной текста — а сегодня эти строки не приезжают пониженными, они не приезжают вообще.
Медиа в порядке документа. Фото или видео — это блок, и стоит он там, куда его поставил автор. Пятьдесят штук в одном сообщении; двадцать четыре из них прогнаны от начала до конца в посте канала, и каждая подпись доехала. Старая модель делала длинный текст структурно невозможным: альбом — это стена фотографий, за ней стена текста и одна подпись на всё. У rich messages альбома нет: его заменяют блоки <tg-collage> и <tg-slideshow> — на случай, когда сетка и правда то, что нужно. Медиа Telegram забирает на своей стороне по обычной HTTPS-ссылке: один .mp4 вернулся как Video с верными длительностью, размерами и весом файла, сгенерированной миниатюрой и постоянным file_id.
Место. 32768 символов против 4096 у сообщения и 1024 у подписи. 500 блоков верхнего уровня. 50 медиа. Каждый лимит померен ровно на границе в личном чате: 32768 проходят, 32769 — нет; 500 проходят, 501 — нет; 50 проходят, 51 — нет; а лимит на медиа, перемеренный в канале, лёг ровно туда же. Элементы списка, оказывается, в эти 500 вообще не считаются: список из 501 пункта — один блок, и он проходит, а 501 абзац — не проходит. Для заметок из сплошных буллетов, к которым подталкивает Obsidian, потолок тем самым тихо поднимается. И всё это — одно сообщение, так что статья с полусотней картинок должна стоить один слот рейт-лимита там, где альбом плюс текст стоят несколько; лимиты померены, а вот это следствие — вывод.
Место и видимое место — не одно и то же. На экране длинный пост сворачивается за Show more. Ни один прогон API этого не нашёл бы: в документации Bot API об этом ни слова, а единственный вторичный источник, который называет порог, говорит про восемь тысяч символов — не то число, под которое я стал бы строить. Померил позже — источник оказался примерно прав, но померилось это с экрана, а не с эндпоинта. 32768 символов ёмкости — это не 32768 символов видимого поста, и эта разница важнее самой ёмкости.
Чего пайплайн публикации раньше отправить не мог. Важнее всего для нас <details>/<summary>, потому что коллауты Obsidian ложатся на него один в один — а сегодня коллауты выбрасываются целиком, и case для них нигде в internal/markdownv2/ нет. Узел коллаута уже несёт Foldable и Expanded; эти два поля и есть атрибут open. Каждый свёрнутый блок-предупреждение в каждой существующей заметке становится родным сворачиваемым блоком, с сохранённым авторским состоянием сворачивания. Второе — таблицы: GFM-таблицы с выравниванием по колонкам приезжают таблицами, тогда как сегодня они попадают в ту же ветку и исчезают с предупреждением. А карта — это блок, который собирается из широты, долготы и зума; для платформы про путешествия — самый очевидный, и единственный пункт этого списка, который пока никто не отправлял.
Дальше длинный хвост, который я просто перечислю, не защищая: сноски, LaTeX строчный и блочный, ==highlight== как настоящий участок marked вместо спойлера, которым он становится сейчас, выносные цитаты с <cite>, иллюстрации с <figcaption>, нижние и верхние индексы.
Не всё, что читается как новая возможность, ново. Цитаты, блок кода с меткой языка и спойлеры уходят через классический пайплайн уже сегодня; rich messages дают им семантику получше, а не первый выход в свет. И ровно одно работает в обратную сторону: кастомные эмодзи, которые классический путь отправляет спокойно, в rich-сообщении не выживают вовсе. Rich-формат приносит не расширенный список тегов. Он приносит то, что теги теперь висят на документе.
Якоря — тёмная лошадка. Я ждал хрупкий хак, готовый деградировать, а получил структуру. <a name="x"> возвращается полноценным блоком anchor, <a href="#x"> — типизированным участком anchor_link, а <tg-reference> даёт настоящие сноски. Значит, длинный пост может нести внутри себя работающее оглавление, и API считает это оглавление структурой, а не текстом, который просто похож на ссылки. Это было предсказание, прочитанное по дереву разбора. Теперь это то, что я видел работающим на экране. trip2g уже считает то, что для этого нужно: extractHeadingsAndGenerateIDs (internal/model/note.go:850-887) проставляет id каждому заголовку каждой заметки, а поверх этого веб возит целую подсистему оглавления (assets/toc/src/index.ts). Этой структуре в Telegram просто некуда было деваться. Каждый якорь — блок, так что статья на полсотни заголовков тратит полсотни из своих пятисот.
Стоит развести три уровня. Видел на экране, в настоящем клиенте: заголовки, таблицы, медиа в порядке документа, сворачиваемые блоки, ссылки и якоря. Померено на живом API, но глазами не просмотрено: всё остальное из перечисленного выше — в личном чате, а потом ещё раз в канале. Есть в документации и ни разу не попало в прогон: карты, разделители, нижние и верхние индексы, аудио, документы и анимации, 16 уровней вложенности, 20 колонок таблицы. Третий список ужался настолько, что единственный пункт в нём, который меня волнует, — карты.
Честный итог — не «Telegram улучшил форматирование». Telegram перестал быть форматом чат-сообщения и отрастил формат документа — и бот получил его первым.
Кто может это отправить и почему асимметрия важна
Бот умеет уже сегодня, и Premium ему не нужен. Убить идею целиком могло одно предположение — Premium, а страница Bot API нигде не связывает Premium с rich messages ни в одну, ни в другую сторону. Поэтому я отправил агента померить: одноразовый бот на аккаунте без Premium, около 130 живых вызовов, примерно сорок отправленных rich messages. Нигде ни одной ошибки про Premium, и getMe не отдаёт на всё это ни единого флага возможностей — нечего проверять, нечего включать.
И в канал он тоже умеет — а это тот путь, который и имеет значение. Когда я записывал это в первый раз, каналы не были прогнаны, и это был блокер релиза: готовый рендерер, которому некуда публиковать, писать незачем. Второй заход, около 120 вызовов по приватному каналу, где бот администратор, закрыл вопрос с первой попытки: целая статья с H1, двумя H2, GFM-таблицей, блоком кода на Go, цитатой и видео посреди документа, которое Telegram забрал по HTTPS-ссылке сам, — одним вызовом. Между двумя типами чатов ничего не отвалилось. Замка по типу чата нет.
Человеку редактор дали в этом месяце. Desktop 6.9 выкатил «Rich Text Formatting for Bots» 9 июня — за два дня до того, как появился сам метод API; редактор приехал с Desktop 7.0 в середине июля. Стоит ли замок по Premium на самом сочинении — этого я померить не смог. В TDLib есть константа фичи с именем premiumFeatureRichMessages; снаружи замок выглядит именно так. Мои прогоны опровергли замок по Premium для ботов в обоих типах чатов — про человеческие аккаунты они не говорят ничего.
Наш собственный путь через аккаунт — не умеет. trip2g публикует в Telegram двумя путями: через бота и через аккаунт пользователя по MTProto. Зависимость gotd/td закреплена на слое 222, и под tg/ нет ни единого символа RichMessage; типы впервые появляются на слое 227.
Интересна именно эта асимметрия, потому что её следствие — решение продуктовое, а не техническое. У каналов, куда мы публикуем через аккаунт пользователя, rich-постов быть не может. Они остаются на классике — либо переезжают на публикацию через бота и принимают подпись «via bot», ради избавления от которой путь через аккаунт и существует.
Что строим — и что отваливается
Библиотеку обновлять не нужно. trip2g отправляет через go-telegram-bot-api/v5 — библиотеку, заброшенную на Bot API 6.0 и ничего не знающую про rich messages. Ей и не надо знать. BotAPI.MakeRequest(endpoint, params) (bot.go:94) — это то, что Send и Request и так зовут под собой, а sendRichMessage — обычный метод JSON поверх HTTPS с вложенным объектом в значении формы. Это новый метод Env, а не новая зависимость.
Одна заусеница: реализовать tgbotapi.Chattable снаружи пакета нельзя — params() и method() неэкспортируемые, — так что rich-отправка не встанет в существующую сигнатуру SendTelegramMessage(ctx, chatID, Chattable). У неё будет своя.
Апгрейд gotd выпадает из этого проекта целиком. Шесть слоёв ломающих изменений схемы на ~1800 строках MTProto-клиента — самый крупный пункт старого плана — оказались вообще не на критическом пути. gotd остаётся ради того, что уже делает: публикация через аккаунты пользователей, импорт каналов. Он просто перестаёт быть предусловием для rich messages.
Отдавать blocks[], а не markdown. На одном и том же документе оба дают побайтово одинаковый результат, но ломаются в противоположные стороны. Markdown ломается молча. blocks[] ломается громко и называет виноватое поле: can't parse InputRichBlock: Field "video" must be of type Object. Для пайплайна, который рендерит чужие заметки, громко — единственное приемлемое свойство. По goldmark AST мы и так ходим, а из AST в типизированные блоки — короче, типобезопасно и убирает экранировщик, который иначе пришлось бы писать и тестировать.
Конкретно: пакет internal/tgrich под типы протокола, лимиты и транспорт, и rich_converter.go рядом с существующим HTMLConverter, который остаётся нетронутым: он всё ещё нужен классическому пути, навигационному боту и canvas.
Что рендерер чинит в первый же день. На пути публикации заголовки исчезают целиком: case *ast.Heading там нет, узел проваливается в default: и возвращает WalkSkipChildren, забирая текст с собой. (Навигационный бот прикрывает это своим UnknownNodeHandler, который пишет <b>; путь публикации никакого такого обработчика не ставит.) То же исчезновение забирает таблицы, списки задач, коллауты и разделители. ==highlight== сейчас рисуется спойлером, пряча выделенный текст за «нажми, чтобы увидеть». Аудиофайлы и PDF попадают в ветку default:, которая заворачивает их в NewInputMediaPhoto, — и они либо не отправляются, либо приезжают битыми. Порядок медиа в альбоме недетерминирован, потому что мы ходим по Go-мапе. И один пункт, который пока не баг: классический путь отдаёт expandable-цитату, когда цитата кончается на ||, а в rich раскрывающейся цитаты нет — её придётся класть в Details, иначе выпущенная фича тихо исчезнет. У каждого из этих пунктов есть верная цель в rich-сообщении, а порядок медиа стоит починить в любом случае.
Раскатка — включением по каждому чату, а не глобальным рубильником, потому что отрисовка в старых клиентах не описана, а те немногие свидетельства, что есть, выглядят плохо: Telegram Web по состоянию на июнь 2026 показывал «This message is currently not supported on Telegram Web» и вообще никакого текста. Зато с миграцией всё неожиданно по-доброму: обычное сообщение превращается в rich на месте, с тем же message_id — проверено на уже опубликованном посте канала, — так что ничего не нужно перепощивать и ни одна постоянная ссылка не ломается. Пересылка воспроизводит дерево блоков побайтово, копирование его тоже сохраняет, так что ни один путь перепоста не роняет rich-пост обратно в текст. Одна деталь протокола, под которую надо писать: пост в канале несёт sender_chat и вообще не несёт поля from, так что всё, что читает result.from.id, чтобы узнать собственное сообщение, получит nil.
С чем придётся разбираться
Все сбои ниже возвращают ok:true. Именно поэтому они — требования к тому, что мы строим.
- API может выбросить контент и всё равно отчитаться об успехе.
**bold**, повторённый 5000 раз, вернулся с 4374 участками. Сорок тысяч эмодзи вернулись строкой в 8749 символов — около 78% пропало. Ни ошибки, ни флага. Значит: после каждой отправки сверять вернувшуюся длину и число участков с тем, что отправили, и бить тревогу при расхождении. Другого способа это заметить нет. - Правка заменяет, а не патчит.
editMessageTextс полемtextсохраняетmessage_idи выбрасывает все блоки, а Telegram не хранит ничего: наш сохранённый исходник — единственная копия. Правка черезrich_messageне добрее: если отправить обратно заголовок с абзацем на пост, в котором было видео, видео молча пропадёт. Значит: держать в кэшеfile_idкаждого вложения и переигрывать его в каждой правке; метаданные сервер развернёт сам, аfile_id, как выяснилось, работает во всех чатах, так что ничего не перезаливается. - Медиа внутри строки теряет подпись. Медиа посреди предложения принимается, разрезает абзац и молча выбрасывает текст подписи. Нормализовать медиа на отдельную строку.
- Табы внутри блока кода схлопываются ровно в один пробел. Отступы в четыре и восемь пробелов доезжают побайтово. trip2g — кодовая база на Go, а gofmt отступает табами, так что каждый опубликованный кусок Go расплющится, пока рендерер не развернёт табы.
- Битый якорь деградирует в обычную ссылку.
href="#nowhere"не даёт ошибки, а просто становится обычным url. Выше по течению наше оглавление никто не проверяет — значит, проверяем мы. skip_entity_detection: trueставим по умолчанию. У API умолчание обратное, и распознавание превращает$USDв кэштег, а любую голую цепочку из 16 цифр — в номер банковской карты. Ещё#tagв начале строки без пробела становится H1 — от этогоblocks[]избавляет даром, потому что блоки мы собираем сами.
Кастомные эмодзи дорогу не переживают. Все формы ввода возвращают ok:true и приезжают голой строкой с эмодзи вместо типизированного участка — при том что сервер id явно распознаёт: один и тот же alt 🔥, отправленный с двумя разными id, вернулся как 🌿 и 🟠, каждый раз фолбэком самого стикерпака. Отличить понижение от эха с потерями ни один вызов API не мог. Клиент — может: на экране только обычные эмодзи. Rich-пост — то место, где кастомные эмодзи умирают, и заметка, которая на них держится, при переезде что-то теряет.
Громкая половина этой истории — наша. <tg-emoji emoji-id="…">, у которого в теле тега стоит слово, возвращает 400 RICH_MESSAGE_EMOJI_INVALID и убивает весь пост. renderCustomEmoji в internal/markdownv2/html_converter.go пишет в этот слот alt-текст картинки из Obsidian как есть, а три случая в html_converter_test.go именно такую форму и проверяют. Пустой alt — нормально, настоящий символ эмодзи — нормально, слово — фатально. Это латентная проблема, а не баг в проде: классический HTML-путь такую нагрузку принимает и работает сегодня. Она сработает в тот момент, когда этот alt-текст доедет до sendRichMessage, — так что rich-рендерер не должен унаследовать этот приём, а тот тест не должен переехать вместе с ним.
Ещё одно следствие, которое надо заложить в дизайн: у rich messages нет превью ссылок. API не отдаёт данных превью вообще — проверено контрольной отправкой обычным текстом, которая тот же URL раскрыла спокойно. Синтезирует ли клиент превью сам — этого я на экране не проверял. Карточка превью должна стать явным медиа-блоком.
Что решил экран и что он открыл
Всё эссе висело на одном: видел ли хоть кто-нибудь такое сообщение отрисованным. Каждый зелёный результат обоих прогонов — около 250 вызовов — это API возвращал мне моё же дерево разбора; единственный независимый путь чтения, который у меня был, — MTProto через мой собственный аккаунт — отдавал пустой text на каждый rich-пост в канале: доказательство ничего.
Так что я открыл Telegram на тестовом канале и посмотрел. Они отрисовываются. Заголовки — заголовки, таблицы — таблицы, медиа стоит между теми абзацами, к которым относится, сворачиваемые блоки сворачиваются и раскрываются. Ссылки и якоря работают — та самая тёмная лошадка выше, подтверждённая в единственной инстанции, которая считается. Кастомных эмодзи нет — и это решает то, чего не мог решить ни один вызов API. А ещё есть сгиб Show more, которого не предсказал ни один прогон, потому что не мог.
Последнее переосмысляет всю работу, потому что сгиб и якоря отвечают друг другу. Если длинный пост сворачивается, место над сгибом — самое дефицитное, что есть в формате: маленькое редакторское окно, которое решает, прочитают ли остальное вообще. Оглавление — ровно то, что там уместно: компактное, сгенерированное, а не написанное, по традиции наверху, и оно отдаёт читателю карту спрятанного вместо обрубленного первого абзаца. trip2g считает эту карту для каждой заметки столько, сколько существует веб-шаблон, — и отправлять её было некуда. Теперь есть куда.
Между этой идеей и дизайном стоял один вопрос, и он был узловой: раскрывает ли клик по ссылке в оглавлении пост за сгиб — или прыгает только внутри уже видимой части? Я отправил нарочно длинный пост: оглавление из шести якорей и тринадцать с лишним тысяч символов под ним, — и ткнул в пункты, которые вели под сгиб. Раскрывают. Оглавление и есть ответ на сгиб, и это одна фича.
Тот же пост дал сгибу цифру, которой нет в документации. Превью оборвалось посреди статьи на фразе, которую я мог найти точно: около 7350 символов видимого текста, 19 блоков. И оборвалось на границе абзаца, а не посреди предложения: следующий блок, а в нём около 1700 символов, отложили целиком. Значит сгиб уважает границы блоков, а настоящий порог лежит между тем местом, где обрезало, и тем, куда его утащил бы следующий блок, — где-то 7500–9000. Это один замер на одном клиенте, и я не знаю, что именно считает Telegram: символы, блоки или отрисованную высоту. Но сгиб из непознаваемого ограничения превращается в параметр вёрстки: держать то, что обязано быть видно, примерно в семи тысячах символов и осторожнее с большой таблицей или блоком кода у самой границы — их не разрежут, их спрячут целиком.
Форма работы от этого не зависела никогда, а теперь зависит ещё меньше. Возможность настоящая, она достаёт до тех чатов, куда мы и правда публикуем, она отрисовывается, транспорт — вызов метода, который мы и так умеем делать, а заметка, которую мы бы отправили, уже дерево блоков. Остался вопрос о том, как это читают, а не о том, строить ли.