English
Telegram grew a block tree
What this is about: Telegram's new Rich Messages — posts shaped like a document instead of a chat message — what they give someone publishing long-form content, and what trip2g is going to build to use them. The conclusions up front. Rich messages carry real headings, media sitting with the paragraphs it belongs to, and eight times the room: a document, not a message. A bot can send them today, free and ungated, into private chats and into channels alike — about 250 live API calls behind that sentence, and no chat-type gate anywhere in them. And they render: I have now opened the probe channel in a real client, and it all looks the way it was meant to. The build turns out far smaller than it looked: no library upgrade, no MTProto work, no new dependency, just a second renderer over the goldmark AST we already walk. The price is a per-chat opt-in, a verification step after every send, custom emoji we lose on the way, and one product decision — channels published through a user account stay classic.
A chat message and a document are different shapes
I noticed posts in Telegram that did not look like messages. Real headings. A table. A video between two paragraphs instead of stapled above a caption. Telegram calls these Rich Messages; the Bot API method is sendRichMessage, which the changelog dates to Bot API 10.1 on 11 June 2026, with a typed blocks representation added in 10.2 on 14 July.
Telegram spent years constraining every sender to chat-message shape: 4096 characters, one caption per album, no heading entity at any price. It just handed the bot a document format.
What is actually in the box
Structure that survives the trip. Six heading levels, and the level is preserved — #…###### arrive as size: 1..6, not flattened into bold. Nested lists keep their depth. Task lists keep checkbox state. Dividers exist. For a travel note built out of ## Day 1 and ## Day 2, this is the difference between a published article and a wall of text — and today those lines do not arrive downgraded, they do not arrive at all.
Media in document order. A photo or a video is a block, and it sits where the author put it. Fifty of them in one message, twenty-four of those measured end to end in a channel post with every caption preserved. The old model made long-form structurally impossible: an album is a photo wall followed by a text wall with one caption for the lot. Rich messages have no album — a <tg-collage> or <tg-slideshow> block replaces it for when a grid is actually what you want. Media is ingested server-side from a plain HTTPS URL, and comes back with correct duration, dimensions, a generated thumbnail and a permanent file_id.
Room. 32768 characters against 4096 for a message and 1024 for a caption. 500 top-level blocks. 50 media. Every one measured at its exact boundary — 32768 passes and 32769 fails, 500 and 501, 50 and 51 — and the media limit re-measured identically in a channel. List items turn out not to count against the 500 at all: a 501-item list is one block and goes through, where 501 paragraphs fail. For the bullet-heavy notes Obsidian encourages, that quietly enlarges the ceiling. And the whole thing is one message, so a fifty-image article should cost a single rate-limit slot where album-plus-text costs several — limits measured, that consequence inferred.
Room is not the same as visible room. On screen, a long post collapses behind a Show more fold. No API probe could have found this: the Bot API docs mention it nowhere, and the one secondary source that names a threshold says about 8000 characters, which is not a number I would have built against. I measured it later, and that source turns out to have been roughly right — but the measurement came from a screen, not an endpoint. 32768 characters of capacity is not 32768 characters of visible post, and that difference matters more than the capacity does.
Things a publishing pipeline could not send before. The one that matters most to us is <details>/<summary>, because Obsidian callouts map straight onto it — and today callouts are dropped whole, with no case for them anywhere in internal/markdownv2/. The callout node already carries Foldable and Expanded; those two fields are the open attribute. Every folded warning box in every existing note becomes a native collapsible block with the author's fold state intact. Tables are second: GFM tables with per-column alignment arrive as tables, where today they hit the same fall-through and vanish with a warning. And a map is a block you build from a lat/long/zoom — for a travel platform the obvious one, and the only thing on this list nobody has yet sent.
Then the long tail, which I will list without arguing for: footnotes, LaTeX inline and block, ==highlight== as a genuine marked run instead of the spoiler it currently becomes, pull quotes with <cite>, figures with <figcaption>, sub- and superscript.
Some of what reads like new capability is not. Blockquotes, fenced code with a language tag and spoilers all go out through the classic pipeline today; rich messages give them better semantics, not a first appearance. And exactly one thing runs the other way: custom emoji, which the classic path sends fine, do not survive into a rich message at all. What the rich format contributes is not a bigger tag inventory. It is that the tags now hang on a document.
Anchors, which turned out to be the sleeper. I expected a degradation-prone hack and got structure. <a name="x"> comes back as a first-class anchor block, <a href="#x"> as a typed anchor_link run, and <tg-reference> gives real footnotes — so a long post can carry a working table of contents inside itself, and the API treats that contents as structure rather than as text that happens to look like links. That was a prediction read off a parse tree. It is now something I have watched work on screen. trip2g already computes the input — extractHeadingsAndGenerateIDs (internal/model/note.go:850-887) stamps an ID onto every heading of every note, and the web side ships a whole TOC subsystem on top of that (assets/toc/src/index.ts). The structure has never had anywhere to go in Telegram. Each anchor is a block, so a fifty-heading article spends fifty of its five hundred.
Worth separating three tiers. Seen on a screen, in a real client: headings, tables, media in document order, collapsible sections, links and anchors. Measured against the live API but not yet watched: everything else above, in a private chat and then again in a channel. Documented and still never in a probe set: maps, dividers, sub- and superscript, audio, document and animation media, 16 levels of nesting, 20 table columns. The third list has shrunk to where the only item on it I care about is maps.
The honest summary is not "Telegram improved its formatting". Telegram stopped being a chat-message format and grew a document format — and the bot got it first.
Who can send one, and why the asymmetry matters
A bot can, today, and it does not need Premium. That was the one assumption capable of killing the whole idea, and the Bot API page never ties Premium to rich messages in either direction. So I sent an agent to measure it: a throwaway bot on a non-Premium account, about 130 live calls, roughly forty rich messages sent. No Premium-related error anywhere, and getMe exposes no capability flag for any of it — nothing to check, nothing to enable.
And it can send into a channel, which is the path that actually matters. Channels were unprobed when I first wrote this down, and that was the ship blocker: a finished renderer with nowhere to publish is not worth writing. A second round of about 120 calls, against a private channel the bot administers, closed it on the first attempt — a full article with H1, two H2s, a GFM table, a fenced Go block, a blockquote and a mid-document video fetched server-side from an HTTPS URL, in a single call. Nothing regressed between the two chat types. There is no chat-type gate.
A human got a composer this month. Desktop 6.9 shipped "Rich Text Formatting for Bots" on 9 June, two days before the API method existed; the composer arrived with Desktop 7.0 in mid-July. Whether authoring sits behind Premium I could not measure — TDLib carries a feature constant named premiumFeatureRichMessages, which is what a gate looks like from outside. My probes refute Premium gating for bots, in both chat types, and say nothing about human accounts.
Our own account path cannot. trip2g publishes to Telegram two ways: through a bot, and through a user account over MTProto. gotd/td is pinned at layer 222 and there is not a single RichMessage symbol under tg/; the types first appear at layer 227.
That asymmetry is the interesting part, because its consequence is a product decision rather than a technical one. Channels we publish through a user account cannot have rich posts. They stay classic, or they move to bot publishing and accept the "via bot" attribution that the account path exists precisely to avoid.
What we build — and what drops out
No library upgrade. trip2g sends through go-telegram-bot-api/v5, unmaintained since Bot API 6.0 and utterly ignorant of rich messages. It does not need to know. BotAPI.MakeRequest(endpoint, params) (bot.go:94) is what Send and Request already call underneath, and sendRichMessage is an ordinary JSON-over-HTTPS method with a nested object in a form value. This is a new Env method, not a new dependency.
One wrinkle: you cannot implement tgbotapi.Chattable from outside the package — params() and method() are unexported — so a rich send cannot drop into the existing SendTelegramMessage(ctx, chatID, Chattable) signature. It gets its own.
The gotd upgrade drops out of this project entirely. Six layers of breaking schema churn across ~1800 lines of MTProto client, the largest single item on the old plan, turns out not to be on the critical path at all. gotd stays for what it already does — publishing through user accounts, channel import. It just stops being a prerequisite for rich messages.
Emit blocks[], not markdown. Both produce byte-identical output on the same document; they fail in opposite directions. Markdown fails silently. blocks[] fails loudly and names the offending field: can't parse InputRichBlock: Field "video" must be of type Object. For a pipeline rendering somebody else's notes, loud is the only acceptable property. We already walk a goldmark AST — AST to typed blocks is shorter, type-safe, and deletes an escaper we would otherwise have to write and test.
Concretely: a package internal/tgrich for the wire types, limits and transport, and rich_converter.go as a sibling of the existing HTMLConverter, which stays untouched — the classic path, the navigation bot and canvas all still need it.
What the renderer fixes on day one. On the publish path, headings vanish entirely: there is no case *ast.Heading, so the node falls through to default: and returns WalkSkipChildren, taking the text with it. (The navigation bot covers for this with its own UnknownNodeHandler, which writes <b>; the publish path installs no such handler.) The same disappearance takes tables, task lists, callouts and thematic breaks. ==highlight== currently renders as a spoiler, hiding highlighted prose behind tap-to-reveal. Audio files and PDFs fall into a default: arm that wraps them as NewInputMediaPhoto, so they fail to send or arrive corrupt. Album media order is non-deterministic because we range over a Go map. And one that is not a bug yet: the classic path emits an expandable blockquote when the quote ends in ||, and rich has none — it must map to Details, or a shipped feature quietly disappears. Every one of these has a correct rich-message target, and the media-order one is worth fixing regardless.
Rollout is per-chat opt-in, not a global switch, because old-client rendering is undocumented and the little evidence there is looks bad: Telegram Web, as of June 2026, showed "This message is currently not supported on Telegram Web" and no text at all. The migration story is kinder than expected: a plain message upgrades to rich in place, keeping its message_id — verified on an already-published channel post — so nothing needs reposting and no permalink breaks. Forwarding reproduces the block tree byte-identically and copying preserves it too, so no repost path quietly degrades a rich post back to text. One wire detail to code against: a channel post carries sender_chat and no from field at all, so anything reading result.from.id to recognise its own message gets nil.
What we have to handle
Every failure mode below returns ok:true. That is what makes them build requirements.
- The API can discard content and still report success.
**bold**repeated 5000 times came back with 4374 runs; forty thousand emoji came back as 8749 characters, about 78% gone. No error, no flag. So: compare returned length and run count against what we submitted, after every send, and alarm on mismatch. There is no other way to notice. - The edit path replaces; it never patches.
editMessageTextwith atextfield keeps themessage_idand drops every block, and Telegram retains nothing — our stored source is the only copy. Arich_messageedit is no gentler: sending back heading-plus-paragraph on a post that carried a video silently dropped the video. Cache each asset'sfile_idand replay it in every edit; the server re-expands the metadata, andfile_idturns out to be global across chats, so nothing re-uploads. - Inline media loses its caption. Media mid-sentence is accepted, splits the paragraph, and silently drops the caption text. Normalise media onto its own line.
- Tabs inside fenced code collapse to exactly one space. Four- and eight-space indents survive byte-identical. trip2g is a Go codebase and gofmt indents with tabs, so every published Go snippet flattens unless the renderer expands them first.
- A dangling anchor degrades to an ordinary link.
href="#nowhere"does not error, it just becomes a plain url. Nothing upstream validates our table of contents, so we do. - We set
skip_entity_detection: trueby default. The API's default is the other way round, and detection turns$USDinto a cashtag and any bare 16-digit run into a bank card number. A line-leading#tagwith no space also becomes an H1 —blocks[]sidesteps that one for free, since we construct the blocks ourselves.
Custom emoji do not make the trip. Every input form returns ok:true and comes back as a bare string emoji instead of a typed run — while the server demonstrably resolves the id, since the same alt 🔥 sent with two different ids came back 🌿 and 🟠, each the sticker set's own fallback. The API could not tell a downgrade from a lossy echo. The client can: only ordinary emoji appear on screen. A rich post is where custom emoji go to die, and a note that leans on them loses something in the upgrade.
The loud half of that is ours. <tg-emoji emoji-id="…"> with a word as the tag body returns 400 RICH_MESSAGE_EMOJI_INVALID and kills the entire post. renderCustomEmoji in internal/markdownv2/html_converter.go writes the Obsidian image alt text straight into that slot, and three cases in html_converter_test.go assert exactly that shape. Empty alt is fine, a real emoji character is fine, a word is fatal. Latent, not live: the classic HTML path accepts that payload and ships today. It fires the moment that alt text reaches sendRichMessage, so the rich renderer must not inherit the pattern, nor the assertion with it.
One more consequence to design around: rich messages get no link preview data, proven against a plain-text control that previewed the same URL fine. Whether a client synthesises one anyway was not among the things I checked on screen. A preview card has to become an explicit media block.
What the screen settled, and what it opened
The whole essay was waiting on one thing: whether anyone had ever seen one of these on a screen. Every green result across both probes — some 250 calls — was the API echoing its own parse tree back at me, and the one independent read path I had — MTProto through my own account — returned an empty text for every rich post in the channel: evidence of nothing.
So I opened Telegram on the test channel and looked. They render. Headings are headings, tables are tables, media sits between the paragraphs it belongs to, collapsible sections collapse and open. Links and anchors work, which is the sleeper call above, proved right in the only venue that counts. Custom emoji are gone, which settles what no API call could. And there is a Show more fold, which no probe predicted, because no probe could.
That last one reframes the build, because the fold and the anchors answer each other. If a long post collapses, the space above the fold is the scarcest thing in the format — a small editorial window deciding whether the rest is read at all. A table of contents is precisely what belongs there: compact, generated rather than written, conventionally at the top, and it hands the reader a map of what is hidden instead of a severed first paragraph. trip2g has been computing that map for every note for as long as the web template has existed, with nowhere to send it. Now there is somewhere.
One question stood between that idea and a design, and it was the hinge: does clicking a link in the contents expand the post past the fold, or only jump within the part already visible? So I sent a deliberately long post — a contents list of six anchors over thirteen thousand characters — and tapped the entries that pointed below the fold. They expand it. The contents list is the answer to the fold, and the two are one feature.
The same post put a number on the fold, which the documentation never has. The preview ended mid-article on a sentence I could locate exactly: about 7,350 characters of visible text, 19 blocks in. And it ended at a paragraph boundary rather than mid-sentence — the next block, some 1,700 characters of it, was held back whole. So the fold respects block boundaries, and the real threshold sits somewhere between where it cut and where the next block would have carried it, call it 7,500 to 9,000. That is one measurement on one client, and I do not know whether Telegram counts characters, blocks, or rendered height. But it turns the fold from an unknowable constraint into a layout parameter: keep what must be seen inside roughly seven thousand characters, and be careful with a large table or code block near the boundary, because it will not be split — it will be hidden entirely.
The shape of the work never depended on any of this, and depends on it less now. The capability is real, it reaches the chats we actually publish to, it renders, the transport is a method call we can already make, and the note we would send is already a block tree. What is left is a question about how it gets read, not about whether to build.