@jeik/dingtalk-connector 0.8.27 → 0.8.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.en.md +40 -82
  3. package/README.md +50 -93
  4. package/dist/background-work-DkaSDqWk.mjs +53 -0
  5. package/dist/card-content-cache-C7-XHhHw.mjs +2 -0
  6. package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-DT_OYThh.mjs} +15 -1
  7. package/dist/chunk-upload-BKM1y4Ba.mjs +2 -0
  8. package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-CIU5G_ay.mjs} +1 -1
  9. package/dist/{common-DE0gd0Jg.mjs → common-BfIYs1yC.mjs} +3 -3
  10. package/dist/{common-B_A78n8u.mjs → common-C0Oaf0z-.mjs} +1 -1
  11. package/dist/connection-obtrPkj4.mjs +641 -0
  12. package/dist/entry-bundled.mjs +1 -1
  13. package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-BdcpfxEZ.mjs} +1 -1
  14. package/dist/gateway-methods-DQ6TwOAr.mjs +2 -0
  15. package/dist/index.mjs +2 -2
  16. package/dist/{logger-mZ9OSbmD.mjs → logger-CnBTcwyq.mjs} +16 -2
  17. package/dist/logger-D8H2IcYz.mjs +2 -0
  18. package/dist/{media-BqpUGCni.mjs → media-DDtX37T2.mjs} +1 -1
  19. package/dist/{media-DnQB2BrL.mjs → media-g8tJtpfU.mjs} +29 -20
  20. package/dist/{message-handler-DQZiKx3i.mjs → message-handler-BrO2i7tg.mjs} +251 -139
  21. package/dist/{messaging-WleKf54h.mjs → messaging-CK0Wz3Ze.mjs} +23 -21
  22. package/dist/{runtime-DzZo6Gvk.mjs → runtime-DbqGRyhI.mjs} +6 -5
  23. package/dist/{utils-Bjh4r_qS.mjs → utils-Bwz2Fls_.mjs} +1 -1
  24. package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
  25. package/docs/RELEASE_NOTES_V0.8.29.md +57 -0
  26. package/openclaw.plugin.json +9 -5
  27. package/package.json +2 -2
  28. package/src/config/schema.ts +2 -1
  29. package/src/core/connection.ts +504 -220
  30. package/src/core/message-handler.ts +197 -102
  31. package/src/reply-dispatcher.ts +244 -93
  32. package/src/services/media.ts +60 -27
  33. package/src/services/messaging/card-content-cache.ts +11 -6
  34. package/src/services/messaging/card.ts +2 -0
  35. package/src/services/messaging.ts +52 -38
  36. package/src/utils/background-work.ts +65 -0
  37. package/src/utils/logger.ts +23 -2
  38. package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
  39. package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
  40. package/dist/connection-fc6B4z4G.mjs +0 -423
  41. package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
  42. package/dist/logger-BmJkQkm1.mjs +0 -2
  43. /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * 职责:
5
5
  * - 管理单个钉钉账号的 WebSocket 连接
6
- * - 实现应用层心跳检测(10 秒间隔,90 秒超时)
6
+ * - 实现应用层心跳检测(10 秒间隔,20 秒无 pong 超时)
7
7
  * - 处理连接重连逻辑,带指数退避
8
8
  * - 消息去重(内置 Map,5 分钟 TTL)
9
9
  *
10
10
  * 核心特性:
11
11
  * - 关闭 SDK 内置 keepAlive,使用自定义心跳
12
+ * - connect 后等 OPEN + SYSTEM/REGISTERED 再报 ready(防首条消息丢失)
13
+ * - 重连后先挂 pong/message/close listener 再等就绪(防幽灵重连)
12
14
  * - 详细的消息接收日志(三阶段:接收、解析、处理)
13
15
  * - 连接统计和监控(每分钟输出)
14
16
  */
@@ -18,6 +20,10 @@ import type { ResolvedDingtalkAccount } from "../types/index.ts";
18
20
  import {
19
21
  checkAndMarkDingtalkMessage,
20
22
  } from "../utils/utils-legacy.ts";
23
+ import {
24
+ getActiveBackgroundWorkCount,
25
+ onBackgroundWorkCountChange,
26
+ } from "../utils/background-work.ts";
21
27
 
22
28
  // ============ 类型定义 ============
23
29
 
@@ -57,12 +63,61 @@ export type MonitorDingtalkAccountOpts = {
57
63
 
58
64
  /** 心跳间隔(毫秒) */
59
65
  const HEARTBEAT_INTERVAL = 10 * 1000; // 10 秒
60
- /** 超时阈值(毫秒) */
61
- const TIMEOUT_THRESHOLD = 20 * 1000; // 20 秒(2 次心跳未响应)
66
+ /**
67
+ * 无 pong 软超时:仅计「连续未确认」次数,不立刻 disconnect。
68
+ * 过去这里直接 doReconnect(),会在 socket 仍 OPEN 时拆掉活连接,
69
+ * 造成数秒空窗 → 用户连发 3~7 条消息钉钉推不到本进程(网关 UI 完全收不到)。
70
+ */
71
+ const SOFT_STALE_MS = 20 * 1000;
72
+ /** 连续软超时次数达到该值,且 socket 非 OPEN 或 ping 失败,才硬重连 */
73
+ const HARD_RECONNECT_AFTER_MISSES = 3;
74
+ /** 两次硬重连之间的最小间隔(除非 socket 已死) */
75
+ const MIN_RECONNECT_GAP_MS = 30 * 1000;
76
+ /**
77
+ * 消息处理期间刷新 lastSocketAvailableTime 的间隔。
78
+ * 必须明显小于 SOFT_STALE_MS。
79
+ */
80
+ const MESSAGE_PROCESSING_KEEPALIVE_MS = 15 * 1000; // 15 秒
62
81
  /** 基础退避时间(毫秒) */
63
82
  const BASE_BACKOFF_DELAY = 1000; // 1 秒
64
83
  /** 最大退避时间(毫秒) */
65
84
  const MAX_BACKOFF_DELAY = 30 * 1000; // 30 秒
85
+ /** 单次等待 OPEN + REGISTERED 的超时 */
86
+ const STREAM_READY_TIMEOUT_MS = 15_000;
87
+ /**
88
+ * REGISTERED 未就绪时最多完整重连次数(含首次)。
89
+ * 超时禁止 OPEN-only 假 ready,避免「显示已连接但收不到 CALLBACK」。
90
+ */
91
+ const REGISTERED_CONNECT_MAX_ATTEMPTS = 5;
92
+
93
+ // ============ 上游 SDK 噪音抑制(借鉴官方 #571/#536/#573)============
94
+ // dingtalk-stream 在 connect/disconnect 时直接 console.info 两条固定串,
95
+ // 绕过插件 logger,频繁重连时会刷屏造成「故障」误判。只过滤这两条精确匹配。
96
+ let _streamNoiseSilenced = false;
97
+ function silenceDingtalkStreamConsoleNoise(): void {
98
+ if (_streamNoiseSilenced) return;
99
+ _streamNoiseSilenced = true;
100
+ const origConsoleInfo = console.info.bind(console);
101
+ console.info = (...args: any[]) => {
102
+ const first = args[0];
103
+ if (typeof first === "string") {
104
+ if (first === "Disconnecting.") return;
105
+ if (/^\[[^\]]+\] connect success$/.test(first)) return;
106
+ }
107
+ return origConsoleInfo(...args);
108
+ };
109
+ }
110
+
111
+ let _connectionNoticePrinted = false;
112
+ function printConnectionNoticeOnce(): void {
113
+ if (_connectionNoticePrinted) return;
114
+ _connectionNoticePrinted = true;
115
+ console.log(
116
+ "[dingtalk-connector] ℹ️ 上游 dingtalk-stream 噪音已过滤;" +
117
+ "OPEN 成功即可上线,REGISTERED 尽量等待;若环境不推 REGISTERED,首条消息会确认订阅。" +
118
+ "正常运行不应出现 ≤30s 周期性硬重连。",
119
+ );
120
+ }
66
121
 
67
122
  // ============ 监控账号 ============
68
123
 
@@ -72,12 +127,17 @@ export async function monitorSingleAccount(
72
127
  const { cfg, account, runtime, abortSignal, messageHandler, onStatusChange } = opts;
73
128
  const { accountId } = account;
74
129
 
130
+ // 在动态 import dingtalk-stream 之前抑制其 console.info 噪音
131
+ silenceDingtalkStreamConsoleNoise();
132
+
75
133
  // 保存 cfg 以便传递给 messageHandler
76
134
  const clawdbotConfig = cfg;
77
135
  const log = runtime?.log;
78
136
 
79
137
  // 创建 debug logger(仅在 debug 模式下输出 info/debug 日志)
80
- const { createLoggerFromConfig } = await import('../utils/logger');
138
+ // 配置 debug: true 时同步打开图片/引用/队列详细诊断(无需环境变量)
139
+ const { createLoggerFromConfig, setDingtalkDebug } = await import('../utils/logger');
140
+ if (account.config?.debug) setDingtalkDebug(true);
81
141
  const logger = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
82
142
 
83
143
  // 验证凭据是否存在
@@ -158,53 +218,68 @@ export async function monitorSingleAccount(
158
218
  let reconnectAttempts = 0;
159
219
  let keepAliveTimer: NodeJS.Timeout | null = null;
160
220
  let isStopped = false;
221
+ /** 连续 keepAlive 周期内未收到 pong / 未刷新的次数 */
222
+ let consecutiveStaleMisses = 0;
223
+ let lastHardReconnectAt = 0;
161
224
 
162
225
  // ============ 消息处理活跃标记 ============
163
- // 用于在消息处理期间防止心跳超时触发重连
226
+ // 覆盖「WS 回调短暂入队」+「sessionQueues 后台 AI 全长」(见 background-work.ts)
227
+ // 仅覆盖 WS 回调会导致:入队后立刻 End → 503/长任务期间幽灵重连 → 后续消息无反应
228
+ let wsCallbackActive = false;
164
229
  let activeMessageProcessing = false;
165
230
  let messageProcessingKeepAliveTimer: NodeJS.Timeout | null = null;
166
-
167
- /**
168
- * 标记消息处理开始,启动定期更新机制
169
- * 在消息处理期间,每 30 秒更新一次 lastSocketAvailableTime
170
- * 防止长时间处理(如复杂的 AI 任务)触发心跳超时
171
- */
172
- function markMessageProcessingStart() {
173
- activeMessageProcessing = true;
174
- lastSocketAvailableTime = Date.now();
175
-
176
- // 清理旧的定时器(如果存在)
177
- if (messageProcessingKeepAliveTimer) {
178
- clearInterval(messageProcessingKeepAliveTimer);
179
- }
180
-
181
- // 每 30 秒更新一次,确保不会触发 90 秒超时
231
+ let unsubscribeBackgroundWork: (() => void) | null = null;
232
+
233
+ function ensureProcessingKeepAliveTimer(): void {
234
+ if (messageProcessingKeepAliveTimer) return;
182
235
  messageProcessingKeepAliveTimer = setInterval(() => {
183
236
  if (activeMessageProcessing) {
184
237
  lastSocketAvailableTime = Date.now();
185
- logger.debug(`📝 消息处理中,更新 socket 可用时间`);
238
+ logger.debug(
239
+ `📝 消息处理中,更新 socket 可用时间 (ws=${wsCallbackActive} bg=${getActiveBackgroundWorkCount()})`,
240
+ );
186
241
  }
187
- }, 30 * 1000); // 30 秒间隔
188
-
189
- logger.debug(`📝 消息处理开始,启动活跃标记定时器`);
242
+ }, MESSAGE_PROCESSING_KEEPALIVE_MS);
190
243
  }
191
-
192
- /**
193
- * 标记消息处理结束,停止定期更新机制
194
- */
195
- function markMessageProcessingEnd() {
196
- activeMessageProcessing = false;
197
-
244
+
245
+ function clearProcessingKeepAliveTimer(): void {
198
246
  if (messageProcessingKeepAliveTimer) {
199
247
  clearInterval(messageProcessingKeepAliveTimer);
200
248
  messageProcessingKeepAliveTimer = null;
201
249
  }
202
-
203
- // 最后更新一次时间
250
+ }
251
+
252
+ /** 根据 WS 入队中 / 后台任务数 合成 activeMessageProcessing */
253
+ function refreshProcessingActive(reason: string): void {
254
+ const bg = getActiveBackgroundWorkCount();
255
+ const next = wsCallbackActive || bg > 0;
256
+ activeMessageProcessing = next;
204
257
  lastSocketAvailableTime = Date.now();
205
- logger.debug(`✅ 消息处理结束,清理活跃标记定时器`);
258
+ if (next) {
259
+ ensureProcessingKeepAliveTimer();
260
+ logger.debug(
261
+ `📝 处理活跃 reason=${reason} ws=${wsCallbackActive} bg=${bg}`,
262
+ );
263
+ } else {
264
+ clearProcessingKeepAliveTimer();
265
+ logger.debug(`✅ 处理空闲 reason=${reason}`);
266
+ }
267
+ }
268
+
269
+ function markMessageProcessingStart() {
270
+ wsCallbackActive = true;
271
+ refreshProcessingActive("ws-start");
206
272
  }
207
273
 
274
+ function markMessageProcessingEnd() {
275
+ wsCallbackActive = false;
276
+ refreshProcessingActive("ws-end");
277
+ }
278
+
279
+ unsubscribeBackgroundWork = onBackgroundWorkCountChange(() => {
280
+ refreshProcessingActive("bg-count");
281
+ });
282
+
208
283
  // ============ 辅助函数 ============
209
284
 
210
285
  /** 计算指数退避延迟(带抖动) */
@@ -214,6 +289,198 @@ export async function monitorSingleAccount(
214
289
  return Math.min(exponentialDelay + jitter, MAX_BACKOFF_DELAY);
215
290
  }
216
291
 
292
+ /**
293
+ * 等待钉钉 Stream 可服务。
294
+ *
295
+ * - OPEN:必须成功,否则 throw
296
+ * - REGISTERED:尽量等待;超时则 **降级 OPEN 上线**(不 throw)
297
+ *
298
+ * 原因(生产日志):部分环境 socket 已 OPEN 且 CALLBACK 能收消息,
299
+ * 但长期收不到 SYSTEM/REGISTERED 或 client.registered 不被置 true。
300
+ * 若硬性失败会触发网关 auto-restart 风暴,而旧连接/半连接其实仍在处理消息。
301
+ */
302
+ async function waitForStreamReady(timeoutMs = STREAM_READY_TIMEOUT_MS): Promise<{
303
+ registered: boolean;
304
+ }> {
305
+ const started = Date.now();
306
+
307
+ // 1) 等 WebSocket OPEN
308
+ if ((client as any).socket?.readyState !== 1) {
309
+ const opened = await new Promise<boolean>((resolve) => {
310
+ const socket = (client as any).socket;
311
+ if (!socket) {
312
+ resolve(false);
313
+ return;
314
+ }
315
+ if (socket.readyState === 1) {
316
+ resolve(true);
317
+ return;
318
+ }
319
+ const remain = Math.max(1_000, timeoutMs - (Date.now() - started));
320
+ let settled = false;
321
+ const finish = (ok: boolean) => {
322
+ if (settled) return;
323
+ settled = true;
324
+ clearTimeout(timer);
325
+ socket.removeListener?.("open", onOpen);
326
+ socket.removeListener?.("error", onError);
327
+ resolve(ok);
328
+ };
329
+ const timer = setTimeout(() => finish(false), remain);
330
+ const onOpen = () => finish(true);
331
+ const onError = () => finish(false);
332
+ socket.once("open", onOpen);
333
+ socket.once("error", onError);
334
+ });
335
+ if (!opened) {
336
+ throw new Error(`WebSocket OPEN 超时(${timeoutMs}ms)`);
337
+ }
338
+ }
339
+
340
+ // 2) 等服务端 REGISTERED(可选;超时降级)
341
+ if ((client as any).registered === true) {
342
+ logger.info(
343
+ `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
344
+ );
345
+ return { registered: true };
346
+ }
347
+
348
+ const registered = await new Promise<boolean>((resolve) => {
349
+ const socket = (client as any).socket;
350
+ if (!socket) {
351
+ resolve(false);
352
+ return;
353
+ }
354
+ const remain = Math.max(1_000, timeoutMs - (Date.now() - started));
355
+ let settled = false;
356
+ const finish = (ok: boolean) => {
357
+ if (settled) return;
358
+ settled = true;
359
+ clearTimeout(timer);
360
+ clearInterval(poll);
361
+ socket.removeListener?.("message", onMessage);
362
+ resolve(ok);
363
+ };
364
+ const timer = setTimeout(() => finish(false), remain);
365
+
366
+ const onMessage = (data: any) => {
367
+ try {
368
+ const raw = typeof data === "string" ? data : data?.toString?.() ?? String(data);
369
+ const msg = JSON.parse(raw);
370
+ if (msg?.type === "SYSTEM" && msg?.headers?.topic === "REGISTERED") {
371
+ finish(true);
372
+ return;
373
+ }
374
+ } catch {
375
+ // ignore
376
+ }
377
+ if ((client as any).registered === true) {
378
+ finish(true);
379
+ }
380
+ };
381
+
382
+ const poll = setInterval(() => {
383
+ if ((client as any).registered === true) {
384
+ finish(true);
385
+ }
386
+ }, 100);
387
+
388
+ socket.on("message", onMessage);
389
+ });
390
+
391
+ if (registered || (client as any).registered === true) {
392
+ logger.info(
393
+ `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
394
+ );
395
+ return { registered: true };
396
+ }
397
+
398
+ // 降级:OPEN 成功即可上线。许多环境不推 REGISTERED 或检测不到,但仍可收 CALLBACK。
399
+ logger.warn(
400
+ `⚠️ 未观察到 SYSTEM/REGISTERED(${timeoutMs}ms),socket 已 OPEN → 降级上线。` +
401
+ ` 收到首条机器人消息后会记为「订阅 empirically 生效」。`,
402
+ );
403
+ return { registered: false };
404
+ }
405
+
406
+ /**
407
+ * connect 之后、wait ready 之前挂上 pong/message/close。
408
+ * 官方 #566:若在 connect 前 setup,socket 为 undefined,listener 静默 no-op →
409
+ * pong 无人接 → lastSocketAvailableTime 不刷新 → TIMEOUT 幽灵重连。
410
+ * 必须在 wait OPEN 之前挂好,否则等待窗内的 pong 也会丢。
411
+ */
412
+ function attachSocketLifecycleListeners(): void {
413
+ setupPongListener();
414
+ setupMessageListener();
415
+ setupCloseListener();
416
+ }
417
+
418
+ /**
419
+ * 单次:connect + 挂 listener + 等 OPEN(REGISTERED 可选)。
420
+ * 仅 OPEN 失败 throw;REGISTERED 超时则降级成功。
421
+ */
422
+ async function connectAndWaitReady(): Promise<{ registered: boolean }> {
423
+ await client.connect();
424
+ attachSocketLifecycleListeners();
425
+ return await waitForStreamReady(STREAM_READY_TIMEOUT_MS);
426
+ }
427
+
428
+ /**
429
+ * 建立可服务连接。
430
+ * - 优先拿到 REGISTERED
431
+ * - OPEN 成功即可上线(避免环境不推 REGISTERED 时 restart 风暴)
432
+ * - 仅在 OPEN 都失败时多轮重试
433
+ */
434
+ async function ensureRegisteredConnection(
435
+ maxAttempts = REGISTERED_CONNECT_MAX_ATTEMPTS,
436
+ ): Promise<void> {
437
+ let lastError: Error | undefined;
438
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
439
+ if (isStopped) {
440
+ throw new Error("连接已停止,中止 Stream 建立");
441
+ }
442
+ try {
443
+ if (attempt > 1) {
444
+ logger.warn(
445
+ `🔄 Stream 建立重试 ${attempt}/${maxAttempts}…`,
446
+ );
447
+ try {
448
+ if ((client as any).socket) {
449
+ await client.disconnect();
450
+ }
451
+ } catch (discErr: any) {
452
+ logger.debug(`断开旧连接: ${discErr?.message || discErr}`);
453
+ }
454
+ const delay = Math.min(1000 * attempt, 5_000);
455
+ await new Promise((r) => setTimeout(r, delay));
456
+ } else {
457
+ logger.info(
458
+ `⏳ 建立 Stream(OPEN 必须;REGISTERED 尽量等待 ${STREAM_READY_TIMEOUT_MS}ms)…`,
459
+ );
460
+ }
461
+
462
+ const ready = await connectAndWaitReady();
463
+ noteSocketAlive(ready.registered ? "registered-ok" : "open-degraded");
464
+ connectionEstablishedTime = Date.now();
465
+ logger.info(
466
+ `✅ Stream 可服务(attempt ${attempt}/${maxAttempts}, ` +
467
+ `registered=${ready.registered || Boolean((client as any).registered)}, ` +
468
+ `socket=${(client as any).socket?.readyState})`,
469
+ );
470
+ return;
471
+ } catch (err: any) {
472
+ lastError = err instanceof Error ? err : new Error(String(err));
473
+ logger.warn(
474
+ `⚠️ Stream 建立失败 attempt=${attempt}/${maxAttempts}: ${lastError.message}`,
475
+ );
476
+ }
477
+ }
478
+ throw new Error(
479
+ `钉钉 Stream 在 ${maxAttempts} 次尝试后仍无法 OPEN。` +
480
+ ` 最后错误: ${lastError?.message || "unknown"}`,
481
+ );
482
+ }
483
+
217
484
  /** 统一重连函数,带指数退避(无限重连) */
218
485
  async function doReconnect(immediate = false) {
219
486
  if (isReconnecting || isStopped) {
@@ -233,68 +500,31 @@ export async function monitorSingleAccount(
233
500
  }
234
501
 
235
502
  try {
236
- // 1. 先断开旧连接(检查 WebSocket 状态)
503
+ // 1. 先断开旧连接
237
504
  if ((client as any).socket?.readyState === 1 || (client as any).socket?.readyState === 3) {
238
505
  await client.disconnect();
239
506
  logger.info(`已断开旧连接`);
240
507
  }
241
508
 
242
- // 2. 重新建立连接
243
- await client.connect();
244
-
245
- // 3. 等待连接真正建立(监听 open 事件,最多等待 10 秒)
246
- const connectionEstablished = await new Promise<boolean>((resolve) => {
247
- const timeout = setTimeout(() => {
248
- resolve(false);
249
- }, 10_000); // 10 秒超时
509
+ // 2. 重连直到 REGISTERED(内部含 connect + listener + wait,失败会多轮)
510
+ await ensureRegisteredConnection(REGISTERED_CONNECT_MAX_ATTEMPTS);
250
511
 
251
- // 如果已经是 OPEN 状态,直接返回
252
- if ((client as any).socket?.readyState === 1) {
253
- clearTimeout(timeout);
254
- resolve(true);
255
- return;
256
- }
257
-
258
- // 否则监听 open 事件
259
- const onOpen = () => {
260
- clearTimeout(timeout);
261
- (client as any).socket?.removeListener('open', onOpen);
262
- (client as any).socket?.removeListener('error', onError);
263
- resolve(true);
264
- };
512
+ // 3. 重置计时与状态
513
+ noteSocketAlive("reconnect-ok");
514
+ connectionEstablishedTime = Date.now();
515
+ reconnectAttempts = 0;
516
+ lastHardReconnectAt = Date.now();
265
517
 
266
- const onError = (err: any) => {
267
- clearTimeout(timeout);
268
- (client as any).socket?.removeListener('open', onOpen);
269
- (client as any).socket?.removeListener('error', onError);
270
- logger.warn(`连接建立失败: ${err.message}`);
271
- resolve(false);
272
- };
273
-
274
- (client as any).socket?.once('open', onOpen);
275
- (client as any).socket?.once('error', onError);
276
- });
277
-
278
- if (!connectionEstablished) {
279
- throw new Error(`连接建立超时或失败`);
280
- }
281
-
282
- // 4. 重置 socket 可用时间、连接建立时间和重连计数
283
- lastSocketAvailableTime = Date.now();
284
- connectionEstablishedTime = Date.now(); // 重置连接建立时间
285
- reconnectAttempts = 0; // 重连成功,重置计数
286
-
287
- // 重连成功,向框架报告 connected: true
518
+ // 4. 真正可收 CALLBACK 后再报 connected
288
519
  onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
289
520
 
290
- // 重新注册 socket 事件监听器(新 socket 需要新的 listener)
291
- setupPongListener();
292
- setupMessageListener();
293
- setupCloseListener();
294
-
295
- logger.info(`✅ 重连成功 (socket 状态=${(client as any).socket?.readyState})`);
521
+ logger.info(
522
+ `✅ 重连成功 (socket=${(client as any).socket?.readyState}, registered=${Boolean((client as any).registered)})`,
523
+ );
296
524
  } catch (err: any) {
297
525
  reconnectAttempts++;
526
+ // 未 REGISTERED:保持 connected=false,避免 UI 假在线
527
+ onStatusChange?.({ connected: false });
298
528
  logger.error(
299
529
  `重连失败:${err.message} (尝试 ${reconnectAttempts})`,
300
530
  );
@@ -308,10 +538,17 @@ export async function monitorSingleAccount(
308
538
  function setupPongListener() {
309
539
  (client as any).socket?.on("pong", () => {
310
540
  lastSocketAvailableTime = Date.now();
541
+ consecutiveStaleMisses = 0;
311
542
  logger.debug(`收到 PONG 响应`);
312
543
  });
313
544
  }
314
545
 
546
+ function noteSocketAlive(reason: string): void {
547
+ lastSocketAvailableTime = Date.now();
548
+ consecutiveStaleMisses = 0;
549
+ logger.debug(`socket alive reason=${reason}`);
550
+ }
551
+
315
552
  /** 监听 WebSocket message 事件,收到 disconnect 消息时立即触发重连 */
316
553
  function setupMessageListener() {
317
554
  (client as any).socket?.on("message", (data: any) => {
@@ -355,12 +592,54 @@ export async function monitorSingleAccount(
355
592
  }
356
593
 
357
594
  /**
358
- * 启动 keepAlive 机制(单定时器 + 指数退避)
359
- *
360
- * 业界最佳实践:
361
- * - 单定时器:每 10 秒检查一次,同时完成心跳和超时检测
362
- * - 使用 WebSocket 原生 Ping
363
- * - 指数退避重连:避免雪崩效应
595
+ * 请求硬重连(拆 socket)。
596
+ * - socket 仍 OPEN:先补 PING,连续 misses 未达阈值则绝不拆连接(防空窗吞消息)
597
+ * - socket 已死:立即重连
598
+ * - 冷却:非死连接时距上次硬重连 < MIN_RECONNECT_GAP_MS 则跳过
599
+ */
600
+ async function requestHardReconnect(reason: string, socketDead = false): Promise<void> {
601
+ if (isReconnecting || isStopped) return;
602
+
603
+ const socketState = (client as any).socket?.readyState;
604
+
605
+ // 活连接:优先补 ping,未达连续 miss 阈值不拆
606
+ if (!socketDead && socketState === 1) {
607
+ try {
608
+ (client as any).socket?.ping();
609
+ logger.warn(
610
+ `⚠️ 软超时但 socket 仍 OPEN (reason=${reason}, misses=${consecutiveStaleMisses}/${HARD_RECONNECT_AFTER_MISSES}),仅补 PING`,
611
+ );
612
+ } catch (err: any) {
613
+ logger.warn(`补 PING 失败: ${err.message}`);
614
+ consecutiveStaleMisses += 1;
615
+ }
616
+ if (consecutiveStaleMisses < HARD_RECONNECT_AFTER_MISSES) {
617
+ return;
618
+ }
619
+ }
620
+
621
+ // 冷却(socket 已死时跳过冷却)
622
+ if (!socketDead) {
623
+ const gap = Date.now() - lastHardReconnectAt;
624
+ if (lastHardReconnectAt > 0 && gap < MIN_RECONNECT_GAP_MS) {
625
+ logger.warn(
626
+ `⚠️ 跳过硬重连:距上次仅 ${Math.round(gap / 1000)}s < ${MIN_RECONNECT_GAP_MS / 1000}s 冷却 (reason=${reason})`,
627
+ );
628
+ return;
629
+ }
630
+ }
631
+
632
+ lastHardReconnectAt = Date.now();
633
+ consecutiveStaleMisses = 0;
634
+ logger.info(`🔄 硬重连 reason=${reason} socket=${socketState}`);
635
+ await doReconnect(socketDead);
636
+ }
637
+
638
+ /**
639
+ * 启动 keepAlive:
640
+ * - 10s 发 ping;pong 刷新 lastSocketAvailableTime
641
+ * - 软超时只计数 + 补 ping,达到 HARD_RECONNECT_AFTER_MISSES 且 socket 异常才拆连接
642
+ * - 处理中任务期间绝不硬重连
364
643
  */
365
644
  function startKeepAlive(): () => void {
366
645
  logger.debug(
@@ -375,24 +654,16 @@ export async function monitorSingleAccount(
375
654
 
376
655
  try {
377
656
  const elapsed = Date.now() - lastSocketAvailableTime;
378
-
379
- // 【超时检测】超过 90 秒未确认 socket 可用,触发重连
380
- if (elapsed > TIMEOUT_THRESHOLD) {
381
- logger.info(
382
- `⚠️ 超时检测:已 ${Math.round(elapsed / 1000)} 秒未确认 socket 可用,触发重连...`,
383
- );
384
- await doReconnect();
385
- return;
386
- }
387
-
388
- // 【心跳检测】检查 socket 状态
389
657
  const socketState = (client as any).socket?.readyState;
390
658
  const timeSinceConnection = Date.now() - connectionEstablishedTime;
659
+
391
660
  logger.debug(
392
- `心跳检测:socket 状态=${socketState}, elapsed=${Math.round(elapsed / 1000)}s, 连接已建立=${Math.round(timeSinceConnection / 1000)}s`,
661
+ `心跳检测:socket=${socketState}, elapsed=${Math.round(elapsed / 1000)}s, ` +
662
+ `misses=${consecutiveStaleMisses}, processing=${activeMessageProcessing}, ` +
663
+ `connectedFor=${Math.round(timeSinceConnection / 1000)}s`,
393
664
  );
394
665
 
395
- // 给新建立的连接 15 秒宽限期,避免在连接建立初期就触发重连
666
+ // socket 非 OPEN:宽限期后硬重连
396
667
  if (socketState !== 1) {
397
668
  if (timeSinceConnection < 15_000) {
398
669
  logger.debug(
@@ -400,31 +671,55 @@ export async function monitorSingleAccount(
400
671
  );
401
672
  return;
402
673
  }
403
-
404
- logger.info(
405
- `⚠️ 心跳检测:socket 状态=${socketState},触发重连...`,
674
+ if (activeMessageProcessing) {
675
+ logger.warn(
676
+ `⚠️ socket 非 OPEN 但消息处理中,暂缓硬重连 state=${socketState}`,
677
+ );
678
+ return;
679
+ }
680
+ await requestHardReconnect(`socket-dead state=${socketState}`, true);
681
+ return;
682
+ }
683
+
684
+ // 软超时:无 pong/刷新超过 SOFT_STALE_MS
685
+ if (elapsed > SOFT_STALE_MS) {
686
+ consecutiveStaleMisses += 1;
687
+ if (activeMessageProcessing) {
688
+ noteSocketAlive("processing-hold");
689
+ logger.warn(
690
+ `⚠️ 软超时但处理中,刷新活跃时间不重连 (elapsed=${Math.round(elapsed / 1000)}s)`,
691
+ );
692
+ return;
693
+ }
694
+ logger.warn(
695
+ `⚠️ 软超时 misses=${consecutiveStaleMisses}/${HARD_RECONNECT_AFTER_MISSES} ` +
696
+ `elapsed=${Math.round(elapsed / 1000)}s socket=OPEN`,
406
697
  );
407
- await doReconnect(true); // 立即重连,不退避
698
+ // OPEN 时优先补 ping;达到次数再考虑硬重连
699
+ await requestHardReconnect("stale-pong", false);
408
700
  return;
409
701
  }
410
702
 
411
- // 【发送原生 Ping】仅发送,不刷新时间戳;
412
- // 只有收到 pong 响应时才更新 lastSocketAvailableTime(见 setupPongListener)
703
+ consecutiveStaleMisses = 0;
704
+
705
+ // 正常心跳 ping(pong 到了才刷新时间戳)
413
706
  try {
414
707
  (client as any).socket?.ping();
415
708
  logger.debug(`💓 发送 PING 心跳成功`);
416
709
  } catch (err: any) {
417
710
  logger.warn(`发送 PING 失败:${err.message}`);
418
- // 发送失败也计入超时
711
+ consecutiveStaleMisses += 1;
712
+ if (consecutiveStaleMisses >= HARD_RECONNECT_AFTER_MISSES) {
713
+ await requestHardReconnect("ping-failed", true);
714
+ }
419
715
  }
420
716
  } catch (err: any) {
421
717
  logger.error(`keepAlive 检测失败:${err.message}`);
422
718
  }
423
- }, HEARTBEAT_INTERVAL); // 每 10 秒检测一次
719
+ }, HEARTBEAT_INTERVAL);
424
720
 
425
721
  logger.debug(`✅ keepAlive 定时器已启动`);
426
722
 
427
- // 返回清理函数
428
723
  return () => {
429
724
  if (keepAliveTimer) clearInterval(keepAliveTimer);
430
725
  keepAliveTimer = null;
@@ -441,10 +736,11 @@ export async function monitorSingleAccount(
441
736
  keepAliveTimer = null;
442
737
 
443
738
  // 清理消息处理活跃标记定时器
444
- if (messageProcessingKeepAliveTimer) {
445
- clearInterval(messageProcessingKeepAliveTimer);
446
- messageProcessingKeepAliveTimer = null;
447
- }
739
+ clearProcessingKeepAliveTimer();
740
+ wsCallbackActive = false;
741
+ activeMessageProcessing = false;
742
+ unsubscribeBackgroundWork?.();
743
+ unsubscribeBackgroundWork = null;
448
744
 
449
745
  // 清理事件监听器
450
746
  if ((client as any).socket) {
@@ -492,113 +788,92 @@ export async function monitorSingleAccount(
492
788
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
493
789
  receivedCount++;
494
790
  lastMessageTime = Date.now();
791
+ // 能收到 CALLBACK 说明链路活着——立刻刷新,打断「软超时→硬重连」误判
792
+ noteSocketAlive("inbound-callback");
793
+ // 部分环境收不到 SYSTEM/REGISTERED,但 CALLBACK 已通:记为 empirically 订阅成功
794
+ if ((client as any).registered !== true) {
795
+ (client as any).registered = true;
796
+ logger.info(
797
+ `✅ 收到机器人 CALLBACK,视为订阅已生效(此前可能未观察到 SYSTEM/REGISTERED)`,
798
+ );
799
+ }
495
800
 
496
801
  // 收到消息时,向框架报告 lastInboundAt(用于 UI 显示 "Last inbound")
497
802
  onStatusChange?.({ lastInboundAt: Date.now() });
498
803
 
499
804
  const messageId = res.headers?.messageId;
500
- const timestamp = new Date().toISOString();
501
-
502
- // ===== 第一步:记录原始消息接收 =====
503
- logger.info(`\n========== 收到新消息 ==========`);
504
- logger.info(`时间:${timestamp}`);
505
- logger.info(`MessageId: ${messageId || "N/A"}`);
506
- logger.info(`Headers: ${JSON.stringify(res.headers || {})}`);
507
- logger.info(`Data 长度:${res.data?.length || 0} 字符`);
508
-
509
- // 立即确认回调
510
- if (messageId) {
511
- (client as any).socketCallBackResponse(messageId, { success: true });
512
- logger.info(`✅ 已立即确认回调:messageId=${messageId}`);
513
- } else {
514
- logger.warn(`⚠️ 警告:消息没有 messageId`);
515
- }
516
805
 
517
- // 协议层去重(headers.messageId):拦截同一次投递的重复回调
518
- // 注意:业务层去重(data.msgId)在 JSON 解析后执行,两层合并在 checkAndMarkDingtalkMessage 中
519
- // 此处仅做协议层的快速预检,避免不必要的 JSON 解析
520
- if (messageId && checkAndMarkDingtalkMessage(accountId, messageId, undefined)) {
521
- processedCount++;
522
- logger.warn(`⚠️ 检测到重复消息(协议层),跳过处理:messageId=${messageId} (${processedCount}/${receivedCount})`);
523
- logger.info(`========== 消息处理结束(重复) ==========\n`);
524
- return;
525
- }
806
+ // 入队成功后再 ACK;失败不 ACK 以便钉钉重投
807
+ let acked = false;
808
+ const ackMessage = (why: string) => {
809
+ if (acked || !messageId) return;
810
+ try {
811
+ (client as any).socketCallBackResponse(messageId, { success: true });
812
+ acked = true;
813
+ logger.debug(`ACK messageId=${messageId} (${why})`);
814
+ } catch (ackErr: any) {
815
+ logger.warn(`确认回调失败: ${ackErr?.message || ackErr}`);
816
+ }
817
+ };
526
818
 
527
- // 异步处理消息
528
- // ✅ 标记消息处理开始,防止长时间处理触发心跳超时
529
819
  markMessageProcessingStart();
530
-
820
+
531
821
  try {
532
- // 解析消息数据
533
822
  let data;
534
823
  try {
535
824
  data = JSON.parse(res.data);
536
825
  } catch (parseError: any) {
537
- logger.error('Failed to parse response data as JSON:', {
538
- error: parseError instanceof Error ? parseError.message : String(parseError),
539
- rawData: typeof res.data === 'string'
540
- ? res.data.substring(0, 500) // 只记录前 500 字符
541
- : res.data,
542
- dataType: typeof res.data,
543
- });
826
+ logger.error(
827
+ `JSON 解析失败: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
828
+ );
544
829
  throw new Error(
545
- `Invalid JSON response from DingTalk API. ` +
546
- `Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. ` +
547
- `Raw data (first 100 chars): ${String(res.data).substring(0, 100)}`
830
+ `Invalid JSON from DingTalk: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
548
831
  );
549
832
  }
550
833
 
551
- // ===== 第二步:记录解析后的消息详情 =====
552
- logger.info(`\n----- 消息详情 -----`);
553
- logger.info(`消息类型:${data.msgtype || "unknown"}`);
554
- logger.info(
555
- `会话类型:${data.conversationType === "1" ? "DM (单聊)" : data.conversationType === "2" ? "Group (群聊)" : data.conversationType}`,
556
- );
557
- logger.info(
558
- `发送者:${data.senderNick || "unknown"} (${data.senderStaffId || data.senderId || "unknown"})`,
559
- );
560
- logger.info(`会话 ID: ${data.conversationId || "N/A"}`);
561
- logger.info(`消息 ID: ${data.msgId || "N/A"}`);
562
- logger.info(
563
- `SessionWebhook: ${data.sessionWebhook ? "已提供" : "未提供"}`,
564
- );
834
+ const businessMsgId = data.msgId;
835
+ const convType =
836
+ data.conversationType === "1"
837
+ ? "DM"
838
+ : data.conversationType === "2"
839
+ ? "Group"
840
+ : String(data.conversationType ?? "?");
841
+ const preview =
842
+ (data.text?.content || "").trim().slice(0, 80) ||
843
+ data.msgtype ||
844
+ "(no text)";
845
+
846
+ // 一行摘要(debug 模式才打 Headers/全字段)
565
847
  logger.info(
566
- `RobotCode: ${data.robotCode || account.config?.clientId || "N/A"}`,
848
+ `📩 入站 ${convType} from=${data.senderNick || data.senderStaffId || data.senderId || "?"} ` +
849
+ `msg=${businessMsgId || messageId || "-"} preview=${JSON.stringify(preview)}`,
567
850
  );
568
- // 暴露当前机器人的加密身份(chatbotUserId / chatbotCorpId)
569
- // 用途:多机器人协作时,把这两个值配进 openclaw.json 对应 account 的元数据下,
570
- // 让其他 agent 能在群消息里通过 atDingtalkIds 写上对方的 chatbotUserId 触发 @ UI。
571
- // 注意:这里**不**经过 logger.info(受 debug 开关控制),直接 console.log,
572
- // 否则首次配置的用户在未开 debug 时永远看不到这两个 ID,无法完成多机器人协作配置。
851
+ logger.debug(`Headers: ${JSON.stringify(res.headers || {})}`);
852
+ logger.debug(`字段: ${Object.keys(data).join(",")}`);
853
+
854
+ // BotIdentity 每个进程每个账号只打一次(配置多机器人时用)
573
855
  if (data.chatbotUserId || data.chatbotCorpId) {
574
- console.log(
575
- `[DingTalk:${accountId}] [BotIdentity] accountId=${accountId} chatbotUserId=${data.chatbotUserId || "N/A"} chatbotCorpId=${data.chatbotCorpId || "N/A"}`,
576
- );
856
+ const key = `${accountId}:${data.chatbotUserId || ""}:${data.chatbotCorpId || ""}`;
857
+ if (!(globalThis as any).__dingtalkBotIdentityLogged) {
858
+ (globalThis as any).__dingtalkBotIdentityLogged = new Set<string>();
859
+ }
860
+ const seen = (globalThis as any).__dingtalkBotIdentityLogged as Set<string>;
861
+ if (!seen.has(key)) {
862
+ seen.add(key);
863
+ console.log(
864
+ `[DingTalk:${accountId}] [BotIdentity] chatbotUserId=${data.chatbotUserId || "N/A"} chatbotCorpId=${data.chatbotCorpId || "N/A"}`,
865
+ );
866
+ }
577
867
  }
578
868
 
579
- // ===== 业务层去重:补充 data.msgId,防止钉钉服务端重发穿透 =====
580
- // 协议层已标记了 headers.messageId,此处再补充标记 data.msgId。
581
- // 钉钉重发时 headers.messageId 是新值,但 data.msgId 不变,
582
- // checkAndMarkDingtalkMessage 会命中 data.msgId 并返回 true 拦截重发。
583
- const businessMsgId = data.msgId;
584
-
585
- // 记录消息内容(简化版,避免过长)
586
- let contentPreview = "N/A";
587
- if (data.text?.content) {
588
- contentPreview =
589
- data.text.content.length > 100
590
- ? data.text.content.substring(0, 100) + "..."
591
- : data.text.content;
592
- } else if (data.content) {
593
- contentPreview =
594
- JSON.stringify(data.content).substring(0, 100) + "...";
869
+ if (checkAndMarkDingtalkMessage(accountId, messageId, businessMsgId)) {
870
+ processedCount++;
871
+ ackMessage("duplicate");
872
+ logger.warn(
873
+ `⚠️ 重复消息已跳过 protocol=${messageId || "-"} business=${businessMsgId || "-"}`,
874
+ );
875
+ return;
595
876
  }
596
- logger.info(`消息内容预览:${contentPreview}`);
597
- logger.info(`完整数据字段:${Object.keys(data).join(", ")}`);
598
- logger.info(`----- 消息详情结束 -----\n`);
599
-
600
- // ===== 第三步:开始处理消息 =====
601
- logger.info(`🚀 开始处理消息...`);
602
877
 
603
878
  await messageHandler({
604
879
  accountId,
@@ -610,21 +885,23 @@ export async function monitorSingleAccount(
610
885
  cfg: clawdbotConfig,
611
886
  });
612
887
 
888
+ ackMessage("enqueued");
889
+ noteSocketAlive("after-enqueue");
890
+
613
891
  processedCount++;
614
- logger.info(`✅ 消息处理完成 (${processedCount}/${receivedCount})`);
615
- logger.info(`========== 消息处理结束(成功) ==========\n`);
892
+ logger.info(`✅ 入队完成 (${processedCount}/${receivedCount})`);
616
893
  } catch (error: any) {
617
894
  processedCount++;
618
- const errorMsg = `❌ 处理消息异常 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`;
619
- const errorStack = error?.stack || "无堆栈信息";
620
-
621
- // 使用 logger 记录错误信息
622
- logger.error(errorMsg);
623
- logger.error(`错误堆栈:\n${errorStack}`);
624
-
625
- logger.info(`========== 消息处理结束(失败) ==========\n`);
895
+ logger.error(
896
+ `❌ 入站处理失败 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`,
897
+ );
898
+ if (error?.stack) logger.debug(error.stack);
899
+ if (!acked) {
900
+ logger.warn(
901
+ `⚠️ 未 ACK messageId=${messageId || "N/A"},等待钉钉重投`,
902
+ );
903
+ }
626
904
  } finally {
627
- // ✅ 无论成功或失败,都要标记消息处理结束
628
905
  markMessageProcessingEnd();
629
906
  }
630
907
  });
@@ -637,20 +914,27 @@ export async function monitorSingleAccount(
637
914
 
638
915
  // Connect to DingTalk Stream
639
916
  try {
640
- await client.connect();
917
+ // 注意:registerCallbackListener 必须在 connect 之前(已在上方完成),
918
+ // 这样 getEndpoint 会把 ROBOT 回调 topic 写进 subscriptions。
919
+ //
920
+ // 关键:禁止 OPEN-only 假 ready。
921
+ // 未收到 SYSTEM/REGISTERED 时会强制多轮 disconnect+connect,
922
+ // 直到 registered=true 才 onStatusChange(connected);否则启动失败。
923
+ await ensureRegisteredConnection(REGISTERED_CONNECT_MAX_ATTEMPTS);
641
924
 
642
- // 注册 socket 事件监听器(必须在 connect 后,此时 (client as any).socket 已创建)
643
- setupPongListener();
644
- setupMessageListener();
645
- setupCloseListener();
925
+ noteSocketAlive("initial-ready");
926
+ connectionEstablishedTime = Date.now();
646
927
 
647
928
  logger.info(`Connected to DingTalk Stream successfully`);
648
929
  logger.info(`PID: ${process.pid}`);
649
930
  logger.info(
650
- `✅ 自定义 keepAlive: true (10 秒心跳,90 秒超时), 指数退避重连`,
931
+ `✅ keepAlive: 心跳 ${HEARTBEAT_INTERVAL / 1000}s / 软超时 ${SOFT_STALE_MS / 1000}s×${HARD_RECONNECT_AFTER_MISSES} / ` +
932
+ `处理中刷新 ${MESSAGE_PROCESSING_KEEPALIVE_MS / 1000}s / 重连冷却 ${MIN_RECONNECT_GAP_MS / 1000}s, ` +
933
+ `registered=${Boolean((client as any).registered)}`,
651
934
  );
935
+ printConnectionNoticeOnce();
652
936
 
653
- // 初次连接成功,向框架报告 connected: true
937
+ // 仅 REGISTERED 成功后才报 connected
654
938
  onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
655
939
 
656
940
  // 启动自定义心跳检测