OpenClaw 设计与实现
第8章 通道实现深度剖析
第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)选择了这条路。但它有三个致命问题:
- 合规风险高。Telegram API 使用条款禁止"在用户不知情、未同意的情况下代其操作"(第 1.4 条),用个人账号跑自动化很容易被判定为滥用。账号被封禁的风险实实在在——你的 Agent 可能在运行三个月后突然失去所有连接。
- 需要手机号。每个 User API 会话需要一个手机号登录,且需要接收验证码。对于想运行多个 Agent 的运营者,这意味着多个手机号——管理成本高,且手机号是个人身份信息。
- 安全边界模糊。以"用户"身份操作意味着 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_CONTENTintent。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 客户端协议实现消息收发。这需要:
- QR 码登录:首次使用需扫描 QR 码
- 持久化认证:会话状态存储在
authDir目录 - 被动监听:作为"普通用户"接收消息
// 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 框架三个选择:
- 直接实现 Signal Protocol。从头实现 Double Ratchet、X3DH 密钥交换、密封消息等密码学原语。工作量巨大(数月开发),且需要持续跟进协议变更。
- 使用 libsignal 库。Signal 的官方协议库,核心用 Rust 编写、对外提供 Java/Swift/TypeScript 封装,但官方明确声明不支持 Signal 之外的使用(libsignal README:"Use outside of Signal is unsupported")。
- 委托给 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 | 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() 按需动态加载。这带来了:
- 延迟加载:重模块只在需要时加载
- 测试隔离:测试时可以注入 mock runtime
- 循环依赖避免:模块加载顺序不影响运行
// 示例:延迟加载模式
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 如何发现、加载、注册和管理这些通道插件——插件与扩展系统。
本章的几个关键工程教训:
- API 文档写的和实际行为经常不一致——永远要实测、永远要准备兜底。
- 开发环境跑得好不代表生产环境跑得好——生产环境的网络、限流、版本差异会让"完美代码"露出马脚。
- 每个平台都有潜规则(各平台对 bot 发信频率的隐性限流、Discord 对 privileged intents 的审核门槛、WhatsApp 对非官方 Web 客户端的态度),官方文档往往语焉不详,只能从社区经验里积累——OpenClaw 的应对是把这些经验固化成代码里的常量与降级分支(比如 Telegram 的 4096 夹取、Discord 的 intent 诊断警告)。
这些经验在任何跨平台工程中都适用——iOS/Android、微信/支付宝、AWS/Azure/GCP。与《Claude Code 源码深度解析》专栏第 17 章(终端 UI)、《微前端源码精讲》专栏第 13 章(Wujie iframe 驯服)一起看,本质都是"在不太友好的既有 API 之上构建友好的上层体验"。
思考题
- 概念理解:Telegram、Discord 和 WhatsApp 三个平台在消息模型上有哪些根本差异?这些差异如何影响通道实现的复杂度?
- 实践应用:WhatsApp 的端到端加密对消息处理流程施加了哪些约束?如果要在加密通道中实现消息搜索功能,你会面临什么架构挑战?
- 开放讨论:不同平台对 Bot 的速率限制差异巨大(Telegram Bot API 公布了明确的频率限制,WhatsApp 这类非官方 Web 客户端则没有公开额度,只能凭经验避开风控)。这种差异应该在通道层还是在更高层(如 Agent 层)处理?为什么?
📚 推荐阅读
- Discord 开发者文档 — Discord API 与 Gateway 协议的完整文档
- WhatsApp Business Platform (Meta) — WhatsApp 商业 API 的设计约束与能力
- Signal Protocol 技术文档 — 理解端到端加密对通道实现的深层影响