koishi-plugin-best-cave 2.1.3 → 2.2.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/lib/HashManager.d.ts +12 -26
- package/lib/index.js +224 -291
- package/package.json +2 -2
- package/readme.md +41 -31
- package/lib/DataManager.d.ts +0 -38
- package/lib/FileManager.d.ts +0 -48
- package/lib/ProfileManager.d.ts +0 -50
- package/lib/ReviewManager.d.ts +0 -40
- package/lib/Utils.d.ts +0 -86
- package/lib/index.d.ts +0 -61
package/lib/index.js
CHANGED
|
@@ -77,7 +77,7 @@ var FileManager = class {
|
|
|
77
77
|
* @template T 异步操作的返回类型。
|
|
78
78
|
* @param fullPath 需要加锁的文件的完整路径。
|
|
79
79
|
* @param operation 要执行的异步函数。
|
|
80
|
-
* @returns
|
|
80
|
+
* @returns 异步操作的结果。
|
|
81
81
|
*/
|
|
82
82
|
async withLock(fullPath, operation) {
|
|
83
83
|
while (this.locks.has(fullPath)) {
|
|
@@ -93,7 +93,7 @@ var FileManager = class {
|
|
|
93
93
|
* @description 保存文件,自动选择 S3 或本地存储。
|
|
94
94
|
* @param fileName 用作 S3 Key 或本地文件名。
|
|
95
95
|
* @param data 要写入的 Buffer 数据。
|
|
96
|
-
* @returns
|
|
96
|
+
* @returns 保存时使用的文件名。
|
|
97
97
|
*/
|
|
98
98
|
async saveFile(fileName, data) {
|
|
99
99
|
if (this.s3Client) {
|
|
@@ -136,8 +136,7 @@ var FileManager = class {
|
|
|
136
136
|
async deleteFile(fileIdentifier) {
|
|
137
137
|
try {
|
|
138
138
|
if (this.s3Client) {
|
|
139
|
-
|
|
140
|
-
await this.s3Client.send(command);
|
|
139
|
+
await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
|
|
141
140
|
} else {
|
|
142
141
|
const filePath = path.join(this.resourceDir, fileIdentifier);
|
|
143
142
|
await this.withLock(filePath, () => fs.unlink(filePath));
|
|
@@ -160,12 +159,9 @@ var ProfileManager = class {
|
|
|
160
159
|
this.ctx = ctx;
|
|
161
160
|
this.ctx.model.extend("cave_user", {
|
|
162
161
|
userId: "string",
|
|
163
|
-
// 用户 ID
|
|
164
162
|
nickname: "string"
|
|
165
|
-
// 用户自定义昵称
|
|
166
163
|
}, {
|
|
167
164
|
primary: "userId"
|
|
168
|
-
// 保证每个用户只有一条昵称记录。
|
|
169
165
|
});
|
|
170
166
|
}
|
|
171
167
|
static {
|
|
@@ -176,7 +172,7 @@ var ProfileManager = class {
|
|
|
176
172
|
* @param cave - 主 `cave` 命令实例。
|
|
177
173
|
*/
|
|
178
174
|
registerCommands(cave) {
|
|
179
|
-
cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("
|
|
175
|
+
cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
|
|
180
176
|
const trimmedNickname = nickname?.trim();
|
|
181
177
|
if (trimmedNickname) {
|
|
182
178
|
await this.setNickname(session.userId, trimmedNickname);
|
|
@@ -197,10 +193,10 @@ var ProfileManager = class {
|
|
|
197
193
|
/**
|
|
198
194
|
* @description 获取指定用户的昵称。
|
|
199
195
|
* @param userId - 目标用户的 ID。
|
|
200
|
-
* @returns
|
|
196
|
+
* @returns 用户的昵称字符串或 null。
|
|
201
197
|
*/
|
|
202
198
|
async getNickname(userId) {
|
|
203
|
-
const [profile] = await this.ctx.database.get("cave_user", { userId }
|
|
199
|
+
const [profile] = await this.ctx.database.get("cave_user", { userId });
|
|
204
200
|
return profile?.nickname ?? null;
|
|
205
201
|
}
|
|
206
202
|
/**
|
|
@@ -220,14 +216,14 @@ var DataManager = class {
|
|
|
220
216
|
* @param config 插件配置。
|
|
221
217
|
* @param fileManager 文件管理器实例。
|
|
222
218
|
* @param logger 日志记录器实例。
|
|
223
|
-
* @param
|
|
219
|
+
* @param hashManager 哈希管理器实例,用于增量更新哈希。
|
|
224
220
|
*/
|
|
225
|
-
constructor(ctx, config, fileManager, logger2,
|
|
221
|
+
constructor(ctx, config, fileManager, logger2, hashManager) {
|
|
226
222
|
this.ctx = ctx;
|
|
227
223
|
this.config = config;
|
|
228
224
|
this.fileManager = fileManager;
|
|
229
225
|
this.logger = logger2;
|
|
230
|
-
this.
|
|
226
|
+
this.hashManager = hashManager;
|
|
231
227
|
}
|
|
232
228
|
static {
|
|
233
229
|
__name(this, "DataManager");
|
|
@@ -238,8 +234,9 @@ var DataManager = class {
|
|
|
238
234
|
*/
|
|
239
235
|
registerCommands(cave) {
|
|
240
236
|
const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
|
|
241
|
-
|
|
242
|
-
|
|
237
|
+
if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
|
|
238
|
+
return "此指令仅限在管理群组中使用";
|
|
239
|
+
}
|
|
243
240
|
try {
|
|
244
241
|
await session.send("正在处理,请稍候...");
|
|
245
242
|
return await action();
|
|
@@ -248,8 +245,8 @@ var DataManager = class {
|
|
|
248
245
|
return `操作失败: ${error.message}`;
|
|
249
246
|
}
|
|
250
247
|
}, "requireAdmin");
|
|
251
|
-
cave.subcommand(".export", "导出回声洞数据").
|
|
252
|
-
cave.subcommand(".import", "导入回声洞数据").
|
|
248
|
+
cave.subcommand(".export", "导出回声洞数据").action(requireAdmin(() => this.exportData()));
|
|
249
|
+
cave.subcommand(".import", "导入回声洞数据").action(requireAdmin(() => this.importData()));
|
|
253
250
|
}
|
|
254
251
|
/**
|
|
255
252
|
* @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
|
|
@@ -259,8 +256,7 @@ var DataManager = class {
|
|
|
259
256
|
const fileName = "cave_export.json";
|
|
260
257
|
const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
|
|
261
258
|
const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
|
|
262
|
-
|
|
263
|
-
await this.fileManager.saveFile(fileName, Buffer.from(data));
|
|
259
|
+
await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(portableCaves, null, 2)));
|
|
264
260
|
return `成功导出 ${portableCaves.length} 条数据`;
|
|
265
261
|
}
|
|
266
262
|
/**
|
|
@@ -273,17 +269,13 @@ var DataManager = class {
|
|
|
273
269
|
try {
|
|
274
270
|
const fileContent = await this.fileManager.readFile(fileName);
|
|
275
271
|
importedCaves = JSON.parse(fileContent.toString("utf-8"));
|
|
276
|
-
if (!Array.isArray(importedCaves)
|
|
277
|
-
|
|
272
|
+
if (!Array.isArray(importedCaves) || !importedCaves.length) {
|
|
273
|
+
throw new Error("导入文件格式无效或为空");
|
|
274
|
+
}
|
|
278
275
|
} catch (error) {
|
|
279
|
-
this.logger.error(`读取导入文件失败:`, error);
|
|
280
276
|
throw new Error(`读取导入文件失败: ${error.message}`);
|
|
281
277
|
}
|
|
282
|
-
const [lastCave] = await this.ctx.database.get("cave", {}, {
|
|
283
|
-
sort: { id: "desc" },
|
|
284
|
-
limit: 1,
|
|
285
|
-
fields: ["id"]
|
|
286
|
-
});
|
|
278
|
+
const [lastCave] = await this.ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
|
|
287
279
|
let startId = (lastCave?.id || 0) + 1;
|
|
288
280
|
const newCavesToInsert = importedCaves.map((cave, index) => ({
|
|
289
281
|
...cave,
|
|
@@ -291,8 +283,6 @@ var DataManager = class {
|
|
|
291
283
|
status: "active"
|
|
292
284
|
}));
|
|
293
285
|
await this.ctx.database.upsert("cave", newCavesToInsert);
|
|
294
|
-
this.reusableIds.clear();
|
|
295
|
-
await this.ctx.database.remove("cave_hash", {});
|
|
296
286
|
return `成功导入 ${newCavesToInsert.length} 条数据`;
|
|
297
287
|
}
|
|
298
288
|
};
|
|
@@ -304,7 +294,6 @@ var import_koishi2 = require("koishi");
|
|
|
304
294
|
var import_koishi = require("koishi");
|
|
305
295
|
var path2 = __toESM(require("path"));
|
|
306
296
|
var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
|
|
307
|
-
var MAX_ID_FLAG = 0;
|
|
308
297
|
function storedFormatToHElements(elements) {
|
|
309
298
|
return elements.map((el) => {
|
|
310
299
|
if (el.type === "text") return import_koishi.h.text(el.content);
|
|
@@ -320,8 +309,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
|
|
|
320
309
|
const fileName = element.attrs.src;
|
|
321
310
|
if (!isMedia || !fileName) return element;
|
|
322
311
|
if (config.enableS3 && config.publicUrl) {
|
|
323
|
-
|
|
324
|
-
return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
|
|
312
|
+
return (0, import_koishi.h)(element.type, { ...element.attrs, src: new URL(fileName, config.publicUrl).href });
|
|
325
313
|
}
|
|
326
314
|
if (config.localPath) {
|
|
327
315
|
return (0, import_koishi.h)(element.type, { ...element.attrs, src: `file://${path2.join(config.localPath, fileName)}` });
|
|
@@ -336,8 +324,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
|
|
|
336
324
|
}
|
|
337
325
|
}));
|
|
338
326
|
const replacements = { id: cave.id.toString(), name: cave.userName };
|
|
339
|
-
const
|
|
340
|
-
const [header, footer] = config.caveFormat.split("|", 2).map(formatPart);
|
|
327
|
+
const [header, footer] = config.caveFormat.split("|", 2).map((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim());
|
|
341
328
|
const finalMessage = [];
|
|
342
329
|
if (header) finalMessage.push(header + "\n");
|
|
343
330
|
finalMessage.push(...processedElements);
|
|
@@ -349,51 +336,44 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
|
|
|
349
336
|
try {
|
|
350
337
|
const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
|
|
351
338
|
if (!cavesToDelete.length) return;
|
|
339
|
+
const idsToDelete = cavesToDelete.map((c) => c.id);
|
|
352
340
|
for (const cave of cavesToDelete) {
|
|
353
|
-
|
|
354
|
-
await Promise.all(deletePromises);
|
|
355
|
-
reusableIds.add(cave.id);
|
|
356
|
-
reusableIds.delete(MAX_ID_FLAG);
|
|
357
|
-
await ctx.database.remove("cave", { id: cave.id });
|
|
358
|
-
await ctx.database.remove("cave_hash", { cave: cave.id });
|
|
341
|
+
await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
|
|
359
342
|
}
|
|
343
|
+
reusableIds.delete(0);
|
|
344
|
+
idsToDelete.forEach((id) => reusableIds.add(id));
|
|
345
|
+
await ctx.database.remove("cave", { id: { $in: idsToDelete } });
|
|
346
|
+
await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
|
|
360
347
|
} catch (error) {
|
|
361
348
|
logger2.error("清理回声洞时发生错误:", error);
|
|
362
349
|
}
|
|
363
350
|
}
|
|
364
351
|
__name(cleanupPendingDeletions, "cleanupPendingDeletions");
|
|
365
|
-
function getScopeQuery(session, config) {
|
|
366
|
-
const baseQuery = { status: "active" };
|
|
352
|
+
function getScopeQuery(session, config, includeStatus = true) {
|
|
353
|
+
const baseQuery = includeStatus ? { status: "active" } : {};
|
|
367
354
|
return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
|
|
368
355
|
}
|
|
369
356
|
__name(getScopeQuery, "getScopeQuery");
|
|
370
357
|
async function getNextCaveId(ctx, query = {}, reusableIds) {
|
|
371
358
|
for (const id of reusableIds) {
|
|
372
|
-
if (id >
|
|
359
|
+
if (id > 0) {
|
|
373
360
|
reusableIds.delete(id);
|
|
374
361
|
return id;
|
|
375
362
|
}
|
|
376
363
|
}
|
|
377
|
-
if (reusableIds.has(
|
|
378
|
-
reusableIds.delete(
|
|
379
|
-
const [lastCave] = await ctx.database.get("cave", query, {
|
|
380
|
-
fields: ["id"],
|
|
381
|
-
sort: { id: "desc" },
|
|
382
|
-
limit: 1
|
|
383
|
-
});
|
|
364
|
+
if (reusableIds.has(0)) {
|
|
365
|
+
reusableIds.delete(0);
|
|
366
|
+
const [lastCave] = await ctx.database.get("cave", query, { sort: { id: "desc" }, limit: 1 });
|
|
384
367
|
const newId2 = (lastCave?.id || 0) + 1;
|
|
385
|
-
reusableIds.add(
|
|
368
|
+
reusableIds.add(0);
|
|
386
369
|
return newId2;
|
|
387
370
|
}
|
|
388
371
|
const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
|
|
389
372
|
const existingIds = new Set(allCaveIds);
|
|
390
373
|
let newId = 1;
|
|
391
|
-
while (existingIds.has(newId))
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const maxIdInDb = allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0;
|
|
395
|
-
if (existingIds.size === maxIdInDb) {
|
|
396
|
-
reusableIds.add(MAX_ID_FLAG);
|
|
374
|
+
while (existingIds.has(newId)) newId++;
|
|
375
|
+
if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) {
|
|
376
|
+
reusableIds.add(0);
|
|
397
377
|
}
|
|
398
378
|
return newId;
|
|
399
379
|
}
|
|
@@ -414,35 +394,30 @@ function updateCooldownTimestamp(session, config, lastUsed) {
|
|
|
414
394
|
}
|
|
415
395
|
}
|
|
416
396
|
__name(updateCooldownTimestamp, "updateCooldownTimestamp");
|
|
417
|
-
async function processMessageElements(sourceElements, newId,
|
|
397
|
+
async function processMessageElements(sourceElements, newId, session) {
|
|
418
398
|
const finalElementsForDb = [];
|
|
419
399
|
const mediaToSave = [];
|
|
420
400
|
let mediaIndex = 0;
|
|
421
|
-
const typeMap = {
|
|
422
|
-
"img": "image",
|
|
423
|
-
"image": "image",
|
|
424
|
-
"video": "video",
|
|
425
|
-
"audio": "audio",
|
|
426
|
-
"file": "file",
|
|
427
|
-
"text": "text"
|
|
428
|
-
};
|
|
401
|
+
const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text" };
|
|
429
402
|
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
430
403
|
async function traverse(elements) {
|
|
431
404
|
for (const el of elements) {
|
|
432
|
-
const
|
|
433
|
-
if (
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
405
|
+
const type = typeMap[el.type];
|
|
406
|
+
if (!type) {
|
|
407
|
+
if (el.children) await traverse(el.children);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (type === "text" && el.attrs.content?.trim()) {
|
|
411
|
+
finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
|
|
412
|
+
} else if (type !== "text" && el.attrs.src) {
|
|
413
|
+
let fileIdentifier = el.attrs.src;
|
|
414
|
+
if (fileIdentifier.startsWith("http")) {
|
|
415
|
+
const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
|
|
416
|
+
const fileName = `${newId}_${++mediaIndex}_${session.channelId || "private"}_${session.userId}${ext}`;
|
|
417
|
+
mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
|
|
418
|
+
fileIdentifier = fileName;
|
|
445
419
|
}
|
|
420
|
+
finalElementsForDb.push({ type, file: fileIdentifier });
|
|
446
421
|
}
|
|
447
422
|
if (el.children) await traverse(el.children);
|
|
448
423
|
}
|
|
@@ -456,49 +431,35 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
456
431
|
try {
|
|
457
432
|
const downloadedMedia = [];
|
|
458
433
|
const imageHashesToStore = [];
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
const pHash = await hashManager.generateImagePHash(buffer);
|
|
468
|
-
const subHashes = [...await hashManager.generateImageSubHashes(buffer)];
|
|
469
|
-
allNewImageHashes.push(pHash, ...subHashes);
|
|
470
|
-
imageHashesToStore.push({ hash: pHash, type: "image", subType: "pHash" });
|
|
471
|
-
subHashes.forEach((sh) => imageHashesToStore.push({ hash: sh, type: "image", subType: "subImage" }));
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (allNewImageHashes.length > 0) {
|
|
475
|
-
const existingImageHashes = await ctx.database.get("cave_hash", { type: "image" });
|
|
434
|
+
for (const media of mediaToSave) {
|
|
435
|
+
const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
|
|
436
|
+
downloadedMedia.push({ fileName: media.fileName, buffer });
|
|
437
|
+
if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
|
|
438
|
+
const pHash = await hashManager.generateImagePHash(buffer);
|
|
439
|
+
const subHashes = await hashManager.generateImageSubHashes(buffer);
|
|
440
|
+
const allNewImageHashes = [pHash, ...subHashes];
|
|
441
|
+
const existingImageHashes = await ctx.database.get("cave_hash", { type: /^image_/ });
|
|
476
442
|
for (const newHash of allNewImageHashes) {
|
|
477
443
|
for (const existing of existingImageHashes) {
|
|
478
|
-
const similarity = hashManager.
|
|
444
|
+
const similarity = hashManager.calculateSimilarity(newHash, existing.hash);
|
|
479
445
|
if (similarity >= config.imageThreshold) {
|
|
480
446
|
await session.send(`图片与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`);
|
|
481
447
|
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
482
|
-
|
|
448
|
+
reusableIds.add(cave.id);
|
|
483
449
|
return;
|
|
484
450
|
}
|
|
485
451
|
}
|
|
486
452
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
|
|
491
|
-
downloadedMedia.push({ fileName: media.fileName, buffer: Buffer.from(response) });
|
|
453
|
+
const pHashEntry = { hash: pHash, type: "phash" };
|
|
454
|
+
const subHashEntries = [...subHashes].map((sh) => ({ hash: sh, type: "sub" }));
|
|
455
|
+
imageHashesToStore.push(pHashEntry, ...subHashEntries);
|
|
492
456
|
}
|
|
493
457
|
}
|
|
494
458
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
495
459
|
const finalStatus = config.enableReview ? "pending" : "active";
|
|
496
460
|
await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
|
|
497
461
|
if (hashManager) {
|
|
498
|
-
const allHashesToInsert = [
|
|
499
|
-
...textHashesToStore.map((h4) => ({ ...h4, cave: cave.id })),
|
|
500
|
-
...imageHashesToStore.map((h4) => ({ ...h4, cave: cave.id }))
|
|
501
|
-
];
|
|
462
|
+
const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
|
|
502
463
|
if (allHashesToInsert.length > 0) {
|
|
503
464
|
await ctx.database.upsert("cave_hash", allHashesToInsert);
|
|
504
465
|
}
|
|
@@ -518,7 +479,6 @@ __name(handleFileUploads, "handleFileUploads");
|
|
|
518
479
|
// src/ReviewManager.ts
|
|
519
480
|
var ReviewManager = class {
|
|
520
481
|
/**
|
|
521
|
-
* @constructor
|
|
522
482
|
* @param ctx Koishi 上下文。
|
|
523
483
|
* @param config 插件配置。
|
|
524
484
|
* @param fileManager 文件管理器实例。
|
|
@@ -541,50 +501,50 @@ var ReviewManager = class {
|
|
|
541
501
|
*/
|
|
542
502
|
registerCommands(cave) {
|
|
543
503
|
const requireAdmin = /* @__PURE__ */ __name((session) => {
|
|
544
|
-
|
|
545
|
-
if (session.channelId !== adminChannelId) {
|
|
504
|
+
if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
|
|
546
505
|
return "此指令仅限在管理群组中使用";
|
|
547
506
|
}
|
|
548
507
|
return null;
|
|
549
508
|
}, "requireAdmin");
|
|
550
|
-
const review = cave.subcommand(".review [id:posint]", "审核回声洞").
|
|
509
|
+
const review = cave.subcommand(".review [id:posint]", "审核回声洞").action(async ({ session }, id) => {
|
|
551
510
|
const adminError = requireAdmin(session);
|
|
552
511
|
if (adminError) return adminError;
|
|
553
|
-
if (
|
|
554
|
-
const
|
|
555
|
-
if (!
|
|
556
|
-
return
|
|
557
|
-
|
|
512
|
+
if (id) {
|
|
513
|
+
const [targetCave] = await this.ctx.database.get("cave", { id });
|
|
514
|
+
if (!targetCave) return `回声洞(${id})不存在`;
|
|
515
|
+
if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
|
|
516
|
+
return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
|
|
558
517
|
}
|
|
559
|
-
const
|
|
560
|
-
if (!
|
|
561
|
-
|
|
562
|
-
|
|
518
|
+
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
|
|
519
|
+
if (!pendingCaves.length) return "当前没有需要审核的回声洞";
|
|
520
|
+
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
|
|
521
|
+
${pendingCaves.map((c) => c.id).join("|")}`;
|
|
563
522
|
});
|
|
564
523
|
const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
|
|
565
524
|
const adminError = requireAdmin(session);
|
|
566
525
|
if (adminError) return adminError;
|
|
567
526
|
try {
|
|
527
|
+
const targetStatus = actionType === "approve" ? "active" : "delete";
|
|
528
|
+
const actionText = actionType === "approve" ? "通过" : "拒绝";
|
|
568
529
|
if (!id) {
|
|
569
530
|
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
|
|
570
|
-
if (!pendingCaves.length) return `当前没有需要${
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
} else {
|
|
575
|
-
await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "delete" })));
|
|
576
|
-
cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
|
|
577
|
-
return `已拒绝 ${pendingCaves.length} 条回声洞`;
|
|
578
|
-
}
|
|
531
|
+
if (!pendingCaves.length) return `当前没有需要${actionText}的回声洞`;
|
|
532
|
+
await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: targetStatus })));
|
|
533
|
+
if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
|
|
534
|
+
return `已批量${actionText} ${pendingCaves.length} 条回声洞`;
|
|
579
535
|
}
|
|
580
|
-
|
|
536
|
+
const [cave2] = await this.ctx.database.get("cave", { id, status: "pending" });
|
|
537
|
+
if (!cave2) return `回声洞(${id})无需审核`;
|
|
538
|
+
await this.ctx.database.upsert("cave", [{ id, status: targetStatus }]);
|
|
539
|
+
if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
|
|
540
|
+
return `回声洞(${id})已${actionText}`;
|
|
581
541
|
} catch (error) {
|
|
582
542
|
this.logger.error(`审核操作失败:`, error);
|
|
583
543
|
return `操作失败: ${error.message}`;
|
|
584
544
|
}
|
|
585
545
|
}, "createReviewAction");
|
|
586
|
-
review.subcommand(".Y [id:posint]", "通过审核").
|
|
587
|
-
review.subcommand(".N [id:posint]", "拒绝审核").
|
|
546
|
+
review.subcommand(".Y [id:posint]", "通过审核").action(createReviewAction("approve"));
|
|
547
|
+
review.subcommand(".N [id:posint]", "拒绝审核").action(createReviewAction("reject"));
|
|
588
548
|
}
|
|
589
549
|
/**
|
|
590
550
|
* @description 将新回声洞提交到管理群组以供审核。
|
|
@@ -603,28 +563,11 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
|
|
|
603
563
|
this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
|
|
604
564
|
}
|
|
605
565
|
}
|
|
606
|
-
/**
|
|
607
|
-
* @description 处理管理员的审核决定(通过或拒绝)。
|
|
608
|
-
* @param action 'approve' (通过) 或 'reject' (拒绝)。
|
|
609
|
-
* @param caveId 被审核的回声洞 ID。
|
|
610
|
-
* @returns 返回给操作者的确认消息。
|
|
611
|
-
*/
|
|
612
|
-
async processReview(action, caveId) {
|
|
613
|
-
const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
|
|
614
|
-
if (!cave) return `回声洞(${caveId})无需审核`;
|
|
615
|
-
if (action === "approve") {
|
|
616
|
-
await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
|
|
617
|
-
return `回声洞(${caveId})已通过`;
|
|
618
|
-
} else {
|
|
619
|
-
await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
|
|
620
|
-
cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
|
|
621
|
-
return `回声洞(${caveId})已拒绝`;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
566
|
};
|
|
625
567
|
|
|
626
568
|
// src/HashManager.ts
|
|
627
569
|
var import_sharp = __toESM(require("sharp"));
|
|
570
|
+
var crypto = __toESM(require("crypto"));
|
|
628
571
|
var HashManager = class {
|
|
629
572
|
/**
|
|
630
573
|
* @constructor
|
|
@@ -638,6 +581,13 @@ var HashManager = class {
|
|
|
638
581
|
this.config = config;
|
|
639
582
|
this.logger = logger2;
|
|
640
583
|
this.fileManager = fileManager;
|
|
584
|
+
this.ctx.model.extend("cave_hash", {
|
|
585
|
+
cave: "unsigned",
|
|
586
|
+
hash: "string",
|
|
587
|
+
type: "string"
|
|
588
|
+
}, {
|
|
589
|
+
primary: ["cave", "hash", "type"]
|
|
590
|
+
});
|
|
641
591
|
}
|
|
642
592
|
static {
|
|
643
593
|
__name(this, "HashManager");
|
|
@@ -647,15 +597,14 @@ var HashManager = class {
|
|
|
647
597
|
* @param cave - 主 `cave` 命令实例。
|
|
648
598
|
*/
|
|
649
599
|
registerCommands(cave) {
|
|
650
|
-
cave.subcommand(".hash", "校验回声洞").usage("
|
|
600
|
+
cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希,并检查现有内容的相似度。").action(async ({ session }) => {
|
|
651
601
|
const adminChannelId = this.config.adminChannel?.split(":")[1];
|
|
652
602
|
if (session.channelId !== adminChannelId) {
|
|
653
603
|
return "此指令仅限在管理群组中使用";
|
|
654
604
|
}
|
|
655
605
|
await session.send("正在处理,请稍候...");
|
|
656
606
|
try {
|
|
657
|
-
|
|
658
|
-
return report;
|
|
607
|
+
return await this.validateAllCaves();
|
|
659
608
|
} catch (error) {
|
|
660
609
|
this.logger.error("校验哈希失败:", error);
|
|
661
610
|
return `校验失败: ${error.message}`;
|
|
@@ -663,74 +612,113 @@ var HashManager = class {
|
|
|
663
612
|
});
|
|
664
613
|
}
|
|
665
614
|
/**
|
|
666
|
-
* @description
|
|
667
|
-
* @returns {Promise<string>}
|
|
615
|
+
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
|
|
616
|
+
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
668
617
|
*/
|
|
669
618
|
async validateAllCaves() {
|
|
670
619
|
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
const hashesToInsert = [];
|
|
620
|
+
const existingHashedCaveIds = new Set((await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] })).map((h4) => h4.cave));
|
|
621
|
+
let hashesToInsert = [];
|
|
674
622
|
let historicalCount = 0;
|
|
623
|
+
let totalHashesGenerated = 0;
|
|
624
|
+
let batchStartCaveCount = 0;
|
|
625
|
+
const flushHashes = /* @__PURE__ */ __name(async () => {
|
|
626
|
+
if (hashesToInsert.length > 0) {
|
|
627
|
+
this.logger.info(`补全第 ${batchStartCaveCount + 1} 到 ${historicalCount} 条回声洞哈希中...`);
|
|
628
|
+
await this.ctx.database.upsert("cave_hash", hashesToInsert);
|
|
629
|
+
totalHashesGenerated += hashesToInsert.length;
|
|
630
|
+
hashesToInsert = [];
|
|
631
|
+
batchStartCaveCount = historicalCount;
|
|
632
|
+
}
|
|
633
|
+
}, "flushHashes");
|
|
675
634
|
for (const cave of allCaves) {
|
|
676
635
|
if (existingHashedCaveIds.has(cave.id)) continue;
|
|
677
|
-
this.logger.info(`正在为回声洞(${cave.id})生成哈希...`);
|
|
678
636
|
historicalCount++;
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
hashesToInsert.push({ cave: cave.id, hash: textHash, type: "text", subType: "shingle" });
|
|
637
|
+
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
638
|
+
if (combinedText) {
|
|
639
|
+
hashesToInsert.push({ cave: cave.id, hash: this.generateTextSimhash(combinedText), type: "sim" });
|
|
683
640
|
}
|
|
684
|
-
const
|
|
685
|
-
for (const el of imageElements) {
|
|
641
|
+
for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
686
642
|
try {
|
|
687
643
|
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
688
644
|
const pHash = await this.generateImagePHash(imageBuffer);
|
|
689
|
-
hashesToInsert.push({ cave: cave.id, hash: pHash, type: "
|
|
645
|
+
hashesToInsert.push({ cave: cave.id, hash: pHash, type: "phash" });
|
|
690
646
|
const subHashes = await this.generateImageSubHashes(imageBuffer);
|
|
691
|
-
subHashes.forEach((subHash) => {
|
|
692
|
-
hashesToInsert.push({ cave: cave.id, hash: subHash, type: "image", subType: "subImage" });
|
|
693
|
-
});
|
|
647
|
+
subHashes.forEach((subHash) => hashesToInsert.push({ cave: cave.id, hash: subHash, type: "sub" }));
|
|
694
648
|
} catch (e) {
|
|
695
649
|
this.logger.warn(`无法为回声洞(${cave.id})的内容(${el.file})生成哈希:`, e);
|
|
696
650
|
}
|
|
697
651
|
}
|
|
652
|
+
if (hashesToInsert.length >= 100) await flushHashes();
|
|
698
653
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
654
|
+
await flushHashes();
|
|
655
|
+
const generationReport = totalHashesGenerated > 0 ? `已补全 ${historicalCount} 个回声洞的 ${totalHashesGenerated} 条哈希
|
|
656
|
+
` : "无需补全回声洞的哈希\n";
|
|
657
|
+
const allHashes = await this.ctx.database.get("cave_hash", {});
|
|
658
|
+
const caveTextHashes = /* @__PURE__ */ new Map();
|
|
659
|
+
const caveImagePHashes = /* @__PURE__ */ new Map();
|
|
660
|
+
for (const hash of allHashes) {
|
|
661
|
+
if (hash.type === "sim") {
|
|
662
|
+
caveTextHashes.set(hash.cave, hash.hash);
|
|
663
|
+
} else if (hash.type === "phash") {
|
|
664
|
+
if (!caveImagePHashes.has(hash.cave)) caveImagePHashes.set(hash.cave, []);
|
|
665
|
+
caveImagePHashes.get(hash.cave).push(hash.hash);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const caveIds = allCaves.map((c) => c.id);
|
|
669
|
+
const similarPairs = /* @__PURE__ */ new Set();
|
|
670
|
+
for (let i = 0; i < caveIds.length; i++) {
|
|
671
|
+
for (let j = i + 1; j < caveIds.length; j++) {
|
|
672
|
+
const id1 = caveIds[i];
|
|
673
|
+
const id2 = caveIds[j];
|
|
674
|
+
const textHash1 = caveTextHashes.get(id1);
|
|
675
|
+
const textHash2 = caveTextHashes.get(id2);
|
|
676
|
+
if (textHash1 && textHash2) {
|
|
677
|
+
const textSim = this.calculateSimilarity(textHash1, textHash2);
|
|
678
|
+
if (textSim >= this.config.textThreshold) {
|
|
679
|
+
similarPairs.add(`文本:(${id1},${id2}),相似度:${(textSim * 100).toFixed(2)}%`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const imageHashes1 = caveImagePHashes.get(id1) || [];
|
|
683
|
+
const imageHashes2 = caveImagePHashes.get(id2) || [];
|
|
684
|
+
if (imageHashes1.length > 0 && imageHashes2.length > 0) {
|
|
685
|
+
for (const imgHash1 of imageHashes1) {
|
|
686
|
+
for (const imgHash2 of imageHashes2) {
|
|
687
|
+
const imgSim = this.calculateSimilarity(imgHash1, imgHash2);
|
|
688
|
+
if (imgSim >= this.config.imageThreshold) {
|
|
689
|
+
similarPairs.add(`图片:(${id1},${id2}),相似度:${(imgSim * 100).toFixed(2)}%`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
703
695
|
}
|
|
704
|
-
|
|
696
|
+
const similarityReport = similarPairs.size > 0 ? `发现 ${similarPairs.size} 对高相似度内容:
|
|
697
|
+
` + [...similarPairs].join("\n") : "未发现高相似度内容";
|
|
698
|
+
return `校验完成:
|
|
699
|
+
${generationReport}${similarityReport}`;
|
|
705
700
|
}
|
|
706
701
|
/**
|
|
707
702
|
* @description 将图片切割为4个象限并为每个象限生成pHash。
|
|
708
703
|
* @param imageBuffer - 图片的 Buffer 数据。
|
|
709
|
-
* @returns {Promise<Set<string>>}
|
|
704
|
+
* @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
|
|
710
705
|
*/
|
|
711
706
|
async generateImageSubHashes(imageBuffer) {
|
|
712
707
|
const hashes = /* @__PURE__ */ new Set();
|
|
713
708
|
try {
|
|
714
709
|
const metadata = await (0, import_sharp.default)(imageBuffer).metadata();
|
|
715
710
|
const { width, height } = metadata;
|
|
716
|
-
if (!width || !height || width < 16 || height < 16)
|
|
717
|
-
return hashes;
|
|
718
|
-
}
|
|
711
|
+
if (!width || !height || width < 16 || height < 16) return hashes;
|
|
719
712
|
const regions = [
|
|
720
713
|
{ left: 0, top: 0, width: Math.floor(width / 2), height: Math.floor(height / 2) },
|
|
721
|
-
// Top-left
|
|
722
714
|
{ left: Math.floor(width / 2), top: 0, width: Math.ceil(width / 2), height: Math.floor(height / 2) },
|
|
723
|
-
// Top-right
|
|
724
715
|
{ left: 0, top: Math.floor(height / 2), width: Math.floor(width / 2), height: Math.ceil(height / 2) },
|
|
725
|
-
// Bottom-left
|
|
726
716
|
{ left: Math.floor(width / 2), top: Math.floor(height / 2), width: Math.ceil(width / 2), height: Math.ceil(height / 2) }
|
|
727
|
-
// Bottom-right
|
|
728
717
|
];
|
|
729
718
|
for (const region of regions) {
|
|
730
719
|
if (region.width < 8 || region.height < 8) continue;
|
|
731
720
|
const quadrantBuffer = await (0, import_sharp.default)(imageBuffer).extract(region).toBuffer();
|
|
732
|
-
|
|
733
|
-
hashes.add(subHash);
|
|
721
|
+
hashes.add(await this.generateImagePHash(quadrantBuffer));
|
|
734
722
|
}
|
|
735
723
|
} catch (e) {
|
|
736
724
|
this.logger.warn(`生成子哈希失败:`, e);
|
|
@@ -738,22 +726,15 @@ var HashManager = class {
|
|
|
738
726
|
return hashes;
|
|
739
727
|
}
|
|
740
728
|
/**
|
|
741
|
-
* @description
|
|
742
|
-
* @param imageBuffer
|
|
743
|
-
* @returns
|
|
729
|
+
* @description 根据感知哈希(pHash)算法为图片生成哈希。
|
|
730
|
+
* @param imageBuffer 图片的 Buffer 数据。
|
|
731
|
+
* @returns 64位二进制哈希字符串。
|
|
744
732
|
*/
|
|
745
733
|
async generateImagePHash(imageBuffer) {
|
|
746
734
|
const smallImage = await (0, import_sharp.default)(imageBuffer).grayscale().resize(8, 8, { fit: "fill" }).raw().toBuffer();
|
|
747
|
-
|
|
748
|
-
for (let i = 0; i < 64; i++) {
|
|
749
|
-
totalLuminance += smallImage[i];
|
|
750
|
-
}
|
|
735
|
+
const totalLuminance = smallImage.reduce((acc, val) => acc + val, 0);
|
|
751
736
|
const avgLuminance = totalLuminance / 64;
|
|
752
|
-
|
|
753
|
-
for (let i = 0; i < 64; i++) {
|
|
754
|
-
hash += smallImage[i] > avgLuminance ? "1" : "0";
|
|
755
|
-
}
|
|
756
|
-
return hash;
|
|
737
|
+
return Array.from(smallImage).map((lum) => lum > avgLuminance ? "1" : "0").join("");
|
|
757
738
|
}
|
|
758
739
|
/**
|
|
759
740
|
* @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
|
|
@@ -763,61 +744,40 @@ var HashManager = class {
|
|
|
763
744
|
*/
|
|
764
745
|
calculateHammingDistance(hash1, hash2) {
|
|
765
746
|
let distance = 0;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
747
|
+
const len = Math.min(hash1.length, hash2.length);
|
|
748
|
+
for (let i = 0; i < len; i++) {
|
|
749
|
+
if (hash1[i] !== hash2[i]) distance++;
|
|
770
750
|
}
|
|
771
751
|
return distance;
|
|
772
752
|
}
|
|
773
753
|
/**
|
|
774
|
-
* @description
|
|
754
|
+
* @description 根据汉明距离计算图片或文本哈希的相似度。
|
|
775
755
|
* @param hash1 - 第一个哈希字符串。
|
|
776
756
|
* @param hash2 - 第二个哈希字符串。
|
|
777
757
|
* @returns {number} 范围在0到1之间的相似度得分。
|
|
778
758
|
*/
|
|
779
|
-
|
|
759
|
+
calculateSimilarity(hash1, hash2) {
|
|
780
760
|
const distance = this.calculateHammingDistance(hash1, hash2);
|
|
781
|
-
const hashLength =
|
|
782
|
-
return 1 - distance / hashLength;
|
|
761
|
+
const hashLength = Math.max(hash1.length, hash2.length);
|
|
762
|
+
return hashLength === 0 ? 1 : 1 - distance / hashLength;
|
|
783
763
|
}
|
|
784
764
|
/**
|
|
785
|
-
* @description
|
|
786
|
-
* @param text - 输入的文本。
|
|
787
|
-
* @param size - 每个瓦片的大小,默认为2。
|
|
788
|
-
* @returns {Set<string>} 包含所有唯一瓦片的集合。
|
|
789
|
-
*/
|
|
790
|
-
getShingles(text, size = 2) {
|
|
791
|
-
const shingles = /* @__PURE__ */ new Set();
|
|
792
|
-
const cleanedText = text.replace(/\s+/g, "");
|
|
793
|
-
for (let i = 0; i <= cleanedText.length - size; i++) {
|
|
794
|
-
shingles.add(cleanedText.substring(i, i + size));
|
|
795
|
-
}
|
|
796
|
-
return shingles;
|
|
797
|
-
}
|
|
798
|
-
/**
|
|
799
|
-
* @description 为文本生成基于Shingling的哈希字符串。
|
|
765
|
+
* @description 为文本生成基于 Simhash 算法的哈希字符串。
|
|
800
766
|
* @param text - 需要处理的文本。
|
|
801
|
-
* @returns {string}
|
|
802
|
-
*/
|
|
803
|
-
generateTextHash(text) {
|
|
804
|
-
if (!text) return "";
|
|
805
|
-
const shingles = Array.from(this.getShingles(text));
|
|
806
|
-
return shingles.sort().join("|");
|
|
807
|
-
}
|
|
808
|
-
/**
|
|
809
|
-
* @description 使用Jaccard相似度系数计算两个文本哈希的相似度。
|
|
810
|
-
* @param hash1 - 第一个文本哈希。
|
|
811
|
-
* @param hash2 - 第二个文本哈希。
|
|
812
|
-
* @returns {number} 范围在0到1之间的相似度得分。
|
|
767
|
+
* @returns {string} 64位二进制 Simhash 字符串。
|
|
813
768
|
*/
|
|
814
|
-
|
|
815
|
-
if (!
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
769
|
+
generateTextSimhash(text) {
|
|
770
|
+
if (!text?.trim()) return "";
|
|
771
|
+
const tokens = text.toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/).filter(Boolean);
|
|
772
|
+
if (tokens.length === 0) return "";
|
|
773
|
+
const vector = new Array(64).fill(0);
|
|
774
|
+
tokens.forEach((token) => {
|
|
775
|
+
const hash = crypto.createHash("md5").update(token).digest();
|
|
776
|
+
for (let i = 0; i < 64; i++) {
|
|
777
|
+
vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1;
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
return vector.map((v) => v > 0 ? "1" : "0").join("");
|
|
821
781
|
}
|
|
822
782
|
};
|
|
823
783
|
|
|
@@ -851,7 +811,7 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
851
811
|
enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
|
|
852
812
|
textThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("文本相似度阈值"),
|
|
853
813
|
imageThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("图片相似度阈值")
|
|
854
|
-
}).description("
|
|
814
|
+
}).description("复核配置"),
|
|
855
815
|
import_koishi3.Schema.object({
|
|
856
816
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
857
817
|
enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
|
|
@@ -873,21 +833,13 @@ function apply(ctx, config) {
|
|
|
873
833
|
status: "string",
|
|
874
834
|
time: "timestamp"
|
|
875
835
|
}, { primary: "id" });
|
|
876
|
-
ctx.model.extend("cave_hash", {
|
|
877
|
-
cave: "unsigned",
|
|
878
|
-
hash: "string",
|
|
879
|
-
type: "string",
|
|
880
|
-
subType: "string"
|
|
881
|
-
}, {
|
|
882
|
-
primary: ["cave", "hash", "subType"]
|
|
883
|
-
});
|
|
884
836
|
const fileManager = new FileManager(ctx.baseDir, config, logger);
|
|
885
837
|
const lastUsed = /* @__PURE__ */ new Map();
|
|
886
838
|
const reusableIds = /* @__PURE__ */ new Set();
|
|
887
839
|
const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
|
|
888
|
-
const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
|
|
889
840
|
const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
|
|
890
841
|
const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
|
|
842
|
+
const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, hashManager) : null;
|
|
891
843
|
const cave = ctx.command("cave", "回声洞").option("add", "-a <content:text> 添加回声洞").option("view", "-g <id:posint> 查看指定回声洞").option("delete", "-r <id:posint> 删除指定回声洞").option("list", "-l 查询投稿统计").usage("随机抽取一条已添加的回声洞。").action(async ({ session, options }) => {
|
|
892
844
|
if (options.add) return session.execute(`cave.add ${options.add}`);
|
|
893
845
|
if (options.view) return session.execute(`cave.view ${options.view}`);
|
|
@@ -910,7 +862,7 @@ function apply(ctx, config) {
|
|
|
910
862
|
return "随机获取回声洞失败";
|
|
911
863
|
}
|
|
912
864
|
});
|
|
913
|
-
cave.subcommand(".add [content:text]", "添加回声洞").usage("
|
|
865
|
+
cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
|
|
914
866
|
try {
|
|
915
867
|
let sourceElements = session.quote?.elements;
|
|
916
868
|
if (!sourceElements && content?.trim()) {
|
|
@@ -922,39 +874,28 @@ function apply(ctx, config) {
|
|
|
922
874
|
if (!reply) return "等待操作超时";
|
|
923
875
|
sourceElements = import_koishi3.h.parse(reply);
|
|
924
876
|
}
|
|
925
|
-
const
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
newId,
|
|
930
|
-
session.channelId,
|
|
931
|
-
session.userId
|
|
932
|
-
);
|
|
933
|
-
if (finalElementsForDb.length === 0) {
|
|
934
|
-
return "无可添加内容";
|
|
935
|
-
}
|
|
936
|
-
let textHashesToStore = [];
|
|
877
|
+
const newId = await getNextCaveId(ctx, getScopeQuery(session, config, false), reusableIds);
|
|
878
|
+
const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session);
|
|
879
|
+
if (finalElementsForDb.length === 0) return "无可添加内容";
|
|
880
|
+
const textHashesToStore = [];
|
|
937
881
|
if (hashManager) {
|
|
938
|
-
const
|
|
939
|
-
if (
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
const existingTextHashes = await ctx.database.get("cave_hash", { type: "text", hash: { $in: newTextHashes } });
|
|
882
|
+
const combinedText = finalElementsForDb.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
883
|
+
if (combinedText) {
|
|
884
|
+
const newSimhash = hashManager.generateTextSimhash(combinedText);
|
|
885
|
+
const existingTextHashes = await ctx.database.get("cave_hash", { type: "sim" });
|
|
943
886
|
for (const existing of existingTextHashes) {
|
|
944
|
-
const
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
if (similarity >= config.textThreshold) {
|
|
948
|
-
return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
|
|
949
|
-
}
|
|
887
|
+
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
888
|
+
if (similarity >= config.textThreshold) {
|
|
889
|
+
return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
|
|
950
890
|
}
|
|
951
891
|
}
|
|
892
|
+
textHashesToStore.push({ hash: newSimhash, type: "sim" });
|
|
952
893
|
}
|
|
953
894
|
}
|
|
954
895
|
const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
955
896
|
const hasMedia = mediaToSave.length > 0;
|
|
956
897
|
const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
|
|
957
|
-
const newCave = {
|
|
898
|
+
const newCave = await ctx.database.create("cave", {
|
|
958
899
|
id: newId,
|
|
959
900
|
elements: finalElementsForDb,
|
|
960
901
|
channelId: session.channelId,
|
|
@@ -962,33 +903,29 @@ function apply(ctx, config) {
|
|
|
962
903
|
userName,
|
|
963
904
|
status: initialStatus,
|
|
964
905
|
time: /* @__PURE__ */ new Date()
|
|
965
|
-
};
|
|
966
|
-
await ctx.database.create("cave", newCave);
|
|
906
|
+
});
|
|
967
907
|
if (hasMedia) {
|
|
968
908
|
handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
|
|
969
909
|
} else {
|
|
970
910
|
if (hashManager && textHashesToStore.length > 0) {
|
|
971
|
-
|
|
972
|
-
await ctx.database.upsert("cave_hash", hashObjectsToInsert);
|
|
911
|
+
await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
|
|
973
912
|
}
|
|
974
913
|
if (initialStatus === "pending") {
|
|
975
914
|
reviewManager.sendForReview(newCave);
|
|
976
915
|
}
|
|
977
916
|
}
|
|
978
|
-
|
|
979
|
-
return responseMessage;
|
|
917
|
+
return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
|
|
980
918
|
} catch (error) {
|
|
981
919
|
logger.error("添加回声洞失败:", error);
|
|
982
920
|
return "添加失败,请稍后再试";
|
|
983
921
|
}
|
|
984
922
|
});
|
|
985
|
-
cave.subcommand(".view <id:posint>", "查看指定回声洞").
|
|
923
|
+
cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
|
|
986
924
|
if (!id) return "请输入要查看的回声洞序号";
|
|
987
925
|
const cdMessage = checkCooldown(session, config, lastUsed);
|
|
988
926
|
if (cdMessage) return cdMessage;
|
|
989
927
|
try {
|
|
990
|
-
const
|
|
991
|
-
const [targetCave] = await ctx.database.get("cave", query);
|
|
928
|
+
const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
|
|
992
929
|
if (!targetCave) return `回声洞(${id})不存在`;
|
|
993
930
|
updateCooldownTimestamp(session, config, lastUsed);
|
|
994
931
|
return buildCaveMessage(targetCave, config, fileManager, logger);
|
|
@@ -997,17 +934,14 @@ function apply(ctx, config) {
|
|
|
997
934
|
return "查看失败,请稍后再试";
|
|
998
935
|
}
|
|
999
936
|
});
|
|
1000
|
-
cave.subcommand(".del <id:posint>", "删除指定回声洞").
|
|
937
|
+
cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => {
|
|
1001
938
|
if (!id) return "请输入要删除的回声洞序号";
|
|
1002
939
|
try {
|
|
1003
940
|
const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
|
|
1004
941
|
if (!targetCave) return `回声洞(${id})不存在`;
|
|
1005
|
-
const adminChannelId = config.adminChannel?.split(":")[1];
|
|
1006
942
|
const isAuthor = targetCave.userId === session.userId;
|
|
1007
|
-
const isAdmin = session.channelId ===
|
|
1008
|
-
if (!isAuthor && !isAdmin)
|
|
1009
|
-
return "你没有权限删除这条回声洞";
|
|
1010
|
-
}
|
|
943
|
+
const isAdmin = session.channelId === config.adminChannel?.split(":")[1];
|
|
944
|
+
if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
|
|
1011
945
|
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
1012
946
|
const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
|
|
1013
947
|
cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
|
|
@@ -1017,10 +951,9 @@ function apply(ctx, config) {
|
|
|
1017
951
|
return "删除失败,请稍后再试";
|
|
1018
952
|
}
|
|
1019
953
|
});
|
|
1020
|
-
cave.subcommand(".list", "查询我的投稿").
|
|
954
|
+
cave.subcommand(".list", "查询我的投稿").action(async ({ session }) => {
|
|
1021
955
|
try {
|
|
1022
|
-
const
|
|
1023
|
-
const userCaves = await ctx.database.get("cave", query, { fields: ["id"] });
|
|
956
|
+
const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
|
|
1024
957
|
if (!userCaves.length) return "你还没有投稿过回声洞";
|
|
1025
958
|
const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
|
|
1026
959
|
return `你已投稿 ${userCaves.length} 条回声洞,序号为:
|