OpenClaw 设计与实现

第8章 通道实现深度剖析

作者 杨艺韬 · 13,680 字 · 发布于 · 更新于

第8章 通道实现深度剖析

代码中最危险的假设是'这两个平台的行为应该一样'。Telegram 单条消息最多 4096 个字符,Discord 只有 2000;Discord 的机器人没有 Message Content Intent 就读不到频道消息的正文,Telegram 的机器人在群里默认只看得到发给它的命令。每一个'应该'都是一个等待爆炸的 bug。

本章要点

  • 逐一剖析六大通道实现:Telegram、Discord、WhatsApp、Signal、Slack、飞书
  • 理解每个平台的独特约束与技术选型背后的权衡
  • 掌握通道实现中的共性设计模式与差异化处理策略
  • 对比六大通道的能力矩阵,指导实际选型

8.1 引言

"读六个通道的实现"听起来像是"读六遍类似的代码"——但真正读下去、你会发现它们的"共性"反而凸显了"差异"。每个通道都在回答同样一组问题——如何认证?如何收消息?如何发消息?如何处理错误?如何维持连接?——但每个平台给出的答案都不同、背后的设计权衡都不同。读完六个对比、你对"通讯协议设计"这个领域会有一个立体的、历史感的认知——从最早的 IRC(1988)到 XMPP(1999)到 Matrix(2014)、再到商业平台的 Telegram/Discord/Slack——每一代协议都在解决前一代没解决好的问题。OpenClaw 作为"协议适配者"、它的工作不是发明新协议、而是"在这些协议的历史遗产之上、构造一个统一的现代接口"。这种"站在巨人肩膀上"的工作方式、是软件工程师最务实的姿态之一。

想象一个已经上线的 Agent 的日常切面:一条 WhatsApp 语音从巴西圣保罗发来,同一秒,东京的开发者在 Discord 的 #support 频道里 @了它,伦敦的产品经理在 Slack 线程里追问部署进度。三条消息,三种截然不同的协议栈——但 Agent 的核心逻辑看到的是同一种标准化的入站上下文(MsgContext,定义在 src/auto-reply/templating.ts)。上一章定义的抽象合约在此刻兑现了它的承诺。

上一章,我们设计了通道的抽象合约——那是蓝图。本章走进施工现场,看看那些优雅的接口定义如何与混乱的现实短兵相接。每个平台都有自己的"脾气":Telegram 要求你在 webhook 和长轮询之间二选一;Discord 强制声明 intents,遗漏一个就收不到消息;WhatsApp 需要扫码登录,认证状态还会悄然过期;Signal 依赖外部 daemon 进程,通信走 JSON-RPC。

没有两个通道是一样的。但 OpenClaw 的通道插件,让它们看起来一样——正如没有两种语言的语法是一样的,但一个优秀的翻译让你忘记原文是什么语言。

这正是抽象的价值所在——不是消灭差异,而是将差异封装在边界之内,让边界之外的世界保持简洁与统一。

🔥 深度洞察:每个通道都是一部外交史

深入阅读本章时,你会发现每个通道实现都不只是"技术适配"——它是与一个平台长期博弈的结晶。Telegram 选 Bot API 而非 User API,是安全与功能的权衡;WhatsApp 的认证状态管理,是与端到端加密设计的妥协;Discord 的 Intent 声明机制,是隐私保护与功能需求的平衡。每一个看似"技术细节"的选择,背后都有平台政策、安全合规、用户体验的多方博弈。这就像外交——你不能只懂对方的语言,还要懂对方的文化、法律和政治红线。优秀的通道实现工程师,本质上是技术外交官。

graph TB
    subgraph Plugins["通道插件实现"]
        TG[Telegram Plugin]
        DC[Discord Plugin]
        WA[WhatsApp Plugin]
        SG[Signal Plugin]
        SL[Slack Plugin]
        FS[Feishu Plugin]
    end
    subgraph Adapters["共享适配器模式"]
        CONFIG[Config Adapter]
        OUTBOUND[Outbound Adapter]
        GATEWAY[Gateway Adapter]
        STATUS[Status Adapter]
        PAIRING[Pairing Adapter]
    end
    TG --> CONFIG
    TG --> OUTBOUND
    TG --> GATEWAY
    DC --> CONFIG
    DC --> OUTBOUND
    DC --> GATEWAY
    WA --> CONFIG
    WA --> OUTBOUND
    WA --> GATEWAY
    SG --> CONFIG
    SG --> OUTBOUND
    SG --> GATEWAY
    SL --> CONFIG
    SL --> OUTBOUND
    SL --> GATEWAY
    FS --> CONFIG
    FS --> OUTBOUND
    FS --> GATEWAY
    style Plugins fill:#e3f2fd
    style Adapters fill:#f3e5f5

图 8-1:通道插件与共享适配器模式

8.2 Telegram 通道实现

Telegram 作为第一个被深入剖析的通道、值得多说几句。为什么 Telegram 被很多 AI 项目选为"第一个接入的通道"? 有几个原因——(1) Bot API 设计简洁明了、文档质量一流;(2) 免费、注册门槛低(找 @BotFather 就能拿到 token);(3) 用户群中科技爱好者比例高、对新工具接受度高;(4) 支持丰富的消息类型(文本、图片、文件、音频、视频、位置、联系人、支付)。这些特性、让 Telegram 成为"AI Agent 开发者的游乐场"——想验证一个新 AI 想法时、接入 Telegram 常常是最快的方式。本章把 Telegram 当作"模范生"来读——它是 OpenClaw 功能覆盖最全的通道之一。如果你只有时间深入一个通道实现、Telegram 是首选。

8.2.1 设计决策:为什么选择 Bot API 而非 User API?

深入实现之前,先讨论一个根本性的设计选择:Telegram 提供两种 API——Bot API 和 User API(MTProto)。OpenClaw 选择了 Bot API,这个决定并非不言自明。

User API 的诱惑:MTProto 允许以普通用户身份操作,可以加入任意群组、读取历史消息、使用 Telegram 的全部功能。一些项目(如基于 Telethon 这类 MTProto 库的 userbot)选择了这条路。但它有三个致命问题:

  1. 合规风险高。Telegram API 使用条款禁止"在用户不知情、未同意的情况下代其操作"(第 1.4 条),用个人账号跑自动化很容易被判定为滥用。账号被封禁的风险实实在在——你的 Agent 可能在运行三个月后突然失去所有连接。
  2. 需要手机号。每个 User API 会话需要一个手机号登录,且需要接收验证码。对于想运行多个 Agent 的运营者,这意味着多个手机号——管理成本高,且手机号是个人身份信息。
  3. 安全边界模糊。以"用户"身份操作意味着 Agent 拥有与人类用户完全相同的权限——包括读取所有私聊、操作所有群组。没有权限隔离。

Bot API 的优势:Bot 是 Telegram 的一等公民,有官方支持的生命周期(创建、配置、撤销)。更重要的是,Bot 的权限天然受限——在群里它默认只看得到发给它的命令和回复它的消息,除非它是群管理员。这种"天然最小权限"恰好是 Agent 安全的理想基础。

关键概念:通道协议适配 每个消息平台都有独特的通信协议——Telegram 使用长轮询或 Webhook,Discord 使用 WebSocket Gateway,WhatsApp 需要扫码认证的持久连接,Signal 依赖外部 JSON-RPC daemon。通道插件的核心职责就是将这些异构协议适配为 OpenClaw 的统一消息接口,同时保留平台的独特能力。

⚠️ 注意:Telegram Bot 在群组中默认处于 Privacy Mode,只能看到明确发给它的命令(如 /command@this_bot)、回复它的消息等少数几类(以管理员身份加入的 Bot 不受此限)。如果需要 Bot 读取群组中的所有消息,必须在 @BotFather 中关闭 Privacy Mode,并把 Bot 重新拉进群才生效。但请谨慎使用——这会让 Bot 接收群组中的每一条消息,可能带来 Token 消耗和隐私方面的考量。

技术选型不只是"能不能"的问题,更是"应不应该"的问题。Bot API 的功能限制,在安全语境下反而是优势——就像消防员穿防护服不是因为灵活,而是因为安全。限制本身就是保护。

WhatsApp 为什么做了相反的选择? 因为 WhatsApp 没有官方 Bot API(仅有面向企业的 Cloud API,要挂在 Meta 商业账户下、按模板消息计费)。OpenClaw 被迫使用 Web 客户端协议——这不是偏好,而是唯一可行的消费级方案。这种对比恰好说明:设计决策受平台约束的影响,和受技术偏好的影响一样大。

8.2.2 架构概览

Telegram 是 OpenClaw 支持最完善的通道之一。它支持 Bot API 的完整功能集,包括文本消息、媒体、投票、inline keyboard、话题(Topics)、webhook 和 polling 两种接收模式。

// extensions/telegram/src/channel.ts:456(结构节选)
export const telegramPlugin = createChatChannelPlugin({
  base: {
    ...createTelegramPluginBase({
      setupWizard: telegramSetupWizard,
      setup: telegramSetupAdapter,
    }),
    allowlist: buildDmGroupAccountAllowlistAdapter({ /* DM/群组白名单 */ }),
    bindings: { /* ACP 会话绑定 */ },
    conversationBindings: { /* 话题绑定管理器 */ },
    // ... status / gateway / actions ...
  },
  pairing: {
    text: {
      idLabel: "telegramUserId",
      message: PAIRING_APPROVED_MESSAGE,
      normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i),
      notify: async ({ cfg, id, message, accountId }) => {
        const { token } = resolveTelegramTokenHelper()(cfg, { accountId });
        if (!token) {
          throw new Error("telegram token not configured");
        }
        await resolveTelegramSend()(id, message, { token, accountId });
      },
    },
  },
  security: { dm: { /* ... */ }, collectWarnings: collectTelegramSecurityWarnings },
  threading: { topLevelReplyToMode: "telegram", /* ... */ },
  outbound: { base: { /* ... */ }, attachedResults: { /* ... */ } },
});

注意这里的组装方式:本章六个通道(以及仓库里绝大多数聊天通道)都不是手写一个大对象,而是调用 createChatChannelPlugin({ base, security, pairing, threading, outbound })(src/plugin-sdk/core.ts)。这个工厂把 security / pairing / threading / outbound 这四个"高频且写法雷同"的位置从完整适配器降级为声明式选项,再展开成真正的 ChannelPlugin——同一套 DM 策略、配对文案、线程回退逻辑不必在每个通道里各抄一遍。

8.2.3 配置解析

Telegram 的 token 可以来自五个位置,解析逻辑集中在 extensions/telegram/src/token.ts 的 resolveTelegramToken(),返回 { token, source },其中 source 是 "env" | "tokenFile" | "config" | "none"。真实优先级是从最具体到最通用,环境变量在最后而不是最前:

// extensions/telegram/src/token.ts:66-116(优先级顺序节选)
// 1. accounts.<id>.tokenFile —— 命中即返回,读不到直接判定 none(不再回退)
if (accountTokenFile) { /* tryReadSecretFileSync(..., { rejectSymlink: true }) */ }
// 2. accounts.<id>.botToken
if (accountToken) return { token: accountToken, source: "config" };
// 3. channels.telegram.tokenFile
if (tokenFile) { /* 同样:读不到就是 none */ }
// 4. channels.telegram.botToken
if (configToken) return { token: configToken, source: "config" };
// 5. 环境变量 TELEGRAM_BOT_TOKEN —— 只有默认账户 (allowEnv) 才允许
const envToken = allowEnv ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
if (envToken) return { token: envToken, source: "env" };
return { token: "", source: "none" };

两个容易踩到的细节:其一,一旦配置了 tokenFile 但文件读不出来,函数会直接返回 none 而不会继续往下回退到 botToken 或环境变量——这是刻意的"配置了就必须生效"语义;其二,环境变量只对默认账户开放(allowEnv = accountId === DEFAULT_ACCOUNT_ID),多账户部署里给某个具名账户配 token 只能走配置文件。

resolveTelegramAccount()(extensions/telegram/src/accounts.ts)在此之上再包一层账户合并与默认账户回退:mergeTelegramAccountConfig() 把 channel 级配置与 accounts.<id> 合并,resolveAccountWithDefaultFallback() 则在未显式指定 accountId 时,优先挑一个"确实有凭证"(tokenSource !== "none")的账户。

8.2.4 出站消息发送

Telegram 的出站消息发送遵循 ChannelOutboundAdapter 合约,涵盖文本分块、媒体发送、投票、inline keyboard 等能力:

// extensions/telegram/src/channel.ts:828(节选,省略了审批提示相关的钩子)
outbound: {
  base: {
    deliveryMode: "direct",          // 直接调用 Bot API
    chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
    chunkerMode: "markdown",         // 分块时尊重 Markdown 语法
    textChunkLimit: 4000,            // 默认分块上限
    pollMaxOptions: 10,
    // 无论用户把 textChunkLimit 调到多大,都不得越过 Bot API 的 4096 硬上限
    resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
      typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
    sendPayload: async ({ cfg, to, payload, ...opts }) => {
      const send = resolveTelegramSend(opts.deps);
      return attachChannelToResult("telegram",
        await sendTelegramPayloadMessages({ send, to, payload, baseOpts: buildTelegramSendOptions(opts) }));
    },
  },
  attachedResults: {
    channel: "telegram",
    sendText: async (ctx) => { /* ... */ },
    sendMedia: async (ctx) => { /* ... */ },
    sendPoll: async (ctx) => { /* ... */ },
  },
},

deliveryMode: "direct" 表示 Telegram 插件直接调用 Bot API,不需要通过 Gateway 代理。chunkerMode: "markdown" 表示消息分块时会尊重 Markdown 语法结构,避免在代码块中间断开。resolveEffectiveTextChunkLimit 则是一道"用户配置不能突破平台硬限制"的保险栓——默认 4000 留了余量,但即便用户把 channels.telegram.textChunkLimit 配成 10000,最终仍会被夹到 4096。

8.2.5 Gateway 启动流程

Telegram 支持两种消息接收模式:Polling(轮询) 和 Webhook(推送)。

// extensions/telegram/src/channel.ts:696(节选)
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    // 同一个 bot token 被多个账户重复配置时,直接拒绝启动
    const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg: ctx.cfg, accountId: account.accountId });
    if (ownerAccountId) {
      const reason = formatDuplicateTelegramTokenReason({ accountId: account.accountId, ownerAccountId });
      ctx.log?.error?.(`[${account.accountId}] ${reason}`);
      throw new Error(reason);
    }
    const token = (account.token ?? "").trim();

    // 探测 bot 信息(@username),失败时静默降级
    let telegramBotLabel = "";
    try {
      const probe = await resolveTelegramProbe()(token, 2500, { /* accountId, proxyUrl, network, apiRoot */ });
      const username = probe.ok ? probe.bot?.username?.trim() : null;
      if (username) telegramBotLabel = ` (@${username})`;
    } catch (err) { /* 仅 verbose 时打 debug 日志 */ }
    ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);

    // 有 webhookUrl 时用 Webhook 模式,否则用 Long Polling
    return resolveTelegramMonitor()({   // 即 monitorTelegramProvider
      token, accountId: account.accountId,
      config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal,
      useWebhook: Boolean(account.config.webhookUrl),
      // ... webhookUrl, webhookSecret, webhookPath, webhookHost, webhookPort, webhookCertPath
    });
  },
},

当配置了 webhookUrl 时,monitorTelegramProvider 会注册 webhook;否则使用 getUpdates long polling。

8.2.6 话题(Topics)支持

Telegram 群组支持话题功能,每个话题有独立的 message_thread_id。OpenClaw 将话题映射为独立的会话:

// extensions/telegram/src/channel.ts:309 — 话题路由(节选)
function resolveTelegramOutboundSessionRoute(params: {
  cfg; agentId: string; accountId?: string | null; target: string; threadId?: string | number | null; /* ... */
}) {
  const parsed = parseTelegramTarget(params.target);
  const chatId = parsed.chatId.trim();
  if (!chatId) return null;
  const resolvedThreadId = parsed.messageThreadId ?? parseTelegramThreadId(normalizeOutboundThreadId(params.threadId));
  const isGroup = parsed.chatType === "group" || /* chatType 未知时看 resolvedTarget.kind */;
  // 群组话题:话题折进 peerId = "chatId:topic:threadId",每个话题一个独立会话
  const peerId = isGroup && resolvedThreadId
    ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId;

  const baseSessionKey = buildTelegramBaseSessionKey({ cfg, agentId, accountId, peer: { kind: isGroup ? "group" : "direct", id: peerId } });
  // 私聊里的线程:不改 peer,而是在基础会话键上再派生线程会话键
  const threadKeys = resolvedThreadId && !isGroup
    ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(resolvedThreadId) }) : null;
  return { sessionKey: threadKeys?.sessionKey ?? baseSessionKey, baseSessionKey, /* ... */ };
}

8.2.7 Exec Approval 集成

Telegram 通道通过 auth + approvals 两个适配器接入命令审批,插件对象上只声明渲染方式,其余都转交给原生审批适配器:

// extensions/telegram/src/channel.ts:577
auth: telegramNativeApprovalAdapter.auth,
approvals: {
  delivery: telegramNativeApprovalAdapter.delivery,
  native: telegramNativeApprovalAdapter.native,
  render: {
    exec: {
      buildPendingPayload: ({ request, nowMs }) =>
        buildTelegramExecApprovalPendingPayload({ request, nowMs }),
    },
  },
},

什么时候启用、谁是审批人、审批消息发到哪里,由 telegramNativeApprovalAdapter 决定(extensions/telegram/src/approval-native.ts,用 createApproverRestrictedNativeApprovalAdapter() 生成)。审批消息本身由 buildTelegramExecApprovalPendingPayload()(extensions/telegram/src/exec-approval-forwarding.ts)生成,它调用通用的 buildExecApprovalPendingReplyPayload()(src/infra/exec-approval-reply.ts),生成一段含待执行命令、/approve <id> allow-once 等备选指令的文本,外加 Allow Once / Allow Always / Deny 三个交互按钮;投递时 extensions/telegram/src/exec-approvals-handler.ts 再经 resolveTelegramInlineButtons()(extensions/telegram/src/button-types.ts)把这组按钮转成 Inline Keyboard。按钮回调的内容就是 /approve <id> <决定> 这条命令本身,而 Telegram 的 callback_data 最多 64 字节,所以 "Allow Always" 的回调被缩写成 /approve <id> always(src/infra/exec-approval-reply.ts),以便带着完整的审批 ID 也放得下。

sequenceDiagram
    participant User as 用户
    participant Agent as OpenClaw Agent
    participant TG as Telegram Bot
    participant Exec as Exec Approval
    User->>Agent: /deploy production
    Agent->>Exec: 创建审批请求
    Exec->>TG: 发送审批消息 + Inline Keyboard
    TG->>User: 显示 "Allow Once" / "Allow Always" / "Deny" 按钮
    User->>TG: 点击 "Allow Once"
    TG->>Exec: 回调处理
    Exec->>Agent: 执行命令
    Agent-->>TG: 返回结果
    TG-->>User: 显示执行结果

图 8-2:Telegram Exec Approval 流程

8.2.8 Webhook vs. Polling 的深层权衡

表面上看,这是一个"推 vs. 拉"的简单选择。但深入分析,两种模式在不同部署场景下的适用性差异巨大:

考量维度 Long Polling Webhook
部署复杂度 零——只需出站连接 需要公网 HTTPS 端点
NAT 友好 是——出站请求穿越 NAT 否——需要端口转发或反向代理
消息延迟 接近实时(长轮询挂起等待,有更新即返回) 接近实时(Telegram 主动推送)
资源消耗 持续 HTTP 连接 仅在有消息时消耗
可靠性 简单——失败就重试 复杂——webhook 返回非 2xx 时,Telegram 重试有限次数后放弃
调试难度 低——可以在本地直接看到请求 高——需要 ngrok 等隧道工具

OpenClaw 在未配置 webhookUrl 时默认使用 Long Polling,这正契合它的目标用户画像:个人部署者在家用服务器或 VPS 上运行,通常在 NAT 之后,不想(或不会)配置 SSL 证书和反向代理。渐进式复杂度的哲学在这里体现——默认零配置即可工作,有公网 HTTPS 入口时可以选择升级到 Webhook。

8.3 Discord 通道实现

从 Telegram 跳到 Discord、你会立刻感受到"平台文化"的差异。Telegram 的 Bot API 是"极简主义"——一个 token、一组 REST 端点、几十个方法、就能做所有事。Discord 则是"企业级复杂度"——需要创建 Application、注册 Bot、配置 OAuth Scopes、开启 Privileged Intents,进入 100 个以上服务器后还要走应用验证和特权 intent 审核。这种复杂度的差异、反映了两个平台对"bot 开发者"的定位差异——Telegram 把 bot 开发当成"平民能力"、Discord 把 bot 开发当成"需要被管控的开发者生态"。读 Discord 通道实现时、你不只是在学代码、也是在理解"一个成熟平台如何平衡开放性和安全性"。这种理解、对你未来在任何"有大量第三方开发者的平台"上工作时都有帮助——API Gateway 的设计、开放平台的治理、SDK 的权限管理都会用到类似的思维。

8.3.1 设计决策:Intents 是安全特性而非技术障碍

Discord 使用 WebSocket Gateway 连接接收事件。但在讨论实现之前,必须理解 Discord 的 Gateway Intents 设计——这不只是技术限制,而是一个深思熟虑的隐私保护架构。

2020 年之前,Discord Bot 可以自动接收所有事件。这意味着一个被加入 1000 个服务器的 Bot 可以静默监控所有消息——即使它的功能只是播放音乐。Intents 的引入强制 Bot 声明"我需要看到什么",未声明的事件类型不会被推送。

这对 OpenClaw 的影响是双面的:

正面:Intents 天然限制了 Agent 的信息范围。一个只声明了 GUILD_MESSAGES intent 的 Bot 看不到用户的在线状态、语音状态或成员变更——这正是最小权限原则的平台级实现。

负面:MESSAGE_CONTENT intent 需要额外审批(超过 100 个服务器的 Bot)。这意味着大规模部署的 Bot 可能需要 Discord 的人工审批——一个完全在 OpenClaw 控制之外的依赖。

OpenClaw 的应对是先诊断、再明确失败:它连接网关时总会请求 MessageContent(连同 Guilds、GuildMessages、DirectMessages、表情回应与语音状态等 intent,见 extensions/discord/src/monitor/gateway-plugin.ts 的 resolveDiscordGatewayIntents(),配置里只能额外打开 presence 和 guildMembers)。启动时先用 probeDiscord 查询应用的 intent 状态,发现是 disabled 就输出警告;如果 Portal 里确实没开,Discord 会以 4014(Disallowed intents)关闭连接,OpenClaw 把它识别为 disallowed-intents 事件,打印修复提示并停止该账户的重连(extensions/discord/src/monitor/provider.lifecycle.ts),而不是无休止地重试。

💡 最佳实践:部署 Discord 通道时,务必在 Discord Developer Portal 中启用 MESSAGE_CONTENT intent。OpenClaw 总会请求它,没开的话网关连接会被拒绝。(按 Discord 文档,一般的 Bot 缺少这个 intent 时,只能看到私信、@提及它的消息和它自己发的消息的正文。)进入 100 个以上服务器的 Bot 必须先通过验证,再申请特权 intent 的审核。

// extensions/discord/src/channel.ts:285(结构节选)
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe> =
  createChatChannelPlugin<ResolvedDiscordAccount, DiscordProbe>({
    base: {
      ...createDiscordPluginBase({ setup: discordSetupAdapter }),
      allowlist: { ...buildLegacyDmAccountAllowlistAdapter({ channelId: "discord", /* ... */ }) },
      actions: discordMessageActions,
      bindings: { /* ACP 会话绑定 */ },
      status: { /* probe / 权限诊断 */ },
      gateway: { /* startAccount */ },
    },
    threading: { topLevelReplyToMode: "discord" },
    outbound: { base: { deliveryMode: "direct", chunker: null, textChunkLimit: 2000, /* ... */ } },
  });

Discord 的能力声明(extensions/discord/src/shared.ts)是 chatTypes: ["direct", "channel", "thread"] 加上 polls/reactions/threads/media/nativeCommands 全开——注意它没有 group 这个 chatType,Discord 的服务器频道在 OpenClaw 里归到 channel。

8.3.2 Discord 的凭证:单一 Bot Token

需要先澄清一个常见误解:Discord 通道只用一个 Bot Token。Discord 通道的代码里(extensions/discord/)既没有 userToken,也没有 getTokenForOperation —— 那套"读写分离双 Token"的写法属于 Slack,不要张冠李戴。Discord 侧的复杂度不在凭证个数,而在 Gateway Intents 的申请与降级(下一节)。

作为对照,Slack 的双 Token 选择逻辑长这样:

// extensions/slack/src/channel.ts:102
function getTokenForOperation(
  account: ResolvedSlackAccount,
  operation: "read" | "write",
): string | undefined {
  const userToken = account.config.userToken?.trim() || undefined;
  const botToken = account.botToken?.trim();
  const allowUserWrites = account.config.userTokenReadOnly === false;

  if (operation === "read") {
    return userToken ?? botToken;  // 读操作优先使用 user token
  }

  if (!allowUserWrites) {
    return botToken;  // 写操作默认只用 bot token
  }

  return botToken ?? userToken;
}

读操作优先用 User Token(能看到 Bot 看不到的历史与私有频道),写操作则默认只走 Bot Token——只有显式把 userTokenReadOnly 配成 false 时,才允许在缺少 Bot Token 的情况下用 User Token 写。注意判定写的是 account.config.userTokenReadOnly === false:也就是说"未配置"和"配成 true"都算只读,必须显式关闭才放开。这是一个典型的"默认安全、显式放开"的设计。

8.3.3 Intents 检测

Discord 要求显式声明 Gateway Intents。OpenClaw 会在启动时检测 Message Content Intent 状态:

// extensions/discord/src/channel.ts:565(节选)
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    const token = account.token.trim();
    let discordBotLabel = "";
    // 探测 bot 信息 & Intent 状态(失败时静默降级)
    try {
      const probe = await (await loadDiscordProbeRuntime()).probeDiscord(token, 2500, { includeApplication: true });
      const username = probe.ok ? probe.bot?.username?.trim() : null;
      if (username) discordBotLabel = ` (@${username})`;
      ctx.setStatus({ accountId: account.accountId, bot: probe.bot, application: probe.application });
      const messageContent = probe.application?.intents?.messageContent;
      if (messageContent === "disabled") {
        ctx.log?.warn(`[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. ...`);
      } else if (messageContent === "limited") {
        ctx.log?.info(`[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`);
      }
    } catch (err) { /* 仅 verbose 时打 debug 日志 */ }
    ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);

    return (await loadDiscordProviderRuntime()).monitorDiscordProvider({
      token, accountId: account.accountId,
      config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal,
      // ... mediaMaxMb, historyLimit, setStatus
    });
  },
},

8.3.4 线程与回复模式

Discord 的线程(Thread)有特殊的行为——消息可以"发送到线程"或"回复消息但不在线程中"。不过 Discord 侧的 threading 声明其实非常薄——它只指定了顶层回复模式,把线程解析交给通用逻辑:

// extensions/discord/src/channel.ts:629
threading: {
  topLevelReplyToMode: "discord",
},

对照之下,需要"自动挑线程"的是 Slack 和 Telegram。Slack 的 threading 用 scopedAccountReplyToMode 做按账户/按聊天类型的回复模式解析,并在没有显式 replyToId 时才自动推断线程:

// extensions/slack/src/channel.ts:499
threading: {
  scopedAccountReplyToMode: {
    resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
    resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
  },
  allowExplicitReplyTagsWhenOff: false,
  buildToolContext: (params) => buildSlackThreadingToolContext(params),
  resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
    replyToId ? undefined : resolveSlackAutoThreadId({ to, toolContext }),
  resolveReplyTransport: ({ threadId, replyToId }) => ({
    replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
    threadId: null,
  }),
},

8.3.5 消息 Actions

Discord 支持丰富的消息操作,包括 reactions、timeout、kick、ban:

// extensions/discord/src/channel.ts:117 — 消息操作适配器(简化:源码每处都写作
// getDiscordRuntime().channel.discord.messageActions,这里简记为 messageActions)
const discordMessageActions: ChannelMessageActionAdapter = {
  describeMessageTool: (ctx) => messageActions?.describeMessageTool?.(ctx) ?? null,
  extractToolSend:     (ctx) => messageActions?.extractToolSend?.(ctx) ?? null,
  handleAction: async (ctx) => {  // 委托给运行时,运行时缺失则直接抛错
    if (!messageActions?.handleAction) throw new Error("Discord message actions not available");
    return messageActions.handleAction(ctx);
  },
  // timeout/kick/ban 等敏感操作要求受信任的请求者
  requiresTrustedRequesterSender: ({ action, toolContext }) =>
    Boolean(toolContext && (action === "timeout" || action === "kick" || action === "ban")),
};

requiresTrustedRequesterSender 确保 timeout/kick/ban 等敏感操作只能在受信任的请求者发起时执行。注意它同时要求 toolContext 存在——也就是说只有"由工具调用发起"的这三个动作才走加严路径。

8.3.6 权限诊断

Discord 插件通过 buildCapabilitiesDiagnostics 诊断 bot 在特定频道中的权限缺口:

// extensions/discord/src/channel.ts:442 — 权限诊断(简化:省略了目标不是频道、缺 token、请求出错三条报错分支)
status: {
  buildCapabilitiesDiagnostics: async ({ account, target }) => {
    if (!target?.trim()) return undefined;
    const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" });
    const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, { token: account.token.trim(), /* accountId */ });
    // REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"]
    const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter(p => !perms.permissions.includes(p));
    return {
      details: { /* target, permissions: { ...perms, missingRequired } */ },
      lines: [
        { text: `Permissions (${perms.channelId}): ${perms.permissions.join(", ") || "none"}` },
        missingRequired.length > 0
          ? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" }
          : { text: "Missing required: none", tone: "success" },
      ],
    };
  },
},
graph LR
    subgraph DiscordAPI["Discord API"]
        GUILD[Guild]
        CHANNEL[Channel]
        PERMS[Permissions]
    end
    subgraph OpenClaw
        PROBE[probeDiscord]
        DIAG[buildCapabilitiesDiagnostics]
        MONITOR[monitorDiscordProvider]
    end
    PROBE --> |获取 bot 信息| DiscordAPI
    DIAG --> |查询频道权限| DiscordAPI
    MONITOR --> |WebSocket 连接| DiscordAPI
    style DiscordAPI fill:#5865F2,color:white
    style OpenClaw fill:#e8f5e9

图 8-3:Discord 与 OpenClaw 的交互

8.4 WhatsApp 通道实现

WhatsApp 是本章最"特殊"的通道——因为它根本没有官方的 bot API(至少没有平民可用的那种)。Meta 虽然推出了 WhatsApp Business Platform(Cloud API)、但它要挂在 Meta 商业账户下,企业主动发起的消息必须使用审核过的模板、按条付费——对个人开发者和小团队门槛很高。那么 OpenClaw 怎么接入 WhatsApp? 答案是:借助 WhatsApp Web 协议——一个"不公开但被社区逆向出来"的客户端协议。这种方案的优点是"免费、无需企业认证、支持几乎所有个人账户";缺点是"Meta 可能随时封号、协议随版本变化可能失效、不被官方认可"。这种"法律灰色地带"的技术选择、在很多领域都存在——iOS 的越狱工具、Android 的 Xposed 框架、游戏的 modding 社区、浏览器的广告屏蔽插件——都在"厂商不希望"和"用户有需要"之间寻找平衡。作为开发者、你需要清楚这种方案的风险、并在产品中明确告知用户。

8.4.1 设计决策:为什么选择 Web 客户端协议?

WhatsApp 的接入方式有过三条可能的路径,OpenClaw 选择了其中最"非正统"的一条:

路径 A:WhatsApp Cloud API(官方)。Meta 提供的官方 API,需要 Meta 开发者应用和商业账户(business portfolio)、一个专用于 API 的电话号码;自 2025 年 7 月 1 日起按投递的模板消息逐条计费。优点:官方支持、稳定。缺点:不适合个人用户——要以企业身份接入。OpenClaw 的目标用户是个人开发者和小团队,这个门槛太高。

路径 B:WhatsApp Business API 本地部署版(On-Premises API)。这条路已经关闭:Meta 自 2024 年 7 月起只允许把企业号码注册到 Cloud API,On-Premises API 的最后一个版本已于 2025 年 10 月 23 日到期(Meta 官方文档"On-Premises API Sunset")。

路径 C:WhatsApp Web 协议(最终选择)。通过反向工程的 Web 客户端协议连接,相当于"程序模拟你在浏览器中使用 WhatsApp Web"。优点:零成本、零审批、任何 WhatsApp 账号都能用。缺点:依赖第三方库维护协议兼容性(OpenClaw 用的是 Baileys,extensions/whatsapp/package.json 锁定 @whiskeysockets/baileys 7.0.0-rc.9),且 WhatsApp 可能随时更改协议。

这个选择体现了一个更深层的哲学:OpenClaw 优先服务个人用户和小团队,而非企业。企业有资源走官方路径;个人用户需要"零摩擦"的接入方式。这种优先级决定了很多看似"非标准"的设计选择。

8.4.2 Web 客户端架构

OpenClaw 使用 WhatsApp Web 客户端协议实现消息收发。这需要:

  1. QR 码登录:首次使用需扫描 QR 码
  2. 持久化认证:会话状态存储在 authDir 目录
  3. 被动监听:作为"普通用户"接收消息
// extensions/whatsapp/src/channel.ts:66(结构节选)
export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
  createChatChannelPlugin<ResolvedWhatsAppAccount>({
    pairing: { idLabel: "whatsappSenderId" },
    outbound: {
      ...createWhatsAppOutboundBase({
        chunker: chunkText,
        sendMessageWhatsApp,
        sendPollWhatsApp,
        resolveTarget: ({ to, allowFrom, mode }) =>
          resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
      }),
      normalizePayload: ({ payload }) => ({
        ...payload,
        text: normalizeWhatsAppPayloadText(payload.text),
      }),
    },
    base: {
      ...createWhatsAppPluginBase({
        groups: {
          resolveRequireMention: resolveWhatsAppGroupRequireMention,
          resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
          resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
        },
        setupWizard: whatsappSetupWizardProxy,
        setup: whatsappSetupAdapter,
        isConfigured: async (account) =>
          await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir),
      }),
      agentTools: () => [createWhatsAppLoginTool()],
      // ... allowlist / status / heartbeat / gateway ...
    },
  });

有个容易忽略的细节:WhatsApp 的账户配置(allowFrom、dmPolicy、accounts 等)在 channels.whatsapp 下,但 Web 客户端连接本身的开关和参数放在顶层的 web 块里(web.enabled、web.heartbeatSeconds、web.reconnect,见 src/config/zod-schema.ts)。热重载规则也按此区分——reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }(extensions/whatsapp/src/shared.ts):改 web.* 会重启 WhatsApp 通道,改 channels.whatsapp.* 则不触发重启;isEnabled 也要求 cfg.web?.enabled !== false。顶层这个 web 的命名,是 WhatsApp 通道以"Web 客户端"方式接入留下的印记。

8.4.3 QR 码登录流程

gateway 适配器把"起账号 / 扫码登录 / 等待登录 / 登出"四件事交给 WhatsApp Web 运行时:

// extensions/whatsapp/src/channel.ts:318-359
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir);
    const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
    ctx.log?.info(`[${account.accountId}] starting provider (${identity})`);
    return (await loadWhatsAppChannelRuntime()).monitorWebChannel(
      getWhatsAppRuntime().logging.shouldLogVerbose(),
      undefined,
      true,
      undefined,
      ctx.runtime,
      ctx.abortSignal,
      {
        statusSink: (next: WebChannelStatus) =>
          ctx.setStatus({ accountId: ctx.accountId, ...next }),
        accountId: account.accountId,
      },
    );
  },
  loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) =>
    await (
      await loadWhatsAppChannelRuntime()
    ).startWebLoginWithQr({
      accountId,
      force,
      timeoutMs,
      verbose,
    }),
  loginWithQrWait: async ({ accountId, timeoutMs }) =>
    await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }),
  logoutAccount: async ({ account, runtime }) => {
    const cleared = await (
      await loadWhatsAppChannelRuntime()
    ).logoutWeb({
      authDir: account.authDir,
      isLegacyAuthDir: account.isLegacyAuthDir,
      runtime,
    });
    return { cleared, loggedOut: cleared };
  },
},
sequenceDiagram
    participant User as 用户
    participant CLI as OpenClaw CLI
    participant WA as WhatsApp Runtime
    participant Web as WhatsApp Web
    CLI->>WA: loginWithQrStart()
    WA->>Web: 建立连接
    Web-->>WA: 返回 QR 码数据
    WA-->>CLI: 显示 QR 码
    User->>CLI: 手机扫描 QR
    Web-->>WA: 认证成功
    WA-->>CLI: loginWithQrWait() 完成
    CLI->>WA: 启动 monitorWebChannel()
    WA->>Web: 开始接收消息

图 8-4:WhatsApp QR 码登录流程

8.4.4 认证状态管理

WhatsApp 的认证状态存储在文件系统中,status 适配器会检查认证文件的存在和年龄:

// extensions/whatsapp/src/channel.ts:244 — 认证状态检查(简化:省略了 authDir 判空与连接/重连等状态字段)
status: {
  buildChannelSummary: async ({ account, snapshot }) => {
    const runtime = await loadWhatsAppChannelRuntime();
    const linked = typeof snapshot.linked === "boolean" ? snapshot.linked : await runtime.webAuthExists(account.authDir);
    const authAgeMs = linked ? await runtime.getWebAuthAgeMs(account.authDir) : null;
    const self = linked ? runtime.readWebSelfId(account.authDir) : { e164: null, jid: null };
    return { configured: linked, linked, authAgeMs, self, running: snapshot.running ?? false, /* ... */ };
  },
},

8.4.5 Heartbeat 集成

WhatsApp 插件借助 heartbeat 适配器探测 WhatsApp 是否可用作心跳通知通道:

// extensions/whatsapp/src/channel.ts:222 — 心跳就绪检查(简化:省略了测试用的 deps 注入,运行时函数实为懒加载取得)
heartbeat: {
  checkReady: async ({ cfg, accountId }) => {
    if (cfg.web?.enabled === false) return { ok: false, reason: "whatsapp-disabled" };
    const account = resolveWhatsAppAccount({ cfg, accountId });
    if (!await webAuthExists(account.authDir)) return { ok: false, reason: "whatsapp-not-linked" };
    if (!getActiveWebListener()) return { ok: false, reason: "whatsapp-not-running" };
    return { ok: true, reason: "ok" };
  },
  resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts),
},

8.5 Signal 通道实现

Signal 是"隐私极端主义"的代表——它的每一个设计选择、都在把"用户隐私"放在最高优先级。这种极端、让 Signal 成为记者、活动家、高敏感人群的首选通讯工具、也让它对 bot 集成极其不友好——没有官方 bot API、所有消息端到端加密、bot 只能以一个真实手机号账户(或其关联设备)的身份运行。OpenClaw 接入 Signal 的方式、是借助 signal-cli——一个开源的 Signal 客户端命令行工具。这种"外挂 daemon + JSON-RPC 通信"的架构、在其他类似场景(比如集成只有 GUI 客户端的软件)也常被使用——核心思路是"把不友好的协议适配成友好的 API"。这种"跨进程桥接"的设计、是软件工程里一个经典的模式——Java 的 JNI、Python 的 subprocess、Node.js 的 child_process、shell 脚本的管道组合——都在做类似的事情。

8.5.1 设计决策:为什么依赖外部 Daemon?

Signal 是六大通道中最"特立独行"的——它是唯一一个依赖外部进程的通道。这个决定背后有深刻的原因。

Signal 协议(Signal Protocol)是端到端加密消息领域的标准,但它的设计初衷是抵抗中心化控制。Signal 官方不提供 Bot API。

这留给 Agent 框架三个选择:

  1. 直接实现 Signal Protocol。从头实现 Double Ratchet、X3DH 密钥交换、密封消息等密码学原语。工作量巨大(数月开发),且需要持续跟进协议变更。
  2. 使用 libsignal 库。Signal 的官方协议库,核心用 Rust 编写、对外提供 Java/Swift/TypeScript 封装,但官方明确声明不支持 Signal 之外的使用(libsignal README:"Use outside of Signal is unsupported")。
  3. 委托给 signal-cli(最终选择)。signal-cli 是一个成熟的第三方项目,已经解决了协议实现的所有复杂性。OpenClaw 通过 JSON-RPC 与它通信。

选择 signal-cli 的权衡很明确:获得稳定的协议实现,但引入了外部依赖。这意味着运营者需要额外安装和维护 signal-cli daemon。OpenClaw 通过状态探测(probeSignal)和自动拉起 daemon 来减轻这个负担——未配置 httpUrl 时 autoStart 默认开启,由 extensions/signal/src/daemon.ts 直接 spawn signal-cli(extensions/signal/src/monitor.ts);但安装 signal-cli、注册或关联号码,终究是额外的运维步骤。

有时候,最好的架构决策是承认"不做"。直接实现 Signal Protocol 会让 OpenClaw 团队承担远超其核心使命的密码学维护责任。知道自己的边界在哪里,比知道自己能做什么更难——也更重要。

8.5.2 JSON-RPC 通信架构

OpenClaw 通过 JSON-RPC 接口与 signal-cli 通信(extensions/signal/src/client.ts 向 daemon 的 /api/v1/rpc 发送 JSON-RPC 2.0 请求),插件对象本身则和其他通道一样用 createChatChannelPlugin 组装:

// extensions/signal/src/channel.ts:228(结构节选)
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
  createChatChannelPlugin({
    base: {
      ...createSignalPluginBase({
        setupWizard: signalSetupWizard,
        setup: signalSetupAdapter,
      }),
      actions: signalMessageActions,
      auth: signalApprovalAuth,
      allowlist: buildDmGroupAccountAllowlistAdapter({ channelId: "signal", /* ... */ }),
      // ... status / gateway ...
    },
    // ... outbound / threading ...
  });

Signal 声明的能力很克制(extensions/signal/src/shared.ts):chatTypes: ["direct", "group"],只开 media 和 reactions——没有 threads、没有 polls、没有 nativeCommands。这与 Signal 本身的功能面相符,而不是实现偷懒。

8.5.3 多身份标识

Signal 支持多种用户标识方式:E.164 电话号码、UUID、用户名。OpenClaw 会自动解析和标准化:

// extensions/signal/src/channel.ts:80
function inferSignalTargetChatType(rawTo: string) {
  let to = rawTo.trim();
  if (!to) return undefined;

  if (/^signal:/i.test(to)) {
    to = to.replace(/^signal:/i, "").trim();
  }
  if (!to) return undefined;

  const lower = to.toLowerCase();
  if (lower.startsWith("group:")) {
    return "group" as const;
  }
  if (lower.startsWith("username:") || lower.startsWith("u:")) {
    return "direct" as const;
  }

  return "direct" as const;
}

8.5.4 格式化文本

Signal 支持 basic styling(粗体、斜体、等宽),OpenClaw 会将 Markdown 转换为 Signal 的样式格式:

// extensions/signal/src/channel.ts:144 — Markdown → Signal 样式转换与分块发送(简化)
async function sendFormattedSignalText(ctx: { cfg; to: string; text: string; accountId?; deps?; abortSignal? }) {
  const { send, maxBytes } = resolveSignalSendContext({ cfg: ctx.cfg, accountId: ctx.accountId, deps: ctx.deps });
  const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId, { fallbackLimit: 4000 });

  // 将 Markdown 转换为 Signal 文本样式块(粗体、斜体、等宽等)
  const tableMode = resolveMarkdownTableMode({ cfg: ctx.cfg, channel: "signal", accountId: ctx.accountId });
  let chunks =
    limit === undefined
      ? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode })
      : markdownToSignalTextChunks(ctx.text, limit, { tableMode });
  if (!chunks.length && ctx.text) chunks = [{ text: ctx.text, styles: [] }];

  // 逐块发送,支持中断
  const results = [];
  for (const chunk of chunks) {
    ctx.abortSignal?.throwIfAborted();
    results.push(await send(ctx.to, chunk.text, { cfg: ctx.cfg, maxBytes, accountId: ctx.accountId, textMode: "plain", textStyles: chunk.styles }));
  }
  return attachChannelToResults("signal", results);
}

8.5.5 状态探测

Signal 插件通过 probeAccount 探测 signal-cli daemon 是否就绪:

// extensions/signal/src/channel.ts:266
status: createComputedAccountStatusAdapter<ResolvedSignalAccount, SignalProbe>({
  defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
  collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("signal", accounts),
  buildChannelSummary: ({ snapshot }) =>
    buildBaseChannelStatusSummary(snapshot, {
      baseUrl: snapshot.baseUrl ?? null,
      probe: snapshot.probe,
      lastProbeAt: snapshot.lastProbeAt ?? null,
    }),
  probeAccount: async ({ account, timeoutMs }) => {
    const baseUrl = account.baseUrl;
    return await probeSignal(baseUrl, timeoutMs);
  },
  formatCapabilitiesProbe: ({ probe }) =>
    (probe as SignalProbe | undefined)?.version
      ? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }]
      : [],
  // ...
}),

8.6 Slack 通道实现

Slack 是"企业通讯平台"的代表——它的每一个设计、都反映了"企业用户对工具的期待"。企业用户期待什么? 稳定性(不会随便断线)、权限控制(谁能说什么、谁能看什么)、审计能力(所有消息可追溯)、集成能力(能和公司其他工具联动)——Slack 在这些方面做得相当不错、所以它能成为全球企业 IM 市场的头部玩家。OpenClaw 接入 Slack 的实现、也反映了这种"企业级"特质——它同时支持 Socket Mode(WebSocket 长连接,只需出站网络)和 HTTP Mode(Slack 把事件推到你公网可达的 HTTP 端点,适合已有 HTTPS 入口的部署)。这种"双模式并存"的设计、是企业级产品的典型特征——因为企业环境的多样性、单一方案很难满足所有部署场景。读 Slack 通道实现、你同时在学"如何设计能满足多种企业部署需求的集成方案"。

8.6.1 设计决策:Socket Mode 为什么是默认推荐?

Slack 支持两种事件接收模式,OpenClaw 默认推荐 Socket Mode——这个选择与 Telegram 选择 Long Polling 的逻辑一脉相承:

  • Socket Mode:通过 WebSocket 连接接收事件,不需要公网可访问的服务器
  • HTTP Mode:通过 HTTP webhook 接收事件,需要配置签名验证
// extensions/slack/src/channel.ts:427(节选)
status: {
  resolveAccountSnapshot: ({ account }) => {
    const mode = account.config.mode ?? "socket";   // 默认 socket
    const configured =
      (mode === "http"
        ? resolveConfiguredFromRequiredCredentialStatuses(account, [
            "botTokenStatus",
            "signingSecretStatus",
          ])
        : resolveConfiguredFromRequiredCredentialStatuses(account, [
            "botTokenStatus",
            "appTokenStatus",
          ])) ?? isSlackPluginAccountConfigured(account);

    // ...
  },
},

8.6.2 频道类型检测

Slack 有多种频道类型:public channel、private channel、DM、MPIM(多用户 DM)。OpenClaw 需要动态检测:

// extensions/slack/src/channel-type.ts:6 — 频道类型检测(带缓存,节选)
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();

export async function resolveSlackChannelType(params: {
  cfg: OpenClawConfig; accountId?: string | null; channelId: string;
}): Promise<"channel" | "group" | "dm" | "unknown"> {
  const channelId = params.channelId.trim();
  if (!channelId) return "unknown";
  const cacheKey = `${params.accountId ?? "default"}:${channelId}`;
  const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey);
  if (cached) return cached;

  const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
  // 1. 先查 dm.groupChannels 配置(裸 ID 或 slack:/channel:/group:/mpim: 前缀均可)
  const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
  if (groupChannels.includes(channelIdLower) /* || 各前缀形式 */) {
    SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group");
    return "group";
  }
  // 2. 出现在 channels 配置里的 key 视为普通频道
  if (/* Object.keys(account.channels ?? {}) 中有匹配项 */) { /* 缓存并返回 "channel" */ }

  // 3. 调用 conversations.info API 动态检测(无 token 或调用失败则缓存为 "unknown")
  const client = createSlackWebClient(token);
  const info = await client.conversations.info({ channel: channelId });
  const type = info.channel?.is_im ? "dm" : info.channel?.is_mpim ? "group" : "channel";
  SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type);
  return type;
}

8.6.3 交互式回复

Slack 支持通过 Block Kit 构建丰富的交互界面。OpenClaw 可以检测并处理结构化的回复负载:

// extensions/slack/src/channel.ts:294(节选)
messaging: {
  enableInteractiveReplies: ({ cfg, accountId }) =>
    isSlackInteractiveRepliesEnabled({ cfg, accountId }),

  hasStructuredReplyPayload: ({ payload }) => {
    try {
      return Boolean(resolveSlackReplyBlocks(payload)?.length);
    } catch {
      return false;
    }
  },
  // ... normalizeTarget / targetResolver 等
},

8.6.4 Slash Commands

Slack 支持通过 Slash Commands 触发 Agent:

// extensions/slack/src/channel.ts:450(简化)
gateway: {
  startAccount: async (ctx) => {
    ctx.log?.info(`[${ctx.account.accountId}] starting provider`);
    return getSlackRuntime().channel.slack.monitorSlackProvider({
      botToken: ctx.account.botToken?.trim() ?? "", appToken: ctx.account.appToken?.trim() ?? "",
      accountId: ctx.account.accountId, config: ctx.cfg,
      runtime: ctx.runtime, abortSignal: ctx.abortSignal,
      slashCommand: ctx.account.config.slashCommand,  // Slash Command 配置
      // ... mediaMaxMb, setStatus, getStatus
    });
  },
},

8.7 Feishu(飞书)通道实现

飞书是字节跳动推出的企业协作平台——它在国际市场叫 Lark、在中国市场叫飞书。飞书的 API 设计、相对于 Slack 更"现代"——它借鉴了 Slack 的成功经验、又加入了针对中国市场的特色(比如更深度的组织架构集成、更丰富的卡片消息类型、对审批流程的原生支持)。OpenClaw 接入飞书、反映了"开源项目对中国市场的接入"。很多中国企业用飞书而不是 Slack——如果一个开源 AI 工具不支持飞书、它就失去了在中国企业市场的入场券。这种"本土化考量"、在全球化的开源项目中越来越重要。OpenClaw 的飞书通道实现、是这种本土化努力的一个具体体现——让中国团队能够在自己熟悉的平台上、享受 OpenClaw 带来的 AI 能力。

8.7.1 设计决策:飞书 vs. Lark 的国际化困境

飞书和 Lark 是同一个产品的中国版和国际版,API 端点不同,部分功能也不同。OpenClaw 通过 domain 配置参数统一处理两者——注意取值是枚举名而不是域名:

channels:
  feishu:
    appId: "cli_xxx"
    appSecret: "xxx"
    domain: feishu          # "feishu"(默认) | "lark"(国际版) | 自定义 URL(私有化部署)

类型定义是 export type FeishuDomain = "feishu" | "lark" | (string & {})(extensions/feishu/src/types.ts),配置 schema 里 domain 默认为 "feishu"。创建 SDK 客户端时由 resolveDomain() 把它翻译成真正的端点:

// extensions/feishu/src/client.ts:58
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
  if (domain === "lark") return feishuClientSdk.Domain.Lark;
  if (domain === "feishu" || !domain) return feishuClientSdk.Domain.Feishu;
  return domain.replace(/\/+$/, ""); // 私有化部署:直接当自定义 URL 用
}

这看似简单,但背后涉及一个更深层的设计问题:如何在"通道无关"的抽象下处理同一平台的区域差异? 飞书的消息格式、卡片结构和事件类型在两个版本中几乎相同,区别集中在 API 基础 URL。OpenClaw 的处理方式是把这种差异挤到端点映射里——主体是 resolveDomain(),流式卡片模块(extensions/feishu/src/streaming-card.ts)直接走 HTTP,另有一对 resolveApiBase() / resolveAllowedHostnames() 做同样的三分支映射——顺带用"字符串即自定义 URL"的兜底分支,把私有化部署也一并接住了。

8.7.2 飞书 API 架构

飞书是字节跳动的企业协作平台,支持丰富的消息类型和交互卡片。OpenClaw 通过飞书开放 API 实现消息收发:

// extensions/feishu/src/channel.ts:100
const meta: ChannelMeta = {
  id: "feishu", label: "Feishu", selectionLabel: "Feishu/Lark (飞书)",
  docsPath: "/channels/feishu", docsLabel: "feishu",
  blurb: "飞书/Lark enterprise messaging.",
  aliases: ["lark"], order: 70,
};

export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResult> =
  createChatChannelPlugin({
    base: {
      id: "feishu",
      meta: { ...meta },
      capabilities: {
        chatTypes: ["direct", "channel"],
        polls: false,  // 飞书通道不接原生投票
        threads: true, media: true, reactions: true, edit: true, reply: true,
      },
      // ... agentPrompt / config / outbound / gateway / actions 等适配器
    },
  });

飞书是六个通道里唯一显式声明了 edit 和 reply 能力的——它的消息操作集合覆盖 send/read/edit/thread-reply/pin/list-pins/unpin/member-info 等。

8.7.3 应用凭证配置

飞书使用 App ID 和 App Secret 进行认证:

// extensions/feishu/src/accounts.ts:252-325(节选,参数类型从略)
function buildResolvedFeishuAccount(params: {
  cfg: ClawdbotConfig; accountId?: string | null;
  baseMode: FeishuCredentialResolutionMode;      // "inspect" | "strict"
  eventSecretMode: FeishuCredentialResolutionMode;
}): ResolvedFeishuAccount {
  const merged = mergeFeishuAccountConfig(params.cfg, accountId);   // 账户配置覆盖 channel 级
  const baseCreds = resolveFeishuBaseCredentials(merged, params.baseMode);
  const eventSecrets = resolveFeishuEventSecrets(merged, params.eventSecretMode);
  return {
    accountId, selectionSource, enabled,
    configured: Boolean(baseCreds),
    appId: baseCreds?.appId,
    appSecret: baseCreds?.appSecret,
    encryptKey: eventSecrets.encryptKey,
    verificationToken: eventSecrets.verificationToken,
    domain: baseCreds?.domain ?? "feishu",
    config: merged,
  };
}

// 两个对外入口只差在"凭证解析模式"上
export function resolveFeishuAccount(params) {                   // CLI/配置面板:解析不了就当没有
  return buildResolvedFeishuAccount({ ...params, baseMode: "inspect", eventSecretMode: "inspect" });
}
export function resolveFeishuRuntimeAccount(params, options?) {  // 运行时:凭证必须真的能拿到
  return buildResolvedFeishuAccount({
    ...params, baseMode: "strict",
    eventSecretMode: options?.requireEventSecrets ? "strict" : "inspect",
  });
}

这对 inspect / strict 的分野很值得学:同一份解析逻辑,读配置时"缺就缺着、别抛异常",跑运行时"缺了就必须炸"——用一个模式参数区分,而不是写两份代码。

8.7.4 卡片消息

飞书支持交互式卡片消息,OpenClaw 可以发送结构化的卡片数据。send / thread-reply 动作按"卡片 → 媒体 → 纯文本"的优先级选择发送方式(卡片不能与媒体同发):

// extensions/feishu/src/channel.ts:617 — 卡片/文本消息发送(简化:省略了参数校验、thread-reply 的 messageId 与媒体分支)
actions: {
  handleAction: async (ctx) => {
    if (ctx.action === "send" || ctx.action === "thread-reply") {
      const to = resolveFeishuActionTarget(ctx);
      const card = ctx.params.card as Record<string, unknown> | undefined;
      const text = readFirstString(ctx.params, ["text", "message"]);
      const runtime = await loadFeishuChannelRuntime();

      // 有卡片发卡片(媒体分支从略),否则发送纯文本
      const result = card
        ? await runtime.sendCardFeishu({ cfg: ctx.cfg, to, card, replyInThread: ctx.action === "thread-reply" })
        : await runtime.sendMessageFeishu({ cfg: ctx.cfg, to, text: text!, replyInThread: ctx.action === "thread-reply" });
      return jsonActionResult({ ok: true, channel: "feishu", ...result });
    }
    // ... 其他 action 类型
  },
},

8.7.5 会话 ID 解析

飞书群聊的会话 ID 以群的 chat_id(oc_xxx)为底,按会话粒度追加后缀:<chatId>:topic:<topicId>(话题)、<chatId>:sender:<openId>(按发送者隔离),或两者叠加。私聊对象则是用户的 open_id(ou_xxx,也接受 on_ 前缀),由同一文件里的 parseFeishuDirectConversationId() 单独识别:

// extensions/feishu/src/conversation-id.ts:87(节选:返回值里的 canonicalConversationId 从略)
export function parseFeishuConversationId(params: {
  conversationId: string;
  parentConversationId?: string;
}) {
  const conversationId = normalizeText(params.conversationId);
  const parentConversationId = normalizeText(params.parentConversationId);
  if (!conversationId) return null;

  // <chatId>:topic:<topicId>:sender:<openId>
  const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/);
  if (topicSenderMatch) {
    const [, chatId, topicId, senderOpenId] = topicSenderMatch;
    return { chatId, topicId, senderOpenId, scope: "group_topic_sender" };
  }
  // <chatId>:topic:<topicId>
  const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/);
  if (topicMatch) {
    const [, chatId, topicId] = topicMatch;
    return { chatId, topicId, scope: "group_topic" };
  }
  // <chatId>:sender:<openId>
  const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/);
  if (senderMatch) {
    const [, chatId, senderOpenId] = senderMatch;
    return { chatId, senderOpenId, scope: "group_sender" };
  }
  // 只给了话题 ID、群 ID 另行传入
  if (parentConversationId) {
    return { chatId: parentConversationId, topicId: conversationId, scope: "group_topic" };
  }
  return { chatId: conversationId, scope: "group" };
}

8.7.6 Webhook 与 WebSocket

飞书支持两种事件接收方式:Webhook 和 WebSocket。OpenClaw 根据配置选择:

// extensions/feishu/src/channel.ts:1134
gateway: {
  startAccount: async (ctx) => {
    const { monitorFeishuProvider } = await import("./monitor.js");
    // 运行时入口:事件密钥必须真的解析得出来
    const account = resolveFeishuRuntimeAccount(
      { cfg: ctx.cfg, accountId: ctx.accountId },
      { requireEventSecrets: true },
    );
    const port = account.config?.webhookPort ?? null;

    ctx.setStatus({ accountId: ctx.accountId, port });
    ctx.log?.info(
      `starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`
    );

    return monitorFeishuProvider({
      config: ctx.cfg,
      runtime: ctx.runtime,
      abortSignal: ctx.abortSignal,
      accountId: ctx.accountId,
    });
  },
},
graph TB
    subgraph FeishuCloud["飞书云端"]
        EVENT[事件订阅]
        API[开放 API]
    end
    subgraph OpenClaw
        WS[WebSocket 连接]
        WH[Webhook 接收]
        CLIENT[Feishu Client]
    end
    EVENT --> |WebSocket 模式| WS
    EVENT --> |Webhook 模式| WH
    CLIENT --> |发送消息| API
    CLIENT --> |获取用户信息| API
    CLIENT --> |管理群组| API
    style FeishuCloud fill:#3370FF,color:white
    style OpenClaw fill:#e8f5e9

图 8-5:飞书通道架构

8.8 通道实现对比

横向对比六个通道的实现、能让我们看到一些"在单个通道里看不到"的深层规律。比如——每个通道的"认证机制"几乎都不一样:Telegram 用 bot token(简单粗暴)、Discord 用 Bot Token + Gateway Intents(权限细粒度)、WhatsApp 用 QR 扫码(用户设备绑定)、Signal 用电话号码 + signal-cli daemon(设备本地认证)、Slack 用 Bot Token + Socket Mode Token(双 token)、Feishu 用 App ID + App Secret(企业应用凭证换取访问令牌)。这些差异、反映了每个平台对"安全 vs 便利"取舍的不同选择。做对比时、不要简单说"谁好谁坏"——每种机制都在服务于其平台的核心用户场景。Telegram 追求极致简单、所以用最轻的 token;Discord 追求社区治理、所以强制细粒度权限;WhatsApp 追求用户归属明确、所以绑定设备;Signal 追求隐私、所以账号密钥只保存在本地(由 signal-cli 持有)。读这种对比、你学到的不只是 API 知识、更是"不同设计哲学下同一问题的不同答案"——这对你未来做 API 设计时、有直接的参考价值。

特性 Telegram Discord WhatsApp Signal Slack Feishu
认证方式 Bot Token Bot Token QR 码扫码 signal-cli daemon Bot Token + App Token(可选 User Token) App ID + App Secret
底层库(pin 版本) grammY ^1.41.1 @buape/carbon + discord-api-types Baileys 7.0.0-rc.9 外部 signal-cli(无 SDK 依赖) @slack/bolt ^4.6.0 @larksuiteoapi/node-sdk ^1.60.0
消息接收 Webhook / Polling WebSocket Gateway Web 客户端 JSON-RPC Socket Mode / HTTP WebSocket / Webhook
默认分块上限 4000(markdown,硬上限 4096) 2000(chunker: null) 4000(text) 4000(text) 8000(chunker: null) 4000(markdown)
chatTypes direct/group/channel/thread direct/channel/thread direct/group direct/group direct/channel/thread direct/channel
交互组件 Inline Keyboard Components V2 无 无 Block Kit Cards
polls ✅ ✅ ✅ — — ❌(显式 false)
reactions ✅ ✅ ✅ ✅ ✅ ✅
threads ✅ ✅ — — ✅ ✅
media ✅ ✅ ✅ ✅ ✅ ✅
nativeCommands ✅ ✅ — — ✅ —
edit / reply — — — — — ✅ / ✅
blockStreaming ✅ — — — — —

表 8-1:通道实现特性对比

表 8-1 的口径:能力行直接取自各通道 capabilities 声明块(Telegram/Discord/WhatsApp/Signal/Slack 在 extensions/<id>/src/shared.ts,Feishu 在 extensions/feishu/src/channel.ts),"—" 表示该字段未声明(等价于 falsy),"❌" 表示显式写了 false。分块上限取各自 outbound 适配器里的 textChunkLimit 默认值(用户可用 channels.<id>.textChunkLimit 覆盖);chunker: null 表示核心不预先分块,由通道自己的发送函数切分(如 extensions/discord/src/chunk.ts、extensions/slack/src/send.ts)。底层库一行取自各 extensions/<id>/package.json 的 dependencies。要强调的是:capabilities 是给核心引擎看的能力声明,不等同于"平台本身能不能做"——比如 Discord/Slack 平台都支持编辑消息,但它们没在 capabilities.edit 里声明,而是通过 actions 适配器暴露。

8.9 设计模式分析

"设计模式"在 1994 年 GoF 的《Design Patterns》提出时、是面向对象时代的产物——23 个模式大多基于类继承和多态。但在现代 JavaScript/TypeScript 生态里、很多经典模式的实现方式已经发生了根本变化——用函数、闭包、对象字面量代替类;用组合代替继承;用泛型代替模板方法。OpenClaw 的通道实现里、你能看到这种"现代化的设计模式"——适配器模式不再用继承实现、而是用"工厂函数 + 接口对象"实现;依赖注入不再用容器实现、而是用"显式函数参数"实现;策略模式不再用接口实现、而是用"高阶函数"实现。这些现代化的实现、代码更少、可读性更好、测试更容易——它们是 JavaScript 生态积累多年的"语言特性红利"。读 OpenClaw 的通道代码时、留意这种"老模式新写法"的技巧——它们会让你的代码风格更现代、更符合 TypeScript 生态的最佳实践。

8.9.1 适配器模式的统一应用

所有通道都采用适配器模式,根据平台能力实现不同的适配器组合:

  • 必需适配器:config(配置解析)
  • 常用适配器:outbound(出站消息)、gateway(生命周期)、status(状态查询)
  • 可选适配器:pairing(配对)、actions(消息操作)、heartbeat(心跳)、execApprovals(命令审批)

8.9.2 运行时注入

各通道通过自己的 getXxxRuntime()(如 getTelegramRuntime(),由 createPluginRuntimeStore 生成)取得宿主注入的运行时,重模块则经 createLazyRuntimeNamedExport() 按需动态加载。这带来了:

  1. 延迟加载:重模块只在需要时加载
  2. 测试隔离:测试时可以注入 mock runtime
  3. 循环依赖避免:模块加载顺序不影响运行
// 示例:延迟加载模式
const loadFeishuChannelRuntime = createLazyRuntimeNamedExport(
  () => import("./channel.runtime.js"),
  "feishuChannelRuntime",
);

8.9.3 共享工具函数

多个通道共享的工具函数被提取到核心里——一部分在对外的 plugin-sdk,一部分在内部的通道/出站模块:

  • createScopedDmSecurityResolver(src/plugin-sdk/channel-config-helpers.ts):DM 安全策略解析器
  • createAttachedChannelResultAdapter(src/plugin-sdk/channel-send-result.ts):出站结果适配器
  • createChatChannelPlugin(src/plugin-sdk/core.ts):聊天通道插件组装工厂
  • createTextPairingAdapter(src/channels/plugins/pairing-adapters.ts):文本配对适配器工厂
  • buildOutboundBaseSessionKey(src/infra/outbound/base-session-key.ts):出站会话键构建器
  • createLazyRuntimeNamedExport(src/shared/lazy-runtime.ts):懒加载运行时导出

8.10 测试策略

"测试通道代码"是一门独特的手艺——因为通道代码的"正确性"往往取决于"外部平台是否按文档行事"。你不能信任文档(平台常常改接口没更新文档)、不能信任测试环境(测试账号的行为可能和正式账号不同)、不能信任单次测试(因为限流、网络波动、服务端边缘情况都可能导致测试不稳定)。在这种"处处不可信"的环境下、测试策略要有层次——最内层用单元测试(只测纯逻辑、不碰外部 API)、中间层用 mock 测试(模拟外部 API 的响应,覆盖各种边界情况)、最外层用集成测试(在真实环境跑一些关键流程、但次数要少)。这种金字塔式的测试分层、在任何集成外部服务的代码里都是黄金法则——因为真实世界比代码更混乱、你需要用多层测试来应对这种混乱。

8.10.1 单元测试

每个通道都有独立的单元测试,测试配置解析、目标格式化、会话键构建等纯逻辑:

extensions/telegram/src/*.test.ts
extensions/discord/src/*.test.ts
extensions/whatsapp/src/*.test.ts

8.10.2 运行时侧车模块

*.runtime.ts 并不是测试文件,而是运行时侧车——把"重"的依赖(平台 SDK、监控循环、登录流程)隔离到单独模块,由 createLazyRuntimeNamedExport() 按需动态 import,避免插件一被枚举就把整条依赖链拉进内存:

extensions/telegram/src/bot.runtime.ts
extensions/telegram/src/update-offset-store.runtime.ts
extensions/discord/src/channel.runtime.ts
extensions/feishu/src/channel.runtime.ts
src/channels/read-only-account-inspect.discord.runtime.ts

真正带 *.runtime.test.ts 后缀的运行时测试全仓库只有寥寥几个(如 extensions/matrix/src/plugin-entry.runtime.test.ts),验证的是插件入口在真实模块加载路径下的行为,而不是去调真实平台 API。

8.10.3 集成测试

src/channels/plugins/contracts/ 目录下有通道合约的一致性测试,确保所有通道遵循相同的接口规范。

8.11 本章小结:六个世界,一个接口

抽象层的优雅(上一章的 ChannelPlugin 接口)与实现层的凌乱(本章各平台的奇特约束)并非矛盾,而是互相成就——图纸视角让系统保持方向,工地视角让系统真正落地。

每个平台都带着自己的文化包袱:Telegram 偏技术极客品味、Discord 带着"服务器/角色/频道"概念、WhatsApp 留着 Facebook 移动时代的印记、Signal 是隐私极端主义代表、Slack 讲究企业流程和集成、Feishu 反映中国互联网的"一站式工作平台"野心。理解这些差异,才能写出真正健壮的适配代码。

六大通道,六种截然不同的工程挑战:

  • Telegram 是"模范生"——API 设计干净,功能丰富,是 OpenClaw 支持最完善的通道
  • Discord 是"复杂的巨人"——Application/Bot 注册链路、Gateway intents、slash commands,每一个特性都带着自己的陷阱
  • WhatsApp 是"叛逆者"——没有官方 bot API,你需要用 Web 客户端协议,扫码登录,还要处理认证状态过期
  • Signal 是"隐士"——依赖外部 daemon 进程,通信走 JSON-RPC,消息格式独特
  • Slack 是"企业老兵"——Socket Mode 和 HTTP Mode 两种模式,Block Kit 的结构化消息
  • Feishu 是"后起之秀"——飞书开放 API 设计现代,但事件订阅和卡片消息有独特的学习曲线

通道实现的铁律:永远不要假设两个平台的行为一样。 API 表面相似(都有"发送消息"端点),语义暗流截然不同——你的代码会在最意想不到的地方崩溃。正如"打招呼"在每种文化中都存在,但鞠躬的角度、握手的力度、拥抱的时机完全不同。

六个平台,六种截然不同的工程挑战。收编到同一个 ChannelPlugin 接口之后,Agent 运行时对它们的差异浑然不觉。每个通道实现中隐藏的复杂性,正是这层抽象替你消化掉的"成本"——通道无关不是免费的午餐,而是有人替你买了单。

下一章,我们退后一步,看 OpenClaw 如何发现、加载、注册和管理这些通道插件——插件与扩展系统。

本章的几个关键工程教训:

  1. API 文档写的和实际行为经常不一致——永远要实测、永远要准备兜底。
  2. 开发环境跑得好不代表生产环境跑得好——生产环境的网络、限流、版本差异会让"完美代码"露出马脚。
  3. 每个平台都有潜规则(各平台对 bot 发信频率的隐性限流、Discord 对 privileged intents 的审核门槛、WhatsApp 对非官方 Web 客户端的态度),官方文档往往语焉不详,只能从社区经验里积累——OpenClaw 的应对是把这些经验固化成代码里的常量与降级分支(比如 Telegram 的 4096 夹取、Discord 的 intent 诊断警告)。

这些经验在任何跨平台工程中都适用——iOS/Android、微信/支付宝、AWS/Azure/GCP。与《Claude Code 源码深度解析》专栏第 17 章(终端 UI)、《微前端源码精讲》专栏第 13 章(Wujie iframe 驯服)一起看,本质都是"在不太友好的既有 API 之上构建友好的上层体验"。

思考题

  1. 概念理解:Telegram、Discord 和 WhatsApp 三个平台在消息模型上有哪些根本差异?这些差异如何影响通道实现的复杂度?
  2. 实践应用:WhatsApp 的端到端加密对消息处理流程施加了哪些约束?如果要在加密通道中实现消息搜索功能,你会面临什么架构挑战?
  3. 开放讨论:不同平台对 Bot 的速率限制差异巨大(Telegram Bot API 公布了明确的频率限制,WhatsApp 这类非官方 Web 客户端则没有公开额度,只能凭经验避开风控)。这种差异应该在通道层还是在更高层(如 Agent 层)处理?为什么?

📚 推荐阅读