@soimy/dingtalk 3.0.0 → 3.0.1
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/package.json +1 -1
- package/src/channel.ts +94 -15
- package/src/connection-manager.ts +81 -9
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -30,6 +30,46 @@ import type {
|
|
|
30
30
|
import { ConnectionState } from "./types";
|
|
31
31
|
import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
|
|
32
32
|
|
|
33
|
+
const processingDedupKeys = new Set<string>();
|
|
34
|
+
const inboundCountersByAccount = new Map<
|
|
35
|
+
string,
|
|
36
|
+
{
|
|
37
|
+
received: number;
|
|
38
|
+
acked: number;
|
|
39
|
+
dedupSkipped: number;
|
|
40
|
+
inflightSkipped: number;
|
|
41
|
+
processed: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
noMessageId: number;
|
|
44
|
+
}
|
|
45
|
+
>();
|
|
46
|
+
const INBOUND_COUNTER_LOG_EVERY = 10;
|
|
47
|
+
|
|
48
|
+
function getInboundCounters(accountId: string) {
|
|
49
|
+
const existing = inboundCountersByAccount.get(accountId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
return existing;
|
|
52
|
+
}
|
|
53
|
+
const created = {
|
|
54
|
+
received: 0,
|
|
55
|
+
acked: 0,
|
|
56
|
+
dedupSkipped: 0,
|
|
57
|
+
inflightSkipped: 0,
|
|
58
|
+
processed: 0,
|
|
59
|
+
failed: 0,
|
|
60
|
+
noMessageId: 0,
|
|
61
|
+
};
|
|
62
|
+
inboundCountersByAccount.set(accountId, created);
|
|
63
|
+
return created;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function logInboundCounters(log: any, accountId: string, reason: string): void {
|
|
67
|
+
const stats = getInboundCounters(accountId);
|
|
68
|
+
log?.info?.(
|
|
69
|
+
`[${accountId}] Inbound counters (${reason}): received=${stats.received}, acked=${stats.acked}, processed=${stats.processed}, dedupSkipped=${stats.dedupSkipped}, inflightSkipped=${stats.inflightSkipped}, failed=${stats.failed}, noMessageId=${stats.noMessageId}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
33
73
|
// DingTalk Channel Definition (assembly layer).
|
|
34
74
|
// Heavy logic is delegated to service modules for maintainability.
|
|
35
75
|
export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
@@ -254,36 +294,75 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
254
294
|
|
|
255
295
|
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
256
296
|
const messageId = res.headers?.messageId;
|
|
297
|
+
const stats = getInboundCounters(account.accountId);
|
|
298
|
+
stats.received += 1;
|
|
257
299
|
try {
|
|
258
300
|
if (messageId) {
|
|
259
301
|
client.socketCallBackResponse(messageId, { success: true });
|
|
302
|
+
stats.acked += 1;
|
|
260
303
|
}
|
|
261
304
|
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
262
305
|
|
|
263
306
|
// Message deduplication key is bot-scoped to avoid cross-account conflicts.
|
|
264
307
|
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
265
308
|
const msgId = data.msgId || messageId;
|
|
309
|
+
const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
|
|
266
310
|
|
|
267
|
-
if (!
|
|
311
|
+
if (!dedupKey) {
|
|
268
312
|
ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
313
|
+
stats.noMessageId += 1;
|
|
314
|
+
await handleDingTalkMessage({
|
|
315
|
+
cfg,
|
|
316
|
+
accountId: account.accountId,
|
|
317
|
+
data,
|
|
318
|
+
sessionWebhook: data.sessionWebhook,
|
|
319
|
+
log: ctx.log,
|
|
320
|
+
dingtalkConfig: config,
|
|
321
|
+
});
|
|
322
|
+
stats.processed += 1;
|
|
323
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
324
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
274
325
|
}
|
|
275
|
-
|
|
326
|
+
return;
|
|
276
327
|
}
|
|
277
328
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
329
|
+
if (isMessageProcessed(dedupKey)) {
|
|
330
|
+
ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
|
|
331
|
+
stats.dedupSkipped += 1;
|
|
332
|
+
logInboundCounters(ctx.log, account.accountId, "dedup-skipped");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (processingDedupKeys.has(dedupKey)) {
|
|
337
|
+
ctx.log?.debug?.(
|
|
338
|
+
`[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
|
|
339
|
+
);
|
|
340
|
+
stats.inflightSkipped += 1;
|
|
341
|
+
logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
processingDedupKeys.add(dedupKey);
|
|
346
|
+
try {
|
|
347
|
+
await handleDingTalkMessage({
|
|
348
|
+
cfg,
|
|
349
|
+
accountId: account.accountId,
|
|
350
|
+
data,
|
|
351
|
+
sessionWebhook: data.sessionWebhook,
|
|
352
|
+
log: ctx.log,
|
|
353
|
+
dingtalkConfig: config,
|
|
354
|
+
});
|
|
355
|
+
stats.processed += 1;
|
|
356
|
+
markMessageProcessed(dedupKey);
|
|
357
|
+
if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
|
|
358
|
+
logInboundCounters(ctx.log, account.accountId, "periodic");
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
processingDedupKeys.delete(dedupKey);
|
|
362
|
+
}
|
|
286
363
|
} catch (error: any) {
|
|
364
|
+
stats.failed += 1;
|
|
365
|
+
logInboundCounters(ctx.log, account.accountId, "failed");
|
|
287
366
|
ctx.log?.error?.(`[${account.accountId}] Error processing message: ${error.message}`);
|
|
288
367
|
}
|
|
289
368
|
});
|
|
@@ -31,6 +31,21 @@ export class ConnectionManager {
|
|
|
31
31
|
private attemptCount: number = 0;
|
|
32
32
|
private reconnectTimer?: NodeJS.Timeout;
|
|
33
33
|
private stopped: boolean = false;
|
|
34
|
+
private connectedAt?: number;
|
|
35
|
+
private consecutiveUnhealthyChecks: number = 0;
|
|
36
|
+
|
|
37
|
+
private static readonly HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
38
|
+
private static readonly HEALTH_CHECK_GRACE_MS = 3000;
|
|
39
|
+
private static readonly HEALTH_CHECK_UNHEALTHY_THRESHOLD = 2;
|
|
40
|
+
private runtimeCounters = {
|
|
41
|
+
healthUnhealthyChecks: 0,
|
|
42
|
+
healthTriggeredReconnects: 0,
|
|
43
|
+
socketCloseEvents: 0,
|
|
44
|
+
runtimeDisconnects: 0,
|
|
45
|
+
reconnectAttempts: 0,
|
|
46
|
+
reconnectSuccess: 0,
|
|
47
|
+
reconnectFailures: 0,
|
|
48
|
+
};
|
|
34
49
|
|
|
35
50
|
// Runtime monitoring resources
|
|
36
51
|
private healthCheckInterval?: NodeJS.Timeout;
|
|
@@ -61,6 +76,13 @@ export class ConnectionManager {
|
|
|
61
76
|
}
|
|
62
77
|
}
|
|
63
78
|
|
|
79
|
+
private logRuntimeCounters(reason: string): void {
|
|
80
|
+
const c = this.runtimeCounters;
|
|
81
|
+
this.log?.info?.(
|
|
82
|
+
`[${this.accountId}] Runtime counters (${reason}): healthUnhealthyChecks=${c.healthUnhealthyChecks}, healthTriggeredReconnects=${c.healthTriggeredReconnects}, socketCloseEvents=${c.socketCloseEvents}, runtimeDisconnects=${c.runtimeDisconnects}, reconnectAttempts=${c.reconnectAttempts}, reconnectSuccess=${c.reconnectSuccess}, reconnectFailures=${c.reconnectFailures}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* Calculate next reconnection delay with exponential backoff and jitter
|
|
66
88
|
* Formula: delay = min(initialDelay * 2^attempt, maxDelay) * (1 ± jitter)
|
|
@@ -130,6 +152,8 @@ export class ConnectionManager {
|
|
|
130
152
|
|
|
131
153
|
// Connection successful
|
|
132
154
|
this.state = ConnectionStateEnum.CONNECTED;
|
|
155
|
+
this.connectedAt = Date.now();
|
|
156
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
133
157
|
this.notifyStateChange();
|
|
134
158
|
const successfulAttempt = this.attemptCount;
|
|
135
159
|
this.attemptCount = 0; // Reset counter on success
|
|
@@ -230,17 +254,51 @@ export class ConnectionManager {
|
|
|
230
254
|
return;
|
|
231
255
|
}
|
|
232
256
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
257
|
+
if (this.state !== ConnectionStateEnum.CONNECTED) {
|
|
258
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const withinGraceWindow =
|
|
264
|
+
this.connectedAt !== undefined &&
|
|
265
|
+
now - this.connectedAt < ConnectionManager.HEALTH_CHECK_GRACE_MS;
|
|
266
|
+
if (withinGraceWindow) {
|
|
267
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const socketReadyState = (client.socket as { readyState?: number } | undefined)?.readyState;
|
|
272
|
+
const socketOpen = socketReadyState === 1;
|
|
273
|
+
const unhealthy = !client.connected && !socketOpen;
|
|
274
|
+
|
|
275
|
+
if (!unhealthy) {
|
|
276
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.consecutiveUnhealthyChecks += 1;
|
|
281
|
+
this.runtimeCounters.healthUnhealthyChecks += 1;
|
|
282
|
+
if (
|
|
283
|
+
this.consecutiveUnhealthyChecks <
|
|
284
|
+
ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD
|
|
285
|
+
) {
|
|
286
|
+
this.log?.debug?.(
|
|
287
|
+
`[${this.accountId}] Connection health check unhealthy (${this.consecutiveUnhealthyChecks}/${ConnectionManager.HEALTH_CHECK_UNHEALTHY_THRESHOLD}) connected=${String(client.connected)} socketReadyState=${socketReadyState ?? "unknown"}`,
|
|
237
288
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.log?.warn?.(
|
|
293
|
+
`[${this.accountId}] Connection health check failed - detected disconnection`,
|
|
294
|
+
);
|
|
295
|
+
this.runtimeCounters.healthTriggeredReconnects += 1;
|
|
296
|
+
this.logRuntimeCounters("health-triggered-reconnect");
|
|
297
|
+
if (this.healthCheckInterval) {
|
|
298
|
+
clearInterval(this.healthCheckInterval);
|
|
242
299
|
}
|
|
243
|
-
|
|
300
|
+
this.handleRuntimeDisconnection();
|
|
301
|
+
}, ConnectionManager.HEALTH_CHECK_INTERVAL_MS);
|
|
244
302
|
|
|
245
303
|
// Additionally, if we have access to the socket, monitor its events
|
|
246
304
|
// The DWClient uses 'ws' WebSocket library which extends EventEmitter
|
|
@@ -251,9 +309,11 @@ export class ConnectionManager {
|
|
|
251
309
|
|
|
252
310
|
// Handler for socket close event
|
|
253
311
|
this.socketCloseHandler = (code: number, reason: string) => {
|
|
312
|
+
this.runtimeCounters.socketCloseEvents += 1;
|
|
254
313
|
this.log?.warn?.(
|
|
255
314
|
`[${this.accountId}] WebSocket closed event (code: ${code}, reason: ${reason || "none"})`,
|
|
256
315
|
);
|
|
316
|
+
this.logRuntimeCounters("socket-close");
|
|
257
317
|
|
|
258
318
|
// Only trigger reconnection if we were previously connected and not stopping
|
|
259
319
|
if (!this.stopped && this.state === ConnectionStateEnum.CONNECTED) {
|
|
@@ -319,10 +379,13 @@ export class ConnectionManager {
|
|
|
319
379
|
this.log?.warn?.(
|
|
320
380
|
`[${this.accountId}] Runtime disconnection detected, initiating reconnection...`,
|
|
321
381
|
);
|
|
382
|
+
this.runtimeCounters.runtimeDisconnects += 1;
|
|
322
383
|
|
|
323
384
|
this.state = ConnectionStateEnum.DISCONNECTED;
|
|
324
385
|
this.notifyStateChange("Runtime disconnection detected");
|
|
325
386
|
this.attemptCount = 0; // Reset attempt counter for runtime reconnection
|
|
387
|
+
this.connectedAt = undefined;
|
|
388
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
326
389
|
|
|
327
390
|
// Clear any existing timer
|
|
328
391
|
this.clearReconnectTimer();
|
|
@@ -349,17 +412,24 @@ export class ConnectionManager {
|
|
|
349
412
|
}
|
|
350
413
|
|
|
351
414
|
this.log?.info?.(`[${this.accountId}] Attempting to reconnect...`);
|
|
415
|
+
this.runtimeCounters.reconnectAttempts += 1;
|
|
352
416
|
|
|
353
417
|
try {
|
|
354
418
|
await this.connect();
|
|
355
419
|
this.log?.info?.(`[${this.accountId}] Reconnection successful`);
|
|
420
|
+
this.runtimeCounters.reconnectSuccess += 1;
|
|
421
|
+
this.logRuntimeCounters("reconnect-success");
|
|
356
422
|
} catch (err: any) {
|
|
357
423
|
if (this.stopped) {
|
|
358
424
|
return;
|
|
359
425
|
}
|
|
360
426
|
|
|
361
427
|
this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
|
|
428
|
+
this.runtimeCounters.reconnectFailures += 1;
|
|
429
|
+
this.logRuntimeCounters("reconnect-failed");
|
|
362
430
|
this.state = ConnectionStateEnum.FAILED;
|
|
431
|
+
this.connectedAt = undefined;
|
|
432
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
363
433
|
this.notifyStateChange(err.message);
|
|
364
434
|
|
|
365
435
|
// Continue runtime recovery instead of getting stuck in FAILED.
|
|
@@ -387,6 +457,8 @@ export class ConnectionManager {
|
|
|
387
457
|
|
|
388
458
|
this.stopped = true;
|
|
389
459
|
this.state = ConnectionStateEnum.DISCONNECTING;
|
|
460
|
+
this.connectedAt = undefined;
|
|
461
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
390
462
|
|
|
391
463
|
// Clear reconnect timer
|
|
392
464
|
this.clearReconnectTimer();
|