← dsh-channel-wechat

把微信接进 DSH:一个通道插件是怎么写出来的

这篇不写安装教程——那在 README 里。 这篇写的是实现时真正花时间的部分:一个聊天通道接进 DSH,难点几乎全在 「错了不会报错」的地方。协议、扫码、发消息都是明面上的活;真正难的是几个 看起来能跑、实际每条消息都白发的接法。

我把它写成文,是因为读完 DSH 的官方文档之后我仍然踩了这些坑,而每个坑的共同点是 没有任何报错:进程健康、日志干净、插件页正常,只是消息不回。

目录

  1. 先看形状:一个通道要做几件事
  2. 会话 id 必须是 peer 的纯函数
  3. 回复不能来自流,必须来自 durable 事件
  4. 交互卡:两个都是静默失败的坑
  5. 媒体失败绝不吞掉消息
  6. 出站文件只发声明过的
  7. 长轮询与处理必须解耦
  8. 为什么这些坑都静默,以及怎么防

一、先看形状:一个通道要做几件事

DSH 的插件是 cordis 的 fiber,通道插件要做的事可以拆成五件,每件都有对应的扩展点:

要做的事用什么
收消息协议层的长轮询(微信没有回调、没有 socket)
把消息变成一轮对话ctx.agents → agent.followup()
把回答发回去ctx.on('session/event') 读 durable 事件
回答交互卡approval/request、user-questions/request 两条 waterfall
发文件deliverables/presented 事件

框架给的东西不多,但够用。问题在于每一条都有一种「看起来接上了、其实没接上」的写法。 下面按踩坑顺序写。

二、会话 id 必须是 peer 的纯函数

一个微信用户应该固定对应一个 DSH 会话。最自然的写法是「第一次收到消息时 create 一个会话,把 id 记住」。这个写法在重启之后就会失效, 而且失效的方式很难查。

因为 DSH 会把会话状态落盘。重启后,那个 id 已经存在了。此时再对同一个 id 调 create,注册表会拒绝——而拒绝之后,本次进程里每一条入站消息都被丢掉。 你看到的现象是:插件加载正常、长轮询在跑、日志里没有异常,但用户发什么都没有回复。

正确的做法是让 id 由 peer 决定,而不是由「第几次创建」决定: channel-wechat-<escaped peerId>。这样它在重启前后是同一个值, 于是可以在起跑前先探测:

// src/index.ts
sessionExists: sessionExistsOf(ctx),

探测到已存在就 resume,不存在才 create。 sessionExistsOf 读的是 ctx.get('sessionPersistence') 的 stat;这个服务可能不存在(headless),所以拿不到时返回 async () => false——宁可多建一次,也不要因为探测不了就不启动。

顺带一个必须解释的设计:/new 不是删除会话

用户想「重新开始」时,直觉是删掉旧会话。但 DSH 的 sessionPersistence 没有删除 API——只有 create/open/flush/stat/list。

所以 /new 实现成换一个 id:代次 +1,写成 ...~g<N>,并把代次持久化到 ~/.dsh/channel-wechat/peer-sessions.json。持久化这一步不能省: 只在内存里转的话,重启后会 resume 回旧对话,用户会以为 /new 失效了。

代价写在明处:会话文件随 /new 累积且永不删除。这是协议层没有删除能力的直接后果, 不是漏做。

三、回复不能来自流,必须来自 durable 事件

这是我认为最容易写错的一处。DSH 有两个「回答」的来源:

来源性质能不能用来回消息
assistant-stream 分片进程内实时,没人订阅时就丢不能
assistant/message会话日志 commit 后的事件能

用流来拼回复很自然——你能立刻拿到文字,还能做「边生成边发」的效果。但它是 process-local 的:重新加载之后,已经流过的那部分拿不回来。 更糟的是,如果拼装逻辑有 bug,你得到的是残缺但不报错的回答。

正确做法是订阅 session/event,只在 assistant/message 时取出文本:

// src/channel/channel.ts
return events.on('session/event', (session, event) => { ... })

这带来一个要求:sessionEvents 是 必填参数,不是可选默认值。 因为「没订阅」的后果是跑得完一轮却什么也不回,而通道看起来完全健康—— 这种失败不该由调用方决定要不要避免。

另外三个看着也能用的 API,都不要用:turnOutline 是 120 字符预览, finalAssistantOutput 会越界,snapshotEvents() 已废弃。 而 whenIdle() 是 whole-agent 静默,无法区分是谁的回合结束—— 多 peer 场景下它会把你等成别人的空闲。

四、交互卡:两个都是静默失败的坑

审批卡和提问卡是我最想写进这篇的部分,因为它有两个相互独立的失败, 而且都是静默的。

坑一:不写 prepend: true,请求被 GUI 抢走

两条 seam 都是 waterfall。GUI 自己也挂了一个 answerer。如果你的监听是普通追加, GUI 的 answerer 会先跑并 claim 掉请求,你的通道再也看不到它。 没有报错,卡片就是单纯不出现。

// src/bridge/cards-service.ts —— 两条都必须带 prepend
{ prepend: true }

坑二:卡片回复走 peer 队列 → 死锁

这个更隐蔽。提出问题的那一轮正阻塞在 ask_user_question 里面, 而它持有该 peer 的队列。如果卡片回复也要排进同一个队列, 回复永远到不了——两边互等。

所以卡片回复(以及 /stop)必须绕过 peer 队列。 这不是优化,是可用性的前提。

为什么测试很难发现它

直接调 handleMessage 的测试绕过了串行化,看不见死锁。 死锁的回归测试必须走真实 poll 循环。

还有一个不对称值得记下:审批卡最多有个 callId,提问卡完全没有 id, 也没有 durable 事件。所以用户要手输的卡片 id(P1、P2…)是插件自己发的, 两条 seam 共用同一个 id 命名空间——否则同一句话可能同时命中两张卡。

五、媒体失败绝不吞掉消息

图片、文件这些附加物,处理链路比文本长得多:下载、解密、落 store、按模型能力决定是否转成图片块。 任何一步都可能失败。

这里的判断很明确:媒体是用户所说内容的附加物,不是被听见的前提。 所以 CDN 报错、字节不被 store 接受、模型不支持图片——一律降级为 追加在文本后的说明,而不是丢掉整轮消息。

两个易错点:

六、出站文件只发声明过的

DSH 没有「把文件发给用户」的一等 API。deliverables/presented (载荷是路径)是唯一信号。

「产出了文件」和「这个文件是给你的」是两件事。Agent 写报告时会顺手写一堆临时脚本和中间数据。 所以这个通道绝不扫描目录,只发 present 工具显式声明的文件。

邻接陷阱:workspace/changes 事件描述「本轮碰过的所有文件」, 正是绝不该发的那个——而且它的文件清单存在 projection 里而非事件里, 所以事件名是唯一判别依据。

七、长轮询与处理必须解耦

微信没有回调、没有 socket、没有订阅:getUpdates 是唯一的收信方式, 服务端会把请求挂住,直到有消息或超时(约 35 秒)。

这个形状决定了一件事:处理消息不能阻塞轮询。如果一轮对话要跑一分钟, 而轮询被它挡住,那么用户中途想发 /stop 的那条消息根本收不进来—— 打断机制自己变成了被阻塞的东西。

所以消息处理是 detached 的,跑在轮询循环之外。每个 peer 的处理仍然串行 (同一个会话不能并行两轮),但那条串行化属于会话映射层,不属于传输层。

还有一条:一次轮询失败当作「没有新闻」,不当作通道故障。 面对 35 秒的长轮询,网关超时是常态。

八、为什么这些坑都静默,以及怎么防

回头看,上面每个坑的共同点是:错误不发生在类型上,也不发生在启动上。 它发生在「接线」——两个都对的东西,没接在一起。

这带来一个具体的测试纪律,我认为是这篇最有用的结论:

如果你在单测里自己注入了 sink 或协作者,那么没有任何测试能观察到 apply 有没有把它接上。

这个模式在这个项目里反复复发过至少四次。所以规则是: 任何新增的生产接线,都要补一个「真实 apply 启动」的集成测试。 不是测那个函数,是测「装配点有没有把线接上」。

配套的还有两条:

一个真实的小例子:诊断本身也会静默

桌面端 host 的 stdout 被 pipe 给 Electron 主进程,而主进程没有终端,也不写日志文件。 所以任何绕过 reporter 的诊断,在桌面端等于不存在。

这个盲区咬过三次:一次是「跳过 profile bundle」,一次是「name mismatch」, 还有一次是登录地址——插件完全正常,用户却两眼一抹黑。 现在的规则是:所有要人看见的诊断都走同一个 reporter(终端 + 每日日志双写), 而且 reporter 是每 ctx 一个,否则多个调用点会开出多个 sink 盖同一个文件。


结语

写完之后我的感受是:接聊天通道的技术难度不在协议,也不在 DSH 的 API—— 这些都有文档。难的是区分「能跑」和「接对了」。 前者在第一次手动测试时就显现,后者要等到重启、并发、或者真实用户的那条消息。

如果你也在写通道插件,希望这篇能让你少踩几个静默的坑。完整的不变量清单在 docs/invariants.md, 每条都写了违反后果与证据位置。

本文对应的实现:dsh-channel-wechat (MIT)。文中提到的路径都相对仓库根目录。

当前代码规模:538 项单元测试 + 111 项集成测试全通过。 集成测试用真实 launcher 启动隔离的 DSH_HOME,不触碰开发者自己的 profile。