@yaoyuanchao/dingtalk 1.7.6 → 1.7.8
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/api.ts +14 -4
- package/src/monitor.ts +79 -4
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -116,11 +116,16 @@ export async function getDingTalkAccessToken(clientId: string, clientSecret: str
|
|
|
116
116
|
export async function sendViaSessionWebhook(
|
|
117
117
|
sessionWebhook: string,
|
|
118
118
|
text: string,
|
|
119
|
+
atUserIds?: string[],
|
|
119
120
|
): Promise<{ ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string }> {
|
|
120
|
-
const
|
|
121
|
+
const body: any = {
|
|
121
122
|
msgtype: "text",
|
|
122
123
|
text: { content: text },
|
|
123
|
-
}
|
|
124
|
+
};
|
|
125
|
+
if (atUserIds?.length) {
|
|
126
|
+
body.at = { atUserIds, isAtAll: false };
|
|
127
|
+
}
|
|
128
|
+
const res = await jsonPost(sessionWebhook, body);
|
|
124
129
|
const ok = res?.errcode === 0 || !res?.errcode;
|
|
125
130
|
if (!ok) {
|
|
126
131
|
console.warn(`[dingtalk] SessionWebhook text error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
|
|
@@ -133,11 +138,16 @@ export async function sendMarkdownViaSessionWebhook(
|
|
|
133
138
|
sessionWebhook: string,
|
|
134
139
|
title: string,
|
|
135
140
|
text: string,
|
|
141
|
+
atUserIds?: string[],
|
|
136
142
|
): Promise<{ ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string }> {
|
|
137
|
-
const
|
|
143
|
+
const body: any = {
|
|
138
144
|
msgtype: "markdown",
|
|
139
145
|
markdown: { title, text },
|
|
140
|
-
}
|
|
146
|
+
};
|
|
147
|
+
if (atUserIds?.length) {
|
|
148
|
+
body.at = { atUserIds, isAtAll: false };
|
|
149
|
+
}
|
|
150
|
+
const res = await jsonPost(sessionWebhook, body);
|
|
141
151
|
const ok = res?.errcode === 0 || !res?.errcode;
|
|
142
152
|
if (!ok) {
|
|
143
153
|
console.warn(`[dingtalk] SessionWebhook markdown error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
|
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;
|
|
@@ -311,7 +370,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
311
370
|
|
|
312
371
|
// Reconnection configuration
|
|
313
372
|
const HEARTBEAT_CHECK_MS = 30_000; // Check connectivity every 30s
|
|
314
|
-
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no activity = force reconnect
|
|
373
|
+
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no activity = force reconnect (safety net only)
|
|
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;
|
|
@@ -362,6 +421,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
362
421
|
});
|
|
363
422
|
|
|
364
423
|
client.registerAllEventListener((msg: any) => {
|
|
424
|
+
touchActivity(); // SDK events (including ping/pong) count as activity
|
|
365
425
|
return { status: "SUCCESS", message: "OK" };
|
|
366
426
|
});
|
|
367
427
|
|
|
@@ -378,7 +438,7 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
378
438
|
log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
|
|
379
439
|
setStatus?.({ running: true, lastStartAt: connectTime });
|
|
380
440
|
|
|
381
|
-
// Start heartbeat monitor: if no activity for 5
|
|
441
|
+
// Start heartbeat monitor: if no activity for 5 min, force disconnect to trigger reconnect.
|
|
382
442
|
// The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
|
|
383
443
|
// client.connected=false on missed pongs, which our poll loop below detects.
|
|
384
444
|
// This heartbeat is a secondary safety net for higher-level silent failures where
|
|
@@ -1280,6 +1340,13 @@ async function processInboundMessage(
|
|
|
1280
1340
|
}
|
|
1281
1341
|
}
|
|
1282
1342
|
|
|
1343
|
+
// Content-level dedup: DingTalk may re-deliver the same message with a
|
|
1344
|
+
// completely new msgId after Stream reconnect. Catch by sender + content hash.
|
|
1345
|
+
if (isContentDuplicate(senderId, rawBody, log)) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
markContentProcessed(senderId, rawBody);
|
|
1349
|
+
|
|
1283
1350
|
const sessionKey = "dingtalk:" + account.accountId + ":" + (isDm ? "dm" : "group") + ":" + conversationId;
|
|
1284
1351
|
|
|
1285
1352
|
const replyTarget = {
|
|
@@ -1959,6 +2026,10 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1959
2026
|
}
|
|
1960
2027
|
}
|
|
1961
2028
|
|
|
2029
|
+
// Auto @mention sender in group chats (only on first chunk to avoid spam)
|
|
2030
|
+
const atUserIds = (!target.isDm && target.senderId) ? [target.senderId] : undefined;
|
|
2031
|
+
let atApplied = false; // Only @ on the first chunk
|
|
2032
|
+
|
|
1962
2033
|
for (const chunk of chunks) {
|
|
1963
2034
|
let webhookSuccess = false;
|
|
1964
2035
|
const maxRetries = 2;
|
|
@@ -1967,14 +2038,16 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1967
2038
|
if (target.sessionWebhook && now < (target.sessionWebhookExpiry - 60_000)) {
|
|
1968
2039
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1969
2040
|
try {
|
|
2041
|
+
await throttleSend();
|
|
1970
2042
|
log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
|
|
1971
2043
|
log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
|
|
2044
|
+
const currentAt = (!atApplied && atUserIds) ? atUserIds : undefined;
|
|
1972
2045
|
let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
|
|
1973
2046
|
if (isMarkdown) {
|
|
1974
2047
|
const markdownTitle = buildMarkdownPreviewTitle(chunk, "Jax");
|
|
1975
|
-
sendResult = await sendMarkdownViaSessionWebhook(target.sessionWebhook, markdownTitle, chunk);
|
|
2048
|
+
sendResult = await sendMarkdownViaSessionWebhook(target.sessionWebhook, markdownTitle, chunk, currentAt);
|
|
1976
2049
|
} else {
|
|
1977
|
-
sendResult = await sendViaSessionWebhook(target.sessionWebhook, chunk);
|
|
2050
|
+
sendResult = await sendViaSessionWebhook(target.sessionWebhook, chunk, currentAt);
|
|
1978
2051
|
}
|
|
1979
2052
|
if (!sendResult.ok) {
|
|
1980
2053
|
throw new Error(`SessionWebhook rejected: errcode=${sendResult.errcode}, errmsg=${sendResult.errmsg}`);
|
|
@@ -1988,6 +2061,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1988
2061
|
// so interactiveCard quotes can resolve via repliedMsg.createdAt
|
|
1989
2062
|
cacheOutboundMessageByTime(chunk);
|
|
1990
2063
|
}
|
|
2064
|
+
if (currentAt) atApplied = true;
|
|
1991
2065
|
webhookSuccess = true;
|
|
1992
2066
|
break;
|
|
1993
2067
|
} catch (err) {
|
|
@@ -2008,6 +2082,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
2008
2082
|
// Fallback to REST API if webhook failed after all retries
|
|
2009
2083
|
if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
|
|
2010
2084
|
try {
|
|
2085
|
+
await throttleSend();
|
|
2011
2086
|
log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
|
|
2012
2087
|
const restResult = await sendDingTalkRestMessage({
|
|
2013
2088
|
clientId: target.account.clientId,
|