koishi-plugin-best-cave 2.7.11 → 2.7.12
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 +6 -0
- package/lib/Utils.d.ts +2 -2
- package/lib/index.js +86 -68
- package/package.json +1 -1
package/lib/HashManager.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export declare class HashManager {
|
|
|
32
32
|
* @param cave - 主 `cave` 命令实例。
|
|
33
33
|
*/
|
|
34
34
|
registerCommands(cave: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
|
|
37
|
+
* @param imageBuffer - 原始的图片 Buffer。
|
|
38
|
+
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
|
|
39
|
+
*/
|
|
40
|
+
sanitizeImageBuffer(imageBuffer: Buffer): Buffer;
|
|
35
41
|
/**
|
|
36
42
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
37
43
|
* @param input - 输入的数字数组。
|
package/lib/Utils.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
|
|
|
57
57
|
* @description 执行文本 (Simhash) 和图片 (pHash) 相似度查重。
|
|
58
58
|
* @returns 一个对象,指示是否发现重复项;如果未发现,则返回生成的哈希。
|
|
59
59
|
*/
|
|
60
|
-
export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, finalElementsForDb: StoredElement[], downloadedMedia: {
|
|
60
|
+
export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, logger: Logger, finalElementsForDb: StoredElement[], downloadedMedia: {
|
|
61
61
|
fileName: string;
|
|
62
62
|
buffer: Buffer;
|
|
63
63
|
}[]): Promise<{
|
|
@@ -81,7 +81,7 @@ export declare function performSimilarityChecks(ctx: Context, config: Config, ha
|
|
|
81
81
|
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, cave: CaveObject, downloadedMedia: {
|
|
82
82
|
fileName: string;
|
|
83
83
|
buffer: Buffer;
|
|
84
|
-
}[], reusableIds: Set<number>,
|
|
84
|
+
}[], reusableIds: Set<number>, needsReview: boolean): Promise<'pending' | 'active'>;
|
|
85
85
|
/**
|
|
86
86
|
* @description 校验会话是否来自指定的管理群组。
|
|
87
87
|
* @param session 当前会话。
|
package/lib/index.js
CHANGED
|
@@ -450,42 +450,46 @@ async function processMessageElements(sourceElements, newId, session, creationTi
|
|
|
450
450
|
return { finalElementsForDb, mediaToSave };
|
|
451
451
|
}
|
|
452
452
|
__name(processMessageElements, "processMessageElements");
|
|
453
|
-
async function performSimilarityChecks(ctx, config, hashManager, finalElementsForDb, downloadedMedia) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
453
|
+
async function performSimilarityChecks(ctx, config, hashManager, logger2, finalElementsForDb, downloadedMedia) {
|
|
454
|
+
try {
|
|
455
|
+
const textHashesToStore = [];
|
|
456
|
+
const imageHashesToStore = [];
|
|
457
|
+
const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
|
|
458
|
+
if (combinedText) {
|
|
459
|
+
const newSimhash = hashManager.generateTextSimhash(combinedText);
|
|
460
|
+
if (newSimhash) {
|
|
461
|
+
const existingTextHashes = await ctx.database.get("cave_hash", { type: "text" });
|
|
462
|
+
for (const existing of existingTextHashes) {
|
|
463
|
+
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
464
|
+
if (similarity >= config.textThreshold) return { duplicate: true, message: `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
|
|
465
|
+
}
|
|
466
|
+
textHashesToStore.push({ hash: newSimhash, type: "text" });
|
|
464
467
|
}
|
|
465
|
-
textHashesToStore.push({ hash: newSimhash, type: "text" });
|
|
466
468
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
469
|
+
if (downloadedMedia.length > 0) {
|
|
470
|
+
const allExistingImageHashes = await ctx.database.get("cave_hash", { type: "image" });
|
|
471
|
+
for (const media of downloadedMedia) {
|
|
472
|
+
if ([".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
|
|
473
|
+
const imageHash = await hashManager.generatePHash(media.buffer);
|
|
474
|
+
for (const existing of allExistingImageHashes) {
|
|
475
|
+
const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
|
|
476
|
+
if (similarity >= config.imageThreshold) return { duplicate: true, message: `图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值` };
|
|
477
|
+
}
|
|
478
|
+
imageHashesToStore.push({ hash: imageHash, type: "image" });
|
|
479
|
+
allExistingImageHashes.push({ cave: 0, hash: imageHash, type: "image" });
|
|
476
480
|
}
|
|
477
|
-
imageHashesToStore.push({ hash: imageHash, type: "image" });
|
|
478
|
-
allExistingImageHashes.push({ cave: 0, hash: imageHash, type: "image" });
|
|
479
481
|
}
|
|
480
482
|
}
|
|
483
|
+
return { duplicate: false, textHashesToStore, imageHashesToStore };
|
|
484
|
+
} catch (error) {
|
|
485
|
+
logger2.warn("相似度比较失败:", error);
|
|
486
|
+
return { duplicate: false, textHashesToStore: [], imageHashesToStore: [] };
|
|
481
487
|
}
|
|
482
|
-
return { duplicate: false, textHashesToStore, imageHashesToStore };
|
|
483
488
|
}
|
|
484
489
|
__name(performSimilarityChecks, "performSimilarityChecks");
|
|
485
|
-
async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds,
|
|
490
|
+
async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds, needsReview) {
|
|
486
491
|
try {
|
|
487
492
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
488
|
-
const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
|
|
489
493
|
const finalStatus = needsReview ? "pending" : "active";
|
|
490
494
|
await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
|
|
491
495
|
return finalStatus;
|
|
@@ -845,37 +849,12 @@ var HashManager = class {
|
|
|
845
849
|
if (!cavesToProcess.length) return "无可修复的回声洞";
|
|
846
850
|
let fixedFiles = 0;
|
|
847
851
|
let errorCount = 0;
|
|
848
|
-
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
849
|
-
const JPEG_SIGNATURE = Buffer.from([255, 216]);
|
|
850
|
-
const GIF_SIGNATURE = Buffer.from("GIF");
|
|
851
852
|
for (const cave2 of cavesToProcess) {
|
|
852
853
|
const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
|
|
853
854
|
for (const element of imageElements) {
|
|
854
855
|
try {
|
|
855
856
|
const originalBuffer = await this.fileManager.readFile(element.file);
|
|
856
|
-
|
|
857
|
-
if (originalBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
|
|
858
|
-
const IEND_CHUNK = Buffer.from("IEND");
|
|
859
|
-
const iendIndex = originalBuffer.lastIndexOf(IEND_CHUNK);
|
|
860
|
-
if (iendIndex !== -1) {
|
|
861
|
-
const endOfPngData = iendIndex + 8;
|
|
862
|
-
if (originalBuffer.length > endOfPngData) sanitizedBuffer = originalBuffer.slice(0, endOfPngData);
|
|
863
|
-
}
|
|
864
|
-
} else if (originalBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
|
|
865
|
-
const EOI_MARKER = Buffer.from([255, 217]);
|
|
866
|
-
const eoiIndex = originalBuffer.lastIndexOf(EOI_MARKER);
|
|
867
|
-
if (eoiIndex !== -1) {
|
|
868
|
-
const endOfJpegData = eoiIndex + 2;
|
|
869
|
-
if (originalBuffer.length > endOfJpegData) sanitizedBuffer = originalBuffer.slice(0, endOfJpegData);
|
|
870
|
-
}
|
|
871
|
-
} else if (originalBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
|
|
872
|
-
const GIF_TERMINATOR = Buffer.from([59]);
|
|
873
|
-
const terminatorIndex = originalBuffer.lastIndexOf(GIF_TERMINATOR);
|
|
874
|
-
if (terminatorIndex !== -1) {
|
|
875
|
-
const endOfGifData = terminatorIndex + 1;
|
|
876
|
-
if (originalBuffer.length > endOfGifData) sanitizedBuffer = originalBuffer.slice(0, endOfGifData);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
857
|
+
const sanitizedBuffer = this.sanitizeImageBuffer(originalBuffer);
|
|
879
858
|
if (!originalBuffer.equals(sanitizedBuffer)) {
|
|
880
859
|
await this.fileManager.saveFile(element.file, sanitizedBuffer);
|
|
881
860
|
fixedFiles++;
|
|
@@ -895,6 +874,40 @@ var HashManager = class {
|
|
|
895
874
|
}
|
|
896
875
|
});
|
|
897
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
|
|
879
|
+
* @param imageBuffer - 原始的图片 Buffer。
|
|
880
|
+
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
|
|
881
|
+
*/
|
|
882
|
+
sanitizeImageBuffer(imageBuffer) {
|
|
883
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
884
|
+
const JPEG_SIGNATURE = Buffer.from([255, 216]);
|
|
885
|
+
const GIF_SIGNATURE = Buffer.from("GIF");
|
|
886
|
+
let sanitizedBuffer = imageBuffer;
|
|
887
|
+
if (imageBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
|
|
888
|
+
const IEND_CHUNK = Buffer.from("IEND");
|
|
889
|
+
const iendIndex = imageBuffer.lastIndexOf(IEND_CHUNK);
|
|
890
|
+
if (iendIndex !== -1) {
|
|
891
|
+
const endOfPngData = iendIndex + 8;
|
|
892
|
+
if (imageBuffer.length > endOfPngData) sanitizedBuffer = imageBuffer.slice(0, endOfPngData);
|
|
893
|
+
}
|
|
894
|
+
} else if (imageBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
|
|
895
|
+
const EOI_MARKER = Buffer.from([255, 217]);
|
|
896
|
+
const eoiIndex = imageBuffer.lastIndexOf(EOI_MARKER);
|
|
897
|
+
if (eoiIndex !== -1) {
|
|
898
|
+
const endOfJpegData = eoiIndex + 2;
|
|
899
|
+
if (imageBuffer.length > endOfJpegData) sanitizedBuffer = imageBuffer.slice(0, endOfJpegData);
|
|
900
|
+
}
|
|
901
|
+
} else if (imageBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
|
|
902
|
+
const GIF_TERMINATOR = Buffer.from([59]);
|
|
903
|
+
const terminatorIndex = imageBuffer.lastIndexOf(GIF_TERMINATOR);
|
|
904
|
+
if (terminatorIndex !== -1) {
|
|
905
|
+
const endOfGifData = terminatorIndex + 1;
|
|
906
|
+
if (imageBuffer.length > endOfGifData) sanitizedBuffer = imageBuffer.slice(0, endOfGifData);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return sanitizedBuffer;
|
|
910
|
+
}
|
|
898
911
|
/**
|
|
899
912
|
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
900
913
|
* @param input - 输入的数字数组。
|
|
@@ -1094,15 +1107,19 @@ var AIManager = class {
|
|
|
1094
1107
|
* @returns {Promise<void>} 分析和存储操作完成后解析的 Promise。
|
|
1095
1108
|
*/
|
|
1096
1109
|
async analyzeAndStore(cave, mediaBuffers) {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
const mediaMap = mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0;
|
|
1112
|
+
const [result] = await this.getAnalyses([cave], mediaMap);
|
|
1113
|
+
if (result) {
|
|
1114
|
+
await this.ctx.database.upsert("cave_meta", [{
|
|
1115
|
+
cave: cave.id,
|
|
1116
|
+
keywords: result.keywords || [],
|
|
1117
|
+
description: result.description || "",
|
|
1118
|
+
rating: Math.max(0, Math.min(100, result.rating || 0))
|
|
1119
|
+
}]);
|
|
1120
|
+
}
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
this.logger.error(`分析回声洞(${cave.id})出错:`, error);
|
|
1106
1123
|
}
|
|
1107
1124
|
}
|
|
1108
1125
|
/**
|
|
@@ -1256,7 +1273,7 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1256
1273
|
enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
|
|
1257
1274
|
aiEndpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").default("https://generativelanguage.googleapis.com/v1beta/openai"),
|
|
1258
1275
|
aiApiKey: import_koishi3.Schema.string().description("密钥 (Key)").role("secret"),
|
|
1259
|
-
aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-
|
|
1276
|
+
aiModel: import_koishi3.Schema.string().description("模型 (Model)").default("gemini-1.5-flash"),
|
|
1260
1277
|
aiRPM: import_koishi3.Schema.number().description("每分钟请求数 (RPM)").default(60),
|
|
1261
1278
|
AnalysePrompt: import_koishi3.Schema.string().role("textarea").default(`你是一位内容分析专家。请分析我以JSON格式提供的一组内容(每项包含ID、文本和图片),为每一项内容总结关键词、概括内容并评分。你需要返回一个包含所有分析结果的JSON对象。`).description("分析 Prompt"),
|
|
1262
1279
|
aiAnalyseSchema: import_koishi3.Schema.string().role("textarea").default(
|
|
@@ -1403,7 +1420,8 @@ function apply(ctx, config) {
|
|
|
1403
1420
|
let textHashesToStore = [];
|
|
1404
1421
|
let imageHashesToStore = [];
|
|
1405
1422
|
if (hashManager) {
|
|
1406
|
-
const
|
|
1423
|
+
for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
|
|
1424
|
+
const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
|
|
1407
1425
|
if (checkResult.duplicate) return checkResult.message;
|
|
1408
1426
|
textHashesToStore = checkResult.textHashesToStore;
|
|
1409
1427
|
imageHashesToStore = checkResult.imageHashesToStore;
|
|
@@ -1413,7 +1431,7 @@ function apply(ctx, config) {
|
|
|
1413
1431
|
if (duplicateResult && duplicateResult.duplicate) return `内容与回声洞(${duplicateResult.id})重复`;
|
|
1414
1432
|
}
|
|
1415
1433
|
const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
1416
|
-
const needsReview = config.enablePend && session.
|
|
1434
|
+
const needsReview = config.enablePend && session.cid !== config.adminChannel;
|
|
1417
1435
|
let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
|
|
1418
1436
|
const newCave = await ctx.database.create("cave", {
|
|
1419
1437
|
id: newId,
|
|
@@ -1424,7 +1442,7 @@ function apply(ctx, config) {
|
|
|
1424
1442
|
status: finalStatus,
|
|
1425
1443
|
time: creationTime
|
|
1426
1444
|
});
|
|
1427
|
-
if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds,
|
|
1445
|
+
if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
|
|
1428
1446
|
if (finalStatus !== "preload") {
|
|
1429
1447
|
newCave.status = finalStatus;
|
|
1430
1448
|
if (aiManager) await aiManager.analyzeAndStore(newCave, downloadedMedia);
|
|
@@ -1458,7 +1476,7 @@ function apply(ctx, config) {
|
|
|
1458
1476
|
const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
|
|
1459
1477
|
if (!targetCave) return `回声洞(${id})不存在`;
|
|
1460
1478
|
const isAuthor = targetCave.userId === session.userId;
|
|
1461
|
-
const isAdmin = session.
|
|
1479
|
+
const isAdmin = session.cid === config.adminChannel;
|
|
1462
1480
|
if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
|
|
1463
1481
|
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
1464
1482
|
const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
|
|
@@ -1471,8 +1489,8 @@ function apply(ctx, config) {
|
|
|
1471
1489
|
});
|
|
1472
1490
|
cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
|
|
1473
1491
|
if (options.all) {
|
|
1474
|
-
const
|
|
1475
|
-
if (
|
|
1492
|
+
const adminError = requireAdmin(session, config);
|
|
1493
|
+
if (adminError) return adminError;
|
|
1476
1494
|
try {
|
|
1477
1495
|
const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.count(row.id), "count") }).execute();
|
|
1478
1496
|
if (!aggregatedStats.length) return "目前没有回声洞投稿";
|