@yaoyuanchao/dingtalk 1.7.5 → 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 +123 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.7.5",
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;
@@ -198,6 +257,30 @@ const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catchin
198
257
 
199
258
  const sessionQueues = new Map<string, Promise<void>>();
200
259
  const sessionQueueLastActivity = new Map<string, number>();
260
+
261
+ // Track delivery activity per queue key to detect when the SDK's dispatch resolves
262
+ // before the agent's full turn completes (e.g. followup turns running in background).
263
+ // This supplements sessionQueues for the "busy" check.
264
+ const DELIVER_ACTIVITY_GRACE_MS = 8000; // 8s after last delivery, consider idle
265
+ const deliverActivityTimestamps = new Map<string, number>();
266
+
267
+ function markDeliverActivity(queueKey: string): void {
268
+ deliverActivityTimestamps.set(queueKey, Date.now());
269
+ }
270
+
271
+ function hasActiveDelivery(queueKey: string): boolean {
272
+ const ts = deliverActivityTimestamps.get(queueKey);
273
+ if (!ts) return false;
274
+ if (Date.now() - ts > DELIVER_ACTIVITY_GRACE_MS) {
275
+ deliverActivityTimestamps.delete(queueKey);
276
+ return false;
277
+ }
278
+ return true;
279
+ }
280
+
281
+ function clearDeliverActivity(queueKey: string): void {
282
+ deliverActivityTimestamps.delete(queueKey);
283
+ }
201
284
  const SESSION_QUEUE_TTL_MS = 5 * 60 * 1000; // 5 min
202
285
 
203
286
  const QUEUE_BUSY_PHRASES = [
@@ -286,8 +369,8 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
286
369
  });
287
370
 
288
371
  // Reconnection configuration
289
- const HEARTBEAT_CHECK_MS = 30_000; // Check connectivity every 30s
290
- 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
291
374
  const RECONNECT_BASE_MS = 1_000; // 1s initial backoff
292
375
  const RECONNECT_CAP_MS = 30_000; // 30s max backoff
293
376
  let reconnectAttempt = 0;
@@ -354,7 +437,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
354
437
  log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
355
438
  setStatus?.({ running: true, lastStartAt: connectTime });
356
439
 
357
- // 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.
358
441
  // The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
359
442
  // client.connected=false on missed pongs, which our poll loop below detects.
360
443
  // This heartbeat is a secondary safety net for higher-level silent failures where
@@ -1256,6 +1339,13 @@ async function processInboundMessage(
1256
1339
  }
1257
1340
  }
1258
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
+
1259
1349
  const sessionKey = "dingtalk:" + account.accountId + ":" + (isDm ? "dm" : "group") + ":" + conversationId;
1260
1350
 
1261
1351
  const replyTarget = {
@@ -1395,7 +1485,11 @@ async function dispatchMessage(params: {
1395
1485
  const { account, log } = ctx;
1396
1486
 
1397
1487
  const queueKey = `${account.accountId}:${conversationId}`;
1398
- const isQueueBusy = sessionQueues.has(queueKey);
1488
+ // Check both the explicit queue AND recent delivery activity.
1489
+ // The SDK's dispatchReplyFromConfig may resolve before the agent's full turn
1490
+ // completes (followup turns run in background), clearing the queue entry
1491
+ // while deliveries are still happening.
1492
+ const isQueueBusy = sessionQueues.has(queueKey) || hasActiveDelivery(queueKey);
1399
1493
 
1400
1494
  // If queue is busy, add emotion reaction on user's message to indicate queued
1401
1495
  let queueAckCleanup: (() => Promise<void>) | null = null;
@@ -1437,11 +1531,13 @@ async function dispatchMessage(params: {
1437
1531
  // Clean up only if this is still the latest task
1438
1532
  if (sessionQueues.get(queueKey) === currentTask) {
1439
1533
  sessionQueues.delete(queueKey);
1534
+ log?.info?.("[dingtalk] Queue entry removed for " + queueKey + " (deliverActive=" + hasActiveDelivery(queueKey) + ")");
1440
1535
  }
1441
1536
  });
1442
1537
 
1443
1538
  sessionQueues.set(queueKey, currentTask);
1444
1539
  sessionQueueLastActivity.set(queueKey, Date.now());
1540
+ log?.info?.("[dingtalk] Queue entry set for " + queueKey + " (wasQueueBusy=" + isQueueBusy + ")");
1445
1541
 
1446
1542
  // Don't await — fire-and-forget so message buffering and SDK callback stay responsive
1447
1543
  }
@@ -1604,11 +1700,14 @@ async function dispatchMessageInternal(params: {
1604
1700
 
1605
1701
  // Await dispatch so per-session queue waits for reply delivery to complete
1606
1702
  // before starting the next queued message.
1703
+ const fallbackQueueKey = `${account.accountId}:${conversationId}`;
1607
1704
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1608
1705
  ctx: ctxPayload,
1609
1706
  cfg: actualCfg,
1610
1707
  dispatcherOptions: {
1611
1708
  deliver: async (payload: any) => {
1709
+ // Track delivery activity for queue busy detection
1710
+ markDeliverActivity(fallbackQueueKey);
1612
1711
  // Recall typing indicator on first delivery
1613
1712
  await cleanupTyping();
1614
1713
 
@@ -1757,9 +1856,12 @@ async function dispatchWithFullPipeline(params: {
1757
1856
  }
1758
1857
 
1759
1858
  // 8. Create typing-aware dispatcher
1859
+ const deliverQueueKey = `${account.accountId}:${conversationId}`;
1760
1860
  const { dispatcher, replyOptions, markDispatchIdle } = rt.channel.reply.createReplyDispatcherWithTyping({
1761
1861
  responsePrefix: '',
1762
1862
  deliver: async (payload: any) => {
1863
+ // Track delivery activity for queue busy detection
1864
+ markDeliverActivity(deliverQueueKey);
1763
1865
  // Recall typing indicator on first delivery
1764
1866
  if (!firstReplyFired && onFirstReply) {
1765
1867
  firstReplyFired = true;
@@ -1787,14 +1889,24 @@ async function dispatchWithFullPipeline(params: {
1787
1889
 
1788
1890
  // 9. Dispatch reply from config
1789
1891
  try {
1892
+ log?.info?.("[dingtalk] dispatchReplyFromConfig started for " + deliverQueueKey);
1790
1893
  await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
1894
+ log?.info?.("[dingtalk] dispatchReplyFromConfig completed for " + deliverQueueKey);
1791
1895
  } finally {
1896
+ // OpenClaw 2026.4+ moved dispatcher.markComplete() + waitForIdle() out of
1897
+ // dispatchReplyFromConfig into the withReplyDispatcher wrapper. Since we call
1898
+ // dispatchReplyFromConfig directly (not through withReplyDispatcher), we must
1899
+ // do this ourselves to ensure all pending deliveries drain before returning.
1900
+ try {
1901
+ if (typeof dispatcher.markComplete === 'function') {
1902
+ dispatcher.markComplete();
1903
+ }
1904
+ await dispatcher.waitForIdle();
1905
+ log?.info?.("[dingtalk] dispatcher.waitForIdle completed for " + deliverQueueKey);
1906
+ } catch (err) {
1907
+ log?.info?.("[dingtalk] dispatcher settle error: " + err);
1908
+ }
1792
1909
  markDispatchIdle();
1793
- // Don't recall typing here — dispatchReplyFromConfig resolves when dispatch
1794
- // is *initiated*, not when the agent finishes. The agent may still be doing
1795
- // tool calls for many minutes before producing text. The deliver callback
1796
- // above handles recall on first delivery. If the agent crashes without
1797
- // replying, the emoji reaction simply stays — acceptable tradeoff.
1798
1910
  }
1799
1911
 
1800
1912
  // 10. Record activity
@@ -1921,6 +2033,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1921
2033
  if (target.sessionWebhook && now < (target.sessionWebhookExpiry - 60_000)) {
1922
2034
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1923
2035
  try {
2036
+ await throttleSend();
1924
2037
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
1925
2038
  log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
1926
2039
  let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
@@ -1962,6 +2075,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1962
2075
  // Fallback to REST API if webhook failed after all retries
1963
2076
  if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
1964
2077
  try {
2078
+ await throttleSend();
1965
2079
  log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
1966
2080
  const restResult = await sendDingTalkRestMessage({
1967
2081
  clientId: target.account.clientId,