koishi-plugin-best-cave 2.2.1 → 2.2.3
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 +7 -2
- package/lib/Utils.d.ts +85 -0
- package/lib/index.js +101 -46
- package/package.json +1 -1
package/lib/HashManager.d.ts
CHANGED
|
@@ -24,10 +24,15 @@ export declare class HashManager {
|
|
|
24
24
|
*/
|
|
25
25
|
registerCommands(cave: any): void;
|
|
26
26
|
/**
|
|
27
|
-
* @description
|
|
27
|
+
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
|
|
28
28
|
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
generateHashesForHistoricalCaves(): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* @description 对回声洞进行混合策略的相似度与重复内容检查。
|
|
33
|
+
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
34
|
+
*/
|
|
35
|
+
checkForSimilarCaves(): Promise<string>;
|
|
31
36
|
/**
|
|
32
37
|
* @description 将图片切割为4个象限并为每个象限生成pHash。
|
|
33
38
|
* @param imageBuffer - 图片的 Buffer 数据。
|
package/lib/Utils.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Context, h, Logger, Session } from 'koishi';
|
|
2
|
+
import { CaveObject, Config, StoredElement, CaveHashObject } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
import { HashManager } from './HashManager';
|
|
5
|
+
import { ReviewManager } from './ReviewManager';
|
|
6
|
+
/**
|
|
7
|
+
* @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
|
|
8
|
+
* @param elements 从数据库读取的元素数组。
|
|
9
|
+
* @returns 转换后的 h() 元素数组。
|
|
10
|
+
*/
|
|
11
|
+
export declare function storedFormatToHElements(elements: StoredElement[]): h[];
|
|
12
|
+
/**
|
|
13
|
+
* @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
|
|
14
|
+
* @param cave 回声洞对象。
|
|
15
|
+
* @param config 插件配置。
|
|
16
|
+
* @param fileManager 文件管理器实例。
|
|
17
|
+
* @param logger 日志记录器实例。
|
|
18
|
+
* @returns 包含 h() 元素和字符串的消息数组。
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
|
|
21
|
+
/**
|
|
22
|
+
* @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
|
|
23
|
+
* @param ctx Koishi 上下文。
|
|
24
|
+
* @param fileManager 文件管理器实例。
|
|
25
|
+
* @param logger 日志记录器实例。
|
|
26
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
27
|
+
*/
|
|
28
|
+
export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* @description 根据配置和会话,生成数据库查询的范围条件。
|
|
31
|
+
* @param session 当前会话。
|
|
32
|
+
* @param config 插件配置。
|
|
33
|
+
* @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
|
|
34
|
+
* @returns 数据库查询条件对象。
|
|
35
|
+
*/
|
|
36
|
+
export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
|
|
37
|
+
/**
|
|
38
|
+
* @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
|
|
39
|
+
* @param ctx Koishi 上下文。
|
|
40
|
+
* @param query 查询范围条件。
|
|
41
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
42
|
+
* @returns 可用的新 ID。
|
|
43
|
+
*/
|
|
44
|
+
export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
|
|
45
|
+
/**
|
|
46
|
+
* @description 检查用户是否处于指令冷却中。
|
|
47
|
+
* @returns 若在冷却中则提示字符串,否则 null。
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* @description 更新指定频道的指令使用时间戳。
|
|
52
|
+
*/
|
|
53
|
+
export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
|
|
54
|
+
/**
|
|
55
|
+
* @description 解析消息元素,分离出文本和待下载的媒体文件。
|
|
56
|
+
* @param sourceElements 原始的 Koishi 消息元素数组。
|
|
57
|
+
* @param newId 这条回声洞的新 ID。
|
|
58
|
+
* @param session 触发操作的会话。
|
|
59
|
+
* @returns 包含数据库元素和待保存媒体列表的对象。
|
|
60
|
+
*/
|
|
61
|
+
export declare function processMessageElements(sourceElements: h[], newId: number, session: Session): Promise<{
|
|
62
|
+
finalElementsForDb: StoredElement[];
|
|
63
|
+
mediaToSave: {
|
|
64
|
+
sourceUrl: string;
|
|
65
|
+
fileName: string;
|
|
66
|
+
}[];
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* @description 异步处理文件上传、查重和状态更新的后台任务。
|
|
70
|
+
* @param ctx - Koishi 上下文。
|
|
71
|
+
* @param config - 插件配置。
|
|
72
|
+
* @param fileManager - FileManager 实例,用于保存文件。
|
|
73
|
+
* @param logger - 日志记录器实例。
|
|
74
|
+
* @param reviewManager - ReviewManager 实例,用于提交审核。
|
|
75
|
+
* @param cave - 刚刚在数据库中创建的 `preload` 状态的回声洞对象。
|
|
76
|
+
* @param mediaToSave - 需要下载和处理的媒体文件列表。
|
|
77
|
+
* @param reusableIds - 可复用 ID 的内存缓存。
|
|
78
|
+
* @param session - 触发此操作的用户会话,用于发送反馈。
|
|
79
|
+
* @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
|
|
80
|
+
* @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
|
|
81
|
+
*/
|
|
82
|
+
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: ReviewManager, cave: CaveObject, mediaToToSave: {
|
|
83
|
+
sourceUrl: string;
|
|
84
|
+
fileName: string;
|
|
85
|
+
}[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
|
package/lib/index.js
CHANGED
|
@@ -427,32 +427,39 @@ async function processMessageElements(sourceElements, newId, session) {
|
|
|
427
427
|
return { finalElementsForDb, mediaToSave };
|
|
428
428
|
}
|
|
429
429
|
__name(processMessageElements, "processMessageElements");
|
|
430
|
-
async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave,
|
|
430
|
+
async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToToSave, reusableIds, session, hashManager, textHashesToStore) {
|
|
431
431
|
try {
|
|
432
432
|
const downloadedMedia = [];
|
|
433
433
|
const imageHashesToStore = [];
|
|
434
|
-
|
|
434
|
+
const existingPHashes = hashManager ? await ctx.database.get("cave_hash", { type: "phash" }) : [];
|
|
435
|
+
const existingSubHashes = hashManager ? await ctx.database.get("cave_hash", { type: "sub" }) : [];
|
|
436
|
+
for (const media of mediaToToSave) {
|
|
435
437
|
const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
|
|
436
438
|
downloadedMedia.push({ fileName: media.fileName, buffer });
|
|
437
439
|
if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
|
|
438
440
|
const pHash = await hashManager.generateImagePHash(buffer);
|
|
441
|
+
for (const existing of existingPHashes) {
|
|
442
|
+
const similarity = hashManager.calculateSimilarity(pHash, existing.hash);
|
|
443
|
+
if (similarity >= config.imageThreshold) {
|
|
444
|
+
await session.send(`图片与回声洞(${existing.cave})的相似度为 ${(similarity * 100).toFixed(2)}%,超过阈值`);
|
|
445
|
+
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
446
|
+
reusableIds.add(cave.id);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const pHashEntry = { hash: pHash, type: "phash" };
|
|
451
|
+
imageHashesToStore.push(pHashEntry);
|
|
439
452
|
const subHashes = await hashManager.generateImageSubHashes(buffer);
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
for (const existing of existingImageHashes) {
|
|
444
|
-
const similarity = hashManager.calculateSimilarity(newHash, existing.hash);
|
|
453
|
+
for (const newSubHash of subHashes) {
|
|
454
|
+
for (const existing of existingSubHashes) {
|
|
455
|
+
const similarity = hashManager.calculateSimilarity(newSubHash, existing.hash);
|
|
445
456
|
if (similarity >= config.imageThreshold) {
|
|
446
|
-
await session.send(
|
|
447
|
-
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
448
|
-
reusableIds.add(cave.id);
|
|
449
|
-
return;
|
|
457
|
+
await session.send(`图片局部与回声洞(${existing.cave})的相似度为 ${(similarity * 100).toFixed(2)}%`);
|
|
450
458
|
}
|
|
451
459
|
}
|
|
452
460
|
}
|
|
453
|
-
const pHashEntry = { hash: pHash, type: "phash" };
|
|
454
461
|
const subHashEntries = [...subHashes].map((sh) => ({ hash: sh, type: "sub" }));
|
|
455
|
-
imageHashesToStore.push(
|
|
462
|
+
imageHashesToStore.push(...subHashEntries);
|
|
456
463
|
}
|
|
457
464
|
}
|
|
458
465
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
@@ -597,25 +604,40 @@ var HashManager = class {
|
|
|
597
604
|
* @param cave - 主 `cave` 命令实例。
|
|
598
605
|
*/
|
|
599
606
|
registerCommands(cave) {
|
|
600
|
-
|
|
607
|
+
const adminCheck = /* @__PURE__ */ __name(({ session }) => {
|
|
601
608
|
const adminChannelId = this.config.adminChannel?.split(":")[1];
|
|
602
609
|
if (session.channelId !== adminChannelId) {
|
|
603
610
|
return "此指令仅限在管理群组中使用";
|
|
604
611
|
}
|
|
605
|
-
|
|
612
|
+
}, "adminCheck");
|
|
613
|
+
cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,补全所有哈希记录。").action(async (argv) => {
|
|
614
|
+
const checkResult = adminCheck(argv);
|
|
615
|
+
if (checkResult) return checkResult;
|
|
616
|
+
await argv.session.send("正在处理,请稍候...");
|
|
617
|
+
try {
|
|
618
|
+
return await this.generateHashesForHistoricalCaves();
|
|
619
|
+
} catch (error) {
|
|
620
|
+
this.logger.error("生成历史哈希失败:", error);
|
|
621
|
+
return `操作失败: ${error.message}`;
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
cave.subcommand(".check", "检查回声洞").usage("检查所有已存在哈希的回声洞的相似度。").action(async (argv) => {
|
|
625
|
+
const checkResult = adminCheck(argv);
|
|
626
|
+
if (checkResult) return checkResult;
|
|
627
|
+
await argv.session.send("正在检查,请稍候...");
|
|
606
628
|
try {
|
|
607
|
-
return await this.
|
|
629
|
+
return await this.checkForSimilarCaves();
|
|
608
630
|
} catch (error) {
|
|
609
|
-
this.logger.error("
|
|
610
|
-
return
|
|
631
|
+
this.logger.error("检查相似度失败:", error);
|
|
632
|
+
return `检查失败: ${error.message}`;
|
|
611
633
|
}
|
|
612
634
|
});
|
|
613
635
|
}
|
|
614
636
|
/**
|
|
615
|
-
* @description
|
|
637
|
+
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
|
|
616
638
|
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
617
639
|
*/
|
|
618
|
-
async
|
|
640
|
+
async generateHashesForHistoricalCaves() {
|
|
619
641
|
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
620
642
|
const existingHashedCaveIds = new Set((await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] })).map((h4) => h4.cave));
|
|
621
643
|
let hashesToInsert = [];
|
|
@@ -664,51 +686,84 @@ var HashManager = class {
|
|
|
664
686
|
if (hashesToInsert.length >= 100) await flushHashes();
|
|
665
687
|
}
|
|
666
688
|
await flushHashes();
|
|
667
|
-
|
|
668
|
-
|
|
689
|
+
return totalHashesGenerated > 0 ? `已补全 ${historicalCount} 个回声洞的 ${totalHashesGenerated} 条哈希` : "无需补全回声洞哈希";
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* @description 对回声洞进行混合策略的相似度与重复内容检查。
|
|
693
|
+
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
694
|
+
*/
|
|
695
|
+
async checkForSimilarCaves() {
|
|
669
696
|
const allHashes = await this.ctx.database.get("cave_hash", {});
|
|
670
697
|
const caveTextHashes = /* @__PURE__ */ new Map();
|
|
671
698
|
const caveImagePHashes = /* @__PURE__ */ new Map();
|
|
699
|
+
const subHashToCaves = /* @__PURE__ */ new Map();
|
|
672
700
|
for (const hash of allHashes) {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
701
|
+
switch (hash.type) {
|
|
702
|
+
case "sim":
|
|
703
|
+
caveTextHashes.set(hash.cave, hash.hash);
|
|
704
|
+
break;
|
|
705
|
+
case "phash":
|
|
706
|
+
if (!caveImagePHashes.has(hash.cave)) caveImagePHashes.set(hash.cave, []);
|
|
707
|
+
caveImagePHashes.get(hash.cave).push(hash.hash);
|
|
708
|
+
break;
|
|
709
|
+
case "sub":
|
|
710
|
+
if (!subHashToCaves.has(hash.hash)) subHashToCaves.set(hash.hash, /* @__PURE__ */ new Set());
|
|
711
|
+
subHashToCaves.get(hash.hash).add(hash.cave);
|
|
712
|
+
break;
|
|
678
713
|
}
|
|
679
714
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
715
|
+
const subHashDuplicates = [];
|
|
716
|
+
subHashToCaves.forEach((caves, hash) => {
|
|
717
|
+
if (caves.size > 1) {
|
|
718
|
+
const sortedCaves = [...caves].sort((a, b) => a - b).join(", ");
|
|
719
|
+
subHashDuplicates.push(`[${sortedCaves}]`);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
const textSimilarPairs = [];
|
|
723
|
+
const imageSimilarPairs = [];
|
|
724
|
+
const allCaveIds = Array.from(/* @__PURE__ */ new Set([...caveTextHashes.keys(), ...caveImagePHashes.keys()]));
|
|
725
|
+
for (let i = 0; i < allCaveIds.length; i++) {
|
|
726
|
+
for (let j = i + 1; j < allCaveIds.length; j++) {
|
|
727
|
+
const id1 = allCaveIds[i];
|
|
728
|
+
const id2 = allCaveIds[j];
|
|
686
729
|
const textHash1 = caveTextHashes.get(id1);
|
|
687
730
|
const textHash2 = caveTextHashes.get(id2);
|
|
688
731
|
if (textHash1 && textHash2) {
|
|
689
732
|
const textSim = this.calculateSimilarity(textHash1, textHash2);
|
|
690
733
|
if (textSim >= this.config.textThreshold) {
|
|
691
|
-
|
|
734
|
+
textSimilarPairs.push(`${id1} & ${id2} = ${(textSim * 100).toFixed(2)}%`);
|
|
692
735
|
}
|
|
693
736
|
}
|
|
694
|
-
const
|
|
695
|
-
const
|
|
696
|
-
if (
|
|
697
|
-
for (const imgHash1 of
|
|
698
|
-
for (const imgHash2 of
|
|
737
|
+
const pHashes1 = caveImagePHashes.get(id1) || [];
|
|
738
|
+
const pHashes2 = caveImagePHashes.get(id2) || [];
|
|
739
|
+
if (pHashes1.length > 0 && pHashes2.length > 0) {
|
|
740
|
+
for (const imgHash1 of pHashes1) {
|
|
741
|
+
for (const imgHash2 of pHashes2) {
|
|
699
742
|
const imgSim = this.calculateSimilarity(imgHash1, imgHash2);
|
|
700
743
|
if (imgSim >= this.config.imageThreshold) {
|
|
701
|
-
|
|
744
|
+
imageSimilarPairs.push(`${id1} & ${id2} = ${(imgSim * 100).toFixed(2)}%`);
|
|
702
745
|
}
|
|
703
746
|
}
|
|
704
747
|
}
|
|
705
748
|
}
|
|
706
749
|
}
|
|
707
750
|
}
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
751
|
+
const totalFindings = textSimilarPairs.length + imageSimilarPairs.length + subHashDuplicates.length;
|
|
752
|
+
if (totalFindings === 0) {
|
|
753
|
+
return "未发现高相似度的内容";
|
|
754
|
+
}
|
|
755
|
+
let report = `已发现 ${totalFindings} 组高相似度的内容:
|
|
756
|
+
`;
|
|
757
|
+
if (textSimilarPairs.length > 0) {
|
|
758
|
+
report += "文本相似度过高:\n" + [...new Set(textSimilarPairs)].join("\n");
|
|
759
|
+
}
|
|
760
|
+
if (imageSimilarPairs.length > 0) {
|
|
761
|
+
report += "图片相似度过高:\n" + [...new Set(imageSimilarPairs)].join("\n");
|
|
762
|
+
}
|
|
763
|
+
if (subHashDuplicates.length > 0) {
|
|
764
|
+
report += "子图完全重复:\n" + subHashDuplicates.join("\n");
|
|
765
|
+
}
|
|
766
|
+
return report.trim();
|
|
712
767
|
}
|
|
713
768
|
/**
|
|
714
769
|
* @description 将图片切割为4个象限并为每个象限生成pHash。
|
|
@@ -898,7 +953,7 @@ function apply(ctx, config) {
|
|
|
898
953
|
for (const existing of existingTextHashes) {
|
|
899
954
|
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
900
955
|
if (similarity >= config.textThreshold) {
|
|
901
|
-
return
|
|
956
|
+
return `文本与回声洞(${existing.cave})的相似度为 ${(similarity * 100).toFixed(2)}%,超过阈值`;
|
|
902
957
|
}
|
|
903
958
|
}
|
|
904
959
|
textHashesToStore.push({ hash: newSimhash, type: "sim" });
|
|
@@ -967,7 +1022,7 @@ function apply(ctx, config) {
|
|
|
967
1022
|
try {
|
|
968
1023
|
const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
|
|
969
1024
|
if (!userCaves.length) return "你还没有投稿过回声洞";
|
|
970
|
-
const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("
|
|
1025
|
+
const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
|
|
971
1026
|
return `你已投稿 ${userCaves.length} 条回声洞,序号为:
|
|
972
1027
|
${caveIds}`;
|
|
973
1028
|
} catch (error) {
|