接入 Telegram Bot API 10.2 Rich Messages:流式 AI 回复、降级与调试

发布于 2026-07-23 21:26 5460 字 28 min read ... 访问量

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

从 grammY 类型升级、Rich Message 发送与流式草稿,到 Markdown 分流、完整文本降级、平台能力隔离和真实发送调试,记录一次可用于生产环境的 Telegram 富文本接入。

本文由 AI 辅助整理,代码与结论来自实际项目实现。

最近给一个长期运行的多平台 Bot 项目接入了 Telegram 新增的 Rich Messages。起因很简单:LLM 天然会输出标题、列表、代码块、引用、公式和 spoiler,但旧的 sendMessage + parse_mode 更像是“在普通文本上加几个样式”,很难完整承载结构化内容;流式回复还需要不断编辑草稿,失败时很容易只留下半截答案。

Telegram 在 Bot API 10.1 中加入了 Rich Messages、sendRichMessagesendRichMessageDraft,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 要求 markdownhtmlblocks 三者只能选择一个。当前项目选择 Markdown 作为 AI 主路径,因为模型输出可以直接交给 Telegram 解析;如果业务需要精确控制表格、媒体和嵌套结构,再考虑生成 blocks。

截至本文写作时,官方限制包括 32768 个 UTF-8 字符、500 个 blocks、16 层嵌套、50 个媒体附件和 20 列表格。服务端入口最好同步这些边界,避免无效请求进入 Bot 层后才失败。完整语法和限制可查看 Rich Message formatting options

Telegram Rich Message 中的 LaTeX 公式实测
Telegram Rich Message 中的 LaTeX 公式实测

客户端实测:AI 回复中的粗体层级、行内变量与多行 LaTeX 公式均由 Rich Message 原生排版。

为了覆盖更多结构,我还发送了一条连续的全格式测试消息。下面三张图按原消息从上到下排列,依次展示行内样式、标题与列表、引用、代码块、表格、公式、脚注、文内锚点、折叠详情,以及地图和图片 block:

Telegram Rich Message 全格式测试上半部分:行内样式、标题、列表与引用
Telegram Rich Message 全格式测试上半部分:行内样式、标题、列表与引用
Telegram Rich Message 全格式测试中段:代码、表格、公式、脚注与折叠详情
Telegram Rich Message 全格式测试中段:代码、表格、公式、脚注与折叠详情
Telegram Rich Message 全格式测试下半部分:地图、图片 block 与测试结论
Telegram 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
  }
}

输入中不要同时出现 markdownhtml,也不要把 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) 只覆盖了理想路径。真实环境里可能发生:

  1. 已经写入一部分草稿后,Telegram 请求失败;
  2. LLM 的流读取失败,但稍后的聚合结果给出另一个更泛化的错误;
  3. 输出开头只有空白,Telegram 拒绝空草稿;
  4. 过滤 thinking 或元信息后,整个流变成空字符串;
  5. 草稿失败后立即 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,可以把投递过程显式写成状态机,而不是散落的 sentstreaminghasText 布尔值:

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,并从失败片段继续重试;如果业务暂时不支持续传,至少要记录 chunkIndexchunkCount,让日志能够解释用户为什么只收到一部分内容。

功能开关与平台隔离

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 只能是 markdownhtml
  • content 去除首尾空白后不能为空,长度不能超过 32768;
  • bot 未运行或平台不支持返回 409;
  • Telegram 实际投递失败返回 502。

调试接口只调用严格发送器,不执行 fallback。这是有意为之:生产链路追求“尽量让用户收到答案”,调试链路追求“明确证明 Rich Message 是否成功”。这两个目标不能共用同一套成功语义。

前端还有一个容易漏掉的竞态:用户发送测试后立刻切换 bot,旧请求的回包不能覆盖新 bot 的界面状态。实现中同时记录 requestedBotId 和递增的 request ID,只接收仍属于当前页面上下文的结果。

Telegram Rich Message 真实发送调试器:选择 Markdown 或 HTML,并查看成功回执
Telegram Rich Message 真实发送调试器:选择 Markdown 或 HTML,并查看成功回执

错误分类与可观测性

“发送失败”不足以支撑线上排查。至少要区分 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_raterich_persist_failure_ratefallback_success_rate:总成功率可能仍然很高,但 fallback 比例持续上升通常意味着新 API、客户端兼容或 payload 分类出现了退化。

调试接口里的 409 与 502 也体现了同一思路:409 表示当前资源状态或平台能力不允许操作,502 表示服务已经尝试调用上游 Telegram,但上游投递失败。前端因此能给出可操作提示,而不是统一显示一个红色的 Unknown error。

测试覆盖

这次接入最后把验证拆成了四层:

  1. 分类与降级单测:普通文本、粗斜体、结构化 Markdown、Rich 失败、HTML 再失败。
  2. 流式生命周期测试:thinking 只存在于草稿、完整文本持久化、空流、开头空白、草稿失败后仍发送完整答案。
  3. 服务端能力与状态测试:Telegram manager 可发送,Lark manager 被拒绝,未运行和不支持返回 409,投递异常返回 502。
  4. 浏览器测试: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,这些失败路径才是接入工作的主体。

参考资料

喜欢的话,留下你的评论吧~

... 访问量
© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka