@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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve quoted file messages via DingTalk group-file storage APIs.
|
|
3
|
+
*
|
|
4
|
+
* Five-step chain:
|
|
5
|
+
* senderStaffId → unionId
|
|
6
|
+
* conversationId + unionId → spaceId
|
|
7
|
+
* spaceId + createdAt±10s → dentryId (scan up to 3 pages)
|
|
8
|
+
* spaceId + dentryId → resourceUrl (signed CDN)
|
|
9
|
+
* download resourceUrl → local temp file
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import https from "node:https";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import { getDingTalkAccessToken } from "./api.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface ResolvedQuotedFile {
|
|
24
|
+
media: { path: string; mimeType: string };
|
|
25
|
+
spaceId: string;
|
|
26
|
+
fileId: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ResolveParams {
|
|
31
|
+
openConversationId: string;
|
|
32
|
+
senderStaffId?: string;
|
|
33
|
+
fileCreatedAt?: number; // ms timestamp
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Constants
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const DINGTALK_API = "https://api.dingtalk.com/v1.0";
|
|
41
|
+
const DINGTALK_OAPI = "https://oapi.dingtalk.com";
|
|
42
|
+
const PAGE_SIZE = 50;
|
|
43
|
+
const MAX_PAGES = 3;
|
|
44
|
+
const TIME_TOLERANCE_MS = 10_000; // ±10 s for createdAt matching
|
|
45
|
+
const TEMP_DIR = path.join(os.tmpdir(), "dingtalk-media");
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// LRU caches (in-memory only)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** staffId → unionId */
|
|
52
|
+
const unionIdCache = new Map<string, string>();
|
|
53
|
+
const UNION_CACHE_MAX = 5000;
|
|
54
|
+
|
|
55
|
+
/** conversationId:unionId → spaceId */
|
|
56
|
+
const spaceIdCache = new Map<string, string>();
|
|
57
|
+
const SPACE_CACHE_MAX = 500;
|
|
58
|
+
|
|
59
|
+
function lruSet<V>(map: Map<string, V>, key: string, value: V, max: number): void {
|
|
60
|
+
if (map.size >= max) {
|
|
61
|
+
// Delete first (oldest) entry
|
|
62
|
+
const first = map.keys().next().value;
|
|
63
|
+
if (first !== undefined) map.delete(first);
|
|
64
|
+
}
|
|
65
|
+
map.set(key, value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// HTTP helpers (mirrors api.ts internals — those aren't exported)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function jsonPost(url: string, body: any, headers?: Record<string, string>): Promise<any> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const data = JSON.stringify(body);
|
|
75
|
+
const urlObj = new URL(url);
|
|
76
|
+
const mod = urlObj.protocol === "https:" ? https : http;
|
|
77
|
+
const req = mod.request(urlObj, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json", "Content-Length": String(Buffer.byteLength(data)), ...headers },
|
|
80
|
+
timeout: 15_000,
|
|
81
|
+
family: 4,
|
|
82
|
+
}, (res) => {
|
|
83
|
+
let buf = "";
|
|
84
|
+
res.on("data", (c: any) => { buf += c; });
|
|
85
|
+
res.on("end", () => { try { resolve(JSON.parse(buf)); } catch { resolve({ raw: buf }); } });
|
|
86
|
+
});
|
|
87
|
+
req.on("error", reject);
|
|
88
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
|
|
89
|
+
req.write(data);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function jsonGet(url: string, headers?: Record<string, string>): Promise<any> {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const urlObj = new URL(url);
|
|
97
|
+
const mod = urlObj.protocol === "https:" ? https : http;
|
|
98
|
+
const req = mod.request(urlObj, {
|
|
99
|
+
method: "GET",
|
|
100
|
+
headers: { ...headers },
|
|
101
|
+
timeout: 15_000,
|
|
102
|
+
family: 4,
|
|
103
|
+
}, (res) => {
|
|
104
|
+
let buf = "";
|
|
105
|
+
res.on("data", (c: any) => { buf += c; });
|
|
106
|
+
res.on("end", () => { try { resolve(JSON.parse(buf)); } catch { resolve({ raw: buf }); } });
|
|
107
|
+
});
|
|
108
|
+
req.on("error", reject);
|
|
109
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function downloadBuffer(url: string, headers?: Record<string, string>, forceIPv4 = false): Promise<{ buffer: Buffer; contentType?: string }> {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const urlObj = new URL(url);
|
|
117
|
+
const mod = urlObj.protocol === "https:" ? https : http;
|
|
118
|
+
const opts: https.RequestOptions = {
|
|
119
|
+
method: "GET",
|
|
120
|
+
headers: headers || {},
|
|
121
|
+
timeout: 60_000,
|
|
122
|
+
};
|
|
123
|
+
if (forceIPv4) opts.family = 4;
|
|
124
|
+
const req = mod.request(urlObj, opts, (res) => {
|
|
125
|
+
// Follow redirects (3xx)
|
|
126
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
127
|
+
downloadBuffer(res.headers.location, headers, forceIPv4).then(resolve, reject);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const chunks: Buffer[] = [];
|
|
131
|
+
res.on("data", (c: Buffer) => { chunks.push(c); });
|
|
132
|
+
res.on("end", () => { resolve({ buffer: Buffer.concat(chunks), contentType: res.headers["content-type"] }); });
|
|
133
|
+
});
|
|
134
|
+
req.on("error", reject);
|
|
135
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Download timeout")); });
|
|
136
|
+
req.end();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Step 1: staffId → unionId
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async function getUnionId(
|
|
145
|
+
clientId: string, clientSecret: string, staffId: string, log?: any,
|
|
146
|
+
): Promise<string | null> {
|
|
147
|
+
const cached = unionIdCache.get(staffId);
|
|
148
|
+
if (cached) return cached;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const token = await getDingTalkAccessToken(clientId, clientSecret);
|
|
152
|
+
const res = await jsonPost(
|
|
153
|
+
`${DINGTALK_OAPI}/topapi/v2/user/get?access_token=${token}`,
|
|
154
|
+
{ userid: staffId, language: "zh_CN" },
|
|
155
|
+
);
|
|
156
|
+
if (res.errcode !== 0 || !res.result?.unionid) {
|
|
157
|
+
log?.warn?.(`[dingtalk][quoted-file] Failed to get unionId for ${staffId}: ${res.errmsg || JSON.stringify(res)}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const unionId = res.result.unionid as string;
|
|
161
|
+
lruSet(unionIdCache, staffId, unionId, UNION_CACHE_MAX);
|
|
162
|
+
return unionId;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log?.warn?.(`[dingtalk][quoted-file] getUnionId error: ${err}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Step 2: conversationId + unionId → spaceId
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
async function getSpaceId(
|
|
174
|
+
clientId: string, clientSecret: string,
|
|
175
|
+
openConversationId: string, unionId: string, log?: any,
|
|
176
|
+
): Promise<string | null> {
|
|
177
|
+
const cacheKey = `${openConversationId}:${unionId}`;
|
|
178
|
+
const cached = spaceIdCache.get(cacheKey);
|
|
179
|
+
if (cached) return cached;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const token = await getDingTalkAccessToken(clientId, clientSecret);
|
|
183
|
+
const res = await jsonPost(
|
|
184
|
+
`${DINGTALK_API}/convFile/conversations/spaces/query`,
|
|
185
|
+
{ openConversationId, unionId },
|
|
186
|
+
{ "x-acs-dingtalk-access-token": token },
|
|
187
|
+
);
|
|
188
|
+
if (!res.spaceId) {
|
|
189
|
+
log?.warn?.(`[dingtalk][quoted-file] Failed to get spaceId for conv=${openConversationId}: ${JSON.stringify(res).substring(0, 300)}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const spaceId = String(res.spaceId);
|
|
193
|
+
lruSet(spaceIdCache, cacheKey, spaceId, SPACE_CACHE_MAX);
|
|
194
|
+
return spaceId;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
log?.warn?.(`[dingtalk][quoted-file] getSpaceId error: ${err}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Step 3: spaceId + createdAt → dentryId (list entries, match by time)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async function findDentryByTime(
|
|
206
|
+
clientId: string, clientSecret: string,
|
|
207
|
+
spaceId: string, createdAt: number, log?: any,
|
|
208
|
+
): Promise<{ dentryId: string; name?: string } | null> {
|
|
209
|
+
try {
|
|
210
|
+
const token = await getDingTalkAccessToken(clientId, clientSecret);
|
|
211
|
+
const headers = { "x-acs-dingtalk-access-token": token };
|
|
212
|
+
|
|
213
|
+
let nextToken: string | undefined;
|
|
214
|
+
|
|
215
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
216
|
+
const url = new URL(`${DINGTALK_API}/storage/spaces/${spaceId}/dentries/listAll`);
|
|
217
|
+
url.searchParams.set("maxResults", String(PAGE_SIZE));
|
|
218
|
+
url.searchParams.set("orderType", "modifiedTimeDesc");
|
|
219
|
+
if (nextToken) url.searchParams.set("nextToken", nextToken);
|
|
220
|
+
|
|
221
|
+
const res = await jsonGet(url.toString(), headers);
|
|
222
|
+
|
|
223
|
+
const items: any[] = res.items || res.dentries || [];
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
const itemTime = item.createdTime ? new Date(item.createdTime).getTime()
|
|
226
|
+
: (item.createTime ?? 0);
|
|
227
|
+
if (Math.abs(itemTime - createdAt) <= TIME_TOLERANCE_MS) {
|
|
228
|
+
const dentryId = item.dentryId || item.id;
|
|
229
|
+
if (dentryId) {
|
|
230
|
+
log?.info?.(`[dingtalk][quoted-file] Matched dentry by time: id=${dentryId} name=${item.name}`);
|
|
231
|
+
return { dentryId: String(dentryId), name: item.name };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
nextToken = res.nextToken;
|
|
237
|
+
if (!nextToken || items.length < PAGE_SIZE) break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
log?.warn?.(`[dingtalk][quoted-file] No dentry matched createdAt=${createdAt} in spaceId=${spaceId}`);
|
|
241
|
+
return null;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log?.warn?.(`[dingtalk][quoted-file] findDentryByTime error: ${err}`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Step 4: spaceId + dentryId → resourceUrl
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
async function getDownloadUrl(
|
|
253
|
+
clientId: string, clientSecret: string,
|
|
254
|
+
spaceId: string, dentryId: string, log?: any,
|
|
255
|
+
): Promise<{ url: string; headers?: Record<string, string> } | null> {
|
|
256
|
+
try {
|
|
257
|
+
const token = await getDingTalkAccessToken(clientId, clientSecret);
|
|
258
|
+
const res = await jsonPost(
|
|
259
|
+
`${DINGTALK_API}/storage/spaces/${spaceId}/dentries/${dentryId}/downloadInfos/query`,
|
|
260
|
+
{},
|
|
261
|
+
{ "x-acs-dingtalk-access-token": token },
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const info = res.downloadInfo || res;
|
|
265
|
+
const resourceUrl = info.resourceUrl || info.url;
|
|
266
|
+
if (!resourceUrl) {
|
|
267
|
+
log?.warn?.(`[dingtalk][quoted-file] No resourceUrl for dentry=${dentryId}: ${JSON.stringify(res).substring(0, 300)}`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Collect signed headers if any
|
|
272
|
+
const signedHeaders: Record<string, string> = {};
|
|
273
|
+
const headerEntries = info.headers || info.headerSignatureInfos;
|
|
274
|
+
if (Array.isArray(headerEntries)) {
|
|
275
|
+
for (const h of headerEntries) {
|
|
276
|
+
if (h.headerName && h.headerValue) {
|
|
277
|
+
signedHeaders[h.headerName] = h.headerValue;
|
|
278
|
+
} else if (h.name && h.value) {
|
|
279
|
+
signedHeaders[h.name] = h.value;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else if (headerEntries && typeof headerEntries === "object") {
|
|
283
|
+
Object.assign(signedHeaders, headerEntries);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { url: resourceUrl, headers: Object.keys(signedHeaders).length > 0 ? signedHeaders : undefined };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
log?.warn?.(`[dingtalk][quoted-file] getDownloadUrl error: ${err}`);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Step 5: download to temp file
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
298
|
+
".pdf": "application/pdf",
|
|
299
|
+
".doc": "application/msword",
|
|
300
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
301
|
+
".xls": "application/vnd.ms-excel",
|
|
302
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
303
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
304
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
305
|
+
".zip": "application/zip",
|
|
306
|
+
".rar": "application/x-rar-compressed",
|
|
307
|
+
".7z": "application/x-7z-compressed",
|
|
308
|
+
".txt": "text/plain",
|
|
309
|
+
".csv": "text/csv",
|
|
310
|
+
".jpg": "image/jpeg",
|
|
311
|
+
".jpeg": "image/jpeg",
|
|
312
|
+
".png": "image/png",
|
|
313
|
+
".gif": "image/gif",
|
|
314
|
+
".mp4": "video/mp4",
|
|
315
|
+
".mp3": "audio/mpeg",
|
|
316
|
+
".amr": "audio/amr",
|
|
317
|
+
".m4a": "audio/mp4",
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
async function downloadToTemp(
|
|
321
|
+
resourceUrl: string, signedHeaders?: Record<string, string>,
|
|
322
|
+
name?: string, log?: any,
|
|
323
|
+
): Promise<{ path: string; mimeType: string } | null> {
|
|
324
|
+
try {
|
|
325
|
+
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
|
326
|
+
|
|
327
|
+
let result: { buffer: Buffer; contentType?: string };
|
|
328
|
+
try {
|
|
329
|
+
result = await downloadBuffer(resourceUrl, signedHeaders);
|
|
330
|
+
} catch {
|
|
331
|
+
// Retry with IPv4-only
|
|
332
|
+
log?.info?.("[dingtalk][quoted-file] CDN download failed, retrying IPv4-only");
|
|
333
|
+
result = await downloadBuffer(resourceUrl, signedHeaders, true);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const ext = name ? path.extname(name).toLowerCase() : "";
|
|
337
|
+
const mimeType = result.contentType?.split(";")[0]?.trim()
|
|
338
|
+
|| MIME_BY_EXT[ext]
|
|
339
|
+
|| "application/octet-stream";
|
|
340
|
+
|
|
341
|
+
let filename: string;
|
|
342
|
+
if (name && path.extname(name)) {
|
|
343
|
+
const base = path.basename(name, path.extname(name)).replace(/[^\w\u4e00-\u9fa5.-]/g, "_").slice(0, 60);
|
|
344
|
+
filename = `${base}_${Date.now()}${path.extname(name)}`;
|
|
345
|
+
} else {
|
|
346
|
+
filename = `quoted_file_${Date.now()}${ext || ".bin"}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const filePath = path.join(TEMP_DIR, filename);
|
|
350
|
+
fs.writeFileSync(filePath, result.buffer);
|
|
351
|
+
|
|
352
|
+
log?.info?.(`[dingtalk][quoted-file] Downloaded ${filePath} (${result.buffer.length} bytes, ${mimeType})`);
|
|
353
|
+
return { path: filePath, mimeType };
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log?.warn?.(`[dingtalk][quoted-file] Download failed: ${err}`);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Main entry point
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
export async function resolveQuotedFile(
|
|
365
|
+
config: { clientId?: string; clientSecret?: string },
|
|
366
|
+
params: ResolveParams,
|
|
367
|
+
log?: any,
|
|
368
|
+
): Promise<ResolvedQuotedFile | null> {
|
|
369
|
+
const { clientId, clientSecret } = config;
|
|
370
|
+
if (!clientId || !clientSecret) return null;
|
|
371
|
+
|
|
372
|
+
const { openConversationId, senderStaffId, fileCreatedAt } = params;
|
|
373
|
+
if (!openConversationId || !senderStaffId || !fileCreatedAt) {
|
|
374
|
+
log?.warn?.("[dingtalk][quoted-file] Missing required params: conv=" + openConversationId + " sender=" + senderStaffId + " ts=" + fileCreatedAt);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Step 1: staffId → unionId
|
|
379
|
+
const unionId = await getUnionId(clientId, clientSecret, senderStaffId, log);
|
|
380
|
+
if (!unionId) return null;
|
|
381
|
+
|
|
382
|
+
// Step 2: conversationId + unionId → spaceId
|
|
383
|
+
const spaceId = await getSpaceId(clientId, clientSecret, openConversationId, unionId, log);
|
|
384
|
+
if (!spaceId) return null;
|
|
385
|
+
|
|
386
|
+
// Step 3: list dentries, match by time
|
|
387
|
+
const dentry = await findDentryByTime(clientId, clientSecret, spaceId, fileCreatedAt, log);
|
|
388
|
+
if (!dentry) return null;
|
|
389
|
+
|
|
390
|
+
// Step 4: get download URL
|
|
391
|
+
const dlInfo = await getDownloadUrl(clientId, clientSecret, spaceId, dentry.dentryId, log);
|
|
392
|
+
if (!dlInfo) return null;
|
|
393
|
+
|
|
394
|
+
// Step 5: download to temp
|
|
395
|
+
const media = await downloadToTemp(dlInfo.url, dlInfo.headers, dentry.name, log);
|
|
396
|
+
if (!media) return null;
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
media,
|
|
400
|
+
spaceId,
|
|
401
|
+
fileId: dentry.dentryId,
|
|
402
|
+
name: dentry.name,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoted message download-code cache.
|
|
3
|
+
*
|
|
4
|
+
* Persists `msgId → CacheEntry` per (accountId, conversationId) bucket so that
|
|
5
|
+
* when a user quotes a file/video/audio message the bot didn't originally see,
|
|
6
|
+
* we can still recover the downloadCode (or spaceId+fileId) to fetch it.
|
|
7
|
+
*
|
|
8
|
+
* - TTL: 24 h
|
|
9
|
+
* - Per-bucket cap: 100 entries (LRU eviction)
|
|
10
|
+
* - Max buckets: 1000 (LRU eviction)
|
|
11
|
+
* - Atomic write (tmp + rename)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
import * as os from "os";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface QuotedMsgCacheEntry {
|
|
23
|
+
downloadCode?: string;
|
|
24
|
+
spaceId?: string;
|
|
25
|
+
fileId?: string;
|
|
26
|
+
msgType: string; // 'audio' | 'video' | 'file'
|
|
27
|
+
createdAt: number; // ms timestamp from DingTalk
|
|
28
|
+
cachedAt: number; // Date.now() when we cached it
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Constants
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
36
|
+
const MAX_ENTRIES_PER_BUCKET = 100;
|
|
37
|
+
const MAX_BUCKETS = 1000;
|
|
38
|
+
|
|
39
|
+
const CACHE_FILE = path.join(
|
|
40
|
+
os.homedir(),
|
|
41
|
+
".openclaw", "extensions", "dingtalk", ".cache", "quoted-msg-cache.json",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// In-memory store
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** bucketKey → Map<msgId, entry> */
|
|
49
|
+
const store = new Map<string, Map<string, QuotedMsgCacheEntry>>();
|
|
50
|
+
|
|
51
|
+
/** Ordered list of bucket keys for LRU eviction (most-recently-used at end) */
|
|
52
|
+
let bucketOrder: string[] = [];
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Persistence helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function loadFromDisk(): void {
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(CACHE_FILE)) return;
|
|
61
|
+
const raw = fs.readFileSync(CACHE_FILE, "utf-8");
|
|
62
|
+
const data: Record<string, Array<[string, QuotedMsgCacheEntry]>> = JSON.parse(raw);
|
|
63
|
+
const cutoff = Date.now() - DEFAULT_TTL_MS;
|
|
64
|
+
|
|
65
|
+
for (const [bucketKey, entries] of Object.entries(data)) {
|
|
66
|
+
const bucket = new Map<string, QuotedMsgCacheEntry>();
|
|
67
|
+
for (const [msgId, entry] of entries) {
|
|
68
|
+
if (entry.cachedAt > cutoff) {
|
|
69
|
+
bucket.set(msgId, entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (bucket.size > 0) {
|
|
73
|
+
store.set(bucketKey, bucket);
|
|
74
|
+
bucketOrder.push(bucketKey);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Silently ignore corrupt/missing file
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
83
|
+
|
|
84
|
+
function scheduleSave(): void {
|
|
85
|
+
if (_saveTimer) return;
|
|
86
|
+
_saveTimer = setTimeout(() => {
|
|
87
|
+
_saveTimer = null;
|
|
88
|
+
try {
|
|
89
|
+
const dir = path.dirname(CACHE_FILE);
|
|
90
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
91
|
+
|
|
92
|
+
const obj: Record<string, Array<[string, QuotedMsgCacheEntry]>> = {};
|
|
93
|
+
for (const [bk, bucket] of store) {
|
|
94
|
+
obj[bk] = [...bucket.entries()];
|
|
95
|
+
}
|
|
96
|
+
const tmp = CACHE_FILE + ".tmp";
|
|
97
|
+
fs.writeFileSync(tmp, JSON.stringify(obj), "utf-8");
|
|
98
|
+
fs.renameSync(tmp, CACHE_FILE);
|
|
99
|
+
} catch {
|
|
100
|
+
// Best-effort persistence
|
|
101
|
+
}
|
|
102
|
+
}, 2000);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Load on module init
|
|
106
|
+
loadFromDisk();
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Bucket helpers
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function bucketKey(accountId: string, conversationId: string): string {
|
|
113
|
+
return `${accountId}:${conversationId}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Touch a bucket in the LRU order (move to end). */
|
|
117
|
+
function touchBucket(key: string): void {
|
|
118
|
+
const idx = bucketOrder.indexOf(key);
|
|
119
|
+
if (idx >= 0) bucketOrder.splice(idx, 1);
|
|
120
|
+
bucketOrder.push(key);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getOrCreateBucket(key: string): Map<string, QuotedMsgCacheEntry> {
|
|
124
|
+
let bucket = store.get(key);
|
|
125
|
+
if (!bucket) {
|
|
126
|
+
// Evict oldest buckets if at limit
|
|
127
|
+
while (bucketOrder.length >= MAX_BUCKETS) {
|
|
128
|
+
const oldest = bucketOrder.shift();
|
|
129
|
+
if (oldest) store.delete(oldest);
|
|
130
|
+
}
|
|
131
|
+
bucket = new Map();
|
|
132
|
+
store.set(key, bucket);
|
|
133
|
+
}
|
|
134
|
+
touchBucket(key);
|
|
135
|
+
return bucket;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** LRU-evict within a bucket to stay under MAX_ENTRIES_PER_BUCKET. */
|
|
139
|
+
function evictBucket(bucket: Map<string, QuotedMsgCacheEntry>): void {
|
|
140
|
+
if (bucket.size <= MAX_ENTRIES_PER_BUCKET) return;
|
|
141
|
+
|
|
142
|
+
// Expire stale entries first
|
|
143
|
+
const cutoff = Date.now() - DEFAULT_TTL_MS;
|
|
144
|
+
for (const [k, v] of bucket) {
|
|
145
|
+
if (v.cachedAt < cutoff) bucket.delete(k);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If still over, remove oldest by cachedAt
|
|
149
|
+
while (bucket.size > MAX_ENTRIES_PER_BUCKET) {
|
|
150
|
+
let oldestKey: string | undefined;
|
|
151
|
+
let oldestTs = Infinity;
|
|
152
|
+
for (const [k, v] of bucket) {
|
|
153
|
+
if (v.cachedAt < oldestTs) {
|
|
154
|
+
oldestTs = v.cachedAt;
|
|
155
|
+
oldestKey = k;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (oldestKey) bucket.delete(oldestKey);
|
|
159
|
+
else break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Public API
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export function cacheInboundDownloadCode(
|
|
168
|
+
accountId: string,
|
|
169
|
+
conversationId: string,
|
|
170
|
+
msgId: string,
|
|
171
|
+
downloadCode: string | undefined,
|
|
172
|
+
msgType: string,
|
|
173
|
+
createdAt: number,
|
|
174
|
+
extra?: { spaceId?: string; fileId?: string },
|
|
175
|
+
): void {
|
|
176
|
+
const bk = bucketKey(accountId, conversationId);
|
|
177
|
+
const bucket = getOrCreateBucket(bk);
|
|
178
|
+
|
|
179
|
+
bucket.set(msgId, {
|
|
180
|
+
downloadCode,
|
|
181
|
+
spaceId: extra?.spaceId,
|
|
182
|
+
fileId: extra?.fileId,
|
|
183
|
+
msgType,
|
|
184
|
+
createdAt,
|
|
185
|
+
cachedAt: Date.now(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
evictBucket(bucket);
|
|
189
|
+
scheduleSave();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getCachedDownloadCode(
|
|
193
|
+
accountId: string,
|
|
194
|
+
conversationId: string,
|
|
195
|
+
msgId: string,
|
|
196
|
+
): QuotedMsgCacheEntry | null {
|
|
197
|
+
const bk = bucketKey(accountId, conversationId);
|
|
198
|
+
const bucket = store.get(bk);
|
|
199
|
+
if (!bucket) return null;
|
|
200
|
+
|
|
201
|
+
const entry = bucket.get(msgId);
|
|
202
|
+
if (!entry) return null;
|
|
203
|
+
|
|
204
|
+
// Check TTL
|
|
205
|
+
if (Date.now() - entry.cachedAt > DEFAULT_TTL_MS) {
|
|
206
|
+
bucket.delete(msgId);
|
|
207
|
+
scheduleSave();
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
touchBucket(bk);
|
|
212
|
+
return entry;
|
|
213
|
+
}
|