研发复盘:钉钉 ↔ 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-streamvsdingtalk-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 的自愈机制对半开无效,于是进程活着、连接却早已是死连,消息进不来。
修复(三层守护,已实现并过测试):
- 健康哨兵(每 30s):周期检查
client.connected,若 SDK 已感知断开则主动_rebuild()重连(指数退避失连次数)。 - 兜底强制重建(每 15min):无论 SDK 认为连接是否健康,都强制
disconnect()+connect()重建 —— 专治"半开连接"(SDK 无感知但数据不通)。 - 连接超时保护(30s):
Promise.race包裹 SDKconnect(),防止_connect()因网络 hang 永不 resolve、_connecting永久占用导致后续无法重连;同时补上 SDKerror/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));
收到消息后需手动 ACK(client.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):
- 精确匹配:
entry.activeSessionId === sessionId; - 历史兜底:
entry.sessions里用过该会话(仅当唯一,避免群聊/多会话误推); - 更新映射,让 active 指向当前真实活跃会话(等价
/use切换)。
关键踩坑:会推送中间输出(不想要)
现象:一次回复有多条 assistant/message(每个 step 一段:思考、工具前、工具后、最终结论),
第一条就被推送,用户收到一堆过程碎片。
根因:对每个无 replyTarget 的 assistant/message 立即推送。
修复:延迟去抖 + 只推最终结果——收到候选后设静默窗口(ACTIVE_PUSH_QUIET_MS,默认 2.5s);
窗口内任何后续输出(assistant/message、tool/、step/)都会替换候选并重置定时器;
窗口到期仍安静,则推送候选(即最终结果)。
关键技术点
dsh-schedule是session-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子树 +/tmp(writableRoots),~/.dsh在沙箱外 →ctx.fs.writeText抛FS_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/dispatch补ignorable: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,幂等)
三个最容易踩的协议细节:
- SessionEvent 结构是
{type, seq, time, data:{...}},真正的消息在data.message, 不在顶层message。盲写时会踩Cannot read properties of undefined (reading 'content')。 - Mux 流只下行:不要在 WS 上发业务数据;upstream 一律走 HTTP POST。
session/subscribed基线 vs 实时事件:基线lastSeq已含历史尾部,实时新事件 seq 一定递增;重连后重启前的回复不会重推。
3.2 排查链路的方法(广播式诊断 → 逐层定位)
这次排查用了有效的分层诊断法:
- 最小复现:先用独立脚本 + 假数据验证协议(
session.list探测 →session.prompt+ WS 收回复),把"协议是否可行"和"业务代码是否有 bug"分开。 - 加诊断日志要落在数据源头上:在
[raw](SDK 下行事件)、[recv](bridge 收到)、[reply](回发决策)三个点打日志,能立即看出消息卡在哪一层。 - 对比已知可用实现:当自己排查进入死胡同时,找"别人成功跑通"的代码逐行对比, 差异即嫌疑。
- 不要信任 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/register 的 parameters | DSH 参数 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.* 里,触发 assertSupportedJsonSchema(dsh-tools lib)校验失败。
正确写法:
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: { pageId: { type: 'string' } }, // ← 属性节点不写 required
required: ['pageId'], // ← 必填在 object 根声明
},
}
铁律(写 DSH 宿主工具插件必守):
parameters用字段级required: true;output.schema用对象级required: [...]。- 凡是 patch 层新增/修改工具插件行,先做「加载期自检」再重启 DSH:写个 stub
ctx({ tools:{ register:(def)=>{}, effect:()=>{} }, inject:()=>{} })直接import插件模块 并调apply(ctx, cfg),确认不抛错再部署。--dump-config只能验证配置树,不会执行apply,验不出 schema 错误——本次事故就是--dump-config通过但启动崩溃的。 - 别照抄社区 TS 插件写法到
.mjs直连ctx.tools.register的场景——TS 插件的defineTool封装可能已把 schema 转成合法形态,直连 register 必须自己保证子集合规。 - 重启前备份/检查旧进程:
ps aux | grep "dsh web"确认当前 PID,kill 前清楚自己在干嘛; 重启后立即curl http://127.0.0.1:3080/+session.list双探针确认健康。
四、代码层面的改进(已经融入)
| 文件 | 改进 |
|---|---|
src/dingtalk-client.js | registerCallbackListener(TOPIC_ROBOT) + 手动 ACK;去掉对 registered 的依赖;keepAlive:false;2026-08-18 加三层连接守护(健康哨兵 30s / 兜底强制重建 15min / connect 超时保护 30s + SDK error/close 日志) |
src/dsh-client.js | assistantText 吃完整事件(data.message);非关键事件(chunk)不刷屏日志;seq 去重 + 丢弃日志 |
src/bridge.js | 三处诊断日志([recv]/[reply]);_rawText 只用于日志;replyTargets 双键路由(convId 和 dshSessionId);2026-08-18 加已归档会话过滤(/list、/use、/sched 均剔除 workspace.list 的 archivedSessionIds,避免把归档会话展示/投递) |
docs/DEPLOYMENT.md | SDK ≥ 2.1.5 版本要求说明 |
五、给未来项目的 Checklist
遇到"外部 IM/机器人 ↔ 内部系统"集成:
- 装对包、定对版本(同名包陷阱)
- 先
curl验证 SDK 请求的域名可达 - 确认消息走 EVENT 还是 CALLBACK 通道,用对订阅 API
- 连接状态字段不可靠时,以"实际收到消息"为准
- 收到消息后手动 ACK,防止重复投递
- 别盲目开应用层心跳
- 在数据源头(raw / 解析 / 业务决策)三层打诊断日志
- 保留一个"已知可用"的参考实现用于快速对比
- 协议数据结构先读源码/类型定义再写代码,别猜字段
- 记录 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或 macOSlaunchd。 - 群聊 @ 机器人目前是粗粒度判断(以
@开头或包含昵称),如需精确 at 解析可扩展msg.text.mentions。