@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.7.6",
3
+ "version": "1.7.8",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
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 res = await jsonPost(sessionWebhook, {
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 res = await jsonPost(sessionWebhook, {
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 minutes, force disconnect to trigger reconnect.
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,