消息归一化 + 语音 + Canvas
昨天(Day 14)看了 Telegram/Slack/Discord 三种渠道各自怎么连;今天要解决一个必然冒出的问题:它们收进来的消息格式天差地别,上层的大脑/路由/命令逻辑怎么可能挨个去认识二十多种格式?答案是先归一成同一种内部消息——统一类型 MsgContext、收口函数 finalizeInboundContext、回复路由,外加语音 TTS 和 Canvas 可视化。学完这天,第 3 周(技能与渠道)收官,明天起进第 4 周"筋骨"(沙箱/安全/部署)。
if telegram: msg.text; elif slack: msg.blocks[0].text; elif discord: msg.content …——每加一个新平台,大脑、路由、命令解析、记忆全都要改一遍。二十多个平台 × 十几处用到消息的地方 = 几百个分支,改一处漏一处就是 bug。今天的归一化就是来终结这种噩梦的。MsgContext),会议记录、决议、发言只认这一种语言(= 大脑/路由/命令只认 MsgContext)。传译分两道工序:各语种译员先各自翻自己那门语言(= 各渠道做"平台格式→字段"映射),再由总编辑统一校对格式、删掉夹带的私货(= finalizeInboundContext 统一清洗+安全默认)。答复时按发言人席位牌原路传回他的耳机(= 按 OriginatingChannel 送回原渠道)。TTS 就是把文字稿配音念出来,Canvas 就是会场那块大投影屏。记住这一个会场,今天全通。MsgContext 统一类型
MsgContext(src/auto-reply/templating.ts:14)是全系统统一的入站消息类型(~170 字段)。核心分组:
- 正文:
Body/BodyForAgent(喂 LLM)/RawBody/BodyForCommands——区分"给模型的"和"用于命令解析的"。 - 路由:
From/To/SessionKey/AccountId。 - 标识:
MessageSid、ReplyToId、MessageThreadId、NativeChannelId。 - 发送者:
SenderName/SenderId/SenderUsername。 - 来源:
Provider/Surface/OriginatingChannel/OriginatingTo。 - 媒体:
MediaPath(s)/MediaType(s)、Transcript、MediaUnderstanding。
{ 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 一个字段,压根不用知道消息来自哪个平台。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(默认拒绝)。
finalizeInboundContext 负责"统一清洗 + 安全默认"。把清洗集中到一个函数,保证每条入站消息都经过同样的安全处理——不会因为某个渠道忘了清洗而留漏洞。两段式归一
finalizeInboundContext 统一做一遍。这样安全策略只写一处、改一处生效所有渠道,也杜绝了"某渠道忘了清洗"。"公共逻辑收口到一个函数"是消除重复、堵住漏洞的经典手法。👶 小白:既然都要归一,为什么不干脆让每个渠道一步到位、连清洗也自己做完?
👨🏫 老师:那就等于让每个译员既翻译又自己校对格式。二十多个渠道 = 二十多份重复的清洗代码;更要命的是——只要有一个渠道的作者忘了"剥系统标签"这一步,那个渠道就成了提示注入的后门。收口成一个函数后,清洗是"必经关卡",谁都绕不过、也漏不掉,安全策略改一处即全渠道生效。这就是"公共逻辑上提、安全默认集中"的价值。
回复路由(原路送回)
回复怎么知道该发回哪个渠道?靠入站时写入的 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
sessionCtx,上面贴着入站时写的席位牌 OriginatingChannel="telegram"、OriginatingTo="chat -100"。resolveMessageChannelSelection(channel-selection.ts:86)先看有没有人显式指定目标→没有,就用这张回执→正好只有一个已配置渠道也 OK。于是我被交给 telegram 的 outbound adapter,原路送回 Ada 那个群。如果当时开了三个渠道又没贴回执,系统会直接报错拦下我——宁可不发,也不发错群。出站投递
// 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(用卡片),其它退化为纯文本
语音 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。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)
第 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