koishi-plugin-group-control 1.0.2 → 1.0.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/database.d.ts CHANGED
@@ -25,6 +25,11 @@ export interface SmallGroupWhitelist {
25
25
  platform: string;
26
26
  guildId: string;
27
27
  }
28
+ export interface SelfLeftGuild {
29
+ platform: string;
30
+ guildId: string;
31
+ timestamp: number;
32
+ }
28
33
  export interface PendingInvite {
29
34
  platform: string;
30
35
  groupId: string;
@@ -48,6 +53,7 @@ declare module 'koishi' {
48
53
  command_frequency_record: CommandFrequencyRecord;
49
54
  group_bot_status: GroupBotStatus;
50
55
  small_group_whitelist: SmallGroupWhitelist;
56
+ self_left_guild: SelfLeftGuild;
51
57
  pending_invite: PendingInvite;
52
58
  pending_friend_request: PendingFriendRequest;
53
59
  }
@@ -60,6 +66,16 @@ export declare function removeBlacklistedGuild(ctx: Context, guildId: string): P
60
66
  export declare function createBlacklistedGuild(ctx: Context, guildId: string, reason: string): Promise<import("minato").Driver.WriteResult>;
61
67
  export declare function getAllBlacklistedGuilds(ctx: Context): Promise<BlacklistedGuild[]>;
62
68
  export declare function clearBlacklistedGuilds(ctx: Context): Promise<import("minato").Driver.WriteResult>;
69
+ /** 统一写入被踢黑名单行,保证 platform = BLACKLIST_PLATFORM */
70
+ export declare function blacklistKicked(ctx: Context, guildId: string): Promise<import("minato").Driver.WriteResult>;
71
+ /** 在主动退群前写入标记,让 guild-removed 能区分「自己退的」和「被踢的」*/
72
+ export declare function markSelfLeft(ctx: Context, guildId: string): Promise<void>;
73
+ /** 消费标记(单次读取后删除),返回是否在 maxAgeSec 内。用于 guild-removed 判断是自己退的 */
74
+ export declare function consumeSelfLeft(ctx: Context, guildId: string, maxAgeSec?: number): Promise<boolean>;
75
+ /** 清理标记(退群失败时回滚,或 unban 时清理)*/
76
+ export declare function clearSelfLeft(ctx: Context, guildId: string): Promise<void>;
77
+ /** 定期清理过期的主动退群标记(超过 maxAgeSec 秒未消费的)*/
78
+ export declare function clearExpiredSelfLeft(ctx: Context, maxAgeSec?: number): Promise<number>;
63
79
  export declare function getCommandFrequencyRecord(ctx: Context, platform: string, guildId: string): Promise<CommandFrequencyRecord>;
64
80
  export declare function updateCommandFrequencyRecord(ctx: Context, platform: string, guildId: string, data: Partial<CommandFrequencyRecord>): Promise<void>;
65
81
  export declare function getGroupBotStatus(ctx: Context, platform: string, guildId: string): Promise<GroupBotStatus | null>;
package/lib/index.js CHANGED
@@ -34,9 +34,13 @@ __export(database_exports, {
34
34
  addPendingInvite: () => addPendingInvite,
35
35
  addToSmallGroupWhitelist: () => addToSmallGroupWhitelist,
36
36
  apply: () => apply,
37
+ blacklistKicked: () => blacklistKicked,
37
38
  clearBlacklistedGuilds: () => clearBlacklistedGuilds,
38
39
  clearExpiredPendingFriendRequests: () => clearExpiredPendingFriendRequests,
39
40
  clearExpiredPendingInvites: () => clearExpiredPendingInvites,
41
+ clearExpiredSelfLeft: () => clearExpiredSelfLeft,
42
+ clearSelfLeft: () => clearSelfLeft,
43
+ consumeSelfLeft: () => consumeSelfLeft,
40
44
  createBlacklistedGuild: () => createBlacklistedGuild,
41
45
  getAllBlacklistedGuilds: () => getAllBlacklistedGuilds,
42
46
  getAllPendingFriendRequests: () => getAllPendingFriendRequests,
@@ -48,6 +52,7 @@ __export(database_exports, {
48
52
  getPendingFriendRequest: () => getPendingFriendRequest,
49
53
  getPendingInvite: () => getPendingInvite,
50
54
  isInSmallGroupWhitelist: () => isInSmallGroupWhitelist,
55
+ markSelfLeft: () => markSelfLeft,
51
56
  name: () => name,
52
57
  removeBlacklistedGuild: () => removeBlacklistedGuild,
53
58
  removeFromSmallGroupWhitelist: () => removeFromSmallGroupWhitelist,
@@ -84,6 +89,11 @@ function apply(ctx) {
84
89
  platform: "string",
85
90
  guildId: "string"
86
91
  }, { primary: ["platform", "guildId"] });
92
+ ctx.model.extend("self_left_guild", {
93
+ platform: "string",
94
+ guildId: "string",
95
+ timestamp: "integer"
96
+ }, { primary: ["platform", "guildId"] });
87
97
  ctx.model.extend("pending_invite", {
88
98
  platform: "string",
89
99
  groupId: "string",
@@ -129,6 +139,44 @@ async function clearBlacklistedGuilds(ctx) {
129
139
  return await ctx.model.remove("blacklisted_guild", { platform: BLACKLIST_PLATFORM });
130
140
  }
131
141
  __name(clearBlacklistedGuilds, "clearBlacklistedGuilds");
142
+ async function blacklistKicked(ctx, guildId) {
143
+ return await ctx.model.upsert("blacklisted_guild", [{
144
+ platform: BLACKLIST_PLATFORM,
145
+ guildId,
146
+ timestamp: Math.floor(Date.now() / 1e3),
147
+ reason: "kicked"
148
+ }]);
149
+ }
150
+ __name(blacklistKicked, "blacklistKicked");
151
+ async function markSelfLeft(ctx, guildId) {
152
+ await ctx.model.upsert("self_left_guild", [{
153
+ platform: BLACKLIST_PLATFORM,
154
+ guildId,
155
+ timestamp: Math.floor(Date.now() / 1e3)
156
+ }]);
157
+ }
158
+ __name(markSelfLeft, "markSelfLeft");
159
+ async function consumeSelfLeft(ctx, guildId, maxAgeSec = 120) {
160
+ const [row] = await ctx.model.get("self_left_guild", { platform: BLACKLIST_PLATFORM, guildId });
161
+ if (!row) return false;
162
+ await ctx.model.remove("self_left_guild", { platform: BLACKLIST_PLATFORM, guildId });
163
+ return Math.floor(Date.now() / 1e3) - row.timestamp <= maxAgeSec;
164
+ }
165
+ __name(consumeSelfLeft, "consumeSelfLeft");
166
+ async function clearSelfLeft(ctx, guildId) {
167
+ await ctx.model.remove("self_left_guild", { platform: BLACKLIST_PLATFORM, guildId });
168
+ }
169
+ __name(clearSelfLeft, "clearSelfLeft");
170
+ async function clearExpiredSelfLeft(ctx, maxAgeSec = 300) {
171
+ const cutoff = Math.floor(Date.now() / 1e3) - maxAgeSec;
172
+ const all = await ctx.model.get("self_left_guild", { platform: BLACKLIST_PLATFORM });
173
+ const expired = all.filter((r) => r.timestamp < cutoff);
174
+ for (const record of expired) {
175
+ await ctx.model.remove("self_left_guild", { platform: BLACKLIST_PLATFORM, guildId: record.guildId });
176
+ }
177
+ return expired.length;
178
+ }
179
+ __name(clearExpiredSelfLeft, "clearExpiredSelfLeft");
132
180
  async function getCommandFrequencyRecord(ctx, platform, guildId) {
133
181
  const records = await ctx.model.get("command_frequency_record", { platform, guildId });
134
182
  return records.length > 0 ? records[0] : null;
@@ -367,17 +415,24 @@ function apply2(ctx, config) {
367
415
  if (now - time > KICK_DEDUP_MS) processedKicks.delete(key);
368
416
  }
369
417
  }, 30 * 1e3);
418
+ setInterval(async () => {
419
+ try {
420
+ await clearExpiredSelfLeft(ctx);
421
+ } catch {
422
+ }
423
+ }, 5 * 60 * 1e3);
370
424
  ctx.on("guild-added", async (session) => {
371
425
  const { guildId, platform } = session;
372
426
  ctx.logger("group-control-basic").info(`[guild-added] 触发!guildId=${guildId}, platform=${platform}`);
373
427
  if (config.basic.enableBlacklist) {
374
- const [blacklisted] = await ctx.model.get("blacklisted_guild", { platform, guildId });
428
+ const [blacklisted] = await ctx.model.get("blacklisted_guild", { platform: BLACKLIST_PLATFORM, guildId });
375
429
  if (blacklisted) {
376
430
  try {
377
431
  await session.bot.sendMessage(guildId, config.basic.blacklistMessage, platform);
378
432
  } catch (e) {
379
433
  }
380
- quittingGuilds.set(`${platform}:${guildId}`, Date.now());
434
+ quittingGuilds.set(`${BLACKLIST_PLATFORM}:${guildId}`, Date.now());
435
+ await markSelfLeft(ctx, guildId);
381
436
  try {
382
437
  await session.bot.internal.setGroupLeave(parseInt(guildId));
383
438
  } catch (e) {
@@ -437,12 +492,14 @@ function apply2(ctx, config) {
437
492
  机器人已自动退出该群。`;
438
493
  await notifyAdmins(session.bot, config, adminMsg);
439
494
  }
440
- quittingGuilds.set(`${platform}:${guildId}`, Date.now());
495
+ quittingGuilds.set(`${BLACKLIST_PLATFORM}:${guildId}`, Date.now());
496
+ await markSelfLeft(ctx, guildId);
441
497
  try {
442
498
  await session.bot.internal.setGroupLeave(parseInt(guildId));
443
499
  } catch (e) {
444
500
  console.error(`小群自动退群失败 (群号: ${guildId}):`, e);
445
- quittingGuilds.delete(`${platform}:${guildId}`);
501
+ quittingGuilds.delete(`${BLACKLIST_PLATFORM}:${guildId}`);
502
+ await clearSelfLeft(ctx, guildId);
446
503
  }
447
504
  } else if (memberCount > config.basic.smallGroupThreshold) {
448
505
  if (config.basic.smallGroupQualifiedNotifyAdmin) {
@@ -464,25 +521,37 @@ function apply2(ctx, config) {
464
521
  }
465
522
  });
466
523
  ctx.on("guild-removed", async (session) => {
467
- const { guildId, platform } = session;
468
- const quittingKey = `${platform}:${guildId}`;
469
- if (quittingGuilds.has(quittingKey)) {
524
+ const { guildId } = session;
525
+ const platform = BLACKLIST_PLATFORM;
526
+ const dedupKey = `${platform}:${guildId}`;
527
+ if (quittingGuilds.has(dedupKey)) {
528
+ try {
529
+ await clearSelfLeft(ctx, guildId);
530
+ } catch {
531
+ }
470
532
  return;
471
533
  }
472
- if (processedKicks.has(quittingKey)) {
473
- return;
534
+ const isSelfLeft = await consumeSelfLeft(ctx, guildId);
535
+ if (isSelfLeft) return;
536
+ const raw = session.event?._data || session.original || session.onebot || {};
537
+ const subType = String(raw.sub_type ?? session.subtype ?? "");
538
+ const eventTs = typeof raw.time === "number" ? raw.time : Math.floor(Date.now() / 1e3);
539
+ const ageSec = Math.floor(Date.now() / 1e3) - eventTs;
540
+ if (subType === "leave") return;
541
+ if (!subType && ageSec > 60) return;
542
+ if (processedKicks.has(dedupKey)) return;
543
+ processedKicks.set(dedupKey, Date.now());
544
+ if (config.basic.enableBlacklist || config.basic.notifyAdminOnKick) {
545
+ const [existing] = await ctx.model.get("blacklisted_guild", { platform, guildId });
546
+ if (existing && existing.reason === "kicked") {
547
+ return;
548
+ }
474
549
  }
475
- processedKicks.set(quittingKey, Date.now());
476
- const groupName = await getGroupName(session.bot, guildId);
477
550
  if (config.basic.enableBlacklist) {
478
- await ctx.model.upsert("blacklisted_guild", [{
479
- platform,
480
- guildId,
481
- timestamp: Math.floor(Date.now() / 1e3),
482
- reason: "kicked"
483
- }]);
551
+ await blacklistKicked(ctx, guildId);
484
552
  }
485
553
  if (config.basic.notifyAdminOnKick) {
554
+ const groupName = await getGroupName(session.bot, guildId);
486
555
  const kickMsg = config.basic.kickNotificationMessage.replaceAll("{groupId}", guildId).replaceAll("{groupName}", groupName);
487
556
  await notifyAdmins(session.bot, config, kickMsg);
488
557
  }
@@ -516,7 +585,8 @@ function apply2(ctx, config) {
516
585
  群名称:${groupName}
517
586
  群号:${guildId}`;
518
587
  await notifyAdmins(session.bot, config, adminMsg);
519
- quittingGuilds.set(`${platform}:${guildId}`, Date.now());
588
+ quittingGuilds.set(`${BLACKLIST_PLATFORM}:${guildId}`, Date.now());
589
+ await markSelfLeft(ctx, guildId);
520
590
  try {
521
591
  await session.bot.sendMessage(session.guildId, config.basic.quitMessage.replace("{userId}", userId), platform);
522
592
  } catch (e) {
@@ -524,7 +594,8 @@ function apply2(ctx, config) {
524
594
  try {
525
595
  await session.bot.internal.setGroupLeave(parseInt(guildId));
526
596
  } catch (e) {
527
- quittingGuilds.delete(`${platform}:${guildId}`);
597
+ quittingGuilds.delete(`${BLACKLIST_PLATFORM}:${guildId}`);
598
+ await clearSelfLeft(ctx, guildId);
528
599
  return `退出失败: ${e.message}`;
529
600
  }
530
601
  return "";
@@ -562,6 +633,33 @@ function apply3(ctx, config) {
562
633
  const rawUserId = raw.user_id ? String(raw.user_id) : session.userId;
563
634
  const rawGroupId = raw.group_id ? String(raw.group_id) : session.guildId;
564
635
  const { platform } = session;
636
+ if (config.basic.enableBlacklist && rawGroupId && /^\d+$/.test(String(rawGroupId))) {
637
+ const bl = await getBlacklistedGuild(ctx, String(rawGroupId));
638
+ if (bl.length > 0) {
639
+ try {
640
+ await session.bot.internal.setGroupAddRequest(flag, "invite", false, "该群已被机器人拉黑");
641
+ } catch (e) {
642
+ ctx.logger("group-control-invite").warn("拒绝黑名单群邀请失败 (flag 可能已失效):", e);
643
+ }
644
+ const rejectNotify = `已自动拒绝黑名单群邀请
645
+ 群号:${rawGroupId}
646
+ 邀请者 QQ:${rawUserId}
647
+ 如需放行请先执行 gc.unban ${rawGroupId} 再让对方重新邀请。`;
648
+ try {
649
+ await notifyAdmins(session.bot, config, rejectNotify);
650
+ } catch (e) {
651
+ }
652
+ try {
653
+ const rejectMsg = `您邀请加入的群 ${rawGroupId} 已被机器人拉黑,邀请已被自动拒绝。如有疑问请联系机器人管理员。`;
654
+ await session.bot.sendPrivateMessage(rawUserId, rejectMsg);
655
+ } catch (e) {
656
+ }
657
+ if (config.invite.showDetailedLog) {
658
+ console.log(`已自动拒绝黑名单群邀请: 群号 ${rawGroupId}, 邀请者 ${rawUserId}`);
659
+ }
660
+ return;
661
+ }
662
+ }
565
663
  if (!flag && config.invite.showDetailedLog) {
566
664
  console.warn("未能提取到邀请 flag,可能导致无法处理邀请。Raw event:", JSON.stringify(raw));
567
665
  }
@@ -655,6 +753,12 @@ function apply3(ctx, config) {
655
753
  ctx.command("gc.approve <groupId:string>", "同意群聊邀请").action(async ({ session }, groupId) => {
656
754
  if (!groupId) return "请指定群号。用法:gc.approve <群号>";
657
755
  if (!hasGlobalPermission(session, config)) return "权限不足,只有管理员可以审核邀请。";
756
+ if (config.basic.enableBlacklist) {
757
+ const bl = await getBlacklistedGuild(ctx, groupId);
758
+ if (bl.length > 0) {
759
+ return `群 ${groupId} 在黑名单中,无法通过审核。如需放行请先执行 gc.unban ${groupId}。`;
760
+ }
761
+ }
658
762
  const inviteData = await getPendingInvite(ctx, groupId);
659
763
  if (!inviteData) {
660
764
  const allInvites = await getAllPendingInvites(ctx);
@@ -940,6 +1044,7 @@ function apply5(ctx, config) {
940
1044
  const guildId = parseGuildId(input);
941
1045
  if (!guildId) return `输入格式错误。`;
942
1046
  const removed = await removeBlacklistedGuild(ctx, guildId);
1047
+ await clearSelfLeft(ctx, guildId);
943
1048
  return removed ? `已移除群聊 ${guildId}` : `群聊 ${guildId} 不在黑名单中。`;
944
1049
  });
945
1050
  ctx.command("gc.banlist", "查看黑名单").action(async ({ session }) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-group-control",
3
3
  "description": "Koishi 插件,一个多功能的群聊自管理工具。支持被踢出自动拉黑、刷屏自动屏蔽、开关控制等功能。(仅支持 OneBot 适配器)",
4
- "version": "1.0.2",
4
+ "version": "1.0.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [