@yaoyuanchao/dingtalk 1.7.6 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/monitor.ts +71 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
package/src/monitor.ts CHANGED
@@ -46,6 +46,65 @@ function markMessageProcessed(messageId: string): void {
46
46
  }
47
47
  }
48
48
 
49
+ // ============================================================================
50
+ // Content-level dedup: DingTalk may re-deliver the same message with a NEW
51
+ // msgId after Stream reconnect. Detect by sender + content hash + time window.
52
+ // ============================================================================
53
+
54
+ const CONTENT_DEDUP_WINDOW_MS = 2 * 60 * 1000; // 2 minutes
55
+ const CONTENT_DEDUP_MAX = 200;
56
+ const recentContentHashes = new Map<string, number>(); // hash → timestamp
57
+
58
+ function simpleHash(str: string): string {
59
+ let h = 0;
60
+ for (let i = 0; i < str.length; i++) {
61
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
62
+ }
63
+ return h.toString(36);
64
+ }
65
+
66
+ function isContentDuplicate(senderId: string, text: string, log?: any): boolean {
67
+ if (!senderId || !text || text.length < 4) return false;
68
+ const key = `${senderId}:${simpleHash(text)}:${text.length}`;
69
+ const prev = recentContentHashes.get(key);
70
+ if (prev && Date.now() - prev < CONTENT_DEDUP_WINDOW_MS) {
71
+ log?.info?.("[dingtalk] Content-level duplicate detected: sender=" + senderId + " len=" + text.length);
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function markContentProcessed(senderId: string, text: string): void {
78
+ if (!senderId || !text || text.length < 4) return;
79
+ const key = `${senderId}:${simpleHash(text)}:${text.length}`;
80
+ recentContentHashes.set(key, Date.now());
81
+
82
+ // Evict old entries
83
+ if (recentContentHashes.size > CONTENT_DEDUP_MAX) {
84
+ const cutoff = Date.now() - CONTENT_DEDUP_WINDOW_MS;
85
+ for (const [k, ts] of recentContentHashes) {
86
+ if (ts < cutoff) recentContentHashes.delete(k);
87
+ }
88
+ }
89
+ }
90
+
91
+ // ============================================================================
92
+ // Send Throttle - prevent hitting DingTalk's 40 QPS API rate limit.
93
+ // Enforces a minimum interval between outbound API calls.
94
+ // ============================================================================
95
+
96
+ const SEND_THROTTLE_MS = 500; // 500ms minimum between sends
97
+ let lastSendTime = 0;
98
+
99
+ async function throttleSend(): Promise<void> {
100
+ const now = Date.now();
101
+ const elapsed = now - lastSendTime;
102
+ if (elapsed < SEND_THROTTLE_MS) {
103
+ await new Promise(resolve => setTimeout(resolve, SEND_THROTTLE_MS - elapsed));
104
+ }
105
+ lastSendTime = Date.now();
106
+ }
107
+
49
108
  // ============================================================================
50
109
  // Message Cache - used to resolve quoted message content from originalMsgId
51
110
  // DingTalk Stream API does NOT include quoted content in reply callbacks;
@@ -310,8 +369,8 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
310
369
  });
311
370
 
312
371
  // Reconnection configuration
313
- const HEARTBEAT_CHECK_MS = 30_000; // Check connectivity every 30s
314
- const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no activity = force reconnect
372
+ const HEARTBEAT_CHECK_MS = 15_000; // Check connectivity every 15s
373
+ const HEARTBEAT_TIMEOUT_MS = 90_000; // 90s no activity = force reconnect
315
374
  const RECONNECT_BASE_MS = 1_000; // 1s initial backoff
316
375
  const RECONNECT_CAP_MS = 30_000; // 30s max backoff
317
376
  let reconnectAttempt = 0;
@@ -378,7 +437,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
378
437
  log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
379
438
  setStatus?.({ running: true, lastStartAt: connectTime });
380
439
 
381
- // Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
440
+ // Start heartbeat monitor: if no activity for 90s, force disconnect to trigger reconnect.
382
441
  // The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
383
442
  // client.connected=false on missed pongs, which our poll loop below detects.
384
443
  // This heartbeat is a secondary safety net for higher-level silent failures where
@@ -1280,6 +1339,13 @@ async function processInboundMessage(
1280
1339
  }
1281
1340
  }
1282
1341
 
1342
+ // Content-level dedup: DingTalk may re-deliver the same message with a
1343
+ // completely new msgId after Stream reconnect. Catch by sender + content hash.
1344
+ if (isContentDuplicate(senderId, rawBody, log)) {
1345
+ return;
1346
+ }
1347
+ markContentProcessed(senderId, rawBody);
1348
+
1283
1349
  const sessionKey = "dingtalk:" + account.accountId + ":" + (isDm ? "dm" : "group") + ":" + conversationId;
1284
1350
 
1285
1351
  const replyTarget = {
@@ -1967,6 +2033,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1967
2033
  if (target.sessionWebhook && now < (target.sessionWebhookExpiry - 60_000)) {
1968
2034
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1969
2035
  try {
2036
+ await throttleSend();
1970
2037
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
1971
2038
  log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
1972
2039
  let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
@@ -2008,6 +2075,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
2008
2075
  // Fallback to REST API if webhook failed after all retries
2009
2076
  if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
2010
2077
  try {
2078
+ await throttleSend();
2011
2079
  log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
2012
2080
  const restResult = await sendDingTalkRestMessage({
2013
2081
  clientId: target.account.clientId,