@karinjs/adapter-qqbot 2.0.0 → 2.1.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,1739 @@
1
+ import { c as log, s as pluginDirName } from "./config-DKa7596f.js";
2
+ import { karinPathBase, karinPathTemp } from "node-karin";
3
+ import { createWriteStream } from "node:fs";
4
+ import axios from "node-karin/axios";
5
+ import crypto from "node:crypto";
6
+ import { mkdir, readdir, rename, rm, stat, utimes } from "node:fs/promises";
7
+ import { dirname, extname, join } from "node:path";
8
+ import sqlite3 from "node-karin/sqlite3";
9
+ import { Transform } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { imageSizeFromFile } from "image-size/fromFile";
12
+
13
+ //#region src/core/storage/message-store/constants.ts
14
+ /** QQ 官方不提供按消息 ID 查询历史消息;本地缓存保留一天。 */
15
+ const MESSAGE_TTL = 1440 * 60 * 1e3;
16
+ /** 空闲时也定期清理,避免长期运行的机器人积累过期记录。 */
17
+ const CLEANUP_INTERVAL = 600 * 1e3;
18
+ /** 待落库队列上限;超过后丢弃最旧的待写缓存,避免反向拖垮消息事件。 */
19
+ const MAX_WRITE_QUEUE = 5e4;
20
+ /** 媒体本地化待处理队列上限;超过后丢弃最旧任务,原消息仍保留短期 URL。 */
21
+ const MAX_MEDIA_LOCALIZE_QUEUE = 5e4;
22
+ /** 启动时最多补救的远程媒体消息数。 */
23
+ const MEDIA_RECOVERY_LIMIT = 5e4;
24
+
25
+ //#endregion
26
+ //#region src/core/storage/message-store/sql.ts
27
+ /**
28
+ * 消息主表查询字段。
29
+ *
30
+ * 主表只保存整数外键,读取时再 JOIN 联系人和发送者映射表,避免在每条消息和每个
31
+ * 消息段上重复写入 bot、群号、用户 ID 等长字符串。
32
+ */
33
+ const messageSelectFields = () => `
34
+ m.id AS message_ref,
35
+ m.bot_ref,
36
+ m.contact_ref,
37
+ m.sender_ref,
38
+ b.bot_id,
39
+ m.message_id,
40
+ m.message_seq,
41
+ m.time,
42
+ c.scene,
43
+ c.peer,
44
+ c.sub_peer,
45
+ c.name AS contact_name,
46
+ c.sub_name AS contact_sub_name,
47
+ s.user_id AS sender_user_id,
48
+ s.nick AS sender_nick,
49
+ s.name AS sender_name,
50
+ s.role AS sender_role`;
51
+ const SQL = {
52
+ insertBot: "INSERT INTO qqbot_bots (bot_id) VALUES (?) ON CONFLICT(bot_id) DO NOTHING",
53
+ selectBotId: "SELECT id FROM qqbot_bots WHERE bot_id = ?",
54
+ upsertContact: `INSERT INTO qqbot_contacts (bot_ref, scene, peer, sub_peer, name, sub_name)
55
+ VALUES (?, ?, ?, ?, ?, ?)
56
+ ON CONFLICT(bot_ref, scene, peer, sub_peer) DO UPDATE SET
57
+ name = excluded.name,
58
+ sub_name = excluded.sub_name`,
59
+ selectContactId: `SELECT id FROM qqbot_contacts
60
+ WHERE bot_ref = ? AND scene = ? AND peer = ? AND sub_peer = ?`,
61
+ upsertSender: `INSERT INTO qqbot_senders (bot_ref, user_id, nick, name, role)
62
+ VALUES (?, ?, ?, ?, ?)
63
+ ON CONFLICT(bot_ref, user_id, nick, name, role) DO NOTHING`,
64
+ selectSenderId: `SELECT id FROM qqbot_senders
65
+ WHERE bot_ref = ? AND user_id = ? AND nick = ? AND name = ? AND role = ?`,
66
+ upsertMessage: `INSERT INTO qqbot_messages (
67
+ bot_ref, contact_ref, sender_ref, message_id, message_seq, time
68
+ ) VALUES (?, ?, ?, ?, ?, ?)
69
+ ON CONFLICT(bot_ref, contact_ref, message_id) DO UPDATE SET
70
+ sender_ref = excluded.sender_ref,
71
+ message_seq = excluded.message_seq,
72
+ time = excluded.time`,
73
+ selectMessageRef: `SELECT id FROM qqbot_messages
74
+ WHERE bot_ref = ? AND contact_ref = ? AND message_id = ?`,
75
+ deleteElements: "DELETE FROM qqbot_message_elements WHERE message_ref = ?",
76
+ insertElement: `INSERT INTO qqbot_message_elements (
77
+ message_ref, element_index, element_type, value
78
+ ) VALUES (?, ?, ?, ?)`,
79
+ insertAlias: `INSERT INTO qqbot_message_aliases (
80
+ bot_ref, contact_ref, alias_message_id, message_ref
81
+ ) VALUES (?, ?, ?, ?)
82
+ ON CONFLICT(bot_ref, contact_ref, alias_message_id) DO UPDATE SET
83
+ message_ref = excluded.message_ref`,
84
+ selectMessage: `SELECT ${messageSelectFields()}
85
+ FROM qqbot_messages m
86
+ JOIN qqbot_bots b ON b.id = m.bot_ref
87
+ JOIN qqbot_contacts c ON c.id = m.contact_ref
88
+ JOIN qqbot_senders s ON s.id = m.sender_ref
89
+ WHERE b.bot_id = ? AND c.scene = ? AND c.peer = ? AND c.sub_peer = ? AND m.message_id = ?`,
90
+ selectMessageById: `SELECT ${messageSelectFields()}
91
+ FROM qqbot_messages m
92
+ JOIN qqbot_bots b ON b.id = m.bot_ref
93
+ JOIN qqbot_contacts c ON c.id = m.contact_ref
94
+ JOIN qqbot_senders s ON s.id = m.sender_ref
95
+ WHERE b.bot_id = ? AND m.message_id = ?
96
+ LIMIT 1`,
97
+ selectMessageByRef: `SELECT ${messageSelectFields()}
98
+ FROM qqbot_messages m
99
+ JOIN qqbot_bots b ON b.id = m.bot_ref
100
+ JOIN qqbot_contacts c ON c.id = m.contact_ref
101
+ JOIN qqbot_senders s ON s.id = m.sender_ref
102
+ WHERE m.id = ?`,
103
+ selectHistory: `SELECT ${messageSelectFields()}
104
+ FROM qqbot_messages m
105
+ JOIN qqbot_bots b ON b.id = m.bot_ref
106
+ JOIN qqbot_contacts c ON c.id = m.contact_ref
107
+ JOIN qqbot_senders s ON s.id = m.sender_ref
108
+ WHERE b.bot_id = ? AND c.scene = ? AND c.peer = ? AND c.sub_peer = ?
109
+ AND (m.time < ? OR (m.time = ? AND m.id <= ?))
110
+ ORDER BY m.time DESC, m.id DESC
111
+ LIMIT ?`,
112
+ selectElements: `SELECT element_index, element_type, value
113
+ FROM qqbot_message_elements
114
+ WHERE message_ref = ?
115
+ ORDER BY element_index ASC`,
116
+ /** 查询仍保存远程 URL 的媒体消息,value 只匹配 URLSearchParams 编码后的 file 字段。 */
117
+ selectMessagesWithRemoteMedia: `SELECT DISTINCT ${messageSelectFields()}
118
+ FROM qqbot_messages m
119
+ JOIN qqbot_bots b ON b.id = m.bot_ref
120
+ JOIN qqbot_contacts c ON c.id = m.contact_ref
121
+ JOIN qqbot_senders s ON s.id = m.sender_ref
122
+ JOIN qqbot_message_elements e ON e.message_ref = m.id
123
+ WHERE m.time > ?
124
+ AND e.element_type IN ('image', 'video', 'record', 'file')
125
+ AND (
126
+ e.value LIKE 'file=http%3A%2F%2F%' OR e.value LIKE 'file=https%3A%2F%2F%'
127
+ OR e.value LIKE 'file=%2F%2F%'
128
+ )
129
+ ORDER BY m.time DESC, m.id DESC
130
+ LIMIT ?`,
131
+ selectAlias: `SELECT a.message_ref
132
+ FROM qqbot_message_aliases a
133
+ JOIN qqbot_bots b ON b.id = a.bot_ref
134
+ JOIN qqbot_contacts c ON c.id = a.contact_ref
135
+ WHERE b.bot_id = ? AND c.scene = ? AND c.peer = ? AND c.sub_peer = ? AND a.alias_message_id = ?`,
136
+ selectAliasById: `SELECT a.message_ref
137
+ FROM qqbot_message_aliases a
138
+ JOIN qqbot_bots b ON b.id = a.bot_ref
139
+ WHERE b.bot_id = ? AND a.alias_message_id = ?
140
+ LIMIT 1`,
141
+ deleteAlias: `DELETE FROM qqbot_message_aliases
142
+ WHERE bot_ref = (SELECT id FROM qqbot_bots WHERE bot_id = ?)
143
+ AND contact_ref = (
144
+ SELECT id FROM qqbot_contacts
145
+ WHERE bot_ref = (SELECT id FROM qqbot_bots WHERE bot_id = ?)
146
+ AND scene = ? AND peer = ? AND sub_peer = ?
147
+ )
148
+ AND alias_message_id = ?`,
149
+ /** 判断消息是否包含指定 reply 段,value 参数必须使用 elements.replyElementValue 编码。 */
150
+ hasReply: `SELECT 1 AS found FROM qqbot_message_elements
151
+ WHERE message_ref = ? AND element_type = 'reply' AND value = ?
152
+ LIMIT 1`,
153
+ cleanup: "DELETE FROM qqbot_messages WHERE time <= ?"
154
+ };
155
+
156
+ //#endregion
157
+ //#region src/core/storage/message-store/elements.ts
158
+ /** 接收侧当前会缓存的 Karin 消息段类型。 */
159
+ const storableTypes = /* @__PURE__ */ new Set([
160
+ "text",
161
+ "at",
162
+ "reply",
163
+ "face",
164
+ "image",
165
+ "video",
166
+ "record",
167
+ "file",
168
+ "markdown"
169
+ ]);
170
+ /**
171
+ * 保存一个消息段。
172
+ *
173
+ * 只写 `element_type + value`,避免稀疏宽表产生大量 NULL,也避免每个消息段
174
+ * 再额外写详情表。
175
+ *
176
+ * @param run SQL 执行函数,通常绑定到当前 MessageStore 的 SQLite 连接。
177
+ * @param messageRef `qqbot_messages.id`,用于关联所属消息。
178
+ * @param index 消息段在 Karin elements 中的原始顺序。
179
+ * @param element 需要缓存的 Karin 消息段。
180
+ */
181
+ const saveElement = async (run, messageRef, index, element) => {
182
+ const value = elementValue(element);
183
+ if (value === void 0) return;
184
+ await run(SQL.insertElement, [
185
+ messageRef,
186
+ index,
187
+ element.type,
188
+ value
189
+ ]);
190
+ };
191
+ /**
192
+ * 读取并还原消息段。
193
+ *
194
+ * @param getRows SQL 多行查询函数。
195
+ * @param messageRef `qqbot_messages.id`,用于读取该消息的全部消息段。
196
+ * @returns 按 `element_index` 排序后的 Karin 消息段数组。
197
+ */
198
+ const loadElements = async (getRows, messageRef) => {
199
+ return (await getRows(SQL.selectElements, [messageRef])).flatMap(elementFromRow);
200
+ };
201
+ /**
202
+ * 将数据库中的单 value 消息段还原为 Karin 消息段。
203
+ *
204
+ * @param row `qqbot_message_elements` 查询行。
205
+ * @returns 可还原的 Karin 消息段;未知类型返回空数组。
206
+ */
207
+ const elementFromRow = (row) => {
208
+ const value = new URLSearchParams(row.value);
209
+ switch (row.element_type) {
210
+ case "text": return [{
211
+ type: "text",
212
+ text: stringValue(value, "text")
213
+ }];
214
+ case "at": return [{
215
+ type: "at",
216
+ targetId: stringValue(value, "targetId")
217
+ }];
218
+ case "reply": return [{
219
+ type: "reply",
220
+ messageId: stringValue(value, "messageId")
221
+ }];
222
+ case "face": return [{
223
+ type: "face",
224
+ id: numberValue(value, "id") || 0
225
+ }];
226
+ case "image": return [{
227
+ type: "image",
228
+ file: stringValue(value, "file"),
229
+ subType: stringValue(value, "subType") || void 0,
230
+ width: numberValue(value, "width"),
231
+ height: numberValue(value, "height")
232
+ }];
233
+ case "video": return [{
234
+ type: "video",
235
+ file: stringValue(value, "file")
236
+ }];
237
+ case "record": return [{
238
+ type: "record",
239
+ file: stringValue(value, "file"),
240
+ magic: false
241
+ }];
242
+ case "file": return [{
243
+ type: "file",
244
+ file: stringValue(value, "file"),
245
+ name: stringValue(value, "name") || void 0
246
+ }];
247
+ case "markdown": return [{
248
+ type: "markdown",
249
+ markdown: stringValue(value, "markdown")
250
+ }];
251
+ default: return [];
252
+ }
253
+ };
254
+ /**
255
+ * 提取并编码消息段缓存值。
256
+ *
257
+ * 数据库只保存 `element_type + value`,其中 value 统一为 URLSearchParams。每种消息段
258
+ * 只保留 getMsg/getHistoryMsg 还原所需的最小字段。
259
+ *
260
+ * @param element Karin 消息段。
261
+ * @returns URLSearchParams 编码后的最小字段;不支持缓存的类型返回 undefined。
262
+ */
263
+ const elementValue = (element) => {
264
+ if (!storableTypes.has(element.type)) return void 0;
265
+ switch (element.type) {
266
+ case "text": return encodeElementValue({ text: element.text });
267
+ case "at": return encodeElementValue({ targetId: element.targetId });
268
+ case "reply": return replyElementValue(element.messageId);
269
+ case "face": return encodeElementValue({ id: element.id });
270
+ case "image": return encodeElementValue({
271
+ file: element.file,
272
+ subType: element.subType,
273
+ width: element.width,
274
+ height: element.height
275
+ });
276
+ case "video": return encodeElementValue({ file: element.file });
277
+ case "record": return encodeElementValue({ file: element.file });
278
+ case "file": return encodeElementValue({
279
+ file: element.file,
280
+ name: element.name
281
+ });
282
+ case "markdown": return encodeElementValue({ markdown: element.markdown });
283
+ }
284
+ };
285
+ /**
286
+ * 编码 reply 段的数据库 value。
287
+ *
288
+ * `hasReply` 查询需要和写入时使用完全一致的 URLSearchParams 格式,单独导出这个
289
+ * helper 可以避免查询侧再手写一份 `messageId=...`。
290
+ *
291
+ * @param messageId 被引用的消息 ID。
292
+ * @returns 可直接写入或查询 `qqbot_message_elements.value` 的编码值。
293
+ */
294
+ const replyElementValue = (messageId) => encodeElementValue({ messageId });
295
+ /**
296
+ * 将消息段字段编码成数据库 value。
297
+ *
298
+ * 所有消息段都统一使用 URLSearchParams,避免不同类型混用裸字符串和结构化值。空值
299
+ * 不写入,减少缓存字段噪声。
300
+ *
301
+ * @param data 需要保留的最小消息段字段。
302
+ * @returns URLSearchParams 编码后的 value。
303
+ */
304
+ const encodeElementValue = (data) => {
305
+ const value = new URLSearchParams();
306
+ for (const [key, item] of Object.entries(data)) {
307
+ if (item === void 0 || item === null || item === "") continue;
308
+ value.set(key, String(item));
309
+ }
310
+ return value.toString();
311
+ };
312
+ /**
313
+ * 从解析后的 value 中读取字符串字段。
314
+ *
315
+ * URLSearchParams 允许重复 key,这里固定取首个值,保证还原结果稳定。
316
+ *
317
+ * @param value 已解析的 value。
318
+ * @param key 字段名。
319
+ * @returns 字符串字段;不存在时返回空字符串。
320
+ */
321
+ const stringValue = (value, key) => value.get(key) || "";
322
+ /**
323
+ * 从解析后的 value 中读取正数字段。
324
+ *
325
+ * 宽高、表情 ID 等数值字段只有大于 0 时才还原,避免把空值或异常值写回消息段。
326
+ *
327
+ * @param value 已解析的 value。
328
+ * @param key 字段名。
329
+ * @returns 正数值;不存在或非法时返回 undefined。
330
+ */
331
+ const numberValue = (value, key) => {
332
+ const num = Number(stringValue(value, key));
333
+ return Number.isFinite(num) && num > 0 ? num : void 0;
334
+ };
335
+
336
+ //#endregion
337
+ //#region src/core/storage/message-store/media.ts
338
+ /** 消息媒体缓存根目录:${karinPathTemp}/${pluginDirName}/message-media。 */
339
+ const MEDIA_CACHE_DIR = join(karinPathTemp, pluginDirName, "message-media");
340
+ /** 单个媒体下载请求的超时时间。 */
341
+ const FETCH_TIMEOUT = 300 * 1e3;
342
+ /** 各类型允许缓存的最大体积,防止短期 URL 下载拖垮磁盘或内存。 */
343
+ const MAX_CACHE_BYTES = {
344
+ image: 30 * 1024 * 1024,
345
+ video: 100 * 1024 * 1024,
346
+ record: 20 * 1024 * 1024,
347
+ file: 100 * 1024 * 1024
348
+ };
349
+ /** 无法从消息段或响应头判断格式时使用的兜底扩展名。 */
350
+ const EXTENSIONS_BY_TYPE = {
351
+ image: ".jpg",
352
+ video: ".mp4",
353
+ record: ".mp3",
354
+ file: ".bin"
355
+ };
356
+ /** 常见 content-type 到本地文件扩展名的映射。 */
357
+ const CONTENT_TYPE_EXTENSIONS = {
358
+ "image/jpeg": ".jpg",
359
+ "image/jpg": ".jpg",
360
+ "image/png": ".png",
361
+ "image/gif": ".gif",
362
+ "image/webp": ".webp",
363
+ "image/bmp": ".bmp",
364
+ "video/mp4": ".mp4",
365
+ "audio/mpeg": ".mp3",
366
+ "audio/mp3": ".mp3",
367
+ "audio/wav": ".wav",
368
+ "audio/x-wav": ".wav",
369
+ "audio/amr": ".amr"
370
+ };
371
+ /** 图片 subType 到扩展名的映射,优先级高于响应头和 URL 后缀。 */
372
+ const SUBTYPE_EXTENSIONS = {
373
+ jpg: ".jpg",
374
+ jpeg: ".jpg",
375
+ png: ".png",
376
+ gif: ".gif",
377
+ webp: ".webp",
378
+ bmp: ".bmp",
379
+ mp4: ".mp4",
380
+ silk: ".silk",
381
+ amr: ".amr",
382
+ wav: ".wav",
383
+ mp3: ".mp3"
384
+ };
385
+ /** 上次清理本地媒体缓存的时间戳。 */
386
+ let lastCleanup = 0;
387
+ /**
388
+ * 判断 elements 中是否存在需要本地化缓存的远程 file 字段。
389
+ *
390
+ * @param elements Karin 消息段数组。
391
+ * @returns true 表示至少有一个 image/video/record/file 使用远程 URL。
392
+ */
393
+ const hasRemoteFileElement = (elements) => {
394
+ return elements.some((element) => isFileBackedElement(element) && isRemoteUrl(element.file));
395
+ };
396
+ /**
397
+ * 将接收侧短期媒体 URL 缓存到本地 temp 目录。
398
+ *
399
+ * Karin 的 image/video/record/file 段都通过 `file` 字段承载资源。这里优先保存
400
+ * 本地路径,避免每条入站媒体都转存到图床;失败时保留原 URL,让缓存尽量完整。
401
+ *
402
+ * @param elements 原始 Karin 消息段数组。
403
+ * @returns 本地化后的消息段、是否变化、以及单段失败信息。
404
+ */
405
+ const localizeFileElements = async (elements) => {
406
+ await cleanupMessageMediaCacheIfDue();
407
+ let changed = false;
408
+ const failures = [];
409
+ const localized = [];
410
+ for (const element of elements) {
411
+ if (!isFileBackedElement(element) || !isRemoteUrl(element.file)) {
412
+ localized.push({ ...element });
413
+ continue;
414
+ }
415
+ try {
416
+ const cached = await cacheFileElement(element);
417
+ changed ||= cached.file !== element.file;
418
+ localized.push(cached);
419
+ } catch (err) {
420
+ failures.push(err instanceof Error ? err.message : String(err));
421
+ localized.push({ ...element });
422
+ }
423
+ }
424
+ return {
425
+ elements: localized,
426
+ changed,
427
+ failures
428
+ };
429
+ };
430
+ /**
431
+ * 清理超过消息缓存 TTL 的本地媒体文件。
432
+ *
433
+ * @returns 清理完成 Promise。
434
+ */
435
+ const cleanupMessageMediaCache = async () => {
436
+ lastCleanup = Date.now();
437
+ await cleanupDir(MEDIA_CACHE_DIR, Date.now() - MESSAGE_TTL);
438
+ };
439
+ /**
440
+ * 到达清理间隔时清理本地媒体文件。
441
+ *
442
+ * @returns 清理完成 Promise;未到间隔时立即返回。
443
+ */
444
+ const cleanupMessageMediaCacheIfDue = async () => {
445
+ if (Date.now() - lastCleanup < 6e5) return;
446
+ await cleanupMessageMediaCache();
447
+ };
448
+ /**
449
+ * 缓存单个媒体消息段的远程文件。
450
+ *
451
+ * 图片会在下载后补充 subType、width、height;其他类型只替换 file 为本地路径。
452
+ *
453
+ * @param element 需要本地化的 image/video/record/file 消息段。
454
+ * @returns 替换 file 字段后的消息段。
455
+ */
456
+ const cacheFileElement = async (element) => {
457
+ const source = normalizeRemoteUrl(element.file);
458
+ const dir = join(MEDIA_CACHE_DIR, element.type);
459
+ await mkdir(dir, { recursive: true });
460
+ const downloaded = await downloadRemoteFile({
461
+ url: source,
462
+ dir,
463
+ element,
464
+ maxBytes: MAX_CACHE_BYTES[element.type]
465
+ });
466
+ const result = {
467
+ ...element,
468
+ file: downloaded.file
469
+ };
470
+ if (element.type === "image") {
471
+ const dimensions = await imageSizeFromFile(downloaded.file).catch(() => void 0);
472
+ return {
473
+ ...result,
474
+ subType: downloaded.subType || element.subType,
475
+ width: dimensions?.width || element.width,
476
+ height: dimensions?.height || element.height
477
+ };
478
+ }
479
+ return result;
480
+ };
481
+ /**
482
+ * 使用 node-karin/axios 以流方式下载远程媒体到本地文件。
483
+ *
484
+ * 先写入 `.part` 临时文件,完整下载后再 rename,避免半文件被后续读取。
485
+ *
486
+ * @param params 下载参数。
487
+ * @param params.url 规范化后的远程 URL。
488
+ * @param params.dir 当前媒体类型的缓存目录。
489
+ * @param params.element 原始媒体消息段,用于命名和扩展名推断。
490
+ * @param params.maxBytes 当前类型允许的最大下载体积。
491
+ * @returns 下载后的本地文件路径和格式信息。
492
+ */
493
+ const downloadRemoteFile = async (params) => {
494
+ const { url, dir, element, maxBytes } = params;
495
+ const response = await axios({
496
+ url,
497
+ method: "GET",
498
+ responseType: "stream",
499
+ timeout: FETCH_TIMEOUT,
500
+ maxContentLength: maxBytes,
501
+ maxBodyLength: maxBytes,
502
+ validateStatus: () => true
503
+ });
504
+ if (response.status < 200 || response.status >= 300) throw new Error(`下载媒体失败: HTTP ${response.status}`);
505
+ const body = response.data;
506
+ if (!body) throw new Error("下载媒体失败: 响应体为空");
507
+ const contentType = String(response.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
508
+ validateContentType(element.type, contentType);
509
+ const contentLength = Number(response.headers["content-length"] || 0);
510
+ if (contentLength > maxBytes) throw new Error(`下载媒体失败: 文件过大 ${contentLength} bytes`);
511
+ const tempFile = join(dir, `${Date.now()}-${crypto.randomBytes(6).toString("hex")}.part`);
512
+ const progress = { bytes: 0 };
513
+ try {
514
+ await pipeline(body, createLimitStream(progress, maxBytes), createWriteStream(tempFile));
515
+ const filename = getLocalFilename(element, url, contentType);
516
+ const finalFile = join(dir, filename);
517
+ const now = /* @__PURE__ */ new Date();
518
+ if (await stat(finalFile).then(() => true).catch(() => false)) {
519
+ await rm(tempFile, { force: true }).catch(() => void 0);
520
+ await utimes(finalFile, now, now).catch(() => void 0);
521
+ } else await rename(tempFile, finalFile);
522
+ return {
523
+ file: finalFile,
524
+ contentType,
525
+ subType: element.type === "image" ? extensionToSubType(extname(filename)) : void 0
526
+ };
527
+ } catch (err) {
528
+ await rm(tempFile, { force: true }).catch(() => void 0);
529
+ throw err;
530
+ }
531
+ };
532
+ /**
533
+ * 创建限制下载体积的 Transform 流。
534
+ *
535
+ * axios 的 content-length 可能缺失或不可信,这里按实际流经字节再次限制。
536
+ *
537
+ * @param progress 下载进度对象。
538
+ * @param maxBytes 允许通过的最大字节数。
539
+ * @returns 可插入 pipeline 的 Transform 流。
540
+ */
541
+ const createLimitStream = (progress, maxBytes) => {
542
+ return new Transform({ transform(chunk, _encoding, callback) {
543
+ progress.bytes += chunk.length;
544
+ if (progress.bytes > maxBytes) {
545
+ callback(/* @__PURE__ */ new Error(`下载媒体失败: 文件过大 ${progress.bytes} bytes`));
546
+ return;
547
+ }
548
+ callback(null, chunk);
549
+ } });
550
+ };
551
+ /**
552
+ * 递归清理过期缓存文件。
553
+ *
554
+ * 目录本身会保留,只删除超过 deadline 的普通文件。
555
+ *
556
+ * @param dir 需要扫描的目录。
557
+ * @param deadline 过期时间戳,小于等于该时间的文件会被删除。
558
+ * @returns 清理完成 Promise。
559
+ */
560
+ const cleanupDir = async (dir, deadline) => {
561
+ let entries;
562
+ try {
563
+ entries = await readdir(dir, { withFileTypes: true });
564
+ } catch (err) {
565
+ if (err.code === "ENOENT") return;
566
+ throw err;
567
+ }
568
+ await Promise.all(entries.map(async (entry) => {
569
+ const file = join(dir, entry.name);
570
+ if (entry.isDirectory()) {
571
+ await cleanupDir(file, deadline);
572
+ return;
573
+ }
574
+ if (!entry.isFile()) return;
575
+ const info = await stat(file).catch(() => void 0);
576
+ if (!info || info.mtimeMs > deadline) return;
577
+ await rm(file, { force: true }).catch(() => void 0);
578
+ }));
579
+ };
580
+ /**
581
+ * 生成本地缓存文件名。
582
+ *
583
+ * 文件名使用原始 name 的主文件名加 UUID,既保留可读性,也避免同名文件冲突。
584
+ *
585
+ * @param element 媒体消息段。
586
+ * @param source 远程 URL。
587
+ * @param contentType 响应 content-type。
588
+ * @returns 不含目录的本地文件名。
589
+ */
590
+ const getLocalFilename = (element, source, contentType) => {
591
+ const ext = getFileExtension(element, source, contentType);
592
+ return `${getFileBaseName(element) || element.type}-${crypto.randomUUID()}${ext}`;
593
+ };
594
+ /**
595
+ * 推断本地缓存文件扩展名。
596
+ *
597
+ * 优先级:
598
+ * 1. 图片 subType;
599
+ * 2. 文件消息段自带 name 后缀;
600
+ * 3. 语音固定 .mp3;
601
+ * 4. content-type、name、URL 后缀;
602
+ * 5. 类型默认扩展名。
603
+ *
604
+ * @param element 媒体消息段。
605
+ * @param source 远程 URL。
606
+ * @param contentType 响应 content-type。
607
+ * @returns 带点号的扩展名。
608
+ */
609
+ const getFileExtension = (element, source, contentType) => {
610
+ if (element.type === "image") {
611
+ const subtype = element.subType?.toLowerCase();
612
+ if (subtype && SUBTYPE_EXTENSIONS[subtype]) return SUBTYPE_EXTENSIONS[subtype];
613
+ }
614
+ if (element.type === "file") {
615
+ const fileNameExt = normalizeExtension(extname(element.name || ""));
616
+ if (fileNameExt) return fileNameExt;
617
+ }
618
+ if (element.type === "record") return EXTENSIONS_BY_TYPE.record;
619
+ const byType = CONTENT_TYPE_EXTENSIONS[contentType];
620
+ if (byType) return byType;
621
+ const byName = normalizeExtension(extname(element.name || ""));
622
+ if (byName) return byName;
623
+ return normalizeExtension(extname(new URL(source).pathname)) || EXTENSIONS_BY_TYPE[element.type];
624
+ };
625
+ /**
626
+ * 规范化并校验扩展名。
627
+ *
628
+ * 只允许短的字母数字扩展名,避免 URL 或文件名中的异常内容进入本地路径。
629
+ *
630
+ * @param ext 原始扩展名。
631
+ * @returns 合法扩展名;非法时返回空字符串。
632
+ */
633
+ const normalizeExtension = (ext) => {
634
+ const normalized = ext.toLowerCase();
635
+ if (!normalized || normalized.length > 16) return "";
636
+ if (!/^\.[a-z0-9]+$/.test(normalized)) return "";
637
+ return normalized === ".jpeg" ? ".jpg" : normalized;
638
+ };
639
+ /**
640
+ * 获取用于本地文件名的主文件名。
641
+ *
642
+ * 只有 file 消息段通常会带业务文件名;其他媒体没有 name 时由类型名兜底。
643
+ *
644
+ * @param element 媒体消息段。
645
+ * @returns 已清理的主文件名,不包含扩展名。
646
+ */
647
+ const getFileBaseName = (element) => {
648
+ const raw = element.name || "";
649
+ return sanitizeFilename(raw ? raw.slice(0, raw.length - extname(raw).length) : "");
650
+ };
651
+ /**
652
+ * 清理文件名中的危险字符。
653
+ *
654
+ * @param name 原始文件名。
655
+ * @returns 可安全拼接到本地路径中的文件名片段。
656
+ */
657
+ const sanitizeFilename = (name) => {
658
+ return name.split("").map((char) => isUnsafeFilenameChar(char) ? "_" : char).join("").replace(/\s+/g, " ").trim().slice(0, 80);
659
+ };
660
+ /**
661
+ * 判断字符是否不适合出现在 Windows/Unix 文件名中。
662
+ *
663
+ * @param char 单个字符。
664
+ * @returns true 表示需要替换。
665
+ */
666
+ const isUnsafeFilenameChar = (char) => {
667
+ return char.charCodeAt(0) < 32 || "<>:\"/\\|?*".includes(char);
668
+ };
669
+ /**
670
+ * 将图片扩展名转换为 Karin image.subType。
671
+ *
672
+ * @param ext 带点号的扩展名。
673
+ * @returns Karin 使用的图片格式名。
674
+ */
675
+ const extensionToSubType = (ext) => {
676
+ const normalized = ext.replace(/^\./, "").toLowerCase();
677
+ return normalized === "jpg" ? "jpeg" : normalized;
678
+ };
679
+ /**
680
+ * 校验响应 content-type 是否和消息段类型相容。
681
+ *
682
+ * 当前只严格校验图片,其他媒体平台可能返回 octet-stream 或不完整类型。
683
+ *
684
+ * @param type 消息段类型。
685
+ * @param contentType 响应 content-type。
686
+ */
687
+ const validateContentType = (type, contentType) => {
688
+ if (!contentType) return;
689
+ if (type === "image" && !contentType.startsWith("image/") && contentType !== "application/octet-stream") throw new Error(`下载媒体失败: 非图片类型 ${contentType}`);
690
+ };
691
+ /**
692
+ * 判断消息段是否带有可本地化的 file 字段。
693
+ *
694
+ * @param element Karin 消息段。
695
+ * @returns true 表示该消息段可以进行媒体本地化。
696
+ */
697
+ const isFileBackedElement = (element) => {
698
+ return element.type === "image" || element.type === "video" || element.type === "record" || element.type === "file";
699
+ };
700
+ /**
701
+ * 判断 file 字段是否是远程 URL。
702
+ *
703
+ * @param value file 字段值。
704
+ * @returns true 表示需要下载到本地缓存。
705
+ */
706
+ const isRemoteUrl = (value) => {
707
+ return /^https?:\/\//i.test(value) || value.startsWith("//");
708
+ };
709
+ /**
710
+ * 规范化远程 URL。
711
+ *
712
+ * QQ 下发的资源有时是协议相对 URL,这里默认补全为 https。
713
+ *
714
+ * @param value 原始 URL。
715
+ * @returns 可交给 axios 请求的完整 URL。
716
+ */
717
+ const normalizeRemoteUrl = (value) => {
718
+ return value.startsWith("//") ? `https:${value}` : value;
719
+ };
720
+
721
+ //#endregion
722
+ //#region src/core/storage/message-store/schema.ts
723
+ const LEGACY_DETAIL_TABLES = [
724
+ "qqbot_element_text",
725
+ "qqbot_element_at",
726
+ "qqbot_element_reply",
727
+ "qqbot_element_face",
728
+ "qqbot_element_media",
729
+ "qqbot_element_markdown"
730
+ ];
731
+ /**
732
+ * 判断当前数据库是否已经是 v1 首发基线结构。
733
+ *
734
+ * @param tableColumns 读取指定表字段名的函数。
735
+ * @param tableExists 判断指定表是否存在的函数。
736
+ * @returns true 表示当前表结构可直接使用;false 表示需要重建缓存表。
737
+ */
738
+ const isCurrentSchema = async (tableColumns, tableExists) => {
739
+ const columns = await tableColumns("qqbot_messages");
740
+ if (!columns.includes("contact_ref")) return false;
741
+ if (columns.includes("expires_at")) return false;
742
+ if (!(await tableColumns("qqbot_message_elements")).includes("value")) return false;
743
+ if (!await tableExists("qqbot_bots")) return false;
744
+ if (!await tableExists("qqbot_contacts")) return false;
745
+ if (!await tableExists("qqbot_senders")) return false;
746
+ if (!await tableExists("qqbot_message_elements")) return false;
747
+ if (!await tableExists("qqbot_message_aliases")) return false;
748
+ return true;
749
+ };
750
+ /**
751
+ * 删除所有开发阶段旧缓存表。
752
+ *
753
+ * @param run SQL 执行函数。
754
+ */
755
+ const dropSchema = async (run) => {
756
+ await run("DROP TABLE IF EXISTS qqbot_message_aliases");
757
+ for (const table of LEGACY_DETAIL_TABLES) await run(`DROP TABLE IF EXISTS ${table}`);
758
+ await run("DROP TABLE IF EXISTS qqbot_message_elements");
759
+ await run("DROP TABLE IF EXISTS qqbot_messages");
760
+ await run("DROP TABLE IF EXISTS qqbot_senders");
761
+ await run("DROP TABLE IF EXISTS qqbot_contacts");
762
+ await run("DROP TABLE IF EXISTS qqbot_bots");
763
+ await run("DROP TABLE IF EXISTS qqbot_message_alias");
764
+ await run("DROP TABLE IF EXISTS qqbot_message_cache");
765
+ };
766
+ /**
767
+ * 创建 v1 首发基线表结构。
768
+ *
769
+ * @param run SQL 执行函数。
770
+ */
771
+ const createSchema = async (run) => {
772
+ await run(`CREATE TABLE qqbot_bots (
773
+ id INTEGER PRIMARY KEY,
774
+ bot_id TEXT NOT NULL UNIQUE
775
+ )`);
776
+ await run(`CREATE TABLE qqbot_contacts (
777
+ id INTEGER PRIMARY KEY,
778
+ bot_ref INTEGER NOT NULL,
779
+ scene TEXT NOT NULL,
780
+ peer TEXT NOT NULL,
781
+ sub_peer TEXT NOT NULL DEFAULT '',
782
+ name TEXT NOT NULL DEFAULT '',
783
+ sub_name TEXT NOT NULL DEFAULT '',
784
+ UNIQUE(bot_ref, scene, peer, sub_peer),
785
+ FOREIGN KEY (bot_ref) REFERENCES qqbot_bots(id) ON DELETE CASCADE
786
+ )`);
787
+ await run(`CREATE TABLE qqbot_senders (
788
+ id INTEGER PRIMARY KEY,
789
+ bot_ref INTEGER NOT NULL,
790
+ user_id TEXT NOT NULL,
791
+ nick TEXT NOT NULL DEFAULT '',
792
+ name TEXT NOT NULL DEFAULT '',
793
+ role TEXT NOT NULL DEFAULT 'member',
794
+ UNIQUE(bot_ref, user_id, nick, name, role),
795
+ FOREIGN KEY (bot_ref) REFERENCES qqbot_bots(id) ON DELETE CASCADE
796
+ )`);
797
+ await run(`CREATE TABLE qqbot_messages (
798
+ id INTEGER PRIMARY KEY,
799
+ bot_ref INTEGER NOT NULL,
800
+ contact_ref INTEGER NOT NULL,
801
+ sender_ref INTEGER NOT NULL,
802
+ message_id TEXT NOT NULL,
803
+ message_seq INTEGER NOT NULL DEFAULT 0,
804
+ time INTEGER NOT NULL,
805
+ UNIQUE(bot_ref, contact_ref, message_id),
806
+ FOREIGN KEY (bot_ref) REFERENCES qqbot_bots(id) ON DELETE CASCADE,
807
+ FOREIGN KEY (contact_ref) REFERENCES qqbot_contacts(id) ON DELETE CASCADE,
808
+ FOREIGN KEY (sender_ref) REFERENCES qqbot_senders(id) ON DELETE RESTRICT
809
+ )`);
810
+ await run(`CREATE TABLE qqbot_message_elements (
811
+ message_ref INTEGER NOT NULL,
812
+ element_index INTEGER NOT NULL,
813
+ element_type TEXT NOT NULL,
814
+ value TEXT NOT NULL,
815
+ PRIMARY KEY (message_ref, element_index),
816
+ FOREIGN KEY (message_ref) REFERENCES qqbot_messages(id) ON DELETE CASCADE
817
+ ) WITHOUT ROWID`);
818
+ await run(`CREATE TABLE qqbot_message_aliases (
819
+ bot_ref INTEGER NOT NULL,
820
+ contact_ref INTEGER NOT NULL,
821
+ alias_message_id TEXT NOT NULL,
822
+ message_ref INTEGER NOT NULL,
823
+ PRIMARY KEY (bot_ref, contact_ref, alias_message_id),
824
+ FOREIGN KEY (bot_ref) REFERENCES qqbot_bots(id) ON DELETE CASCADE,
825
+ FOREIGN KEY (contact_ref) REFERENCES qqbot_contacts(id) ON DELETE CASCADE,
826
+ FOREIGN KEY (message_ref) REFERENCES qqbot_messages(id) ON DELETE CASCADE
827
+ ) WITHOUT ROWID`);
828
+ await run("CREATE INDEX idx_qqbot_contacts_lookup ON qqbot_contacts(bot_ref, scene, peer, sub_peer)");
829
+ await run("CREATE INDEX idx_qqbot_messages_lookup ON qqbot_messages(bot_ref, message_id)");
830
+ await run("CREATE INDEX idx_qqbot_messages_time ON qqbot_messages(time)");
831
+ await run("CREATE INDEX idx_qqbot_message_aliases_id ON qqbot_message_aliases(bot_ref, alias_message_id)");
832
+ };
833
+
834
+ //#endregion
835
+ //#region src/core/storage/message-store/migrations.ts
836
+ /**
837
+ * 消息缓存数据库迁移入口。
838
+ *
839
+ * 后续改表时只需要在数组末尾追加对象,例如:
840
+ *
841
+ * ```ts
842
+ * {
843
+ * version: 2,
844
+ * description: '给 qqbot_messages 增加 xxx 字段',
845
+ * up: async ({ run }) => {
846
+ * await run('ALTER TABLE qqbot_messages ADD COLUMN xxx TEXT')
847
+ * },
848
+ * }
849
+ * ```
850
+ */
851
+ const migrations = [{
852
+ version: 1,
853
+ description: "首个 alpha 发布基线:消息主表、单 value 消息段表和 alias 表",
854
+ up: async ({ run, tableColumns, tableExists, markVacuum }) => {
855
+ if (await isCurrentSchema(tableColumns, tableExists)) return;
856
+ await dropSchema(run);
857
+ markVacuum();
858
+ await createSchema(run);
859
+ }
860
+ }];
861
+
862
+ //#endregion
863
+ //#region src/core/storage/message-store/store.ts
864
+ /**
865
+ * QQBot 收到的消息缓存。
866
+ *
867
+ * 热路径只写内存并入队,不等待 SQLite:
868
+ * - `getMsg` 优先查内存,插件在同一事件回调内可立即命中;
869
+ * - SQLite 在后台按批次事务落库,避免一条消息一个事务拖慢事件分发;
870
+ * - 元素表只保存 `type + value`,避免宽表 NULL 和多详情表多次 INSERT。
871
+ */
872
+ var MessageStore = class {
873
+ db;
874
+ ready;
875
+ lastCleanup = 0;
876
+ shouldVacuum = false;
877
+ flushTimer;
878
+ flushing = false;
879
+ mediaTimer;
880
+ mediaActive = 0;
881
+ pending = [];
882
+ pendingMedia = [];
883
+ pendingMediaKeys = /* @__PURE__ */ new Set();
884
+ memory = /* @__PURE__ */ new Map();
885
+ directIndex = /* @__PURE__ */ new Map();
886
+ aliasIndex = /* @__PURE__ */ new Map();
887
+ scopedAliasIndex = /* @__PURE__ */ new Map();
888
+ botRefCache = /* @__PURE__ */ new Map();
889
+ contactRefCache = /* @__PURE__ */ new Map();
890
+ senderRefCache = /* @__PURE__ */ new Map();
891
+ constructor() {
892
+ this.ready = this.initialize();
893
+ }
894
+ /**
895
+ * 立即执行一次过期消息和本地媒体缓存清理。
896
+ *
897
+ * 该方法交给 Karin `task` 调度调用;类内部只保留清理能力,不自行维护常驻定时器。
898
+ *
899
+ * @returns 清理完成 Promise。
900
+ */
901
+ async cleanupExpired() {
902
+ await this.ready;
903
+ await this.cleanup();
904
+ }
905
+ /**
906
+ * 保存实际消息,并为 QQ 的 `msg_idx` 等引用索引建立轻量映射。
907
+ *
908
+ * 该方法只同步写入内存并投递后台队列,不等待 SQLite。这样消息事件创建不会被
909
+ * 数据库写锁或小事务阻塞。
910
+ *
911
+ * @param botId 当前 QQBot appId。
912
+ * @param message 已转换为 Karin elements 的消息缓存。
913
+ * @param aliases 可查询到该消息的额外消息索引,例如 QQ `msg_idx`。
914
+ */
915
+ async save(botId, message, aliases = []) {
916
+ if (!message.messageId) return;
917
+ this.cleanupMemoryIfDue();
918
+ const cached = this.cloneMessage(message);
919
+ this.putMemory(botId, cached, aliases);
920
+ this.enqueue({
921
+ botId,
922
+ message: cached,
923
+ aliases,
924
+ referenceOnly: false
925
+ });
926
+ this.enqueueMediaLocalize(botId, cached, aliases);
927
+ }
928
+ /**
929
+ * 仅当内存中没有该引用索引时保存 QQ 下发的引用上下文。
930
+ * 数据库是否已存在由后台 flush 再判断,避免阻塞事件热路径。
931
+ *
932
+ * @param botId 当前 QQBot appId。
933
+ * @param message 以 `ref_msg_idx` 作为 messageId 的引用上下文。
934
+ */
935
+ async saveReferenceIfAbsent(botId, message) {
936
+ if (!message.messageId) return;
937
+ this.cleanupMemoryIfDue();
938
+ if (this.getFromMemory(botId, message.messageId, message.contact)) return;
939
+ const cached = this.cloneMessage(message);
940
+ this.putMemory(botId, cached);
941
+ this.enqueue({
942
+ botId,
943
+ message: cached,
944
+ aliases: [],
945
+ referenceOnly: true
946
+ });
947
+ this.enqueueMediaLocalize(botId, cached, []);
948
+ }
949
+ /**
950
+ * 读取缓存消息。
951
+ *
952
+ * OneBot v11 标准调用只传 messageId;contact 参数仅用于额外限定范围。
953
+ *
954
+ * @param botId 当前 QQBot appId。
955
+ * @param messageId 真实消息 ID 或 QQ `REFIDX` 引用索引。
956
+ * @param contact 可选会话范围,传入后只在该会话下查询。
957
+ * @returns 命中的消息缓存;未命中或已过期时返回 null。
958
+ */
959
+ async get(botId, messageId, contact) {
960
+ if (!messageId) return null;
961
+ const memory = this.getFromMemory(botId, messageId, contact);
962
+ if (memory) return this.toResponse(memory);
963
+ await this.ready;
964
+ await this.cleanupIfDue();
965
+ const key = contact ? this.contactKey(botId, contact, messageId) : [botId, messageId];
966
+ const direct = await this.getRow(contact ? SQL.selectMessage : SQL.selectMessageById, key);
967
+ const alias = direct ? void 0 : await this.getRow(contact ? SQL.selectAlias : SQL.selectAliasById, key);
968
+ if (!direct && !alias) return null;
969
+ const row = direct || await this.getRow(SQL.selectMessageByRef, [alias.message_ref]);
970
+ if (!row) return null;
971
+ if (!direct && await this.hasReplyTo(row.message_ref, messageId)) {
972
+ /** 拒绝旧缓存中错误指向当前引用消息的别名。 */
973
+ await this.deleteAlias(botId, this.contactFromRow(row), messageId);
974
+ return null;
975
+ }
976
+ if (this.isExpired(row)) {
977
+ await this.cleanup();
978
+ return null;
979
+ }
980
+ return this.rowToResponse(row);
981
+ }
982
+ /**
983
+ * 按起始消息 ID 读取同一会话内的历史缓存消息。
984
+ *
985
+ * QQ 官方 Bot 没有 seq 历史查询能力;这里以本地缓存中命中的起始消息为锚点,
986
+ * 按时间倒序返回最近的 `count` 条消息,包含起始消息本身。
987
+ *
988
+ * @param botId 当前 QQBot appId。
989
+ * @param contact 查询限定的会话。
990
+ * @param startMsgId 起始消息 ID 或 QQ `REFIDX` 引用索引。
991
+ * @param count 获取消息数量。
992
+ * @returns 命中的历史消息数组;未命中或已过期时返回空数组。
993
+ */
994
+ async getHistory(botId, contact, startMsgId, count) {
995
+ const limit = Math.trunc(count || 1);
996
+ if (!startMsgId || limit <= 0) return [];
997
+ await this.ready;
998
+ await this.cleanupIfDue();
999
+ await this.flushQueue();
1000
+ const anchor = await this.resolveMessageRow(botId, contact, startMsgId);
1001
+ if (!anchor) return [];
1002
+ if (this.isExpired(anchor)) {
1003
+ await this.cleanup();
1004
+ return [];
1005
+ }
1006
+ const rows = await this.getRows(SQL.selectHistory, [
1007
+ botId,
1008
+ contact.scene,
1009
+ contact.peer,
1010
+ contact.subPeer || "",
1011
+ anchor.time,
1012
+ anchor.time,
1013
+ anchor.message_ref,
1014
+ limit
1015
+ ]);
1016
+ return Promise.all(rows.map((row) => this.rowToResponse(row)));
1017
+ }
1018
+ /**
1019
+ * 初始化 SQLite 连接、表结构,并执行启动清理和媒体本地化补救。
1020
+ *
1021
+ * @returns 初始化完成 Promise。
1022
+ */
1023
+ async initialize() {
1024
+ const file = join(karinPathBase, "@karinjs-adapter-qqbot", "data", "message-cache.db");
1025
+ await mkdir(dirname(file), { recursive: true });
1026
+ this.db = await new Promise((resolve, reject) => {
1027
+ const db = new sqlite3.Database(file, (err) => err ? reject(err) : resolve(db));
1028
+ });
1029
+ await this.run("PRAGMA foreign_keys = ON");
1030
+ await this.run("PRAGMA journal_mode = WAL");
1031
+ await this.run("PRAGMA synchronous = NORMAL");
1032
+ await this.run("PRAGMA temp_store = MEMORY");
1033
+ await this.run("PRAGMA busy_timeout = 5000");
1034
+ await this.migrate();
1035
+ await this.cleanup();
1036
+ await this.recoverRemoteMedia();
1037
+ }
1038
+ /**
1039
+ * 投递待落库消息。
1040
+ *
1041
+ * @param item 待写入 SQLite 的缓存任务。
1042
+ */
1043
+ enqueue(item) {
1044
+ this.pending.push(item);
1045
+ if (this.pending.length > 5e4) {
1046
+ this.prunePending();
1047
+ if (this.pending.length > 5e4) log("warn", `[getMsg] SQLite 写入队列过长,已丢弃 ${this.pending.splice(0, this.pending.length - MAX_WRITE_QUEUE).length} 条最旧待写缓存`);
1048
+ }
1049
+ this.scheduleFlush();
1050
+ }
1051
+ /**
1052
+ * 安排一次后台批量落库。
1053
+ *
1054
+ * 已有 timer 或正在 flush 时不会重复安排。
1055
+ */
1056
+ scheduleFlush() {
1057
+ if (this.flushTimer || this.flushing) return;
1058
+ this.flushTimer = setTimeout(() => {
1059
+ this.flushTimer = void 0;
1060
+ this.flushQueue().catch((err) => log("warn", "[getMsg] 批量写入消息缓存失败", err));
1061
+ }, 100);
1062
+ this.flushTimer.unref();
1063
+ }
1064
+ /**
1065
+ * 批量落库队列中的消息。
1066
+ *
1067
+ * 单次最多处理 `FLUSH_BATCH_SIZE` 条,并在一个事务内完成写入。
1068
+ *
1069
+ * @returns flush 完成 Promise。
1070
+ */
1071
+ async flushQueue() {
1072
+ if (this.flushing) return;
1073
+ this.flushing = true;
1074
+ try {
1075
+ await this.ready;
1076
+ const batch = this.pending.splice(0, 500);
1077
+ if (!batch.length) return;
1078
+ await this.transaction(async () => {
1079
+ for (const item of batch) {
1080
+ if (this.isExpired(item.message)) continue;
1081
+ await this.writeItem(item);
1082
+ }
1083
+ });
1084
+ if (this.pending.length) this.scheduleFlush();
1085
+ } finally {
1086
+ this.flushing = false;
1087
+ if (this.pending.length) this.scheduleFlush();
1088
+ }
1089
+ }
1090
+ /**
1091
+ * 写入一条待落库消息。
1092
+ *
1093
+ * @param item 待写入的消息、alias 和引用上下文标记。
1094
+ */
1095
+ async writeItem(item) {
1096
+ const botRef = await this.ensureBot(item.botId);
1097
+ const contactRef = await this.ensureContact(botRef, item.message.contact);
1098
+ if (item.referenceOnly) {
1099
+ if (await this.getRow(SQL.selectMessageRef, [
1100
+ botRef,
1101
+ contactRef,
1102
+ item.message.messageId
1103
+ ])) return;
1104
+ /** 原消息已通过 msg_idx alias 缓存时,不再把引用上下文另存为一条消息。 */
1105
+ if (await this.getRow(SQL.selectAlias, this.contactKey(item.botId, item.message.contact, item.message.messageId))) return;
1106
+ }
1107
+ const senderRef = await this.ensureSender(botRef, item.message.sender);
1108
+ const messageRef = await this.ensureMessage(botRef, contactRef, senderRef, item.message);
1109
+ await this.run(SQL.deleteElements, [messageRef]);
1110
+ for (const [index, element] of item.message.elements.entries()) await saveElement((sql, params) => this.run(sql, params), messageRef, index, element);
1111
+ for (const alias of new Set(item.aliases.filter((id) => id && id !== item.message.messageId))) await this.run(SQL.insertAlias, [
1112
+ botRef,
1113
+ contactRef,
1114
+ alias,
1115
+ messageRef
1116
+ ]);
1117
+ }
1118
+ /**
1119
+ * 清理待写队列中已经过期的消息。
1120
+ */
1121
+ prunePending() {
1122
+ const deadline = Date.now() - MESSAGE_TTL;
1123
+ for (let index = this.pending.length - 1; index >= 0; index--) if (this.pending[index].message.time <= deadline) this.pending.splice(index, 1);
1124
+ }
1125
+ /**
1126
+ * 投递媒体本地化任务。
1127
+ *
1128
+ * 原消息会先保留短期 URL 写入缓存;本地化成功后再覆盖同一条消息。队列会按
1129
+ * message scoped key 去重,避免高频更新同一条消息时重复下载。
1130
+ *
1131
+ * @param botId 当前 QQBot appId。
1132
+ * @param message 可能包含远程媒体 URL 的消息快照。
1133
+ * @param aliases 该消息可被查询到的额外 alias。
1134
+ */
1135
+ enqueueMediaLocalize(botId, message, aliases) {
1136
+ if (!hasRemoteFileElement(message.elements)) return;
1137
+ if (this.isExpired(message)) return;
1138
+ const key = this.scopedKey(botId, message.contact, message.messageId);
1139
+ if (this.pendingMediaKeys.has(key)) return;
1140
+ const item = {
1141
+ botId,
1142
+ message: this.cloneMessage(message),
1143
+ aliases,
1144
+ key
1145
+ };
1146
+ this.pendingMedia.push(item);
1147
+ this.pendingMediaKeys.add(key);
1148
+ if (this.pendingMedia.length > 5e4) {
1149
+ this.prunePendingMedia();
1150
+ if (this.pendingMedia.length > 5e4) {
1151
+ const dropped = this.pendingMedia.splice(0, this.pendingMedia.length - MAX_MEDIA_LOCALIZE_QUEUE);
1152
+ dropped.forEach((item) => this.pendingMediaKeys.delete(item.key));
1153
+ log("warn", `[getMsg] 媒体本地缓存队列过长,已丢弃 ${dropped.length} 条最旧任务`);
1154
+ }
1155
+ }
1156
+ this.scheduleMediaLocalize();
1157
+ }
1158
+ /**
1159
+ * 安排媒体本地化队列处理。
1160
+ *
1161
+ * @returns 已存在定时器或队列为空时直接返回。
1162
+ */
1163
+ scheduleMediaLocalize() {
1164
+ if (this.mediaTimer) return;
1165
+ if (!this.pendingMedia.length) return;
1166
+ this.mediaTimer = setTimeout(() => {
1167
+ this.mediaTimer = void 0;
1168
+ this.drainMediaLocalizeQueue();
1169
+ }, 100);
1170
+ this.mediaTimer.unref();
1171
+ }
1172
+ /**
1173
+ * 按固定并发处理媒体本地化任务。
1174
+ *
1175
+ * @returns 队列为空或达到并发上限时返回。
1176
+ */
1177
+ drainMediaLocalizeQueue() {
1178
+ while (this.mediaActive < 4 && this.pendingMedia.length) {
1179
+ const item = this.pendingMedia.shift();
1180
+ this.mediaActive++;
1181
+ this.localizeMediaItem(item).catch((err) => log("warn", `[getMsg] 媒体本地缓存失败: ${item.message.messageId}`, err)).finally(() => {
1182
+ this.mediaActive--;
1183
+ this.pendingMediaKeys.delete(item.key);
1184
+ if (this.pendingMedia.length) this.scheduleMediaLocalize();
1185
+ });
1186
+ }
1187
+ }
1188
+ /**
1189
+ * 本地化单条消息中的远程媒体字段,并在成功后覆盖内存与 SQLite。
1190
+ *
1191
+ * @param item 待处理的媒体本地化任务。
1192
+ * @returns 本地化与回写完成 Promise。
1193
+ */
1194
+ async localizeMediaItem(item) {
1195
+ if (this.isExpired(item.message)) return;
1196
+ const result = await localizeFileElements(item.message.elements);
1197
+ for (const failure of result.failures) log("debug", `[getMsg] 媒体本地缓存跳过: ${item.message.messageId} ${failure}`);
1198
+ if (!result.changed) return;
1199
+ const localized = this.cloneMessage({
1200
+ ...item.message,
1201
+ elements: result.elements
1202
+ });
1203
+ this.putMemory(item.botId, localized, item.aliases);
1204
+ this.enqueue({
1205
+ botId: item.botId,
1206
+ message: localized,
1207
+ aliases: item.aliases,
1208
+ referenceOnly: false
1209
+ });
1210
+ }
1211
+ /**
1212
+ * 清理已经过期的媒体本地化待处理任务。
1213
+ *
1214
+ * @returns 无返回值;会同步维护 pendingMediaKeys。
1215
+ */
1216
+ prunePendingMedia() {
1217
+ const deadline = Date.now() - MESSAGE_TTL;
1218
+ for (let index = this.pendingMedia.length - 1; index >= 0; index--) {
1219
+ const item = this.pendingMedia[index];
1220
+ if (item.message.time > deadline) continue;
1221
+ this.pendingMedia.splice(index, 1);
1222
+ this.pendingMediaKeys.delete(item.key);
1223
+ }
1224
+ }
1225
+ /**
1226
+ * 启动时扫描 SQLite 中尚未本地化的远程媒体 URL,并重新投递队列补救。
1227
+ *
1228
+ * @returns 扫描和投递完成 Promise。
1229
+ */
1230
+ async recoverRemoteMedia() {
1231
+ const rows = await this.getRows(SQL.selectMessagesWithRemoteMedia, [Date.now() - MESSAGE_TTL, MEDIA_RECOVERY_LIMIT]);
1232
+ if (!rows.length) return;
1233
+ for (const row of rows) {
1234
+ const response = await this.rowToResponse(row);
1235
+ this.enqueueMediaLocalize(row.bot_id, {
1236
+ messageId: response.messageId,
1237
+ messageSeq: response.messageSeq,
1238
+ time: response.time,
1239
+ contact: response.contact,
1240
+ sender: response.sender,
1241
+ elements: response.elements
1242
+ }, []);
1243
+ }
1244
+ log("debug", `[getMsg] 已投递 ${rows.length} 条远程媒体缓存补救任务`);
1245
+ }
1246
+ /**
1247
+ * 写入内存热缓存。
1248
+ *
1249
+ * @param botId 当前 QQBot appId。
1250
+ * @param message 消息缓存。
1251
+ * @param aliases 可查询到该消息的额外消息索引。
1252
+ */
1253
+ putMemory(botId, message, aliases = []) {
1254
+ const scoped = this.scopedKey(botId, message.contact, message.messageId);
1255
+ this.memory.set(scoped, message);
1256
+ this.directIndex.set(this.globalKey(botId, message.messageId), scoped);
1257
+ for (const alias of new Set(aliases.filter((id) => id && id !== message.messageId))) {
1258
+ this.scopedAliasIndex.set(this.scopedKey(botId, message.contact, alias), scoped);
1259
+ this.aliasIndex.set(this.globalKey(botId, alias), scoped);
1260
+ }
1261
+ }
1262
+ /**
1263
+ * 从内存热缓存读取消息。
1264
+ *
1265
+ * @param botId 当前 QQBot appId。
1266
+ * @param messageId 真实消息 ID 或 alias。
1267
+ * @param contact 可选会话范围。
1268
+ * @returns 命中的缓存消息;未命中或过期时返回 null。
1269
+ */
1270
+ getFromMemory(botId, messageId, contact) {
1271
+ const scoped = contact ? this.scopedKey(botId, contact, messageId) : this.directIndex.get(this.globalKey(botId, messageId));
1272
+ const alias = contact ? this.scopedAliasIndex.get(this.scopedKey(botId, contact, messageId)) : this.aliasIndex.get(this.globalKey(botId, messageId));
1273
+ const key = scoped && this.memory.has(scoped) ? scoped : alias;
1274
+ if (!key) return null;
1275
+ const message = this.memory.get(key);
1276
+ if (!message) return null;
1277
+ if (!this.isExpired(message)) return message;
1278
+ this.cleanupMemory();
1279
+ return null;
1280
+ }
1281
+ /**
1282
+ * 到达清理间隔时清理内存缓存。
1283
+ */
1284
+ cleanupMemoryIfDue() {
1285
+ if (Date.now() - this.lastCleanup < 6e5) return;
1286
+ this.cleanupMemory();
1287
+ }
1288
+ /**
1289
+ * 清理一天前的内存消息,并同步清理失效索引。
1290
+ */
1291
+ cleanupMemory() {
1292
+ const deadline = Date.now() - MESSAGE_TTL;
1293
+ for (const [key, message] of this.memory) if (message.time <= deadline) this.memory.delete(key);
1294
+ this.cleanIndex(this.directIndex);
1295
+ this.cleanIndex(this.aliasIndex);
1296
+ this.cleanIndex(this.scopedAliasIndex);
1297
+ }
1298
+ /**
1299
+ * 清理指向已删除内存消息的索引。
1300
+ *
1301
+ * @param index 需要清理的索引 Map。
1302
+ */
1303
+ cleanIndex(index) {
1304
+ for (const [key, scoped] of index) if (!this.memory.has(scoped)) index.delete(key);
1305
+ }
1306
+ /**
1307
+ * 浅拷贝消息缓存,避免后续事件对象被插件侧修改后污染缓存。
1308
+ *
1309
+ * @param message 原始缓存消息。
1310
+ * @returns 可安全放入缓存的消息副本。
1311
+ */
1312
+ cloneMessage(message) {
1313
+ return {
1314
+ ...message,
1315
+ contact: { ...message.contact },
1316
+ sender: { ...message.sender },
1317
+ elements: message.elements.map((element) => ({ ...element }))
1318
+ };
1319
+ }
1320
+ /**
1321
+ * 将内存缓存转换为 Karin `MessageResponse`。
1322
+ *
1323
+ * @param message 内存缓存消息。
1324
+ * @returns Karin 标准消息查询结果。
1325
+ */
1326
+ toResponse(message) {
1327
+ return {
1328
+ messageId: message.messageId,
1329
+ messageSeq: message.messageSeq,
1330
+ time: message.time,
1331
+ contact: { ...message.contact },
1332
+ sender: { ...message.sender },
1333
+ elements: message.elements.map((element) => ({ ...element }))
1334
+ };
1335
+ }
1336
+ /**
1337
+ * 执行数据库版本迁移。
1338
+ *
1339
+ * 使用 SQLite `PRAGMA user_version` 记录已应用的 schema 版本。
1340
+ * 版本 0 只表示“未初始化或开发期旧库”,首个 alpha 发布基线为 v1。
1341
+ *
1342
+ * @returns 迁移完成 Promise。
1343
+ */
1344
+ async migrate() {
1345
+ const latest = migrations.at(-1)?.version || 0;
1346
+ if (latest !== 1) throw new Error(`消息缓存数据库迁移版本不一致: 常量 v${1},迁移入口 v${latest}`);
1347
+ const current = await this.getUserVersion();
1348
+ if (current > 1) throw new Error(`消息缓存数据库版本过高: 当前 v${current},适配器支持 v${1}`);
1349
+ for (let version = current + 1; version <= 1; version++) {
1350
+ const migration = migrations.find((item) => item.version === version);
1351
+ if (!migration) throw new Error(`缺少消息缓存数据库迁移: v${version}`);
1352
+ await this.transaction(() => migration.up({
1353
+ run: (sql, params) => this.run(sql, params),
1354
+ tableColumns: (name) => this.tableColumns(name),
1355
+ tableExists: (name) => this.tableExists(name),
1356
+ markVacuum: () => {
1357
+ this.shouldVacuum = true;
1358
+ }
1359
+ }));
1360
+ await this.setUserVersion(version);
1361
+ }
1362
+ if (this.shouldVacuum) {
1363
+ await this.run("VACUUM");
1364
+ this.shouldVacuum = false;
1365
+ }
1366
+ }
1367
+ /**
1368
+ * 获取或创建 bot 映射 ID。
1369
+ *
1370
+ * @param botId 当前 QQBot appId。
1371
+ * @returns `qqbot_bots.id`。
1372
+ */
1373
+ async ensureBot(botId) {
1374
+ const cached = this.botRefCache.get(botId);
1375
+ if (cached) return cached;
1376
+ await this.run(SQL.insertBot, [botId]);
1377
+ const row = await this.getRow(SQL.selectBotId, [botId]);
1378
+ if (!row) throw new Error(`消息缓存 bot 映射写入失败: ${botId}`);
1379
+ this.botRefCache.set(botId, row.id);
1380
+ return row.id;
1381
+ }
1382
+ /**
1383
+ * 获取或创建会话映射 ID。
1384
+ *
1385
+ * @param botRef `qqbot_bots.id`。
1386
+ * @param contact Karin 会话对象。
1387
+ * @returns `qqbot_contacts.id`。
1388
+ */
1389
+ async ensureContact(botRef, contact) {
1390
+ const key = [
1391
+ botRef,
1392
+ contact.scene,
1393
+ contact.peer,
1394
+ contact.subPeer || ""
1395
+ ].join("");
1396
+ const cached = this.contactRefCache.get(key);
1397
+ if (cached) return cached;
1398
+ const values = [
1399
+ botRef,
1400
+ contact.scene,
1401
+ contact.peer,
1402
+ contact.subPeer || "",
1403
+ contact.name || "",
1404
+ contact.subName || ""
1405
+ ];
1406
+ await this.run(SQL.upsertContact, values);
1407
+ const row = await this.getRow(SQL.selectContactId, values.slice(0, 4));
1408
+ if (!row) throw new Error(`消息缓存会话映射写入失败: ${contact.scene}:${contact.peer}`);
1409
+ this.contactRefCache.set(key, row.id);
1410
+ return row.id;
1411
+ }
1412
+ /**
1413
+ * 获取或创建发送者映射 ID。
1414
+ *
1415
+ * @param botRef `qqbot_bots.id`。
1416
+ * @param sender Karin sender 对象。
1417
+ * @returns `qqbot_senders.id`。
1418
+ */
1419
+ async ensureSender(botRef, sender) {
1420
+ const role = sender.role || "member";
1421
+ const values = [
1422
+ botRef,
1423
+ sender.userId || "",
1424
+ sender.nick || "",
1425
+ sender.name || "",
1426
+ role
1427
+ ];
1428
+ const key = values.join("");
1429
+ const cached = this.senderRefCache.get(key);
1430
+ if (cached) return cached;
1431
+ await this.run(SQL.upsertSender, values);
1432
+ const row = await this.getRow(SQL.selectSenderId, values);
1433
+ if (!row) throw new Error(`消息缓存发送者映射写入失败: ${sender.userId || ""}`);
1434
+ this.senderRefCache.set(key, row.id);
1435
+ return row.id;
1436
+ }
1437
+ /**
1438
+ * 获取或创建消息主记录。
1439
+ *
1440
+ * @param botRef `qqbot_bots.id`。
1441
+ * @param contactRef `qqbot_contacts.id`。
1442
+ * @param senderRef `qqbot_senders.id`。
1443
+ * @param message 消息缓存。
1444
+ * @returns `qqbot_messages.id`。
1445
+ */
1446
+ async ensureMessage(botRef, contactRef, senderRef, message) {
1447
+ await this.run(SQL.upsertMessage, [
1448
+ botRef,
1449
+ contactRef,
1450
+ senderRef,
1451
+ message.messageId,
1452
+ message.messageSeq,
1453
+ message.time
1454
+ ]);
1455
+ const row = await this.getRow(SQL.selectMessageRef, [
1456
+ botRef,
1457
+ contactRef,
1458
+ message.messageId
1459
+ ]);
1460
+ if (!row) throw new Error(`消息缓存主记录写入失败: ${message.messageId}`);
1461
+ return row.id;
1462
+ }
1463
+ /**
1464
+ * 从查询行还原 Karin Contact。
1465
+ *
1466
+ * @param row 消息主查询结果。
1467
+ * @returns Karin Contact。
1468
+ */
1469
+ contactFromRow(row) {
1470
+ const common = {
1471
+ peer: row.peer,
1472
+ name: row.contact_name
1473
+ };
1474
+ switch (row.scene) {
1475
+ case "group": return {
1476
+ scene: "group",
1477
+ ...common
1478
+ };
1479
+ case "friend": return {
1480
+ scene: "friend",
1481
+ ...common
1482
+ };
1483
+ case "guild": return {
1484
+ scene: "guild",
1485
+ ...common,
1486
+ subPeer: row.sub_peer,
1487
+ subName: row.contact_sub_name
1488
+ };
1489
+ case "direct": return {
1490
+ scene: "direct",
1491
+ ...common,
1492
+ subPeer: row.sub_peer,
1493
+ subName: row.contact_sub_name
1494
+ };
1495
+ case "groupTemp": return {
1496
+ scene: "groupTemp",
1497
+ ...common,
1498
+ subPeer: row.sub_peer
1499
+ };
1500
+ }
1501
+ }
1502
+ /**
1503
+ * 将数据库行还原为 Karin `MessageResponse`。
1504
+ *
1505
+ * @param row 消息主查询结果。
1506
+ * @returns Karin 标准消息查询结果。
1507
+ */
1508
+ async rowToResponse(row) {
1509
+ return {
1510
+ messageId: row.message_id,
1511
+ messageSeq: row.message_seq,
1512
+ time: row.time,
1513
+ contact: this.contactFromRow(row),
1514
+ sender: {
1515
+ userId: row.sender_user_id,
1516
+ nick: row.sender_nick,
1517
+ name: row.sender_name,
1518
+ role: row.sender_role
1519
+ },
1520
+ elements: await loadElements((sql, params) => this.getRows(sql, params), row.message_ref)
1521
+ };
1522
+ }
1523
+ /**
1524
+ * 在指定会话中解析消息 ID 或 alias 到消息主表行。
1525
+ *
1526
+ * @param botId 当前 QQBot appId。
1527
+ * @param contact Karin 会话对象。
1528
+ * @param messageId 真实消息 ID 或 alias。
1529
+ * @returns 命中的消息行;未命中时返回 null。
1530
+ */
1531
+ async resolveMessageRow(botId, contact, messageId) {
1532
+ const key = this.contactKey(botId, contact, messageId);
1533
+ const direct = await this.getRow(SQL.selectMessage, key);
1534
+ const alias = direct ? void 0 : await this.getRow(SQL.selectAlias, key);
1535
+ if (!direct && !alias) return null;
1536
+ const row = direct || await this.getRow(SQL.selectMessageByRef, [alias.message_ref]);
1537
+ if (!row) return null;
1538
+ if (!direct && await this.hasReplyTo(row.message_ref, messageId)) {
1539
+ await this.deleteAlias(botId, this.contactFromRow(row), messageId);
1540
+ return null;
1541
+ }
1542
+ return row;
1543
+ }
1544
+ /**
1545
+ * 构造会话级查询键。
1546
+ *
1547
+ * @param botId 当前 QQBot appId。
1548
+ * @param contact Karin 会话对象。
1549
+ * @param messageId 消息 ID 或 alias。
1550
+ * @returns 用于 SQL 参数或内存 key 的字段数组。
1551
+ */
1552
+ contactKey(botId, contact, messageId) {
1553
+ return [
1554
+ botId,
1555
+ contact.scene,
1556
+ contact.peer,
1557
+ contact.subPeer || "",
1558
+ messageId
1559
+ ];
1560
+ }
1561
+ /**
1562
+ * 构造内存会话级 key。
1563
+ *
1564
+ * @param botId 当前 QQBot appId。
1565
+ * @param contact Karin 会话对象。
1566
+ * @param messageId 消息 ID 或 alias。
1567
+ * @returns 内存 Map 使用的稳定 key。
1568
+ */
1569
+ scopedKey(botId, contact, messageId) {
1570
+ return this.contactKey(botId, contact, messageId).join("");
1571
+ }
1572
+ /**
1573
+ * 构造内存全局 key。
1574
+ *
1575
+ * @param botId 当前 QQBot appId。
1576
+ * @param messageId 消息 ID 或 alias。
1577
+ * @returns 不限定会话时使用的内存 key。
1578
+ */
1579
+ globalKey(botId, messageId) {
1580
+ return `${botId}\x1f${messageId}`;
1581
+ }
1582
+ /**
1583
+ * 删除指定会话下的 alias。
1584
+ *
1585
+ * @param botId 当前 QQBot appId。
1586
+ * @param contact Karin 会话对象。
1587
+ * @param messageId 需要删除的 alias ID。
1588
+ */
1589
+ async deleteAlias(botId, contact, messageId) {
1590
+ await this.run(SQL.deleteAlias, [
1591
+ botId,
1592
+ botId,
1593
+ contact.scene,
1594
+ contact.peer,
1595
+ contact.subPeer || "",
1596
+ messageId
1597
+ ]);
1598
+ }
1599
+ /**
1600
+ * 判断某条消息是否包含指向指定 ID 的 reply 段。
1601
+ *
1602
+ * @param messageRef `qqbot_messages.id`。
1603
+ * @param replyId 被引用的消息 ID。
1604
+ * @returns true 表示该消息包含对应 reply 段。
1605
+ */
1606
+ async hasReplyTo(messageRef, replyId) {
1607
+ return !!await this.getRow(SQL.hasReply, [messageRef, replyElementValue(replyId)]);
1608
+ }
1609
+ /**
1610
+ * 判断消息是否已经超过本地缓存 TTL。
1611
+ *
1612
+ * @param row 带 `time` 字段的消息对象。
1613
+ * @returns true 表示已经超过一天缓存窗口。
1614
+ */
1615
+ isExpired(row) {
1616
+ return row.time <= Date.now() - MESSAGE_TTL;
1617
+ }
1618
+ /**
1619
+ * 到达清理间隔时清理内存和 SQLite 过期消息。
1620
+ */
1621
+ async cleanupIfDue() {
1622
+ if (Date.now() - this.lastCleanup < 6e5) return;
1623
+ await this.cleanup();
1624
+ }
1625
+ /**
1626
+ * 清理一天前的缓存消息。
1627
+ *
1628
+ * SQLite 只删除主消息表,消息段和 alias 通过外键级联删除。
1629
+ */
1630
+ async cleanup() {
1631
+ this.cleanupMemory();
1632
+ await this.run(SQL.cleanup, [Date.now() - MESSAGE_TTL]);
1633
+ await cleanupMessageMediaCache();
1634
+ this.lastCleanup = Date.now();
1635
+ }
1636
+ /**
1637
+ * 判断 SQLite 表是否存在。
1638
+ *
1639
+ * @param name 表名。
1640
+ * @returns true 表示表存在。
1641
+ */
1642
+ async tableExists(name) {
1643
+ return !!await this.getRow("SELECT 1 AS found FROM sqlite_master WHERE type = ? AND name = ?", ["table", name]);
1644
+ }
1645
+ /**
1646
+ * 读取 SQLite 表字段名。
1647
+ *
1648
+ * @param name 表名。
1649
+ * @returns 字段名数组;表不存在时返回空数组。
1650
+ */
1651
+ async tableColumns(name) {
1652
+ if (!await this.tableExists(name)) return [];
1653
+ return (await this.getRows(`PRAGMA table_info(${name})`)).map((row) => row.name);
1654
+ }
1655
+ /**
1656
+ * 读取 SQLite schema 版本。
1657
+ *
1658
+ * @returns 当前 `PRAGMA user_version`;新库默认为 0。
1659
+ */
1660
+ async getUserVersion() {
1661
+ return (await this.getRow("PRAGMA user_version"))?.user_version || 0;
1662
+ }
1663
+ /**
1664
+ * 写入 SQLite schema 版本。
1665
+ *
1666
+ * @param version 已成功应用的数据库版本号。
1667
+ */
1668
+ async setUserVersion(version) {
1669
+ await this.run(`PRAGMA user_version = ${version}`);
1670
+ }
1671
+ /**
1672
+ * 在 `BEGIN IMMEDIATE` 事务中执行写操作。
1673
+ *
1674
+ * @param fn 事务内执行的异步函数。
1675
+ */
1676
+ async transaction(fn) {
1677
+ await this.run("BEGIN IMMEDIATE");
1678
+ try {
1679
+ await fn();
1680
+ await this.run("COMMIT");
1681
+ } catch (err) {
1682
+ await this.run("ROLLBACK").catch(() => void 0);
1683
+ throw err;
1684
+ }
1685
+ }
1686
+ /**
1687
+ * 执行不返回行的 SQL。
1688
+ *
1689
+ * @param sql SQL 字符串。
1690
+ * @param params SQL 参数。
1691
+ */
1692
+ async run(sql, params = []) {
1693
+ const db = this.db;
1694
+ if (!db) throw new Error("消息缓存数据库尚未初始化");
1695
+ await new Promise((resolve, reject) => {
1696
+ db.run(sql, params, (err) => err ? reject(err) : resolve());
1697
+ });
1698
+ }
1699
+ /**
1700
+ * 查询单行数据。
1701
+ *
1702
+ * @param sql SQL 字符串。
1703
+ * @param params SQL 参数。
1704
+ * @returns 查询结果;未命中时返回 undefined。
1705
+ */
1706
+ async getRow(sql, params = []) {
1707
+ const db = this.db;
1708
+ if (!db) throw new Error("消息缓存数据库尚未初始化");
1709
+ return new Promise((resolve, reject) => {
1710
+ db.get(sql, params, (err, row) => err ? reject(err) : resolve(row));
1711
+ });
1712
+ }
1713
+ /**
1714
+ * 查询多行数据。
1715
+ *
1716
+ * @param sql SQL 字符串。
1717
+ * @param params SQL 参数。
1718
+ * @returns 查询结果数组。
1719
+ */
1720
+ async getRows(sql, params = []) {
1721
+ const db = this.db;
1722
+ if (!db) throw new Error("消息缓存数据库尚未初始化");
1723
+ return new Promise((resolve, reject) => {
1724
+ db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows));
1725
+ });
1726
+ }
1727
+ };
1728
+
1729
+ //#endregion
1730
+ //#region src/core/storage/message-store/index.ts
1731
+ /** 单一 SQLite 连接:所有 bot 共用同一份缓存表与清理任务。 */
1732
+ let store;
1733
+ const getMessageStore = () => {
1734
+ store ||= new MessageStore();
1735
+ return store;
1736
+ };
1737
+
1738
+ //#endregion
1739
+ export { getMessageStore as t };