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.
@@ -0,0 +1,16 @@
1
+ export declare function sha256Hex(text: string): string;
2
+ /** 五段用单个 `\n` 连接、末尾没有换行的待签名字符串。 */
3
+ export declare function signingString(path: string, timestamp: string, nonce: string, bodyText: string): string;
4
+ export declare function sign(secret: string, path: string, timestamp: string, nonce: string, bodyText: string): string;
5
+ /** 32 个十六进制字符,符合 API 要求的 16–64 位 `[A-Za-z0-9_-]`。 */
6
+ export declare function makeNonce(): string;
7
+ /** 当前 Unix 时间,单位秒。 */
8
+ export declare function unixSeconds(now?: number): string;
9
+ export interface SignedHeaders {
10
+ 'Content-Type': string;
11
+ 'X-QQBot-Key': string;
12
+ 'X-QQBot-Timestamp': string;
13
+ 'X-QQBot-Nonce': string;
14
+ 'X-QQBot-Signature': string;
15
+ }
16
+ export declare function signedHeaders(keyId: string, secret: string, path: string, bodyText: string, now?: number): SignedHeaders;
package/lib/signing.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // 请求签名,算法见 aa-qqbot 仓库 API.md 第 2 节。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.sha256Hex = sha256Hex;
5
+ exports.signingString = signingString;
6
+ exports.sign = sign;
7
+ exports.makeNonce = makeNonce;
8
+ exports.unixSeconds = unixSeconds;
9
+ exports.signedHeaders = signedHeaders;
10
+ const node_crypto_1 = require("node:crypto");
11
+ function sha256Hex(text) {
12
+ return (0, node_crypto_1.createHash)('sha256').update(text, 'utf8').digest('hex');
13
+ }
14
+ /** 五段用单个 `\n` 连接、末尾没有换行的待签名字符串。 */
15
+ function signingString(path, timestamp, nonce, bodyText) {
16
+ return ['POST', path, timestamp, nonce, sha256Hex(bodyText)].join('\n');
17
+ }
18
+ function sign(secret, path, timestamp, nonce, bodyText) {
19
+ return (0, node_crypto_1.createHmac)('sha256', secret).update(signingString(path, timestamp, nonce, bodyText), 'utf8').digest('hex');
20
+ }
21
+ /** 32 个十六进制字符,符合 API 要求的 16–64 位 `[A-Za-z0-9_-]`。 */
22
+ function makeNonce() {
23
+ return (0, node_crypto_1.randomBytes)(16).toString('hex');
24
+ }
25
+ /** 当前 Unix 时间,单位秒。 */
26
+ function unixSeconds(now = Date.now()) {
27
+ return Math.floor(now / 1000).toString();
28
+ }
29
+ function signedHeaders(keyId, secret, path, bodyText, now = Date.now()) {
30
+ const timestamp = unixSeconds(now);
31
+ const nonce = makeNonce();
32
+ return {
33
+ 'Content-Type': 'application/json',
34
+ 'X-QQBot-Key': keyId,
35
+ 'X-QQBot-Timestamp': timestamp,
36
+ 'X-QQBot-Nonce': nonce,
37
+ 'X-QQBot-Signature': sign(secret, path, timestamp, nonce, bodyText),
38
+ };
39
+ }
package/lib/store.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { Context } from 'koishi';
2
+ import type { Mode } from './config';
3
+ /** 被发现不合格、正在宽限期里的成员。 */
4
+ export interface TrackedMember {
5
+ groupId: string;
6
+ qq: string;
7
+ reason: string;
8
+ firstDeniedAt: Date;
9
+ /** enforce 模式下的移出截止时间;remind 模式下为 null(只提醒不踢)。 */
10
+ graceUntil: Date | null;
11
+ marked: boolean;
12
+ lastRemindedAt: Date | null;
13
+ }
14
+ export interface GroupState {
15
+ groupId: string;
16
+ /** 管理员确认过的最高模式;升级到 remind / enforce 必须经过确认。 */
17
+ confirmedMode: string;
18
+ confirmedAt: Date | null;
19
+ confirmedBy: string;
20
+ /** 熔断开始的时间;不为 null 表示这个群处于熔断状态。 */
21
+ holdSince: Date | null;
22
+ holdNote: string;
23
+ /** 管理员最近一次 aaqq.confirm 的时间。截止时间早于它的移出视为已确认。 */
24
+ lastConfirmAt: Date | null;
25
+ /** 确认后的豁免:在这个时间之前的下一轮巡检,人数不超过下面两个数就不熔断。 */
26
+ bypassUntil: Date | null;
27
+ bypassMaxNew: number;
28
+ bypassMaxKicks: number;
29
+ /** 最近一轮巡检的「新发现不合格」和「到期要移出」人数(确认时用作豁免上限)。 */
30
+ lastNewDenies: number;
31
+ lastKicksDue: number;
32
+ lastPatrolAt: Date | null;
33
+ lastPatrolOk: boolean;
34
+ lastPatrolNote: string;
35
+ }
36
+ interface KvRow {
37
+ key: string;
38
+ value: string;
39
+ }
40
+ export interface AuditRow {
41
+ id: number;
42
+ at: Date;
43
+ action: string;
44
+ groupId: string;
45
+ qq: string;
46
+ detail: string;
47
+ actor: string;
48
+ }
49
+ declare module 'koishi' {
50
+ interface Tables {
51
+ aaqqbot_member: TrackedMember;
52
+ aaqqbot_group: GroupState;
53
+ aaqqbot_kv: KvRow;
54
+ aaqqbot_audit: AuditRow;
55
+ }
56
+ }
57
+ export declare function extendModels(ctx: Context): void;
58
+ export declare function defaultGroupState(groupId: string): GroupState;
59
+ export declare class Store {
60
+ private ctx;
61
+ private now;
62
+ constructor(ctx: Context, now?: () => number);
63
+ private get db();
64
+ tracked(groupId: string): Promise<Map<string, TrackedMember>>;
65
+ saveTracked(rows: TrackedMember[]): Promise<void>;
66
+ /** 只更新还存在的记录(不会把刚被删除的人重新写回来)。 */
67
+ markReminded(groupId: string, rows: Array<Pick<TrackedMember, 'qq' | 'graceUntil' | 'lastRemindedAt'>>): Promise<void>;
68
+ removeTracked(groupId: string, qqs: string[]): Promise<void>;
69
+ /** 群从 AA 上移除:宽限记录和确认状态都清掉,以后重新加回来要重新确认。 */
70
+ forgetGroup(groupId: string): Promise<void>;
71
+ groupState(groupId: string): Promise<GroupState>;
72
+ setGroupState(groupId: string, patch: Partial<GroupState>): Promise<void>;
73
+ getKv<T>(key: string): Promise<T | undefined>;
74
+ setKv(key: string, value: unknown): Promise<void>;
75
+ audit(action: string, groupId: string, qq: string, detail: string, actor?: string): Promise<void>;
76
+ countAudit(action: string, groupId: string, since: Date): Promise<number>;
77
+ recentAudit(limit: number): Promise<AuditRow[]>;
78
+ pruneAudit(before: Date): Promise<void>;
79
+ }
80
+ export declare function isMode(value: string): value is Mode;
81
+ export {};
package/lib/store.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ // 插件自己的数据表(表名都带 aaqqbot_ 前缀)。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.Store = void 0;
5
+ exports.extendModels = extendModels;
6
+ exports.defaultGroupState = defaultGroupState;
7
+ exports.isMode = isMode;
8
+ function extendModels(ctx) {
9
+ ctx.model.extend('aaqqbot_member', {
10
+ groupId: { type: 'string', length: 32 },
11
+ qq: { type: 'string', length: 32 },
12
+ reason: { type: 'string', length: 64 },
13
+ firstDeniedAt: 'timestamp',
14
+ graceUntil: { type: 'timestamp', nullable: true },
15
+ marked: 'boolean',
16
+ lastRemindedAt: { type: 'timestamp', nullable: true },
17
+ }, { primary: ['groupId', 'qq'] });
18
+ ctx.model.extend('aaqqbot_group', {
19
+ groupId: { type: 'string', length: 32 },
20
+ confirmedMode: { type: 'string', length: 16 },
21
+ confirmedAt: { type: 'timestamp', nullable: true },
22
+ confirmedBy: { type: 'string', length: 64 },
23
+ holdSince: { type: 'timestamp', nullable: true },
24
+ holdNote: 'text',
25
+ lastConfirmAt: { type: 'timestamp', nullable: true },
26
+ bypassUntil: { type: 'timestamp', nullable: true },
27
+ bypassMaxNew: 'unsigned',
28
+ bypassMaxKicks: 'unsigned',
29
+ lastNewDenies: 'unsigned',
30
+ lastKicksDue: 'unsigned',
31
+ lastPatrolAt: { type: 'timestamp', nullable: true },
32
+ lastPatrolOk: 'boolean',
33
+ lastPatrolNote: 'text',
34
+ }, { primary: 'groupId' });
35
+ ctx.model.extend('aaqqbot_kv', {
36
+ key: { type: 'string', length: 64 },
37
+ value: 'text',
38
+ }, { primary: 'key' });
39
+ ctx.model.extend('aaqqbot_audit', {
40
+ id: 'unsigned',
41
+ at: 'timestamp',
42
+ action: { type: 'string', length: 32 },
43
+ groupId: { type: 'string', length: 32 },
44
+ qq: { type: 'string', length: 32 },
45
+ detail: 'text',
46
+ actor: { type: 'string', length: 64 },
47
+ }, { primary: 'id', autoInc: true });
48
+ }
49
+ function defaultGroupState(groupId) {
50
+ return {
51
+ groupId,
52
+ confirmedMode: 'report',
53
+ confirmedAt: null,
54
+ confirmedBy: '',
55
+ holdSince: null,
56
+ holdNote: '',
57
+ lastConfirmAt: null,
58
+ bypassUntil: null,
59
+ bypassMaxNew: 0,
60
+ bypassMaxKicks: 0,
61
+ lastNewDenies: 0,
62
+ lastKicksDue: 0,
63
+ lastPatrolAt: null,
64
+ lastPatrolOk: false,
65
+ lastPatrolNote: '',
66
+ };
67
+ }
68
+ class Store {
69
+ ctx;
70
+ now;
71
+ constructor(ctx, now = Date.now) {
72
+ this.ctx = ctx;
73
+ this.now = now;
74
+ }
75
+ get db() {
76
+ return this.ctx.database;
77
+ }
78
+ async tracked(groupId) {
79
+ const rows = await this.db.get('aaqqbot_member', { groupId });
80
+ return new Map(rows.map((row) => [row.qq, row]));
81
+ }
82
+ async saveTracked(rows) {
83
+ if (rows.length)
84
+ await this.db.upsert('aaqqbot_member', rows);
85
+ }
86
+ /** 只更新还存在的记录(不会把刚被删除的人重新写回来)。 */
87
+ async markReminded(groupId, rows) {
88
+ for (const row of rows) {
89
+ await this.db.set('aaqqbot_member', { groupId, qq: row.qq }, { graceUntil: row.graceUntil, lastRemindedAt: row.lastRemindedAt });
90
+ }
91
+ }
92
+ async removeTracked(groupId, qqs) {
93
+ if (qqs.length)
94
+ await this.db.remove('aaqqbot_member', { groupId, qq: qqs });
95
+ }
96
+ /** 群从 AA 上移除:宽限记录和确认状态都清掉,以后重新加回来要重新确认。 */
97
+ async forgetGroup(groupId) {
98
+ await this.db.remove('aaqqbot_member', { groupId });
99
+ await this.db.remove('aaqqbot_group', { groupId });
100
+ }
101
+ async groupState(groupId) {
102
+ const [row] = await this.db.get('aaqqbot_group', { groupId });
103
+ return row ?? defaultGroupState(groupId);
104
+ }
105
+ async setGroupState(groupId, patch) {
106
+ const current = await this.groupState(groupId);
107
+ await this.db.upsert('aaqqbot_group', [{ ...current, ...patch, groupId }]);
108
+ }
109
+ async getKv(key) {
110
+ const [row] = await this.db.get('aaqqbot_kv', { key });
111
+ if (!row)
112
+ return undefined;
113
+ try {
114
+ return JSON.parse(row.value);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ async setKv(key, value) {
121
+ await this.db.upsert('aaqqbot_kv', [{ key, value: JSON.stringify(value) }]);
122
+ }
123
+ async audit(action, groupId, qq, detail, actor = 'bot') {
124
+ await this.db.create('aaqqbot_audit', { at: new Date(this.now()), action, groupId, qq, detail, actor });
125
+ }
126
+ async countAudit(action, groupId, since) {
127
+ const rows = await this.db.get('aaqqbot_audit', { action, groupId, at: { $gte: since } }, ['id']);
128
+ return rows.length;
129
+ }
130
+ async recentAudit(limit) {
131
+ return this.db.select('aaqqbot_audit').orderBy('id', 'desc').limit(limit).execute();
132
+ }
133
+ async pruneAudit(before) {
134
+ await this.db.remove('aaqqbot_audit', { at: { $lt: before } });
135
+ }
136
+ }
137
+ exports.Store = Store;
138
+ function isMode(value) {
139
+ return value === 'off' || value === 'report' || value === 'remind' || value === 'enforce';
140
+ }
package/lib/texts.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function reasonShort(reason: string): string;
2
+ export declare function rejectHint(outcome: string | undefined, reason: string): string;
3
+ export declare const MODE_TEXT: Record<string, string>;
4
+ export declare function errorHint(error: string | undefined, status: number | undefined, location?: string | null): string;
package/lib/texts.js ADDED
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ // 给人看的中文说明:判定原因、验证码结果、AA 接口错误。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.MODE_TEXT = void 0;
5
+ exports.reasonShort = reasonShort;
6
+ exports.rejectHint = rejectHint;
7
+ exports.errorHint = errorHint;
8
+ /** 群里 @ 提醒、运维报告里用的简短原因。 */
9
+ const REASON_SHORT = {
10
+ OK: '合格',
11
+ NOT_BOUND: '没有在 AA 绑定 QQ',
12
+ PENDING_VERIFY: '已在 AA 提交但还没验证',
13
+ USER_INACTIVE: 'AA 账号已停用',
14
+ NO_MAIN: 'AA 账号没有主角色',
15
+ NO_ACCESS: 'AA 账号没有成员资格',
16
+ GROUP_ROLE_MISSING: '不在本群要求的 AA 组里',
17
+ CONFLICT: '同一个 QQ 被两个 AA 账号认领',
18
+ GROUP_MISCONFIGURED: 'AA 上这个群的设置不完整',
19
+ BAD_QQ: 'QQ 号格式不对',
20
+ };
21
+ function reasonShort(reason) {
22
+ return REASON_SHORT[reason] ?? `不满足条件(${reason})`;
23
+ }
24
+ /** 拒绝入群申请时给申请人看的说明(按验证码结果优先,其次按判定原因)。 */
25
+ const OUTCOME_HINT = {
26
+ code_invalid: '验证码不对,请核对 AA 上显示的验证码后重新申请',
27
+ code_expired: '验证码已过期,请在 AA 上重新生成验证码后重新申请',
28
+ code_used: '验证码已经用过,请在 AA 上重新生成验证码后重新申请',
29
+ qq_mismatch: '这个验证码不是给这个 QQ 的,请确认 AA 上填写的 QQ 号正确,重新生成验证码后再申请',
30
+ };
31
+ const REASON_HINT = {
32
+ NOT_BOUND: '请先登录联盟 AA 绑定 QQ,再把验证码填进入群申请的验证信息',
33
+ PENDING_VERIFY: '请把 AA 上显示的验证码填进入群申请的验证信息',
34
+ USER_INACTIVE: '你的 AA 账号已停用,请联系管理员',
35
+ NO_MAIN: '请先在 AA 设置主角色',
36
+ NO_ACCESS: '你的 AA 账号暂时没有成员资格,请联系管理员',
37
+ GROUP_ROLE_MISSING: '你不在本群要求的 AA 组里,请联系管理员',
38
+ };
39
+ function rejectHint(outcome, reason) {
40
+ if (outcome && OUTCOME_HINT[outcome])
41
+ return OUTCOME_HINT[outcome];
42
+ return REASON_HINT[reason] ?? '你暂时不满足入群条件,请联系管理员';
43
+ }
44
+ exports.MODE_TEXT = {
45
+ off: 'off(不管)',
46
+ report: 'report(只报告)',
47
+ remind: 'remind(提醒,不踢)',
48
+ enforce: 'enforce(提醒并移出)',
49
+ };
50
+ /** AA 错误码 → 怎么修(给运维看)。见 API.md 第 6 节。 */
51
+ const ERROR_HINT = {
52
+ bad_request: '请求内容不对,多半是插件的问题,请把日志发给插件维护者',
53
+ missing_headers: '签名请求头缺失或格式不对;检查插件配置里的「密钥编号」,不能有空格或中文',
54
+ unknown_key: '密钥编号在 AA 上不存在;插件配置里的「密钥编号」要和 AA 的 QQBOT_API_KEYS 一致',
55
+ stale_timestamp: '机器人电脑和 AA 服务器的时间相差太大;请打开机器人电脑的「自动设置时间」',
56
+ bad_signature: '签名不对;检查插件配置里的密钥是否与 AA 上的完全一致(不能多空格)',
57
+ replayed_nonce: '随机数重复(一般是网络重试造成的),会自动恢复',
58
+ unknown_group: '这个群在 AA 上不存在或已停用,插件会重新获取群列表',
59
+ method_not_allowed: '请求方法不对,请把日志发给插件维护者',
60
+ too_large: '一次发送的内容太多',
61
+ rate_limited: '请求太频繁,已被 AA 限速,稍后自动继续',
62
+ internal_error: 'AA 内部出错,稍后自动重试;多次出现请联系 IT 查看 AA 日志',
63
+ misconfigured: 'AA 没有配置机器人密钥;请联系 IT 在 local.py 配置 QQBOT_API_KEYS',
64
+ };
65
+ function errorHint(error, status, location) {
66
+ if (error && ERROR_HINT[error])
67
+ return ERROR_HINT[error];
68
+ if (status === 302 || status === 303 || status === 307) {
69
+ if (location && /login/i.test(location)) {
70
+ return 'AA 把请求转到了登录页:AA 的 local.py 缺少 APPS_WITH_PUBLIC_VIEWS += ["qqbot"],请联系 IT 追加后重启 AA';
71
+ }
72
+ return `AA 返回了重定向(HTTP ${status}):检查插件配置里的 AA 网址(用 https://,末尾不要多写路径)`;
73
+ }
74
+ if (status === 301 || status === 308) {
75
+ return `AA 返回了永久重定向(HTTP ${status}):AA 网址请用 https://`;
76
+ }
77
+ if (status === 404)
78
+ return 'AA 上找不到机器人接口(HTTP 404):检查 AA 网址,或让 IT 确认 aa-qqbot 插件已安装';
79
+ if (status === 403)
80
+ return 'AA 前面的网关或防火墙拦截了请求(HTTP 403),请让 IT 放行';
81
+ if (status === 413)
82
+ return 'AA 前面的 nginx 拒绝了请求(HTTP 413),请让 IT 把 client_max_body_size 调大到至少 1 MB';
83
+ if (status === 502 || status === 504)
84
+ return `AA 正在重启或过载(HTTP ${status}),稍后自动重试`;
85
+ if (status && status >= 500)
86
+ return `AA 服务器出错(HTTP ${status}),稍后自动重试`;
87
+ if (status)
88
+ return `AA 返回了意外的结果(HTTP ${status})`;
89
+ return '无法连接 AA';
90
+ }
package/lib/util.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** 全角数字、字母转半角,去掉首尾空白。 */
2
+ export declare function toHalfWidth(value: string): string;
3
+ /**
4
+ * 把 QQ 号或群号统一成 5–11 位的 ASCII 数字字符串;不合法时返回 null。
5
+ * 接受数字(配置文件里没加引号)、全角数字和首尾空白。
6
+ */
7
+ export declare function normalizeId(value: unknown): string | null;
8
+ /** 规范化一组号码,丢掉不合法的,去重。 */
9
+ export declare function normalizeIdList(values: readonly unknown[] | undefined): string[];
10
+ /** 把号码打码成 `12****78`,用于日志。 */
11
+ export declare function maskId(id: string): string;
12
+ /** 按 UTF-8 字节数截断,不会切断多字节字符(包括 emoji)。 */
13
+ export declare function truncateUtf8(text: string, maxBytes: number): string;
14
+ /** 解析 `HH:mm`,不合法时返回 null。 */
15
+ export declare function parseClock(text: string): {
16
+ hour: number;
17
+ minute: number;
18
+ } | null;
19
+ /** 从 `now` 起下一次到达本地时间 `hour:minute` 的时刻(毫秒时间戳)。 */
20
+ export declare function nextClockTime(now: number, hour: number, minute: number): number;
21
+ /** `9月27日 19:30` 这样的本地时间。 */
22
+ export declare function formatDeadline(time: number): string;
23
+ /** `09-25 14:00` 这样的本地时间。 */
24
+ export declare function formatShortTime(time: number): string;
25
+ /** 按占位符 `{name}` 填充模板;没有提供的占位符原样保留。 */
26
+ export declare function fillTemplate(template: string, values: Record<string, string>): string;
27
+ export declare class AbortedError extends Error {
28
+ constructor();
29
+ }
30
+ /** 等待 `ms` 毫秒;`signal` 中止时立即以 AbortedError 结束。 */
31
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
32
+ export declare function throwIfAborted(signal?: AbortSignal): void;
33
+ export declare function chunk<T>(items: readonly T[], size: number): T[][];
34
+ export declare function errorText(error: unknown): string;
package/lib/util.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ // 与业务无关的小工具:号码规范化、名片截断、时间计算、可中断的等待。
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.AbortedError = void 0;
5
+ exports.toHalfWidth = toHalfWidth;
6
+ exports.normalizeId = normalizeId;
7
+ exports.normalizeIdList = normalizeIdList;
8
+ exports.maskId = maskId;
9
+ exports.truncateUtf8 = truncateUtf8;
10
+ exports.parseClock = parseClock;
11
+ exports.nextClockTime = nextClockTime;
12
+ exports.formatDeadline = formatDeadline;
13
+ exports.formatShortTime = formatShortTime;
14
+ exports.fillTemplate = fillTemplate;
15
+ exports.sleep = sleep;
16
+ exports.throwIfAborted = throwIfAborted;
17
+ exports.chunk = chunk;
18
+ exports.errorText = errorText;
19
+ /** 全角数字、字母转半角,去掉首尾空白。 */
20
+ function toHalfWidth(value) {
21
+ return value.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)).trim();
22
+ }
23
+ const QQ_PATTERN = /^[1-9]\d{4,10}$/;
24
+ /**
25
+ * 把 QQ 号或群号统一成 5–11 位的 ASCII 数字字符串;不合法时返回 null。
26
+ * 接受数字(配置文件里没加引号)、全角数字和首尾空白。
27
+ */
28
+ function normalizeId(value) {
29
+ if (typeof value === 'number') {
30
+ if (!Number.isSafeInteger(value))
31
+ return null;
32
+ value = String(value);
33
+ }
34
+ if (typeof value !== 'string')
35
+ return null;
36
+ const text = toHalfWidth(value);
37
+ return QQ_PATTERN.test(text) ? text : null;
38
+ }
39
+ /** 规范化一组号码,丢掉不合法的,去重。 */
40
+ function normalizeIdList(values) {
41
+ const result = new Set();
42
+ for (const value of values ?? []) {
43
+ const id = normalizeId(value);
44
+ if (id)
45
+ result.add(id);
46
+ }
47
+ return [...result];
48
+ }
49
+ /** 把号码打码成 `12****78`,用于日志。 */
50
+ function maskId(id) {
51
+ if (id.length <= 4)
52
+ return '****';
53
+ return `${id.slice(0, 2)}****${id.slice(-2)}`;
54
+ }
55
+ /** 按 UTF-8 字节数截断,不会切断多字节字符(包括 emoji)。 */
56
+ function truncateUtf8(text, maxBytes) {
57
+ let bytes = 0;
58
+ let result = '';
59
+ for (const char of text) {
60
+ const size = Buffer.byteLength(char, 'utf8');
61
+ if (bytes + size > maxBytes)
62
+ break;
63
+ bytes += size;
64
+ result += char;
65
+ }
66
+ return result;
67
+ }
68
+ /** 解析 `HH:mm`,不合法时返回 null。 */
69
+ function parseClock(text) {
70
+ const match = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(toHalfWidth(text));
71
+ if (!match)
72
+ return null;
73
+ const hour = +match[1];
74
+ const minute = +match[2];
75
+ if (hour > 23 || minute > 59)
76
+ return null;
77
+ return { hour, minute };
78
+ }
79
+ /** 从 `now` 起下一次到达本地时间 `hour:minute` 的时刻(毫秒时间戳)。 */
80
+ function nextClockTime(now, hour, minute) {
81
+ const date = new Date(now);
82
+ date.setHours(hour, minute, 0, 0);
83
+ if (date.getTime() <= now)
84
+ date.setDate(date.getDate() + 1);
85
+ return date.getTime();
86
+ }
87
+ /** `9月27日 19:30` 这样的本地时间。 */
88
+ function formatDeadline(time) {
89
+ const date = new Date(time);
90
+ const pad = (n) => String(n).padStart(2, '0');
91
+ return `${date.getMonth() + 1}月${date.getDate()}日 ${pad(date.getHours())}:${pad(date.getMinutes())}`;
92
+ }
93
+ /** `09-25 14:00` 这样的本地时间。 */
94
+ function formatShortTime(time) {
95
+ const date = new Date(time);
96
+ const pad = (n) => String(n).padStart(2, '0');
97
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
98
+ }
99
+ /** 按占位符 `{name}` 填充模板;没有提供的占位符原样保留。 */
100
+ function fillTemplate(template, values) {
101
+ return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? values[key] : whole));
102
+ }
103
+ class AbortedError extends Error {
104
+ constructor() {
105
+ super('aborted');
106
+ this.name = 'AbortedError';
107
+ }
108
+ }
109
+ exports.AbortedError = AbortedError;
110
+ /** 等待 `ms` 毫秒;`signal` 中止时立即以 AbortedError 结束。 */
111
+ function sleep(ms, signal) {
112
+ return new Promise((resolve, reject) => {
113
+ if (signal?.aborted)
114
+ return reject(new AbortedError());
115
+ const timer = setTimeout(() => {
116
+ signal?.removeEventListener('abort', onAbort);
117
+ resolve();
118
+ }, ms);
119
+ const onAbort = () => {
120
+ clearTimeout(timer);
121
+ reject(new AbortedError());
122
+ };
123
+ signal?.addEventListener('abort', onAbort, { once: true });
124
+ });
125
+ }
126
+ function throwIfAborted(signal) {
127
+ if (signal?.aborted)
128
+ throw new AbortedError();
129
+ }
130
+ function chunk(items, size) {
131
+ const result = [];
132
+ for (let i = 0; i < items.length; i += size)
133
+ result.push(items.slice(i, i + size));
134
+ return result;
135
+ }
136
+ function errorText(error) {
137
+ if (error instanceof Error)
138
+ return error.message;
139
+ return String(error);
140
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "koishi-plugin-aaqqbot",
3
+ "version": "0.1.0",
4
+ "description": "配合 AllianceAuth 插件 aa-qqbot,用 QQ 机器人管理联盟 QQ 群成员:审批入群、定时巡检、提醒与移出、同步群名片",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/yilifaer/Koishi-AAqqbot-plugin.git"
16
+ },
17
+ "homepage": "https://github.com/yilifaer/Koishi-AAqqbot-plugin#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/yilifaer/Koishi-AAqqbot-plugin/issues"
20
+ },
21
+ "keywords": [
22
+ "chatbot",
23
+ "koishi",
24
+ "plugin",
25
+ "qq",
26
+ "onebot",
27
+ "allianceauth",
28
+ "eve-online"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "vitest run",
37
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
38
+ },
39
+ "peerDependencies": {
40
+ "koishi": "^4.18.0"
41
+ },
42
+ "koishi": {
43
+ "description": {
44
+ "zh": "配合 AllianceAuth 插件 aa-qqbot 管理联盟 QQ 群:审批入群、巡检、提醒、移出、同步群名片",
45
+ "en": "Guard QQ groups with AllianceAuth (aa-qqbot): join approval, patrol, reminders, removal and card sync"
46
+ },
47
+ "service": {
48
+ "required": [
49
+ "database",
50
+ "http"
51
+ ]
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@koishijs/plugin-database-memory": "^3.7.0",
56
+ "@koishijs/plugin-http": "^0.6.3",
57
+ "@types/node": "^22.20.4",
58
+ "koishi": "^4.18.11",
59
+ "koishi-plugin-adapter-onebot": "^6.9.4",
60
+ "typescript": "^5.9.3",
61
+ "vitest": "^3.2.7"
62
+ }
63
+ }