@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.
- package/package.json +1 -1
- package/src/monitor.ts +71 -3
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;
|
|
@@ -310,8 +369,8 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
310
369
|
});
|
|
311
370
|
|
|
312
371
|
// Reconnection configuration
|
|
313
|
-
const HEARTBEAT_CHECK_MS =
|
|
314
|
-
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
|
|
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
|
|
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,
|