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/LICENSE +21 -0
- package/README.md +180 -0
- package/lib/aa.d.ts +91 -0
- package/lib/aa.js +255 -0
- package/lib/commands.d.ts +3 -0
- package/lib/commands.js +73 -0
- package/lib/config.d.ts +39 -0
- package/lib/config.js +91 -0
- package/lib/guard.d.ts +175 -0
- package/lib/guard.js +1361 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +32 -0
- package/lib/notifier.d.ts +24 -0
- package/lib/notifier.js +126 -0
- package/lib/platform.d.ts +34 -0
- package/lib/platform.js +116 -0
- package/lib/policy.d.ts +122 -0
- package/lib/policy.js +203 -0
- package/lib/signing.d.ts +16 -0
- package/lib/signing.js +39 -0
- package/lib/store.d.ts +81 -0
- package/lib/store.js +140 -0
- package/lib/texts.d.ts +4 -0
- package/lib/texts.js +90 -0
- package/lib/util.d.ts +34 -0
- package/lib/util.js +140 -0
- package/package.json +63 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { Config } from './config';
|
|
3
|
+
export declare const name = "aaqqbot";
|
|
4
|
+
export declare const inject: {
|
|
5
|
+
required: string[];
|
|
6
|
+
};
|
|
7
|
+
export { Config };
|
|
8
|
+
export declare const usage = "\n\u914D\u5408 AllianceAuth \u63D2\u4EF6 **aa-qqbot** \u7BA1\u7406\u8054\u76DF QQ \u7FA4\uFF1A\u5BA1\u6279\u5165\u7FA4\u7533\u8BF7\u3001\u5B9A\u65F6\u5DE1\u68C0\u3001@ \u63D0\u9192\u3001\u540D\u7247\u52A0\u6807\u8BB0\u3001\u79FB\u51FA\u4E0D\u5408\u683C\u6210\u5458\u3001\u540C\u6B65\u7FA4\u540D\u7247\u3002\n\n**\u7B2C\u4E00\u6B21\u4F7F\u7528\uFF1A**\n\n1. \u5728 AA \u4E0A\u88C5\u597D aa-qqbot\uFF0C\u7531 IT \u5728 `local.py` \u91CC\u914D\u7F6E `QQBOT_API_KEYS`\uFF08\u5BC6\u94A5\u7F16\u53F7 \u2192 \u5BC6\u94A5\uFF09\u3002\n2. \u5728\u4E0B\u9762\u586B\u597D\u300CAA \u7684\u7F51\u5740\u300D\u300C\u5BC6\u94A5\u7F16\u53F7\u300D\u300C\u5BC6\u94A5\u300D\uFF0C\u4EE5\u53CA\u8FD0\u7EF4\u7FA4\u53F7\u3001\u8FD0\u7EF4\u540D\u5355\u3002\n3. \u542F\u7528\u63D2\u4EF6\u540E\uFF0C\u5728\u8FD0\u7EF4\u7FA4\u91CC\u53D1\u9001 `aaqq.health` \u68C0\u67E5\u8FDE\u63A5\uFF0C\u53D1\u9001 `aaqq.status` \u67E5\u770B\u53D7\u7BA1\u7FA4\u3002\n4. \u6240\u6709\u7FA4\u9ED8\u8BA4 **report\uFF08\u53EA\u62A5\u544A\uFF09**\uFF1A\u673A\u5668\u4EBA\u53EA\u5BA1\u6279\u5408\u683C\u7684\u5165\u7FA4\u7533\u8BF7\uFF0C\u5E76\u628A\u5DE1\u68C0\u7ED3\u679C\u53D1\u5230\u8FD0\u7EF4\u7FA4\uFF0C\u4E0D\u6539\u52A8\u7FA4\u91CC\u4EFB\u4F55\u4E1C\u897F\u3002\n5. \u770B\u8FC7\u62A5\u544A\u3001\u786E\u8BA4\u6CA1\u95EE\u9898\u540E\uFF0C\u518D\u628A\u67D0\u4E2A\u7FA4\u6539\u6210 remind \u6216 enforce\uFF0C\u5E76\u5728\u8FD0\u7EF4\u7FA4\u53D1\u9001 `aaqq.confirm \u7FA4\u53F7` \u786E\u8BA4\u3002\n\n**\u5B89\u5168\u4FDD\u8BC1\uFF1A** AA \u8FDE\u4E0D\u4E0A\u3001\u8FD4\u56DE\u5F02\u5E38\u6216\u5224\u5B9A\u4E3A\u300C\u9700\u8981\u4EBA\u5DE5\u5904\u7406\u300D\u65F6\uFF0C\u673A\u5668\u4EBA\u4EC0\u4E48\u90FD\u4E0D\u505A\uFF1B\u7FA4\u4E3B\u3001\u7BA1\u7406\u5458\u3001\u673A\u5668\u4EBA\u81EA\u5DF1\u548C\u767D\u540D\u5355\u6C38\u8FDC\u4E0D\u4F1A\u88AB\u5904\u7F6E\uFF1B\n\u4E00\u8F6E\u91CC\u65B0\u53D1\u73B0\u7684\u4E0D\u5408\u683C\u4EBA\u6570\u592A\u591A\u65F6\u81EA\u52A8\u7194\u65AD\uFF0C\u7B49\u7BA1\u7406\u5458\u786E\u8BA4\u3002\u7D27\u6025\u60C5\u51B5\u53D1\u9001 `aaqq.pause` \u7ACB\u5373\u505C\u6B62\u4E00\u5207\u64CD\u4F5C\u3002\n\n\u8BE6\u7EC6\u8BF4\u660E\u89C1 [README](https://github.com/yilifaer/Koishi-AAqqbot-plugin#readme)\u3002\n";
|
|
9
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.usage = exports.Config = exports.inject = exports.name = void 0;
|
|
4
|
+
exports.apply = apply;
|
|
5
|
+
const commands_1 = require("./commands");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
Object.defineProperty(exports, "Config", { enumerable: true, get: function () { return config_1.Config; } });
|
|
8
|
+
const guard_1 = require("./guard");
|
|
9
|
+
exports.name = 'aaqqbot';
|
|
10
|
+
// 数据库:保存宽限记录、游标、暂停状态;没有数据库时 Koishi 的权限检查会完全失效(交接文档 03 #26)
|
|
11
|
+
exports.inject = { required: ['database', 'http'] };
|
|
12
|
+
exports.usage = `
|
|
13
|
+
配合 AllianceAuth 插件 **aa-qqbot** 管理联盟 QQ 群:审批入群申请、定时巡检、@ 提醒、名片加标记、移出不合格成员、同步群名片。
|
|
14
|
+
|
|
15
|
+
**第一次使用:**
|
|
16
|
+
|
|
17
|
+
1. 在 AA 上装好 aa-qqbot,由 IT 在 \`local.py\` 里配置 \`QQBOT_API_KEYS\`(密钥编号 → 密钥)。
|
|
18
|
+
2. 在下面填好「AA 的网址」「密钥编号」「密钥」,以及运维群号、运维名单。
|
|
19
|
+
3. 启用插件后,在运维群里发送 \`aaqq.health\` 检查连接,发送 \`aaqq.status\` 查看受管群。
|
|
20
|
+
4. 所有群默认 **report(只报告)**:机器人只审批合格的入群申请,并把巡检结果发到运维群,不改动群里任何东西。
|
|
21
|
+
5. 看过报告、确认没问题后,再把某个群改成 remind 或 enforce,并在运维群发送 \`aaqq.confirm 群号\` 确认。
|
|
22
|
+
|
|
23
|
+
**安全保证:** AA 连不上、返回异常或判定为「需要人工处理」时,机器人什么都不做;群主、管理员、机器人自己和白名单永远不会被处置;
|
|
24
|
+
一轮里新发现的不合格人数太多时自动熔断,等管理员确认。紧急情况发送 \`aaqq.pause\` 立即停止一切操作。
|
|
25
|
+
|
|
26
|
+
详细说明见 [README](https://github.com/yilifaer/Koishi-AAqqbot-plugin#readme)。
|
|
27
|
+
`;
|
|
28
|
+
function apply(ctx, config) {
|
|
29
|
+
const guard = new guard_1.Guard(ctx, config);
|
|
30
|
+
guard.install();
|
|
31
|
+
(0, commands_1.registerCommands)(ctx, guard);
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Logger } from 'koishi';
|
|
2
|
+
import type { Platform } from './platform';
|
|
3
|
+
export declare class Notifier {
|
|
4
|
+
private platform;
|
|
5
|
+
private logger;
|
|
6
|
+
private getAdminGroup;
|
|
7
|
+
private now;
|
|
8
|
+
private queue;
|
|
9
|
+
private timer;
|
|
10
|
+
private sentAt;
|
|
11
|
+
private dropped;
|
|
12
|
+
/** 测试用:记录发出的每条消息。 */
|
|
13
|
+
history: string[];
|
|
14
|
+
constructor(platform: Platform, logger: Logger, getAdminGroup: () => string | null, now?: () => number);
|
|
15
|
+
/** 排队一条通知;几秒内的通知合并发送。 */
|
|
16
|
+
push(text: string): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
flush(): Promise<void>;
|
|
19
|
+
private sendOne;
|
|
20
|
+
}
|
|
21
|
+
/** 按行把多条通知拼成若干条不超过上限的消息。 */
|
|
22
|
+
export declare function pack(items: string[]): string[];
|
|
23
|
+
/** 运维群号:规范化后返回;没填返回 null。 */
|
|
24
|
+
export declare function resolveAdminGroup(value: string): string | null;
|
package/lib/notifier.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 发到运维群的通知:短时间内的多条合并成一条、全局限速、发送失败只记日志(交接文档 R18)。
|
|
3
|
+
// 所有内容都按纯文本发送(h.text),名片、群名、错误信息里的 <at> 之类不会被当成消息元素。
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.Notifier = void 0;
|
|
6
|
+
exports.pack = pack;
|
|
7
|
+
exports.resolveAdminGroup = resolveAdminGroup;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const util_1 = require("./util");
|
|
10
|
+
const MAX_MESSAGE_CHARS = 1500;
|
|
11
|
+
const MAX_MESSAGES_PER_HOUR = 30;
|
|
12
|
+
const FLUSH_DELAY_MS = 3000;
|
|
13
|
+
class Notifier {
|
|
14
|
+
platform;
|
|
15
|
+
logger;
|
|
16
|
+
getAdminGroup;
|
|
17
|
+
now;
|
|
18
|
+
queue = [];
|
|
19
|
+
timer = null;
|
|
20
|
+
sentAt = [];
|
|
21
|
+
dropped = 0;
|
|
22
|
+
/** 测试用:记录发出的每条消息。 */
|
|
23
|
+
history = [];
|
|
24
|
+
constructor(platform, logger, getAdminGroup, now = Date.now) {
|
|
25
|
+
this.platform = platform;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
this.getAdminGroup = getAdminGroup;
|
|
28
|
+
this.now = now;
|
|
29
|
+
}
|
|
30
|
+
/** 排队一条通知;几秒内的通知合并发送。 */
|
|
31
|
+
push(text) {
|
|
32
|
+
this.logger.info('%s', text.replace(/\n/g, ' | '));
|
|
33
|
+
if (!this.getAdminGroup())
|
|
34
|
+
return;
|
|
35
|
+
this.queue.push(text);
|
|
36
|
+
if (!this.timer)
|
|
37
|
+
this.timer = setTimeout(() => void this.flush(), FLUSH_DELAY_MS);
|
|
38
|
+
}
|
|
39
|
+
dispose() {
|
|
40
|
+
if (this.timer)
|
|
41
|
+
clearTimeout(this.timer);
|
|
42
|
+
this.timer = null;
|
|
43
|
+
this.queue = [];
|
|
44
|
+
}
|
|
45
|
+
async flush() {
|
|
46
|
+
if (this.timer)
|
|
47
|
+
clearTimeout(this.timer);
|
|
48
|
+
this.timer = null;
|
|
49
|
+
const items = this.queue.splice(0);
|
|
50
|
+
if (!items.length)
|
|
51
|
+
return;
|
|
52
|
+
const groupId = this.getAdminGroup();
|
|
53
|
+
if (!groupId)
|
|
54
|
+
return;
|
|
55
|
+
for (const message of pack(items)) {
|
|
56
|
+
await this.sendOne(groupId, message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async sendOne(groupId, text) {
|
|
60
|
+
const now = this.now();
|
|
61
|
+
this.sentAt = this.sentAt.filter((t) => now - t < 3600_000);
|
|
62
|
+
if (this.sentAt.length >= MAX_MESSAGES_PER_HOUR) {
|
|
63
|
+
this.dropped++;
|
|
64
|
+
this.logger.warn('运维通知超过每小时 %d 条的上限,这条只写日志', MAX_MESSAGES_PER_HOUR);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (this.dropped) {
|
|
68
|
+
text = `(之前有 ${this.dropped} 条通知因为限速没有发出,请看 Koishi 日志)\n${text}`;
|
|
69
|
+
this.dropped = 0;
|
|
70
|
+
}
|
|
71
|
+
const { bot, problem } = this.platform.pickBot();
|
|
72
|
+
if (!bot) {
|
|
73
|
+
this.logger.warn('运维通知没有发出:%s', problem);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
this.sentAt.push(now);
|
|
78
|
+
this.history.push(text);
|
|
79
|
+
if (this.history.length > 50)
|
|
80
|
+
this.history.shift();
|
|
81
|
+
await this.platform.sendGroup(bot, groupId, koishi_1.h.text(text));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.logger.warn('运维通知发送失败:%s', error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.Notifier = Notifier;
|
|
89
|
+
/** 按行把多条通知拼成若干条不超过上限的消息。 */
|
|
90
|
+
function pack(items) {
|
|
91
|
+
const messages = [];
|
|
92
|
+
let current = '';
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
for (const piece of splitLong(item)) {
|
|
95
|
+
if (current && current.length + 2 + piece.length > MAX_MESSAGE_CHARS) {
|
|
96
|
+
messages.push(current);
|
|
97
|
+
current = '';
|
|
98
|
+
}
|
|
99
|
+
current = current ? `${current}\n\n${piece}` : piece;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (current)
|
|
103
|
+
messages.push(current);
|
|
104
|
+
return messages;
|
|
105
|
+
}
|
|
106
|
+
function splitLong(text) {
|
|
107
|
+
if (text.length <= MAX_MESSAGE_CHARS)
|
|
108
|
+
return [text];
|
|
109
|
+
const pieces = [];
|
|
110
|
+
let current = '';
|
|
111
|
+
for (const line of text.split('\n')) {
|
|
112
|
+
const safeLine = line.length > MAX_MESSAGE_CHARS ? line.slice(0, MAX_MESSAGE_CHARS - 1) + '…' : line;
|
|
113
|
+
if (current && current.length + 1 + safeLine.length > MAX_MESSAGE_CHARS) {
|
|
114
|
+
pieces.push(current);
|
|
115
|
+
current = '';
|
|
116
|
+
}
|
|
117
|
+
current = current ? `${current}\n${safeLine}` : safeLine;
|
|
118
|
+
}
|
|
119
|
+
if (current)
|
|
120
|
+
pieces.push(current);
|
|
121
|
+
return pieces;
|
|
122
|
+
}
|
|
123
|
+
/** 运维群号:规范化后返回;没填返回 null。 */
|
|
124
|
+
function resolveAdminGroup(value) {
|
|
125
|
+
return (0, util_1.normalizeId)(value);
|
|
126
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Bot, Context, Fragment } from 'koishi';
|
|
2
|
+
import type { Member } from './policy';
|
|
3
|
+
export interface PendingJoinRequest {
|
|
4
|
+
flag: string;
|
|
5
|
+
groupId: string;
|
|
6
|
+
qq: string;
|
|
7
|
+
comment: string;
|
|
8
|
+
invitorId: string | null;
|
|
9
|
+
}
|
|
10
|
+
export declare class Platform {
|
|
11
|
+
private ctx;
|
|
12
|
+
private getBotId;
|
|
13
|
+
constructor(ctx: Context, getBotId: () => string);
|
|
14
|
+
/** 所有机器人账号(任何平台),永远不处置。 */
|
|
15
|
+
allSelfIds(): Set<string>;
|
|
16
|
+
/**
|
|
17
|
+
* 选出负责管理的 OneBot 机器人。配置了 botId 时只用它;
|
|
18
|
+
* 没配置时,只有恰好一个 OneBot 机器人才使用,避免多个机器人重复处置(交接文档 D37)。
|
|
19
|
+
*/
|
|
20
|
+
pickBot(): {
|
|
21
|
+
bot: Bot | null;
|
|
22
|
+
problem: string | null;
|
|
23
|
+
};
|
|
24
|
+
listMembers(bot: Bot, groupId: string): Promise<Member[]>;
|
|
25
|
+
/** 实时查询一个成员(不走缓存)。查不到或出错时返回 null。 */
|
|
26
|
+
getMember(bot: Bot, groupId: string, qq: string): Promise<Member | null>;
|
|
27
|
+
/** 移出,不拉黑(reject_add_request = false,DECISIONS 第 15 条)。 */
|
|
28
|
+
kick(bot: Bot, groupId: string, qq: string): Promise<void>;
|
|
29
|
+
setCard(bot: Bot, groupId: string, qq: string, card: string): Promise<void>;
|
|
30
|
+
handleJoinRequest(bot: Bot, flag: string, approve: boolean, reason?: string): Promise<void>;
|
|
31
|
+
sendGroup(bot: Bot, groupId: string, content: Fragment): Promise<void>;
|
|
32
|
+
/** 掉线期间积压的入群申请(LLBot 的 get_group_system_msg;字段名按 LLBot 实际返回解析)。 */
|
|
33
|
+
pendingJoinRequests(bot: Bot): Promise<PendingJoinRequest[]>;
|
|
34
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 对 OneBot(LLBot)接口的薄封装。只用 adapter-onebot 6.9.4 里确实存在的方法(见交接文档 03 平台调研)。
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.Platform = void 0;
|
|
5
|
+
const util_1 = require("./util");
|
|
6
|
+
class Platform {
|
|
7
|
+
ctx;
|
|
8
|
+
getBotId;
|
|
9
|
+
constructor(ctx, getBotId) {
|
|
10
|
+
this.ctx = ctx;
|
|
11
|
+
this.getBotId = getBotId;
|
|
12
|
+
}
|
|
13
|
+
/** 所有机器人账号(任何平台),永远不处置。 */
|
|
14
|
+
allSelfIds() {
|
|
15
|
+
return new Set(this.ctx.bots.map((bot) => bot.selfId).filter(Boolean));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 选出负责管理的 OneBot 机器人。配置了 botId 时只用它;
|
|
19
|
+
* 没配置时,只有恰好一个 OneBot 机器人才使用,避免多个机器人重复处置(交接文档 D37)。
|
|
20
|
+
*/
|
|
21
|
+
pickBot() {
|
|
22
|
+
const bots = this.ctx.bots.filter((bot) => bot.platform === 'onebot');
|
|
23
|
+
const wanted = (0, util_1.normalizeId)(this.getBotId());
|
|
24
|
+
let bot;
|
|
25
|
+
if (wanted) {
|
|
26
|
+
bot = bots.find((b) => b.selfId === wanted);
|
|
27
|
+
if (!bot)
|
|
28
|
+
return { bot: null, problem: `找不到 QQ 号为 ${wanted} 的 OneBot 机器人` };
|
|
29
|
+
}
|
|
30
|
+
else if (bots.length === 1) {
|
|
31
|
+
bot = bots[0];
|
|
32
|
+
}
|
|
33
|
+
else if (bots.length === 0) {
|
|
34
|
+
return { bot: null, problem: '这个 Koishi 里没有 OneBot 机器人' };
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
return { bot: null, problem: '这个 Koishi 里有多个 OneBot 机器人,请在插件配置里填写「用哪个机器人账号管理」' };
|
|
38
|
+
}
|
|
39
|
+
if (bot.status !== 1 /* Universal.Status.ONLINE */)
|
|
40
|
+
return { bot: null, problem: `机器人 ${bot.selfId} 当前不在线` };
|
|
41
|
+
if (typeof bot.internal?._request !== 'function')
|
|
42
|
+
return { bot: null, problem: `机器人 ${bot.selfId} 与 LLBot 的连接已断开` };
|
|
43
|
+
return { bot, problem: null };
|
|
44
|
+
}
|
|
45
|
+
async listMembers(bot, groupId) {
|
|
46
|
+
const list = await bot.internal.getGroupMemberList(groupId);
|
|
47
|
+
if (!Array.isArray(list))
|
|
48
|
+
throw new Error('群成员列表格式不对');
|
|
49
|
+
const members = [];
|
|
50
|
+
for (const item of list) {
|
|
51
|
+
const member = toMember(item);
|
|
52
|
+
if (member)
|
|
53
|
+
members.push(member);
|
|
54
|
+
}
|
|
55
|
+
return members;
|
|
56
|
+
}
|
|
57
|
+
/** 实时查询一个成员(不走缓存)。查不到或出错时返回 null。 */
|
|
58
|
+
async getMember(bot, groupId, qq) {
|
|
59
|
+
try {
|
|
60
|
+
return toMember(await bot.internal.getGroupMemberInfo(groupId, qq, true));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** 移出,不拉黑(reject_add_request = false,DECISIONS 第 15 条)。 */
|
|
67
|
+
async kick(bot, groupId, qq) {
|
|
68
|
+
await bot.internal.setGroupKick(groupId, qq, false);
|
|
69
|
+
}
|
|
70
|
+
async setCard(bot, groupId, qq, card) {
|
|
71
|
+
await bot.internal.setGroupCard(groupId, qq, card);
|
|
72
|
+
}
|
|
73
|
+
async handleJoinRequest(bot, flag, approve, reason = '') {
|
|
74
|
+
await bot.handleGuildMemberRequest(flag, approve, reason);
|
|
75
|
+
}
|
|
76
|
+
async sendGroup(bot, groupId, content) {
|
|
77
|
+
await bot.sendMessage(groupId, content);
|
|
78
|
+
}
|
|
79
|
+
/** 掉线期间积压的入群申请(LLBot 的 get_group_system_msg;字段名按 LLBot 实际返回解析)。 */
|
|
80
|
+
async pendingJoinRequests(bot) {
|
|
81
|
+
const data = await bot.internal.getGroupSystemMsg();
|
|
82
|
+
const list = Array.isArray(data?.join_requests) ? data.join_requests : [];
|
|
83
|
+
const result = [];
|
|
84
|
+
for (const item of list) {
|
|
85
|
+
if (!item || item.checked === true)
|
|
86
|
+
continue;
|
|
87
|
+
const groupId = (0, util_1.normalizeId)(item.group_id);
|
|
88
|
+
const qq = (0, util_1.normalizeId)(item.requester_uin);
|
|
89
|
+
if (!groupId || !qq || item.request_id === undefined || item.request_id === null)
|
|
90
|
+
continue;
|
|
91
|
+
result.push({
|
|
92
|
+
flag: String(item.request_id),
|
|
93
|
+
groupId,
|
|
94
|
+
qq,
|
|
95
|
+
comment: typeof item.message === 'string' ? item.message : '',
|
|
96
|
+
invitorId: (0, util_1.normalizeId)(item.invitor_uin),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.Platform = Platform;
|
|
103
|
+
function toMember(item) {
|
|
104
|
+
const qq = (0, util_1.normalizeId)(item?.user_id);
|
|
105
|
+
if (!qq)
|
|
106
|
+
return null;
|
|
107
|
+
// 只有明确是 member 的才算普通成员;认不出来的按受保护处理(R7「角色判不出就跳过」)
|
|
108
|
+
const role = item.role === 'owner' || item.role === 'admin' || item.role === 'member' ? item.role : 'unknown';
|
|
109
|
+
return {
|
|
110
|
+
qq,
|
|
111
|
+
role,
|
|
112
|
+
card: typeof item.card === 'string' ? item.card : '',
|
|
113
|
+
nickname: typeof item.nickname === 'string' ? item.nickname : '',
|
|
114
|
+
isRobot: item.is_robot === true,
|
|
115
|
+
};
|
|
116
|
+
}
|
package/lib/policy.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Verdict } from './aa';
|
|
2
|
+
import type { Mode } from './config';
|
|
3
|
+
import type { TrackedMember } from './store';
|
|
4
|
+
/** QQ 群名片上限按 60 字节处理(与 AA 端一致,API.md 第 9 节)。 */
|
|
5
|
+
export declare const CARD_LIMIT_BYTES = 60;
|
|
6
|
+
/** unknown:平台返回的身份认不出来,按受保护处理(R7「角色判不出就跳过」)。 */
|
|
7
|
+
export type Role = 'owner' | 'admin' | 'member' | 'unknown';
|
|
8
|
+
export interface Member {
|
|
9
|
+
qq: string;
|
|
10
|
+
role: Role;
|
|
11
|
+
card: string;
|
|
12
|
+
nickname: string;
|
|
13
|
+
isRobot: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface PlanSettings {
|
|
16
|
+
breakerCount: number;
|
|
17
|
+
breakerPercent: number;
|
|
18
|
+
/** 这个群这一小时里还能移出几个人。 */
|
|
19
|
+
kickBudget: number;
|
|
20
|
+
syncCards: boolean;
|
|
21
|
+
markCards: boolean;
|
|
22
|
+
markPrefix: string;
|
|
23
|
+
/** 只有完整巡检才允许移出;事件、新人、提醒这些局部复查不移出。 */
|
|
24
|
+
allowKicks: boolean;
|
|
25
|
+
/** 移出前多久之内必须成功提醒过(毫秒)。 */
|
|
26
|
+
remindFreshMs: number;
|
|
27
|
+
}
|
|
28
|
+
/** 管理员确认后的豁免:不超过报告里看到的人数就不熔断。 */
|
|
29
|
+
export interface Bypass {
|
|
30
|
+
maxNew: number;
|
|
31
|
+
maxKicks: number;
|
|
32
|
+
}
|
|
33
|
+
export interface PlanInput {
|
|
34
|
+
groupId: string;
|
|
35
|
+
/** 生效中的模式(未确认的升级不生效)。 */
|
|
36
|
+
mode: Mode;
|
|
37
|
+
/** 这个群已经处于熔断状态。 */
|
|
38
|
+
held: boolean;
|
|
39
|
+
bypass: Bypass | null;
|
|
40
|
+
/** 截止时间在这个时刻之前的,已经由管理员确认过(不计入「批量移出」熔断)。 */
|
|
41
|
+
kickApprovedBefore: number;
|
|
42
|
+
/** members 只是群里的一部分人(事件、新人、提醒)。 */
|
|
43
|
+
partial: boolean;
|
|
44
|
+
/** 群的总人数,用来算熔断比例。 */
|
|
45
|
+
groupSize: number;
|
|
46
|
+
members: Member[];
|
|
47
|
+
verdicts: Map<string, Verdict>;
|
|
48
|
+
tracked: Map<string, TrackedMember>;
|
|
49
|
+
/** 这个 Koishi 里的机器人账号 + 白名单。 */
|
|
50
|
+
protectedIds: Set<string>;
|
|
51
|
+
/** 机器人在这个群里的身份;不是群主或管理员时什么都改不了。 */
|
|
52
|
+
botRole: Role | null;
|
|
53
|
+
now: number;
|
|
54
|
+
settings: PlanSettings;
|
|
55
|
+
}
|
|
56
|
+
export interface CardChange {
|
|
57
|
+
qq: string;
|
|
58
|
+
from: string;
|
|
59
|
+
to: string;
|
|
60
|
+
why: 'sync' | 'mark' | 'unmark';
|
|
61
|
+
}
|
|
62
|
+
export interface KickPlan {
|
|
63
|
+
qq: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
name: string;
|
|
66
|
+
}
|
|
67
|
+
export interface Plan {
|
|
68
|
+
counts: {
|
|
69
|
+
members: number;
|
|
70
|
+
allow: number;
|
|
71
|
+
deny: number;
|
|
72
|
+
review: number;
|
|
73
|
+
unknown: number;
|
|
74
|
+
};
|
|
75
|
+
/** 不受保护、判为 deny 的人。 */
|
|
76
|
+
denies: Array<{
|
|
77
|
+
qq: string;
|
|
78
|
+
reason: string;
|
|
79
|
+
isNew: boolean;
|
|
80
|
+
}>;
|
|
81
|
+
/** 判为 deny 但受保护(群主、管理员、机器人、白名单),永不处置,只报告。 */
|
|
82
|
+
protectedDenies: Array<{
|
|
83
|
+
qq: string;
|
|
84
|
+
reason: string;
|
|
85
|
+
}>;
|
|
86
|
+
reviews: Array<{
|
|
87
|
+
qq: string;
|
|
88
|
+
reason: string;
|
|
89
|
+
}>;
|
|
90
|
+
unknowns: string[];
|
|
91
|
+
newDenies: Array<{
|
|
92
|
+
qq: string;
|
|
93
|
+
reason: string;
|
|
94
|
+
}>;
|
|
95
|
+
threshold: number;
|
|
96
|
+
/** 宽限期已到、可以移出的人数(未扣除每小时上限)。 */
|
|
97
|
+
kicksDue: number;
|
|
98
|
+
/** 这一轮新触发了熔断。 */
|
|
99
|
+
tripped: boolean;
|
|
100
|
+
tripReason: string;
|
|
101
|
+
/** 无法判断的人太多,这一轮不改动(R9 ③)。 */
|
|
102
|
+
unknownHeavy: boolean;
|
|
103
|
+
/** 这一轮允许改动群(加标记、改名片、移出)。 */
|
|
104
|
+
writes: boolean;
|
|
105
|
+
track: TrackedMember[];
|
|
106
|
+
untrack: string[];
|
|
107
|
+
/** 先加/去标记,再同步名片。 */
|
|
108
|
+
cards: CardChange[];
|
|
109
|
+
kicks: KickPlan[];
|
|
110
|
+
kicksDeferred: number;
|
|
111
|
+
/** 不允许改动时,本来要同步的名片数(写进报告)。 */
|
|
112
|
+
cardsPending: number;
|
|
113
|
+
}
|
|
114
|
+
export declare function breakerThreshold(groupSize: number, count: number, percent: number): number;
|
|
115
|
+
/** 只有身份确定是普通成员、不是机器人、不在保护名单里的人才可能被处置。 */
|
|
116
|
+
export declare function isProtected(member: Member, protectedIds: Set<string>): boolean;
|
|
117
|
+
export declare function botCanWrite(botRole: Role | null): boolean;
|
|
118
|
+
/** 机器人能不能改这个普通成员的名片(群主和管理员都能改普通成员)。 */
|
|
119
|
+
export declare function canEditCard(botRole: Role | null, target: Member): boolean;
|
|
120
|
+
export declare function markedCard(prefix: string, member: Member): string;
|
|
121
|
+
export declare function stripMark(prefix: string, card: string): string;
|
|
122
|
+
export declare function planGroup(input: PlanInput): Plan;
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 处置规划:给定群成员、AA 判定和已有的宽限记录,算出这一轮要做的事。
|
|
3
|
+
// 这里是纯函数,不碰 QQ 也不碰数据库;真正执行由 guard.ts 完成(执行前还会再实时复核一次)。
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.CARD_LIMIT_BYTES = void 0;
|
|
6
|
+
exports.breakerThreshold = breakerThreshold;
|
|
7
|
+
exports.isProtected = isProtected;
|
|
8
|
+
exports.botCanWrite = botCanWrite;
|
|
9
|
+
exports.canEditCard = canEditCard;
|
|
10
|
+
exports.markedCard = markedCard;
|
|
11
|
+
exports.stripMark = stripMark;
|
|
12
|
+
exports.planGroup = planGroup;
|
|
13
|
+
const util_1 = require("./util");
|
|
14
|
+
/** QQ 群名片上限按 60 字节处理(与 AA 端一致,API.md 第 9 节)。 */
|
|
15
|
+
exports.CARD_LIMIT_BYTES = 60;
|
|
16
|
+
function breakerThreshold(groupSize, count, percent) {
|
|
17
|
+
return Math.max(1, Math.min(count, Math.floor(groupSize * percent / 100)));
|
|
18
|
+
}
|
|
19
|
+
/** 只有身份确定是普通成员、不是机器人、不在保护名单里的人才可能被处置。 */
|
|
20
|
+
function isProtected(member, protectedIds) {
|
|
21
|
+
return member.role !== 'member' || member.isRobot || protectedIds.has(member.qq);
|
|
22
|
+
}
|
|
23
|
+
function botCanWrite(botRole) {
|
|
24
|
+
return botRole === 'owner' || botRole === 'admin';
|
|
25
|
+
}
|
|
26
|
+
/** 机器人能不能改这个普通成员的名片(群主和管理员都能改普通成员)。 */
|
|
27
|
+
function canEditCard(botRole, target) {
|
|
28
|
+
return botCanWrite(botRole) && target.role === 'member';
|
|
29
|
+
}
|
|
30
|
+
function markedCard(prefix, member) {
|
|
31
|
+
const base = member.card || member.nickname || member.qq;
|
|
32
|
+
return (0, util_1.truncateUtf8)(prefix + base, exports.CARD_LIMIT_BYTES);
|
|
33
|
+
}
|
|
34
|
+
function stripMark(prefix, card) {
|
|
35
|
+
return prefix && card.startsWith(prefix) ? card.slice(prefix.length) : card;
|
|
36
|
+
}
|
|
37
|
+
function planGroup(input) {
|
|
38
|
+
const { settings, mode, now } = input;
|
|
39
|
+
const prefix = settings.markPrefix;
|
|
40
|
+
const writesMode = mode === 'remind' || mode === 'enforce';
|
|
41
|
+
const plan = {
|
|
42
|
+
counts: { members: input.members.length, allow: 0, deny: 0, review: 0, unknown: 0 },
|
|
43
|
+
denies: [],
|
|
44
|
+
protectedDenies: [],
|
|
45
|
+
reviews: [],
|
|
46
|
+
unknowns: [],
|
|
47
|
+
newDenies: [],
|
|
48
|
+
threshold: breakerThreshold(input.groupSize, settings.breakerCount, settings.breakerPercent),
|
|
49
|
+
kicksDue: 0,
|
|
50
|
+
tripped: false,
|
|
51
|
+
tripReason: '',
|
|
52
|
+
unknownHeavy: false,
|
|
53
|
+
writes: false,
|
|
54
|
+
track: [],
|
|
55
|
+
untrack: [],
|
|
56
|
+
cards: [],
|
|
57
|
+
kicks: [],
|
|
58
|
+
kicksDeferred: 0,
|
|
59
|
+
cardsPending: 0,
|
|
60
|
+
};
|
|
61
|
+
const allowed = [];
|
|
62
|
+
const denied = [];
|
|
63
|
+
const settled = []; // 已有宽限记录、现在变成合格 / 需人工 / 受保护的人
|
|
64
|
+
const kickable = [];
|
|
65
|
+
const present = new Set();
|
|
66
|
+
for (const member of input.members) {
|
|
67
|
+
present.add(member.qq);
|
|
68
|
+
const verdict = input.verdicts.get(member.qq);
|
|
69
|
+
const decision = verdict?.decision ?? 'unknown';
|
|
70
|
+
const record = input.tracked.get(member.qq);
|
|
71
|
+
const prot = isProtected(member, input.protectedIds);
|
|
72
|
+
if (decision === 'allow') {
|
|
73
|
+
plan.counts.allow++;
|
|
74
|
+
allowed.push({ member, verdict: verdict });
|
|
75
|
+
if (record)
|
|
76
|
+
settled.push(member);
|
|
77
|
+
}
|
|
78
|
+
else if (decision === 'deny') {
|
|
79
|
+
plan.counts.deny++;
|
|
80
|
+
if (prot) {
|
|
81
|
+
plan.protectedDenies.push({ qq: member.qq, reason: verdict.reason });
|
|
82
|
+
if (record)
|
|
83
|
+
settled.push(member);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
plan.denies.push({ qq: member.qq, reason: verdict.reason, isNew: !record });
|
|
87
|
+
if (!record)
|
|
88
|
+
plan.newDenies.push({ qq: member.qq, reason: verdict.reason });
|
|
89
|
+
denied.push({ member, verdict: verdict });
|
|
90
|
+
// 可以移出:enforce、完整巡检、截止时间已到、并且最近成功提醒过(截止时间是在提醒时定下的)
|
|
91
|
+
const deadline = record?.graceUntil?.getTime();
|
|
92
|
+
const remindedAt = record?.lastRemindedAt?.getTime();
|
|
93
|
+
if (mode === 'enforce' && settings.allowKicks && deadline !== undefined && deadline <= now
|
|
94
|
+
&& remindedAt !== undefined && now - remindedAt <= settings.remindFreshMs) {
|
|
95
|
+
kickable.push({ qq: member.qq, reason: verdict.reason, name: member.card || member.nickname || member.qq, deadline });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (decision === 'review') {
|
|
99
|
+
// 需要人工处理(例如冲突):永不处置;已有的宽限记录取消
|
|
100
|
+
plan.counts.review++;
|
|
101
|
+
plan.reviews.push({ qq: member.qq, reason: verdict.reason });
|
|
102
|
+
if (record)
|
|
103
|
+
settled.push(member);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// 无法判断:什么都不改,宽限记录原样保留
|
|
107
|
+
plan.counts.unknown++;
|
|
108
|
+
plan.unknowns.push(member.qq);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// 已经离开群的人,删除宽限记录(只有完整名单才能判断谁离开了)
|
|
112
|
+
if (!input.partial) {
|
|
113
|
+
for (const qq of input.tracked.keys()) {
|
|
114
|
+
if (!present.has(qq))
|
|
115
|
+
plan.untrack.push(qq);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 熔断:新发现的不合格太多,或者一次要移出的人太多(没被管理员确认过的)
|
|
119
|
+
plan.kicksDue = kickable.length;
|
|
120
|
+
const unapprovedKicks = kickable.filter((k) => k.deadline > input.kickApprovedBefore).length;
|
|
121
|
+
const maxNew = Math.max(plan.threshold, input.bypass?.maxNew ?? 0);
|
|
122
|
+
const maxKicks = Math.max(plan.threshold, input.bypass?.maxKicks ?? 0);
|
|
123
|
+
if (writesMode && !input.held) {
|
|
124
|
+
if (plan.newDenies.length > maxNew) {
|
|
125
|
+
plan.tripped = true;
|
|
126
|
+
plan.tripReason = `新发现不合格 ${plan.newDenies.length} 人,超过阈值 ${maxNew} 人`;
|
|
127
|
+
}
|
|
128
|
+
else if (unapprovedKicks > maxKicks) {
|
|
129
|
+
plan.tripped = true;
|
|
130
|
+
plan.tripReason = `一次有 ${unapprovedKicks} 人到期要移出,超过阈值 ${maxKicks} 人`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
plan.unknownHeavy = plan.unknowns.length > plan.threshold;
|
|
134
|
+
plan.writes = writesMode && !input.held && !plan.tripped && !plan.unknownHeavy && botCanWrite(input.botRole);
|
|
135
|
+
if (!plan.writes) {
|
|
136
|
+
// 机器人以后能改的名片里,和 AA 不一致的有多少(写进报告)
|
|
137
|
+
for (const { member, verdict } of allowed) {
|
|
138
|
+
if (settings.syncCards && verdict.card && member.card !== verdict.card && !isProtected(member, input.protectedIds))
|
|
139
|
+
plan.cardsPending++;
|
|
140
|
+
}
|
|
141
|
+
// 降级到 report 时,撤掉以前加的标记、清空宽限记录(恢复原状)。
|
|
142
|
+
// 其他不能改动的情况(熔断、机器人不是管理员……)保留宽限记录,等能改动时再撤标记,避免标记残留。
|
|
143
|
+
if (mode === 'report' && !input.held && botCanWrite(input.botRole)) {
|
|
144
|
+
for (const member of input.members) {
|
|
145
|
+
const record = input.tracked.get(member.qq);
|
|
146
|
+
if (!record)
|
|
147
|
+
continue;
|
|
148
|
+
if (record.marked && prefix && member.card.startsWith(prefix) && canEditCard(input.botRole, member)) {
|
|
149
|
+
plan.cards.push({ qq: member.qq, from: member.card, to: stripMark(prefix, member.card), why: 'unmark' });
|
|
150
|
+
}
|
|
151
|
+
if (!plan.untrack.includes(member.qq))
|
|
152
|
+
plan.untrack.push(member.qq);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return plan;
|
|
156
|
+
}
|
|
157
|
+
// ---- 以下只在允许改动时执行
|
|
158
|
+
const marks = [];
|
|
159
|
+
const syncs = [];
|
|
160
|
+
// 不再需要宽限的人:取消记录,撤掉标记(受保护的人只取消记录,不碰名片)
|
|
161
|
+
for (const member of settled) {
|
|
162
|
+
plan.untrack.push(member.qq);
|
|
163
|
+
const verdict = input.verdicts.get(member.qq);
|
|
164
|
+
const willSync = verdict?.decision === 'allow' && settings.syncCards && !!verdict.card;
|
|
165
|
+
if (!willSync && input.tracked.get(member.qq)?.marked && prefix && member.card.startsWith(prefix)
|
|
166
|
+
&& !isProtected(member, input.protectedIds) && canEditCard(input.botRole, member)) {
|
|
167
|
+
marks.push({ qq: member.qq, from: member.card, to: stripMark(prefix, member.card), why: 'unmark' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 合格的人:同步名片(受保护的人不改,DECISIONS 第 26 条)
|
|
171
|
+
for (const { member, verdict } of allowed) {
|
|
172
|
+
if (isProtected(member, input.protectedIds) || !canEditCard(input.botRole, member))
|
|
173
|
+
continue;
|
|
174
|
+
if (settings.syncCards && verdict.card && member.card !== verdict.card) {
|
|
175
|
+
syncs.push({ qq: member.qq, from: member.card, to: verdict.card, why: 'sync' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const { member, verdict } of denied) {
|
|
179
|
+
const existing = input.tracked.get(member.qq);
|
|
180
|
+
const row = existing
|
|
181
|
+
? { ...existing }
|
|
182
|
+
: { groupId: input.groupId, qq: member.qq, reason: verdict.reason, firstDeniedAt: new Date(now), graceUntil: null, marked: false, lastRemindedAt: null };
|
|
183
|
+
row.reason = verdict.reason;
|
|
184
|
+
// 截止时间只在 enforce 模式下、第一次成功发出带截止时间的提醒时定下(guard.sendReminder)
|
|
185
|
+
if (mode !== 'enforce')
|
|
186
|
+
row.graceUntil = null;
|
|
187
|
+
if (settings.markCards && prefix) {
|
|
188
|
+
if (member.card.startsWith(prefix)) {
|
|
189
|
+
row.marked = true;
|
|
190
|
+
}
|
|
191
|
+
else if (canEditCard(input.botRole, member)) {
|
|
192
|
+
marks.push({ qq: member.qq, from: member.card, to: markedCard(prefix, member), why: 'mark' });
|
|
193
|
+
row.marked = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
plan.track.push(row);
|
|
197
|
+
}
|
|
198
|
+
plan.cards = [...marks, ...syncs];
|
|
199
|
+
const budget = Math.max(0, settings.kickBudget);
|
|
200
|
+
plan.kicks = kickable.slice(0, budget).map(({ qq, reason, name }) => ({ qq, reason, name }));
|
|
201
|
+
plan.kicksDeferred = kickable.length - plan.kicks.length;
|
|
202
|
+
return plan;
|
|
203
|
+
}
|