本文由 AI 辅助整理,代码与结论来自实际项目实现。
最近给一个长期运行的多平台 Bot 项目接入了 Telegram 新增的 Rich Messages。起因很简单:LLM 天然会输出标题、列表、代码块、引用、公式和 spoiler,但旧的 sendMessage + parse_mode 更像是“在普通文本上加几个样式”,很难完整承载结构化内容;流式回复还需要不断编辑草稿,失败时很容易只留下半截答案。
Telegram 在 Bot API 10.1 中加入了 Rich Messages、sendRichMessage 和 sendRichMessageDraft,10.2 又补上了显式媒体和 block 输入。本文不只介绍如何发出第一条 Rich Message,还会展开项目中真正麻烦的部分:怎样把它接进 AI 流、为什么不能只依赖流式 helper、如何设计 Rich → HTML → 纯文本降级,以及怎样在多平台 Bot 服务里隔离 Telegram 专属能力。
Rich Message 与普通消息
传统 sendMessage 支持 MarkdownV2 或 HTML 实体,适合粗体、斜体、链接和代码等行内格式。Rich Message 则把消息提升成结构化文档,除了常见行内样式,还能表达:
- 多级标题、列表和任务列表
- 代码块、引用、分隔线和可折叠详情
- 表格、数学公式、锚点与文内引用
- collage、slideshow、地图和多种媒体 block
- 专用于 AI 流式阶段的 thinking block
Rich Message 有两条最重要的发送路径:
| API | 用途 | 是否持久化 | 主要限制 |
|---|---|---|---|
sendRichMessage | 发送最终完整内容 | 是 | 可用于目标聊天,返回最终 Message |
sendRichMessageDraft | 在生成过程中更新部分内容 | 否 | 仅私聊;草稿是 30 秒临时预览,结束后仍须发送完整消息 |
这里最容易误解的是第二行:sendRichMessageDraft 不是“边生成边保存最终消息”。它只是临时预览;即使最后一帧看起来已经完整,也必须再调用 sendRichMessage 持久化结果。
InputRichMessage 要求 markdown、html、blocks 三者只能选择一个。当前项目选择 Markdown 作为 AI 主路径,因为模型输出可以直接交给 Telegram 解析;如果业务需要精确控制表格、媒体和嵌套结构,再考虑生成 blocks。
截至本文写作时,官方限制包括 32768 个 UTF-8 字符、500 个 blocks、16 层嵌套、50 个媒体附件和 20 列表格。服务端入口最好同步这些边界,避免无效请求进入 Bot 层后才失败。完整语法和限制可查看 Rich Message formatting options。

客户端实测:AI 回复中的粗体层级、行内变量与多行 LaTeX 公式均由 Rich Message 原生排版。
为了覆盖更多结构,我还发送了一条连续的全格式测试消息。下面三张图按原消息从上到下排列,依次展示行内样式、标题与列表、引用、代码块、表格、公式、脚注、文内锚点、折叠详情,以及地图和图片 block:



三张截图来自同一条 Rich Message。它们不仅验证了 Markdown 的行内格式,也验证了需要结构化布局才能成立的表格、公式、折叠区域和媒体 block;不同 Telegram 客户端版本的视觉细节可能略有差异。
投递架构与发送基础
只看 sendRichMessage 很容易把接入理解成一次 API 替换,但 AI 回复实际上跨过了 provider 输出、草稿渲染、最终持久化和兼容降级四个边界。下面这张图是这次实现最终收敛出的投递生命周期:
infographic sequence-zigzag-steps-underline-text
data
title Telegram AI 回复投递生命周期
desc 临时草稿、最终持久化与失败恢复必须分开建模
items
- label Provider 输出
desc 消费 token 流,同时保留可等待的完整文本 Promise
- label 私聊草稿
desc 使用 thinking 和 Rich Markdown 持续更新 30 秒临时预览
- label 最终持久化
desc 流结束后调用 sendRichMessage,写入真正的聊天记录
- label 内容分流
desc 完整文本按 plain、basic、rich 选择成本最低的发送路径
- label 失败降级
desc Rich 失败转 HTML,HTML 失败再分片发送纯文本
theme
palette
- #229ED9
- #38BDF8
- #14B8A6
- #F59E0B
- #64748B
这条链路有三个不能混淆的成功状态:provider 完成只说明“答案生成出来了”,草稿更新成功只说明“用户看到了临时预览”,只有最终发送返回 Message 才代表“聊天中留下了可持久化结果”。把三者压成一个布尔值,会让重试、埋点和错误提示都失去准确语义。
Rich Message 的三种输入形式也并非简单的偏好选择:
| 输入形式 | 优点 | 代价 | 更适合的场景 |
|---|---|---|---|
markdown | 可以直接承接 LLM 输出,接入成本最低 | 结构由 Telegram 解析,精确控制较弱 | AI 对话、文档摘要、代码解释 |
html | 标签语义明确,适合服务端模板 | 需要正确转义,仍要受支持标签集合约束 | 可控模板、运营通知 |
blocks | 结构最强,可显式描述媒体和复杂布局 | 需要维护 AST 映射、类型和版本兼容 | 表格、媒体卡片、确定性的产品消息 |
因此本文的主链路使用 markdown,而不是把模型输出先转成自定义 blocks。后者更适合结构由业务决定、内容只负责填槽的消息;对于开放式 AI 输出,过早做完整 AST 映射会显著增加解析和降级成本。
运行时准备
项目使用 grammY,因此先升级类型与流式插件:
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 内部使用了 Promise.withResolvers(),所以这个项目同时把本地、CI 和部署运行时统一到了 Node.js 22+。只升级依赖、不升级生产镜像,会出现“类型检查通过、运行时启动后才报错”的经典情况。
在自定义 Context 中加入 StreamFlavor,然后注册插件:
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());流式更新会在短时间内产生多次 Bot API 调用,grammY 官方也建议搭配 auto-retry。这不是 Rich Message 的语法要求,但对真实网络环境很有必要。
严格发送器
最小调用并不复杂:
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);
}这里故意叫 Strict:这个函数只验证 Rich Message 本身是否成功,不在内部静默改发普通消息。之后生产回复可以在它外面降级,而调试器则直接使用严格发送,确保测试按钮真的测到了新 API。
如果一开始就把 fallback 塞进最底层函数,调试页面显示“发送成功”时,你无法知道用户收到的是 Rich Message、HTML,还是纯文本。
从 HTTP 层看,grammY 最终只是把参数转换为 Bot API 的 snake_case JSON。理解这一层有助于排查“TypeScript 调用正确,但 Telegram 拒绝 payload”的问题:
{
"chat_id": 123456789,
"rich_message": {
"markdown": "# Build passed\n\n- typecheck\n- tests"
},
"reply_parameters": {
"message_id": 42
}
}输入中不要同时出现 markdown 和 html,也不要把 reply_parameters 塞进 rich_message。这类边界最好由 toInputRichMessage 和明确的 options 类型固定住,而不是让每个调用点手写对象。
流式回复与失败恢复
对于 AI SDK 一类会暴露 AsyncIterable<string> 的工具,最短接法是:
const { textStream } = streamText({
model,
messages,
});
await ctx.replyWithMarkdownStream(textStream);
replyWithMarkdownStream 会使用 Rich Markdown 更新临时草稿,并在流完成后发送持久化的 Rich Message。注意 Telegram 只允许在私聊中流式发送,因此入口必须先判断聊天类型:
if (ctx.chat?.type === 'private') {
await ctx.replyWithMarkdownStream(textStream);
} else {
// 群聊先消费完整输出,再发送最终消息
}
如果想在模型输出第一个 token 前显示 Telegram 原生的思考状态,可以单独发送 <tg-thinking>:
async function showThinkingDraft(api: Api, chatId: number, draftId: number) {
await api.sendRichMessageDraft(chatId, draftId, {
html: '<tg-thinking>Thinking...</tg-thinking>',
});
}
<tg-thinking> 只能用于 sendRichMessageDraft,不能出现在最终消息里。这一点值得单独写测试,否则一次看似无害的“复用模板”就可能让最终发送失败。
Draft ID、并发与背压
sendRichMessageDraft 要求一个非零 draft_id。同一段流持续使用相同 ID,Telegram 才会把更新识别成同一份草稿并播放连续动画。grammY 的 replyWithMarkdownStream 默认使用当前 update_id,对“一个 update 只产生一个流”的 handler 很方便;如果同一次 update 要并行生成两段内容,就必须显式分配不同 ID,或者把第二段改成串行。
更隐蔽的问题是同一聊天的两个 update 并发执行。若两个 AI 回复同时更新草稿,用户会看到内容交错,conversation history 也可能以错误顺序写入。解决方式不是让整个 Bot 串行,而是按 chat 建立约束:同一 chat 顺序处理,不同 chat 仍可并发。
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());中间件顺序同样重要:先建立同一聊天的串行边界,再让 handler 创建流。auto-retry 则放在 API transformer 上处理限流和瞬时网络错误,不要在 token 循环里写固定 sleep;固定延迟既不能正确读取 Telegram 的重试时间,还会让正常输出平白变慢。
流式插件内部还承担了背压协调:provider 可能每几十毫秒产生 token,而 Telegram 不适合以同样频率接收请求。当上一次 draft 请求尚未完成时,插件会合并尚未发送的最新内容,避免建立无限增长的更新队列。换句话说,token 必须完整消费,但并不要求每个 token 都对应一次 API 调用;用户需要的是最新草稿,而不是所有中间帧。
流式成功不等于回复成功
直接 await ctx.replyWithMarkdownStream(result.textStream) 只覆盖了理想路径。真实环境里可能发生:
- 已经写入一部分草稿后,Telegram 请求失败;
- LLM 的流读取失败,但稍后的聚合结果给出另一个更泛化的错误;
- 输出开头只有空白,Telegram 拒绝空草稿;
- 过滤 thinking 或元信息后,整个流变成空字符串;
- 草稿失败后立即 fallback,此时模型其实还没生成完整答案。
项目里的处理方式是把“草稿展示”和“完整结果”视为两条相关但独立的生命周期:
let streamStarted = false;
let capturedStreamError: unknown;
try {
await ctx.replyWithMarkdownStream(managedTextStream(result.textStream));
streamStarted = true;
} catch (error) {
capturedStreamError = error;
}
// 即使草稿发送失败,也继续等待 provider 的完整输出。
const completeText = (await result.text).trim();
if (!streamStarted && completeText) {
await sendFormattedChunks(ctx, completeText);
streamStarted = true;
}
// 没有任何可见输出时,保留最早、最可操作的错误。
if (capturedStreamError && !streamStarted) {
throw capturedStreamError;
}这段逻辑解决的是一个很实际的问题:Rich 草稿失败不代表 LLM 生成失败。只要 provider 最终能给出完整文本,就仍然可以通过非流式路径把完整答案发出去;反过来,如果最终也没有任何输出,就应该抛出最早捕获的网络或 API 错误,而不是用后续的 “No output generated” 把根因覆盖掉。
managedTextStream 还需要暂存开头的空白,直到遇到第一个可见字符才 yield。过滤器结束时也要 flush 缓冲区,否则最后一小段内容可能永远不会进入 Telegram。
为了让实现更容易 review,可以把投递过程显式写成状态机,而不是散落的 sent、streaming、hasText 布尔值:
type DeliveryPhase =
| 'waiting'
| 'drafting'
| 'persisting'
| 'fallback'
| 'done'
| 'failed';
状态机不一定需要引入库,关键是守住三条 invariant:草稿可见或最终发送成功前不要删掉 waiting 消息;只在持久化消息成功后写入“已回复”的业务记录;进入 fallback 后仍然使用 provider 的完整文本,而不是拿最后一帧草稿充当答案。这些约束比某个具体 helper 更接近系统真正需要保证的行为。
内容路由与兼容降级
技术上可以把所有 AI 输出都交给 sendRichMessage,但我最后选择按内容分三类:
| 内容 | 发送路径 | 示例 |
|---|---|---|
| 纯文本 | sendMessage | 普通句子、裸 URL、邮箱 |
| 基础格式 | sendMessage + HTML | 只包含粗体、斜体 |
| 结构化格式 | sendRichMessage | 标题、列表、命名链接、代码、引用、删除线、spoiler、公式等 |
原因主要有两个:普通文本没必要承担额外解析复杂度;同时,旧客户端或偶发 API 失败时,基础 HTML 仍然是很稳的兼容层。
分类器以 marked 的 lexer 为主,而不是用一大串正则重新实现 Markdown:
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;
}lexer 能可靠识别标题、列表、代码块、引用和命名链接;spoiler、marked、脚注和公式等扩展语法再用少量显式规则补充。裸 URL 和邮箱虽然会被 lexer 识别为 link,但它们不改变用户输入的展示结构,因此仍归为纯文本。
最终降级顺序是 Rich Markdown → Telegram HTML → 纯文本。这个顺序不能反过来:Rich Markdown 如果先被转换成旧 HTML 子集,标题、表格、公式等结构已经丢失,后面再调用 Rich API 也恢复不了。
降级函数最好返回实际采用的 delivery class,而不是只返回 Message。这样上层可以区分“Rich 成功”和“Rich 失败但 HTML 成功”,既方便监控新 API 的稳定性,也不会把一次兼容性成功误记成 Rich 成功。
消息长度与分片
Rich Message 的上限是 32768 个 UTF-8 字符,而旧 sendMessage 文本上限更小。于是一个完全合法的 Rich payload 在降级为 HTML 或纯文本时,可能因为长度再次失败。如果只写一个 try/catch 改 parse mode,所谓 fallback 实际上并不完整。
正确做法是在进入旧消息路径前重新按旧上限分片:
async function sendLegacyFallback(ctx: BotContext, markdown: string) {
for (const chunk of splitMessage(markdown, 4096)) {
await sendMarkdownWithHtmlThenPlainFallback(ctx, chunk);
}
}
splitMessage 不能只是 text.slice(0, 4096)。至少要考虑代码围栏、Markdown 实体、换行边界和 emoji;JavaScript 的 string.length 计算的是 UTF-16 code units,也不能想当然地等同于 API 文档里的字符语义。工程上可以采用保守阈值,并用中英文、emoji、超长代码块和未闭合 Markdown 做边界测试。
分片还引入了部分成功:前三片已经发送,第四片失败时,盲目重试整个答案会造成重复。比较稳妥的接口应返回已发送的 message IDs,并从失败片段继续重试;如果业务暂时不支持续传,至少要记录 chunkIndex 和 chunkCount,让日志能够解释用户为什么只收到一部分内容。
功能开关与平台隔离
Rich Message 是 Telegram 能力,不应该污染所有 Bot manager。项目同时支持 Telegram 和 Lark,如果直接把 sendRichMessage 塞进通用 IBotManager,Lark manager 只能实现一个永远报错的假方法。
更合适的做法是单独定义 capability:
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 在调用前检查 capability;前端则等 bot 详情加载完成后,仅在 platform === 'telegram' 时显示真实发送卡片。这样平台边界在类型、服务端和 UI 三层保持一致。
业务侧还增加了 richMessageEnabled 配置和 /richtext on|off 命令:
- 开启时,私聊流式回复固定使用 Rich Markdown;非流式回复按内容分类。
- 关闭时,继续使用普通流式草稿,完成后尽量编辑成兼容 HTML,最后退回纯文本。
这个开关不仅是回滚手段,也方便在不同 bot、客户端版本和网络环境下逐步放量。
调试与验证
自动化测试能证明 payload、分支和错误处理正确,但不能证明某个真实 bot token、chat ID 和 Telegram 客户端组合一定可用。因此项目增加了 owner-only 调试入口:
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"
}
服务端边界使用 Zod 检查:
chatId必须是安全的非零整数;format只能是markdown或html;content去除首尾空白后不能为空,长度不能超过 32768;- bot 未运行或平台不支持返回 409;
- Telegram 实际投递失败返回 502。
调试接口只调用严格发送器,不执行 fallback。这是有意为之:生产链路追求“尽量让用户收到答案”,调试链路追求“明确证明 Rich Message 是否成功”。这两个目标不能共用同一套成功语义。
前端还有一个容易漏掉的竞态:用户发送测试后立刻切换 bot,旧请求的回包不能覆盖新 bot 的界面状态。实现中同时记录 requestedBotId 和递增的 request ID,只接收仍属于当前页面上下文的结果。

错误分类与可观测性
“发送失败”不足以支撑线上排查。至少要区分 provider 生成失败、草稿更新失败、最终 Rich 持久化失败、HTML/纯文本降级失败,以及 Bot 未运行或平台不支持。它们的处理对象不同:provider 错误要看模型服务,Telegram 429 要交给 auto-retry,能力错误则应该在请求进入投递层前就被拒绝。
一条有用的结构化日志可以包含:
logger.warn('telegram reply degraded', {
phase: 'fallback',
deliveryClass: 'html',
fallbackFrom: 'rich',
chunkIndex,
chunkCount,
errorName: richError instanceof Error ? richError.name : 'UnknownError',
});
不要把完整 prompt、回复正文、bot token 或原始 chat ID 写入日志。需要按聊天关联事件时,可以使用只在服务端可复现的哈希或内部 trace ID。建议分别统计 draft_failure_rate、rich_persist_failure_rate 和 fallback_success_rate:总成功率可能仍然很高,但 fallback 比例持续上升通常意味着新 API、客户端兼容或 payload 分类出现了退化。
调试接口里的 409 与 502 也体现了同一思路:409 表示当前资源状态或平台能力不允许操作,502 表示服务已经尝试调用上游 Telegram,但上游投递失败。前端因此能给出可操作提示,而不是统一显示一个红色的 Unknown error。
测试覆盖
这次接入最后把验证拆成了四层:
- 分类与降级单测:普通文本、粗斜体、结构化 Markdown、Rich 失败、HTML 再失败。
- 流式生命周期测试:thinking 只存在于草稿、完整文本持久化、空流、开头空白、草稿失败后仍发送完整答案。
- 服务端能力与状态测试:Telegram manager 可发送,Lark manager 被拒绝,未运行和不支持返回 409,投递异常返回 502。
- 浏览器测试:Markdown/HTML payload、错误提示、切换 bot 后忽略旧回包、切换语言时保留输入、Lark bot 不显示发送卡片。
流式测试最重要的不是断言 helper 被调用,而是断言生命周期顺序和最终副作用。例如:
expect(sendRichMessageDraft).toHaveBeenCalled();
expect(sendRichMessage).toHaveBeenCalledWith(
chatId,
expect.objectContaining({ markdown: completeText }),
expect.anything(),
);
expect(sendRichMessage.mock.invocationCallOrder[0]).toBeLessThan(
saveConversationReply.mock.invocationCallOrder[0],
);还应加入故障注入:让第二次草稿更新抛错、让 Rich 最终发送返回 400、让 HTML fallback 返回 429,分别确认完整文本仍被消费、降级顺序没有反转、最早的可操作错误没有丢失。相比只测一个 happy path,这些测试更能保护以后升级 grammY 或调整 AI SDK 时的行为契约。
总结与参考资料
Telegram Rich Messages 的最小接入只有一行 api.sendRichMessage(...),真正决定它能否稳定上线的却是周围那一圈工程设计:
- 草稿只是 30 秒预览,最终内容必须单独持久化;
- 流式失败后要等待完整 provider 输出,不能急着发送半截 fallback;
- Markdown 应按表达能力分流,并保留 Rich → HTML → 纯文本降级;
- Telegram 专属能力要在类型、服务端与 UI 中同时隔离;
- 生产发送与严格调试要使用不同的成功标准;
- 运行时、CI 和容器版本必须一起升级。
如果只是做 demo,replyWithMarkdownStream(textStream) 已经足够漂亮;如果要把它放进一个长期运行、支持多平台的 AI Bot,这些失败路径才是接入工作的主体。
喜欢的话,留下你的评论吧~