mioku-plugin-admin 2.2.2 → 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,313 @@
1
+ import { type MiokiContext, wait } from "mioki";
2
+ import { getPluginRuntimeState, type AIService } from "mioku";
3
+ import type { AdminConfig } from "../config";
4
+
5
+ export async function resolveMemberName(
6
+ ctx: MiokiContext,
7
+ groupId: number,
8
+ userId: number,
9
+ selfId: number,
10
+ ): Promise<string> {
11
+ try {
12
+ const member = await ctx
13
+ .pickBot(selfId)
14
+ .getGroupMemberInfo(groupId, userId);
15
+ return (
16
+ String(member?.card || "").trim() ||
17
+ String(member?.nickname || "").trim() ||
18
+ String(userId)
19
+ );
20
+ } catch {
21
+ return String(userId);
22
+ }
23
+ }
24
+
25
+ interface PendingMember {
26
+ userId: number;
27
+ memberName: string;
28
+ }
29
+
30
+ interface BatchState {
31
+ members: PendingMember[];
32
+ timer: ReturnType<typeof setTimeout> | null;
33
+ groupName: string;
34
+ }
35
+
36
+ const RUNTIME_KEY = "welcomeBatch";
37
+
38
+ function getBatchMap(): Map<string, BatchState> {
39
+ const state = getPluginRuntimeState("admin");
40
+ if (!state[RUNTIME_KEY]) {
41
+ state[RUNTIME_KEY] = new Map<string, BatchState>();
42
+ }
43
+ return state[RUNTIME_KEY] as Map<string, BatchState>;
44
+ }
45
+
46
+ function batchKey(selfId: number, groupId: number): string {
47
+ return `${selfId}:${groupId}`;
48
+ }
49
+
50
+ function renderTemplate(
51
+ template: string,
52
+ values: Record<string, string>,
53
+ ): string {
54
+ let output = String(template || "");
55
+ for (const [key, value] of Object.entries(values)) {
56
+ output = output.split(`{${key}}`).join(value);
57
+ }
58
+ return output;
59
+ }
60
+
61
+ function normalizeGeneratedText(value: string): string {
62
+ return String(value || "")
63
+ .replace(/[`"'“”‘’]/g, "")
64
+ .replace(/\s+/g, " ")
65
+ .trim();
66
+ }
67
+
68
+ async function flushBatch(options: {
69
+ ctx: MiokiContext;
70
+ aiService?: AIService;
71
+ config: AdminConfig;
72
+ selfId: number;
73
+ groupId: number;
74
+ groupName: string;
75
+ members: PendingMember[];
76
+ promptInjections?: { content: string; title?: string }[];
77
+ }): Promise<string> {
78
+ const { ctx, aiService, config, selfId, groupId, groupName, members, promptInjections } =
79
+ options;
80
+ if (!members.length) return "";
81
+
82
+ const names = members.map((m) => m.memberName || String(m.userId));
83
+ const userList = names.join("、");
84
+ const userIdList = members.map((m) => String(m.userId)).join(", ");
85
+
86
+ const fallbackText =
87
+ normalizeGeneratedText(
88
+ renderTemplate(config.welcome.text, {
89
+ user: userList,
90
+ group: groupName,
91
+ }),
92
+ ) || `欢迎新人~`;
93
+
94
+ if (config.welcome.mode !== "ai") {
95
+ return fallbackText;
96
+ }
97
+
98
+ const chatRuntime = aiService?.getChatRuntime();
99
+ if (!chatRuntime) {
100
+ return fallbackText;
101
+ }
102
+
103
+ try {
104
+ await chatRuntime.generateNotice({
105
+ selfId,
106
+ groupId,
107
+ send: true,
108
+ instruction: [
109
+ `当前有 ${members.length} 位新成员同时入群,请一次性发送一段统一的欢迎语(不要逐个 @ 欢迎、不要重复点名)。`,
110
+ `新成员昵称:${userList}`,
111
+ `新成员 QQ:${userIdList}`,
112
+ `所在群:${groupName}`,
113
+ `${config.welcome.aiPrompt || ""}`,
114
+ ].join("\n"),
115
+ promptInjections,
116
+ });
117
+
118
+ return "";
119
+ } catch (error) {
120
+ ctx.logger.error(`admin welcome chat-runtime 生成失败: ${error}`);
121
+ return fallbackText;
122
+ }
123
+ }
124
+
125
+ async function sendSingleWelcome(options: {
126
+ ctx: MiokiContext;
127
+ aiService?: AIService;
128
+ config: AdminConfig;
129
+ selfId: number;
130
+ groupId: number;
131
+ groupName: string;
132
+ userId: number;
133
+ memberName: string;
134
+ promptInjections?: { content: string; title?: string }[];
135
+ }): Promise<void> {
136
+ const {
137
+ ctx,
138
+ aiService,
139
+ config,
140
+ selfId,
141
+ groupId,
142
+ groupName,
143
+ userId,
144
+ memberName,
145
+ promptInjections,
146
+ } = options;
147
+ const welcomeMessage = await flushBatch({
148
+ ctx,
149
+ aiService,
150
+ config,
151
+ selfId,
152
+ groupId,
153
+ groupName,
154
+ members: [{ userId, memberName }],
155
+ promptInjections,
156
+ });
157
+ if (!welcomeMessage) return;
158
+ const bot = ctx.pickBot(selfId);
159
+ if (!bot) return;
160
+ try {
161
+ await bot.sendGroupMsg(groupId, [ctx.segment.text(welcomeMessage)]);
162
+ } catch (error) {
163
+ ctx.logger.warn(`发送入群欢迎失败: ${error}`);
164
+ }
165
+ }
166
+
167
+ export async function triggerSingleWelcome(options: {
168
+ ctx: MiokiContext;
169
+ aiService?: AIService;
170
+ getConfig: () => AdminConfig;
171
+ selfId: number;
172
+ groupId: number;
173
+ groupName: string;
174
+ userId: number;
175
+ memberName?: string;
176
+ promptInjections?: { content: string; title?: string }[];
177
+ }): Promise<void> {
178
+ const memberName =
179
+ options.memberName ||
180
+ (await resolveMemberName(
181
+ options.ctx,
182
+ options.groupId,
183
+ options.userId,
184
+ options.selfId,
185
+ ));
186
+ await sendSingleWelcome({
187
+ ctx: options.ctx,
188
+ aiService: options.aiService,
189
+ config: options.getConfig(),
190
+ selfId: options.selfId,
191
+ groupId: options.groupId,
192
+ groupName: options.groupName,
193
+ userId: options.userId,
194
+ memberName,
195
+ promptInjections: options.promptInjections,
196
+ });
197
+ }
198
+
199
+ export function registerWelcomeHandler(
200
+ ctx: MiokiContext,
201
+ aiService: AIService | undefined,
202
+ getConfig: () => AdminConfig,
203
+ shouldSuppress?: (info: {
204
+ selfId: number;
205
+ groupId: number;
206
+ userId: number;
207
+ groupName: string;
208
+ }) => Promise<boolean> | boolean,
209
+ ): () => void {
210
+ const batches = getBatchMap();
211
+
212
+ const dispose = ctx.handle("notice.group.increase" as any, async (event: any) => {
213
+ const cfg = getConfig();
214
+ const selfId = Number(event?.self_id || ctx.self_id);
215
+ const groupId = Number(event?.group_id || 0);
216
+ const userId = Number(event?.user_id || 0);
217
+ if (!groupId || !userId) return;
218
+ if (userId === selfId) return;
219
+
220
+ const groupName =
221
+ String(event?.group?.group_name || "").trim() || String(groupId);
222
+
223
+ if (
224
+ shouldSuppress &&
225
+ (await shouldSuppress({ selfId, groupId, userId, groupName }))
226
+ ) {
227
+ return;
228
+ }
229
+
230
+ if (!cfg.welcome.enabled) return;
231
+
232
+ const batchWindowMs = Math.max(0, Number(cfg.welcome.batchWindowMs) || 0);
233
+
234
+ if (batchWindowMs === 0) {
235
+ const memberName = await resolveMemberName(ctx, groupId, userId, selfId);
236
+ const welcomeMessage = await flushBatch({
237
+ ctx,
238
+ aiService,
239
+ config: cfg,
240
+ selfId,
241
+ groupId,
242
+ groupName,
243
+ members: [{ userId, memberName }],
244
+ });
245
+ if (!welcomeMessage) return;
246
+ const bot = ctx.pickBot(selfId);
247
+ if (!bot) return;
248
+ try {
249
+ await bot.sendGroupMsg(groupId, [ctx.segment.text(welcomeMessage)]);
250
+ } catch (error) {
251
+ ctx.logger.warn(`发送入群欢迎失败: ${error}`);
252
+ }
253
+ return;
254
+ }
255
+
256
+ const key = batchKey(selfId, groupId);
257
+ let state = batches.get(key);
258
+ if (!state) {
259
+ state = { members: [], timer: null, groupName };
260
+ batches.set(key, state);
261
+ }
262
+ if (groupName && groupName !== String(groupId)) {
263
+ state.groupName = groupName;
264
+ }
265
+
266
+ const memberName = await resolveMemberName(ctx, groupId, userId, selfId);
267
+ if (!state.members.some((m) => m.userId === userId)) {
268
+ state.members.push({ userId, memberName });
269
+ }
270
+
271
+ if (state.timer) {
272
+ return;
273
+ }
274
+
275
+ state.timer = setTimeout(async () => {
276
+ try {
277
+ const pending = state;
278
+ batches.delete(key);
279
+ if (!pending || !pending.members.length) return;
280
+
281
+ const currentConfig = getConfig();
282
+ const welcomeMessage = await flushBatch({
283
+ ctx,
284
+ aiService,
285
+ config: currentConfig,
286
+ selfId,
287
+ groupId,
288
+ groupName: pending.groupName,
289
+ members: pending.members,
290
+ });
291
+ if (!welcomeMessage) return;
292
+
293
+ const bot = ctx.pickBot(selfId);
294
+ if (!bot) return;
295
+ try {
296
+ await bot.sendGroupMsg(groupId, [ctx.segment.text(welcomeMessage)]);
297
+ } catch (error) {
298
+ ctx.logger.warn(`发送入群欢迎失败: ${error}`);
299
+ }
300
+ } catch (error) {
301
+ ctx.logger.error(`admin welcome 批次处理失败: ${error}`);
302
+ }
303
+ }, batchWindowMs);
304
+ });
305
+
306
+ return () => {
307
+ for (const state of batches.values()) {
308
+ if (state.timer) clearTimeout(state.timer);
309
+ }
310
+ batches.clear();
311
+ dispose();
312
+ };
313
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-admin",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "管理插件,提供事件通知与群管/个人管理指令",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -112,6 +112,31 @@
112
112
  {
113
113
  "id": "/全部群聊",
114
114
  "match": "/^\\/全部群聊/"
115
+ },
116
+ {
117
+ "id": "welcome",
118
+ "event": "notice.group.increase",
119
+ "description": "新人入群相关功能"
120
+ },
121
+ {
122
+ "id": "/开启验证",
123
+ "match": "/^[/#]开启验证$/"
124
+ },
125
+ {
126
+ "id": "/关闭验证",
127
+ "match": "/^[/#]关闭验证$/"
128
+ },
129
+ {
130
+ "id": "/切换验证模式",
131
+ "match": "/^[/#]切换验证模式/"
132
+ },
133
+ {
134
+ "id": "/绕过验证",
135
+ "match": "/^[/#]绕过验证/"
136
+ },
137
+ {
138
+ "id": "/重新验证",
139
+ "match": "/^[/#]重新验证/"
115
140
  }
116
141
  ],
117
142
  "help": {
@@ -235,6 +260,34 @@
235
260
  "cmd": "/全部群聊",
236
261
  "desc": "获取全部群聊列表",
237
262
  "role": "master"
263
+ },
264
+ {
265
+ "cmd": "/开启验证",
266
+ "desc": "开启本群入群验证",
267
+ "role": "admin"
268
+ },
269
+ {
270
+ "cmd": "/关闭验证",
271
+ "desc": "关闭本群入群验证",
272
+ "role": "admin"
273
+ },
274
+ {
275
+ "cmd": "/切换验证模式",
276
+ "desc": "切换验证模式:回应/数字/手性碳",
277
+ "usage": "/切换验证模式 回应",
278
+ "role": "admin"
279
+ },
280
+ {
281
+ "cmd": "/绕过验证",
282
+ "desc": "绕过指定新成员的验证直接欢迎",
283
+ "usage": "/绕过验证 @新成员",
284
+ "role": "admin"
285
+ },
286
+ {
287
+ "cmd": "/重新验证",
288
+ "desc": "让指定成员重新进行入群验证",
289
+ "usage": "/重新验证 @成员",
290
+ "role": "admin"
238
291
  }
239
292
  ]
240
293
  }
@@ -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
+ }