koishi-plugin-best-cave 2.5.4 → 2.6.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.
@@ -7,7 +7,7 @@ import { FileManager } from './FileManager';
7
7
  export interface CaveHashObject {
8
8
  cave: number;
9
9
  hash: string;
10
- type: 'simhash' | 'phash_g' | 'phash_q1' | 'phash_q2' | 'phash_q3' | 'phash_q4';
10
+ type: 'simhash' | 'phash';
11
11
  }
12
12
  /**
13
13
  * @class HashManager
@@ -52,20 +52,6 @@ export declare class HashManager {
52
52
  textThreshold?: number;
53
53
  imageThreshold?: number;
54
54
  }): Promise<string>;
55
- /**
56
- * @description 为单个图片Buffer生成全局pHash和四个象限的局部pHash。
57
- * @param imageBuffer - 图片的Buffer数据。
58
- * @returns 包含全局哈希和四象限哈希的对象。
59
- */
60
- generateAllImageHashes(imageBuffer: Buffer): Promise<{
61
- globalHash: string;
62
- quadrantHashes: {
63
- q1: string;
64
- q2: string;
65
- q3: string;
66
- q4: string;
67
- };
68
- }>;
69
55
  /**
70
56
  * @description 执行二维离散余弦变换 (DCT-II)。
71
57
  * @param matrix - 输入的 N x N 像素亮度矩阵。
@@ -78,7 +64,7 @@ export declare class HashManager {
78
64
  * @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。
79
65
  * @returns 十六进制pHash字符串。
80
66
  */
81
- private _generatePHash;
67
+ generatePHash(imageBuffer: Buffer, size: number): Promise<string>;
82
68
  /**
83
69
  * @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
84
70
  * @param hex1 - 第一个哈希。
package/lib/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface ForwardNode {
15
15
  * @description 存储在数据库中的单个消息元素。
16
16
  */
17
17
  export interface StoredElement {
18
- type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'at' | 'forward' | 'reply';
18
+ type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'at' | 'forward' | 'reply' | 'face';
19
19
  content?: string | ForwardNode[];
20
20
  file?: string;
21
21
  }
package/lib/index.js CHANGED
@@ -239,8 +239,8 @@ var DataManager = class {
239
239
  return `操作失败: ${error.message}`;
240
240
  }
241
241
  }, "requireAdmin");
242
- cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json 中。").action(requireAdmin(() => this.exportData()));
243
- cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
242
+ cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave_export.json 中。").action(requireAdmin(() => this.exportData()));
243
+ cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
244
244
  }
245
245
  /**
246
246
  * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
@@ -292,6 +292,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2, platform, pr
292
292
  if (el.type === "text") return import_koishi.h.text(el.content);
293
293
  if (el.type === "at") return (0, import_koishi.h)("at", { id: el.content });
294
294
  if (el.type === "reply") return (0, import_koishi.h)("reply", { id: el.content });
295
+ if (el.type === "face") return (0, import_koishi.h)("face", { id: el.content });
295
296
  if (el.type === "forward") {
296
297
  try {
297
298
  const forwardNodes = Array.isArray(el.content) ? el.content : [];
@@ -345,24 +346,24 @@ async function buildCaveMessage(cave, config, fileManager, logger2, platform, pr
345
346
  time: cave.time.toLocaleString()
346
347
  };
347
348
  const placeholderRegex = /\{([^}]+)\}/g;
348
- const replacer = /* @__PURE__ */ __name((match, content) => {
349
- const parts = content.split(":");
350
- if (parts.length === 1) return data[content] ?? match;
351
- if (parts.length === 4) {
352
- const [maxStarsStr, key, prefixStr, suffixStr] = parts;
353
- const originalValue = data[key];
354
- if (!originalValue) return "";
355
- const maxStars = parseInt(maxStarsStr, 10);
356
- const prefixLength = parseInt(prefixStr, 10);
357
- const suffixLength = parseInt(suffixStr, 10);
358
- if (isNaN(maxStars) || isNaN(prefixLength) || isNaN(suffixLength)) return match;
359
- if (prefixLength + suffixLength >= originalValue.length) return originalValue;
360
- const prefix2 = originalValue.substring(0, prefixLength);
361
- const suffix = originalValue.substring(originalValue.length - suffixLength);
362
- const maskedLength = Math.min(maxStars, originalValue.length - prefixLength - suffixLength);
363
- return `${prefix2}${"*".repeat(maskedLength)}${suffix}`;
364
- }
365
- return match;
349
+ const replacer = /* @__PURE__ */ __name((match, rawContent) => {
350
+ const isReviewMode = !!prefix;
351
+ const [normalPart, reviewPart] = rawContent.split("/", 2);
352
+ const contentToProcess = isReviewMode ? reviewPart !== void 0 ? reviewPart : normalPart : normalPart;
353
+ if (!contentToProcess?.trim()) return "";
354
+ const useMask = contentToProcess.startsWith("*");
355
+ const key = (useMask ? contentToProcess.substring(1) : contentToProcess).trim();
356
+ if (!key) return "";
357
+ const originalValue = data[key];
358
+ if (originalValue === void 0 || originalValue === null) return match;
359
+ const valueStr = String(originalValue);
360
+ if (!useMask) return valueStr;
361
+ const len = valueStr.length;
362
+ if (len <= 5) return valueStr;
363
+ let keep = 0;
364
+ if (len <= 7) keep = 2;
365
+ else keep = 3;
366
+ return `${valueStr.substring(0, keep)}***${valueStr.substring(len - keep)}`;
366
367
  }, "replacer");
367
368
  const [rawHeader, rawFooter] = config.caveFormat.split("|", 2);
368
369
  let header = rawHeader ? rawHeader.replace(placeholderRegex, replacer).trim() : "";
@@ -441,7 +442,7 @@ __name(getNextCaveId, "getNextCaveId");
441
442
  async function processMessageElements(sourceElements, newId, session, config, logger2) {
442
443
  const mediaToSave = [];
443
444
  let mediaIndex = 0;
444
- const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply" };
445
+ const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" };
445
446
  const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
446
447
  async function transform(elements) {
447
448
  const result = [];
@@ -513,6 +514,8 @@ async function processMessageElements(sourceElements, newId, session, config, lo
513
514
  fileIdentifier = fileName;
514
515
  }
515
516
  result.push({ type, file: fileIdentifier });
517
+ } else if (type === "face" && el.attrs.id) {
518
+ result.push({ type: "face", content: el.attrs.id });
516
519
  }
517
520
  }
518
521
  return result;
@@ -526,15 +529,14 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
526
529
  try {
527
530
  const downloadedMedia = [];
528
531
  const imageHashesToStore = [];
529
- const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: { $ne: "simhash" } }) : [];
530
- const existingGlobalHashes = allExistingImageHashes.filter((h4) => h4.type === "phash_g");
532
+ const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: "phash" }) : [];
531
533
  for (const media of mediaToToSave) {
532
534
  const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
533
535
  downloadedMedia.push({ fileName: media.fileName, buffer });
534
536
  if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
535
- const { globalHash, quadrantHashes } = await hashManager.generateAllImageHashes(buffer);
536
- for (const existing of existingGlobalHashes) {
537
- const similarity = hashManager.calculateSimilarity(globalHash, existing.hash);
537
+ const imageHash = await hashManager.generatePHash(buffer, 256);
538
+ for (const existing of allExistingImageHashes) {
539
+ const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
538
540
  if (similarity >= config.imageThreshold) {
539
541
  await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
540
542
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
@@ -542,11 +544,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
542
544
  return;
543
545
  }
544
546
  }
545
- imageHashesToStore.push({ hash: globalHash, type: "phash_g" });
546
- imageHashesToStore.push({ hash: quadrantHashes.q1, type: "phash_q1" });
547
- imageHashesToStore.push({ hash: quadrantHashes.q2, type: "phash_q2" });
548
- imageHashesToStore.push({ hash: quadrantHashes.q3, type: "phash_q3" });
549
- imageHashesToStore.push({ hash: quadrantHashes.q4, type: "phash_q4" });
547
+ imageHashesToStore.push({ hash: imageHash, type: "phash" });
550
548
  }
551
549
  }
552
550
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
@@ -597,7 +595,7 @@ var PendManager = class {
597
595
  if (session.channelId !== this.config.adminChannel?.split(":")[1]) return "此指令仅限在管理群组中使用";
598
596
  return null;
599
597
  }, "requireAdmin");
600
- const pend = cave.subcommand(".pend [id:posint]", "审核回声洞").usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => {
598
+ const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => {
601
599
  const adminError = requireAdmin(session);
602
600
  if (adminError) return adminError;
603
601
  if (id) {
@@ -699,7 +697,7 @@ var HashManager = class {
699
697
  const adminChannelId = this.config.adminChannel?.split(":")[1];
700
698
  if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
701
699
  }, "adminCheck");
702
- cave.subcommand(".hash", "校验回声洞").usage("校验缺失哈希的回声洞,补全哈希记录。").action(async (argv) => {
700
+ cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async (argv) => {
703
701
  const checkResult = adminCheck(argv);
704
702
  if (checkResult) return checkResult;
705
703
  await argv.session.send("正在处理,请稍候...");
@@ -710,7 +708,7 @@ var HashManager = class {
710
708
  return `操作失败: ${error.message}`;
711
709
  }
712
710
  });
713
- cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => {
711
+ cave.subcommand(".check", "检查相似度", { hidden: true }).usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => {
714
712
  const checkResult = adminCheck(argv);
715
713
  if (checkResult) return checkResult;
716
714
  await argv.session.send("正在检查,请稍候...");
@@ -786,12 +784,8 @@ var HashManager = class {
786
784
  for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
787
785
  try {
788
786
  const imageBuffer = await this.fileManager.readFile(el.file);
789
- const { globalHash, quadrantHashes } = await this.generateAllImageHashes(imageBuffer);
790
- addUniqueHash({ cave: cave.id, hash: globalHash, type: "phash_g" });
791
- addUniqueHash({ cave: cave.id, hash: quadrantHashes.q1, type: "phash_q1" });
792
- addUniqueHash({ cave: cave.id, hash: quadrantHashes.q2, type: "phash_q2" });
793
- addUniqueHash({ cave: cave.id, hash: quadrantHashes.q3, type: "phash_q3" });
794
- addUniqueHash({ cave: cave.id, hash: quadrantHashes.q4, type: "phash_q4" });
787
+ const imageHash = await this.generatePHash(imageBuffer, 256);
788
+ addUniqueHash({ cave: cave.id, hash: imageHash, type: "phash" });
795
789
  } catch (e) {
796
790
  this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e);
797
791
  }
@@ -809,24 +803,17 @@ var HashManager = class {
809
803
  const allHashes = await this.ctx.database.get("cave_hash", {});
810
804
  const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
811
805
  const textHashes = /* @__PURE__ */ new Map();
812
- const globalHashes = /* @__PURE__ */ new Map();
813
- const quadrantHashesByCave = /* @__PURE__ */ new Map();
814
- const partialHashToCaves = /* @__PURE__ */ new Map();
806
+ const imageHashes = /* @__PURE__ */ new Map();
815
807
  for (const hash of allHashes) {
816
808
  if (hash.type === "simhash") {
817
809
  textHashes.set(hash.cave, hash.hash);
818
- } else if (hash.type === "phash_g") {
819
- globalHashes.set(hash.cave, hash.hash);
820
- } else if (hash.type.startsWith("phash_q")) {
821
- if (!quadrantHashesByCave.has(hash.cave)) quadrantHashesByCave.set(hash.cave, /* @__PURE__ */ new Set());
822
- quadrantHashesByCave.get(hash.cave).add(hash.hash);
823
- if (!partialHashToCaves.has(hash.hash)) partialHashToCaves.set(hash.hash, /* @__PURE__ */ new Set());
824
- partialHashToCaves.get(hash.hash).add(hash.cave);
810
+ } else if (hash.type === "phash") {
811
+ imageHashes.set(hash.cave, hash.hash);
825
812
  }
826
813
  }
827
814
  const similarPairs = {
828
815
  text: /* @__PURE__ */ new Set(),
829
- global: /* @__PURE__ */ new Set()
816
+ image: /* @__PURE__ */ new Set()
830
817
  };
831
818
  for (let i = 0; i < allCaveIds.length; i++) {
832
819
  for (let j = i + 1; j < allCaveIds.length; j++) {
@@ -839,71 +826,21 @@ var HashManager = class {
839
826
  const similarity = this.calculateSimilarity(text1, text2);
840
827
  if (similarity >= textThreshold) similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
841
828
  }
842
- const global1 = globalHashes.get(id1);
843
- const global2 = globalHashes.get(id2);
844
- if (global1 && global2) {
845
- const similarity = this.calculateSimilarity(global1, global2);
846
- if (similarity >= imageThreshold) similarPairs.global.add(`${pair} = ${similarity.toFixed(2)}%`);
829
+ const image1 = imageHashes.get(id1);
830
+ const image2 = imageHashes.get(id2);
831
+ if (image1 && image2) {
832
+ const similarity = this.calculateSimilarity(image1, image2);
833
+ if (similarity >= imageThreshold) similarPairs.image.add(`${pair} = ${similarity.toFixed(2)}%`);
847
834
  }
848
835
  }
849
836
  }
850
- const allPartialCaveIds = Array.from(quadrantHashesByCave.keys());
851
- const parent = /* @__PURE__ */ new Map();
852
- const find = /* @__PURE__ */ __name((i) => {
853
- if (parent.get(i) === i) return i;
854
- parent.set(i, find(parent.get(i)));
855
- return parent.get(i);
856
- }, "find");
857
- const union = /* @__PURE__ */ __name((i, j) => {
858
- const rootI = find(i);
859
- const rootJ = find(j);
860
- if (rootI !== rootJ) parent.set(rootI, rootJ);
861
- }, "union");
862
- allPartialCaveIds.forEach((id) => parent.set(id, id));
863
- for (const caveIds of partialHashToCaves.values()) {
864
- if (caveIds.size <= 1) continue;
865
- const ids = Array.from(caveIds);
866
- for (let i = 1; i < ids.length; i++) union(ids[0], ids[i]);
867
- }
868
- const components = /* @__PURE__ */ new Map();
869
- for (const id of allPartialCaveIds) {
870
- const root = find(id);
871
- if (!components.has(root)) components.set(root, /* @__PURE__ */ new Set());
872
- components.get(root).add(id);
873
- }
874
- const partialGroups = [];
875
- for (const component of components.values()) if (component.size > 1) partialGroups.push(Array.from(component).sort((a, b) => a - b).join(" & "));
876
- const totalFindings = similarPairs.text.size + similarPairs.global.size + partialGroups.length;
837
+ const totalFindings = similarPairs.text.size + similarPairs.image.size;
877
838
  if (totalFindings === 0) return "未发现高相似度的内容";
878
839
  let report = `已发现 ${totalFindings} 组高相似度的内容:`;
879
840
  if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
880
- if (similarPairs.global.size > 0) report += "\n图片整体相似:\n" + [...similarPairs.global].join("\n");
881
- if (partialGroups.length > 0) report += "\n图片局部相同:\n" + partialGroups.join("\n");
841
+ if (similarPairs.image.size > 0) report += "\n图片内容相似:\n" + [...similarPairs.image].join("\n");
882
842
  return report.trim();
883
843
  }
884
- /**
885
- * @description 为单个图片Buffer生成全局pHash和四个象限的局部pHash。
886
- * @param imageBuffer - 图片的Buffer数据。
887
- * @returns 包含全局哈希和四象限哈希的对象。
888
- */
889
- async generateAllImageHashes(imageBuffer) {
890
- const globalHash = await this._generatePHash(imageBuffer, 256);
891
- const { width, height } = await (0, import_sharp.default)(imageBuffer).metadata();
892
- const w2 = Math.floor(width / 2), h22 = Math.floor(height / 2);
893
- const regions = [
894
- { left: 0, top: 0, width: w2, height: h22 },
895
- { left: w2, top: 0, width: width - w2, height: h22 },
896
- { left: 0, top: h22, width: w2, height: height - h22 },
897
- { left: w2, top: h22, width: width - w2, height: height - h22 }
898
- ];
899
- const [q1, q2, q3, q4] = await Promise.all(
900
- regions.map((region) => {
901
- if (region.width < 16 || region.height < 16) return this._generatePHash(imageBuffer, 64);
902
- return (0, import_sharp.default)(imageBuffer).extract(region).toBuffer().then((b) => this._generatePHash(b, 64));
903
- })
904
- );
905
- return { globalHash, quadrantHashes: { q1, q2, q3, q4 } };
906
- }
907
844
  /**
908
845
  * @description 执行二维离散余弦变换 (DCT-II)。
909
846
  * @param matrix - 输入的 N x N 像素亮度矩阵。
@@ -938,7 +875,7 @@ var HashManager = class {
938
875
  * @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。
939
876
  * @returns 十六进制pHash字符串。
940
877
  */
941
- async _generatePHash(imageBuffer, size) {
878
+ async generatePHash(imageBuffer, size) {
942
879
  const dctSize = 32;
943
880
  const hashGridSize = Math.sqrt(size);
944
881
  if (!Number.isInteger(hashGridSize)) throw new Error("哈希位数必须是完全平方数");
@@ -1032,7 +969,7 @@ var Config = import_koishi3.Schema.intersect([
1032
969
  enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
1033
970
  enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
1034
971
  adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
1035
- caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本,用“|”分隔(占位符配置参见 README)")
972
+ caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)")
1036
973
  }).description("基础配置"),
1037
974
  import_koishi3.Schema.object({
1038
975
  enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.5.4",
4
+ "version": "2.6.0",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -62,7 +62,7 @@
62
62
  | `enableName` | `boolean` | `false` | 是否启用自定义昵称功能 (`cave.name` 指令)。 |
63
63
  | `enableIO` | `boolean` | `false` | 是否启用数据导入/导出功能 (`cave.export` / `.import` 指令)。 |
64
64
  | `adminChannel` | `string` | `'onebot:'` | **管理群组ID**。格式为 `平台名:群号`,如 `onebot:12345678`。管理指令仅在此群组生效。若配置无效,审核将自动通过。 |
65
- | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {name}'` | 回声洞消息的显示格式。`\|`作为分隔符,前面是页眉,后面是页脚。可用占位符:`{id}`, `{name}`, `{time}`, `{user}` `{channel}` 。支持打码,语法为 `{最大星号数:占位符:保留前缀:保留后缀}`,例如 `{3:user:2:2}`。 |
65
+ | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {*name}'` | 回声洞消息的显示格式。`\|`为页眉页脚分隔符。支持强大的占位符语法:• **基本**: `{id}`, `{name}`, `{time}`, `{user}`, `{channel}`• **自动打码**: `{*user}` (在占位符前加\*)• **审核可见**: `{/channel}` (在占位符前加/)• **组合**: `{*user/user}` (常规打码/审核时完整) |
66
66
 
67
67
  ### 复核配置 (审核与查重)
68
68