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 +3 -0
- package/lib/guard.js +66 -21
- package/lib/platform.d.ts +4 -0
- package/lib/platform.js +12 -1
- package/lib/store.d.ts +2 -0
- package/lib/store.js +2 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
this.groups = saved;
|
|
184
|
-
this.groupsLoaded = true;
|
|
186
|
+
// 先读暂停状态:读不出来时按「暂停」处理(宁可不动,也不误动)
|
|
187
|
+
try {
|
|
188
|
+
this.paused = (await this.store.getKv('paused')) ?? false;
|
|
185
189
|
}
|
|
186
|
-
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 ? '插件已停用或配置已修改' : '超过
|
|
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:
|
|
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 (
|
|
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:
|
|
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
|
|
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: '',
|