@yaoyuanchao/dingtalk 1.5.10 → 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/channel.ts +515 -510
- package/src/monitor.ts +312 -32
- package/src/quoted-file-service.ts +404 -0
- package/src/quoted-msg-cache.ts +213 -0
package/src/monitor.ts
CHANGED
|
@@ -1,10 +1,51 @@
|
|
|
1
1
|
import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
|
|
2
2
|
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator } from "./api.js";
|
|
3
3
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
4
|
+
import { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache.js";
|
|
5
|
+
import { resolveQuotedFile } from "./quoted-file-service.js";
|
|
4
6
|
import * as fs from "fs";
|
|
5
7
|
import * as path from "path";
|
|
6
8
|
import * as os from "os";
|
|
7
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
|
+
|
|
8
49
|
// ============================================================================
|
|
9
50
|
// Message Cache - used to resolve quoted message content from originalMsgId
|
|
10
51
|
// DingTalk Stream API does NOT include quoted content in reply callbacks;
|
|
@@ -26,6 +67,15 @@ const MSG_CACHE_FILE = path.join(os.homedir(), ".openclaw", "extensions", "dingt
|
|
|
26
67
|
|
|
27
68
|
const msgCache = new Map<string, CachedMessage>();
|
|
28
69
|
|
|
70
|
+
/** Time-indexed outbound message cache for interactiveCard quote resolution.
|
|
71
|
+
* Key = send timestamp (ms), value = message text.
|
|
72
|
+
* When a quote comes in with msgType=interactiveCard (no content), we look up
|
|
73
|
+
* the closest outbound message by repliedMsg.createdAt within OUTBOUND_TIME_WINDOW_MS.
|
|
74
|
+
*/
|
|
75
|
+
const outboundByTime: Array<{ ts: number; text: string }> = [];
|
|
76
|
+
const OUTBOUND_TIME_CACHE_MAX = 100;
|
|
77
|
+
const OUTBOUND_TIME_WINDOW_MS = 10_000; // ±10s tolerance
|
|
78
|
+
|
|
29
79
|
/** Load persisted cache from disk on startup */
|
|
30
80
|
function loadMsgCache(): void {
|
|
31
81
|
try {
|
|
@@ -87,7 +137,34 @@ function msgCacheSet(msgId: string, entry: CachedMessage): void {
|
|
|
87
137
|
/** Cache an outbound (bot-sent) message so it can be resolved when quoted */
|
|
88
138
|
export function cacheOutboundMessage(key: string, text: string): void {
|
|
89
139
|
if (!key || !text) return;
|
|
90
|
-
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
msgCacheSet(key, { senderNick: 'Jax', text, msgtype: 'text', ts: now });
|
|
142
|
+
// Also cache by timestamp for interactiveCard quote resolution
|
|
143
|
+
outboundByTime.push({ ts: now, text });
|
|
144
|
+
if (outboundByTime.length > OUTBOUND_TIME_CACHE_MAX) outboundByTime.shift();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Cache an outbound message by timestamp only (for sessionWebhook sends that return no key) */
|
|
148
|
+
export function cacheOutboundMessageByTime(text: string): void {
|
|
149
|
+
if (!text) return;
|
|
150
|
+
outboundByTime.push({ ts: Date.now(), text });
|
|
151
|
+
if (outboundByTime.length > OUTBOUND_TIME_CACHE_MAX) outboundByTime.shift();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Look up an outbound message by DingTalk createdAt timestamp (for interactiveCard quotes) */
|
|
155
|
+
function resolveOutboundByTime(createdAt: number): string | undefined {
|
|
156
|
+
if (!createdAt || outboundByTime.length === 0) return undefined;
|
|
157
|
+
// Find the closest entry within the tolerance window
|
|
158
|
+
let best: { ts: number; text: string } | undefined;
|
|
159
|
+
let bestDiff = Infinity;
|
|
160
|
+
for (const entry of outboundByTime) {
|
|
161
|
+
const diff = Math.abs(entry.ts - createdAt);
|
|
162
|
+
if (diff < OUTBOUND_TIME_WINDOW_MS && diff < bestDiff) {
|
|
163
|
+
best = entry;
|
|
164
|
+
bestDiff = diff;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return best?.text;
|
|
91
168
|
}
|
|
92
169
|
|
|
93
170
|
// ============================================================================
|
|
@@ -166,15 +243,39 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
166
243
|
const client = new DWClient({
|
|
167
244
|
clientId: account.clientId,
|
|
168
245
|
clientSecret: account.clientSecret,
|
|
246
|
+
keepAlive: true, // Enable SDK ping/pong for socket-level liveness
|
|
247
|
+
autoReconnect: false, // We manage reconnection with exponential backoff
|
|
169
248
|
});
|
|
170
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
|
+
|
|
171
262
|
client.registerCallbackListener(TOPIC_ROBOT, async (downstream: any) => {
|
|
263
|
+
const protocolMsgId = downstream.headers?.messageId;
|
|
264
|
+
|
|
172
265
|
// Immediately ACK to prevent DingTalk from retrying (60s timeout)
|
|
173
|
-
// SDK method is socketCallBackResponse, not socketResponse
|
|
174
266
|
try {
|
|
175
|
-
client.socketCallBackResponse(
|
|
267
|
+
client.socketCallBackResponse(protocolMsgId, { status: 'SUCCESS' });
|
|
176
268
|
} catch (_) { /* best-effort ACK */ }
|
|
177
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
|
+
|
|
178
279
|
try {
|
|
179
280
|
const data: DingTalkRobotMessage = typeof downstream.data === "string"
|
|
180
281
|
? JSON.parse(downstream.data) : downstream.data;
|
|
@@ -190,28 +291,76 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
190
291
|
return { status: "SUCCESS", message: "OK" };
|
|
191
292
|
});
|
|
192
293
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
};
|
|
197
|
-
if (abortSignal) {
|
|
198
|
-
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
await client.connect();
|
|
202
|
-
log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
|
|
203
|
-
setStatus?.({ running: true, lastStartAt: Date.now() });
|
|
204
|
-
|
|
205
|
-
// 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.
|
|
206
297
|
// If we return, OpenClaw considers the channel "stopped" and enters auto-restart loop.
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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)));
|
|
213
336
|
}
|
|
214
|
-
|
|
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() });
|
|
215
364
|
}
|
|
216
365
|
|
|
217
366
|
/**
|
|
@@ -690,6 +839,15 @@ async function processInboundMessage(
|
|
|
690
839
|
log?.info?.("[dingtalk] Audio ASR text available, skipping .amr download");
|
|
691
840
|
}
|
|
692
841
|
|
|
842
|
+
// Cache inbound file/video/audio downloadCode for quoted-message fallback
|
|
843
|
+
if (extracted.mediaDownloadCode && msg.msgId && msg.conversationId &&
|
|
844
|
+
(extracted.messageType === 'file' || extracted.messageType === 'video' || extracted.messageType === 'audio')) {
|
|
845
|
+
cacheInboundDownloadCode(
|
|
846
|
+
account.accountId, msg.conversationId, msg.msgId,
|
|
847
|
+
extracted.mediaDownloadCode, extracted.messageType, msg.createAt,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
693
851
|
let rawBody = extracted.text;
|
|
694
852
|
|
|
695
853
|
// Check if this might be a quote-only @mention (user quoted a message and @bot with no extra text)
|
|
@@ -821,6 +979,120 @@ async function processInboundMessage(
|
|
|
821
979
|
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
822
980
|
rawBody = `[引用回复${senderTag}: "${quotedContent.trim()}"]\n${rawBody}`;
|
|
823
981
|
log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
|
|
982
|
+
} else if (repliedMsg.msgType === 'interactiveCard' && repliedMsg.createdAt) {
|
|
983
|
+
// interactiveCard: DingTalk doesn't include content in quote payload.
|
|
984
|
+
// Try timestamp-based lookup against our outbound message cache.
|
|
985
|
+
const timeResolved = resolveOutboundByTime(repliedMsg.createdAt);
|
|
986
|
+
if (timeResolved) {
|
|
987
|
+
rawBody = `[引用回复 (Jax): "${timeResolved.trim().substring(0, 200)}"]\n${rawBody}`;
|
|
988
|
+
log?.info?.("[dingtalk] Resolved interactiveCard quote via timestamp (createdAt=" + repliedMsg.createdAt + "): " + timeResolved.slice(0, 50));
|
|
989
|
+
} else {
|
|
990
|
+
log?.warn?.("[dingtalk] interactiveCard quote: no timestamp match in outbound cache (createdAt=" + repliedMsg.createdAt + ", cache size=" + outboundByTime.length + ")");
|
|
991
|
+
}
|
|
992
|
+
} else if (['file', 'video', 'audio', 'unknownMsgType'].includes(repliedMsg.msgType)) {
|
|
993
|
+
// Quoted file/video/audio message — bot may not have seen the original.
|
|
994
|
+
// 'unknownMsgType' is returned by DingTalk for files sent via drag-and-drop
|
|
995
|
+
// (without @bot), so the bot never indexed the original message type.
|
|
996
|
+
// Fallback chain: inline downloadCode → cache → group file API
|
|
997
|
+
log?.info?.("[dingtalk] Quoted file-type message (msgType=" + repliedMsg.msgType + "), attempting fallback resolution");
|
|
998
|
+
const quoteSender = repliedMsg.senderNick || repliedMsg.senderName || '';
|
|
999
|
+
const quoteMsgId = repliedMsg.msgId;
|
|
1000
|
+
let resolvedFile = false;
|
|
1001
|
+
|
|
1002
|
+
// 1. Try inline downloadCode (rare — usually absent when bot didn't see the original)
|
|
1003
|
+
const inlineDownloadCode = repliedMsg.content?.downloadCode || repliedMsg.downloadCode;
|
|
1004
|
+
if (inlineDownloadCode && account.clientId && account.clientSecret) {
|
|
1005
|
+
try {
|
|
1006
|
+
const robotCode = account.robotCode || account.clientId;
|
|
1007
|
+
const dlResult = await downloadMediaFile(
|
|
1008
|
+
account.clientId, account.clientSecret, robotCode,
|
|
1009
|
+
inlineDownloadCode, repliedMsg.msgType,
|
|
1010
|
+
repliedMsg.content?.fileName,
|
|
1011
|
+
);
|
|
1012
|
+
if (dlResult.filePath) {
|
|
1013
|
+
if (!mediaPath) {
|
|
1014
|
+
mediaPath = dlResult.filePath;
|
|
1015
|
+
mediaType = dlResult.mimeType || repliedMsg.msgType;
|
|
1016
|
+
}
|
|
1017
|
+
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
1018
|
+
rawBody = `[引用文件${senderTag}: ${dlResult.filePath}]\n${rawBody}`;
|
|
1019
|
+
resolvedFile = true;
|
|
1020
|
+
log?.info?.("[dingtalk] Quoted file resolved via inline downloadCode: " + dlResult.filePath);
|
|
1021
|
+
}
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
log?.warn?.("[dingtalk] Quoted file inline download failed: " + err);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// 2. Try quoted-msg-cache
|
|
1028
|
+
if (!resolvedFile && quoteMsgId && account.clientId && account.clientSecret) {
|
|
1029
|
+
const cached = getCachedDownloadCode(account.accountId, msg.conversationId, quoteMsgId);
|
|
1030
|
+
if (cached?.downloadCode) {
|
|
1031
|
+
try {
|
|
1032
|
+
const robotCode = account.robotCode || account.clientId;
|
|
1033
|
+
const dlResult = await downloadMediaFile(
|
|
1034
|
+
account.clientId, account.clientSecret, robotCode,
|
|
1035
|
+
cached.downloadCode, cached.msgType as any,
|
|
1036
|
+
);
|
|
1037
|
+
if (dlResult.filePath) {
|
|
1038
|
+
if (!mediaPath) {
|
|
1039
|
+
mediaPath = dlResult.filePath;
|
|
1040
|
+
mediaType = dlResult.mimeType || cached.msgType;
|
|
1041
|
+
}
|
|
1042
|
+
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
1043
|
+
rawBody = `[引用文件${senderTag}: ${dlResult.filePath}]\n${rawBody}`;
|
|
1044
|
+
resolvedFile = true;
|
|
1045
|
+
log?.info?.("[dingtalk] Quoted file resolved via cache (downloadCode): " + dlResult.filePath);
|
|
1046
|
+
}
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
log?.warn?.("[dingtalk] Quoted file cache download failed: " + err);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// 3. Try group file API fallback
|
|
1054
|
+
if (!resolvedFile && account.clientId && account.clientSecret) {
|
|
1055
|
+
try {
|
|
1056
|
+
const fileResult = await resolveQuotedFile(
|
|
1057
|
+
{ clientId: account.clientId, clientSecret: account.clientSecret },
|
|
1058
|
+
{
|
|
1059
|
+
openConversationId: msg.conversationId,
|
|
1060
|
+
// repliedMsg often lacks senderStaffId (DingTalk only provides encrypted senderId).
|
|
1061
|
+
// Fall back to the outer message sender's staffId — any group member works for space query.
|
|
1062
|
+
senderStaffId: repliedMsg.senderStaffId || msg.senderStaffId,
|
|
1063
|
+
fileCreatedAt: repliedMsg.createdAt || repliedMsg.createAt,
|
|
1064
|
+
},
|
|
1065
|
+
log,
|
|
1066
|
+
);
|
|
1067
|
+
if (fileResult) {
|
|
1068
|
+
if (!mediaPath) {
|
|
1069
|
+
mediaPath = fileResult.media.path;
|
|
1070
|
+
mediaType = fileResult.media.mimeType;
|
|
1071
|
+
}
|
|
1072
|
+
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
1073
|
+
const nameLabel = fileResult.name ? ` ${fileResult.name}` : '';
|
|
1074
|
+
rawBody = `[引用文件${senderTag}:${nameLabel} ${fileResult.media.path}]\n${rawBody}`;
|
|
1075
|
+
resolvedFile = true;
|
|
1076
|
+
// Write back to cache for future lookups
|
|
1077
|
+
if (quoteMsgId) {
|
|
1078
|
+
cacheInboundDownloadCode(
|
|
1079
|
+
account.accountId, msg.conversationId, quoteMsgId,
|
|
1080
|
+
undefined, repliedMsg.msgType, repliedMsg.createdAt || repliedMsg.createAt || Date.now(),
|
|
1081
|
+
{ spaceId: fileResult.spaceId, fileId: fileResult.fileId },
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
log?.info?.("[dingtalk] Quoted file resolved via group file API: " + fileResult.media.path);
|
|
1085
|
+
}
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
log?.warn?.("[dingtalk] Quoted file group API fallback failed: " + err);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (!resolvedFile) {
|
|
1092
|
+
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
1093
|
+
rawBody = `[引用文件${senderTag},无法获取内容]\n${rawBody}`;
|
|
1094
|
+
log?.warn?.("[dingtalk] Quoted file could not be resolved, all fallbacks exhausted. msgType=" + repliedMsg.msgType + " msgId=" + (quoteMsgId || 'unknown'));
|
|
1095
|
+
}
|
|
824
1096
|
} else {
|
|
825
1097
|
log?.warn?.("[dingtalk] Reply message found but no content extracted, repliedMsg keys: " + Object.keys(repliedMsg || {}).join(',') + " | full: " + JSON.stringify(repliedMsg).substring(0, 500));
|
|
826
1098
|
}
|
|
@@ -1260,7 +1532,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1260
1532
|
}) ?? rawBody;
|
|
1261
1533
|
|
|
1262
1534
|
// 6. Finalize inbound context (includes media info)
|
|
1263
|
-
const to = isDm ? `dingtalk:${senderId}` : `dingtalk:group:${conversationId}`;
|
|
1535
|
+
const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
|
|
1264
1536
|
|
|
1265
1537
|
// 6a. Per-group system prompt (read from account.config.groups)
|
|
1266
1538
|
const groupsConfig = account?.config?.groups ?? {};
|
|
@@ -1453,8 +1725,8 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1453
1725
|
let webhookSuccess = false;
|
|
1454
1726
|
const maxRetries = 2;
|
|
1455
1727
|
|
|
1456
|
-
// Try sessionWebhook with retry
|
|
1457
|
-
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)) {
|
|
1458
1730
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1459
1731
|
try {
|
|
1460
1732
|
log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
|
|
@@ -1473,13 +1745,22 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1473
1745
|
// Cache outbound message so it can be resolved when quoted
|
|
1474
1746
|
if (sendResult.processQueryKey) {
|
|
1475
1747
|
cacheOutboundMessage(sendResult.processQueryKey, chunk);
|
|
1748
|
+
} else {
|
|
1749
|
+
// sessionWebhook doesn't return processQueryKey — cache by timestamp only
|
|
1750
|
+
// so interactiveCard quotes can resolve via repliedMsg.createdAt
|
|
1751
|
+
cacheOutboundMessageByTime(chunk);
|
|
1476
1752
|
}
|
|
1477
1753
|
webhookSuccess = true;
|
|
1478
1754
|
break;
|
|
1479
1755
|
} catch (err) {
|
|
1480
|
-
|
|
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
|
+
}
|
|
1481
1763
|
if (attempt < maxRetries) {
|
|
1482
|
-
// Wait 1 second before retry
|
|
1483
1764
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1484
1765
|
}
|
|
1485
1766
|
}
|
|
@@ -1489,16 +1770,15 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1489
1770
|
// Fallback to REST API if webhook failed after all retries
|
|
1490
1771
|
if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
|
|
1491
1772
|
try {
|
|
1492
|
-
log?.info?.("[dingtalk] SessionWebhook
|
|
1493
|
-
// REST API only supports text format
|
|
1494
|
-
const textChunk = messageFormat === "markdown" ? chunk : chunk;
|
|
1773
|
+
log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
|
|
1495
1774
|
const restResult = await sendDingTalkRestMessage({
|
|
1496
1775
|
clientId: target.account.clientId,
|
|
1497
1776
|
clientSecret: target.account.clientSecret,
|
|
1498
1777
|
robotCode: target.account.robotCode || target.account.clientId,
|
|
1499
1778
|
userId: target.isDm ? target.senderId : undefined,
|
|
1500
1779
|
conversationId: !target.isDm ? target.conversationId : undefined,
|
|
1501
|
-
text:
|
|
1780
|
+
text: chunk,
|
|
1781
|
+
format: isMarkdown ? 'markdown' : 'text',
|
|
1502
1782
|
});
|
|
1503
1783
|
log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
|
|
1504
1784
|
// Cache outbound message so it can be resolved when quoted
|