mioku-plugin-admin 2.2.3 → 2.3.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,95 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import type { VerifyConfig } from "./config";
3
+ import type { PendingVerify } from "./types";
4
+
5
+ interface ChiralCaptcha {
6
+ regions: string[];
7
+ imageDataUrl: string;
8
+ }
9
+
10
+ function extractRegions(text: string): string[] {
11
+ const matches = String(text || "").match(/[A-Za-z]\s*\d+/g);
12
+ if (!matches) return [];
13
+ return matches.map((m) => m.replace(/\s+/g, "").toUpperCase());
14
+ }
15
+
16
+ async function fetchChiralCaptcha(
17
+ apiUrl: string,
18
+ difficulty: "simple" | "hard",
19
+ ): Promise<ChiralCaptcha> {
20
+ const res = await fetch(
21
+ `${apiUrl}/captcha/chiralCarbon/getChiralCarbonCaptcha`,
22
+ {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify({ answer: true, hint: difficulty === "simple" }),
26
+ },
27
+ );
28
+ if (!res.ok) {
29
+ throw new Error(`手性碳验证接口返回 ${res.status}`);
30
+ }
31
+ const json: any = await res.json();
32
+ const data = json?.data?.data;
33
+ const regions: string[] = Array.isArray(data?.regions)
34
+ ? data.regions.map((r: any) => String(r).toUpperCase())
35
+ : [];
36
+ const imageDataUrl = String(data?.base64 || "").trim();
37
+ if (!regions.length || !imageDataUrl) {
38
+ throw new Error("手性碳验证接口返回数据缺失");
39
+ }
40
+ return { regions, imageDataUrl };
41
+ }
42
+
43
+ export async function prepareChiral(
44
+ ctx: MiokiContext,
45
+ cfg: VerifyConfig,
46
+ p: PendingVerify,
47
+ ): Promise<boolean> {
48
+ const bot = ctx.pickBot(p.selfId);
49
+ if (!bot) return false;
50
+ try {
51
+ const captcha = await fetchChiralCaptcha(cfg.chiralApiUrl, cfg.chiralDifficulty);
52
+ p.requiredRegions = captcha.regions;
53
+ p.matchedRegions = new Set<string>();
54
+ const prompt = cfg.chiralPrompt.replace(
55
+ "{count}",
56
+ String(captcha.regions.length),
57
+ );
58
+ await bot.sendGroupMsg(p.groupId, [
59
+ ctx.segment.at(String(p.userId)),
60
+ ctx.segment.text(` ${prompt}`),
61
+ ctx.segment.image(captcha.imageDataUrl),
62
+ ]);
63
+ return true;
64
+ } catch (err) {
65
+ ctx.logger.error(`admin verify 手性碳验证准备失败: ${err}`);
66
+ return false;
67
+ }
68
+ }
69
+
70
+ export type ChiralCheckResult = {
71
+ status: "pass" | "progress" | "none";
72
+ remaining: number;
73
+ };
74
+
75
+ export function checkChiralAnswer(
76
+ p: PendingVerify,
77
+ text: string,
78
+ ): ChiralCheckResult {
79
+ const required = p.requiredRegions ?? [];
80
+ if (!required.length) return { status: "none", remaining: 0 };
81
+ if (!p.matchedRegions) p.matchedRegions = new Set<string>();
82
+
83
+ const requiredSet = new Set(required);
84
+ let progressed = false;
85
+ for (const region of extractRegions(text)) {
86
+ if (requiredSet.has(region) && !p.matchedRegions.has(region)) {
87
+ p.matchedRegions.add(region);
88
+ progressed = true;
89
+ }
90
+ }
91
+
92
+ const remaining = requiredSet.size - p.matchedRegions.size;
93
+ if (remaining <= 0) return { status: "pass", remaining: 0 };
94
+ return { status: progressed ? "progress" : "none", remaining };
95
+ }
@@ -0,0 +1,137 @@
1
+ export type VerifyMode = "reaction" | "number" | "chiral";
2
+
3
+ export interface VerifyGroupConfig {
4
+ groupId: number;
5
+ enabled: boolean;
6
+ mode: VerifyMode;
7
+ }
8
+
9
+ export interface VerifyConfig {
10
+ groups: VerifyGroupConfig[];
11
+ reactionEmojiId: string;
12
+ reactionDelayMs: number;
13
+ verifyTimeoutMs: number;
14
+ reactionPrompt: string;
15
+ numberPrompt: string;
16
+ chiralApiUrl: string;
17
+ chiralDifficulty: "simple" | "hard";
18
+ chiralPrompt: string;
19
+ maxInvalidMessages: number;
20
+ kickOnFail: boolean;
21
+ kickOnTimeout: boolean;
22
+ }
23
+
24
+ export const DEFAULT_VERIFY_CONFIG: VerifyConfig = {
25
+ groups: [],
26
+ reactionEmojiId: "424",
27
+ reactionDelayMs: 3000,
28
+ verifyTimeoutMs: 120000,
29
+ reactionPrompt:
30
+ "新来的小伙伴请在2分钟内点击下方红色按钮完成验证 不听话会被移出群聊喵~",
31
+ numberPrompt:
32
+ "新来的小伙伴请在2分钟内回答下面的题目完成验证,不听话移出群聊喵~\n请问:{question}",
33
+ chiralApiUrl: "https://carbon.crystelf.top",
34
+ chiralDifficulty: "simple",
35
+ chiralPrompt:
36
+ "新来的小伙伴请在2分钟内完成下面的手性碳验证:找出图中{count}个手性碳所在的格子,回复格子编号即可,不听话会被移出群聊喵~",
37
+ maxInvalidMessages: 5,
38
+ kickOnFail: true,
39
+ kickOnTimeout: true,
40
+ };
41
+
42
+ export function normalizeVerifyMode(value: unknown): VerifyMode {
43
+ const v = String(value || "").trim();
44
+ if (v === "number" || v === "数字") return "number";
45
+ if (v === "chiral" || v === "手性碳") return "chiral";
46
+ return "reaction";
47
+ }
48
+
49
+ function normalizeVerifyGroup(raw: any): VerifyGroupConfig {
50
+ const groupId = Number(raw?.groupId || raw?.group_id || 0);
51
+ return {
52
+ groupId: groupId > 0 ? groupId : 0,
53
+ enabled: raw?.enabled === true,
54
+ mode: normalizeVerifyMode(raw?.mode),
55
+ };
56
+ }
57
+
58
+ export function normalizeVerifyConfig(raw: any): VerifyConfig {
59
+ const groups: VerifyGroupConfig[] = Array.isArray(raw?.groups)
60
+ ? raw.groups
61
+ .map((g: any) => normalizeVerifyGroup(g))
62
+ .filter((g: VerifyGroupConfig) => g.groupId > 0)
63
+ : [];
64
+
65
+ const numOr = (value: unknown, fallback: number): number => {
66
+ const num = Number(value);
67
+ return Number.isFinite(num) && num >= 0 ? Math.floor(num) : fallback;
68
+ };
69
+
70
+ return {
71
+ groups,
72
+ reactionEmojiId:
73
+ typeof raw?.reactionEmojiId === "string" && raw.reactionEmojiId.trim()
74
+ ? raw.reactionEmojiId.trim()
75
+ : DEFAULT_VERIFY_CONFIG.reactionEmojiId,
76
+ reactionDelayMs: numOr(
77
+ raw?.reactionDelayMs,
78
+ DEFAULT_VERIFY_CONFIG.reactionDelayMs,
79
+ ),
80
+ verifyTimeoutMs: numOr(
81
+ raw?.verifyTimeoutMs,
82
+ DEFAULT_VERIFY_CONFIG.verifyTimeoutMs,
83
+ ),
84
+ reactionPrompt:
85
+ typeof raw?.reactionPrompt === "string" && raw.reactionPrompt.trim()
86
+ ? raw.reactionPrompt
87
+ : DEFAULT_VERIFY_CONFIG.reactionPrompt,
88
+ numberPrompt:
89
+ typeof raw?.numberPrompt === "string" && raw.numberPrompt.trim()
90
+ ? raw.numberPrompt
91
+ : DEFAULT_VERIFY_CONFIG.numberPrompt,
92
+ chiralApiUrl:
93
+ typeof raw?.chiralApiUrl === "string" && raw.chiralApiUrl.trim()
94
+ ? raw.chiralApiUrl.trim().replace(/\/+$/, "")
95
+ : DEFAULT_VERIFY_CONFIG.chiralApiUrl,
96
+ chiralDifficulty: raw?.chiralDifficulty === "hard" ? "hard" : "simple",
97
+ chiralPrompt:
98
+ typeof raw?.chiralPrompt === "string" && raw.chiralPrompt.trim()
99
+ ? raw.chiralPrompt
100
+ : DEFAULT_VERIFY_CONFIG.chiralPrompt,
101
+ maxInvalidMessages: numOr(
102
+ raw?.maxInvalidMessages,
103
+ DEFAULT_VERIFY_CONFIG.maxInvalidMessages,
104
+ ),
105
+ kickOnFail: raw?.kickOnFail ?? DEFAULT_VERIFY_CONFIG.kickOnFail,
106
+ kickOnTimeout: raw?.kickOnTimeout ?? DEFAULT_VERIFY_CONFIG.kickOnTimeout,
107
+ };
108
+ }
109
+
110
+ export function getGroupVerifyConfig(
111
+ config: VerifyConfig,
112
+ groupId: number,
113
+ ): VerifyGroupConfig {
114
+ const found = config.groups.find((g) => g.groupId === groupId);
115
+ if (found) return found;
116
+ return { groupId, enabled: false, mode: "reaction" };
117
+ }
118
+
119
+ export function upsertGroupVerifyConfig(
120
+ config: VerifyConfig,
121
+ groupId: number,
122
+ patch: Partial<Omit<VerifyGroupConfig, "groupId">>,
123
+ ): VerifyConfig {
124
+ const idx = config.groups.findIndex((g) => g.groupId === groupId);
125
+ const next = { ...config };
126
+ if (idx >= 0) {
127
+ next.groups = config.groups.map((g, i) =>
128
+ i === idx ? { ...g, ...patch } : g,
129
+ );
130
+ } else {
131
+ next.groups = [
132
+ ...config.groups,
133
+ { groupId, enabled: false, mode: "reaction", ...patch },
134
+ ];
135
+ }
136
+ return next;
137
+ }
@@ -0,0 +1,359 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import { getMemberRole } from "../config";
3
+ import { resolveMemberName, triggerSingleWelcome } from "../notify/welcome";
4
+ import { getGroupVerifyConfig, upsertGroupVerifyConfig } from "./config";
5
+ import type {
6
+ MemberJoinInfo,
7
+ PendingVerify,
8
+ VerifyController,
9
+ VerifyControllerOptions,
10
+ } from "./types";
11
+ import { clearTimers, getPendingMap, pendingKey } from "./state";
12
+ import { isReactionPass, sendReactionPrompt } from "./reaction";
13
+ import { isNumberAnswerCorrect, sendNumberPrompt } from "./number";
14
+ import { checkChiralAnswer, prepareChiral } from "./chiral";
15
+
16
+ const PASS_REACTION_EMOJI_ID = "144";
17
+
18
+ const VERIFY_PASS_PROMPT_INJECTION = {
19
+ title: "新成员通过入群验证",
20
+ content: "这位新成员刚刚通过了入群验证,请在欢迎语中点到验证通过类似话语",
21
+ };
22
+
23
+ export function createVerifyController(
24
+ options: VerifyControllerOptions,
25
+ ): VerifyController {
26
+ const {
27
+ ctx,
28
+ aiService,
29
+ getConfig,
30
+ getVerifyConfig,
31
+ getWelcomeEnabled,
32
+ setVerifyConfig,
33
+ } = options;
34
+ const pending = getPendingMap();
35
+
36
+ async function disableGroupVerify(groupId: number, reason: string) {
37
+ ctx.logger.warn(`admin verify 关闭群 ${groupId} 验证:${reason}`);
38
+ try {
39
+ await setVerifyConfig(
40
+ upsertGroupVerifyConfig(getVerifyConfig(), groupId, { enabled: false }),
41
+ );
42
+ } catch (err) {
43
+ ctx.logger.error(`admin verify 关闭群验证写回配置失败: ${err}`);
44
+ }
45
+ }
46
+
47
+ async function recallMessage(bot: any, messageId: number) {
48
+ try {
49
+ await bot.api("delete_msg", { message_id: messageId });
50
+ } catch (err) {
51
+ ctx.logger.warn(`admin verify 撤回消息失败: ${err}`);
52
+ }
53
+ }
54
+
55
+ async function kickMember(bot: any, groupId: number, userId: number) {
56
+ try {
57
+ await bot.api("set_group_kick", {
58
+ group_id: groupId,
59
+ user_id: userId,
60
+ reject_add_request: false,
61
+ });
62
+ } catch (err) {
63
+ ctx.logger.warn(`admin verify 踢出成员失败: ${err}`);
64
+ }
65
+ }
66
+
67
+ function removePending(key: string) {
68
+ const p = pending.get(key);
69
+ if (p) {
70
+ clearTimers(p);
71
+ pending.delete(key);
72
+ }
73
+ }
74
+
75
+ async function passVerification(p: PendingVerify) {
76
+ if (p.passed) return;
77
+ p.passed = true;
78
+ clearTimers(p);
79
+ pending.delete(pendingKey(p.selfId, p.groupId, p.userId));
80
+
81
+ if (p.mode === "reaction" && p.promptMessageId) {
82
+ const bot = ctx.pickBot(p.selfId);
83
+ if (bot) {
84
+ try {
85
+ await bot.addReaction(p.promptMessageId, PASS_REACTION_EMOJI_ID);
86
+ } catch (err) {
87
+ ctx.logger.warn(`admin verify 通过表态失败: ${err}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ if (!getWelcomeEnabled()) return;
93
+ try {
94
+ await triggerSingleWelcome({
95
+ ctx,
96
+ aiService,
97
+ getConfig,
98
+ selfId: p.selfId,
99
+ groupId: p.groupId,
100
+ groupName: p.groupName,
101
+ userId: p.userId,
102
+ memberName: p.memberName,
103
+ promptInjections: [VERIFY_PASS_PROMPT_INJECTION],
104
+ });
105
+ } catch (err) {
106
+ ctx.logger.error(`admin verify 通过后欢迎失败: ${err}`);
107
+ }
108
+ }
109
+
110
+ async function failKick(p: PendingVerify, reason: string) {
111
+ clearTimers(p);
112
+ pending.delete(pendingKey(p.selfId, p.groupId, p.userId));
113
+ const cfg = getVerifyConfig();
114
+ if (!cfg.kickOnFail) return;
115
+ const bot = ctx.pickBot(p.selfId);
116
+ if (!bot) return;
117
+ ctx.logger.info(
118
+ `admin verify 踢出群 ${p.groupId} 用户 ${p.userId}(${reason})`,
119
+ );
120
+ await kickMember(bot, p.groupId, p.userId);
121
+ }
122
+
123
+ async function timeoutExpire(p: PendingVerify) {
124
+ p.timeoutTimer = null;
125
+ const cfg = getVerifyConfig();
126
+ if (!cfg.kickOnTimeout) {
127
+ pending.delete(pendingKey(p.selfId, p.groupId, p.userId));
128
+ return;
129
+ }
130
+ const bot = ctx.pickBot(p.selfId);
131
+ if (!bot) {
132
+ pending.delete(pendingKey(p.selfId, p.groupId, p.userId));
133
+ return;
134
+ }
135
+ try {
136
+ await bot.sendGroupMsg(p.groupId, [
137
+ ctx.segment.at(String(p.userId)),
138
+ ctx.segment.text(" 验证超时啦,下次再来哦~"),
139
+ ]);
140
+ } catch (err) {
141
+ ctx.logger.warn(`admin verify 超时提示发送失败: ${err}`);
142
+ }
143
+ ctx.logger.info(`admin verify 超时踢出群 ${p.groupId} 用户 ${p.userId}`);
144
+ await kickMember(bot, p.groupId, p.userId);
145
+ pending.delete(pendingKey(p.selfId, p.groupId, p.userId));
146
+ }
147
+
148
+ async function startVerification(
149
+ info: MemberJoinInfo,
150
+ skipDelay = false,
151
+ ): Promise<boolean> {
152
+ const cfg = getVerifyConfig();
153
+ const groupCfg = getGroupVerifyConfig(cfg, info.groupId);
154
+ if (!groupCfg.enabled) return false;
155
+
156
+ const bot = ctx.pickBot(info.selfId);
157
+ if (!bot) return false;
158
+
159
+ const botRole = await getMemberRole(bot, info.groupId, info.selfId);
160
+ if (botRole !== "owner" && botRole !== "admin") {
161
+ try {
162
+ await bot.sendGroupMsg(info.groupId, [
163
+ ctx.segment.text("我在不是管理员,没法入群验证啦,本群验证已关闭~"),
164
+ ]);
165
+ } catch (err) {
166
+ ctx.logger.warn(`admin verify 提醒本群验证关闭失败: ${err}`);
167
+ }
168
+ await disableGroupVerify(
169
+ info.groupId,
170
+ `Bot 群内身份为 ${botRole},非群主/管理员`,
171
+ );
172
+ return false;
173
+ }
174
+
175
+ const mode = groupCfg.mode;
176
+ const memberName = await resolveMemberName(
177
+ ctx,
178
+ info.groupId,
179
+ info.userId,
180
+ info.selfId,
181
+ );
182
+
183
+ const entry: PendingVerify = {
184
+ selfId: info.selfId,
185
+ groupId: info.groupId,
186
+ userId: info.userId,
187
+ memberName,
188
+ groupName: info.groupName,
189
+ mode,
190
+ invalidCount: 0,
191
+ startedAt: Date.now(),
192
+ passed: false,
193
+ timeoutTimer: null,
194
+ delayTimer: null,
195
+ };
196
+ if (mode === "reaction") {
197
+ entry.reactionEmojiId = cfg.reactionEmojiId;
198
+ }
199
+
200
+ const key = pendingKey(info.selfId, info.groupId, info.userId);
201
+ pending.set(key, entry);
202
+
203
+ if (mode === "reaction") {
204
+ const delay = skipDelay ? 0 : Math.max(0, cfg.reactionDelayMs);
205
+ entry.delayTimer = setTimeout(() => {
206
+ entry.delayTimer = null;
207
+ void sendReactionPrompt(ctx, cfg, entry);
208
+ }, delay);
209
+ } else if (mode === "number") {
210
+ void sendNumberPrompt(ctx, cfg, entry);
211
+ } else if (mode === "chiral") {
212
+ const ok = await prepareChiral(ctx, cfg, entry);
213
+ if (!ok) {
214
+ // 验证服务不可用时放行,避免误伤
215
+ removePending(key);
216
+ return false;
217
+ }
218
+ }
219
+
220
+ entry.timeoutTimer = setTimeout(
221
+ () => {
222
+ void timeoutExpire(entry);
223
+ },
224
+ Math.max(1000, cfg.verifyTimeoutMs),
225
+ );
226
+
227
+ return true;
228
+ }
229
+
230
+ async function onGroupMessage(event: any) {
231
+ if (event?.message_type !== "group") return;
232
+ const selfId = Number(event?.self_id || 0);
233
+ const groupId = Number(event?.group_id || 0);
234
+ const userId = Number(event?.user_id || 0);
235
+ if (!selfId || !groupId || !userId) return;
236
+ if (userId === selfId) return;
237
+
238
+ const key = pendingKey(selfId, groupId, userId);
239
+ const p = pending.get(key);
240
+ if (!p || p.passed) return;
241
+
242
+ const cfg = getVerifyConfig();
243
+ const text = ctx.text(event) || "";
244
+ const messageId = Number(event?.message_id || 0);
245
+ const bot = ctx.pickBot(selfId);
246
+
247
+ if (p.mode === "number" && isNumberAnswerCorrect(p, text)) {
248
+ await passVerification(p);
249
+ return;
250
+ }
251
+
252
+ if (p.mode === "chiral") {
253
+ const result = checkChiralAnswer(p, text);
254
+ if (result.status === "pass") {
255
+ await passVerification(p);
256
+ return;
257
+ }
258
+ if (result.status === "progress") {
259
+ if (bot) {
260
+ try {
261
+ await bot.sendGroupMsg(groupId, [
262
+ ctx.segment.at(String(userId)),
263
+ ctx.segment.text(` 答对一部分啦,还差 ${result.remaining} 个哦~`),
264
+ ]);
265
+ } catch (err) {
266
+ ctx.logger.warn(`admin verify 手性碳进度提示发送失败: ${err}`);
267
+ }
268
+ }
269
+ return;
270
+ }
271
+ }
272
+
273
+ if (messageId && bot) {
274
+ await recallMessage(bot, messageId);
275
+ }
276
+
277
+ p.invalidCount += 1;
278
+ if (p.invalidCount >= cfg.maxInvalidMessages) {
279
+ await failKick(p, `连续 ${cfg.maxInvalidMessages} 次无关消息`);
280
+ }
281
+ }
282
+
283
+ async function onGroupReaction(event: any) {
284
+ const selfId = Number(event?.self_id || 0);
285
+ const groupId = Number(event?.group_id || 0);
286
+ const userId = Number(event?.user_id || 0);
287
+ if (!selfId || !groupId || !userId) return;
288
+ if (userId === selfId) return;
289
+ if (event?.is_add === false) return;
290
+
291
+ const key = pendingKey(selfId, groupId, userId);
292
+ const p = pending.get(key);
293
+ if (!p || p.passed || p.mode !== "reaction") return;
294
+
295
+ if (isReactionPass(p, event)) {
296
+ await passVerification(p);
297
+ }
298
+ }
299
+
300
+ const messageDispose = ctx.handle(
301
+ "message.group" as any,
302
+ async (event: any) => {
303
+ try {
304
+ await onGroupMessage(event);
305
+ } catch (err) {
306
+ ctx.logger.error(`admin verify message 处理失败: ${err}`);
307
+ }
308
+ },
309
+ );
310
+
311
+ const reactionDispose = ctx.handle(
312
+ "notice.group.reaction" as any,
313
+ async (event: any) => {
314
+ try {
315
+ await onGroupReaction(event);
316
+ } catch (err) {
317
+ ctx.logger.error(`admin verify reaction 处理失败: ${err}`);
318
+ }
319
+ },
320
+ );
321
+
322
+ const decreaseDispose = ctx.handle(
323
+ "notice.group.decrease" as any,
324
+ async (event: any) => {
325
+ const selfId = Number(event?.self_id || 0);
326
+ const groupId = Number(event?.group_id || 0);
327
+ const userId = Number(event?.user_id || 0);
328
+ if (!selfId || !groupId || !userId) return;
329
+ const key = pendingKey(selfId, groupId, userId);
330
+ if (!pending.has(key)) return;
331
+ removePending(key);
332
+ ctx.logger.info(
333
+ `admin verify 成员 ${userId} 退出群 ${groupId},清除验证队列`,
334
+ );
335
+ },
336
+ );
337
+
338
+ async function restartVerification(info: MemberJoinInfo): Promise<boolean> {
339
+ removePending(pendingKey(info.selfId, info.groupId, info.userId));
340
+ return startVerification(info, true);
341
+ }
342
+
343
+ async function bypassVerification(info: MemberJoinInfo): Promise<void> {
344
+ removePending(pendingKey(info.selfId, info.groupId, info.userId));
345
+ }
346
+
347
+ return {
348
+ handleMemberJoin: startVerification,
349
+ restartVerification,
350
+ bypassVerification,
351
+ dispose() {
352
+ for (const p of pending.values()) clearTimers(p);
353
+ pending.clear();
354
+ messageDispose();
355
+ reactionDispose();
356
+ decreaseDispose();
357
+ },
358
+ };
359
+ }
@@ -0,0 +1,42 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import type { VerifyConfig } from "./config";
3
+ import type { PendingVerify } from "./types";
4
+
5
+ function genNumberQuestion(): { question: string; answer: number } {
6
+ const a = Math.floor(Math.random() * 99) + 1;
7
+ const b = Math.floor(Math.random() * 99) + 1;
8
+ if (Math.random() < 0.5 && a >= b) {
9
+ return { question: `${a} - ${b} = ?`, answer: a - b };
10
+ }
11
+ return { question: `${a} + ${b} = ?`, answer: a + b };
12
+ }
13
+
14
+ function extractNumbers(text: string): number[] {
15
+ const matches = String(text || "").match(/-?\d+/g);
16
+ return matches ? matches.map(Number) : [];
17
+ }
18
+
19
+ export async function sendNumberPrompt(
20
+ ctx: MiokiContext,
21
+ cfg: VerifyConfig,
22
+ p: PendingVerify,
23
+ ): Promise<void> {
24
+ const bot = ctx.pickBot(p.selfId);
25
+ if (!bot) return;
26
+ const { question, answer } = genNumberQuestion();
27
+ p.numberAnswer = answer;
28
+ const prompt = cfg.numberPrompt.replace("{question}", question);
29
+ try {
30
+ await bot.sendGroupMsg(p.groupId, [
31
+ ctx.segment.at(String(p.userId)),
32
+ ctx.segment.text(` ${prompt}`),
33
+ ]);
34
+ } catch (err) {
35
+ ctx.logger.warn(`admin verify 发送数字提示失败: ${err}`);
36
+ }
37
+ }
38
+
39
+ export function isNumberAnswerCorrect(p: PendingVerify, text: string): boolean {
40
+ if (p.numberAnswer == null) return false;
41
+ return extractNumbers(text).includes(p.numberAnswer);
42
+ }
@@ -0,0 +1,38 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import type { VerifyConfig } from "./config";
3
+ import type { PendingVerify } from "./types";
4
+
5
+ export async function sendReactionPrompt(
6
+ ctx: MiokiContext,
7
+ cfg: VerifyConfig,
8
+ p: PendingVerify,
9
+ ): Promise<void> {
10
+ const bot = ctx.pickBot(p.selfId);
11
+ if (!bot) return;
12
+ let messageId: number | undefined;
13
+ try {
14
+ const res = await bot.sendGroupMsg(p.groupId, [
15
+ ctx.segment.at(String(p.userId)),
16
+ ctx.segment.text(` ${cfg.reactionPrompt}`),
17
+ ]);
18
+ messageId = Number(res?.message_id || 0) || undefined;
19
+ } catch (err) {
20
+ ctx.logger.warn(`admin verify 发送回应提示失败: ${err}`);
21
+ return;
22
+ }
23
+ if (!messageId) return;
24
+ p.promptMessageId = messageId;
25
+ try {
26
+ await bot.addReaction(messageId, cfg.reactionEmojiId);
27
+ } catch (err) {
28
+ ctx.logger.warn(`admin verify 添加表态失败: ${err}`);
29
+ }
30
+ }
31
+
32
+ export function isReactionPass(p: PendingVerify, event: any): boolean {
33
+ if (!p.promptMessageId) return false;
34
+ if (Number(event?.message_id || 0) !== p.promptMessageId) return false;
35
+ const emojiId = String(p.reactionEmojiId || "");
36
+ const likes: any[] = Array.isArray(event?.likes) ? event.likes : [];
37
+ return likes.some((l) => String(l?.emoji_id || "") === emojiId);
38
+ }
@@ -0,0 +1,31 @@
1
+ import { getPluginRuntimeState } from "mioku";
2
+ import type { PendingVerify } from "./types";
3
+
4
+ const RUNTIME_KEY = "verifyPending";
5
+
6
+ export function getPendingMap(): Map<string, PendingVerify> {
7
+ const state = getPluginRuntimeState("admin");
8
+ if (!state[RUNTIME_KEY]) {
9
+ state[RUNTIME_KEY] = new Map<string, PendingVerify>();
10
+ }
11
+ return state[RUNTIME_KEY] as Map<string, PendingVerify>;
12
+ }
13
+
14
+ export function pendingKey(
15
+ selfId: number,
16
+ groupId: number,
17
+ userId: number,
18
+ ): string {
19
+ return `${selfId}:${groupId}:${userId}`;
20
+ }
21
+
22
+ export function clearTimers(p: PendingVerify) {
23
+ if (p.timeoutTimer) {
24
+ clearTimeout(p.timeoutTimer);
25
+ p.timeoutTimer = null;
26
+ }
27
+ if (p.delayTimer) {
28
+ clearTimeout(p.delayTimer);
29
+ p.delayTimer = null;
30
+ }
31
+ }