@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
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 (!msgId) {
311
+ if (!dedupKey) {
268
312
  ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
269
- } else {
270
- const dedupKey = `${robotKey}:${msgId}`;
271
- if (isMessageProcessed(dedupKey)) {
272
- ctx.log?.debug?.(`[${account.accountId}] Skipping duplicate message: ${dedupKey}`);
273
- return;
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
- markMessageProcessed(dedupKey);
326
+ return;
276
327
  }
277
328
 
278
- await handleDingTalkMessage({
279
- cfg,
280
- accountId: account.accountId,
281
- data,
282
- sessionWebhook: data.sessionWebhook,
283
- log: ctx.log,
284
- dingtalkConfig: config,
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
- // If we believe we're connected but DWClient disagrees, trigger reconnection
234
- if (this.state === ConnectionStateEnum.CONNECTED && !client.connected) {
235
- this.log?.warn?.(
236
- `[${this.accountId}] Connection health check failed - detected disconnection`,
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
- if (this.healthCheckInterval) {
239
- clearInterval(this.healthCheckInterval);
240
- }
241
- this.handleRuntimeDisconnection();
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
- }, 5000); // Check every 5 seconds
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();