@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.
@@ -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
+ }