@yaoyuanchao/dingtalk 1.5.11 → 1.6.0

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 +145 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.5.11",
3
+ "version": "1.6.0",
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
@@ -7,6 +7,45 @@ import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import * as os from "os";
9
9
 
10
+ // ============================================================================
11
+ // Message Deduplication - prevent duplicate processing after Stream reconnect.
12
+ // DingTalk re-delivers messages that weren't ACK'd before disconnect using the
13
+ // same protocol messageId, so we track recently processed IDs.
14
+ // ============================================================================
15
+
16
+ const DEDUP_MAX_SIZE = 1000;
17
+ const DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
18
+
19
+ const processedMessageIds = new Map<string, number>(); // messageId → timestamp
20
+
21
+ function isDuplicateMessage(messageId: string): boolean {
22
+ if (!messageId) return false;
23
+ return processedMessageIds.has(messageId);
24
+ }
25
+
26
+ function markMessageProcessed(messageId: string): void {
27
+ if (!messageId) return;
28
+ processedMessageIds.set(messageId, Date.now());
29
+
30
+ // Evict expired entries when cache exceeds max size
31
+ if (processedMessageIds.size > DEDUP_MAX_SIZE) {
32
+ const cutoff = Date.now() - DEDUP_TTL_MS;
33
+ for (const [id, ts] of processedMessageIds) {
34
+ if (ts < cutoff) processedMessageIds.delete(id);
35
+ }
36
+ // If still over limit after TTL eviction, drop oldest
37
+ if (processedMessageIds.size > DEDUP_MAX_SIZE) {
38
+ const overflow = processedMessageIds.size - DEDUP_MAX_SIZE;
39
+ let removed = 0;
40
+ for (const id of processedMessageIds.keys()) {
41
+ if (removed >= overflow) break;
42
+ processedMessageIds.delete(id);
43
+ removed++;
44
+ }
45
+ }
46
+ }
47
+ }
48
+
10
49
  // ============================================================================
11
50
  // Message Cache - used to resolve quoted message content from originalMsgId
12
51
  // DingTalk Stream API does NOT include quoted content in reply callbacks;
@@ -204,15 +243,39 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
204
243
  const client = new DWClient({
205
244
  clientId: account.clientId,
206
245
  clientSecret: account.clientSecret,
246
+ keepAlive: true, // Enable SDK ping/pong for socket-level liveness
247
+ autoReconnect: false, // We manage reconnection with exponential backoff
207
248
  });
208
249
 
250
+ // Reconnection configuration
251
+ const HEARTBEAT_CHECK_MS = 30_000; // Check connectivity every 30s
252
+ const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no activity = force reconnect
253
+ const RECONNECT_BASE_MS = 1_000; // 1s initial backoff
254
+ const RECONNECT_CAP_MS = 30_000; // 30s max backoff
255
+ let reconnectAttempt = 0;
256
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
257
+ let lastActivityTime = Date.now();
258
+
259
+ // Track message activity to extend heartbeat window
260
+ const touchActivity = () => { lastActivityTime = Date.now(); };
261
+
209
262
  client.registerCallbackListener(TOPIC_ROBOT, async (downstream: any) => {
263
+ const protocolMsgId = downstream.headers?.messageId;
264
+
210
265
  // Immediately ACK to prevent DingTalk from retrying (60s timeout)
211
- // SDK method is socketCallBackResponse, not socketResponse
212
266
  try {
213
- client.socketCallBackResponse(downstream.headers.messageId, { status: 'SUCCESS' });
267
+ client.socketCallBackResponse(protocolMsgId, { status: 'SUCCESS' });
214
268
  } catch (_) { /* best-effort ACK */ }
215
269
 
270
+ touchActivity(); // Track message activity for heartbeat
271
+
272
+ // Deduplication: skip messages already processed (e.g. re-delivered after reconnect)
273
+ if (isDuplicateMessage(protocolMsgId)) {
274
+ log?.info?.("[dingtalk] Duplicate message skipped: " + protocolMsgId);
275
+ return { status: "SUCCESS", message: "OK" };
276
+ }
277
+ markMessageProcessed(protocolMsgId);
278
+
216
279
  try {
217
280
  const data: DingTalkRobotMessage = typeof downstream.data === "string"
218
281
  ? JSON.parse(downstream.data) : downstream.data;
@@ -228,28 +291,76 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
228
291
  return { status: "SUCCESS", message: "OK" };
229
292
  });
230
293
 
231
- const onAbort = () => {
232
- try { client.disconnect?.(); } catch {}
233
- setStatus?.({ running: false, lastStopAt: Date.now() });
234
- };
235
- if (abortSignal) {
236
- abortSignal.addEventListener("abort", onAbort, { once: true });
237
- }
238
-
239
- await client.connect();
240
- log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
241
- setStatus?.({ running: true, lastStartAt: Date.now() });
242
-
243
- // Keep this function alive until abort signal fires.
294
+ // ============================================================================
295
+ // Connection loop with custom heartbeat + exponential backoff reconnection.
296
+ // Keeps this function alive until abort signal fires.
244
297
  // If we return, OpenClaw considers the channel "stopped" and enters auto-restart loop.
245
- // The DingTalk SDK's connect() resolves immediately (before WebSocket opens),
246
- // so we must hold the Promise pending for the channel's entire lifetime.
247
- await new Promise<void>((resolve) => {
248
- if (abortSignal?.aborted) return resolve();
249
- if (abortSignal) {
250
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
298
+ // ============================================================================
299
+ while (!abortSignal?.aborted) {
300
+ try {
301
+ await client.connect();
302
+ reconnectAttempt = 0;
303
+ lastActivityTime = Date.now();
304
+ log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
305
+ setStatus?.({ running: true, lastStartAt: Date.now() });
306
+
307
+ // Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
308
+ // The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
309
+ // client.connected=false on missed pongs, which our poll loop below detects.
310
+ // This heartbeat is a secondary safety net for higher-level silent failures where
311
+ // the socket stays open but DingTalk stops delivering messages.
312
+ heartbeatTimer = setInterval(() => {
313
+ const elapsed = Date.now() - lastActivityTime;
314
+ if (elapsed > HEARTBEAT_TIMEOUT_MS) {
315
+ log?.warn?.("[dingtalk] Heartbeat timeout (" + Math.round(elapsed / 1000) + "s since last activity), forcing reconnect");
316
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
317
+ try { client.disconnect?.(); } catch {}
318
+ }
319
+ }, HEARTBEAT_CHECK_MS);
320
+
321
+ // Wait for disconnect or abort
322
+ await new Promise<void>((resolve) => {
323
+ // Poll client.connected (SDK sets false on socket close / system disconnect)
324
+ const pollTimer = setInterval(() => {
325
+ if (!client.connected) { clearInterval(pollTimer); resolve(); }
326
+ }, 1000);
327
+ if (abortSignal) {
328
+ abortSignal.addEventListener("abort", () => {
329
+ clearInterval(pollTimer);
330
+ resolve();
331
+ }, { once: true });
332
+ }
333
+ });
334
+ } catch (err) {
335
+ log?.warn?.("[dingtalk] Connection error: " + (err instanceof Error ? err.message : String(err)));
251
336
  }
252
- });
337
+
338
+ // Clean up heartbeat
339
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
340
+
341
+ if (abortSignal?.aborted) break; // Clean shutdown
342
+
343
+ setStatus?.({ running: false });
344
+
345
+ // Exponential backoff with jitter
346
+ reconnectAttempt++;
347
+ const backoff = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt - 1), RECONNECT_CAP_MS);
348
+ const jitter = Math.random() * backoff * 0.3;
349
+ const delay = Math.round(backoff + jitter);
350
+ log?.info?.("[dingtalk] Reconnect attempt " + reconnectAttempt + " in " + delay + "ms");
351
+
352
+ // Sleep with abort-awareness
353
+ await new Promise<void>((resolve) => {
354
+ const timer = setTimeout(resolve, delay);
355
+ if (abortSignal) {
356
+ abortSignal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
357
+ }
358
+ });
359
+ }
360
+
361
+ // Final cleanup
362
+ try { client.disconnect?.(); } catch {}
363
+ setStatus?.({ running: false, lastStopAt: Date.now() });
253
364
  }
254
365
 
255
366
  /**
@@ -1614,8 +1725,8 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1614
1725
  let webhookSuccess = false;
1615
1726
  const maxRetries = 2;
1616
1727
 
1617
- // Try sessionWebhook with retry
1618
- if (target.sessionWebhook && now < target.sessionWebhookExpiry) {
1728
+ // Try sessionWebhook with retry (60s safety buffer before expiry)
1729
+ if (target.sessionWebhook && now < (target.sessionWebhookExpiry - 60_000)) {
1619
1730
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1620
1731
  try {
1621
1732
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
@@ -1642,9 +1753,14 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1642
1753
  webhookSuccess = true;
1643
1754
  break;
1644
1755
  } catch (err) {
1645
- log?.info?.("[dingtalk] SessionWebhook attempt " + attempt + " failed: " + (err instanceof Error ? err.message : String(err)));
1756
+ const errMsg = err instanceof Error ? err.message : String(err);
1757
+ log?.info?.("[dingtalk] SessionWebhook attempt " + attempt + " failed: " + errMsg);
1758
+ // If webhook is definitively expired/invalid, skip remaining retries
1759
+ if (errMsg.includes('880001') || errMsg.includes('invalid session') || errMsg.includes('expired') || errMsg.includes('token is not exist')) {
1760
+ log?.info?.("[dingtalk] SessionWebhook expired/invalid, falling through to REST API");
1761
+ break;
1762
+ }
1646
1763
  if (attempt < maxRetries) {
1647
- // Wait 1 second before retry
1648
1764
  await new Promise(resolve => setTimeout(resolve, 1000));
1649
1765
  }
1650
1766
  }
@@ -1654,16 +1770,15 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1654
1770
  // Fallback to REST API if webhook failed after all retries
1655
1771
  if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
1656
1772
  try {
1657
- log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
1658
- // REST API only supports text format
1659
- const textChunk = messageFormat === "markdown" ? chunk : chunk;
1773
+ log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
1660
1774
  const restResult = await sendDingTalkRestMessage({
1661
1775
  clientId: target.account.clientId,
1662
1776
  clientSecret: target.account.clientSecret,
1663
1777
  robotCode: target.account.robotCode || target.account.clientId,
1664
1778
  userId: target.isDm ? target.senderId : undefined,
1665
1779
  conversationId: !target.isDm ? target.conversationId : undefined,
1666
- text: textChunk,
1780
+ text: chunk,
1781
+ format: isMarkdown ? 'markdown' : 'text',
1667
1782
  });
1668
1783
  log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
1669
1784
  // Cache outbound message so it can be resolved when quoted