@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.
- package/package.json +1 -1
- package/src/monitor.ts +145 -30
package/package.json
CHANGED
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(
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|