@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.
- package/package.json +1 -1
- package/src/monitor.ts +123 -9
package/package.json
CHANGED
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 =
|
|
290
|
-
const HEARTBEAT_TIMEOUT_MS =
|
|
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
|
|
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
|
-
|
|
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,
|