把微信接进 DSH:一个通道插件是怎么写出来的
这篇不写安装教程——那在 README 里。 这篇写的是实现时真正花时间的部分:一个聊天通道接进 DSH,难点几乎全在 「错了不会报错」的地方。协议、扫码、发消息都是明面上的活;真正难的是几个 看起来能跑、实际每条消息都白发的接法。
我把它写成文,是因为读完 DSH 的官方文档之后我仍然踩了这些坑,而每个坑的共同点是 没有任何报错:进程健康、日志干净、插件页正常,只是消息不回。
目录
一、先看形状:一个通道要做几件事
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 接受、模型不支持图片——一律降级为 追加在文本后的说明,而不是丢掉整轮消息。
两个易错点:
- 图片类型靠嗅探前导字节判定。线上不带 media type,而 store 会校验 「声明的类型」与字节是否一致——声明错了,整张图被拒。
-
模型能力要按消息 await 查询,不能先缓存成
false再异步补。 那样每次运行的第一张图都会退化成标签。查询失败时答「否」: 谎报支持的代价是 provider 拒掉整条消息,反过来的代价只是一张图。
六、出站文件只发声明过的
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, 每条都写了违反后果与证据位置。