This article has been translated from Chinese with AI assistance. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.
The code and conclusions in this article come from a real project implementation.
I recently added Telegram’s new Rich Messages to a long-running, multi-platform bot project. The motivation was simple: LLMs naturally produce headings, lists, code blocks, quotations, formulas, and spoilers, while the old sendMessage + parse_mode model is closer to “adding a few styles to plain text.” It cannot represent structured content as faithfully. Streaming adds another problem: a failed draft update can leave the user with only half an answer.
Telegram introduced Rich Messages, sendRichMessage, and sendRichMessageDraft in Bot API 10.1. Bot API 10.2 then added explicit media and block inputs. This article goes beyond sending the first Rich Message. It covers the difficult production details: connecting the API to an AI stream, why a streaming helper is not enough on its own, how to design a Rich → HTML → plain-text fallback, and how to isolate Telegram-only behavior in a multi-platform bot service.
Rich Messages vs. Regular Messages
Traditional sendMessage supports MarkdownV2 or HTML entities. It works well for inline formatting such as bold, italics, links, and code. Rich Messages raise the abstraction to a structured document and can represent:
- Multiple heading levels, lists, and task lists
- Code blocks, quotations, dividers, and collapsible details
- Tables, mathematical expressions, anchors, and in-document references
- Collages, slideshows, maps, and several media block types
- A thinking block designed specifically for AI streaming
There are two essential sending paths:
| API | Purpose | Persistent | Main Constraint |
|---|---|---|---|
sendRichMessage | Send the complete final content | Yes | Can target a chat and returns the final Message |
sendRichMessageDraft | Update partial content during generation | No | Private chats only; the draft is a temporary 30-second preview and still needs a final message |
The second row is easy to misunderstand. sendRichMessageDraft does not progressively save the final message. It only renders a temporary preview. Even if the last draft looks complete, the application must call sendRichMessage again to persist the result.
InputRichMessage requires exactly one of markdown, html, or blocks. This project uses Markdown for the main AI path because model output can be handed directly to Telegram. If a product needs precise control over tables, media, or deeply nested structure, generating blocks is the next option.
At the time of writing, the official limits include 32,768 UTF-8 characters, 500 blocks, 16 nesting levels, 50 media attachments, and 20 table columns. Server-side entry points should enforce the same boundaries so invalid input does not travel all the way into the bot layer. See Rich Message formatting options for the complete syntax and limits.

Client verification: the Rich Message natively renders bold hierarchy, inline variables, and multiline LaTeX formulas in an AI reply.
To cover more structures, I also sent one continuous full-format test message. The following three images are arranged from the top of that message to the bottom. Together, they show inline styles, headings and lists, quotations, code blocks, tables, formulas, footnotes, in-document anchors, collapsible details, and map and image blocks:



All three screenshots come from the same Rich Message. They verify not only inline Markdown formatting but also structures that depend on document layout, including tables, formulas, collapsible sections, and media blocks. Visual details may vary slightly across Telegram client versions.
Delivery Architecture and Sending Basics
Looking only at sendRichMessage makes the integration appear to be a one-API replacement. An AI reply actually crosses four boundaries: provider output, draft rendering, final persistence, and compatibility fallback. The following lifecycle is the architecture this implementation converged on:
infographic sequence-zigzag-steps-underline-text
data
title Telegram AI Reply Delivery Lifecycle
desc Model temporary drafts final persistence and failure recovery separately
items
- label Provider output
desc Consume the token stream while retaining an awaitable complete-text promise
- label Private-chat draft
desc Update a 30-second preview with thinking and Rich Markdown
- label Final persistence
desc Call sendRichMessage after streaming to create the durable chat record
- label Content routing
desc Classify complete text as plain basic or rich and choose the lowest-cost path
- label Failure fallback
desc Degrade Rich to HTML then split and send plain text if HTML also fails
theme
palette
- #229ED9
- #38BDF8
- #14B8A6
- #F59E0B
- #64748B
This lifecycle has three success states that must not be collapsed. Provider completion means only that the answer was generated. A successful draft update means only that the user saw a temporary preview. Only a final call returning a Message means the conversation contains a durable result. Reducing all three to one boolean makes retries, telemetry, and error messages ambiguous.
The three Rich Message input forms are engineering choices rather than personal formatting preferences:
| Input | Advantage | Cost | Best Fit |
|---|---|---|---|
markdown | Accepts LLM output directly and has the lowest integration cost | Telegram controls structural parsing, so precision is lower | AI conversations, document summaries, code explanations |
html | Explicit tag semantics and predictable server templates | Requires correct escaping and remains limited to supported tags | Controlled templates and operational notices |
blocks | Strongest structure, including explicit media and complex layouts | Requires AST mapping, types, and version compatibility | Tables, media cards, deterministic product messages |
The main path in this article therefore uses markdown instead of first translating model output into custom blocks. Blocks fit messages whose structure is product-defined and whose content merely fills slots. For open-ended AI output, an early full AST mapping substantially increases parsing and fallback complexity.
Runtime Requirements
The project uses grammY, so the first step was upgrading its types and streaming plugin:
pnpm add \
grammy@^1.45.1 \
@grammyjs/stream@^1.1.0 \
@grammyjs/auto-retry@^2.0.2 \
marked@^17.0.3
@grammyjs/stream@1.1.0 uses Promise.withResolvers() internally, so the project also aligned local development, CI, and deployment on Node.js 22+. Upgrading the dependency without upgrading the production image creates the classic failure mode where type checking passes and the runtime crashes only after deployment.
Add StreamFlavor to a custom context and register the plugins:
import { autoRetry } from '@grammyjs/auto-retry';
import { stream, type StreamFlavor } from '@grammyjs/stream';
import { Bot, type Context } from 'grammy';
type BotContext = StreamFlavor<Context>;
const bot = new Bot<BotContext>(process.env.BOT_TOKEN!);
bot.api.config.use(autoRetry());
bot.use(stream());Streaming updates make many Bot API calls in a short period, and the grammY documentation recommends using auto-retry with the stream plugin. It is not a Rich Message syntax requirement, but it matters on a real network.
A Strict Sender
The minimal call is straightforward:
import type { Api } from 'grammy';
import type { Message, ReplyParameters } from 'grammy/types';
type RichMessageFormat = 'markdown' | 'html';
interface RichMessageOptions {
message_thread_id?: number;
reply_parameters?: ReplyParameters;
}
function toInputRichMessage(format: RichMessageFormat, content: string) {
return format === 'markdown' ? { markdown: content } : { html: content };
}
async function sendRichMessageStrict(
api: Api,
chatId: number | string,
format: RichMessageFormat,
content: string,
options?: RichMessageOptions,
): Promise<Message.RichMessageMessage> {
return api.sendRichMessage(chatId, toInputRichMessage(format, content), options);
}The Strict suffix is intentional. This function answers one question only: did Rich Message delivery succeed? Production reply code may wrap it with fallbacks, while a debugger can call it directly to prove that the new API itself works.
If fallback behavior is hidden inside the lowest-level function, a green “sent” status in the debugger cannot tell you whether the user received a Rich Message, an HTML message, or plain text.
At the HTTP layer, grammY ultimately maps these arguments to the Bot API’s snake_case JSON. Understanding that representation helps when the TypeScript call looks right but Telegram rejects the payload:
{
"chat_id": 123456789,
"rich_message": {
"markdown": "# Build passed\n\n- typecheck\n- tests"
},
"reply_parameters": {
"message_id": 42
}
}Do not put both markdown and html in the same input, and do not nest reply_parameters inside rich_message. A helper such as toInputRichMessage and an explicit options type should enforce this boundary instead of letting every call site construct the object manually.
Streaming and Failure Recovery
For AI tools that expose an AsyncIterable<string>, such as the AI SDK, the shortest integration is:
const { textStream } = streamText({
model,
messages,
});
await ctx.replyWithMarkdownStream(textStream);
replyWithMarkdownStream updates a temporary Rich Markdown draft and sends a persistent Rich Message after the stream completes. Telegram only supports streaming in private chats, so the application must check the chat type first:
if (ctx.chat?.type === 'private') {
await ctx.replyWithMarkdownStream(textStream);
} else {
// Drain the output, then send one complete message to the group.
}
To display Telegram’s native thinking state before the model emits its first token, send a separate <tg-thinking> draft:
async function showThinkingDraft(api: Api, chatId: number, draftId: number) {
await api.sendRichMessageDraft(chatId, draftId, {
html: '<tg-thinking>Thinking...</tg-thinking>',
});
}
<tg-thinking> is valid only in sendRichMessageDraft; it cannot appear in the final message. This deserves its own test because an apparently harmless attempt to reuse the draft template can break final delivery.
Draft IDs, Concurrency, and Backpressure
sendRichMessageDraft requires a non-zero draft_id. Reusing the same ID throughout one stream lets Telegram recognize updates as the same draft and render a continuous animation. grammY’s replyWithMarkdownStream uses the current update_id by default, which is convenient when one update creates one stream. If one update must generate two streams in parallel, assign distinct IDs explicitly or serialize the second stream.
A subtler issue appears when two updates from the same chat execute concurrently. Their AI replies may interleave draft updates, and conversation history may be persisted in the wrong order. The solution is not to serialize the entire bot. Establish a per-chat constraint so one chat is processed sequentially while different chats remain concurrent.
import { sequentialize } from '@grammyjs/runner';
bot.use(
sequentialize((ctx) =>
ctx.chat ? `chat:${ctx.chat.id}` : `update:${ctx.update.update_id}`,
),
);
bot.api.config.use(autoRetry());
bot.use(stream());Middleware order matters: establish the per-chat boundary before handlers create streams. auto-retry belongs on the API transformer so it can handle rate limits and transient network failures. Avoid adding a fixed sleep inside the token loop; it cannot honor Telegram’s actual retry timing and makes healthy replies slower.
The stream plugin also coordinates backpressure. A provider may emit tokens every few dozen milliseconds, while Telegram should not receive requests at the same frequency. If the previous draft request is still pending, the plugin coalesces unsent content into the latest draft instead of growing an unbounded update queue. Every token must be consumed, but not every token needs its own API call. Users need the newest preview, not every intermediate frame.
Streaming Success Is Not Reply Success
Calling await ctx.replyWithMarkdownStream(result.textStream) directly covers only the happy path. In production, any of the following can happen:
- Telegram fails after part of the draft has already been rendered.
- Reading the LLM stream fails, but the later aggregate result throws a different, less useful error.
- The output begins with whitespace and Telegram rejects an empty draft.
- Filtering thinking or metadata leaves the entire stream empty.
- The application falls back immediately after a draft error, before the model has finished the answer.
The project treats draft presentation and complete output as two related but independent lifecycles:
let streamStarted = false;
let capturedStreamError: unknown;
try {
await ctx.replyWithMarkdownStream(managedTextStream(result.textStream));
streamStarted = true;
} catch (error) {
capturedStreamError = error;
}
// Keep waiting for the complete provider output even if draft delivery failed.
const completeText = (await result.text).trim();
if (!streamStarted && completeText) {
await sendFormattedChunks(ctx, completeText);
streamStarted = true;
}
// Preserve the earliest actionable error if no visible output was delivered.
if (capturedStreamError && !streamStarted) {
throw capturedStreamError;
}This handles an important distinction: a Rich draft failure does not necessarily mean that LLM generation failed. If the provider eventually returns complete text, the application can still deliver the full answer through a non-streaming path. If there is no final output either, the earliest network or API failure should be preserved instead of being replaced by a later “No output generated” error.
managedTextStream also buffers leading whitespace until the first visible character appears. Every filter must flush its buffer at the end; otherwise, the last small piece of output may never reach Telegram.
For easier reviews, model delivery as an explicit state machine instead of scattering sent, streaming, and hasText booleans throughout the handler:
type DeliveryPhase =
| 'waiting'
| 'drafting'
| 'persisting'
| 'fallback'
| 'done'
| 'failed';
This does not require a state-machine library. The important part is preserving three invariants: do not delete a waiting message before a draft is visible or final delivery succeeds; record a business-level “replied” event only after a durable message exists; and use the provider’s complete text during fallback instead of treating the last draft frame as the answer. These constraints describe the system’s real contract more accurately than any particular helper function.
Content Routing and Fallbacks
It is technically possible to pass every AI response to sendRichMessage, but I ended up routing content into three classes:
| Content | Delivery Path | Examples |
|---|---|---|
| Plain text | sendMessage | Regular sentences, bare URLs, email addresses |
| Basic formatting | sendMessage + HTML | Bold and italics only |
| Structured formatting | sendRichMessage | Headings, lists, named links, code, quotations, strikethrough, spoilers, formulas, and more |
There are two reasons for this split. Plain text does not need additional parsing complexity, and basic HTML remains a dependable compatibility layer for older clients or occasional API failures.
The classifier uses the marked lexer as its foundation instead of reimplementing Markdown with one enormous set of regular expressions:
type MarkdownDeliveryClass = 'plain' | 'basic' | 'rich';
function classifyMarkdownDelivery(markdown: string): MarkdownDeliveryClass {
if (hasRichOnlyInlineSyntax(markdown)) return 'rich';
let result: MarkdownDeliveryClass = 'plain';
for (const token of markdownLexer.lexer(markdown)) {
if (token.type === 'space') continue;
if (token.type !== 'paragraph' && token.type !== 'text') return 'rich';
const inline = classifyInlineTokens(token.tokens ?? []);
if (inline === 'rich') return 'rich';
if (inline === 'basic') result = 'basic';
}
return result;
}The lexer reliably identifies headings, lists, code blocks, quotations, and named links. A small number of explicit rules covers extension syntax such as spoilers, marked text, footnotes, and formulas. Although the lexer also recognizes bare URLs and email addresses as links, they do not change the structure the user typed, so they remain plain text.
The final fallback order is Rich Markdown → Telegram HTML → plain text. The order matters. If Rich Markdown is converted to the legacy HTML subset first, structures such as headings, tables, and formulas are already lost. Calling the Rich API afterward cannot reconstruct them.
The fallback function should return the delivery class it actually used instead of returning only a Message. The caller can then distinguish “Rich succeeded” from “Rich failed but HTML succeeded.” That makes the new API observable without treating compatibility success as Rich success.
Message Length and Chunking
Rich Messages allow up to 32,768 UTF-8 characters, while legacy sendMessage text has a much smaller limit. A perfectly valid Rich payload may therefore fail again after degrading to HTML or plain text. A try/catch that merely changes parse mode is not a complete fallback.
Re-split the content using the legacy limit before entering the old message path:
async function sendLegacyFallback(ctx: BotContext, markdown: string) {
for (const chunk of splitMessage(markdown, 4096)) {
await sendMarkdownWithHtmlThenPlainFallback(ctx, chunk);
}
}
splitMessage cannot be a simple text.slice(0, 4096). It should account for fenced code, Markdown entities, line boundaries, and emoji. JavaScript’s string.length counts UTF-16 code units and should not be assumed to match the API documentation’s character semantics. A practical implementation uses a conservative threshold and tests Chinese text, emoji, long code blocks, and unclosed Markdown near the boundary.
Chunking also introduces partial success. If the first three chunks succeed and the fourth fails, retrying the entire answer creates duplicates. A robust interface returns the sent message IDs and resumes from the failed chunk. If resumable delivery is out of scope, at least log chunkIndex and chunkCount so an operator can explain why the user received only part of the answer.
Feature Flags and Platform Isolation
Rich Messages are a Telegram capability and should not leak into every bot manager. The project supports both Telegram and Lark. If sendRichMessage were added directly to the generic IBotManager, the Lark manager would have to implement a fake method that could only fail.
A separate capability keeps the boundary explicit:
interface TelegramRichMessageSender {
sendRichMessage(
chatId: number,
format: 'markdown' | 'html',
content: string,
): Promise<{ messageId: number; chatId: number; format: 'markdown' | 'html' }>;
}
function supportsTelegramRichMessages(
manager: IBotManager,
): manager is IBotManager & TelegramRichMessageSender {
return 'sendRichMessage' in manager && typeof manager.sendRichMessage === 'function';
}BotPool checks the capability before dispatching. The panel waits for the bot record and shows the real-send card only when platform === 'telegram'. The type system, server, and UI now agree on the same platform boundary.
The product also exposes a richMessageEnabled setting and a /richtext on|off command:
- When enabled, private streaming replies always use Rich Markdown; non-streaming replies are routed by content.
- When disabled, the bot uses regular streaming drafts, attempts to edit the completed messages into compatible HTML, and finally falls back to plain text.
This flag is both an operational rollback and a way to roll the feature out gradually across different bots, client versions, and network environments.
Debugging and Validation
Automated tests can prove that payloads, branches, and error handling are correct. They cannot prove that a specific bot token, chat ID, and Telegram client combination works. The project therefore adds an owner-only debugging endpoint:
POST /api/bots/:botId/debug/rich-messages
Content-Type: application/json
{
"chatId": "123456789",
"format": "markdown",
"content": "# Rich Message\n\n- item 1\n- item 2"
}
The server validates the boundary with Zod:
chatIdmust be a safe, non-zero integer.formatmust be eithermarkdownorhtml.contentmust be non-empty after trimming and no longer than 32,768 characters.- A stopped bot or unsupported platform returns 409.
- An actual Telegram delivery failure returns 502.
The debugging endpoint calls the strict sender and never falls back. This is deliberate. Production delivery tries to get an answer to the user by any safe path; debugging tries to prove unambiguously whether Rich Message delivery worked. Those paths should not share the same definition of success.
The panel also has a subtle race to handle. If someone starts a test and immediately switches bots, the old response must not overwrite the new bot’s UI state. The implementation records both the requested bot ID and a monotonically increasing request ID, then accepts a result only when it still belongs to the active page context.

Error Taxonomy and Observability
“Delivery failed” is not enough information for production diagnosis. Distinguish provider generation failure, draft update failure, final Rich persistence failure, HTML/plain-text fallback failure, and a stopped or unsupported bot. Each belongs to a different response path: provider errors go to the model service, Telegram 429 responses belong to auto-retry, and capability errors should be rejected before the request enters delivery.
A useful structured log can look like this:
logger.warn('telegram reply degraded', {
phase: 'fallback',
deliveryClass: 'html',
fallbackFrom: 'rich',
chunkIndex,
chunkCount,
errorName: richError instanceof Error ? richError.name : 'UnknownError',
});
Do not log the complete prompt, reply body, bot token, or raw chat ID. If events must be correlated by chat, use a server-reproducible hash or an internal trace ID. Track draft_failure_rate, rich_persist_failure_rate, and fallback_success_rate independently. Total delivery success may remain high while a rising fallback ratio reveals a regression in the new API, client compatibility, or payload classification.
The debugger’s 409 and 502 responses follow the same principle. A 409 means the current resource state or platform capability does not permit the operation. A 502 means the service attempted an upstream Telegram call and delivery failed there. The panel can then present an actionable message instead of one generic red “Unknown error.”
Test Coverage
The final verification was split into four layers:
- Classification and fallback unit tests: plain text, bold/italics, structured Markdown, Rich failure, and a second HTML failure.
- Streaming lifecycle tests: thinking appears only in drafts, complete text is persisted, empty streams, leading whitespace, and complete fallback after a draft failure.
- Server capability and status tests: Telegram managers can send, Lark managers are rejected, stopped and unsupported bots return 409, and delivery errors return 502.
- Browser tests: Markdown and HTML payloads, visible errors, stale responses after switching bots, preserving input across language changes, and hiding the card for Lark bots.
The most important streaming assertion is not that a helper was called, but that lifecycle ordering and final side effects are correct. For example:
expect(sendRichMessageDraft).toHaveBeenCalled();
expect(sendRichMessage).toHaveBeenCalledWith(
chatId,
expect.objectContaining({ markdown: completeText }),
expect.anything(),
);
expect(sendRichMessage.mock.invocationCallOrder[0]).toBeLessThan(
saveConversationReply.mock.invocationCallOrder[0],
);Add failure injection as well: make the second draft update throw, make final Rich delivery return 400, and make the HTML fallback return 429. These cases should prove that complete output is still consumed, fallback order is not reversed, and the earliest actionable error is preserved. They protect the behavioral contract when grammY or the AI SDK changes far better than one happy-path test.
Conclusions and References
The smallest Telegram Rich Messages integration is a single api.sendRichMessage(...) call. What determines whether it can run reliably in production is the engineering around that line:
- A draft is only a 30-second preview; the final content must be persisted separately.
- After streaming fails, wait for the complete provider output instead of sending a truncated fallback.
- Route Markdown by expressive needs and retain the Rich → HTML → plain-text fallback chain.
- Isolate Telegram-only behavior consistently in types, the server, and the UI.
- Give production delivery and strict debugging different success criteria.
- Upgrade local, CI, and container runtimes together.
For a demo, replyWithMarkdownStream(textStream) already looks excellent. In a long-running, multi-platform AI bot, the failure paths around it are the real integration work.
If you enjoyed this, leave a comment~