koishi-plugin-best-cave 2.2.9 → 2.3.1

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.
@@ -45,9 +45,13 @@ export declare class HashManager {
45
45
  generateAllHashesForCave(cave: Pick<CaveObject, 'id' | 'elements'>): Promise<CaveHashObject[]>;
46
46
  /**
47
47
  * @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
48
+ * @param options 包含临时阈值的可选对象。
48
49
  * @returns 一个包含检查结果的报告字符串。
49
50
  */
50
- checkForSimilarCaves(): Promise<string>;
51
+ checkForSimilarCaves(options?: {
52
+ textThreshold?: number;
53
+ imageThreshold?: number;
54
+ }): Promise<string>;
51
55
  /**
52
56
  * @description 为单个图片Buffer生成全局pHash和四个象限的局部pHash。
53
57
  * @param imageBuffer - 图片的Buffer数据。
@@ -0,0 +1,45 @@
1
+ import { Context } from 'koishi';
2
+ /** 数据库 `cave_user` 表的结构。 */
3
+ export interface UserName {
4
+ userId: string;
5
+ nickname: string;
6
+ }
7
+ declare module 'koishi' {
8
+ interface Tables {
9
+ cave_user: UserName;
10
+ }
11
+ }
12
+ /**
13
+ * @class NameManager
14
+ * @description 负责管理用户在回声洞中的自定义昵称。
15
+ */
16
+ export declare class NameManager {
17
+ private ctx;
18
+ /**
19
+ * @constructor
20
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
21
+ */
22
+ constructor(ctx: Context);
23
+ /**
24
+ * @description 注册 `.name` 子命令,用于管理用户昵称。
25
+ * @param cave - 主 `cave` 命令实例。
26
+ */
27
+ registerCommands(cave: any): void;
28
+ /**
29
+ * @description 设置或更新指定用户的昵称。
30
+ * @param userId - 目标用户的 ID。
31
+ * @param nickname - 要设置的新昵称。
32
+ */
33
+ setNickname(userId: string, nickname: string): Promise<void>;
34
+ /**
35
+ * @description 获取指定用户的昵称。
36
+ * @param userId - 目标用户的 ID。
37
+ * @returns 用户的昵称字符串或 null。
38
+ */
39
+ getNickname(userId: string): Promise<string | null>;
40
+ /**
41
+ * @description 清除指定用户的昵称设置。
42
+ * @param userId - 目标用户的 ID。
43
+ */
44
+ clearNickname(userId: string): Promise<void>;
45
+ }
@@ -0,0 +1,32 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { CaveObject, Config } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @class PendManager
6
+ * @description 负责处理回声洞的审核流程,处理新洞的提交、审核通知和审核操作。
7
+ */
8
+ export declare class PendManager {
9
+ private ctx;
10
+ private config;
11
+ private fileManager;
12
+ private logger;
13
+ private reusableIds;
14
+ /**
15
+ * @param ctx Koishi 上下文。
16
+ * @param config 插件配置。
17
+ * @param fileManager 文件管理器实例。
18
+ * @param logger 日志记录器实例。
19
+ * @param reusableIds 可复用 ID 的内存缓存。
20
+ */
21
+ constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>);
22
+ /**
23
+ * @description 注册与审核相关的子命令。
24
+ * @param cave - 主 `cave` 命令实例。
25
+ */
26
+ registerCommands(cave: any): void;
27
+ /**
28
+ * @description 将新回声洞提交到管理群组以供审核。
29
+ * @param cave 新创建的、状态为 'pending' 的回声洞对象。
30
+ */
31
+ sendForPend(cave: CaveObject): Promise<void>;
32
+ }
package/lib/Utils.d.ts CHANGED
@@ -2,7 +2,7 @@ import { Context, h, Logger, Session } from 'koishi';
2
2
  import { CaveObject, Config, StoredElement } from './index';
3
3
  import { FileManager } from './FileManager';
4
4
  import { HashManager, CaveHashObject } from './HashManager';
5
- import { ReviewManager } from './ReviewManager';
5
+ import { PendManager } from './PendManager';
6
6
  /**
7
7
  * @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
8
8
  * @param elements 从数据库读取的元素数组。
@@ -65,7 +65,7 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
65
65
  fileName: string;
66
66
  }[];
67
67
  }>;
68
- export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: ReviewManager, cave: CaveObject, mediaToToSave: {
68
+ export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: PendManager, cave: CaveObject, mediaToToSave: {
69
69
  sourceUrl: string;
70
70
  fileName: string;
71
71
  }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
package/lib/index.d.ts CHANGED
@@ -33,13 +33,13 @@ export interface Config {
33
33
  coolDown: number;
34
34
  perChannel: boolean;
35
35
  adminChannel: string;
36
- enableProfile: boolean;
36
+ enableName: boolean;
37
37
  enableIO: boolean;
38
- enableReview: boolean;
38
+ enablePend: boolean;
39
39
  caveFormat: string;
40
40
  enableSimilarity: boolean;
41
41
  textThreshold: number;
42
- imageWholeThreshold: number;
42
+ imageThreshold: number;
43
43
  localPath?: string;
44
44
  enableS3: boolean;
45
45
  endpoint?: string;
package/lib/index.js CHANGED
@@ -149,8 +149,8 @@ var FileManager = class {
149
149
  }
150
150
  };
151
151
 
152
- // src/ProfileManager.ts
153
- var ProfileManager = class {
152
+ // src/NameManager.ts
153
+ var NameManager = class {
154
154
  /**
155
155
  * @constructor
156
156
  * @param ctx - Koishi 上下文,用于初始化数据库模型。
@@ -165,14 +165,14 @@ var ProfileManager = class {
165
165
  });
166
166
  }
167
167
  static {
168
- __name(this, "ProfileManager");
168
+ __name(this, "NameManager");
169
169
  }
170
170
  /**
171
- * @description 注册 `.profile` 子命令,用于管理用户昵称。
171
+ * @description 注册 `.name` 子命令,用于管理用户昵称。
172
172
  * @param cave - 主 `cave` 命令实例。
173
173
  */
174
174
  registerCommands(cave) {
175
- cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
175
+ cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
176
176
  const trimmedNickname = nickname?.trim();
177
177
  if (trimmedNickname) {
178
178
  await this.setNickname(session.userId, trimmedNickname);
@@ -196,8 +196,8 @@ var ProfileManager = class {
196
196
  * @returns 用户的昵称字符串或 null。
197
197
  */
198
198
  async getNickname(userId) {
199
- const [profile] = await this.ctx.database.get("cave_user", { userId });
200
- return profile?.nickname ?? null;
199
+ const [name2] = await this.ctx.database.get("cave_user", { userId });
200
+ return name2?.nickname ?? null;
201
201
  }
202
202
  /**
203
203
  * @description 清除指定用户的昵称设置。
@@ -287,7 +287,7 @@ var DataManager = class {
287
287
  }
288
288
  };
289
289
 
290
- // src/ReviewManager.ts
290
+ // src/PendManager.ts
291
291
  var import_koishi2 = require("koishi");
292
292
 
293
293
  // src/Utils.ts
@@ -379,6 +379,10 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
379
379
  }
380
380
  __name(getNextCaveId, "getNextCaveId");
381
381
  function checkCooldown(session, config, lastUsed) {
382
+ const adminChannelId = config.adminChannel?.split(":")[1];
383
+ if (adminChannelId && session.channelId === adminChannelId) {
384
+ return null;
385
+ }
382
386
  if (config.coolDown <= 0 || !session.channelId) return null;
383
387
  const lastTime = lastUsed.get(session.channelId) || 0;
384
388
  const remainingTime = lastTime + config.coolDown * 1e3 - Date.now();
@@ -441,8 +445,8 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
441
445
  const { globalHash, quadrantHashes } = await hashManager.generateAllImageHashes(buffer);
442
446
  for (const existing of existingGlobalHashes) {
443
447
  const similarity = hashManager.calculateSimilarity(globalHash, existing.hash);
444
- if (similarity >= config.imageWholeThreshold) {
445
- await session.send(`图片与回声洞(${existing.cave})的相似度为 ${similarity.toFixed(2)}%,超过阈值`);
448
+ if (similarity >= config.imageThreshold) {
449
+ await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
446
450
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
447
451
  cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
448
452
  return;
@@ -453,7 +457,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
453
457
  for (const existing of existingQuadrantHashes) {
454
458
  if (notifiedPartialCaves.has(existing.cave)) continue;
455
459
  if (newSubHash === existing.hash) {
456
- await session.send(`图片局部与回声洞(${existing.cave})存在完全相同的区块`);
460
+ await session.send(`图片与回声洞(${existing.cave})局部相同`);
457
461
  notifiedPartialCaves.add(existing.cave);
458
462
  }
459
463
  }
@@ -466,7 +470,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
466
470
  }
467
471
  }
468
472
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
469
- const finalStatus = config.enableReview ? "pending" : "active";
473
+ const finalStatus = config.enablePend ? "pending" : "active";
470
474
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
471
475
  if (hashManager) {
472
476
  const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
@@ -476,7 +480,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
476
480
  }
477
481
  if (finalStatus === "pending" && reviewManager) {
478
482
  const [finalCave] = await ctx.database.get("cave", { id: cave.id });
479
- if (finalCave) reviewManager.sendForReview(finalCave);
483
+ if (finalCave) reviewManager.sendForPend(finalCave);
480
484
  }
481
485
  } catch (fileProcessingError) {
482
486
  logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
@@ -486,8 +490,8 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
486
490
  }
487
491
  __name(handleFileUploads, "handleFileUploads");
488
492
 
489
- // src/ReviewManager.ts
490
- var ReviewManager = class {
493
+ // src/PendManager.ts
494
+ var PendManager = class {
491
495
  /**
492
496
  * @param ctx Koishi 上下文。
493
497
  * @param config 插件配置。
@@ -503,7 +507,7 @@ var ReviewManager = class {
503
507
  this.reusableIds = reusableIds;
504
508
  }
505
509
  static {
506
- __name(this, "ReviewManager");
510
+ __name(this, "PendManager");
507
511
  }
508
512
  /**
509
513
  * @description 注册与审核相关的子命令。
@@ -516,7 +520,7 @@ var ReviewManager = class {
516
520
  }
517
521
  return null;
518
522
  }, "requireAdmin");
519
- const review = cave.subcommand(".review [id:posint]", "审核回声洞").action(async ({ session }, id) => {
523
+ const pend = cave.subcommand(".pend [id:posint]", "审核回声洞").action(async ({ session }, id) => {
520
524
  const adminError = requireAdmin(session);
521
525
  if (adminError) return adminError;
522
526
  if (id) {
@@ -530,7 +534,7 @@ var ReviewManager = class {
530
534
  return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
531
535
  ${pendingCaves.map((c) => c.id).join("|")}`;
532
536
  });
533
- const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
537
+ const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
534
538
  const adminError = requireAdmin(session);
535
539
  if (adminError) return adminError;
536
540
  try {
@@ -552,23 +556,23 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
552
556
  this.logger.error(`审核操作失败:`, error);
553
557
  return `操作失败: ${error.message}`;
554
558
  }
555
- }, "createReviewAction");
556
- review.subcommand(".Y [id:posint]", "通过审核").action(createReviewAction("approve"));
557
- review.subcommand(".N [id:posint]", "拒绝审核").action(createReviewAction("reject"));
559
+ }, "createPendAction");
560
+ pend.subcommand(".Y [id:posint]", "通过审核").action(createPendAction("approve"));
561
+ pend.subcommand(".N [id:posint]", "拒绝审核").action(createPendAction("reject"));
558
562
  }
559
563
  /**
560
564
  * @description 将新回声洞提交到管理群组以供审核。
561
565
  * @param cave 新创建的、状态为 'pending' 的回声洞对象。
562
566
  */
563
- async sendForReview(cave) {
567
+ async sendForPend(cave) {
564
568
  if (!this.config.adminChannel?.includes(":")) {
565
569
  this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
566
570
  await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
567
571
  return;
568
572
  }
569
573
  try {
570
- const reviewMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
571
- await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(reviewMessage));
574
+ const pendMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
575
+ await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(pendMessage));
572
576
  } catch (error) {
573
577
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
574
578
  }
@@ -624,12 +628,12 @@ var HashManager = class {
624
628
  return `操作失败: ${error.message}`;
625
629
  }
626
630
  });
627
- cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").action(async (argv) => {
631
+ cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => {
628
632
  const checkResult = adminCheck(argv);
629
633
  if (checkResult) return checkResult;
630
634
  await argv.session.send("正在检查,请稍候...");
631
635
  try {
632
- return await this.checkForSimilarCaves();
636
+ return await this.checkForSimilarCaves(argv.options);
633
637
  } catch (error) {
634
638
  this.logger.error("检查相似度失败:", error);
635
639
  return `检查失败: ${error.message}`;
@@ -716,28 +720,33 @@ var HashManager = class {
716
720
  }
717
721
  /**
718
722
  * @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
723
+ * @param options 包含临时阈值的可选对象。
719
724
  * @returns 一个包含检查结果的报告字符串。
720
725
  */
721
- async checkForSimilarCaves() {
726
+ async checkForSimilarCaves(options = {}) {
727
+ const textThreshold = options.textThreshold ?? this.config.textThreshold;
728
+ const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
722
729
  const allHashes = await this.ctx.database.get("cave_hash", {});
723
730
  const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
724
731
  const textHashes = /* @__PURE__ */ new Map();
725
732
  const globalHashes = /* @__PURE__ */ new Map();
726
- const quadrantHashes = /* @__PURE__ */ new Map();
733
+ const quadrantHashesByCave = /* @__PURE__ */ new Map();
734
+ const partialHashToCaves = /* @__PURE__ */ new Map();
727
735
  for (const hash of allHashes) {
728
736
  if (hash.type === "simhash") {
729
737
  textHashes.set(hash.cave, hash.hash);
730
738
  } else if (hash.type === "phash_g") {
731
739
  globalHashes.set(hash.cave, hash.hash);
732
740
  } else if (hash.type.startsWith("phash_q")) {
733
- if (!quadrantHashes.has(hash.cave)) quadrantHashes.set(hash.cave, /* @__PURE__ */ new Set());
734
- quadrantHashes.get(hash.cave).add(hash.hash);
741
+ if (!quadrantHashesByCave.has(hash.cave)) quadrantHashesByCave.set(hash.cave, /* @__PURE__ */ new Set());
742
+ quadrantHashesByCave.get(hash.cave).add(hash.hash);
743
+ if (!partialHashToCaves.has(hash.hash)) partialHashToCaves.set(hash.hash, /* @__PURE__ */ new Set());
744
+ partialHashToCaves.get(hash.hash).add(hash.cave);
735
745
  }
736
746
  }
737
747
  const similarPairs = {
738
748
  text: /* @__PURE__ */ new Set(),
739
- global: /* @__PURE__ */ new Set(),
740
- partial: /* @__PURE__ */ new Set()
749
+ global: /* @__PURE__ */ new Set()
741
750
  };
742
751
  for (let i = 0; i < allCaveIds.length; i++) {
743
752
  for (let j = i + 1; j < allCaveIds.length; j++) {
@@ -748,7 +757,7 @@ var HashManager = class {
748
757
  const text2 = textHashes.get(id2);
749
758
  if (text1 && text2) {
750
759
  const similarity = this.calculateSimilarity(text1, text2);
751
- if (similarity >= this.config.textThreshold) {
760
+ if (similarity >= textThreshold) {
752
761
  similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
753
762
  }
754
763
  }
@@ -756,32 +765,46 @@ var HashManager = class {
756
765
  const global2 = globalHashes.get(id2);
757
766
  if (global1 && global2) {
758
767
  const similarity = this.calculateSimilarity(global1, global2);
759
- if (similarity >= this.config.imageWholeThreshold) {
768
+ if (similarity >= imageThreshold) {
760
769
  similarPairs.global.add(`${pair} = ${similarity.toFixed(2)}%`);
761
770
  }
762
771
  }
763
- const quads1 = quadrantHashes.get(id1);
764
- const quads2 = quadrantHashes.get(id2);
765
- if (quads1 && quads2 && quads1.size > 0 && quads2.size > 0) {
766
- let matchFound = false;
767
- for (const h1 of quads1) {
768
- if (quads2.has(h1)) {
769
- matchFound = true;
770
- break;
771
- }
772
- }
773
- if (matchFound) {
774
- similarPairs.partial.add(pair);
775
- }
776
- }
777
772
  }
778
773
  }
779
- const totalFindings = similarPairs.text.size + similarPairs.global.size + similarPairs.partial.size;
774
+ const allPartialCaveIds = Array.from(quadrantHashesByCave.keys());
775
+ const parent = /* @__PURE__ */ new Map();
776
+ const find = /* @__PURE__ */ __name((i) => {
777
+ if (parent.get(i) === i) return i;
778
+ parent.set(i, find(parent.get(i)));
779
+ return parent.get(i);
780
+ }, "find");
781
+ const union = /* @__PURE__ */ __name((i, j) => {
782
+ const rootI = find(i);
783
+ const rootJ = find(j);
784
+ if (rootI !== rootJ) parent.set(rootI, rootJ);
785
+ }, "union");
786
+ allPartialCaveIds.forEach((id) => parent.set(id, id));
787
+ for (const caveIds of partialHashToCaves.values()) {
788
+ if (caveIds.size <= 1) continue;
789
+ const ids = Array.from(caveIds);
790
+ for (let i = 1; i < ids.length; i++) union(ids[0], ids[i]);
791
+ }
792
+ const components = /* @__PURE__ */ new Map();
793
+ for (const id of allPartialCaveIds) {
794
+ const root = find(id);
795
+ if (!components.has(root)) components.set(root, /* @__PURE__ */ new Set());
796
+ components.get(root).add(id);
797
+ }
798
+ const partialGroups = [];
799
+ for (const component of components.values()) {
800
+ if (component.size > 1) partialGroups.push(Array.from(component).sort((a, b) => a - b).join(" & "));
801
+ }
802
+ const totalFindings = similarPairs.text.size + similarPairs.global.size + partialGroups.length;
780
803
  if (totalFindings === 0) return "未发现高相似度的内容";
781
804
  let report = `已发现 ${totalFindings} 组高相似度的内容:`;
782
805
  if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
783
806
  if (similarPairs.global.size > 0) report += "\n图片整体相似:\n" + [...similarPairs.global].join("\n");
784
- if (similarPairs.partial.size > 0) report += "\n图片局部相同:\n" + [...similarPairs.partial].join("\n");
807
+ if (partialGroups.length > 0) report += "\n图片局部相同:\n" + partialGroups.join("\n");
785
808
  return report.trim();
786
809
  }
787
810
  /**
@@ -949,16 +972,16 @@ var Config = import_koishi3.Schema.intersect([
949
972
  import_koishi3.Schema.object({
950
973
  coolDown: import_koishi3.Schema.number().default(10).description("冷却时间(秒)"),
951
974
  perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
952
- enableProfile: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
975
+ enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
953
976
  enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
954
977
  adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
955
978
  caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
956
979
  }).description("基础配置"),
957
980
  import_koishi3.Schema.object({
958
- enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核"),
981
+ enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
959
982
  enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
960
983
  textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("文本相似度阈值 (%)"),
961
- imageWholeThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
984
+ imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
962
985
  }).description("复核配置"),
963
986
  import_koishi3.Schema.object({
964
987
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
@@ -984,8 +1007,8 @@ function apply(ctx, config) {
984
1007
  const fileManager = new FileManager(ctx.baseDir, config, logger);
985
1008
  const lastUsed = /* @__PURE__ */ new Map();
986
1009
  const reusableIds = /* @__PURE__ */ new Set();
987
- const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
988
- const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
1010
+ const profileManager = config.enableName ? new NameManager(ctx) : null;
1011
+ const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger, reusableIds) : null;
989
1012
  const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
990
1013
  const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, hashManager) : null;
991
1014
  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 }) => {
@@ -1035,16 +1058,16 @@ function apply(ctx, config) {
1035
1058
  for (const existing of existingTextHashes) {
1036
1059
  const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
1037
1060
  if (similarity >= config.textThreshold) {
1038
- return `文本与回声洞(${existing.cave})的相似度为 ${similarity.toFixed(2)}%,超过阈值`;
1061
+ return `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`;
1039
1062
  }
1040
1063
  }
1041
1064
  textHashesToStore.push({ hash: newSimhash, type: "simhash" });
1042
1065
  }
1043
1066
  }
1044
1067
  }
1045
- const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
1068
+ const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1046
1069
  const hasMedia = mediaToSave.length > 0;
1047
- const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
1070
+ const initialStatus = hasMedia ? "preload" : config.enablePend ? "pending" : "active";
1048
1071
  const newCave = await ctx.database.create("cave", {
1049
1072
  id: newId,
1050
1073
  elements: finalElementsForDb,
@@ -1061,10 +1084,10 @@ function apply(ctx, config) {
1061
1084
  await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
1062
1085
  }
1063
1086
  if (initialStatus === "pending") {
1064
- reviewManager.sendForReview(newCave);
1087
+ reviewManager.sendForPend(newCave);
1065
1088
  }
1066
1089
  }
1067
- return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1090
+ return initialStatus === "pending" || initialStatus === "preload" && config.enablePend ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1068
1091
  } catch (error) {
1069
1092
  logger.error("添加回声洞失败:", error);
1070
1093
  return "添加失败,请稍后再试";
@@ -1101,17 +1124,47 @@ function apply(ctx, config) {
1101
1124
  return "删除失败,请稍后再试";
1102
1125
  }
1103
1126
  });
1104
- cave.subcommand(".list", "查询我的投稿").action(async ({ session }) => {
1105
- try {
1106
- const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
1107
- if (!userCaves.length) return "你还没有投稿过回声洞";
1108
- const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
1109
- return `你已投稿 ${userCaves.length} 条回声洞,序号为:
1110
- ${caveIds}`;
1111
- } catch (error) {
1112
- logger.error("查询投稿列表失败:", error);
1113
- return "查询失败,请稍后再试";
1127
+ cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
1128
+ if (options.all) {
1129
+ const adminChannelId = config.adminChannel?.split(":")[1];
1130
+ if (session.channelId !== adminChannelId) {
1131
+ return "此指令仅限在管理群组中使用";
1132
+ }
1133
+ try {
1134
+ const allCaves = await ctx.database.get("cave", { status: "active" });
1135
+ if (!allCaves.length) return "目前没有任何回声洞投稿。";
1136
+ const userStats = /* @__PURE__ */ new Map();
1137
+ for (const cave2 of allCaves) {
1138
+ const { userId, userName: userName2 } = cave2;
1139
+ const stat = userStats.get(userId);
1140
+ if (stat) {
1141
+ stat.count++;
1142
+ stat.userName = userName2;
1143
+ } else {
1144
+ userStats.set(userId, { userName: userName2, count: 1 });
1145
+ }
1146
+ }
1147
+ const sortedStats = Array.from(userStats.values()).sort((a, b) => b.count - a.count);
1148
+ let report = "回声洞投稿数量排行:\n";
1149
+ sortedStats.forEach((stat, index) => {
1150
+ report += `${index + 1}. ${stat.userName}: ${stat.count} 条
1151
+ `;
1152
+ });
1153
+ return report.trim();
1154
+ } catch (error) {
1155
+ logger.error("查询排行失败:", error);
1156
+ return "查询失败,请稍后再试";
1157
+ }
1114
1158
  }
1159
+ const targetUserId = options.user || session.userId;
1160
+ const isQueryingSelf = !options.user;
1161
+ const query = { ...getScopeQuery(session, config), userId: targetUserId };
1162
+ const userCaves = await ctx.database.get("cave", query);
1163
+ if (!userCaves.length) return isQueryingSelf ? "你还没有投稿过回声洞" : `用户 ${targetUserId} 还没有投稿过回声洞`;
1164
+ const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
1165
+ const userName = userCaves.sort((a, b) => b.time.getTime() - a.time.getTime())[0].userName;
1166
+ return `${isQueryingSelf ? "你" : userName}已投稿 ${userCaves.length} 条回声洞,序号为:
1167
+ ${caveIds}`;
1115
1168
  });
1116
1169
  if (profileManager) profileManager.registerCommands(cave);
1117
1170
  if (dataManager) dataManager.registerCommands(cave);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.2.9",
4
+ "version": "2.3.1",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -8,13 +8,14 @@
8
8
 
9
9
  - **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频甚至文件的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
10
10
  - **灵活的存储后端**:媒体文件可存储在 **本地服务器** (`data/cave` 目录),或配置使用 **AWS S3** 兼容的云端对象存储。支持通过公共URL、本地文件路径或Base64三种方式发送媒体。
11
- - **内容相似度检查**:(可选) 启用后,插件会在添加时自动计算文本(Simhash)和图片(pHash)的哈希值,拒绝与现有内容相似度过高的投稿,有效防止重复。
11
+ - **高级内容查重**:(可选) 启用后,插件会在添加时自动计算文本(Simhash)和图片(pHash,包括整体及分块哈希)的哈希值,拒绝与现有内容相似度过高的投稿,并能对局部相似的图片进行提示,有效防止重复。
12
12
  - **完善的审核机制**:(可选) 启用后,所有新投稿都将进入待审核状态,并通知管理群组。只有管理员审核通过后,内容才会对用户可见,确保内容质量。
13
13
  - **精细的作用域**:通过 `perChannel` 配置,可设定回声洞是在所有群聊中共享(全局模式),还是在每个群聊中独立(分群模式)。
14
14
  - **专属用户昵称**:(可选) 用户可以为自己在回声洞中的发言设置一个专属昵称,增加趣味性。
15
15
  - **便捷的数据管理**:(可选) 管理员可通过指令轻松地将所有回声洞数据导出为 `JSON` 文件备份,或从文件中恢复数据,迁移无忧。
16
- - **强大的维护工具**:管理员可以通过 `cave.hash` 指令为历史数据批量生成哈希值,并获取一份所有内容的相似度报告。
16
+ - **强大的维护工具**:管理员可以通过 `cave.hash` 指令为历史数据批量生成哈希值,并通过 `cave.check` 获取一份所有内容的相似度报告。
17
17
  - **权限分离**:普通用户可删除自己的投稿。审核、数据管理等高级操作则仅限在指定的 **管理群组** 内由管理员执行。
18
+ - **智能ID管理**:采用ID回收和空缺扫描机制,确保回声洞序号紧凑,并能高效处理大量删除和添加操作。
18
19
 
19
20
  ## 📖 指令说明
20
21
 
@@ -23,7 +24,7 @@
23
24
  | 指令 | 别名/选项 | 说明 |
24
25
  | :--- | :--- | :--- |
25
26
  | `cave` | | 随机查看一条回声洞。 |
26
- | `cave.add [内容]` | `cave -a [内容]` | 添加一条新的回声洞。可直接跟内容,也可回复/引用消息,或等待机器人提示后发送。 |
27
+ | `cave.add [内容]` | `cave -a [内容]` | 添加一条新的回声洞。可直接跟内容,也可回复/引用消息,或等待机器人提示后在一分钟内发送。 |
27
28
  | `cave.view <序号>` | `cave -g <序号>` | 查看指定序号的回声洞。 |
28
29
  | `cave.del <序号>` | `cave -r <序号>` | 删除指定序号的回声洞。仅投稿人或在管理群组内的管理员可操作。 |
29
30
  | `cave.list` | `cave -l` | 查询并列出自己投稿过的所有回声洞序号及总数。 |
@@ -41,7 +42,8 @@
41
42
  | `cave.review.N [序号]` | `enableReview: true` | **(仅限管理群组)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
42
43
  | `cave.export` | `enableIO: true` | **(仅限管理群组)** 将所有`active`状态的回声洞导出到 `cave_export.json`。 |
43
44
  | `cave.import` | `enableIO: true` | **(仅限管理群组)** 从 `cave_import.json` 文件中导入数据。 |
44
- | `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)** 校验所有历史数据,补全哈希并检查内容相似度。 |
45
+ | `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
46
+ | `cave.check` | `enableSimilarity: true` | **(仅限管理群组)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
45
47
 
46
48
  ## ⚙️ 配置说明
47
49
 
@@ -60,10 +62,10 @@
60
62
 
61
63
  | 配置项 | 类型 | 默认值 | 说明 |
62
64
  | :--- | :--- | :--- | :--- |
63
- | `enableReview` | `boolean` | `false` | 是否启用审核机制。 |
65
+ | `enableReview` | `boolean` | `false` | 是否启用审核机制。启用后,新投稿将进入`pending`状态。 |
64
66
  | `enableSimilarity` | `boolean` | `false` | 是否启用内容相似度检查(查重)。 |
65
- | `textThreshold` | `number` | `0.9` | 文本相似度阈值 (0-1)。超过此值将被拒绝。 |
66
- | `imageThreshold` | `number` | `0.9` | 图片相似度阈值 (0-1)。超过此值将被拒绝。 |
67
+ | `textThreshold` | `number` | `95` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
68
+ | `imageWholeThreshold` | `number` | `95` | **图片整体**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
67
69
 
68
70
  ### 存储配置
69
71
 
@@ -83,7 +85,8 @@
83
85
  1. **媒体发送方式**:插件会按以下优先级决定如何发送图片、视频等媒体文件:
84
86
  1. **S3 公共URL**:如果 `enableS3` 和 `publicUrl` 已配置。
85
87
  2. **本地文件路径**:如果 `localPath` 已配置。
86
- 3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64 发送(可能受平台大小限制)。
87
- 2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key 权限和存储桶策略配置正确。
88
+ 3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64 发送(可能受平台大小限制或支持问题)。
89
+ 2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key 权限和存储桶策略(如ACL设为`public-read`)配置正确。
88
90
  3. **异步删除**:删除操作(`cave.del` 或审核拒绝)会将内容状态标记为 `delete`,然后由后台任务异步清理关联的文件和数据库条目,以避免阻塞当前指令。
89
- 4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json`。请在操作前放置或备份好相应文件。
91
+ 4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json`。请在操作前放置或备份好相应文件。导入时,ID会从现有最大ID开始自增,不会覆盖或修改老数据。
92
+ 5. **查重机制**:图片查重不仅比较整图,还会比较图片的四个象限。如果象限完全一致,会发送提示但不会直接拒绝,为识别拼接图、裁剪图等提供了依据。