Day 14 / 共 20 天 · 第 3 周 技能与渠道

渠道实现举例

昨天(Day 13)讲了渠道的抽象契约 ChannelPlugin;今天把它落地——拆三个真实渠道:Telegram(长轮询)、Slack(Socket Mode)、Discord(Gateway WebSocket)。三种截然不同的连接技术,如何统一到 Day 13 的同一契约。明天(Day 15)接着讲它们收到的消息怎么归一成同一个 MsgContext。

📍 你在整门课的位置(第 3 周 · 技能与渠道)
W2 大脑· Day11 技能 Day12 扩展 Day13 渠道抽象 Day14 渠道实现 Day15 归一化· W4 安全/部署
💡 用一个类比先兜住今天("三种邮差,同一个收件标准"世界观) 今天三个渠道像三种取信方式:长轮询=你隔一会儿就跑去邮局问"有我的信吗";webhook=邮差有信就上门送(你得有固定门牌=公网地址);Socket/WebSocket=你和邮局架了条专线电话,有信立刻通知。三种取信方式天差地别,但拆开信封后都按同一张"通用收件单"(MsgContext)登记——这就是 Day 13 抽象的威力落到实处。
L01

三种连接方式

渠道连接技术收消息发消息
Telegram长轮询(grammY runner)/ webhookmessage_idREST,direct
SlackSocket Mode(Bolt,WebSocket)tsBolt client
DiscordGateway WebSocket(carbon)message idREST + 富组件
🤔 痛点:三个平台连接技术天差地别,难道每加一个上层就要跟着改一遍? Telegram 要反复轮询、Slack 要维持 WebSocket 长连、Discord 还得先声明 intents……如果 Gateway 和大脑要认得这些差异,代码会被撑成一团乱麻,而且每加一个平台就动一次核心。
💡 本质:三种连接机制都落到同一个 startAccount + 归一成同一 MsgContext 延续 Day 13 的插座标准:不管底层是轮询/Socket/WebSocket,每个渠道都把"连接并开始收消息"塞进 gateway.startAccount,把"发送"塞进 outbound.sendPayload,把"收到的原生消息"翻译成统一的 MsgContext上层只跟这三个统一入口打交道,永远不用管底层是哪种邮差。
读法:三种平台连接机制完全不同,但都实现 Day 13 的 ChannelGatewayAdapter.startAccount(连接)+ ChannelOutboundAdapter.sendPayload(发送),并都把收到的消息归一成同一个 MsgContext(Day 15)。这就是抽象的威力:上层完全不用管底层是轮询还是 WebSocket。
三种连接,归一成同一张"收件单" Telegram长轮询/webhook · id=message_id SlackSocket Mode · id=ts DiscordGateway WS+intents · id=message id startAccount+ finalizeInboundContext MsgContext(统一)MessageSid / From / ToOriginatingChannel
图注:各平台的原生消息 id(message_id / ts / message id)在归一化时统统写进 MsgContext 的 MessageSid——上层只认统一字段。
📝 举个例子:三个平台的"消息 id"归一成同一字段 Telegram 来的消息 msg.message_id = 42MessageSid: "42"
Slack 来的 message.ts = "1720000000.001"MessageSid: "1720000000.001"
Discord 来的 message id → MessageSid
三者原生叫法完全不同,归一后上层一律读 ctx.MessageSid,配合 OriginatingChannel 记住"从哪来",回复时原路送回(Day 15)。
L02

Telegram 注册

// extensions/telegram/index.ts:11
register(api) {
  setTelegramRuntime(api.runtime);
  api.registerChannel({ plugin: telegramPlugin as ChannelPlugin });
}

插件定义 extensions/telegram/src/channel.ts:120telegramPlugin:声明能力(:144 reactions/threads/media/polls/nativeCommands),接上发送/连接实现。

读法:Day 12 讲的扩展加载 + Day 13 讲的渠道契约在这里合流:Telegram 是一个扩展,其 register 调 api.registerChannel 把 telegramPlugin(一个 ChannelPlugin 对象)注册进注册表。之后 Gateway 就能 getChannelPlugin("telegram") 拿到它。
L03

Telegram 连接(收消息入口)

// channel.ts:486 —— gateway.startAccount 实现
// 1. probeTelegram 拿 bot 名(验证 token 有效)
// 2. monitorTelegramProvider({ token, abortSignal, useWebhook, ... })

// src/telegram/monitor.ts:77 —— monitorTelegramProvider
// 用 grammY runner 长轮询;createTelegramRunnerOptions(:37) 配并发/重试
// allowed_updates 由 resolveTelegramAllowedUpdates() 决定(含 reactions)
// webhook 模式走 startTelegramWebhook
"长轮询"是什么? Telegram bot 收消息有两种方式:长轮询——bot 主动反复问 Telegram"有我的新消息吗?"(有就返回,没有就挂着等一会儿);webhook——你给 Telegram 一个网址,有新消息它主动 POST 给你。长轮询简单(不需要公网地址),适合个人自部署;webhook 高效,适合有公网服务器的场景。OpenClaw 两种都支持,startAccount 里按配置选。abortSignal(Day 13)让 Gateway 能随时喊停这个轮询循环。
L04

Telegram 收消息 → 归一化

src/telegram/bot-message-context.session.ts:186 把 Telegram 原生 msg 转成统一 MsgContext

const ctxPayload = finalizeInboundContext({
  Body: combinedBody, BodyForAgent: bodyText,
  From: isGroup ? buildTelegramGroupFrom(chatId, threadId) : `telegram:${chatId}`,
  To: `telegram:${chatId}`,
  ChatType: isGroup ? "group" : "direct",
  SenderName, SenderId, SenderUsername,
  Provider: "telegram", Surface: "telegram",
  MessageSid: String(msg.message_id),          // ← Telegram 用 message_id
  Timestamp: msg.date ? msg.date * 1000 : undefined,  // 秒→毫秒
  OriginatingChannel: "telegram",              // ★ 回复路由用(Day 15)
  OriginatingTo: `telegram:${chatId}`,
});
读法:把 Telegram 特有的字段(message_id、chat、date)映射成 OpenClaw 通用字段(MessageSid、From/To、Timestamp)。OriginatingChannel 记住"这消息从 telegram 来",回复时原路送回。finalizeInboundContext(Day 15)统一收尾清洗。
L05

Slack Socket Mode

// src/slack/monitor/provider.ts:97 —— monitorSlackProvider
// :196 用 Bolt:new App({ token: botToken, appToken, socketMode: true })
// :239 app.client.auth.test() 拿 botUserId/teamId
// :416-474 断线重连(指数退避 computeBackoff)

// 收消息归一 src/slack/monitor/message-handler/prepare.ts:684
finalizeInboundContext({
  Provider: "slack", Surface: "slack",
  MessageSid: message.ts,                       // ← Slack 用 ts(时间戳)作消息 id
  MessageThreadId: threadContext.messageThreadId,   // 线程 thread_ts
  NativeChannelId: message.channel,             // 原生 channel id(C…/D…)
  OriginatingChannel: "slack",
});
读法:对照 Telegram:Slack 用 WebSocket(Socket Mode,不用公网)、用 ts 而非 message_id 作消息标识、有原生线程(thread_ts)。但归一化后都进同一个 MsgContext 的 MessageSid/MessageThreadId断线重连是长连接渠道的必备(Telegram 长轮询、Slack/Discord WebSocket 都需要)。
L06

Discord Gateway

// 连接:@buape/carbon/gateway 的 GatewayPlugin(WebSocket)
// src/discord/monitor/gateway-plugin.ts:22 resolveDiscordGatewayIntents
//   默认 Guilds|GuildMessages|MessageContent|DirectMessages|VoiceStates
//   按配置追加 GuildPresences/GuildMembers
// 发送走 REST:src/discord/client.ts:73 createDiscordClient
//   send.outbound.ts / send.components.ts(Components V2 富组件卡片)
"Intents(意图)"是什么? Discord 出于隐私/性能,要求 bot 显式声明"我要接收哪几类事件"(叫 intents)——比如"我要收服务器消息、私信、语音状态"。不声明就收不到。这是 Discord 特有的机制,Telegram/Slack 没有。OpenClaw 在 resolveDiscordGatewayIntents 里按需组合这些 intents。Discord 还是唯一用"富组件卡片"(Components V2)做跨上下文转发的渠道——其他渠道退化为纯文本(Day 15 路由会讲)。看,三个渠道各有独门机制(Telegram 轮询/webhook、Slack Socket、Discord intents+富组件),但对上层都是"一个 ChannelPlugin"。

👶 小白:intents 不声明会怎样?漏声明一个会不会报错崩掉?

👨‍🏫 老师:不会崩,但会更难查——它会静默地收不到那类事件。比如你忘了声明 MessageContent intent,bot 能连上、能看到"有人发了消息"这个事件,却拿到空的消息正文,于是助理像"听得见有人说话但听不清内容"。这种"不报错、只是收不到"的坑最坑人。所以 resolveDiscordGatewayIntents 会按配置需要的功能把该开的 intents 都组合齐——这也是 Discord 独有、Telegram/Slack 没有的一道"订阅声明"关卡。

L07

Gateway 拉起渠道

谁来调 startAccount?Gateway 的 startChannelInternalsrc/gateway/server-channels.ts:149):

// 1. getChannelPlugin(channelId) 取插件,读 plugin.gateway.startAccount
// 2. plugin.config.listAccountIds(cfg) 枚举多账号
// 3. 每账号检查 isEnabled / isConfigured
// 4. 建 AbortController,写运行态 running:true
// 5. startAccount({ cfg, account, runtime, abortSignal, setStatus, channelRuntime })  ← 拉起!
// 6. .finally 回写 running:false(配合外层重连自动重启)
startChannelInternal 此刻做什么关键对象/状态
1按 id 取出渠道插件,读它的连接 adaptergetChannelPlugin("telegram").gateway.startAccount
2枚举这个渠道配置了几个账号(支持多 bot)config.listAccountIds(cfg)
3逐账号检查是否启用/配置齐全isEnabled / isConfigured
4建停止开关,标记运行态AbortControllerrunning:true
5真正拉起连接,把上下文交给渠道startAccount({ abortSignal, setStatus, channelRuntime, ... })
6连接结束时回写状态(配合外层自动重连).finally → running:false
读法:Gateway 是"总调度":按注册表逐个拉起每个已配置渠道账号的连接,用 abortSignal 统一管停、setStatus 收集状态。这就是 README 说的"The Gateway is just the control plane"(网关只是控制面)——它不处理消息内容,只管理渠道连接的生命周期。多账号支持(一个渠道多个 bot)也在这里。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • Telegram/Slack/Discord 三种连接方式各是什么?
  • 长轮询 vs webhook 的区别?
  • 三个渠道的消息 id 各叫什么?归一化后进哪个字段?
  • Discord 的 intents 是干什么的?
  • Gateway 的 startChannelInternal 六步在做什么?

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
sed -n '480,500p' extensions/telegram/src/channel.ts   # startAccount
sed -n '190,200p' src/slack/monitor/provider.ts         # Bolt App
sed -n '149,250p' src/gateway/server-channels.ts | head -50  # 拉起渠道
明天预告 · Day 15(第3周收官)消息归一化 + 语音 + Canvas——统一消息类型 MsgContext(~170 字段)、finalizeInboundContext 收口清洗、回复路由怎么原路送回、语音 TTS、Canvas 实时可视化。
← Day 13 渠道抽象 Day 15 · 归一化+语音+Canvas →