@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.
- package/CHANGELOG.md +49 -0
- package/README.en.md +28 -35
- package/README.md +28 -43
- package/dist/background-work-Cxb4b-91.mjs +42 -0
- package/dist/card-content-cache-c4Ls96eJ.mjs +2 -0
- package/dist/chunk-upload-PwhogwfE.mjs +2 -0
- package/dist/{common-DE0gd0Jg.mjs → common-BZiVgd3A.mjs} +2 -2
- package/dist/{common-B_A78n8u.mjs → common-DgL28gCZ.mjs} +1 -1
- package/dist/{connection-fc6B4z4G.mjs → connection-CZ7ZoN5_.mjs} +313 -86
- package/dist/entry-bundled.mjs +1 -1
- package/dist/gateway-methods-BkI1gKaC.mjs +2 -0
- package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-kNX3qvqw.mjs} +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{message-handler-DQZiKx3i.mjs → message-handler-B1usz1wb.mjs} +238 -122
- package/dist/{messaging-WleKf54h.mjs → messaging-DVS3UKOp.mjs} +6 -2
- package/dist/{runtime-DzZo6Gvk.mjs → runtime-A6G7WAWn.mjs} +3 -3
- package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
- package/package.json +2 -2
- package/src/core/connection.ts +465 -158
- package/src/core/message-handler.ts +173 -86
- package/src/reply-dispatcher.ts +234 -77
- package/src/services/messaging/card.ts +2 -0
- package/src/utils/background-work.ts +63 -0
- package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
- package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
- package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
- /package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-bDQFz2u8.mjs} +0 -0
- /package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-BE71drPg.mjs} +0 -0
- /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
package/src/core/connection.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 职责:
|
|
5
5
|
* - 管理单个钉钉账号的 WebSocket 连接
|
|
6
|
-
* - 实现应用层心跳检测(10 秒间隔,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
236
|
+
logger.debug(
|
|
237
|
+
`📝 消息处理中,更新 socket 可用时间 (ws=${wsCallbackActive} bg=${getActiveBackgroundWorkCount()})`,
|
|
238
|
+
);
|
|
186
239
|
}
|
|
187
|
-
},
|
|
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
|
-
|
|
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.
|
|
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
|
|
503
|
+
// 2. 重连直到 REGISTERED(内部含 connect + listener + wait,失败会多轮)
|
|
504
|
+
await ensureRegisteredConnection(REGISTERED_CONNECT_MAX_ATTEMPTS);
|
|
244
505
|
|
|
245
|
-
// 3.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
506
|
+
// 3. 重置计时与状态
|
|
507
|
+
noteSocketAlive("reconnect-ok");
|
|
508
|
+
connectionEstablishedTime = Date.now();
|
|
509
|
+
reconnectAttempts = 0;
|
|
510
|
+
lastHardReconnectAt = Date.now();
|
|
250
511
|
|
|
251
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* -
|
|
362
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
692
|
+
// OPEN 时优先补 ping;达到次数再考虑硬重连
|
|
693
|
+
await requestHardReconnect("stale-pong", false);
|
|
408
694
|
return;
|
|
409
695
|
}
|
|
410
696
|
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
-
`✅
|
|
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
|
-
//
|
|
960
|
+
// 仅 REGISTERED 成功后才报 connected
|
|
654
961
|
onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
|
|
655
962
|
|
|
656
963
|
// 启动自定义心跳检测
|