ZON
Best Minds Board Private 2026-03-17 · OpenClaw / Feishu / Voice
Incident Retrospective

OpenClaw × Feishu 语音群复盘:登录复用、原生语音发送与清理重启

这次要修的不是单点 bug,而是一条完整链路:自动化要复用你已经登录的飞书,真实群消息要进入 voice-lite,机器人最终还要在群里显示为飞书原生语音,而不是 [文件] voice-*.webm。到 2026-03-17 09:56:13,这三个目标已经同时成立。

状态:已恢复 目标群:语音 发布链路:board private 验收 token:smoke-20260316-voice-full-06 清理重启:2026-03-17 10:53:09
Login Reuse
Stable
storage.json 可复用,probe 多次回报 needsLogin=false
Final Delivery
audio
最终不再是通用 file,而是飞书原生 msg_type=audio
Main Blockers
3
登录态复用、dispatch 回归、媒体契约失配。
Touched Surfaces
5
登录状态、运行时配置、media.ts、测试、网关重启。
E2E To Audio
84s
09:54:49 收到 smoke,到 09:56:13 音频落群。

Best Minds

Google SRE

先把问题拆成控制面和数据面

按 SRE 的写法,这不是“语音没发出来”那么简单,而是控制面和数据面同时失真:控制面是登录态复用与 runtime 对齐,数据面是 voice-*.webm 被错误投递成 file。只有两边都闭环,验收才算完成。

Charity Majors

要看边界,不要只看模型

真正的问题出在边界处:飞书 UI、网关 dispatch、媒体标准化、Feishu API 类型选择。把每个 handoff 都留下可核对证据, 才能知道“发了没回”究竟卡在哪一跳。

API Contract

扩展名不是语义,消息类型才是语义

这轮最关键的认知升级,是把“WebM 文件”视为可被标准化的语音中间产物,而不是最终投递形态。飞书是否显示成语音, 取决于 file_typedurationmsg_type 的组合,而不只是文件名。

结论

结论可以压缩成一句话:这次不是“再试一次就好”的问题,而是把一条原本只到 voice-*.webm 文件的半成品链路,收口成“登录可复用、dispatch 可达、语音原生显示、重启后仍干净”的完整链路。

用户的真实目标不是“机器人能回消息”,而是“我本地已经登录的飞书要被复用,语音群里要真的看到可播放语音,而且这条链路重启后不能再漂”。这一目标已经实现。

四个原则

要点 1

先保住登录态,再谈消息

如果自动化上下文一开始就失去飞书登录态,后面所有“没回”判断都会混在一起。先把 session 复用变成稳定前提,后续日志才有解释力。

saved probe stable
要点 2

控制面故障和数据面故障必须分开看

ReferenceError 属于 dispatch 控制面;[文件] voice-*.webm 属于媒体语义失配。两者同时出现时,不能用一个结论吃掉全部现象。

control data
要点 3

中间产物不等于最终交付

核心 TTS 输出 WebM 没有错,但它只是中间产物。真正要给飞书的,是 opus + duration + audio 这个最终契约。

webm opus audio
要点 4

验收口径必须落在群里可见结果

真正的成功标准不是“日志里回复了”,而是群成员能同时看到双语可见文字和飞书原生可播放语音。验收口径要始终落在用户界面。

log group UI

时序图

下面这张泳道图把这次从登录复用、问题定位、补丁落地到最终验收的顺序串起来。灰色卡片表示故障或人工环节。

SESSION / LOGIN INCIDENT / GATEWAY PLUGIN PATCH FEISHU DELIVERY ACCEPTANCE Saved storage state `configs/feishu/storage.json` reused Probe says no login `needsLogin=false` + reload still valid Messenger targets 语音 fixed messenger URL + sidebar match Bad output before fix [文件] `voice-*.webm` still visible Dispatch regression ReferenceError on 2026-03-16 17:47–17:59 Runtime aligned clean restart 10:53:09 + tools registered Detect generated voice-* catch `.webm/.mp3/.m4a/.wav/.ogg/.oga` ffmpeg -> .opus 24k mono + libopus Probe duration + audio ffprobe ms + `msg_type=audio` Old contract = file generic upload, no native audio semantics Post lands first visible bilingual text passes group rule Native audio card audio message, duration=9026ms Smoke-06 sent 2026-03-17 09:53:49 / group label=语音 Gateway received 09:54:49 -> voice-lite group session E2E accepted 09:55:30 post, 09:56:13 audio
从保存登录态到原生音频落群的完整时序。灰色节点表示故障态或人工步骤。

关键时间线

时间 观测 意义
2026-03-16 13:42 / 16:34 / 16:56 / 18:38 群侧多次出现 [文件] voice-*.webm 证明语音已经生成,但 Feishu 插件把它当作通用文件发送,数据面语义错了。
2026-03-16 17:47:51 → 17:59:28 ReferenceError: shouldPassthroughTextCommandForAgent is not defined 这是控制面的临时回归,消息在 dispatch 前就失败,和语音格式问题不是同一层。
2026-03-17 09:03:52 → 09:05:00 网关 client ready,随后注册本地 Feishu plugin 工具。 说明本地 live plugin 和运行时重新对齐,链路恢复到可验收状态。
2026-03-17 09:53:49 用户发出 smoke-20260316-voice-full-06 验收消息。 这是最终端到端 smoke 的起点。
2026-03-17 09:54:49 网关日志记录群消息进入 agent:voice-lite:feishu:group:... 证明真实群消息已进入目标 agent 会话。
2026-03-17 09:55:30 按当时 API 验收记录,配对的双语可见文字以 post 形式落群。 文本层先成功,说明 reply 结构和群规则是对的。
2026-03-17 09:56:13 原生音频落群,网关记录 dispatch complete (queuedFinal=true, replies=1) 最终验收完成;当时 API 记录中的音频消息时长为 9026 ms
2026-03-17 10:53:09 → 10:53:12 做一次“无功能变更”的清理重启,工具重新注册,client readyws client ready 正常。 清掉临时调试态,留下更干净的运行面。

诊断图集

为了把这次排障从“文字叙述”提升成“可复核结构”,这里再补五张微型图,分别对应会话、边界、重复试验、分叉决策和接口关系。

Session Matrix

登录态矩阵

关键判断:当前自动化上下文与本地飞书登录态已经稳定重合。
Retry Heat

失败热点

最高热点不在模型,而在“dispatch 回归”和“媒体语义最后一跳”。
Trade-off

补丁取舍

fast local fix better semantics
这次优先选了“本地快速闭环”,下一步才是抽象成 upstream 可收的配置能力。
Decision Tree

排障分叉

login / dispatch media semantics
真正省时间的关键,是尽早把问题切成两棵树,而不是反复重试同一路径。
Boundary Network

边界依赖图

链路横跨 UI、网关、plugin、Feishu API 四层,任何一层语义漂移都会在群里显影。

配置快照

Session + Routing

运行时绑定事实

  • 已保存的飞书登录态位于 configs/feishu/storage.json,探针多次回报 needsLogin=falseverifiedAfterReload=true
  • 目标群侧栏标题是 语音,绑定的 chat_id 为 oc_c79495…90a2f
  • .openclaw/openclaw.json 中,voice-lite 已绑定到该群,群会话作用域为 group
Plugin + TTS

语音链路事实

  • plugins.allow 包含 feishu-official-localplugins.load.paths 指向本地 live plugin 目录 DEVELOPE/Tool/openclaw-feishu
  • 核心 TTS 输出格式仍是 webm-24khz-16bit-mono-opus,也就是“生成 WebM,再由插件决定怎么投递”。
  • 真正修掉问题的不是改 TTS,而是让插件把生成物标准化成飞书原生音频。

改动清单

Code · media.ts

把中间产物变成最终语义

  • DEVELOPE/Tool/openclaw-feishu/src/media.ts 增加 voice-* 识别逻辑,覆盖 .webm/.mp3/.m4a/.wav/.ogg/.oga
  • 检测到生成语音后,先调用 ffmpeg 转成 .opus,再调用 ffprobe 取时长。
  • 上传文件时补齐 duration,并把最终 msg_type 从 file 改成 audio
Code · media.test.ts

补一条核心单测

  • 新增 voice-123.webm -> voice-123.opus -> audio 的单测,锁住最关键的标准化行为。
  • 测试桩模拟 ffmpeg 产出和 ffprobe 返回 1.25s,验证最终 duration=1250
Runtime · cleanup

去掉临时噪音

  • src/bot.ts 中为排障临时加过的 debug stack logging 已经移除,没有留在最终行为里。
  • 2026-03-17 10:53:09 做了一次“只清理不改功能”的重启,确认当前链路在更干净的 runtime 上继续工作。

问题与根因

Control Plane

为什么会出现“发了没回”

  • 自动化上下文并不会天然继承你浏览器当前窗口的登录态,所以必须显式保存并复用 storage.json
  • 中途某次 restart 还触发了 shouldPassthroughTextCommandForAgent 未定义,导致消息在 dispatch 层直接失败。
  • 这也是为什么“我明明登录了”与“发了没回”其实分别属于两个不同问题层次。
Data Plane

为什么会出现“回了,但像文件”

  • 核心 TTS 产物是 WebM/Opus 容器,但插件此前只按扩展名把它视作普通文件。
  • 飞书原生语音依赖的是 file_type=opus、毫秒时长、以及 msg_type=audio 的组合。
  • 因此真正的根因不是“没有语音”,而是“语音语义在最后一跳丢了”。

验证证据

E2E Probe

登录复用已稳定

  • tmp/_codex/feishu-e2e/feishu-send-probe-20260316-183748.json 与同类 probe 持续回报 needsLogin=false
  • 探针能直接打开固定 messenger 地址,侧栏匹配到 语音,并在 reload 后继续保持有效。
  • 这说明“重新登录一次”已经从主问题中退出,后续排障焦点转向发送语义。
Gateway + API

原生语音已闭环

  • .openclaw/logs/gateway.log 在 09:54:49 记录收到 smoke,并把会话送入 voice-lite
  • 09:56:13 记录 dispatch complete (queuedFinal=true, replies=1)
  • 按当时 Feishu API 验收记录,最终配对结果是 post + audio,音频消息 ID 为 om_x100b54b…60b6a,时长 9026 ms

备注:当前 live plugin 目录带有运行依赖,但本地并没有可直接执行的 vitest runner,因此这次报告只能把新增单测作为“代码证据”记录,不能把“本机已跑绿”作为最终结论的一部分。

剩余风险与建议

Risk 1

本地补丁仍依赖外部二进制

现在的标准化过程依赖 ffmpegffprobe。代码里已经做了失败回退,但一旦机器上缺这两个工具,行为会退回 file 上传。

Risk 2

登录态是资产,不是常量

configs/feishu/storage.json 解决的是“复用你当前已登录状态”,不是永久免登录。如果租户会话过期,仍需要重新采集一次。

Recommended

下一步优先级

推荐把这次 media.ts 的标准化逻辑抽成可配置或可 upstream 的能力,其次补齐 live plugin 的本地测试 runner,避免未来只能靠群里现象回归。