@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.
- package/CHANGELOG.md +48 -0
- package/README.en.md +40 -82
- package/README.md +50 -93
- package/dist/background-work-DkaSDqWk.mjs +53 -0
- package/dist/card-content-cache-C7-XHhHw.mjs +2 -0
- package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-DT_OYThh.mjs} +15 -1
- package/dist/chunk-upload-BKM1y4Ba.mjs +2 -0
- package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-CIU5G_ay.mjs} +1 -1
- package/dist/{common-DE0gd0Jg.mjs → common-BfIYs1yC.mjs} +3 -3
- package/dist/{common-B_A78n8u.mjs → common-C0Oaf0z-.mjs} +1 -1
- package/dist/connection-obtrPkj4.mjs +641 -0
- package/dist/entry-bundled.mjs +1 -1
- package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-BdcpfxEZ.mjs} +1 -1
- package/dist/gateway-methods-DQ6TwOAr.mjs +2 -0
- package/dist/index.mjs +2 -2
- package/dist/{logger-mZ9OSbmD.mjs → logger-CnBTcwyq.mjs} +16 -2
- package/dist/logger-D8H2IcYz.mjs +2 -0
- package/dist/{media-BqpUGCni.mjs → media-DDtX37T2.mjs} +1 -1
- package/dist/{media-DnQB2BrL.mjs → media-g8tJtpfU.mjs} +29 -20
- package/dist/{message-handler-DQZiKx3i.mjs → message-handler-BrO2i7tg.mjs} +251 -139
- package/dist/{messaging-WleKf54h.mjs → messaging-CK0Wz3Ze.mjs} +23 -21
- package/dist/{runtime-DzZo6Gvk.mjs → runtime-DbqGRyhI.mjs} +6 -5
- package/dist/{utils-Bjh4r_qS.mjs → utils-Bwz2Fls_.mjs} +1 -1
- package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
- package/docs/RELEASE_NOTES_V0.8.29.md +57 -0
- package/openclaw.plugin.json +9 -5
- package/package.json +2 -2
- package/src/config/schema.ts +2 -1
- package/src/core/connection.ts +504 -220
- package/src/core/message-handler.ts +197 -102
- package/src/reply-dispatcher.ts +244 -93
- package/src/services/media.ts +60 -27
- package/src/services/messaging/card-content-cache.ts +11 -6
- package/src/services/messaging/card.ts +2 -0
- package/src/services/messaging.ts +52 -38
- package/src/utils/background-work.ts +65 -0
- package/src/utils/logger.ts +23 -2
- package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
- package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
- package/dist/connection-fc6B4z4G.mjs +0 -423
- package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
- package/dist/logger-BmJkQkm1.mjs +0 -2
- /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 尽量等待;若环境不推 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
|
-
|
|
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
|
-
|
|
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(
|
|
238
|
+
logger.debug(
|
|
239
|
+
`📝 消息处理中,更新 socket 可用时间 (ws=${wsCallbackActive} bg=${getActiveBackgroundWorkCount()})`,
|
|
240
|
+
);
|
|
186
241
|
}
|
|
187
|
-
},
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* -
|
|
362
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
698
|
+
// OPEN 时优先补 ping;达到次数再考虑硬重连
|
|
699
|
+
await requestHardReconnect("stale-pong", false);
|
|
408
700
|
return;
|
|
409
701
|
}
|
|
410
702
|
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
848
|
+
`📩 入站 ${convType} from=${data.senderNick || data.senderStaffId || data.senderId || "?"} ` +
|
|
849
|
+
`msg=${businessMsgId || messageId || "-"} preview=${JSON.stringify(preview)}`,
|
|
567
850
|
);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
//
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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(`✅
|
|
615
|
-
logger.info(`========== 消息处理结束(成功) ==========\n`);
|
|
892
|
+
logger.info(`✅ 入队完成 (${processedCount}/${receivedCount})`);
|
|
616
893
|
} catch (error: any) {
|
|
617
894
|
processedCount++;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
-
`✅
|
|
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
|
-
//
|
|
937
|
+
// 仅 REGISTERED 成功后才报 connected
|
|
654
938
|
onStatusChange?.({ connected: true, lastConnectedAt: Date.now() });
|
|
655
939
|
|
|
656
940
|
// 启动自定义心跳检测
|