Day 15 / 共 20 天 · 第 3 周收官

消息归一化 + 语音 + Canvas

昨天(Day 14)看了 Telegram/Slack/Discord 三种渠道各自怎么连;今天要解决一个必然冒出的问题:它们收进来的消息格式天差地别,上层的大脑/路由/命令逻辑怎么可能挨个去认识二十多种格式?答案是先归一成同一种内部消息——统一类型 MsgContext、收口函数 finalizeInboundContext、回复路由,外加语音 TTS 和 Canvas 可视化。学完这天,第 3 周(技能与渠道)收官,明天起进第 4 周"筋骨"(沙箱/安全/部署)。

📍 你在整门课的位置(第 3 周收官 · 技能与渠道)
D11 技能 D12 扩展 D13 渠道抽象 D14 渠道实现 D15 归一化+语音+Canvas· W4 沙箱/安全/部署
🤔 痛点:没有"统一消息"会有多痛? 假设没有 MsgContext。那大脑要读正文,就得写 if telegram: msg.text; elif slack: msg.blocks[0].text; elif discord: msg.content …——每加一个新平台,大脑、路由、命令解析、记忆全都要改一遍。二十多个平台 × 十几处用到消息的地方 = 几百个分支,改一处漏一处就是 bug。今天的归一化就是来终结这种噩梦的。
💡 用一个类比兜住整天(「联合国同声传译」世界观) 今天全程只有一个画面:联合国大会。各国代表说各国语言(= Telegram/Slack/Discord 的原生消息,字段名/结构全不同);同声传译把它们统一译成一种官方语言(= MsgContext),会议记录、决议、发言只认这一种语言(= 大脑/路由/命令只认 MsgContext)。传译分两道工序:各语种译员先各自翻自己那门语言(= 各渠道做"平台格式→字段"映射),再由总编辑统一校对格式、删掉夹带的私货(= finalizeInboundContext 统一清洗+安全默认)。答复时按发言人席位牌原路传回他的耳机(= 按 OriginatingChannel 送回原渠道)。TTS 就是把文字稿配音念出来,Canvas 就是会场那块大投影屏。记住这一个会场,今天全通。
L01

MsgContext 统一类型

MsgContextsrc/auto-reply/templating.ts:14)是全系统统一的入站消息类型(~170 字段)。核心分组:

  • 正文Body / BodyForAgent(喂 LLM)/ RawBody / BodyForCommands——区分"给模型的"和"用于命令解析的"。
  • 路由From / To / SessionKey / AccountId
  • 标识MessageSidReplyToIdMessageThreadIdNativeChannelId
  • 发送者SenderName/SenderId/SenderUsername
  • 来源Provider / Surface / OriginatingChannel / OriginatingTo
  • 媒体MediaPath(s)/MediaType(s)TranscriptMediaUnderstanding
MsgContext = "万能翻译后的标准消息" Telegram、Slack、Discord 的消息格式天差地别(字段名、id 类型、结构都不同)。MsgContext 是它们翻译后的"通用语"——不管消息从哪来,翻译成 MsgContext 后,上层的 Agent、路由、命令逻辑就只需要认识这一种格式。好比联合国的同声传译:各国代表说各国语言(平台原生消息),但会议记录统一成一种官方语言(MsgContext)。~170 个字段覆盖了正文、发送者、媒体、线程、路由等一切可能——足够表达任何平台的任何消息。
📝 举个例子:两个平台的"同一句话"被译成同一种 MsgContext Telegram 传来 { message_id: 42, text: "北京天气?", from: { id: 7, first_name: "Ada" }, chat: { id: -100 } }
Slack 传来 { ts: "171...", blocks: [...], user: "U07", channel: "C09" }
两个结构完全不同,但归一后都变成同一形状:MessageSid="42"/"171..."BodyForAgent="北京天气?"SenderName="Ada"OriginatingChannel="telegram"/"slack"。→ 大脑只读 BodyForAgent 一个字段,压根不用知道消息来自哪个平台。
L02

finalizeInboundContext 收口

// src/auto-reply/reply/inbound-context.ts:37
export function finalizeInboundContext<T>(ctx: T, opts = {}): T & FinalizedMsgContext { ... }

所有渠道(Day 14 见过 Telegram/Slack 都调它)构造好各自字段后,都调这一个函数收口。它统一做:

  • 换行归一化(normalizeInboundTextNewlines)。
  • 剥离注入的系统标签(sanitizeInboundSystemTags,安全防注入)。
  • ChatType 归一、BodyForAgent/BodyForCommands 回退链推导。
  • UntrustedContext 净化、强制 CommandAuthorized 默认 false(默认拒绝)。
读法:各渠道负责"平台格式→MsgContext 字段"的映射,finalizeInboundContext 负责"统一清洗 + 安全默认"。把清洗集中到一个函数,保证每条入站消息都经过同样的安全处理——不会因为某个渠道忘了清洗而留漏洞。
L03

两段式归一

为什么分两段(渠道映射 + 统一收口)? 如果让每个渠道各自完整归一化(包括清洗、安全默认),会有两个问题:①二十多个渠道会重复写同样的清洗代码;②某个渠道漏写一步清洗就是安全漏洞。两段式设计:渠道只做自己独有的部分(把 Telegram 的 message_id 映射到 MessageSid),通用的清洗+安全默认全交给 finalizeInboundContext 统一做一遍。这样安全策略只写一处、改一处生效所有渠道,也杜绝了"某渠道忘了清洗"。"公共逻辑收口到一个函数"是消除重复、堵住漏洞的经典手法。
两段式归一:各渠道各译各的 → 收口函数统一校对 Telegram 原生 Slack 原生 Discord 原生 ① 渠道映射各渠道自己写格式→MsgContext 字段 ② finalizeInbound换行归一·剥系统标签安全默认(拒绝)·收口一处改一处→全渠道生效 统一MsgContext 安全清洗只写一处:新增渠道不可能"忘了清洗"——因为它没得选,必须过收口函数
图注:渠道只做"自己独有的翻译",通用清洗+安全默认全部收口到 finalizeInboundContext 一处。

👶 小白:既然都要归一,为什么不干脆让每个渠道一步到位、连清洗也自己做完?

👨‍🏫 老师:那就等于让每个译员既翻译又自己校对格式。二十多个渠道 = 二十多份重复的清洗代码;更要命的是——只要有一个渠道的作者忘了"剥系统标签"这一步,那个渠道就成了提示注入的后门。收口成一个函数后,清洗是"必经关卡",谁都绕不过、也漏不掉,安全策略改一处即全渠道生效。这就是"公共逻辑上提、安全默认集中"的价值。

L04

回复路由(原路送回)

回复怎么知道该发回哪个渠道?靠入站时写入的 OriginatingChannel/OriginatingTo(Day 14 各渠道都设了):

// 出站时从 sessionCtx.OriginatingChannel/OriginatingTo 读回
//   (agent-runner-utils.ts:27-31, agent-runner.ts:147)
// 渠道选择 src/infra/outbound/channel-selection.ts:86 resolveMessageChannelSelection:
//   显式指定 > tool-context 回退 > 唯一已配置渠道 > 报错(多渠道必须显式)
// Agent 路由(消息→哪个 agent/session)src/routing/resolve-route.ts:26-59
//   matchedBy: binding.peer / binding.guild+roles / binding.team / binding.channel / default
读法:入站时记住"从哪来"(OriginatingChannel),出站时据此"回哪去"——用户在 Telegram 问的,答复就回 Telegram。多渠道并存时必须显式指定目标(防止发错群)。Agent 路由则决定"这条消息该由哪个 agent/会话处理"(支持按对端、群+角色、团队等多级绑定匹配)。
🚶 第一人称之旅:现在你是那句"北京天气?"的答复 我在大脑里刚被生成出来,是一句纯文字。出门前得知道回哪去——我翻了翻会话上下文 sessionCtx,上面贴着入站时写的席位牌 OriginatingChannel="telegram"OriginatingTo="chat -100"resolveMessageChannelSelectionchannel-selection.ts:86)先看有没有人显式指定目标→没有,就用这张回执→正好只有一个已配置渠道也 OK。于是我被交给 telegram 的 outbound adapter,原路送回 Ada 那个群。如果当时开了三个渠道又没贴回执,系统会直接报错拦下我——宁可不发,也不发错群。
L05

出站投递

// src/infra/outbound/deliver.ts:141 createChannelHandler
//   → src/channels/plugins/outbound/load.ts:13 loadChannelOutboundAdapter(id)
//     (轻量 loader:只取 plugin.outbound,不加载重的 monitor 代码)
//   → 包成 handler 调 sendPayload/sendText/sendMedia(Day 13 的 outbound adapter)
// 富组件差异 src/infra/outbound/channel-adapters.ts:51 getChannelMessageAdapter:
//   仅 Discord supportsComponentsV2:true(用卡片),其它退化为纯文本
读法:发送时只加载目标渠道的 outbound adapter(Day 13)——不会为了发个消息把整个渠道的 monitor(收消息)代码也加载进来,省内存。富组件(Discord 卡片)只对支持的渠道启用,其余优雅退化为纯文本。这呼应 Day 13 的能力声明。
L06

语音 TTS(能说会听)

核心 src/tts/tts.ts

textToSpeech(:562)              // 合成语音(后端 ElevenLabs / OpenAI TTS)
textToSpeechTelephony(:732)     // 电话专用格式
maybeApplyTtsToPayload(:821)    // 按开关把回复转成语音
resolveTtsConfig(:261)          // 解析 messages.tts.* 配置
读法:回复流里 maybeApplyTtsToPayload 按配置/内联指令决定"这条要不要转成语音发出去"。相关扩展:extensions/talk-voice(ElevenLabs 音色)、extensions/voice-call(电话呼入呼出,Twilio 式媒体流)。"听"(语音唤醒/转录)主要由原生 App 端负责(Day 18 的 Swabble 唤醒词),服务端管合成/转录。Discord 语音频道播放在 src/discord/voice/manager.ts
L07

Canvas(实时可视化)

Canvas 是 agent 驱动的实时可视界面(README 说的"render a live Canvas"):

// src/canvas-host/a2ui.ts
//   路径常量 A2UI_PATH / CANVAS_HOST_PATH / CANVAS_WS_PATH(:8-12)
//   handleA2uiHttpRequest(:142) 提供 A2UI 静态资源
//   injectCanvasLiveReload(:81) 注入动作桥(iOS/Android 的点击回传 node)
// src/canvas-host/server.ts
//   CanvasHostServer(:36)基于 ws 的 WebSocketServer
//   向 node 端推送 A2UI 更新(push/reset/eval/snapshot)
Canvas = "助理能画给你看的屏幕" 纯文字聊天有局限——有时助理想给你看个图表、一个可交互的表单、一个进度面板。Canvas 就是给助理一块"可实时更新的画布":Agent 用工具往画布推 UI(A2UI 描述),网关通过 WebSocket 推给你的设备(mac/iOS/Android)渲染,你在画布上的点击又通过"动作桥"回传给 Agent。这让助理从"只会打字"进化到"能画界面、能交互"。网关做 host(托管画布内容),原生 App 做渲染面。
L08

第 3 周收官 🎓

🧠 第 3 周(技能与渠道)你已掌握

  • Day 11 技能 skills:SKILL.md + frontmatter 门控 + 三层发现 + 渐进式披露
  • Day 12 扩展 extensions:register(api) 注入能力 + 加载流水线 + 生命周期钩子
  • Day 13 渠道抽象:ChannelPlugin 组合式 adapter(非继承)+ 能力声明
  • Day 14 渠道实现:Telegram 轮询 / Slack Socket / Discord Gateway 三种连接统一契约
  • Day 15 MsgContext 归一化 + 两段式清洗 + 回复路由 + 语音/Canvas

你现在理解了 OpenClaw 的"手和嘴":技能/扩展让它可无限扩展本领,渠道抽象让它在任何平台说话,归一化让异构消息统一处理。第 4 周进入"筋骨"——沙箱、安全、原生应用、构建部署。

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
grep -n 'export type MsgContext\|OriginatingChannel' src/auto-reply/templating.ts | head
sed -n '37,90p' src/auto-reply/reply/inbound-context.ts   # 收口
grep -n 'textToSpeech\|CanvasHostServer' src/tts/tts.ts src/canvas-host/server.ts | head
明天预告 · Day 16(第4周开始)沙箱与运行时——助理执行工具/代码怎么不搞坏你的机器?Docker 沙箱(三份镜像)、默认极度收紧的安全配置、容器生命周期、防"配置注入"校验。
← Day 14 渠道实现 Day 16 · 沙箱与运行时 →