@jeik/dingtalk-connector 0.8.27 → 0.8.28

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 (29) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.en.md +28 -35
  3. package/README.md +28 -43
  4. package/dist/background-work-Cxb4b-91.mjs +42 -0
  5. package/dist/card-content-cache-c4Ls96eJ.mjs +2 -0
  6. package/dist/chunk-upload-PwhogwfE.mjs +2 -0
  7. package/dist/{common-DE0gd0Jg.mjs → common-BZiVgd3A.mjs} +2 -2
  8. package/dist/{common-B_A78n8u.mjs → common-DgL28gCZ.mjs} +1 -1
  9. package/dist/{connection-fc6B4z4G.mjs → connection-CZ7ZoN5_.mjs} +313 -86
  10. package/dist/entry-bundled.mjs +1 -1
  11. package/dist/gateway-methods-BkI1gKaC.mjs +2 -0
  12. package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-kNX3qvqw.mjs} +1 -1
  13. package/dist/index.mjs +2 -2
  14. package/dist/{message-handler-DQZiKx3i.mjs → message-handler-B1usz1wb.mjs} +238 -122
  15. package/dist/{messaging-WleKf54h.mjs → messaging-DVS3UKOp.mjs} +6 -2
  16. package/dist/{runtime-DzZo6Gvk.mjs → runtime-A6G7WAWn.mjs} +3 -3
  17. package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
  18. package/package.json +2 -2
  19. package/src/core/connection.ts +465 -158
  20. package/src/core/message-handler.ts +173 -86
  21. package/src/reply-dispatcher.ts +234 -77
  22. package/src/services/messaging/card.ts +2 -0
  23. package/src/utils/background-work.ts +63 -0
  24. package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
  25. package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
  26. package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
  27. /package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-bDQFz2u8.mjs} +0 -0
  28. /package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-BE71drPg.mjs} +0 -0
  29. /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 才报 ready,REGISTERED 超时会强制重连(禁止假 online)。" +
118
+ "正常运行不应出现 ≤30s 周期性硬重连。",
119
+ );
120
+ }
66
121
 
67
122
  // ============ 监控账号 ============
68
123
 
@@ -72,6 +127,9 @@ 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;
@@ -158,53 +216,68 @@ export async function monitorSingleAccount(
158
216
  let reconnectAttempts = 0;
159
217
  let keepAliveTimer: NodeJS.Timeout | null = null;
160
218
  let isStopped = false;
219
+ /** 连续 keepAlive 周期内未收到 pong / 未刷新的次数 */
220
+ let consecutiveStaleMisses = 0;
221
+ let lastHardReconnectAt = 0;
161
222
 
162
223
  // ============ 消息处理活跃标记 ============
163
- // 用于在消息处理期间防止心跳超时触发重连
224
+ // 覆盖「WS 回调短暂入队」+「sessionQueues 后台 AI 全长」(见 background-work.ts)
225
+ // 仅覆盖 WS 回调会导致:入队后立刻 End → 503/长任务期间幽灵重连 → 后续消息无反应
226
+ let wsCallbackActive = false;
164
227
  let activeMessageProcessing = false;
165
228
  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 秒超时
229
+ let unsubscribeBackgroundWork: (() => void) | null = null;
230
+
231
+ function ensureProcessingKeepAliveTimer(): void {
232
+ if (messageProcessingKeepAliveTimer) return;
182
233
  messageProcessingKeepAliveTimer = setInterval(() => {
183
234
  if (activeMessageProcessing) {
184
235
  lastSocketAvailableTime = Date.now();
185
- logger.debug(`📝 消息处理中,更新 socket 可用时间`);
236
+ logger.debug(
237
+ `📝 消息处理中,更新 socket 可用时间 (ws=${wsCallbackActive} bg=${getActiveBackgroundWorkCount()})`,
238
+ );
186
239
  }
187
- }, 30 * 1000); // 30 秒间隔
188
-
189
- logger.debug(`📝 消息处理开始,启动活跃标记定时器`);
240
+ }, MESSAGE_PROCESSING_KEEPALIVE_MS);
190
241
  }
191
-
192
- /**
193
- * 标记消息处理结束,停止定期更新机制
194
- */
195
- function markMessageProcessingEnd() {
196
- activeMessageProcessing = false;
197
-
242
+
243
+ function clearProcessingKeepAliveTimer(): void {
198
244
  if (messageProcessingKeepAliveTimer) {
199
245
  clearInterval(messageProcessingKeepAliveTimer);
200
246
  messageProcessingKeepAliveTimer = null;
201
247
  }
202
-
203
- // 最后更新一次时间
248
+ }
249
+
250
+ /** 根据 WS 入队中 / 后台任务数 合成 activeMessageProcessing */
251
+ function refreshProcessingActive(reason: string): void {
252
+ const bg = getActiveBackgroundWorkCount();
253
+ const next = wsCallbackActive || bg > 0;
254
+ activeMessageProcessing = next;
204
255
  lastSocketAvailableTime = Date.now();
205
- logger.debug(`✅ 消息处理结束,清理活跃标记定时器`);
256
+ if (next) {
257
+ ensureProcessingKeepAliveTimer();
258
+ logger.debug(
259
+ `📝 处理活跃 reason=${reason} ws=${wsCallbackActive} bg=${bg}`,
260
+ );
261
+ } else {
262
+ clearProcessingKeepAliveTimer();
263
+ logger.debug(`✅ 处理空闲 reason=${reason}`);
264
+ }
265
+ }
266
+
267
+ function markMessageProcessingStart() {
268
+ wsCallbackActive = true;
269
+ refreshProcessingActive("ws-start");
270
+ }
271
+
272
+ function markMessageProcessingEnd() {
273
+ wsCallbackActive = false;
274
+ refreshProcessingActive("ws-end");
206
275
  }
207
276
 
277
+ unsubscribeBackgroundWork = onBackgroundWorkCountChange(() => {
278
+ refreshProcessingActive("bg-count");
279
+ });
280
+
208
281
  // ============ 辅助函数 ============
209
282
 
210
283
  /** 计算指数退避延迟(带抖动) */
@@ -214,6 +287,194 @@ export async function monitorSingleAccount(
214
287
  return Math.min(exponentialDelay + jitter, MAX_BACKOFF_DELAY);
215
288
  }
216
289
 
290
+ /**
291
+ * 等待钉钉 Stream 真正可收消息。
292
+ *
293
+ * dingtalk-stream 的 client.connect() 在创建 WebSocket 后立刻 resolve,
294
+ * **不等** socket open,更不等服务端 SYSTEM/REGISTERED。
295
+ *
296
+ * 顺序:socket OPEN → SYSTEM topic=REGISTERED(client.registered=true)
297
+ * 任一步失败均 throw,禁止 OPEN-only 假 ready。
298
+ */
299
+ async function waitForStreamReady(timeoutMs = STREAM_READY_TIMEOUT_MS): Promise<void> {
300
+ const started = Date.now();
301
+
302
+ // 1) 等 WebSocket OPEN
303
+ if ((client as any).socket?.readyState !== 1) {
304
+ const opened = await new Promise<boolean>((resolve) => {
305
+ const socket = (client as any).socket;
306
+ if (!socket) {
307
+ resolve(false);
308
+ return;
309
+ }
310
+ if (socket.readyState === 1) {
311
+ resolve(true);
312
+ return;
313
+ }
314
+ const remain = Math.max(1_000, timeoutMs - (Date.now() - started));
315
+ let settled = false;
316
+ const finish = (ok: boolean) => {
317
+ if (settled) return;
318
+ settled = true;
319
+ clearTimeout(timer);
320
+ socket.removeListener?.("open", onOpen);
321
+ socket.removeListener?.("error", onError);
322
+ resolve(ok);
323
+ };
324
+ const timer = setTimeout(() => finish(false), remain);
325
+ const onOpen = () => finish(true);
326
+ const onError = () => finish(false);
327
+ socket.once("open", onOpen);
328
+ socket.once("error", onError);
329
+ });
330
+ if (!opened) {
331
+ throw new Error(`WebSocket OPEN 超时(${timeoutMs}ms)`);
332
+ }
333
+ }
334
+
335
+ // 2) 等服务端 REGISTERED(订阅生效,此后 CALLBACK 才会推到本连接)
336
+ if ((client as any).registered === true) {
337
+ logger.info(
338
+ `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
339
+ );
340
+ return;
341
+ }
342
+
343
+ const registered = await new Promise<boolean>((resolve) => {
344
+ const socket = (client as any).socket;
345
+ if (!socket) {
346
+ resolve(false);
347
+ return;
348
+ }
349
+ const remain = Math.max(1_000, timeoutMs - (Date.now() - started));
350
+ let settled = false;
351
+ const finish = (ok: boolean) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ clearTimeout(timer);
355
+ clearInterval(poll);
356
+ socket.removeListener?.("message", onMessage);
357
+ resolve(ok);
358
+ };
359
+ const timer = setTimeout(() => finish(false), remain);
360
+
361
+ const onMessage = (data: any) => {
362
+ try {
363
+ const raw = typeof data === "string" ? data : data?.toString?.() ?? String(data);
364
+ const msg = JSON.parse(raw);
365
+ if (msg?.type === "SYSTEM" && msg?.headers?.topic === "REGISTERED") {
366
+ finish(true);
367
+ return;
368
+ }
369
+ } catch {
370
+ // ignore parse errors
371
+ }
372
+ if ((client as any).registered === true) {
373
+ finish(true);
374
+ }
375
+ };
376
+
377
+ const poll = setInterval(() => {
378
+ if ((client as any).registered === true) {
379
+ finish(true);
380
+ }
381
+ }, 100);
382
+
383
+ socket.on("message", onMessage);
384
+ });
385
+
386
+ if (registered || (client as any).registered === true) {
387
+ logger.info(
388
+ `✅ Stream 已就绪(OPEN + REGISTERED),耗时 ${Date.now() - started}ms`,
389
+ );
390
+ return;
391
+ }
392
+
393
+ // 禁止 OPEN-only 假 ready:未 REGISTERED 一律失败,由上层强制重连
394
+ throw new Error(
395
+ `SYSTEM/REGISTERED 超时(${timeoutMs}ms):socket 已 OPEN 但订阅未生效,禁止报 connected`,
396
+ );
397
+ }
398
+
399
+ /**
400
+ * connect 之后、wait ready 之前挂上 pong/message/close。
401
+ * 官方 #566:若在 connect 前 setup,socket 为 undefined,listener 静默 no-op →
402
+ * pong 无人接 → lastSocketAvailableTime 不刷新 → TIMEOUT 幽灵重连。
403
+ * 必须在 wait OPEN 之前挂好,否则等待窗内的 pong 也会丢。
404
+ */
405
+ function attachSocketLifecycleListeners(): void {
406
+ setupPongListener();
407
+ setupMessageListener();
408
+ setupCloseListener();
409
+ }
410
+
411
+ /**
412
+ * 单次:connect + 挂 listener + 等 OPEN/REGISTERED。
413
+ * 失败 throw,不报 connected。
414
+ */
415
+ async function connectAndWaitRegistered(): Promise<void> {
416
+ await client.connect();
417
+ attachSocketLifecycleListeners();
418
+ await waitForStreamReady(STREAM_READY_TIMEOUT_MS);
419
+ if ((client as any).registered !== true) {
420
+ // 双保险:wait 已要求 registered,此处再断言
421
+ throw new Error("connect 后 client.registered 仍为 false");
422
+ }
423
+ }
424
+
425
+ /**
426
+ * 直到 REGISTERED 成功或次数用尽。
427
+ * 用于初次启动与硬重连:绝不在未订阅时对外 connected=true。
428
+ */
429
+ async function ensureRegisteredConnection(
430
+ maxAttempts = REGISTERED_CONNECT_MAX_ATTEMPTS,
431
+ ): Promise<void> {
432
+ let lastError: Error | undefined;
433
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
434
+ if (isStopped) {
435
+ throw new Error("连接已停止,中止 REGISTERED 等待");
436
+ }
437
+ try {
438
+ if (attempt > 1) {
439
+ logger.warn(
440
+ `🔄 REGISTERED 未就绪,强制重连 ${attempt}/${maxAttempts}…`,
441
+ );
442
+ try {
443
+ if ((client as any).socket) {
444
+ await client.disconnect();
445
+ }
446
+ } catch (discErr: any) {
447
+ logger.debug(`断开旧连接: ${discErr?.message || discErr}`);
448
+ }
449
+ const delay = Math.min(1000 * attempt, 5_000);
450
+ await new Promise((r) => setTimeout(r, delay));
451
+ } else {
452
+ logger.info(
453
+ `⏳ 建立 Stream 并等待 REGISTERED(单次超时 ${STREAM_READY_TIMEOUT_MS}ms,最多 ${maxAttempts} 次)…`,
454
+ );
455
+ }
456
+
457
+ await connectAndWaitRegistered();
458
+ noteSocketAlive("registered-ok");
459
+ connectionEstablishedTime = Date.now();
460
+ logger.info(
461
+ `✅ 订阅已生效 registered=true(attempt ${attempt}/${maxAttempts}, ` +
462
+ `socket=${(client as any).socket?.readyState})`,
463
+ );
464
+ return;
465
+ } catch (err: any) {
466
+ lastError = err instanceof Error ? err : new Error(String(err));
467
+ logger.warn(
468
+ `⚠️ Stream 就绪失败 attempt=${attempt}/${maxAttempts}: ${lastError.message}`,
469
+ );
470
+ }
471
+ }
472
+ throw new Error(
473
+ `钉钉 Stream 在 ${maxAttempts} 次尝试后仍未 REGISTERED,拒绝假 connected。` +
474
+ ` 最后错误: ${lastError?.message || "unknown"}`,
475
+ );
476
+ }
477
+
217
478
  /** 统一重连函数,带指数退避(无限重连) */
218
479
  async function doReconnect(immediate = false) {
219
480
  if (isReconnecting || isStopped) {
@@ -233,68 +494,31 @@ export async function monitorSingleAccount(
233
494
  }
234
495
 
235
496
  try {
236
- // 1. 先断开旧连接(检查 WebSocket 状态)
497
+ // 1. 先断开旧连接
237
498
  if ((client as any).socket?.readyState === 1 || (client as any).socket?.readyState === 3) {
238
499
  await client.disconnect();
239
500
  logger.info(`已断开旧连接`);
240
501
  }
241
502
 
242
- // 2. 重新建立连接
243
- await client.connect();
503
+ // 2. 重连直到 REGISTERED(内部含 connect + listener + wait,失败会多轮)
504
+ await ensureRegisteredConnection(REGISTERED_CONNECT_MAX_ATTEMPTS);
244
505
 
245
- // 3. 等待连接真正建立(监听 open 事件,最多等待 10 秒)
246
- const connectionEstablished = await new Promise<boolean>((resolve) => {
247
- const timeout = setTimeout(() => {
248
- resolve(false);
249
- }, 10_000); // 10 秒超时
506
+ // 3. 重置计时与状态
507
+ noteSocketAlive("reconnect-ok");
508
+ connectionEstablishedTime = Date.now();
509
+ reconnectAttempts = 0;
510
+ lastHardReconnectAt = Date.now();
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
- };
265
-
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
512
+ // 4. 真正可收 CALLBACK 后再报 connected
288
513
  onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
289
514
 
290
- // 重新注册 socket 事件监听器(新 socket 需要新的 listener)
291
- setupPongListener();
292
- setupMessageListener();
293
- setupCloseListener();
294
-
295
- logger.info(`✅ 重连成功 (socket 状态=${(client as any).socket?.readyState})`);
515
+ logger.info(
516
+ `✅ 重连成功 (socket=${(client as any).socket?.readyState}, registered=${Boolean((client as any).registered)})`,
517
+ );
296
518
  } catch (err: any) {
297
519
  reconnectAttempts++;
520
+ // 未 REGISTERED:保持 connected=false,避免 UI 假在线
521
+ onStatusChange?.({ connected: false });
298
522
  logger.error(
299
523
  `重连失败:${err.message} (尝试 ${reconnectAttempts})`,
300
524
  );
@@ -308,10 +532,17 @@ export async function monitorSingleAccount(
308
532
  function setupPongListener() {
309
533
  (client as any).socket?.on("pong", () => {
310
534
  lastSocketAvailableTime = Date.now();
535
+ consecutiveStaleMisses = 0;
311
536
  logger.debug(`收到 PONG 响应`);
312
537
  });
313
538
  }
314
539
 
540
+ function noteSocketAlive(reason: string): void {
541
+ lastSocketAvailableTime = Date.now();
542
+ consecutiveStaleMisses = 0;
543
+ logger.debug(`socket alive reason=${reason}`);
544
+ }
545
+
315
546
  /** 监听 WebSocket message 事件,收到 disconnect 消息时立即触发重连 */
316
547
  function setupMessageListener() {
317
548
  (client as any).socket?.on("message", (data: any) => {
@@ -355,12 +586,54 @@ export async function monitorSingleAccount(
355
586
  }
356
587
 
357
588
  /**
358
- * 启动 keepAlive 机制(单定时器 + 指数退避)
359
- *
360
- * 业界最佳实践:
361
- * - 单定时器:每 10 秒检查一次,同时完成心跳和超时检测
362
- * - 使用 WebSocket 原生 Ping
363
- * - 指数退避重连:避免雪崩效应
589
+ * 请求硬重连(拆 socket)。
590
+ * - socket 仍 OPEN:先补 PING,连续 misses 未达阈值则绝不拆连接(防空窗吞消息)
591
+ * - socket 已死:立即重连
592
+ * - 冷却:非死连接时距上次硬重连 < MIN_RECONNECT_GAP_MS 则跳过
593
+ */
594
+ async function requestHardReconnect(reason: string, socketDead = false): Promise<void> {
595
+ if (isReconnecting || isStopped) return;
596
+
597
+ const socketState = (client as any).socket?.readyState;
598
+
599
+ // 活连接:优先补 ping,未达连续 miss 阈值不拆
600
+ if (!socketDead && socketState === 1) {
601
+ try {
602
+ (client as any).socket?.ping();
603
+ logger.warn(
604
+ `⚠️ 软超时但 socket 仍 OPEN (reason=${reason}, misses=${consecutiveStaleMisses}/${HARD_RECONNECT_AFTER_MISSES}),仅补 PING`,
605
+ );
606
+ } catch (err: any) {
607
+ logger.warn(`补 PING 失败: ${err.message}`);
608
+ consecutiveStaleMisses += 1;
609
+ }
610
+ if (consecutiveStaleMisses < HARD_RECONNECT_AFTER_MISSES) {
611
+ return;
612
+ }
613
+ }
614
+
615
+ // 冷却(socket 已死时跳过冷却)
616
+ if (!socketDead) {
617
+ const gap = Date.now() - lastHardReconnectAt;
618
+ if (lastHardReconnectAt > 0 && gap < MIN_RECONNECT_GAP_MS) {
619
+ logger.warn(
620
+ `⚠️ 跳过硬重连:距上次仅 ${Math.round(gap / 1000)}s < ${MIN_RECONNECT_GAP_MS / 1000}s 冷却 (reason=${reason})`,
621
+ );
622
+ return;
623
+ }
624
+ }
625
+
626
+ lastHardReconnectAt = Date.now();
627
+ consecutiveStaleMisses = 0;
628
+ logger.info(`🔄 硬重连 reason=${reason} socket=${socketState}`);
629
+ await doReconnect(socketDead);
630
+ }
631
+
632
+ /**
633
+ * 启动 keepAlive:
634
+ * - 10s 发 ping;pong 刷新 lastSocketAvailableTime
635
+ * - 软超时只计数 + 补 ping,达到 HARD_RECONNECT_AFTER_MISSES 且 socket 异常才拆连接
636
+ * - 处理中任务期间绝不硬重连
364
637
  */
365
638
  function startKeepAlive(): () => void {
366
639
  logger.debug(
@@ -375,24 +648,16 @@ export async function monitorSingleAccount(
375
648
 
376
649
  try {
377
650
  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
651
  const socketState = (client as any).socket?.readyState;
390
652
  const timeSinceConnection = Date.now() - connectionEstablishedTime;
653
+
391
654
  logger.debug(
392
- `心跳检测:socket 状态=${socketState}, elapsed=${Math.round(elapsed / 1000)}s, 连接已建立=${Math.round(timeSinceConnection / 1000)}s`,
655
+ `心跳检测:socket=${socketState}, elapsed=${Math.round(elapsed / 1000)}s, ` +
656
+ `misses=${consecutiveStaleMisses}, processing=${activeMessageProcessing}, ` +
657
+ `connectedFor=${Math.round(timeSinceConnection / 1000)}s`,
393
658
  );
394
659
 
395
- // 给新建立的连接 15 秒宽限期,避免在连接建立初期就触发重连
660
+ // socket 非 OPEN:宽限期后硬重连
396
661
  if (socketState !== 1) {
397
662
  if (timeSinceConnection < 15_000) {
398
663
  logger.debug(
@@ -400,31 +665,55 @@ export async function monitorSingleAccount(
400
665
  );
401
666
  return;
402
667
  }
403
-
404
- logger.info(
405
- `⚠️ 心跳检测:socket 状态=${socketState},触发重连...`,
668
+ if (activeMessageProcessing) {
669
+ logger.warn(
670
+ `⚠️ socket 非 OPEN 但消息处理中,暂缓硬重连 state=${socketState}`,
671
+ );
672
+ return;
673
+ }
674
+ await requestHardReconnect(`socket-dead state=${socketState}`, true);
675
+ return;
676
+ }
677
+
678
+ // 软超时:无 pong/刷新超过 SOFT_STALE_MS
679
+ if (elapsed > SOFT_STALE_MS) {
680
+ consecutiveStaleMisses += 1;
681
+ if (activeMessageProcessing) {
682
+ noteSocketAlive("processing-hold");
683
+ logger.warn(
684
+ `⚠️ 软超时但处理中,刷新活跃时间不重连 (elapsed=${Math.round(elapsed / 1000)}s)`,
685
+ );
686
+ return;
687
+ }
688
+ logger.warn(
689
+ `⚠️ 软超时 misses=${consecutiveStaleMisses}/${HARD_RECONNECT_AFTER_MISSES} ` +
690
+ `elapsed=${Math.round(elapsed / 1000)}s socket=OPEN`,
406
691
  );
407
- await doReconnect(true); // 立即重连,不退避
692
+ // OPEN 时优先补 ping;达到次数再考虑硬重连
693
+ await requestHardReconnect("stale-pong", false);
408
694
  return;
409
695
  }
410
696
 
411
- // 【发送原生 Ping】仅发送,不刷新时间戳;
412
- // 只有收到 pong 响应时才更新 lastSocketAvailableTime(见 setupPongListener)
697
+ consecutiveStaleMisses = 0;
698
+
699
+ // 正常心跳 ping(pong 到了才刷新时间戳)
413
700
  try {
414
701
  (client as any).socket?.ping();
415
702
  logger.debug(`💓 发送 PING 心跳成功`);
416
703
  } catch (err: any) {
417
704
  logger.warn(`发送 PING 失败:${err.message}`);
418
- // 发送失败也计入超时
705
+ consecutiveStaleMisses += 1;
706
+ if (consecutiveStaleMisses >= HARD_RECONNECT_AFTER_MISSES) {
707
+ await requestHardReconnect("ping-failed", true);
708
+ }
419
709
  }
420
710
  } catch (err: any) {
421
711
  logger.error(`keepAlive 检测失败:${err.message}`);
422
712
  }
423
- }, HEARTBEAT_INTERVAL); // 每 10 秒检测一次
713
+ }, HEARTBEAT_INTERVAL);
424
714
 
425
715
  logger.debug(`✅ keepAlive 定时器已启动`);
426
716
 
427
- // 返回清理函数
428
717
  return () => {
429
718
  if (keepAliveTimer) clearInterval(keepAliveTimer);
430
719
  keepAliveTimer = null;
@@ -441,10 +730,11 @@ export async function monitorSingleAccount(
441
730
  keepAliveTimer = null;
442
731
 
443
732
  // 清理消息处理活跃标记定时器
444
- if (messageProcessingKeepAliveTimer) {
445
- clearInterval(messageProcessingKeepAliveTimer);
446
- messageProcessingKeepAliveTimer = null;
447
- }
733
+ clearProcessingKeepAliveTimer();
734
+ wsCallbackActive = false;
735
+ activeMessageProcessing = false;
736
+ unsubscribeBackgroundWork?.();
737
+ unsubscribeBackgroundWork = null;
448
738
 
449
739
  // 清理事件监听器
450
740
  if ((client as any).socket) {
@@ -492,6 +782,8 @@ export async function monitorSingleAccount(
492
782
  client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
493
783
  receivedCount++;
494
784
  lastMessageTime = Date.now();
785
+ // 能收到 CALLBACK 说明链路活着——立刻刷新,打断「软超时→硬重连」误判
786
+ noteSocketAlive("inbound-callback");
495
787
 
496
788
  // 收到消息时,向框架报告 lastInboundAt(用于 UI 显示 "Last inbound")
497
789
  onStatusChange?.({ lastInboundAt: Date.now() });
@@ -506,28 +798,24 @@ export async function monitorSingleAccount(
506
798
  logger.info(`Headers: ${JSON.stringify(res.headers || {})}`);
507
799
  logger.info(`Data 长度:${res.data?.length || 0} 字符`);
508
800
 
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
-
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
- }
801
+ // ⚠️ 不要在解析/入队前 ACK。
802
+ // 旧逻辑「立刻 socketCallBackResponse」:若随后去重误杀、解析失败、或入队前进程重连,
803
+ // 钉钉认为已送达,不再重推 → 网关 UI 永远收不到。
804
+ // 正确:入队成功后再 ACK;失败则不 ACK,让钉钉 ~60s 内重投。
805
+ let acked = false;
806
+ const ackMessage = (why: string) => {
807
+ if (acked || !messageId) return;
808
+ try {
809
+ (client as any).socketCallBackResponse(messageId, { success: true });
810
+ acked = true;
811
+ logger.info(`✅ 已确认回调 messageId=${messageId} (${why})`);
812
+ } catch (ackErr: any) {
813
+ logger.warn(`确认回调失败: ${ackErr?.message || ackErr}`);
814
+ }
815
+ };
526
816
 
527
- // 异步处理消息
528
- // ✅ 标记消息处理开始,防止长时间处理触发心跳超时
529
817
  markMessageProcessingStart();
530
-
818
+
531
819
  try {
532
820
  // 解析消息数据
533
821
  let data;
@@ -537,10 +825,11 @@ export async function monitorSingleAccount(
537
825
  logger.error('Failed to parse response data as JSON:', {
538
826
  error: parseError instanceof Error ? parseError.message : String(parseError),
539
827
  rawData: typeof res.data === 'string'
540
- ? res.data.substring(0, 500) // 只记录前 500 字符
828
+ ? res.data.substring(0, 500)
541
829
  : res.data,
542
830
  dataType: typeof res.data,
543
831
  });
832
+ // 解析失败:不 ACK,允许钉钉重投
544
833
  throw new Error(
545
834
  `Invalid JSON response from DingTalk API. ` +
546
835
  `Error: ${parseError instanceof Error ? parseError.message : String(parseError)}. ` +
@@ -565,24 +854,26 @@ export async function monitorSingleAccount(
565
854
  logger.info(
566
855
  `RobotCode: ${data.robotCode || account.config?.clientId || "N/A"}`,
567
856
  );
568
- // 暴露当前机器人的加密身份(chatbotUserId / chatbotCorpId)
569
- // 用途:多机器人协作时,把这两个值配进 openclaw.json 对应 account 的元数据下,
570
- // 让其他 agent 能在群消息里通过 atDingtalkIds 写上对方的 chatbotUserId 触发 @ UI。
571
- // 注意:这里**不**经过 logger.info(受 debug 开关控制),直接 console.log,
572
- // 否则首次配置的用户在未开 debug 时永远看不到这两个 ID,无法完成多机器人协作配置。
573
857
  if (data.chatbotUserId || data.chatbotCorpId) {
574
858
  console.log(
575
859
  `[DingTalk:${accountId}] [BotIdentity] accountId=${accountId} chatbotUserId=${data.chatbotUserId || "N/A"} chatbotCorpId=${data.chatbotCorpId || "N/A"}`,
576
860
  );
577
861
  }
578
862
 
579
- // ===== 业务层去重:补充 data.msgId,防止钉钉服务端重发穿透 =====
580
- // 协议层已标记了 headers.messageId,此处再补充标记 data.msgId。
581
- // 钉钉重发时 headers.messageId 是新值,但 data.msgId 不变,
582
- // checkAndMarkDingtalkMessage 会命中 data.msgId 并返回 true 拦截重发。
583
863
  const businessMsgId = data.msgId;
584
864
 
585
- // 记录消息内容(简化版,避免过长)
865
+ // 双层去重:协议 messageId + 业务 msgId(须在解析后一次完成)
866
+ if (checkAndMarkDingtalkMessage(accountId, messageId, businessMsgId)) {
867
+ processedCount++;
868
+ // 重复投递:ACK 掉避免钉钉无限重推
869
+ ackMessage("duplicate");
870
+ logger.warn(
871
+ `⚠️ 检测到重复消息,跳过:protocol=${messageId || "-"} business=${businessMsgId || "-"} (${processedCount}/${receivedCount})`,
872
+ );
873
+ logger.info(`========== 消息处理结束(重复) ==========\n`);
874
+ return;
875
+ }
876
+
586
877
  let contentPreview = "N/A";
587
878
  if (data.text?.content) {
588
879
  contentPreview =
@@ -597,7 +888,7 @@ export async function monitorSingleAccount(
597
888
  logger.info(`完整数据字段:${Object.keys(data).join(", ")}`);
598
889
  logger.info(`----- 消息详情结束 -----\n`);
599
890
 
600
- // ===== 第三步:开始处理消息 =====
891
+ // ===== 第三步:入队处理 =====
601
892
  logger.info(`🚀 开始处理消息...`);
602
893
 
603
894
  await messageHandler({
@@ -610,6 +901,10 @@ export async function monitorSingleAccount(
610
901
  cfg: clawdbotConfig,
611
902
  });
612
903
 
904
+ // 入队/受理成功后再 ACK(handleDingTalkMessage 返回表示已进 session 队列)
905
+ ackMessage("enqueued");
906
+ noteSocketAlive("after-enqueue");
907
+
613
908
  processedCount++;
614
909
  logger.info(`✅ 消息处理完成 (${processedCount}/${receivedCount})`);
615
910
  logger.info(`========== 消息处理结束(成功) ==========\n`);
@@ -618,13 +913,18 @@ export async function monitorSingleAccount(
618
913
  const errorMsg = `❌ 处理消息异常 (${processedCount}/${receivedCount}): ${error?.message || "未知错误"}`;
619
914
  const errorStack = error?.stack || "无堆栈信息";
620
915
 
621
- // 使用 logger 记录错误信息
622
916
  logger.error(errorMsg);
623
917
  logger.error(`错误堆栈:\n${errorStack}`);
624
918
 
919
+ // 未成功入队:故意不 ACK,让钉钉重投(最多约 60s)
920
+ if (!acked) {
921
+ logger.warn(
922
+ `⚠️ 未 ACK messageId=${messageId || "N/A"},等待钉钉重投(避免网关永久丢消息)`,
923
+ );
924
+ }
925
+
625
926
  logger.info(`========== 消息处理结束(失败) ==========\n`);
626
927
  } finally {
627
- // ✅ 无论成功或失败,都要标记消息处理结束
628
928
  markMessageProcessingEnd();
629
929
  }
630
930
  });
@@ -637,20 +937,27 @@ export async function monitorSingleAccount(
637
937
 
638
938
  // Connect to DingTalk Stream
639
939
  try {
640
- await client.connect();
940
+ // 注意:registerCallbackListener 必须在 connect 之前(已在上方完成),
941
+ // 这样 getEndpoint 会把 ROBOT 回调 topic 写进 subscriptions。
942
+ //
943
+ // 关键:禁止 OPEN-only 假 ready。
944
+ // 未收到 SYSTEM/REGISTERED 时会强制多轮 disconnect+connect,
945
+ // 直到 registered=true 才 onStatusChange(connected);否则启动失败。
946
+ await ensureRegisteredConnection(REGISTERED_CONNECT_MAX_ATTEMPTS);
641
947
 
642
- // 注册 socket 事件监听器(必须在 connect 后,此时 (client as any).socket 已创建)
643
- setupPongListener();
644
- setupMessageListener();
645
- setupCloseListener();
948
+ noteSocketAlive("initial-ready");
949
+ connectionEstablishedTime = Date.now();
646
950
 
647
951
  logger.info(`Connected to DingTalk Stream successfully`);
648
952
  logger.info(`PID: ${process.pid}`);
649
953
  logger.info(
650
- `✅ 自定义 keepAlive: true (10 秒心跳,90 秒超时), 指数退避重连`,
954
+ `✅ keepAlive: 心跳 ${HEARTBEAT_INTERVAL / 1000}s / 软超时 ${SOFT_STALE_MS / 1000}s×${HARD_RECONNECT_AFTER_MISSES} / ` +
955
+ `处理中刷新 ${MESSAGE_PROCESSING_KEEPALIVE_MS / 1000}s / 重连冷却 ${MIN_RECONNECT_GAP_MS / 1000}s, ` +
956
+ `registered=${Boolean((client as any).registered)}`,
651
957
  );
958
+ printConnectionNoticeOnce();
652
959
 
653
- // 初次连接成功,向框架报告 connected: true
960
+ // 仅 REGISTERED 成功后才报 connected
654
961
  onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
655
962
 
656
963
  // 启动自定义心跳检测