koishi-plugin-aaqqbot 0.1.0 → 0.1.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.
package/lib/guard.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface GuardOptions {
17
17
  kickDelayMs?: number;
18
18
  /** 巡检时两个群之间的间隔。 */
19
19
  groupDelayMs?: number;
20
+ /** 一轮巡检跑满多久就停下,剩下的群接着巡检(测试用)。 */
21
+ patrolSoftBudgetMs?: number;
20
22
  }
21
23
  export interface ModeInfo {
22
24
  desired: Mode;
@@ -98,6 +100,7 @@ export declare class Guard {
98
100
  /** 包一层 try/catch:Koishi 里同步抛错会让整个进程退出(R20)。 */
99
101
  safely(what: string, task: () => unknown): void;
100
102
  start(): Promise<void>;
103
+ private startStep;
101
104
  private schedule;
102
105
  private patrolTick;
103
106
  private eventsTick;
package/lib/guard.js CHANGED
@@ -18,7 +18,13 @@ const store_1 = require("./store");
18
18
  const texts_1 = require("./texts");
19
19
  const util_1 = require("./util");
20
20
  const MAX_CHECK = 3000;
21
- const PATROL_BUDGET_MS = 30 * 60_000;
21
+ /** 一轮巡检跑满这么久,就在两个群之间停下,剩下的群马上接着巡检(不让排在后面的群一直轮不到)。 */
22
+ const PATROL_SOFT_BUDGET_MS = 20 * 60_000;
23
+ /** 一轮巡检的硬上限:超过就中止(防止卡死)。 */
24
+ const PATROL_BUDGET_MS = 40 * 60_000;
25
+ /** 名单比上一轮少了这么多,就怀疑名单不完整。 */
26
+ const ROSTER_DROP_MIN = 5;
27
+ const ROSTER_DROP_RATIO = 0.1;
22
28
  const FLAG_TTL_MS = 30 * 60_000;
23
29
  const APPROVED_TTL_MS = 10 * 60_000;
24
30
  const AUDIT_KEEP_MS = 180 * 86400_000;
@@ -177,28 +183,50 @@ class Guard {
177
183
  });
178
184
  }
179
185
  async start() {
180
- this.paused = (await this.store.getKv('paused')) ?? false;
181
- const saved = await this.store.getKv(this.groupsKey);
182
- if (Array.isArray(saved) && saved.length) {
183
- this.groups = saved;
184
- this.groupsLoaded = true;
186
+ // 先读暂停状态:读不出来时按「暂停」处理(宁可不动,也不误动)
187
+ try {
188
+ this.paused = (await this.store.getKv('paused')) ?? false;
185
189
  }
186
- if (!(0, util_1.parseClock)(this.config.remindTime))
187
- this.logger.warn('提醒时间 %s 格式不对,应为 19:30 这样的格式', this.config.remindTime);
188
- await this.checkHealth(true);
189
- await this.refreshGroups();
190
- if (this.paused)
191
- this.notifier.push('⏸ 插件处于暂停状态:不会审批、提醒、改名片或移出任何人。发送 aaqq.resume 恢复。');
192
- // 机器人已经在线(例如改配置后插件重启)时不会再收到上线事件,这里补处理一次积压的申请
193
- const bot = this.pickBot();
194
- if (bot && this.config.catchUpRequests)
195
- await this.catchUpRequests(bot);
190
+ catch (error) {
191
+ this.paused = true;
192
+ this.logger.error('读取暂停状态失败,为安全起见先暂停:%s', error);
193
+ this.notifier.push('⚠ 启动时读取不到数据库里的暂停状态,为安全起见已暂停。检查 Koishi 的数据库插件后,发送 aaqq.resume 恢复。');
194
+ }
195
+ // 定时任务先安排好:后面任何一步出错,巡检、拉取变化、提醒都照常运行
196
196
  if (this.options.timers !== false) {
197
197
  this.schedule('patrol', 20_000, () => this.patrolTick());
198
198
  this.schedule('events', this.config.eventPollSeconds * 1000, () => this.eventsTick());
199
199
  this.schedule('groups', 3600_000, () => this.groupsTick());
200
200
  this.scheduleReminder();
201
201
  }
202
+ if (!(0, util_1.parseClock)(this.config.remindTime))
203
+ this.logger.warn('提醒时间 %s 格式不对,应为 19:30 这样的格式', this.config.remindTime);
204
+ await this.startStep('读取受管群列表', async () => {
205
+ const saved = await this.store.getKv(this.groupsKey);
206
+ if (Array.isArray(saved) && saved.length && !this.groupsLoaded) {
207
+ this.groups = saved;
208
+ this.groupsLoaded = true;
209
+ }
210
+ });
211
+ await this.startStep('健康检查', () => this.checkHealth(true));
212
+ await this.startStep('获取受管群列表', () => this.refreshGroups());
213
+ if (this.paused)
214
+ this.notifier.push('⏸ 插件处于暂停状态:不会审批、提醒、改名片或移出任何人。发送 aaqq.resume 恢复。');
215
+ // 机器人已经在线(例如改配置后插件重启)时不会再收到上线事件,这里补处理一次积压的申请
216
+ await this.startStep('补处理积压的入群申请', async () => {
217
+ const bot = this.pickBot();
218
+ if (bot && this.config.catchUpRequests)
219
+ await this.catchUpRequests(bot);
220
+ });
221
+ }
222
+ async startStep(what, step) {
223
+ try {
224
+ await step();
225
+ }
226
+ catch (error) {
227
+ if (!this.signal.aborted)
228
+ this.logger.warn('启动步骤「%s」出错(不影响定时任务):%s', what, error);
229
+ }
202
230
  }
203
231
  schedule(name, delay, task) {
204
232
  this.timers.get(name)?.();
@@ -390,8 +418,15 @@ class Guard {
390
418
  const targets = this.groups.filter((g) => !only || only.includes(g.groupId));
391
419
  const sections = [];
392
420
  let ok = true;
421
+ const realStart = Date.now();
393
422
  for (const [index, g] of targets.entries()) {
394
423
  (0, util_1.throwIfAborted)(round.signal);
424
+ if (index > 0 && Date.now() - realStart >= (this.options.patrolSoftBudgetMs ?? PATROL_SOFT_BUDGET_MS)) {
425
+ const rest = targets.slice(index);
426
+ this.requestPatrol(rest.map((x) => x.groupId));
427
+ sections.push(`⏭ 这一轮时间到了,还有 ${rest.length} 个群马上接着巡检:${rest.map((x) => this.groupLabel(x.groupId)).join('、')}`);
428
+ break;
429
+ }
395
430
  if (index > 0)
396
431
  await (0, util_1.sleep)(this.options.groupDelayMs ?? 5000, round.signal);
397
432
  const section = await this.patrolGroup(bot, g, round.signal);
@@ -409,7 +444,7 @@ class Guard {
409
444
  }
410
445
  catch (error) {
411
446
  if (error instanceof util_1.AbortedError || round.signal.aborted) {
412
- const why = this.paused ? '已暂停' : this.signal.aborted ? '插件已停用或配置已修改' : '超过 30 分钟时限';
447
+ const why = this.paused ? '已暂停' : this.signal.aborted ? '插件已停用或配置已修改' : '超过 40 分钟时限';
413
448
  this.logger.info('巡检中止:%s', why);
414
449
  if (!this.signal.aborted)
415
450
  this.notifier.push(`⏹ 巡检已中止(${why})。`);
@@ -463,9 +498,14 @@ class Guard {
463
498
  await this.store.setGroupState(g.groupId, { lastPatrolOk: false, lastPatrolNote: '机器人不在群里' });
464
499
  return { ok: false, text: `${head}\n❌ 机器人不在这个群里` };
465
500
  }
501
+ // 名单比上一轮明显变少:可能是 LLBot 刚启动、只返回了一部分人。
502
+ // 这一轮不当作完整名单交给 AA(否则 AA 会删掉不在名单里的老成员),也不据此取消任何人的跟踪。
503
+ const previous = state.lastRosterSize;
504
+ const drop = previous - members.length;
505
+ const suspicious = previous > 0 && drop > Math.max(ROSTER_DROP_MIN, Math.ceil(previous * ROSTER_DROP_RATIO));
466
506
  const qqs = members.map((m) => m.qq);
467
507
  const verdicts = new Map();
468
- const fullRoster = qqs.length <= MAX_CHECK;
508
+ const fullRoster = qqs.length <= MAX_CHECK && !suspicious;
469
509
  for (const part of (0, util_1.chunk)(qqs, MAX_CHECK)) {
470
510
  const result = await this.aa.check(g.groupId, part, fullRoster, { signal, retryDelays: this.options.retryDelays });
471
511
  if (!result.ok) {
@@ -493,7 +533,7 @@ class Guard {
493
533
  held: info.held,
494
534
  bypass: this.bypassFor(state, now),
495
535
  kickApprovedBefore: state.lastConfirmAt?.getTime() ?? 0,
496
- partial: false,
536
+ partial: suspicious,
497
537
  groupSize: members.length,
498
538
  members,
499
539
  verdicts,
@@ -510,6 +550,7 @@ class Guard {
510
550
  bypassUntil: null,
511
551
  lastNewDenies: plan.newDenies.length,
512
552
  lastKicksDue: plan.kicksDue,
553
+ lastRosterSize: members.length,
513
554
  };
514
555
  if (plan.tripped) {
515
556
  patch.holdSince = new Date(now);
@@ -520,8 +561,12 @@ class Guard {
520
561
  patch.lastPatrolNote = `成员 ${members.length},不合格 ${plan.counts.deny}`;
521
562
  await this.store.setGroupState(g.groupId, patch);
522
563
  const lines = [head, ...this.describePlan(plan, applied, members, info)];
523
- if (!fullRoster)
564
+ if (suspicious) {
565
+ lines.push(`⚠ 这次取到的名单比上一轮少了 ${drop} 人(${previous} → ${members.length}),可能不完整:本轮不作为完整名单交给 AA,也不取消任何人的跟踪。如果确实有很多人退群,下一轮会恢复正常`);
566
+ }
567
+ else if (!fullRoster) {
524
568
  lines.push(`⚠ 群人数超过 ${MAX_CHECK},名单分批提交,AA 上「老成员免验证」对这个群不生效`);
569
+ }
525
570
  if (plan.tripped) {
526
571
  this.notifier.push(`⛔ 熔断:${label} ${plan.tripReason}。\n可能是 AA 配置被改错了。这个群已停止一切处置(不提醒、不改名片、不移出、不拒绝申请),直到管理员确认。\n请先核对 AA 上的设置和下面的名单,确认无误后发送:aaqq.confirm ${g.groupId}`);
527
572
  }
@@ -551,7 +596,7 @@ class Guard {
551
596
  held: false,
552
597
  bypass: null,
553
598
  kickApprovedBefore: 0,
554
- partial: false,
599
+ partial: true, // 只撤掉还在群里的人的标记;名单万一不完整,也不会误删别人的记录
555
600
  groupSize: members.length,
556
601
  members,
557
602
  verdicts: new Map(),
package/lib/platform.d.ts CHANGED
@@ -21,6 +21,10 @@ export declare class Platform {
21
21
  bot: Bot | null;
22
22
  problem: string | null;
23
23
  };
24
+ /**
25
+ * 取群成员名单,强制 LLBot 从 QQ 服务器刷新(no_cache)。
26
+ * adapter-onebot 的 getGroupMemberList 会丢掉 no_cache 参数(交接文档 03 #15),所以直接调用底层接口。
27
+ */
24
28
  listMembers(bot: Bot, groupId: string): Promise<Member[]>;
25
29
  /** 实时查询一个成员(不走缓存)。查不到或出错时返回 null。 */
26
30
  getMember(bot: Bot, groupId: string, qq: string): Promise<Member | null>;
package/lib/platform.js CHANGED
@@ -42,8 +42,19 @@ class Platform {
42
42
  return { bot: null, problem: `机器人 ${bot.selfId} 与 LLBot 的连接已断开` };
43
43
  return { bot, problem: null };
44
44
  }
45
+ /**
46
+ * 取群成员名单,强制 LLBot 从 QQ 服务器刷新(no_cache)。
47
+ * adapter-onebot 的 getGroupMemberList 会丢掉 no_cache 参数(交接文档 03 #15),所以直接调用底层接口。
48
+ */
45
49
  async listMembers(bot, groupId) {
46
- const list = await bot.internal.getGroupMemberList(groupId);
50
+ const id = Number(groupId);
51
+ const response = await bot.internal._request('get_group_member_list', {
52
+ group_id: Math.abs(id) < 4294967296 ? id : groupId,
53
+ no_cache: true,
54
+ });
55
+ if (!response || response.retcode !== 0)
56
+ throw new Error(`取群成员名单失败(retcode ${response?.retcode})`);
57
+ const list = response.data;
47
58
  if (!Array.isArray(list))
48
59
  throw new Error('群成员列表格式不对');
49
60
  const members = [];
package/lib/store.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface GroupState {
29
29
  /** 最近一轮巡检的「新发现不合格」和「到期要移出」人数(确认时用作豁免上限)。 */
30
30
  lastNewDenies: number;
31
31
  lastKicksDue: number;
32
+ /** 上一轮巡检取到的群人数(用来发现「名单突然变少」)。 */
33
+ lastRosterSize: number;
32
34
  lastPatrolAt: Date | null;
33
35
  lastPatrolOk: boolean;
34
36
  lastPatrolNote: string;
package/lib/store.js CHANGED
@@ -28,6 +28,7 @@ function extendModels(ctx) {
28
28
  bypassMaxKicks: 'unsigned',
29
29
  lastNewDenies: 'unsigned',
30
30
  lastKicksDue: 'unsigned',
31
+ lastRosterSize: 'unsigned',
31
32
  lastPatrolAt: { type: 'timestamp', nullable: true },
32
33
  lastPatrolOk: 'boolean',
33
34
  lastPatrolNote: 'text',
@@ -60,6 +61,7 @@ function defaultGroupState(groupId) {
60
61
  bypassMaxKicks: 0,
61
62
  lastNewDenies: 0,
62
63
  lastKicksDue: 0,
64
+ lastRosterSize: 0,
63
65
  lastPatrolAt: null,
64
66
  lastPatrolOk: false,
65
67
  lastPatrolNote: '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-aaqqbot",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "配合 AllianceAuth 插件 aa-qqbot,用 QQ 机器人管理联盟 QQ 群成员:审批入群、定时巡检、提醒与移出、同步群名片",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",