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