研发复盘:钉钉 ↔ DSH 桥接器从搭建到端到端打通

August 20, 2026 · View on GitHub

本文件记录本项目从零搭建 dsh-dingtalk-bridge 的过程中踩过的坑、定位 root cause 的 方法论,以及沉淀下来的可复用经验。写给未来的维护者和所有接外部 IM/机器人的人。


一、事件时间线

阶段现象结论
调研阅读 DSH dsh-client-connection 源码,确认 /api 外部客户端协议协议可行,外部程序能复用浏览器同款通道
1. 端到端验证用真实 DSH 实测 session.create + session.prompt + events.mux WS,收到 Agent 回复「收到」协议打通,架构成立
2. 假凭证冒烟假 AppKey 启动,DSH 侧连上、钉钉侧报 get access_token failed,进程不崩启动路径正确,只差真实凭证
3. 真实凭证启动钉钉侧挂起 45s 无日志,随后 connect ETIMEDOUT 203.119.174.125:443坑 #1:SDK 预发布域名不可达
4. 升级 SDK 后钉钉 connect success,但消息收不到;日志出现反复 TERMINATE SOCKET坑 #2:keepAlive 心跳误杀
5. 关闭 keepAlive连接稳定,但消息仍收不到;加诊断日志发现 registered 恒为 false坑 #3:订阅通道错误(EVENT vs CALLBACK)
6. 参考 openhermit对比成功项目 openhermit,发现它用 registerCallbackListener(TOPIC_ROBOT)(CALLBACK 通道)根因定位:我们用的 registerAllEventListener 是 EVENT 通道,收不到机器人消息
7. 切换 CALLBACK消息到达([raw] 收到 CALLBACK 消息),但回复未回发坑 #4:事件流 seq 去重误杀 + session/subscribed 基线
8. 修正去重逻辑重启后再发消息,assistant/message → [reply] 已回发钉钉✅ 端到端完全打通

二、三大 Root Cause 详解

Root Cause 1:dingtalk-stream SDK 版本坑(预发布域名)

现象:钉钉侧 connect() 挂起 45 秒后报 connect ETIMEDOUT 203.119.174.125:443

根因dingtalk-stream@2.1.0 把 gateway 地址硬编码为预发布域名

// 2.1.0
url: "https://pre-api.dingtalk.com/v1.0/gateway/connections/open"

在你的网络环境里 pre-api.dingtalk.com 不可达,而正式域名 api.dingtalk.com 可达。

修复:升级到 dingtalk-stream@2.1.5,该版本改用正式域名:

// 2.1.5
GATEWAY_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open"
GET_TOKEN_URL = "https://oapi.dingtalk.com/gettoken"

经验

  • 接第三方 SDK 时,先看它请求的域名,用 curl -w 验证可达性,再写业务代码。
  • npm 上同名/近名的包很多(dingtalk-stream vs dingtalk-stream-sdk-nodejs 是两个不同包),先确认装对包、版本对
  • 排查网络类问题:curl -s -o /dev/null -w "HTTP %{http_code} 耗时 %{time_total}s" 是最快的连通性探针。

Root Cause 2:SDK keepAlive 心跳误杀连接

现象:连接建立后约 33 秒,日志出现:

TERMINATE SOCKET: Ping Pong does not transfer heartbeat within heartbeat intervall

随后自动重连,导致连接反复断连,消息恰好错过窗口。

根因dingtalk-stream SDK 的 keepAlive: true 会启用应用层心跳:每 8 秒发一次 ping,若未在下一个 8 秒内收到 pong 就 socket.terminate()。在代理/NAT 环境(如公司网络)下,pong 可能被延迟/拦截,被误判为超时。

修复keepAlive: false(SDK 默认)。钉钉服务端有系统级 SYSTEM/KEEPALIVE 维持连接,不依赖应用层心跳。

经验

  • 不要盲目开启第三方连接的心跳:先了解它的误杀阈值和你的网络环境。
  • 日志里出现 TERMINATE SOCKET 这类"插件主动杀连接"的痕迹,第一反应是它的健康检查误判,而不是真断网。

Root Cause 2b(2026-08-18 新发现):Stream 静默断连 —— SDK autoReconnect 治不了半开连接

现象:桥接器进程一直活着、DSH 出站推送正常(走持久 webhook),但钉钉入站彻底失效约 13 小时

  • 最后一次 [dingtalk] Stream connected 停留在昨天 14:18,之后再无 [recv](入站消息)记录;
  • 用户在钉钉发 /status 完全无反应,重启 DSH 也不恢复(连接在桥接器进程内,与 DSH 无关);
  • 只有重启桥接器进程(launchctl kickstart)才恢复。

根因:SDK(dingtalk-stream v2.1.5)的 autoReconnect:true 只在 WebSocket 触发 close/error 时重连;但网络静默中断(代理/NAT/防火墙把空闲 TCP 掐掉)时,socket 对象不触发任何事件connected 仍为 true —— 即半开连接(half-open)。SDK 的自愈机制对半开无效,于是进程活着、连接却早已是死连,消息进不来。

修复(三层守护,已实现并过测试)

  1. 健康哨兵(每 30s):周期检查 client.connected,若 SDK 已感知断开则主动 _rebuild() 重连(指数退避失连次数)。
  2. 兜底强制重建(每 15min):无论 SDK 认为连接是否健康,都强制 disconnect()+connect() 重建 —— 专治"半开连接"(SDK 无感知但数据不通)。
  3. 连接超时保护(30s):Promise.race 包裹 SDK connect(),防止 _connect() 因网络 hang 永不 resolve、_connecting 永久占用导致后续无法重连;同时补上 SDK error/close 事件日志(原先无这些日志,断连是盲区)。

经验

  • 不要相信第三方 IM SDK 的 autoReconnect 能覆盖所有断连:它只响应"明确的 close/error",对半开连接(静默断连)无效。生产级可靠性必须自己加兜底定时重建,代价仅是周期性的一次瞬时重连,远好于永久失联。
  • 判断是"半开"还是"SDK 感知断开":看 client.connected + 有无 close/error 日志。无日志且 connected=true 却收不到消息 = 半开。
  • 入站与出站是两条独立通道:出站用持久 webhook(可靠),入站靠 Stream(易断)。出站正常不代表入站没死 —— 排查时要分别看两侧日志。

Root Cause 3(最关键):订阅通道错误 —— EVENT vs CALLBACK

现象:连接稳定、registered 恒为 false、收不到机器人消息。

根因dingtalk-stream SDK 有两种订阅通道:

订阅方式订阅类型用途
registerAllEventListener(cb){type:'EVENT', topic:'*'}通用事件流
registerCallbackListener(topic, cb){type:'CALLBACK', topic:'/v1.0/im/bot/messages/get'}机器人消息回调

机器人消息走CALLBACK通道。用 registerAllEventListener 只会向网关注册 EVENT *永远不会收到机器人消息。同时 registered 字段在这个场景下恒为 false(实测 openhermit 成功用的 SDK 2.0.4 也一样),不能作为注册成功的信号。

修复:改用 registerCallbackListener(TOPIC_ROBOT, cb)

this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => this._onCallback(msg));

收到消息后需手动 ACKclient.send(messageId, {status:'SUCCESS'})),防止钉钉重复投递。

经验(最重要)

  • 先找一个"已知可用"的参考实现,再动手。本项目卡在订阅问题上时,是 user 提示 /Users/zhengyd/OpenProject/openhermit 之前对接成功过,一对比立刻找到差异。不要闭门造车
  • 外部 IM SDK 通常有 EVENT / CALLBACK 两套通道,消息类型决定走哪条,订阅方式必须匹配。
  • SDK 的 registered/connected 字段不一定是可靠的"已就绪"信号(本场景 registered 恒 false); 真正的判据是实际收到消息。设计上可以打 [raw] 诊断日志,别只看连接状态。

Root Cause 4:事件流 seq 去重逻辑的时间错位

现象:Agent 回复已产生(assistant/message seq=43),但桥接器没回发。

根因session/subscribed(Mux 流订阅基线)把 lastSeq 设为会话持久化尾部(比如 45), 而回复事件 seq=43 落在基线之内。桥接器的去重逻辑 if (evt.seq <= last) return 把它当作 "已见过的旧事件"丢弃。实际是因为调试中多次重启导致的时间错位:回复在重启前已产生, 重启后订阅基线已经包含了它,实时推流不会重推。

修复:这不是单次运行的真实缺陷(正常启动 → 发消息 → 收回复,事件 seq 单调递增 > 基线, 不会误杀),但暴露了两点:

  • 事件流重连/重启后,**"基线已含历史尾部"**是设计约束:已结束的 turn 不会重推,别指望 收到重启前的回复。
  • 去重时对 assistant/message 这类回复载体应宽容:即使 seq ≤ 基线,如果该会话的回复 从未回发过,应允许补回发(本项目通过 _sentSeq 防重复 + replyTargets 精确路由兜底)。

番外:定时任务 + 钉钉主动推送(端到端实战沉淀)

目标:DSH 定时提醒 → 到期唤醒 Agent → 回复 → 桥接器主动推到钉钉。

链路(实测打通)

schedule_create(after/every) → dsh-schedule 持久化到 session event log
→ 到期 overdue → 会话 idle 后注入 [SCHEDULE REMINDER] user 消息
→ Agent 收到提醒并回复(assistant/message)
→ 桥接器 events.mux 捕获 → 无 replyTarget → _tryActivePush
→ 反查 mapping 的 active/历史会话 → 持久 sessionWebhook → POST 到钉钉

关键踩坑:主动推送反查失配

现象:定时提醒投递到的 DSH 会话(Web 主会话 12367081)不是钉钉映射的 active 目标(旧值 64dc23b2),桥接器 _tryActivePush 只匹配 active → 不匹配 → 忽略, 钉钉收不到。

根因

  • 钉钉映射的 activeSessionId 是「钉钉投递目标」,而 Web UI 用的主会话可能不同;
  • dsh-schedule 的提醒投递到「创建它的会话」(即 Web 主会话),不是映射 active。

修复_tryActivePush):

  1. 精确匹配entry.activeSessionId === sessionId
  2. 历史兜底entry.sessions 里用过该会话(仅当唯一,避免群聊/多会话误推);
  3. 更新映射,让 active 指向当前真实活跃会话(等价 /use 切换)。

关键踩坑:会推送中间输出(不想要)

现象:一次回复有多条 assistant/message(每个 step 一段:思考、工具前、工具后、最终结论), 第一条就被推送,用户收到一堆过程碎片。

根因:对每个无 replyTarget 的 assistant/message 立即推送。

修复延迟去抖 + 只推最终结果——收到候选后设静默窗口(ACTIVE_PUSH_QUIET_MS,默认 2.5s); 窗口内任何后续输出(assistant/message、tool/、step/)都会替换候选并重置定时器; 窗口到期仍安静,则推送候选(即最终结果)。

关键技术点

  • dsh-schedulesession-local:只唤醒「原会话且存活中」的 Agent;冷会话不主动通知。
  • after 一次性、every 周期(≥5min,创建锚定对齐,错过不补)。
  • 主动推送依赖持久 sessionWebhook(该会话最近一次回调的 sessionWebhook)——用户必须先给机器人发过消息。
  • 事件类型参考:step/start → assistant/message → [tool/call → tool/result] → step/end,循环; 最终回复 = 最后一次 step 的 assistant/message(其后无 tool/call)

验证要点(实测)

  • schedule_list 状态 scheduled → overdue(到期未投递),会话 idle 后注入。
  • 桥接器日志 [push] 最终结果稳定 candidate ...[push] 已主动推送 = 成功。
  • [push] ... 无持久 webhook = 该会话还没给过回调。

关键踩坑:自研 cron 插件把历史搞挂 + 定时任务死循环(2026-08-18)

现象:① 会话历史报 SessionFormatUnsupportedError ... contains event type "cron/dispatch" unknown to this harness,历史无法加载;② 定时任务每整点命中两次、 连续数小时不停("死循环")。

根因(一条链,两个面)

  • 会话日志污染:插件每次触发往 agent.session.append('cron/dispatch', {...}) 写 自定义事件。这不是 DSH 官方类型,assertEventsSupported() 遇白名单外且未标 ignorable:true 的事件会拒绝解释整份日志(宁可拒绝,不可错建)。
  • lastFired 回写失败 → 死循环:插件把 lastFiredAt 写回 ~/.dsh/cron-schedules.json, 但 workspace-write 沙箱只放行 header.cwd 子树 + /tmpwritableRoots), ~/.dsh 在沙箱外 → ctx.fs.writeTextFS_SANDBOX_DENIED 被吞掉 → 每次 tick 从配置重建 TriggerState(无 lastFired)→ 同一命中点每 30s 被重新找到 → 死循环。 诡异点:session.append(审计事件)能写进日志、ctx.fs(回写)却写不进,两通道 权限不同。

修复(完整细节见 CRON-SCHEDULER-INCIDENT.md

  • 删除 session.append('cron/dispatch'),审计改 logger;
  • lastFired 状态落到 workspace 内 config/cron-scheduler-state.json(可写);
  • 离线给历史日志的 cron/dispatchignorable:true 信封,恢复加载(已全部验证)。

铁律:① 不向 session 日志写非官方自定义事件;② 跨 tick 状态放 workspace 内可写 路径;③ 写失败别静默吞(FS_SANDBOX_DENIED 是设计问题不是 IO 抖动)。


三、方法论沉淀

3.1 外部程序对接 DSH 的完整协议(可复用)

这是本项目最大的技术产出。外部程序(任何语言)对接 DSH 只需要三条通道:

发消息   : POST {base}/api/session.prompt
          body: { type:'client-request', rpcId:'<uuid>', method:'session.prompt',
                  payload:{ sessionId, mode:'queue', content:[{type:'text',text}] } }
收回复   : WS {base}/api/events.mux   (只下行)
         每帧: { type:'server-request', rpcId, method, payload: MuxFrame }
         关键帧: { type:'session/event', sessionId, event:{ type:'assistant/message', seq, time, data:{ message:{ content:[...] } } } }
会话管理 : POST /api/session.create  (可预分配 sessionId,幂等)

三个最容易踩的协议细节

  1. SessionEvent 结构是 {type, seq, time, data:{...}},真正的消息在 data.message, 不在顶层 message。盲写时会踩 Cannot read properties of undefined (reading 'content')
  2. Mux 流只下行:不要在 WS 上发业务数据;upstream 一律走 HTTP POST。
  3. session/subscribed 基线 vs 实时事件:基线 lastSeq 已含历史尾部,实时新事件 seq 一定递增;重连后重启前的回复不会重推。

3.2 排查链路的方法(广播式诊断 → 逐层定位)

这次排查用了有效的分层诊断法:

  1. 最小复现:先用独立脚本 + 假数据验证协议(session.list 探测 → session.prompt + WS 收回复),把"协议是否可行"和"业务代码是否有 bug"分开。
  2. 加诊断日志要落在数据源头上:在 [raw](SDK 下行事件)、[recv](bridge 收到)、 [reply](回发决策)三个点打日志,能立即看出消息卡在哪一层。
  3. 对比已知可用实现:当自己排查进入死胡同时,找"别人成功跑通"的代码逐行对比, 差异即嫌疑
  4. 不要信任 SDK 的状态字段connected/registered 都可能说谎,用**真实流量 (实际收到消息)**做最终判据。

3.3 配置与环境侧经验

  • 本机 npm 全局缓存有 root 权限问题时,用 npm install --cache ./node_modules/.npm-cache 隔离。
  • 钉钉企业内部应用 Stream 模式不需要公网,但需要:应用已发布、机器人启用且选了 Stream 模式、账号在可用范围内。三者缺一,消息不会推过来。
  • sessionWebhook 是每条消息自带的回传地址,直接 POST 即可回复(无需 access_token)—— 这是 Stream 模式最省事的回复方式。

3.4 DSH 宿主插件工具 schema 铁律(2026-08-20 事故复盘)

事故:新增 browser-reader 宿主插件(5 个 web_read* 工具)后,dsh web 直接启动崩溃: unsupported JSON schema: schema.properties.pageId.required is not supported on type "string"

根因(DSH 工具 schema 有两套语法,不能混用)

位置语法required 写法
defineTool/registerparametersDSH 参数 DSL字段级 { type, required: true, description }
工具的 output.schema标准 JSON Schema 受限子集required: ['a','b'] 只能在 type:"object" 节点;属性节点内部写 required: true 即报错 ❌

我参照社区 TS 插件(dsh-preview)时,把 parameters 的字段级 required: true 照搬到了 output.schema.properties.* 里,触发 assertSupportedJsonSchemadsh-tools lib)校验失败。

正确写法

output: {
  schema: {
    type: 'object',
    additionalProperties: false,
    properties: { pageId: { type: 'string' } },   // ← 属性节点不写 required
    required: ['pageId'],                          // ← 必填在 object 根声明
  },
}

铁律(写 DSH 宿主工具插件必守)

  1. parameters 用字段级 required: trueoutput.schema 用对象级 required: [...]
  2. 凡是 patch 层新增/修改工具插件行,先做「加载期自检」再重启 DSH:写个 stub ctx{ tools:{ register:(def)=>{}, effect:()=>{} }, inject:()=>{} })直接 import 插件模块 并调 apply(ctx, cfg),确认不抛错再部署。--dump-config 只能验证配置树,不会执行 apply验不出 schema 错误——本次事故就是 --dump-config 通过但启动崩溃的。
  3. 别照抄社区 TS 插件写法到 .mjs 直连 ctx.tools.register 的场景——TS 插件的 defineTool 封装可能已把 schema 转成合法形态,直连 register 必须自己保证子集合规。
  4. 重启前备份/检查旧进程:ps aux | grep "dsh web" 确认当前 PID,kill 前清楚自己在干嘛; 重启后立即 curl http://127.0.0.1:3080/ + session.list 双探针确认健康。

四、代码层面的改进(已经融入)

文件改进
src/dingtalk-client.jsregisterCallbackListener(TOPIC_ROBOT) + 手动 ACK;去掉对 registered 的依赖;keepAlive:false2026-08-18 加三层连接守护(健康哨兵 30s / 兜底强制重建 15min / connect 超时保护 30s + SDK error/close 日志)
src/dsh-client.jsassistantText 吃完整事件(data.message);非关键事件(chunk)不刷屏日志;seq 去重 + 丢弃日志
src/bridge.js三处诊断日志([recv]/[reply]);_rawText 只用于日志;replyTargets 双键路由(convId 和 dshSessionId);2026-08-18 加已归档会话过滤/list/use/sched 均剔除 workspace.listarchivedSessionIds,避免把归档会话展示/投递)
docs/DEPLOYMENT.mdSDK ≥ 2.1.5 版本要求说明

五、给未来项目的 Checklist

遇到"外部 IM/机器人 ↔ 内部系统"集成:

  1. 装对包、定对版本(同名包陷阱)
  2. curl 验证 SDK 请求的域名可达
  3. 确认消息走 EVENT 还是 CALLBACK 通道,用对订阅 API
  4. 连接状态字段不可靠时,以"实际收到消息"为准
  5. 收到消息后手动 ACK,防止重复投递
  6. 别盲目开应用层心跳
  7. 在数据源头(raw / 解析 / 业务决策)三层打诊断日志
  8. 保留一个"已知可用"的参考实现用于快速对比
  9. 协议数据结构先读源码/类型定义再写代码,别猜字段
  10. 记录 SDK 版本坑到 DEPLOYMENT,防止回退

六、本次实战可复用的片段

最快的域名连通性探针

curl -s -o /dev/null -w "HTTP %{http_code} 耗时 %{time_total}s\n" --max-time 15 "https://api.dingtalk.com/"

最小 DSH 协议验证脚本(不依赖业务代码)

const BASE = 'http://127.0.0.1:3080';
const rpcId = crypto.randomUUID();
const res = await fetch(`${BASE}/api/session.list`, {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ type: 'client-request', rpcId, method: 'session.list', payload: {} })
});
console.log('HTTP', res.status, (await res.json()).result.ok);

钉钉 SDK 正确的订阅方式(核心)

const client = new DWClient({ clientId: appKey, clientSecret: appSecret, keepAlive: false });
client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
  const data = JSON.parse(msg.data);
  // data.sessionWebhook / data.conversationId / data.text.content ...
  client.send(msg.headers.messageId, { status: 'SUCCESS' }); // 手动 ACK 防重复
});
client.connect();

七、遗留事项

  • 测试过程中产生了一批 session-dingtest-* 测试会话(在 /tmp 下),不影响使用,可按需 归档(workspace.archiveSession)。
  • 当前桥接器用 nohup 启动,机器重启后失效;如需长期运行可配置 pm2 或 macOS launchd
  • 群聊 @ 机器人目前是粗粒度判断(以 @ 开头或包含昵称),如需精确 at 解析可扩展 msg.text.mentions