koishi-plugin-aaqqbot 0.1.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.
package/lib/guard.js ADDED
@@ -0,0 +1,1361 @@
1
+ "use strict";
2
+ // 主引擎:巡检、入群申请、新人、事件、每日提醒、暂停与确认。
3
+ //
4
+ // 安全规则(交接文档 R5–R11、API.md 第 1.1 / 7.1 节):
5
+ // - 拿不到 AA 的明确答案就什么都不做;review 永不处置。
6
+ // - 群主、管理员、机器人、白名单永不处置;移出前实时复核。
7
+ // - 未经管理员确认的模式升级按 report 执行;新增不合格人数超过阈值时整群熔断。
8
+ // - 暂停、停用插件、改配置都会立即中止正在进行的一轮。
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.Guard = void 0;
11
+ const koishi_1 = require("koishi");
12
+ const aa_1 = require("./aa");
13
+ const config_1 = require("./config");
14
+ const notifier_1 = require("./notifier");
15
+ const platform_1 = require("./platform");
16
+ const policy_1 = require("./policy");
17
+ const store_1 = require("./store");
18
+ const texts_1 = require("./texts");
19
+ const util_1 = require("./util");
20
+ const MAX_CHECK = 3000;
21
+ const PATROL_BUDGET_MS = 30 * 60_000;
22
+ const FLAG_TTL_MS = 30 * 60_000;
23
+ const APPROVED_TTL_MS = 10 * 60_000;
24
+ const AUDIT_KEEP_MS = 180 * 86400_000;
25
+ const REMIND_CHUNK = 20;
26
+ const REJECT_REASON_MAX = 200;
27
+ /** 移出前这么久之内必须成功 @ 提醒过这个人。 */
28
+ const REMIND_FRESH_MS = 36 * 3600_000;
29
+ /** 管理员确认后的豁免有效期。 */
30
+ const BYPASS_TTL_MS = 3600_000;
31
+ /** 确认时要求的巡检报告有多新。 */
32
+ const CONFIRM_REPORT_MAX_AGE_MS = 12 * 3600_000;
33
+ /** 每个群每轮最多改几张名片(刚上线时名片很多,分几轮改完,不占满巡检时间)。 */
34
+ const MAX_CARDS_PER_ROUND = 100;
35
+ class Guard {
36
+ ctx;
37
+ config;
38
+ options;
39
+ logger;
40
+ aa;
41
+ store;
42
+ platform;
43
+ notifier;
44
+ groups = [];
45
+ groupsLoaded = false;
46
+ paused = false;
47
+ /** 运维群号出现在受管群列表里时为 true(这时不发通知,见 R18)。 */
48
+ adminGroupConflict = false;
49
+ rosters = new Map();
50
+ lastRound = null;
51
+ patrolRunning = false;
52
+ nextPatrolAt = null;
53
+ /** start() 的执行结果(测试里用来等待启动完成)。 */
54
+ started = null;
55
+ life = new AbortController();
56
+ round = null;
57
+ patrolQueue = null;
58
+ eventsBusy = false;
59
+ remindBusy = false;
60
+ aaDown = false;
61
+ botProblem = null;
62
+ handledFlags = new Map();
63
+ approved = new Map();
64
+ timers = new Map();
65
+ lastPrune = 0;
66
+ constructor(ctx, config, options = {}) {
67
+ this.ctx = ctx;
68
+ this.config = config;
69
+ this.options = options;
70
+ this.logger = ctx.logger('aaqqbot');
71
+ this.store = new store_1.Store(ctx, () => this.now());
72
+ this.platform = new platform_1.Platform(ctx, () => this.config.botId);
73
+ this.aa = new aa_1.AaClient(ctx, {
74
+ baseUrl: config.aaBaseUrl,
75
+ keyId: config.keyId.trim(),
76
+ secret: config.secret,
77
+ timeoutMs: config.timeoutSeconds * 1000,
78
+ });
79
+ this.notifier = new notifier_1.Notifier(this.platform, this.logger, () => this.adminGroup(), () => this.now());
80
+ }
81
+ // ------------------------------------------------------------ 基础
82
+ now() {
83
+ return this.options.now?.() ?? Date.now();
84
+ }
85
+ get signal() {
86
+ return this.life.signal;
87
+ }
88
+ adminGroup() {
89
+ if (this.adminGroupConflict)
90
+ return null;
91
+ return (0, util_1.normalizeId)(this.config.adminGroupId);
92
+ }
93
+ operators() {
94
+ return new Set((0, util_1.normalizeIdList)(this.config.operators));
95
+ }
96
+ protectedIds() {
97
+ return new Set([...this.platform.allSelfIds(), ...(0, util_1.normalizeIdList)(this.config.whitelist)]);
98
+ }
99
+ /**
100
+ * 事件游标和受管群列表是「某一个 AA」的数据:换了 AA 网址(例如从测试 AA 换到正式 AA)后要重新开始,
101
+ * 否则会拿测试 AA 的游标去读正式 AA,漏掉一批变化。
102
+ */
103
+ get cursorKey() {
104
+ return `cursor:${this.aaIdentity()}`;
105
+ }
106
+ get groupsKey() {
107
+ return `groups:${this.aaIdentity()}`;
108
+ }
109
+ aaIdentity() {
110
+ return this.aa.endpoint('events').origin + this.aa.endpoint('events').pathname.replace(/qqbot\/api\/v1\/events\/$/, '');
111
+ }
112
+ bindUrl() {
113
+ const url = this.config.bindUrl?.trim();
114
+ if (url)
115
+ return url;
116
+ return `${this.config.aaBaseUrl.trim().replace(/\/+$/, '')}/services/`;
117
+ }
118
+ group(groupId) {
119
+ return this.groups.find((g) => g.groupId === groupId);
120
+ }
121
+ groupLabel(groupId) {
122
+ const g = this.group(groupId);
123
+ return g ? `${g.name}(${groupId})` : groupId;
124
+ }
125
+ desiredMode(groupId) {
126
+ for (const entry of this.config.groupModes ?? []) {
127
+ if ((0, util_1.normalizeId)(entry.groupId) === groupId && (0, store_1.isMode)(entry.mode))
128
+ return entry.mode;
129
+ }
130
+ return (0, store_1.isMode)(this.config.defaultMode) ? this.config.defaultMode : 'report';
131
+ }
132
+ modeInfo(groupId, state) {
133
+ const desired = this.desiredMode(groupId);
134
+ const confirmed = (0, store_1.isMode)(state.confirmedMode) ? state.confirmedMode : 'report';
135
+ const held = state.holdSince !== null;
136
+ if (config_1.MODE_RANK[desired] <= config_1.MODE_RANK.report || config_1.MODE_RANK[desired] <= config_1.MODE_RANK[confirmed]) {
137
+ return { desired, effective: desired, awaiting: false, held };
138
+ }
139
+ // 没确认的升级先不生效,继续按已确认的模式执行(至少是 report)
140
+ const effective = config_1.MODE_RANK[confirmed] >= config_1.MODE_RANK.report ? confirmed : 'report';
141
+ return { desired, effective, awaiting: true, held };
142
+ }
143
+ writeMode(info) {
144
+ return (info.effective === 'remind' || info.effective === 'enforce') && !info.held && !this.paused;
145
+ }
146
+ // ------------------------------------------------------------ 启动与定时
147
+ install() {
148
+ (0, store_1.extendModels)(this.ctx);
149
+ this.ctx.on('dispose', () => this.dispose());
150
+ this.ctx.on('ready', () => {
151
+ this.started = this.start().catch((error) => {
152
+ if (!this.signal.aborted)
153
+ this.logger.warn('启动出错:%s', error);
154
+ });
155
+ });
156
+ this.ctx.on('guild-member-request', (session) => this.safely('入群申请', () => this.onRequestSession(session)));
157
+ this.ctx.on('guild-member-added', (session) => this.safely('新成员入群', () => this.onMemberAdded(session)));
158
+ this.ctx.on('guild-member-removed', (session) => this.safely('成员退群', () => this.onMemberRemoved(session)));
159
+ this.ctx.on('bot-status-updated', (bot) => this.safely('机器人上线', () => this.onBotStatus(bot)));
160
+ }
161
+ dispose() {
162
+ this.life.abort();
163
+ this.round?.abort();
164
+ for (const cancel of this.timers.values())
165
+ cancel();
166
+ this.timers.clear();
167
+ this.notifier.dispose();
168
+ }
169
+ /** 包一层 try/catch:Koishi 里同步抛错会让整个进程退出(R20)。 */
170
+ safely(what, task) {
171
+ Promise.resolve()
172
+ .then(task)
173
+ .catch((error) => {
174
+ if (error instanceof util_1.AbortedError || this.signal.aborted)
175
+ return;
176
+ this.logger.warn('%s 出错:%s', what, error);
177
+ });
178
+ }
179
+ 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;
185
+ }
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);
196
+ if (this.options.timers !== false) {
197
+ this.schedule('patrol', 20_000, () => this.patrolTick());
198
+ this.schedule('events', this.config.eventPollSeconds * 1000, () => this.eventsTick());
199
+ this.schedule('groups', 3600_000, () => this.groupsTick());
200
+ this.scheduleReminder();
201
+ }
202
+ }
203
+ schedule(name, delay, task) {
204
+ this.timers.get(name)?.();
205
+ if (this.signal.aborted)
206
+ return;
207
+ if (name === 'patrol')
208
+ this.nextPatrolAt = this.now() + delay;
209
+ const cancel = this.ctx.setTimeout(() => {
210
+ this.timers.delete(name);
211
+ this.safely(name, task);
212
+ }, delay);
213
+ this.timers.set(name, cancel);
214
+ }
215
+ async patrolTick() {
216
+ const queue = this.patrolQueue;
217
+ this.patrolQueue = null;
218
+ const only = queue === 'all' || queue === null ? undefined : [...queue];
219
+ let result = 'done';
220
+ try {
221
+ result = await this.runPatrol(only);
222
+ }
223
+ finally {
224
+ // 巡检期间又有人要求巡检:马上再跑;机器人不在线或拿不到群列表:1 分钟后再试;否则等一个巡检周期
225
+ const delay = this.patrolQueue ? 2000
226
+ : result === 'no-bot' || result === 'no-groups' ? 60_000
227
+ : this.config.patrolIntervalHours * 3600_000;
228
+ this.schedule('patrol', delay, () => this.patrolTick());
229
+ }
230
+ }
231
+ async eventsTick() {
232
+ try {
233
+ await this.pollEvents();
234
+ }
235
+ finally {
236
+ this.schedule('events', this.config.eventPollSeconds * 1000, () => this.eventsTick());
237
+ }
238
+ }
239
+ async groupsTick() {
240
+ try {
241
+ await this.refreshGroups();
242
+ }
243
+ finally {
244
+ this.schedule('groups', 3600_000, () => this.groupsTick());
245
+ }
246
+ }
247
+ scheduleReminder() {
248
+ const clock = (0, util_1.parseClock)(this.config.remindTime) ?? { hour: 19, minute: 30 };
249
+ const delay = (0, util_1.nextClockTime)(this.now(), clock.hour, clock.minute) - this.now();
250
+ this.schedule('remind', delay, async () => {
251
+ try {
252
+ await this.runReminders();
253
+ }
254
+ finally {
255
+ this.scheduleReminder();
256
+ }
257
+ });
258
+ }
259
+ /** 尽快巡检(全部群或指定的群)。正在巡检时,结束后立刻再跑一轮。 */
260
+ requestPatrol(groupIds) {
261
+ if (!groupIds) {
262
+ this.patrolQueue = 'all';
263
+ }
264
+ else if (this.patrolQueue !== 'all') {
265
+ this.patrolQueue = new Set([...(this.patrolQueue ?? []), ...groupIds]);
266
+ }
267
+ if (this.options.timers !== false && !this.patrolRunning) {
268
+ this.schedule('patrol', 2000, () => this.patrolTick());
269
+ }
270
+ }
271
+ // ------------------------------------------------------------ AA 状态
272
+ noteAaFailure(result, context) {
273
+ if (result.kind === 'aborted')
274
+ return;
275
+ this.logger.warn('AA 请求失败(%s):%s', context, (0, aa_1.describeFailure)(result));
276
+ if (!this.aaDown) {
277
+ this.aaDown = true;
278
+ this.notifier.push(`⚠ AA 连接出问题(${context}):${(0, aa_1.describeFailure)(result)}\n恢复之前不会处置任何人。恢复后会通知。`);
279
+ }
280
+ }
281
+ noteAaOk() {
282
+ if (this.aaDown) {
283
+ this.aaDown = false;
284
+ this.notifier.push('✅ AA 已恢复连接。');
285
+ }
286
+ }
287
+ pickBot() {
288
+ const { bot, problem } = this.platform.pickBot();
289
+ if (!bot) {
290
+ if (this.botProblem !== problem) {
291
+ this.botProblem = problem;
292
+ this.logger.warn('机器人不可用:%s', problem);
293
+ }
294
+ return null;
295
+ }
296
+ if (this.botProblem) {
297
+ this.botProblem = null;
298
+ this.logger.info('机器人 %s 可用', bot.selfId);
299
+ }
300
+ return bot;
301
+ }
302
+ async checkHealth(notify) {
303
+ const result = await this.aa.health({ signal: this.signal, retryDelays: [] });
304
+ if (!result.ok) {
305
+ if (notify)
306
+ this.noteAaFailure(result, '健康检查');
307
+ return `❌ 连不上 AA:${(0, aa_1.describeFailure)(result)}`;
308
+ }
309
+ this.noteAaOk();
310
+ const lines = [`AA 插件版本 ${result.version},配置${result.configOk ? '正常' : '有问题'}`];
311
+ if (result.problems.length)
312
+ lines.push(`AA 自检发现的问题:${result.problems.join('、')}(含义见 aa-qqbot 的 API.md 5.1 节)`);
313
+ const skew = this.aa.clockSkewMs;
314
+ if (skew !== null && Math.abs(skew) > 60_000) {
315
+ lines.push(`⚠ 机器人电脑和 AA 服务器的时间相差 ${Math.round(skew / 1000)} 秒,超过 300 秒会导致请求被拒绝;请打开机器人电脑的自动对时`);
316
+ }
317
+ if (notify && (!result.configOk || result.problems.length || (skew !== null && Math.abs(skew) > 60_000))) {
318
+ this.notifier.push(`⚠ AA 健康检查:\n${lines.join('\n')}`);
319
+ }
320
+ return lines.join('\n');
321
+ }
322
+ async refreshGroups() {
323
+ const result = await this.aa.groups({ signal: this.signal });
324
+ if (!result.ok) {
325
+ this.noteAaFailure(result, '获取受管群列表');
326
+ return false;
327
+ }
328
+ this.noteAaOk();
329
+ const before = new Set(this.groups.map((g) => g.groupId));
330
+ this.groups = result.groups;
331
+ this.groupsLoaded = true;
332
+ await this.store.setKv(this.groupsKey, this.groups);
333
+ const now = new Set(this.groups.map((g) => g.groupId));
334
+ for (const groupId of this.rosters.keys()) {
335
+ if (!now.has(groupId))
336
+ this.rosters.delete(groupId);
337
+ }
338
+ const added = [...now].filter((id) => !before.has(id));
339
+ const removed = [...before].filter((id) => !now.has(id));
340
+ for (const groupId of removed)
341
+ await this.store.forgetGroup(groupId);
342
+ const admin = (0, util_1.normalizeId)(this.config.adminGroupId);
343
+ const conflict = !!admin && now.has(admin);
344
+ if (conflict && !this.adminGroupConflict) {
345
+ this.logger.error('运维群 %s 同时是受管群!运维群只能放管理人员,已停止发送运维通知。请在插件配置里换一个运维群。', admin);
346
+ }
347
+ this.adminGroupConflict = conflict;
348
+ if (before.size && (added.length || removed.length)) {
349
+ const parts = [];
350
+ if (added.length)
351
+ parts.push(`新增:${added.map((id) => this.groupLabel(id)).join('、')}`);
352
+ if (removed.length)
353
+ parts.push(`移除:${removed.join('、')}`);
354
+ this.notifier.push(`ℹ AA 上的受管群有变化。${parts.join(';')}`);
355
+ }
356
+ return true;
357
+ }
358
+ // ------------------------------------------------------------ 巡检
359
+ /**
360
+ * 巡检一轮。返回 'busy'(上一轮还没结束)、'paused'、'no-bot'、'no-groups' 或 'done'。
361
+ * 同一时间只有一轮(R11);暂停、停用、改配置会立即中止(R10)。
362
+ */
363
+ async runPatrol(only) {
364
+ if (this.patrolRunning) {
365
+ if (only)
366
+ this.requestPatrol(only);
367
+ else
368
+ this.requestPatrol();
369
+ return 'busy';
370
+ }
371
+ if (this.paused)
372
+ return 'paused';
373
+ this.patrolRunning = true;
374
+ const round = new AbortController();
375
+ this.round = round;
376
+ const onLifeAbort = () => round.abort();
377
+ this.signal.addEventListener('abort', onLifeAbort);
378
+ const budget = setTimeout(() => round.abort(), PATROL_BUDGET_MS);
379
+ const started = this.now();
380
+ try {
381
+ const bot = this.pickBot();
382
+ if (!bot) {
383
+ this.notifyBotProblemOnce();
384
+ return 'no-bot';
385
+ }
386
+ if (!this.groupsLoaded)
387
+ await this.refreshGroups();
388
+ if (!this.groupsLoaded)
389
+ return 'no-groups';
390
+ const targets = this.groups.filter((g) => !only || only.includes(g.groupId));
391
+ const sections = [];
392
+ let ok = true;
393
+ for (const [index, g] of targets.entries()) {
394
+ (0, util_1.throwIfAborted)(round.signal);
395
+ if (index > 0)
396
+ await (0, util_1.sleep)(this.options.groupDelayMs ?? 5000, round.signal);
397
+ const section = await this.patrolGroup(bot, g, round.signal);
398
+ if (section.text)
399
+ sections.push(section.text);
400
+ ok &&= section.ok;
401
+ }
402
+ const seconds = Math.round((this.now() - started) / 1000);
403
+ const header = `【AA 巡检】${(0, util_1.formatShortTime)(started)} ${only ? '(指定的群)' : ''}完成,用时 ${seconds} 秒`;
404
+ const text = sections.length ? `${header}\n${sections.join('\n\n')}` : `${header}\n没有需要巡检的群(都是 off 或 AA 上没有受管群)`;
405
+ this.lastRound = { at: started, ok, text };
406
+ this.notifier.push(text);
407
+ await this.pruneAudit();
408
+ return 'done';
409
+ }
410
+ catch (error) {
411
+ if (error instanceof util_1.AbortedError || round.signal.aborted) {
412
+ const why = this.paused ? '已暂停' : this.signal.aborted ? '插件已停用或配置已修改' : '超过 30 分钟时限';
413
+ this.logger.info('巡检中止:%s', why);
414
+ if (!this.signal.aborted)
415
+ this.notifier.push(`⏹ 巡检已中止(${why})。`);
416
+ this.lastRound = { at: started, ok: false, text: `巡检中止(${why})` };
417
+ return 'done';
418
+ }
419
+ throw error;
420
+ }
421
+ finally {
422
+ clearTimeout(budget);
423
+ this.signal.removeEventListener('abort', onLifeAbort);
424
+ this.patrolRunning = false;
425
+ if (this.round === round)
426
+ this.round = null;
427
+ }
428
+ }
429
+ notifyBotProblemOnce() {
430
+ const key = `bot:${this.botProblem}`;
431
+ if (this.handledFlags.has(key))
432
+ return;
433
+ this.handledFlags.set(key, this.now());
434
+ this.logger.warn('巡检跳过:%s', this.botProblem);
435
+ }
436
+ async patrolGroup(bot, g, signal) {
437
+ const label = this.groupLabel(g.groupId);
438
+ let state = await this.store.groupState(g.groupId);
439
+ const info = this.modeInfo(g.groupId, state);
440
+ // 降级立即生效:确认过的模式跟着降下来,以后再升级要重新确认
441
+ if (config_1.MODE_RANK[info.desired] < config_1.MODE_RANK[state.confirmedMode] && config_1.MODE_RANK[state.confirmedMode] > config_1.MODE_RANK.report) {
442
+ const confirmedMode = config_1.MODE_RANK[info.desired] >= config_1.MODE_RANK.report ? info.desired : 'report';
443
+ await this.store.setGroupState(g.groupId, { confirmedMode });
444
+ state = { ...state, confirmedMode };
445
+ }
446
+ if (info.effective === 'off') {
447
+ await this.cleanupOffGroup(bot, g.groupId, signal);
448
+ return { ok: true, text: '' };
449
+ }
450
+ const head = `▶ ${label} ${texts_1.MODE_TEXT[info.effective]}${info.awaiting ? `\n⚠ 设为了 ${info.desired},还没确认,暂时按 ${info.effective} 执行。看完下面的报告确认无误后,发送:aaqq.confirm ${g.groupId}` : ''}${info.held ? `\n⛔ 熔断中(${state.holdNote || '新增不合格人数过多'}),不做任何处置。核实后发送:aaqq.confirm ${g.groupId}` : ''}`;
451
+ let members;
452
+ try {
453
+ members = await this.platform.listMembers(bot, g.groupId);
454
+ }
455
+ catch (error) {
456
+ await this.store.setGroupState(g.groupId, { lastPatrolOk: false, lastPatrolNote: '取群成员失败' });
457
+ return { ok: false, text: `${head}\n❌ 取群成员名单失败(机器人可能不在这个群里):${(0, util_1.errorText)(error)}` };
458
+ }
459
+ (0, util_1.throwIfAborted)(signal);
460
+ this.rosters.set(g.groupId, new Map(members.map((m) => [m.qq, m])));
461
+ const botRole = members.find((m) => m.qq === bot.selfId)?.role ?? null;
462
+ if (!botRole) {
463
+ await this.store.setGroupState(g.groupId, { lastPatrolOk: false, lastPatrolNote: '机器人不在群里' });
464
+ return { ok: false, text: `${head}\n❌ 机器人不在这个群里` };
465
+ }
466
+ const qqs = members.map((m) => m.qq);
467
+ const verdicts = new Map();
468
+ const fullRoster = qqs.length <= MAX_CHECK;
469
+ for (const part of (0, util_1.chunk)(qqs, MAX_CHECK)) {
470
+ const result = await this.aa.check(g.groupId, part, fullRoster, { signal, retryDelays: this.options.retryDelays });
471
+ if (!result.ok) {
472
+ if (result.kind === 'aborted')
473
+ throw new util_1.AbortedError();
474
+ this.noteAaFailure(result, `巡检 ${label}`);
475
+ if (result.error === 'unknown_group')
476
+ await this.refreshGroups();
477
+ await this.store.setGroupState(g.groupId, { lastPatrolOk: false, lastPatrolNote: 'AA 无法判断' });
478
+ return { ok: false, text: `${head}\n❌ AA 无法判断,本群不做任何处置:${(0, aa_1.describeFailure)(result)}` };
479
+ }
480
+ for (const [qq, verdict] of result.verdicts)
481
+ verdicts.set(qq, verdict);
482
+ }
483
+ this.noteAaOk();
484
+ (0, util_1.throwIfAborted)(signal);
485
+ if (this.paused)
486
+ throw new util_1.AbortedError();
487
+ const now = this.now();
488
+ const tracked = await this.store.tracked(g.groupId);
489
+ const kicksLastHour = await this.store.countAudit('kick', g.groupId, new Date(now - 3600_000));
490
+ const plan = (0, policy_1.planGroup)({
491
+ groupId: g.groupId,
492
+ mode: info.effective,
493
+ held: info.held,
494
+ bypass: this.bypassFor(state, now),
495
+ kickApprovedBefore: state.lastConfirmAt?.getTime() ?? 0,
496
+ partial: false,
497
+ groupSize: members.length,
498
+ members,
499
+ verdicts,
500
+ tracked,
501
+ protectedIds: this.protectedIds(),
502
+ botRole,
503
+ now,
504
+ settings: this.planSettings(Math.max(0, this.config.kickPerHour - kicksLastHour), true),
505
+ });
506
+ // 豁免只用一次:这一轮已经做出了判断,不管结果如何都清掉
507
+ const patch = {
508
+ lastPatrolAt: new Date(now),
509
+ lastPatrolOk: true,
510
+ bypassUntil: null,
511
+ lastNewDenies: plan.newDenies.length,
512
+ lastKicksDue: plan.kicksDue,
513
+ };
514
+ if (plan.tripped) {
515
+ patch.holdSince = new Date(now);
516
+ patch.holdNote = plan.tripReason;
517
+ await this.store.audit('hold', g.groupId, '', plan.tripReason);
518
+ }
519
+ const applied = await this.applyPlan(bot, g.groupId, plan, signal);
520
+ patch.lastPatrolNote = `成员 ${members.length},不合格 ${plan.counts.deny}`;
521
+ await this.store.setGroupState(g.groupId, patch);
522
+ const lines = [head, ...this.describePlan(plan, applied, members, info)];
523
+ if (!fullRoster)
524
+ lines.push(`⚠ 群人数超过 ${MAX_CHECK},名单分批提交,AA 上「老成员免验证」对这个群不生效`);
525
+ if (plan.tripped) {
526
+ this.notifier.push(`⛔ 熔断:${label} ${plan.tripReason}。\n可能是 AA 配置被改错了。这个群已停止一切处置(不提醒、不改名片、不移出、不拒绝申请),直到管理员确认。\n请先核对 AA 上的设置和下面的名单,确认无误后发送:aaqq.confirm ${g.groupId}`);
527
+ }
528
+ return { ok: true, text: lines.join('\n') };
529
+ }
530
+ /** 管理员确认后 1 小时内有效的豁免。 */
531
+ bypassFor(state, now) {
532
+ if (!state.bypassUntil || state.bypassUntil.getTime() <= now)
533
+ return null;
534
+ return { maxNew: state.bypassMaxNew, maxKicks: state.bypassMaxKicks };
535
+ }
536
+ /** 群改成 off 时,撤掉以前加的标记、清空宽限记录,之后就不再管这个群。 */
537
+ async cleanupOffGroup(bot, groupId, signal) {
538
+ const tracked = await this.store.tracked(groupId);
539
+ if (!tracked.size)
540
+ return;
541
+ let members;
542
+ try {
543
+ members = await this.platform.listMembers(bot, groupId);
544
+ }
545
+ catch {
546
+ return;
547
+ }
548
+ const plan = (0, policy_1.planGroup)({
549
+ groupId,
550
+ mode: 'report',
551
+ held: false,
552
+ bypass: null,
553
+ kickApprovedBefore: 0,
554
+ partial: false,
555
+ groupSize: members.length,
556
+ members,
557
+ verdicts: new Map(),
558
+ tracked,
559
+ protectedIds: this.protectedIds(),
560
+ botRole: members.find((m) => m.qq === bot.selfId)?.role ?? null,
561
+ now: this.now(),
562
+ settings: this.planSettings(0, false),
563
+ });
564
+ await this.applyPlan(bot, groupId, plan, signal);
565
+ }
566
+ planSettings(kickBudget, allowKicks) {
567
+ return {
568
+ remindFreshMs: REMIND_FRESH_MS,
569
+ breakerCount: this.config.breakerCount,
570
+ breakerPercent: this.config.breakerPercent,
571
+ kickBudget,
572
+ syncCards: this.config.syncCards,
573
+ markCards: this.config.markCards,
574
+ markPrefix: this.config.markPrefix ?? '',
575
+ allowKicks,
576
+ };
577
+ }
578
+ describePlan(plan, applied, members, info) {
579
+ const byQq = new Map(members.map((m) => [m.qq, m]));
580
+ const who = (qq) => {
581
+ const m = byQq.get(qq);
582
+ const name = m ? (m.card || m.nickname) : '';
583
+ return name ? `${name}(${qq})` : qq;
584
+ };
585
+ const list = (items, max = 10) => {
586
+ const shown = items.slice(0, max).map((x) => `${who(x.qq)} ${(0, texts_1.reasonShort)(x.reason)}`);
587
+ if (items.length > max)
588
+ shown.push(`等共 ${items.length} 人`);
589
+ return shown.join(';');
590
+ };
591
+ const c = plan.counts;
592
+ const newCount = plan.newDenies.length;
593
+ const lines = [`成员 ${c.members}:合格 ${c.allow}|不合格 ${c.deny}${newCount ? `(新发现 ${newCount})` : ''}|需人工 ${c.review}|无法判断 ${c.unknown}`];
594
+ const actions = [];
595
+ if (applied.kicked.length)
596
+ actions.push(`移出 ${applied.kicked.length} 人:${applied.kicked.map((k) => `${k.name}(${k.qq})`).join('、')}`);
597
+ if (plan.kicksDeferred)
598
+ actions.push(`${plan.kicksDeferred} 人因每小时上限推迟到下一轮`);
599
+ if (applied.kickFailed)
600
+ actions.push(`移出失败 ${applied.kickFailed} 人`);
601
+ if (applied.kickSkipped)
602
+ actions.push(`复核后跳过 ${applied.kickSkipped} 人`);
603
+ if (applied.synced)
604
+ actions.push(`同步名片 ${applied.synced} 人`);
605
+ if (applied.marked)
606
+ actions.push(`加标记 ${applied.marked} 人`);
607
+ if (applied.unmarked)
608
+ actions.push(`去标记 ${applied.unmarked} 人`);
609
+ if (applied.cardsFailed)
610
+ actions.push(`改名片失败 ${applied.cardsFailed} 次`);
611
+ if (applied.cardsDeferred)
612
+ actions.push(`${applied.cardsDeferred} 张名片留到下一轮再改`);
613
+ if (plan.unknownHeavy)
614
+ actions.push('无法判断的人太多,本轮不做任何改动');
615
+ if (plan.cardsPending && !plan.writes)
616
+ actions.push(`${plan.cardsPending} 人的名片与 AA 不一致(${info.effective === 'report' ? 'report 模式不修改' : '本轮不修改'})`);
617
+ if (actions.length)
618
+ lines.push(actions.join(';'));
619
+ if (plan.newDenies.length)
620
+ lines.push(`新发现不合格:${list(plan.newDenies)}`);
621
+ const old = plan.denies.filter((d) => !d.isNew);
622
+ if (old.length)
623
+ lines.push(`仍不合格:${list(old)}`);
624
+ if (plan.reviews.length)
625
+ lines.push(`需人工处理(在 AA「待处理」里处理):${list(plan.reviews)}`);
626
+ const selfIds = this.platform.allSelfIds();
627
+ const protectedDenies = plan.protectedDenies.filter((d) => !selfIds.has(d.qq)); // 机器人自己没绑定是正常的,不报告
628
+ if (protectedDenies.length)
629
+ lines.push(`不合格但受保护(群主/管理员/白名单,不处置):${list(protectedDenies)}`);
630
+ if (plan.unknowns.length)
631
+ lines.push(`无法判断:${plan.unknowns.slice(0, 10).map(who).join('、')}`);
632
+ return lines;
633
+ }
634
+ /**
635
+ * 执行规划。先移出、再改名片。每一次改动前都重新检查:没有中止、没有暂停、没有熔断;
636
+ * 移出前再问一次 AA、重读宽限记录、实时查询成员身份(R7)。通知失败不影响执行结果(R18)。
637
+ */
638
+ async applyPlan(bot, groupId, plan, signal) {
639
+ const result = { cardsOk: 0, cardsFailed: 0, cardsDeferred: 0, marked: 0, unmarked: 0, synced: 0, kicked: [], kickFailed: 0, kickSkipped: 0 };
640
+ await this.store.removeTracked(groupId, plan.untrack);
641
+ await this.store.saveTracked(plan.track);
642
+ const roster = this.rosters.get(groupId);
643
+ if (plan.kicks.length)
644
+ await this.applyKicks(bot, groupId, plan, signal, result);
645
+ const cards = plan.cards.slice(0, MAX_CARDS_PER_ROUND);
646
+ result.cardsDeferred = plan.cards.length - cards.length;
647
+ for (const change of cards) {
648
+ if (!(await this.stillWritable(groupId, signal)))
649
+ break;
650
+ try {
651
+ await this.platform.setCard(bot, groupId, change.qq, change.to);
652
+ result.cardsOk++;
653
+ if (change.why === 'mark')
654
+ result.marked++;
655
+ else if (change.why === 'unmark')
656
+ result.unmarked++;
657
+ else
658
+ result.synced++;
659
+ const member = roster?.get(change.qq);
660
+ if (member)
661
+ member.card = change.to;
662
+ }
663
+ catch (error) {
664
+ result.cardsFailed++;
665
+ this.logger.warn('改名片失败 群 %s 成员 %s:%s', groupId, (0, util_1.maskId)(change.qq), (0, util_1.errorText)(error));
666
+ }
667
+ await this.pause(this.options.cardDelayMs ?? 1500, signal);
668
+ }
669
+ return result;
670
+ }
671
+ /** 还能不能继续改动这个群:没有中止、没有暂停、没有进入熔断。 */
672
+ async stillWritable(groupId, signal) {
673
+ if (signal.aborted || this.paused)
674
+ return false;
675
+ const state = await this.store.groupState(groupId);
676
+ return state.holdSince === null;
677
+ }
678
+ async applyKicks(bot, groupId, plan, signal, result) {
679
+ // 移出前确认机器人自己仍是管理员
680
+ const self = await this.platform.getMember(bot, groupId, bot.selfId);
681
+ if (!self || !(0, policy_1.botCanWrite)(self.role))
682
+ return;
683
+ // 再问一次 AA:巡检开始后刚绑定好的人不能被移出
684
+ const targets = plan.kicks.map((k) => k.qq);
685
+ const fresh = await this.aa.check(groupId, targets, false, { signal, retryDelays: [] });
686
+ if (!fresh.ok) {
687
+ if (fresh.kind !== 'aborted')
688
+ this.noteAaFailure(fresh, `移出前复核 ${this.groupLabel(groupId)}`);
689
+ return;
690
+ }
691
+ for (const kick of plan.kicks) {
692
+ if (!(await this.stillWritable(groupId, signal)))
693
+ break;
694
+ const state = await this.store.groupState(groupId);
695
+ const info = this.modeInfo(groupId, state);
696
+ if (info.effective !== 'enforce')
697
+ break;
698
+ // 宽限记录还在、截止时间确实已到、最近提醒过(事件复查可能刚把他取消了)
699
+ const record = (await this.store.tracked(groupId)).get(kick.qq);
700
+ const now = this.now();
701
+ const deadline = record?.graceUntil?.getTime();
702
+ const remindedAt = record?.lastRemindedAt?.getTime();
703
+ const verdict = fresh.verdicts.get(kick.qq);
704
+ if (!record || deadline === undefined || deadline > now || remindedAt === undefined || now - remindedAt > REMIND_FRESH_MS
705
+ || verdict?.decision !== 'deny') {
706
+ result.kickSkipped++;
707
+ continue;
708
+ }
709
+ // 实时复核:还在群里、是普通成员、不是机器人、不在保护名单
710
+ const live = await this.platform.getMember(bot, groupId, kick.qq);
711
+ if (!live || (0, policy_1.isProtected)(live, this.protectedIds())) {
712
+ result.kickSkipped++;
713
+ continue;
714
+ }
715
+ try {
716
+ await this.platform.kick(bot, groupId, kick.qq);
717
+ result.kicked.push({ qq: kick.qq, name: kick.name });
718
+ this.rosters.get(groupId)?.delete(kick.qq);
719
+ await this.store.removeTracked(groupId, [kick.qq]);
720
+ await this.store.audit('kick', groupId, kick.qq, (0, texts_1.reasonShort)(verdict.reason));
721
+ }
722
+ catch (error) {
723
+ result.kickFailed++;
724
+ this.logger.warn('移出失败 群 %s 成员 %s:%s', groupId, (0, util_1.maskId)(kick.qq), (0, util_1.errorText)(error));
725
+ }
726
+ const base = this.options.kickDelayMs ?? 3000;
727
+ await this.pause(base + Math.random() * base, signal);
728
+ }
729
+ if (result.kicked.length && this.config.kickAnnounce) {
730
+ const list = result.kicked.map((k) => k.name).join('、');
731
+ const text = (0, util_1.fillTemplate)(this.config.kickAnnounceTemplate, { list, url: this.bindUrl() });
732
+ try {
733
+ await this.platform.sendGroup(bot, groupId, koishi_1.h.text(text));
734
+ }
735
+ catch (error) {
736
+ this.logger.warn('发送移出公告失败 群 %s:%s', groupId, (0, util_1.errorText)(error));
737
+ }
738
+ }
739
+ }
740
+ async pause(ms, signal) {
741
+ if (ms > 0)
742
+ await (0, util_1.sleep)(ms, signal).catch(() => { });
743
+ }
744
+ // ------------------------------------------------------------ 入群申请
745
+ async onRequestSession(session) {
746
+ if (session.platform !== 'onebot')
747
+ return;
748
+ const bot = this.pickBot();
749
+ if (!bot || session.selfId !== bot.selfId)
750
+ return; // 多个机器人时只让选定的那个处理(D37)
751
+ const raw = session.onebot ?? {};
752
+ const groupId = (0, util_1.normalizeId)(session.guildId);
753
+ const qq = (0, util_1.normalizeId)(session.userId);
754
+ const flag = session.messageId;
755
+ if (!groupId || !qq || !flag)
756
+ return;
757
+ await this.handleJoinRequest(bot, {
758
+ flag,
759
+ groupId,
760
+ qq,
761
+ comment: typeof raw.comment === 'string' ? raw.comment : (session.content ?? ''),
762
+ invitorId: (0, util_1.normalizeId)(raw.invitor_id),
763
+ });
764
+ }
765
+ async handleJoinRequest(bot, req, source = '入群申请', catchUp = false) {
766
+ this.pruneMaps();
767
+ const g = this.group(req.groupId);
768
+ if (!g)
769
+ return; // 不在受管群列表里的群一律不管(R6)
770
+ if (this.handledFlags.has(req.flag))
771
+ return;
772
+ // 补处理拿到的编号和实时事件的不一样;同一个人刚处理过就跳过
773
+ const personKey = `${g.groupId}:${req.qq}`;
774
+ if (catchUp && this.handledFlags.has(personKey))
775
+ return;
776
+ this.handledFlags.set(req.flag, this.now());
777
+ this.handledFlags.set(personKey, this.now());
778
+ const state = await this.store.groupState(g.groupId);
779
+ const info = this.modeInfo(g.groupId, state);
780
+ if (info.effective === 'off')
781
+ return;
782
+ const label = this.groupLabel(g.groupId);
783
+ const who = `${req.qq}${req.invitorId ? `(由 ${req.invitorId} 邀请)` : ''}`;
784
+ if (this.paused) {
785
+ this.notifier.push(`📥 ${source}:${who} 申请加入 ${label}。插件暂停中,留给管理员处理。`);
786
+ return;
787
+ }
788
+ if (req.invitorId && this.config.inviteHandling === 'manual') {
789
+ this.notifier.push(`📥 ${source}:${who} 被邀请加入 ${label}。按设置,邀请入群留给管理员处理。`);
790
+ return;
791
+ }
792
+ const result = await this.aa.claim(req.qq, req.comment, g.groupId, { signal: this.signal, retryDelays: [2000] });
793
+ if (!result.ok) {
794
+ if (result.kind === 'aborted')
795
+ return;
796
+ this.noteAaFailure(result, `入群申请 ${label}`);
797
+ if (result.error === 'unknown_group')
798
+ await this.refreshGroups();
799
+ this.notifier.push(`📥 ${source}:${who} 申请加入 ${label}。AA 无法判断(${(0, aa_1.describeFailure)(result)}),留给管理员处理。`);
800
+ return;
801
+ }
802
+ this.noteAaOk();
803
+ const verdict = result.verdict;
804
+ const outcomeText = result.claimed ? '验证码验证成功' : outcomeLabel(result.outcome);
805
+ // 问 AA 的这段时间里可能暂停了或进入了熔断:重新读一次
806
+ if (this.paused || this.signal.aborted) {
807
+ this.notifier.push(`📥 ${source}:${who} 申请加入 ${label}。插件暂停中,留给管理员处理。`);
808
+ return;
809
+ }
810
+ const nowInfo = this.modeInfo(g.groupId, await this.store.groupState(g.groupId));
811
+ if (verdict.decision === 'allow') {
812
+ try {
813
+ await this.platform.handleJoinRequest(bot, req.flag, true);
814
+ this.approved.set(`${g.groupId}:${req.qq}`, { card: verdict.card, at: this.now() });
815
+ await this.store.audit('approve', g.groupId, req.qq, outcomeText);
816
+ this.notifier.push(`✅ ${source}:已同意 ${who} 加入 ${label}(${outcomeText})。`);
817
+ }
818
+ catch (error) {
819
+ this.notifier.push(`⚠ ${source}:${who} 申请加入 ${label},AA 判定合格,但同意时出错(可能已被管理员处理):${(0, util_1.errorText)(error)}`);
820
+ }
821
+ return;
822
+ }
823
+ if (verdict.decision === 'deny') {
824
+ const reasonText = (0, texts_1.reasonShort)(verdict.reason);
825
+ const protectedQq = this.protectedIds().has(req.qq);
826
+ if (this.writeMode(nowInfo) && this.config.autoReject && !protectedQq && !catchUp) {
827
+ const reason = (0, util_1.fillTemplate)(this.config.rejectTemplate, { hint: (0, texts_1.rejectHint)(result.outcome, verdict.reason), url: this.bindUrl() }).slice(0, REJECT_REASON_MAX);
828
+ try {
829
+ await this.platform.handleJoinRequest(bot, req.flag, false, reason);
830
+ await this.store.audit('reject', g.groupId, req.qq, `${reasonText};${outcomeText}`);
831
+ this.notifier.push(`🚫 ${source}:已拒绝 ${who} 加入 ${label}(${reasonText};${outcomeText})。`);
832
+ }
833
+ catch (error) {
834
+ this.notifier.push(`⚠ ${source}:${who} 申请加入 ${label},AA 判定不合格,但拒绝时出错(可能已被管理员处理):${(0, util_1.errorText)(error)}`);
835
+ }
836
+ return;
837
+ }
838
+ const why = protectedQq ? '这个 QQ 在白名单里'
839
+ : catchUp ? '补处理的申请不自动拒绝(可能是邀请入群)'
840
+ : nowInfo.held ? '这个群熔断中'
841
+ : this.writeMode(nowInfo) ? '自动拒绝已关闭'
842
+ : `群模式是 ${nowInfo.effective}${nowInfo.awaiting ? '(升级还没确认)' : ''}`;
843
+ this.notifier.push(`📥 ${source}:${who} 申请加入 ${label},AA 判定不合格(${reasonText};${outcomeText})。${why},留给管理员处理。`);
844
+ return;
845
+ }
846
+ const why = verdict.decision === 'review' ? `需要人工处理(${(0, texts_1.reasonShort)(verdict.reason)})` : 'AA 的结果无法识别';
847
+ this.notifier.push(`📥 ${source}:${who} 申请加入 ${label},${why},留给管理员处理。`);
848
+ }
849
+ /** 机器人重新上线:补处理掉线期间积压的入群申请(DECISIONS 第 13 条)。 */
850
+ async onBotStatus(changed) {
851
+ if (changed.platform !== 'onebot' || changed.status !== 1 /* Universal.Status.ONLINE */)
852
+ return;
853
+ const bot = this.pickBot();
854
+ if (!bot || bot.selfId !== changed.selfId)
855
+ return;
856
+ if (this.config.catchUpRequests)
857
+ await this.catchUpRequests(bot);
858
+ if (!this.lastRound)
859
+ this.requestPatrol();
860
+ }
861
+ async catchUpRequests(bot) {
862
+ if (!this.groupsLoaded || this.paused)
863
+ return;
864
+ let pending;
865
+ try {
866
+ pending = await this.platform.pendingJoinRequests(bot);
867
+ }
868
+ catch (error) {
869
+ this.logger.warn('补拉入群申请失败(LLBot 可能不支持 get_group_system_msg):%s', (0, util_1.errorText)(error));
870
+ return;
871
+ }
872
+ for (const req of pending) {
873
+ if (this.signal.aborted)
874
+ return;
875
+ await this.handleJoinRequest(bot, req, '补处理的入群申请', true);
876
+ await (0, util_1.sleep)(1000, this.signal);
877
+ }
878
+ }
879
+ // ------------------------------------------------------------ 新人入群、退群
880
+ async onMemberAdded(session) {
881
+ if (session.platform !== 'onebot')
882
+ return;
883
+ const bot = this.pickBot();
884
+ if (!bot || session.selfId !== bot.selfId)
885
+ return;
886
+ const groupId = (0, util_1.normalizeId)(session.guildId);
887
+ const qq = (0, util_1.normalizeId)(session.userId);
888
+ if (!groupId || !qq || qq === bot.selfId)
889
+ return;
890
+ await this.handleNewMember(bot, groupId, qq);
891
+ }
892
+ async handleNewMember(bot, groupId, qq) {
893
+ const g = this.group(groupId);
894
+ if (!g || this.paused)
895
+ return;
896
+ const state = await this.store.groupState(groupId);
897
+ const info = this.modeInfo(groupId, state);
898
+ if (info.effective === 'off')
899
+ return;
900
+ const label = this.groupLabel(groupId);
901
+ const key = `${groupId}:${qq}`;
902
+ const approved = this.approved.get(key);
903
+ this.approved.delete(key);
904
+ let member = await this.platform.getMember(bot, groupId, qq);
905
+ if (!member) {
906
+ await (0, util_1.sleep)(3000, this.signal);
907
+ member = await this.platform.getMember(bot, groupId, qq);
908
+ }
909
+ if (!member)
910
+ return;
911
+ const roster = this.rosters.get(groupId);
912
+ roster?.set(qq, member);
913
+ const self = await this.platform.getMember(bot, groupId, bot.selfId);
914
+ const botRole = self?.role ?? null;
915
+ if (approved) {
916
+ // 刚由机器人同意的申请:AA 已经判过 allow,只需要设名片
917
+ if (this.writeMode(info) && this.config.syncCards && approved.card && member.card !== approved.card
918
+ && (0, policy_1.canEditCard)(botRole, member) && !(0, policy_1.isProtected)(member, this.protectedIds())) {
919
+ try {
920
+ await this.platform.setCard(bot, groupId, qq, approved.card);
921
+ member.card = approved.card;
922
+ }
923
+ catch (error) {
924
+ this.logger.warn('给新成员设名片失败:%s', (0, util_1.errorText)(error));
925
+ }
926
+ }
927
+ return;
928
+ }
929
+ const result = await this.aa.check(groupId, [qq], false, { signal: this.signal, retryDelays: this.options.retryDelays });
930
+ if (!result.ok) {
931
+ if (result.kind !== 'aborted')
932
+ this.noteAaFailure(result, `新成员 ${label}`);
933
+ return;
934
+ }
935
+ this.noteAaOk();
936
+ const plan = (0, policy_1.planGroup)({
937
+ groupId,
938
+ mode: info.effective,
939
+ held: info.held,
940
+ bypass: null,
941
+ kickApprovedBefore: 0,
942
+ partial: true,
943
+ groupSize: roster?.size ?? 1,
944
+ members: [member],
945
+ verdicts: result.verdicts,
946
+ tracked: await this.store.tracked(groupId),
947
+ protectedIds: this.protectedIds(),
948
+ botRole,
949
+ now: this.now(),
950
+ settings: this.planSettings(0, false),
951
+ });
952
+ await this.applyPlan(bot, groupId, plan, this.signal);
953
+ const verdict = result.verdicts.get(qq);
954
+ const name = member.card || member.nickname;
955
+ if (verdict.decision === 'allow') {
956
+ this.notifier.push(`👋 ${label} 新成员 ${name}(${qq}):合格。`);
957
+ return;
958
+ }
959
+ if (plan.writes && plan.newDenies.length) {
960
+ await this.sendReminder(bot, groupId, info.effective, plan.track.filter((t) => t.qq === qq));
961
+ }
962
+ const action = plan.writes ? '已开始宽限并提醒' : `群模式 ${info.effective},只报告`;
963
+ this.notifier.push(`👋 ${label} 新成员 ${name}(${qq}):${verdict.decision === 'deny' ? `不合格(${(0, texts_1.reasonShort)(verdict.reason)}),${action}` : verdict.decision === 'review' ? `需人工处理(${(0, texts_1.reasonShort)(verdict.reason)})` : 'AA 无法判断'}。`);
964
+ }
965
+ async onMemberRemoved(session) {
966
+ if (session.platform !== 'onebot')
967
+ return;
968
+ const groupId = (0, util_1.normalizeId)(session.guildId);
969
+ const qq = (0, util_1.normalizeId)(session.userId);
970
+ if (!groupId || !qq || !this.group(groupId))
971
+ return;
972
+ this.rosters.get(groupId)?.delete(qq);
973
+ await this.store.removeTracked(groupId, [qq]);
974
+ }
975
+ // ------------------------------------------------------------ 事件轮询(API.md 5.5、第 8 节)
976
+ async pollEvents() {
977
+ if (this.eventsBusy || this.paused || this.patrolRunning)
978
+ return;
979
+ this.eventsBusy = true;
980
+ try {
981
+ const cursor = await this.store.getKv(this.cursorKey);
982
+ if (typeof cursor !== 'number') {
983
+ await this.initCursor();
984
+ return;
985
+ }
986
+ const qqs = new Set();
987
+ let groupsChanged = false;
988
+ let recheckAll = false;
989
+ let last = cursor;
990
+ for (let page = 0; page < 20; page++) {
991
+ const result = await this.aa.events(last, 200, { signal: this.signal, retryDelays: [] });
992
+ if (!result.ok) {
993
+ this.noteAaFailure(result, '拉取变化');
994
+ return;
995
+ }
996
+ this.noteAaOk();
997
+ for (const event of result.events) {
998
+ if ((event.kind === 'recheck' || event.kind === 'card') && event.qq)
999
+ qqs.add(event.qq);
1000
+ else if (event.kind === 'recheck_all')
1001
+ recheckAll = true;
1002
+ else if (event.kind === 'groups')
1003
+ groupsChanged = true;
1004
+ }
1005
+ last = result.lastId;
1006
+ if (!result.hasMore)
1007
+ break;
1008
+ }
1009
+ if (groupsChanged)
1010
+ await this.refreshGroups();
1011
+ if (recheckAll) {
1012
+ this.requestPatrol();
1013
+ }
1014
+ else if (qqs.size) {
1015
+ const ok = await this.recheck([...qqs]);
1016
+ if (!ok)
1017
+ return; // 先处理、后保存:没处理完就不前进,下次重来
1018
+ }
1019
+ if (last !== cursor)
1020
+ await this.store.setKv(this.cursorKey, last);
1021
+ }
1022
+ finally {
1023
+ this.eventsBusy = false;
1024
+ }
1025
+ }
1026
+ /** 游标丢失(第一次运行):把已有事件拉完只记游标,然后做一次完整巡检。 */
1027
+ async initCursor() {
1028
+ let last = 0;
1029
+ for (let page = 0; page < 1000; page++) {
1030
+ const result = await this.aa.events(last, 500, { signal: this.signal, retryDelays: [] });
1031
+ if (!result.ok) {
1032
+ this.noteAaFailure(result, '初始化事件游标');
1033
+ return;
1034
+ }
1035
+ last = result.lastId;
1036
+ if (!result.hasMore)
1037
+ break;
1038
+ }
1039
+ await this.store.setKv(this.cursorKey, last);
1040
+ this.requestPatrol();
1041
+ }
1042
+ /** 按群合并复查一批 QQ(每个群只发一次 check)。返回是否全部处理完。 */
1043
+ async recheck(qqs) {
1044
+ const bot = this.pickBot();
1045
+ if (!bot)
1046
+ return false;
1047
+ const lines = [];
1048
+ for (const g of this.groups) {
1049
+ const state = await this.store.groupState(g.groupId);
1050
+ const info = this.modeInfo(g.groupId, state);
1051
+ if (info.effective === 'off')
1052
+ continue;
1053
+ // 每次都取最新的成员名单(身份可能变了:刚被设为管理员的人不能被加标记)
1054
+ let roster;
1055
+ try {
1056
+ const members = await this.platform.listMembers(bot, g.groupId);
1057
+ roster = new Map(members.map((m) => [m.qq, m]));
1058
+ this.rosters.set(g.groupId, roster);
1059
+ }
1060
+ catch {
1061
+ continue; // 机器人不在这个群里;巡检会报告
1062
+ }
1063
+ const inGroup = qqs.filter((qq) => roster.has(qq));
1064
+ if (!inGroup.length)
1065
+ continue;
1066
+ const members = inGroup.map((qq) => roster.get(qq));
1067
+ const verdicts = new Map();
1068
+ for (const part of (0, util_1.chunk)(inGroup, MAX_CHECK)) {
1069
+ const result = await this.aa.check(g.groupId, part, false, { signal: this.signal, retryDelays: this.options.retryDelays });
1070
+ if (!result.ok) {
1071
+ if (result.kind === 'aborted')
1072
+ return false;
1073
+ this.noteAaFailure(result, `复查 ${this.groupLabel(g.groupId)}`);
1074
+ if (result.error === 'unknown_group')
1075
+ await this.refreshGroups();
1076
+ // 暂时性故障(网络、超时、5xx):游标不前进,下次重来。
1077
+ // 确定性故障(4xx 等)重来也没用,跳过这个群,交给定时巡检,不能卡住所有群的变化处理。
1078
+ if (result.retryable)
1079
+ return false;
1080
+ break;
1081
+ }
1082
+ for (const [qq, verdict] of result.verdicts)
1083
+ verdicts.set(qq, verdict);
1084
+ }
1085
+ if (verdicts.size !== inGroup.length)
1086
+ continue;
1087
+ const botRole = roster.get(bot.selfId)?.role ?? null;
1088
+ const plan = (0, policy_1.planGroup)({
1089
+ groupId: g.groupId,
1090
+ mode: info.effective,
1091
+ held: info.held,
1092
+ bypass: null,
1093
+ kickApprovedBefore: 0,
1094
+ partial: true,
1095
+ groupSize: roster.size,
1096
+ members,
1097
+ verdicts,
1098
+ tracked: await this.store.tracked(g.groupId),
1099
+ protectedIds: this.protectedIds(),
1100
+ botRole,
1101
+ now: this.now(),
1102
+ settings: this.planSettings(0, false),
1103
+ });
1104
+ if (plan.tripped) {
1105
+ const note = `AA 变化后新增不合格 ${plan.newDenies.length} 人,超过阈值 ${plan.threshold} 人`;
1106
+ await this.store.setGroupState(g.groupId, { holdSince: new Date(this.now()), holdNote: note });
1107
+ await this.store.audit('hold', g.groupId, '', note);
1108
+ this.notifier.push(`⛔ 熔断:${this.groupLabel(g.groupId)} ${note}。这个群已停止一切处置,核实后发送:aaqq.confirm ${g.groupId}`);
1109
+ }
1110
+ const applied = await this.applyPlan(bot, g.groupId, plan, this.signal);
1111
+ const changed = plan.newDenies.length || plan.untrack.length || applied.cardsOk;
1112
+ if (changed)
1113
+ lines.push([`▶ ${this.groupLabel(g.groupId)} ${texts_1.MODE_TEXT[info.effective]}`, ...this.describePlan(plan, applied, members, info)].join('\n'));
1114
+ }
1115
+ if (lines.length)
1116
+ this.notifier.push(`【AA 变化复查】\n${lines.join('\n\n')}`);
1117
+ return true;
1118
+ }
1119
+ // ------------------------------------------------------------ 每日提醒
1120
+ async runReminders() {
1121
+ if (this.remindBusy || this.paused)
1122
+ return;
1123
+ this.remindBusy = true;
1124
+ try {
1125
+ const bot = this.pickBot();
1126
+ if (!bot)
1127
+ return;
1128
+ for (const g of this.groups) {
1129
+ if (this.signal.aborted || this.paused)
1130
+ return;
1131
+ const state = await this.store.groupState(g.groupId);
1132
+ const info = this.modeInfo(g.groupId, state);
1133
+ if (!this.writeMode(info))
1134
+ continue;
1135
+ const tracked = await this.store.tracked(g.groupId);
1136
+ if (!tracked.size)
1137
+ continue;
1138
+ let members;
1139
+ try {
1140
+ members = await this.platform.listMembers(bot, g.groupId);
1141
+ }
1142
+ catch {
1143
+ continue;
1144
+ }
1145
+ const roster = new Map(members.map((m) => [m.qq, m]));
1146
+ this.rosters.set(g.groupId, roster);
1147
+ const targets = [...tracked.keys()].filter((qq) => roster.has(qq));
1148
+ if (!targets.length)
1149
+ continue;
1150
+ // 提醒前再问一次 AA,刚绑定好的人不会被 @
1151
+ const result = await this.aa.check(g.groupId, targets, false, { signal: this.signal, retryDelays: this.options.retryDelays });
1152
+ if (!result.ok) {
1153
+ if (result.kind !== 'aborted')
1154
+ this.noteAaFailure(result, `提醒 ${this.groupLabel(g.groupId)}`);
1155
+ continue;
1156
+ }
1157
+ const plan = (0, policy_1.planGroup)({
1158
+ groupId: g.groupId,
1159
+ mode: info.effective,
1160
+ held: false,
1161
+ bypass: null,
1162
+ kickApprovedBefore: 0,
1163
+ partial: true,
1164
+ groupSize: members.length,
1165
+ members: targets.map((qq) => roster.get(qq)),
1166
+ verdicts: result.verdicts,
1167
+ tracked,
1168
+ protectedIds: this.protectedIds(),
1169
+ botRole: roster.get(bot.selfId)?.role ?? null,
1170
+ now: this.now(),
1171
+ settings: this.planSettings(0, false),
1172
+ });
1173
+ await this.applyPlan(bot, g.groupId, plan, this.signal);
1174
+ if (plan.writes)
1175
+ await this.sendReminder(bot, g.groupId, info.effective, plan.track);
1176
+ }
1177
+ }
1178
+ finally {
1179
+ this.remindBusy = false;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * 在群里 @ 提醒(每条最多 20 人)。enforce 模式下,第一次成功提醒某人时才定下他的截止时间
1184
+ * (现在 + 宽限期),保证每个人被移出前都收到过带截止时间的提醒。
1185
+ */
1186
+ async sendReminder(bot, groupId, mode, rows) {
1187
+ if (!rows.length)
1188
+ return;
1189
+ const template = mode === 'enforce' ? this.config.warnTemplate : this.config.remindTemplate;
1190
+ const url = this.bindUrl();
1191
+ for (const part of (0, util_1.chunk)(rows, REMIND_CHUNK)) {
1192
+ if (!(await this.stillWritable(groupId, this.signal)))
1193
+ return;
1194
+ const now = this.now();
1195
+ const updated = part.map((row) => ({
1196
+ ...row,
1197
+ graceUntil: mode === 'enforce' ? row.graceUntil ?? new Date(now + this.config.graceHours * 3600_000) : null,
1198
+ lastRemindedAt: new Date(now),
1199
+ }));
1200
+ const list = [];
1201
+ for (const row of updated) {
1202
+ const deadline = row.graceUntil ? `,截止 ${(0, util_1.formatDeadline)(row.graceUntil.getTime())}` : '';
1203
+ list.push(koishi_1.h.at(row.qq), koishi_1.h.text(`(${(0, texts_1.reasonShort)(row.reason)}${deadline})\n`));
1204
+ }
1205
+ const [before, ...rest] = template.split('{list}');
1206
+ const after = rest.join('{list}');
1207
+ const content = [koishi_1.h.text((0, util_1.fillTemplate)(before, { url })), ...list];
1208
+ if (after)
1209
+ content.push(koishi_1.h.text((0, util_1.fillTemplate)(after, { url })));
1210
+ try {
1211
+ await this.platform.sendGroup(bot, groupId, content);
1212
+ }
1213
+ catch (error) {
1214
+ // 没发出去就不定截止时间、不记提醒时间:没收到提醒的人不会被移出
1215
+ this.logger.warn('发送提醒失败 群 %s:%s', groupId, (0, util_1.errorText)(error));
1216
+ continue;
1217
+ }
1218
+ await this.store.markReminded(groupId, updated);
1219
+ }
1220
+ }
1221
+ // ------------------------------------------------------------ 管理操作
1222
+ async setPaused(paused, actor) {
1223
+ this.paused = paused;
1224
+ await this.store.setKv('paused', paused);
1225
+ await this.store.audit(paused ? 'pause' : 'resume', '', '', '', actor);
1226
+ if (paused) {
1227
+ this.round?.abort();
1228
+ }
1229
+ else {
1230
+ this.requestPatrol();
1231
+ }
1232
+ }
1233
+ /**
1234
+ * 管理员确认:模式升级生效和/或解除熔断,并马上重新巡检这个群。
1235
+ * 接下来 1 小时内的那一轮巡检,只要人数不超过这次确认时报告里的人数,就不会再熔断。
1236
+ */
1237
+ async confirm(groupId, actor) {
1238
+ const g = this.group(groupId);
1239
+ if (!g)
1240
+ return `${groupId} 不是 AA 上的受管群。`;
1241
+ const state = await this.store.groupState(groupId);
1242
+ const info = this.modeInfo(groupId, state);
1243
+ const now = this.now();
1244
+ const lastAt = state.lastPatrolAt?.getTime() ?? 0;
1245
+ if (!state.lastPatrolOk || now - lastAt > CONFIRM_REPORT_MAX_AGE_MS) {
1246
+ return `这个群最近 12 小时没有成功的巡检报告。请先发送 aaqq.patrol ${groupId},看完运维群里的报告再确认。`;
1247
+ }
1248
+ const changes = [];
1249
+ const patch = {};
1250
+ if (info.awaiting) {
1251
+ patch.confirmedMode = info.desired;
1252
+ patch.confirmedAt = new Date(now);
1253
+ patch.confirmedBy = actor;
1254
+ changes.push(`模式升级为 ${texts_1.MODE_TEXT[info.desired]}`);
1255
+ }
1256
+ if (state.holdSince) {
1257
+ patch.holdSince = null;
1258
+ patch.holdNote = '';
1259
+ changes.push('解除熔断');
1260
+ }
1261
+ if (!changes.length)
1262
+ return `${this.groupLabel(groupId)} 现在不需要确认(当前模式 ${texts_1.MODE_TEXT[info.effective]})。`;
1263
+ Object.assign(patch, {
1264
+ lastConfirmAt: new Date(now),
1265
+ bypassUntil: new Date(now + BYPASS_TTL_MS),
1266
+ bypassMaxNew: state.lastNewDenies,
1267
+ bypassMaxKicks: state.lastKicksDue,
1268
+ });
1269
+ await this.store.setGroupState(groupId, patch);
1270
+ await this.store.audit('confirm', groupId, '', changes.join(','), actor);
1271
+ this.requestPatrol([groupId]);
1272
+ const limits = `新发现不合格不超过 ${state.lastNewDenies} 人、到期移出不超过 ${state.lastKicksDue} 人`;
1273
+ return `已确认 ${this.groupLabel(groupId)}:${changes.join(',')}。马上重新巡检这个群;1 小时内的这一轮只要${limits}(和你看到的报告一致),就不会再熔断。`;
1274
+ }
1275
+ async statusText() {
1276
+ const lines = [];
1277
+ lines.push(`状态:${this.paused ? '⏸ 暂停中' : '▶ 运行中'}`);
1278
+ const { bot, problem } = this.platform.pickBot();
1279
+ lines.push(`机器人:${bot ? `${bot.selfId} 在线` : `❌ ${problem}`}`);
1280
+ lines.push(`AA:${this.aaDown ? '❌ 最近一次请求失败' : '正常'}${this.aa.clockSkewMs !== null ? `(时间差 ${Math.round(this.aa.clockSkewMs / 1000)} 秒)` : ''}`);
1281
+ if (this.adminGroupConflict)
1282
+ lines.push('❌ 运维群同时是受管群,已停止发送运维通知,请修改配置');
1283
+ if (this.lastRound)
1284
+ lines.push(`上次巡检:${(0, util_1.formatShortTime)(this.lastRound.at)}${this.lastRound.ok ? '' : '(有问题)'}`);
1285
+ if (this.patrolRunning)
1286
+ lines.push('正在巡检中');
1287
+ else if (this.nextPatrolAt)
1288
+ lines.push(`下次巡检:${(0, util_1.formatShortTime)(this.nextPatrolAt)}`);
1289
+ if (!this.groupsLoaded) {
1290
+ lines.push('受管群:还没从 AA 拿到列表');
1291
+ }
1292
+ else if (!this.groups.length) {
1293
+ lines.push('受管群:AA 上还没有配置受管群');
1294
+ }
1295
+ else {
1296
+ lines.push('受管群:');
1297
+ for (const g of this.groups) {
1298
+ const state = await this.store.groupState(g.groupId);
1299
+ const info = this.modeInfo(g.groupId, state);
1300
+ const tracked = await this.store.tracked(g.groupId);
1301
+ const extra = [];
1302
+ if (info.awaiting)
1303
+ extra.push(`设为 ${info.desired},等待确认`);
1304
+ if (info.held)
1305
+ extra.push('熔断中');
1306
+ if (tracked.size)
1307
+ extra.push(`宽限中 ${tracked.size} 人`);
1308
+ lines.push(`· ${this.groupLabel(g.groupId)}:${texts_1.MODE_TEXT[info.effective]}${extra.length ? `(${extra.join(',')})` : ''}`);
1309
+ }
1310
+ }
1311
+ return lines.join('\n');
1312
+ }
1313
+ /** 查询一个 QQ 在各受管群的判定(只给运维用,输出不含角色名)。 */
1314
+ async checkQq(qq) {
1315
+ if (!this.groups.length)
1316
+ return 'AA 上还没有受管群。';
1317
+ const lines = [`QQ ${qq}:`];
1318
+ for (const g of this.groups) {
1319
+ const result = await this.aa.check(g.groupId, [qq], false, { signal: this.signal, retryDelays: [] });
1320
+ const inGroup = this.rosters.get(g.groupId)?.has(qq);
1321
+ const where = inGroup === undefined ? '' : inGroup ? '(在群里)' : '(不在群里)';
1322
+ if (!result.ok) {
1323
+ lines.push(`· ${this.groupLabel(g.groupId)}${where}:无法判断(${(0, aa_1.describeFailure)(result)})`);
1324
+ continue;
1325
+ }
1326
+ const verdict = result.verdicts.get(qq);
1327
+ const text = verdict.decision === 'allow' ? '合格' : verdict.decision === 'deny' ? `不合格:${(0, texts_1.reasonShort)(verdict.reason)}` : verdict.decision === 'review' ? `需人工:${(0, texts_1.reasonShort)(verdict.reason)}` : '无法判断';
1328
+ lines.push(`· ${this.groupLabel(g.groupId)}${where}:${text}`);
1329
+ }
1330
+ return lines.join('\n');
1331
+ }
1332
+ // ------------------------------------------------------------ 杂项
1333
+ pruneMaps() {
1334
+ const now = this.now();
1335
+ for (const [flag, at] of this.handledFlags)
1336
+ if (now - at > FLAG_TTL_MS)
1337
+ this.handledFlags.delete(flag);
1338
+ for (const [key, value] of this.approved)
1339
+ if (now - value.at > APPROVED_TTL_MS)
1340
+ this.approved.delete(key);
1341
+ }
1342
+ async pruneAudit() {
1343
+ const now = this.now();
1344
+ if (now - this.lastPrune < 86400_000)
1345
+ return;
1346
+ this.lastPrune = now;
1347
+ await this.store.pruneAudit(new Date(now - AUDIT_KEEP_MS));
1348
+ }
1349
+ }
1350
+ exports.Guard = Guard;
1351
+ function outcomeLabel(outcome) {
1352
+ switch (outcome) {
1353
+ case 'claimed': return '验证码验证成功';
1354
+ case 'no_code': return '申请里没有验证码';
1355
+ case 'code_invalid': return '验证码不对';
1356
+ case 'code_expired': return '验证码已过期';
1357
+ case 'code_used': return '验证码已用过';
1358
+ case 'qq_mismatch': return '验证码不是给这个 QQ 的';
1359
+ default: return outcome || '—';
1360
+ }
1361
+ }