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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yilifaer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # koishi-plugin-aaqqbot
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-aaqqbot)](https://www.npmjs.com/package/koishi-plugin-aaqqbot)
4
+
5
+ 用 QQ 机器人管理 EVE 联盟的 QQ 群:**只有 AllianceAuth(AA)认可的人才能留在对应的群里。**
6
+
7
+ 这是一个 [Koishi](https://koishi.chat) 插件,需要配合 AA 端插件 [aa-qqbot](https://github.com/yilifaer/aa-qqbot-plugin) 使用。
8
+
9
+ - **审批入群申请**:申请人在「验证信息」里填 AA 上显示的验证码,机器人问 AA,合格就同意。
10
+ - **定时巡检**:每隔几小时把每个群的完整名单交给 AA,找出不合格的人。
11
+ - **提醒**:每天定时在群里 @ 不合格的人,并在他们的群名片前加标记(例如「【SPY】张三」)。
12
+ - **移出**:收到带截止时间的提醒后,宽限期(默认 48 小时)过了还不合格,移出群(不拉黑,补好绑定可以重新申请)。
13
+ - **同步群名片**:按 AA 算好的名片自动改,例如 `[IGC] Kaela Voss - 凯拉`。
14
+ - **快速反应**:每分钟向 AA 拉取一次变化(解绑、退组、账号停用……),相关的人立即复查。
15
+
16
+ ## 目录
17
+
18
+ 1. [它是怎么工作的](#1-它是怎么工作的)
19
+ 2. [安全保证](#2-安全保证)
20
+ 3. [安装](#3-安装)
21
+ 4. [配置](#4-配置)
22
+ 5. [群模式与上线步骤](#5-群模式与上线步骤)
23
+ 6. [管理命令](#6-管理命令)
24
+ 7. [常见问题](#7-常见问题)
25
+ 8. [开发](#8-开发)
26
+
27
+ ## 1. 它是怎么工作的
28
+
29
+ ```
30
+ AllianceAuth(aa-qqbot) Koishi(本插件)+ LLBot
31
+ ────────────────────── ─────────────────────────
32
+ 成员在「服务」页绑定 QQ 入群申请 ──► 问 AA ──► 同意 / 拒绝 / 留给管理员
33
+ QQ 管理员在前台配置受管群 ◄── 签名请求 ── 巡检(每 6 小时)──► 完整名单 ──► 判定
34
+ 回答:合格 / 不合格 / 需人工 HTTPS 变化(每 60 秒)──► 相关的人 ──► 复查
35
+ 算好每个人的群名片 提醒、加标记、移出、改名片
36
+ ```
37
+
38
+ - **只有机器人主动连 AA**,AA 不需要能连到机器人(机器人可以在家里的电脑上)。
39
+ - **管哪些群由 AA 决定**:QQ 管理员在 AA 前台添加的群才归机器人管,其他群机器人一概不碰。
40
+ - **踢不踢人由机器人这边决定**:真正移出成员的开关只在 Koishi 里,AA 前台再怎么改也打不开。
41
+
42
+ ## 2. 安全保证
43
+
44
+ 这些规则都有自动化测试覆盖:
45
+
46
+ - **拿不到明确答案就什么都不做**:AA 连不上、超时、返回网页、返回 3xx/4xx/5xx、内容格式不对,一律当作「无法判断」,不拒绝、不提醒、不移出。AA 连不上时只在运维群报警一次,恢复后再通知一次。
47
+ - **「需要人工处理」永不处置**:例如同一个 QQ 被两个 AA 账号认领(冲突),只报告,由 QQ 管理员在 AA 上处理。
48
+ - **永不处置的人**:群主、管理员、QQ 官方机器人、机器人自己、白名单。移出前还会**实时**再查一次对方的身份。
49
+ - **先提醒、后移出**:截止时间从第一次成功 @ 提醒时开始算,移出前 36 小时内一定成功提醒过这个人;提醒没发出去就不会被移出。移出前还会再问一次 AA。
50
+ - **熔断**:一轮里某个群新发现的不合格人数、或者一次到期要移出的人数太多(默认超过 5 人或群人数的 10%,取较小者,至少 1 人),很可能是 AA 被改错了——这个群立即停止一切处置,只在运维群报警,等管理员核实后发送 `aaqq.confirm` 才继续。确认只在 1 小时内、只对报告里的人数有效。
51
+ - **限速**:每个群每小时最多移出 10 人。
52
+ - **升级要确认**:把群改成 remind 或 enforce 后,必须等一轮巡检报告出来、由管理员确认,才真正生效。
53
+ - **紧急刹车**:`aaqq.pause` 立即中止正在进行的巡检,暂停一切操作;停用插件或修改配置同样会立即中止。
54
+ - **不拉黑**:移出时不勾选「拒绝再次申请」,误判了也能补救。
55
+ - **防注入**:群名、名片、错误信息都按纯文本发送,里面的 `<at type="all"/>` 之类不会变成真的 @全体成员。
56
+
57
+ ## 3. 安装
58
+
59
+ ### 3.1 准备
60
+
61
+ | 需要 | 说明 |
62
+ |---|---|
63
+ | Koishi 4.18 及以上 | 推荐 Koishi Desktop |
64
+ | OneBot 机器人 | `adapter-onebot` 适配器 + [LLBot](https://github.com/LLOneBot/LuckyLilliaBot) 等 OneBot v11 实现 |
65
+ | 数据库插件 | 例如 `database-sqlite`(Koishi Desktop 默认已启用) |
66
+ | 机器人是每个受管群的**管理员** | 审批申请、改名片、移出都需要管理员权限 |
67
+ | AA 上装好 aa-qqbot | 并由 IT 在 `local.py` 配置好 `APPS_WITH_PUBLIC_VIEWS` 和 `QQBOT_API_KEYS`,见 aa-qqbot 的 README |
68
+
69
+ ### 3.2 安装插件
70
+
71
+ 在 Koishi 控制台打开「插件市场」,搜索 **aaqqbot**,点「添加」,然后在「插件配置」里找到 `aaqqbot` 进行配置。
72
+
73
+ ### 3.3 生成密钥(在你自己的电脑上)
74
+
75
+ 密钥是机器人和 AA 之间的暗号,**只能由你自己生成,不要发给任何人(包括 AI 助手),不要贴进聊天记录。**
76
+
77
+ 在自己的电脑上打开终端,运行:
78
+
79
+ ```bash
80
+ python3 -c "import secrets; print(secrets.token_urlsafe(48))"
81
+ ```
82
+
83
+ 把输出的那一长串:
84
+
85
+ 1. 交给 IT,写进 AA 的 `local.py`:`QQBOT_API_KEYS = {"koishi-1": "那一长串"}`,然后重启 AA;
86
+ 2. 自己填进 Koishi 插件配置的「密钥」一栏,「密钥编号」填 `koishi-1`。
87
+
88
+ ## 4. 配置
89
+
90
+ | 分组 | 配置项 | 默认值 | 说明 |
91
+ |---|---|---|---|
92
+ | 连接 AA | AA 的网址 | — | 例如 `https://auth.example.com`。正式环境必须用 https |
93
+ | | 密钥编号 / 密钥 | — | 见 3.3 |
94
+ | | 超时 | 30 秒 | |
95
+ | | 绑定网址 | 空 | 写进拒绝理由和提醒里;留空时用「AA 网址 + /services/」 |
96
+ | 机器人与运维 | 机器人账号 | 空 | 只有一个 QQ 机器人时留空 |
97
+ | | 运维群号 | 空 | 汇总和报警发到这里。**只放管理人员,不能是受管群** |
98
+ | | 运维名单 | 空 | 能用管理命令的 QQ |
99
+ | | 白名单 | 空 | 永远不会被处置的 QQ |
100
+ | 群模式 | 默认模式 | report | AA 上新加的群用哪种模式 |
101
+ | | 单独设置 | 空 | 表格:群号 + 模式 |
102
+ | 入群申请 | 自动拒绝 | 开 | 只在 remind / enforce 模式的群里生效 |
103
+ | | 拒绝理由 | `{hint}。绑定地址:{url}` | `{hint}` 是自动生成的原因 |
104
+ | | 邀请入群 | 和普通申请一样 | 也可以选「一律留给管理员」 |
105
+ | | 补处理积压的申请 | 开 | 机器人重新上线时处理掉线期间的申请 |
106
+ | 巡检与群名片 | 巡检间隔 | 6 小时 | 插件启动后先巡检一次 |
107
+ | | 拉取变化间隔 | 60 秒 | |
108
+ | | 同步群名片 | 开 | 只在 remind / enforce 模式 |
109
+ | 宽限、提醒与标记 | 宽限期 | 48 小时 | enforce 模式下,从第一次带截止时间的提醒开始,多久后移出 |
110
+ | | 提醒时间 | 19:30 | 每天几点 @ 提醒(机器人电脑的本地时间) |
111
+ | | 提醒文字 | | remind 和 enforce 各一份,`{list}` 是被 @ 的人 |
112
+ | | 名片加标记 / 标记文字 | 开 / 【SPY】 | |
113
+ | | 移出公告 / 公告文字 | 开 | 移出后在该群发一条公告 |
114
+ | 防误踢 | 熔断人数 / 比例 | 5 人 / 10% | 取较小者,至少为 1 |
115
+ | | 每小时最多移出 | 10 人 | 每个群 |
116
+
117
+ ## 5. 群模式与上线步骤
118
+
119
+ | 模式 | 审批申请 | 提醒、加标记、同步名片 | 移出 |
120
+ |---|---|---|---|
121
+ | **off** | 不管 | 不管 | 不管 |
122
+ | **report**(默认) | 合格的同意,不合格的留给管理员 | ✗(只写进运维群报告) | ✗ |
123
+ | **remind** | 合格的同意,不合格的拒绝 | ✓ | ✗ |
124
+ | **enforce** | 合格的同意,不合格的拒绝 | ✓ | 宽限期过后 ✓ |
125
+
126
+ 建议的上线步骤:
127
+
128
+ 1. 所有群先用 **report** 跑至少一周,看运维群里的报告,督促大家在 AA 上绑定 QQ。
129
+ 2. 在每个受管群的 QQ 设置里,把加群方式改成 **「需要发送验证信息」**,否则机器人收不到入群申请。
130
+ 3. 先挑一个小群改成 **remind**:在插件配置的「单独设置」里填群号和模式 → 保存 → 等巡检报告(或在运维群发 `aaqq.patrol 群号`)→ 看报告没问题后发送 `aaqq.confirm 群号`。
131
+ 4. 提醒跑几天没有问题,再按同样的方法改成 **enforce**。
132
+
133
+ ## 6. 管理命令
134
+
135
+ 只能在**运维群**里或**私聊机器人**时使用,并且要**同时**满足:QQ 在「运维名单」里、Koishi 权限等级 ≥ 3。在其他群里发这些命令,机器人不会回应。
136
+
137
+ | 命令 | 作用 |
138
+ |---|---|
139
+ | `aaqq.status` | 查看状态:机器人、AA 连接、各群模式、熔断、宽限中人数 |
140
+ | `aaqq.health` | 检查与 AA 的连接和 AA 的配置 |
141
+ | `aaqq.patrol [群号]` | 立即巡检(不填群号就巡检全部) |
142
+ | `aaqq.confirm <群号>` | 确认模式升级;解除熔断 |
143
+ | `aaqq.check <QQ号>` | 查询某个 QQ 在各群的判定 |
144
+ | `aaqq.pause` | 紧急暂停一切操作 |
145
+ | `aaqq.resume` | 恢复 |
146
+
147
+ 给运维加权限等级(两种方法任选一种):
148
+
149
+ - 在 Koishi 控制台的「数据库」页面打开 `user` 表,把对应用户的 `authority` 改成 3。(表里还没有这个人时,先让他私聊机器人发一次 `aaqq.status`,会提示「权限不足」,这时表里就有他了。)
150
+ - 启用 `admin` 插件后,由权限等级 ≥ 4 的人发送 `authorize 3 -u @某人`。
151
+
152
+ ## 7. 常见问题
153
+
154
+ | 运维群里看到 | 原因和解决办法 |
155
+ |---|---|
156
+ | 「AA 把请求转到了登录页」 | AA 的 `local.py` 缺少 `APPS_WITH_PUBLIC_VIEWS += ["qqbot"]`,请 IT 追加(不要整行覆盖)后重启 AA |
157
+ | 「签名不对」(bad_signature) | Koishi 里的密钥和 AA 上的不一致,注意不要多复制空格 |
158
+ | 「时间相差太大」(stale_timestamp) | 打开机器人电脑的「自动设置日期与时间」 |
159
+ | 「密钥编号在 AA 上不存在」 | Koishi 里的密钥编号要和 `QQBOT_API_KEYS` 的键完全一样 |
160
+ | 「AA 没有配置机器人密钥」 | 请 IT 配置 `QQBOT_API_KEYS` |
161
+ | 「运维群同时是受管群」 | 换一个只有管理人员的群做运维群 |
162
+ | 「这个 Koishi 里有多个 OneBot 机器人」 | 在配置里填「机器人账号」 |
163
+ | 「⛔ 熔断」 | 先核对 AA 上的设置(例如群的类型、要求的组、成员权限有没有被改错),确认名单没问题后发送 `aaqq.confirm 群号` |
164
+
165
+ ## 8. 开发
166
+
167
+ ```bash
168
+ npm install
169
+ npm test # 自动化测试(真实的 Koishi + 模拟的 AA 和 LLBot)
170
+ npm run typecheck
171
+ npm run build # 编译到 lib/
172
+ ```
173
+
174
+ 接口契约见 aa-qqbot 仓库的 `API.md`;设计决定见本仓库的 [`DECISIONS.md`](DECISIONS.md)。
175
+
176
+ 发布:在 GitHub 的 Actions 页面运行 **Publish to npm**(需要仓库里配置了 `NPM_TOKEN`)。
177
+
178
+ ## 许可证
179
+
180
+ MIT
package/lib/aa.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { Context } from 'koishi';
2
+ export type Decision = 'allow' | 'deny' | 'review' | 'unknown';
3
+ export interface Verdict {
4
+ qq: string;
5
+ decision: Decision;
6
+ reason: string;
7
+ card: string | null;
8
+ }
9
+ export interface ManagedGroup {
10
+ groupId: string;
11
+ name: string;
12
+ kind: string;
13
+ }
14
+ export interface AaEvent {
15
+ id: number;
16
+ kind: string;
17
+ qq: string;
18
+ }
19
+ export interface ApiFailure {
20
+ ok: false;
21
+ /** network:连不上;timeout:超时;http:非 200;invalid:200 但内容不对;aborted:插件停用或暂停。 */
22
+ kind: 'network' | 'timeout' | 'http' | 'invalid' | 'aborted';
23
+ status?: number;
24
+ error?: string;
25
+ message?: string;
26
+ retryable: boolean;
27
+ /** 给运维看的中文说明。 */
28
+ hint: string;
29
+ }
30
+ export type ApiResult<T> = ({
31
+ ok: true;
32
+ serverTime?: string;
33
+ } & T) | ApiFailure;
34
+ export interface CallOptions {
35
+ signal?: AbortSignal;
36
+ /** 网络错误、超时、5xx 时的重试等待(毫秒),长度就是重试次数。 */
37
+ retryDelays?: number[];
38
+ }
39
+ export interface AaClientOptions {
40
+ baseUrl: string;
41
+ keyId: string;
42
+ secret: string;
43
+ timeoutMs: number;
44
+ now?: () => number;
45
+ }
46
+ export declare class AaClient {
47
+ private ctx;
48
+ private options;
49
+ /** 最近一次成功响应里 AA 的时间与本机时间之差(毫秒,AA 减本机)。 */
50
+ clockSkewMs: number | null;
51
+ constructor(ctx: Context, options: AaClientOptions);
52
+ private now;
53
+ endpoint(name: string): URL;
54
+ /** 调用一个接口,返回解析后的 JSON 对象或失败说明。 */
55
+ call(name: string, payload: object, options?: CallOptions): Promise<ApiResult<{
56
+ data: any;
57
+ }>>;
58
+ private once;
59
+ private request;
60
+ private noteServerTime;
61
+ health(options?: CallOptions): Promise<ApiResult<{
62
+ version: string;
63
+ configOk: boolean;
64
+ problems: string[];
65
+ }>>;
66
+ groups(options?: CallOptions): Promise<ApiResult<{
67
+ groups: ManagedGroup[];
68
+ }>>;
69
+ /**
70
+ * 对一个群的一组 QQ 求判定。结果按请求顺序一一对应(API.md 5.3);
71
+ * 顺序对不上、QQ 回显不一致、判定值不认识的项,都当作 unknown。
72
+ */
73
+ check(groupId: string, qqs: string[], fullRoster: boolean, options?: CallOptions): Promise<ApiResult<{
74
+ verdicts: Map<string, Verdict>;
75
+ roster: unknown;
76
+ }>>;
77
+ /** 入群申请:申请人 QQ + 验证信息原文。只看 result.decision(API.md 5.4)。 */
78
+ claim(qq: string, text: string, groupId: string, options?: CallOptions): Promise<ApiResult<{
79
+ verdict: Verdict;
80
+ outcome: string;
81
+ message: string;
82
+ claimed: boolean;
83
+ }>>;
84
+ events(after: number, limit: number, options?: CallOptions): Promise<ApiResult<{
85
+ events: AaEvent[];
86
+ lastId: number;
87
+ hasMore: boolean;
88
+ }>>;
89
+ }
90
+ /** 一行给运维看的失败说明。 */
91
+ export declare function describeFailure(result: ApiFailure): string;
package/lib/aa.js ADDED
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ // 调用 AA(aa-qqbot)的机器人接口。接口契约见 aa-qqbot 仓库 API.md。
3
+ //
4
+ // 核心原则(API.md 1.1):只有「HTTP 200 + JSON + ok === true + 字段结构正确」才算拿到了答案;
5
+ // 其余一切(网络错误、超时、重定向、网页、4xx/5xx、结构不对)都是「无法判断」,调用方绝不能据此处置任何人。
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.AaClient = void 0;
8
+ exports.describeFailure = describeFailure;
9
+ const signing_1 = require("./signing");
10
+ const texts_1 = require("./texts");
11
+ const util_1 = require("./util");
12
+ const DEFAULT_RETRY_DELAYS = [3_000, 15_000];
13
+ const MAX_RETRY_AFTER_MS = 120_000;
14
+ class AaClient {
15
+ ctx;
16
+ options;
17
+ /** 最近一次成功响应里 AA 的时间与本机时间之差(毫秒,AA 减本机)。 */
18
+ clockSkewMs = null;
19
+ constructor(ctx, options) {
20
+ this.ctx = ctx;
21
+ this.options = options;
22
+ }
23
+ now() {
24
+ return this.options.now?.() ?? Date.now();
25
+ }
26
+ endpoint(name) {
27
+ const base = this.options.baseUrl.trim().replace(/\/+$/, '');
28
+ return new URL(`${base}/qqbot/api/v1/${name}/`);
29
+ }
30
+ /** 调用一个接口,返回解析后的 JSON 对象或失败说明。 */
31
+ async call(name, payload, options = {}) {
32
+ let url;
33
+ try {
34
+ url = this.endpoint(name);
35
+ }
36
+ catch {
37
+ return failure('invalid', { hint: 'AA 网址格式不对,请检查插件配置', retryable: false });
38
+ }
39
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
40
+ return failure('invalid', { hint: 'AA 网址必须以 https:// 开头', retryable: false });
41
+ }
42
+ const bodyText = JSON.stringify(payload); // 只序列化一次,签名和发送用同一个字符串
43
+ const delays = options.retryDelays ?? DEFAULT_RETRY_DELAYS;
44
+ let attempt = 0;
45
+ while (true) {
46
+ const result = await this.once(url, bodyText, options.signal);
47
+ if (result.ok || !result.retryable || attempt >= delays.length)
48
+ return result;
49
+ let wait = delays[attempt++];
50
+ if (result.status === 429 && result.retryAfterMs)
51
+ wait = result.retryAfterMs;
52
+ try {
53
+ await (0, util_1.sleep)(wait, options.signal);
54
+ }
55
+ catch {
56
+ return failure('aborted', { hint: '已中止', retryable: false });
57
+ }
58
+ }
59
+ }
60
+ async once(url, bodyText, signal) {
61
+ if (signal?.aborted)
62
+ return failure('aborted', { hint: '已中止', retryable: false });
63
+ // HTTP 插件会在传入的 signal 上挂监听器且不移除;每次请求用一个临时的 signal,用完解绑,避免长期累积
64
+ const local = new AbortController();
65
+ const forward = () => local.abort();
66
+ signal?.addEventListener('abort', forward, { once: true });
67
+ try {
68
+ return await this.request(url, bodyText, local.signal, signal);
69
+ }
70
+ finally {
71
+ signal?.removeEventListener('abort', forward);
72
+ }
73
+ }
74
+ async request(url, bodyText, requestSignal, signal) {
75
+ // 每次请求(包括重试)都重新生成时间戳、随机数和签名
76
+ const headers = (0, signing_1.signedHeaders)(this.options.keyId, this.options.secret, url.pathname, bodyText, this.now());
77
+ let response;
78
+ try {
79
+ response = await this.ctx.http(url.href, {
80
+ method: 'POST',
81
+ data: bodyText,
82
+ headers: { ...headers },
83
+ redirect: 'manual', // 绝不跟随重定向(API.md 1.1 第 3 条)
84
+ validateStatus: () => true, // 状态码由下面自己判断
85
+ responseType: 'text', // 自己解析 JSON,不让 HTTP 库按 Content-Type 猜
86
+ timeout: this.options.timeoutMs,
87
+ signal: requestSignal,
88
+ });
89
+ }
90
+ catch (error) {
91
+ if (signal?.aborted || error instanceof util_1.AbortedError)
92
+ return failure('aborted', { hint: '已中止', retryable: false });
93
+ if (error?.code === 'ETIMEDOUT') {
94
+ if (/disposed/.test(String(error?.message)))
95
+ return failure('aborted', { hint: '插件已停用', retryable: false });
96
+ return failure('timeout', { hint: `等待 AA 超过 ${Math.round(this.options.timeoutMs / 1000)} 秒没有回应`, retryable: true });
97
+ }
98
+ const cause = error?.cause?.cause?.code ?? error?.cause?.code ?? error?.cause?.message ?? error?.message;
99
+ return failure('network', { hint: `无法连接 AA(${String(cause ?? '网络错误')})`, retryable: true });
100
+ }
101
+ const status = response.status;
102
+ let data = null;
103
+ if (typeof response.data === 'string') {
104
+ try {
105
+ data = JSON.parse(response.data);
106
+ }
107
+ catch {
108
+ data = null;
109
+ }
110
+ }
111
+ if (status === 200) {
112
+ if (data && typeof data === 'object' && !Array.isArray(data) && data.ok === true) {
113
+ this.noteServerTime(data.server_time);
114
+ return { ok: true, data, serverTime: typeof data.server_time === 'string' ? data.server_time : undefined };
115
+ }
116
+ return failure('invalid', { status, hint: 'AA 返回了 200,但内容不是预期的 JSON(可能是网页或代理页面)', retryable: false });
117
+ }
118
+ const error = data && typeof data.error === 'string' ? data.error : undefined;
119
+ const message = data && typeof data.message === 'string' ? data.message : undefined;
120
+ const location = response.headers?.get?.('location');
121
+ const result = failure('http', {
122
+ status,
123
+ error,
124
+ message,
125
+ hint: (0, texts_1.errorHint)(error, status, location),
126
+ // 只有 5xx 值得重试;AA 没配密钥(503 misconfigured)重试也没用
127
+ retryable: status >= 500 && error !== 'misconfigured',
128
+ });
129
+ if (status === 429) {
130
+ const seconds = Number(response.headers?.get?.('retry-after'));
131
+ result.retryAfterMs = Math.min(Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 30_000, MAX_RETRY_AFTER_MS);
132
+ result.retryable = true;
133
+ }
134
+ return result;
135
+ }
136
+ noteServerTime(value) {
137
+ if (typeof value !== 'string')
138
+ return;
139
+ const time = Date.parse(value);
140
+ if (Number.isFinite(time))
141
+ this.clockSkewMs = time - this.now();
142
+ }
143
+ // ---------------------------------------------------------------- 各接口
144
+ async health(options) {
145
+ const result = await this.call('health', {}, options);
146
+ if (!result.ok)
147
+ return result;
148
+ const { data } = result;
149
+ return {
150
+ ok: true,
151
+ serverTime: result.serverTime,
152
+ version: typeof data.version === 'string' ? data.version : '?',
153
+ configOk: data.config_ok === true,
154
+ problems: Array.isArray(data.problems) ? data.problems.filter((p) => typeof p === 'string') : [],
155
+ };
156
+ }
157
+ async groups(options) {
158
+ const result = await this.call('groups', {}, options);
159
+ if (!result.ok)
160
+ return result;
161
+ if (!Array.isArray(result.data.groups))
162
+ return invalid('groups 响应里没有 groups 列表');
163
+ const groups = [];
164
+ for (const item of result.data.groups) {
165
+ const groupId = (0, util_1.normalizeId)(item?.group_id);
166
+ if (!groupId)
167
+ continue;
168
+ groups.push({
169
+ groupId,
170
+ name: typeof item.name === 'string' ? item.name : groupId,
171
+ kind: typeof item.kind === 'string' ? item.kind : 'fixed',
172
+ });
173
+ }
174
+ return { ok: true, serverTime: result.serverTime, groups };
175
+ }
176
+ /**
177
+ * 对一个群的一组 QQ 求判定。结果按请求顺序一一对应(API.md 5.3);
178
+ * 顺序对不上、QQ 回显不一致、判定值不认识的项,都当作 unknown。
179
+ */
180
+ async check(groupId, qqs, fullRoster, options) {
181
+ const result = await this.call('check', { group_id: groupId, qqs, full_roster: fullRoster }, options);
182
+ if (!result.ok)
183
+ return result;
184
+ const results = result.data.results;
185
+ if (!Array.isArray(results) || results.length !== qqs.length) {
186
+ return invalid('check 响应的 results 数量和请求的不一致');
187
+ }
188
+ const verdicts = new Map();
189
+ qqs.forEach((qq, index) => {
190
+ verdicts.set(qq, parseVerdict(results[index], qq));
191
+ });
192
+ return { ok: true, serverTime: result.serverTime, verdicts, roster: result.data.roster };
193
+ }
194
+ /** 入群申请:申请人 QQ + 验证信息原文。只看 result.decision(API.md 5.4)。 */
195
+ async claim(qq, text, groupId, options) {
196
+ const result = await this.call('claim', { qq, text: text.slice(0, 2000), group_id: groupId }, options);
197
+ if (!result.ok)
198
+ return result;
199
+ const { data } = result;
200
+ if (!data.result || typeof data.result !== 'object')
201
+ return invalid('claim 响应里没有 result');
202
+ return {
203
+ ok: true,
204
+ serverTime: result.serverTime,
205
+ verdict: parseVerdict(data.result, qq),
206
+ outcome: typeof data.outcome === 'string' ? data.outcome : '',
207
+ message: typeof data.message === 'string' ? data.message : '',
208
+ claimed: data.claimed === true,
209
+ };
210
+ }
211
+ async events(after, limit, options) {
212
+ const result = await this.call('events', { after, limit }, options);
213
+ if (!result.ok)
214
+ return result;
215
+ const { data } = result;
216
+ if (!Array.isArray(data.events) || !Number.isSafeInteger(data.last_id) || data.last_id < after) {
217
+ return invalid('events 响应格式不对');
218
+ }
219
+ const events = [];
220
+ for (const item of data.events) {
221
+ if (!item || !Number.isSafeInteger(item.id) || typeof item.kind !== 'string')
222
+ continue;
223
+ events.push({ id: item.id, kind: item.kind, qq: (0, util_1.normalizeId)(item.qq) ?? '' });
224
+ }
225
+ return { ok: true, serverTime: result.serverTime, events, lastId: data.last_id, hasMore: data.has_more === true };
226
+ }
227
+ }
228
+ exports.AaClient = AaClient;
229
+ function parseVerdict(item, requestedQq) {
230
+ const unknown = { qq: requestedQq, decision: 'unknown', reason: 'UNKNOWN', card: null };
231
+ if (!item || typeof item !== 'object')
232
+ return unknown;
233
+ // AA 回显的 QQ 必须和请求的一致,否则不信任这一项
234
+ if ((0, util_1.normalizeId)(item.qq) !== requestedQq)
235
+ return unknown;
236
+ const reason = typeof item.reason === 'string' ? item.reason : 'UNKNOWN';
237
+ const decision = item.decision === 'allow' || item.decision === 'deny' || item.decision === 'review'
238
+ ? item.decision
239
+ : 'unknown'; // 不认识的 decision 一律当作「不处置」
240
+ const card = decision === 'allow' && typeof item.card === 'string' && item.card.trim() ? item.card : null;
241
+ return { qq: requestedQq, decision, reason, card };
242
+ }
243
+ function failure(kind, rest) {
244
+ return { ok: false, kind, ...rest };
245
+ }
246
+ function invalid(hint) {
247
+ return failure('invalid', { status: 200, hint: `AA 的响应格式不对:${hint}`, retryable: false });
248
+ }
249
+ /** 一行给运维看的失败说明。 */
250
+ function describeFailure(result) {
251
+ const parts = [result.hint];
252
+ if (result.message && result.message !== result.hint)
253
+ parts.push(`AA 说明:${result.message}`);
254
+ return parts.join(';');
255
+ }
@@ -0,0 +1,3 @@
1
+ import { Context } from 'koishi';
2
+ import type { Guard } from './guard';
3
+ export declare function registerCommands(ctx: Context, guard: Guard): void;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ // 管理命令。必须同时满足三个条件才能用(DECISIONS 第 10 条、交接文档 R13):
3
+ // ① 在运维群里或私聊机器人;② QQ 在运维名单里;③ Koishi 权限等级 ≥ 3。
4
+ // 在其他任何群(包括受管群)里发命令,一律不回应。
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerCommands = registerCommands;
7
+ const koishi_1 = require("koishi");
8
+ const util_1 = require("./util");
9
+ function registerCommands(ctx, guard) {
10
+ const gate = ({ session }) => {
11
+ if (!session || session.platform !== 'onebot')
12
+ return '';
13
+ const admin = guard.adminGroup();
14
+ const inPrivate = session.isDirect;
15
+ const inAdminGroup = !session.isDirect && !!admin && (0, util_1.normalizeId)(session.guildId) === admin;
16
+ if (!inPrivate && !inAdminGroup)
17
+ return ''; // 不回应
18
+ const qq = (0, util_1.normalizeId)(session.userId);
19
+ if (!qq || !guard.operators().has(qq))
20
+ return koishi_1.h.text('你不在运维名单里,不能使用这个命令。');
21
+ // 返回 undefined 表示继续,接下来由 Koishi 检查权限等级(authority: 3)
22
+ };
23
+ const define = (decl, description) => ctx.command(decl, description, { authority: 3 }).before(gate);
24
+ define('aaqq', 'AA QQ 群管理').action(() => koishi_1.h.text([
25
+ 'AA QQ 群管理命令:',
26
+ 'aaqq.status 查看状态',
27
+ 'aaqq.health 检查与 AA 的连接',
28
+ 'aaqq.patrol [群号] 立即巡检(不填群号就巡检全部)',
29
+ 'aaqq.confirm <群号> 确认模式升级 / 解除熔断',
30
+ 'aaqq.check <QQ号> 查询某个 QQ 的判定',
31
+ 'aaqq.pause 紧急暂停(停止一切审批、提醒、改名片、移出)',
32
+ 'aaqq.resume 恢复',
33
+ ].join('\n')));
34
+ define('aaqq.status', '查看状态').action(async () => koishi_1.h.text(await guard.statusText()));
35
+ define('aaqq.health', '检查与 AA 的连接').action(async () => koishi_1.h.text(await guard.checkHealth(false)));
36
+ define('aaqq.patrol [group:string]', '立即巡检').action(async (_, group) => {
37
+ if (guard.paused)
38
+ return koishi_1.h.text('插件暂停中,先发送 aaqq.resume 恢复。');
39
+ let only;
40
+ if (group) {
41
+ const groupId = (0, util_1.normalizeId)(group);
42
+ if (!groupId || !guard.group(groupId))
43
+ return koishi_1.h.text(`${group} 不是 AA 上的受管群。`);
44
+ only = [groupId];
45
+ }
46
+ if (guard.patrolRunning) {
47
+ guard.requestPatrol(only);
48
+ return koishi_1.h.text('正在巡检中,这一轮结束后会马上再巡检一次。');
49
+ }
50
+ guard.requestPatrol(only);
51
+ return koishi_1.h.text('已开始巡检,结果会发到运维群。');
52
+ });
53
+ define('aaqq.confirm <group:string>', '确认模式升级 / 解除熔断').action(async ({ session }, group) => {
54
+ const groupId = (0, util_1.normalizeId)(group);
55
+ if (!groupId)
56
+ return koishi_1.h.text('用法:aaqq.confirm 群号');
57
+ return koishi_1.h.text(await guard.confirm(groupId, session?.userId ?? ''));
58
+ });
59
+ define('aaqq.check <qq:string>', '查询某个 QQ 的判定').action(async (_, qq) => {
60
+ const id = (0, util_1.normalizeId)(qq);
61
+ if (!id)
62
+ return koishi_1.h.text('用法:aaqq.check QQ号(5–11 位数字)');
63
+ return koishi_1.h.text(await guard.checkQq(id));
64
+ });
65
+ define('aaqq.pause', '紧急暂停').action(async ({ session }) => {
66
+ await guard.setPaused(true, session?.userId ?? '');
67
+ return koishi_1.h.text('⏸ 已暂停:正在进行的巡检已中止;在恢复之前不会审批、提醒、改名片或移出任何人(入群申请留给管理员)。发送 aaqq.resume 恢复。');
68
+ });
69
+ define('aaqq.resume', '恢复').action(async ({ session }) => {
70
+ await guard.setPaused(false, session?.userId ?? '');
71
+ return koishi_1.h.text('▶ 已恢复,马上巡检一次。');
72
+ });
73
+ }
@@ -0,0 +1,39 @@
1
+ import { Schema } from 'koishi';
2
+ export type Mode = 'off' | 'report' | 'remind' | 'enforce';
3
+ export declare const MODE_RANK: Record<Mode, number>;
4
+ export interface GroupModeEntry {
5
+ groupId: string;
6
+ mode: Mode;
7
+ }
8
+ export interface Config {
9
+ aaBaseUrl: string;
10
+ keyId: string;
11
+ secret: string;
12
+ timeoutSeconds: number;
13
+ bindUrl: string;
14
+ botId: string;
15
+ adminGroupId: string;
16
+ operators: string[];
17
+ whitelist: string[];
18
+ defaultMode: Mode;
19
+ groupModes: GroupModeEntry[];
20
+ autoReject: boolean;
21
+ rejectTemplate: string;
22
+ inviteHandling: 'same' | 'manual';
23
+ catchUpRequests: boolean;
24
+ patrolIntervalHours: number;
25
+ eventPollSeconds: number;
26
+ syncCards: boolean;
27
+ graceHours: number;
28
+ remindTime: string;
29
+ remindTemplate: string;
30
+ warnTemplate: string;
31
+ markCards: boolean;
32
+ markPrefix: string;
33
+ kickAnnounce: boolean;
34
+ kickAnnounceTemplate: string;
35
+ breakerCount: number;
36
+ breakerPercent: number;
37
+ kickPerHour: number;
38
+ }
39
+ export declare const Config: Schema<Config>;