mioku-plugin-admin 2.3.0 → 2.3.2

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.
@@ -1,13 +1,20 @@
1
1
  import type { MiokiContext } from "mioki";
2
- import { getAtUserId, getMemberRole } from "../config";
2
+ import { extractImageUrls, getAtUserId, getMemberRole } from "../config";
3
3
  import {
4
4
  getGroupVerifyConfig,
5
+ hasCustomPrompt,
6
+ MAX_CUSTOM_PROMPT_LENGTH,
7
+ normalizeCustomPrompt,
5
8
  normalizeVerifyMode,
6
9
  upsertGroupVerifyConfig,
7
10
  type VerifyConfig,
8
11
  } from "../verify/config";
9
12
  import { replyAdminErrorNotice } from "./notice";
10
13
  import type { VerifyController } from "../verify/types";
14
+ import {
15
+ pruneGroupPromptImages,
16
+ saveRemoteImageAsPrompt,
17
+ } from "../utils/prompt-image-store";
11
18
 
12
19
  export interface VerifyCommandOptions {
13
20
  ctx: MiokiContext;
@@ -44,7 +51,9 @@ export function registerVerifyCommands(options: VerifyCommandOptions) {
44
51
  text.startsWith("/绕过验证") ||
45
52
  text.startsWith("#绕过验证") ||
46
53
  text.startsWith("/重新验证") ||
47
- text.startsWith("#重新验证");
54
+ text.startsWith("#重新验证") ||
55
+ text.startsWith("/入群提示") ||
56
+ text.startsWith("#入群提示");
48
57
 
49
58
  try {
50
59
  const selfId = event.self_id;
@@ -238,6 +247,104 @@ export function registerVerifyCommands(options: VerifyCommandOptions) {
238
247
  }
239
248
  return;
240
249
  }
250
+
251
+ // /入群提示 xxx
252
+ // /入群提示 关闭
253
+ // /入群提示(带图片)— 仅图片也可
254
+ if (text.startsWith("/入群提示") || text.startsWith("#入群提示")) {
255
+ if (!(await ensureAdminPermission())) return;
256
+
257
+ const rawArg = text.replace(/^[/#]入群提示\s*/, "").trim();
258
+ const current = getVerifyConfig();
259
+ const groupCfg = getGroupVerifyConfig(current, groupId);
260
+ const imageUrls = extractImageUrls(event.message);
261
+
262
+ if (rawArg === "关闭" || rawArg === "清空" || rawArg === "关") {
263
+ if (!hasCustomPrompt(groupCfg)) {
264
+ await event.reply("本群还没设置自定义入群提示哦~", true);
265
+ return;
266
+ }
267
+ const removedFiles = [...groupCfg.promptImages];
268
+ const next = upsertGroupVerifyConfig(current, groupId, {
269
+ customPrompt: "",
270
+ promptImages: [],
271
+ });
272
+ await setVerifyConfig(next);
273
+ await pruneGroupPromptImages(groupId, []).catch(() => {});
274
+ ctx.logger.info(
275
+ `admin verify 关闭群 ${groupId} 自定义入群提示,清理图片 ${removedFiles.join(", ")}`,
276
+ );
277
+ await event.reply("已关闭本群自定义入群提示~", true);
278
+ return;
279
+ }
280
+
281
+ if (!rawArg && !imageUrls.length) {
282
+ if (!hasCustomPrompt(groupCfg)) {
283
+ await event.reply(
284
+ "用法:/入群提示 xxx(最多 50 字),可附带图片一起发送;/入群提示 关闭 关闭自定义提示~",
285
+ true,
286
+ );
287
+ return;
288
+ }
289
+ const summary = [
290
+ `已开启本群自定义入群提示`,
291
+ groupCfg.customPrompt
292
+ ? `文字:${groupCfg.customPrompt}`
293
+ : "文字:(未设置)",
294
+ `图片:${groupCfg.promptImages.length} 张`,
295
+ ].join("\n");
296
+ await event.reply(summary, true);
297
+ return;
298
+ }
299
+
300
+ let nextPrompt = groupCfg.customPrompt;
301
+ if (rawArg) {
302
+ const argLength = Array.from(rawArg).length;
303
+ if (argLength > MAX_CUSTOM_PROMPT_LENGTH) {
304
+ await replyAdminErrorNotice({
305
+ ctx,
306
+ event,
307
+ instruction: `用户想设置本群入群提示,但内容长度 ${argLength} 超过了上限 ${MAX_CUSTOM_PROMPT_LENGTH} 字,请明确告诉用户最多 ${MAX_CUSTOM_PROMPT_LENGTH} 字。`,
308
+ fallbackMessage: `最多 ${MAX_CUSTOM_PROMPT_LENGTH} 字哦~当前 ${argLength} 字`,
309
+ });
310
+ return;
311
+ }
312
+ nextPrompt = normalizeCustomPrompt(rawArg);
313
+ }
314
+
315
+ const saved: string[] = [];
316
+ const failed: number = imageUrls.length;
317
+ for (const url of imageUrls) {
318
+ const savedImage = await saveRemoteImageAsPrompt(groupId, url);
319
+ if (savedImage) saved.push(savedImage.filename);
320
+ }
321
+
322
+ const nextImages = imageUrls.length
323
+ ? [...groupCfg.promptImages, ...saved]
324
+ : groupCfg.promptImages;
325
+
326
+ const next = upsertGroupVerifyConfig(current, groupId, {
327
+ customPrompt: nextPrompt,
328
+ promptImages: nextImages,
329
+ });
330
+ await setVerifyConfig(next);
331
+ await pruneGroupPromptImages(groupId, nextImages).catch(() => {});
332
+
333
+ const lines: string[] = ["已更新本群自定义入群提示~"];
334
+ if (rawArg) lines.push(`文字:${nextPrompt}`);
335
+ else if (groupCfg.customPrompt)
336
+ lines.push(`文字(保持):${groupCfg.customPrompt}`);
337
+ if (imageUrls.length) {
338
+ lines.push(
339
+ saved.length
340
+ ? `新增图片 ${saved.length} 张${failed > saved.length ? `,${failed - saved.length} 张保存失败` : ""}`
341
+ : `图片保存失败 ${failed} 张,请稍后重试`,
342
+ );
343
+ }
344
+ lines.push(`当前共 ${nextImages.length} 张图片`);
345
+ await event.reply(lines.join("\n"), true);
346
+ return;
347
+ }
241
348
  } catch (err) {
242
349
  ctx.logger.error(`[admin verify] 未捕获异常: ${String(err)}`);
243
350
  if (!isVerifyCommand) return;
package/config.md CHANGED
@@ -74,9 +74,43 @@ fields:
74
74
 
75
75
  - key: verify.groups
76
76
  label: 各群入群验证配置
77
- type: json
78
- description: 每个群的验证配置数组,元素形如 {"groupId":123,"enabled":true,"mode":"reaction"}。推荐通过 /开启验证、/关闭验证、/切换验证模式 指令管理,直接编辑需填写合法 JSON 数组。
79
- placeholder: '[]'
77
+ type: array
78
+ description:
79
+ itemFields:
80
+ - key: groupId
81
+ label: 群号
82
+ type: number
83
+ description: 该配置对应的 QQ 群号,每个群只能出现一次
84
+ placeholder: 123456789
85
+
86
+ - key: enabled
87
+ label: 启用验证
88
+ type: switch
89
+ description: 是否对该群开启入群验证
90
+
91
+ - key: mode
92
+ label: 验证模式
93
+ type: select
94
+ options:
95
+ - value: reaction
96
+ label: 回应
97
+ - value: number
98
+ label: 数字
99
+ - value: chiral
100
+ label: 手性碳
101
+ description: 该群入群验证使用的模式
102
+
103
+ - key: customPrompt
104
+ label: 群自定义入群提示词
105
+ type: textarea
106
+ description: 一旦设置该群将跳过 AI 生成欢迎,验证通过后直接发送这段文字(可附带本地图片一起)。上限 50 字,建议在群里直接用 /入群提示 xxx 设置或 /入群提示 关闭 关闭。
107
+ placeholder: 例:欢迎来到本群,请先看群公告~
108
+
109
+ - key: promptImages
110
+ label: 群自定义入群提示图片
111
+ type: textarea
112
+ description: 图片文件名列表(保存在 data/admin/<群号>/prompt-images/),与文字一起发送。在群里发送 /入群提示 xxx 时附带图片即可追加;/入群提示 关闭 会一并清空图片。
113
+ placeholder: 例:["prompt-l8t9a-3f2c.png"]
80
114
 
81
115
  - key: verify.reactionEmojiId
82
116
  label: 回应模式表态表情ID
package/config.ts CHANGED
@@ -113,6 +113,19 @@ export function extractImageUrl(message: RecvElement[]): string | undefined {
113
113
  return undefined;
114
114
  }
115
115
 
116
+ export function extractImageUrls(message: RecvElement[]): string[] {
117
+ if (!Array.isArray(message)) return [];
118
+ const urls: string[] = [];
119
+ for (const seg of message) {
120
+ if (seg.type === "image") {
121
+ const imageSeg = seg as RecvImageElement;
122
+ const url = String(imageSeg.url || imageSeg.file || "").trim();
123
+ if (url) urls.push(url);
124
+ }
125
+ }
126
+ return urls;
127
+ }
128
+
116
129
  // 从消息中提取被@的人的QQ号
117
130
  export function getAtUserId(message: RecvElement[]): number | undefined {
118
131
  if (!Array.isArray(message)) return undefined;
package/index.ts CHANGED
@@ -81,6 +81,7 @@ export default definePlugin({
81
81
  aiService,
82
82
  getConfig,
83
83
  (info) => verifyController.handleMemberJoin(info),
84
+ (info) => verifyController.trySendCustomWelcome(info),
84
85
  );
85
86
 
86
87
  // 注册入群验证指令
package/notify/welcome.ts CHANGED
@@ -74,11 +74,40 @@ async function flushBatch(options: {
74
74
  groupName: string;
75
75
  members: PendingMember[];
76
76
  promptInjections?: { content: string; title?: string }[];
77
+ tryCustomPrompt?: (info: {
78
+ selfId: number;
79
+ groupId: number;
80
+ userId: number;
81
+ groupName: string;
82
+ }) => Promise<boolean>;
77
83
  }): Promise<string> {
78
- const { ctx, aiService, config, selfId, groupId, groupName, members, promptInjections } =
79
- options;
84
+ const {
85
+ ctx,
86
+ aiService,
87
+ config,
88
+ selfId,
89
+ groupId,
90
+ groupName,
91
+ members,
92
+ promptInjections,
93
+ tryCustomPrompt,
94
+ } = options;
80
95
  if (!members.length) return "";
81
96
 
97
+ if (tryCustomPrompt) {
98
+ let anyCustom = false;
99
+ for (const m of members) {
100
+ const handled = await tryCustomPrompt({
101
+ selfId,
102
+ groupId,
103
+ userId: m.userId,
104
+ groupName,
105
+ });
106
+ if (handled) anyCustom = true;
107
+ }
108
+ if (anyCustom) return "";
109
+ }
110
+
82
111
  const names = members.map((m) => m.memberName || String(m.userId));
83
112
  const userList = names.join("、");
84
113
  const userIdList = members.map((m) => String(m.userId)).join(", ");
@@ -106,7 +135,7 @@ async function flushBatch(options: {
106
135
  groupId,
107
136
  send: true,
108
137
  instruction: [
109
- `当前有 ${members.length} 位新成员同时入群,请一次性发送一段统一的欢迎语(不要逐个 @ 欢迎、不要重复点名)。`,
138
+ `当前有 ${members.length} 位新成员同时入群,请一次性发送一段统一的欢迎语(不要逐个 @ 欢迎、不要重复点名)不要长篇大论,精简即可。`,
110
139
  `新成员昵称:${userList}`,
111
140
  `新成员 QQ:${userIdList}`,
112
141
  `所在群:${groupName}`,
@@ -132,6 +161,12 @@ async function sendSingleWelcome(options: {
132
161
  userId: number;
133
162
  memberName: string;
134
163
  promptInjections?: { content: string; title?: string }[];
164
+ tryCustomPrompt?: (info: {
165
+ selfId: number;
166
+ groupId: number;
167
+ userId: number;
168
+ groupName: string;
169
+ }) => Promise<boolean>;
135
170
  }): Promise<void> {
136
171
  const {
137
172
  ctx,
@@ -143,6 +178,7 @@ async function sendSingleWelcome(options: {
143
178
  userId,
144
179
  memberName,
145
180
  promptInjections,
181
+ tryCustomPrompt,
146
182
  } = options;
147
183
  const welcomeMessage = await flushBatch({
148
184
  ctx,
@@ -153,6 +189,7 @@ async function sendSingleWelcome(options: {
153
189
  groupName,
154
190
  members: [{ userId, memberName }],
155
191
  promptInjections,
192
+ tryCustomPrompt,
156
193
  });
157
194
  if (!welcomeMessage) return;
158
195
  const bot = ctx.pickBot(selfId);
@@ -174,6 +211,12 @@ export async function triggerSingleWelcome(options: {
174
211
  userId: number;
175
212
  memberName?: string;
176
213
  promptInjections?: { content: string; title?: string }[];
214
+ tryCustomPrompt?: (info: {
215
+ selfId: number;
216
+ groupId: number;
217
+ userId: number;
218
+ groupName: string;
219
+ }) => Promise<boolean>;
177
220
  }): Promise<void> {
178
221
  const memberName =
179
222
  options.memberName ||
@@ -193,6 +236,7 @@ export async function triggerSingleWelcome(options: {
193
236
  userId: options.userId,
194
237
  memberName,
195
238
  promptInjections: options.promptInjections,
239
+ tryCustomPrompt: options.tryCustomPrompt,
196
240
  });
197
241
  }
198
242
 
@@ -206,90 +250,56 @@ export function registerWelcomeHandler(
206
250
  userId: number;
207
251
  groupName: string;
208
252
  }) => Promise<boolean> | boolean,
253
+ tryCustomPrompt?: (info: {
254
+ selfId: number;
255
+ groupId: number;
256
+ userId: number;
257
+ groupName: string;
258
+ }) => Promise<boolean>,
209
259
  ): () => void {
210
260
  const batches = getBatchMap();
211
261
 
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;
262
+ const dispose = ctx.handle(
263
+ "notice.group.increase" as any,
264
+ async (event: any) => {
265
+ const cfg = getConfig();
266
+ const selfId = Number(event?.self_id || ctx.self_id);
267
+ const groupId = Number(event?.group_id || 0);
268
+ const userId = Number(event?.user_id || 0);
269
+ if (!groupId || !userId) return;
270
+ if (userId === selfId) return;
231
271
 
232
- const batchWindowMs = Math.max(0, Number(cfg.welcome.batchWindowMs) || 0);
272
+ const groupName =
273
+ String(event?.group?.group_name || "").trim() || String(groupId);
233
274
 
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}`);
275
+ if (
276
+ shouldSuppress &&
277
+ (await shouldSuppress({ selfId, groupId, userId, groupName }))
278
+ ) {
279
+ return;
252
280
  }
253
- return;
254
- }
255
281
 
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
- }
282
+ if (!cfg.welcome.enabled) return;
265
283
 
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;
284
+ const batchWindowMs = Math.max(0, Number(cfg.welcome.batchWindowMs) || 0);
280
285
 
281
- const currentConfig = getConfig();
286
+ if (batchWindowMs === 0) {
287
+ const memberName = await resolveMemberName(
288
+ ctx,
289
+ groupId,
290
+ userId,
291
+ selfId,
292
+ );
282
293
  const welcomeMessage = await flushBatch({
283
294
  ctx,
284
295
  aiService,
285
- config: currentConfig,
296
+ config: cfg,
286
297
  selfId,
287
298
  groupId,
288
- groupName: pending.groupName,
289
- members: pending.members,
299
+ groupName,
300
+ members: [{ userId, memberName }],
290
301
  });
291
302
  if (!welcomeMessage) return;
292
-
293
303
  const bot = ctx.pickBot(selfId);
294
304
  if (!bot) return;
295
305
  try {
@@ -297,11 +307,60 @@ export function registerWelcomeHandler(
297
307
  } catch (error) {
298
308
  ctx.logger.warn(`发送入群欢迎失败: ${error}`);
299
309
  }
300
- } catch (error) {
301
- ctx.logger.error(`admin welcome 批次处理失败: ${error}`);
310
+ return;
302
311
  }
303
- }, batchWindowMs);
304
- });
312
+
313
+ const key = batchKey(selfId, groupId);
314
+ let state = batches.get(key);
315
+ if (!state) {
316
+ state = { members: [], timer: null, groupName };
317
+ batches.set(key, state);
318
+ }
319
+ if (groupName && groupName !== String(groupId)) {
320
+ state.groupName = groupName;
321
+ }
322
+
323
+ const memberName = await resolveMemberName(ctx, groupId, userId, selfId);
324
+ if (!state.members.some((m) => m.userId === userId)) {
325
+ state.members.push({ userId, memberName });
326
+ }
327
+
328
+ if (state.timer) {
329
+ return;
330
+ }
331
+
332
+ state.timer = setTimeout(async () => {
333
+ try {
334
+ const pending = state;
335
+ batches.delete(key);
336
+ if (!pending || !pending.members.length) return;
337
+
338
+ const currentConfig = getConfig();
339
+ const welcomeMessage = await flushBatch({
340
+ ctx,
341
+ aiService,
342
+ config: currentConfig,
343
+ selfId,
344
+ groupId,
345
+ groupName: pending.groupName,
346
+ members: pending.members,
347
+ tryCustomPrompt,
348
+ });
349
+ if (!welcomeMessage) return;
350
+
351
+ const bot = ctx.pickBot(selfId);
352
+ if (!bot) return;
353
+ try {
354
+ await bot.sendGroupMsg(groupId, [ctx.segment.text(welcomeMessage)]);
355
+ } catch (error) {
356
+ ctx.logger.warn(`发送入群欢迎失败: ${error}`);
357
+ }
358
+ } catch (error) {
359
+ ctx.logger.error(`admin welcome 批次处理失败: ${error}`);
360
+ }
361
+ }, batchWindowMs);
362
+ },
363
+ );
305
364
 
306
365
  return () => {
307
366
  for (const state of batches.values()) {
@@ -310,4 +369,4 @@ export function registerWelcomeHandler(
310
369
  batches.clear();
311
370
  dispose();
312
371
  };
313
- }
372
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-admin",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "管理插件,提供事件通知与群管/个人管理指令",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,202 @@
1
+ import * as path from "path";
2
+ import * as crypto from "crypto";
3
+ import * as https from "https";
4
+ import * as http from "http";
5
+ import {
6
+ createWriteStream,
7
+ existsSync,
8
+ mkdirSync,
9
+ promises as fsp,
10
+ } from "fs";
11
+ import { getPluginDataDir } from "mioku";
12
+
13
+ const PROMPT_IMAGE_SUBDIR = "prompt-images";
14
+
15
+ const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
16
+
17
+ export interface SavedPromptImage {
18
+ filename: string;
19
+ sourceUrl: string;
20
+ size: number;
21
+ }
22
+
23
+ export function getGroupPromptImageDir(groupId: number): string {
24
+ return path.join(
25
+ getPluginDataDir("admin"),
26
+ String(groupId),
27
+ PROMPT_IMAGE_SUBDIR,
28
+ );
29
+ }
30
+
31
+ export function ensureGroupPromptImageDir(groupId: number): string {
32
+ const dir = getGroupPromptImageDir(groupId);
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
+ return dir;
35
+ }
36
+
37
+ export function getGroupPromptImagePath(groupId: number, filename: string): string {
38
+ return path.join(getGroupPromptImageDir(groupId), filename);
39
+ }
40
+
41
+ function inferExtension(source: string): string {
42
+ try {
43
+ const url = new URL(source);
44
+ const ext = path.extname(url.pathname).toLowerCase();
45
+ if (ALLOWED_EXTENSIONS.includes(ext)) return ext;
46
+ } catch {
47
+ const ext = path.extname(String(source || "")).toLowerCase();
48
+ if (ALLOWED_EXTENSIONS.includes(ext)) return ext;
49
+ }
50
+ return ".jpg";
51
+ }
52
+
53
+ function generateFilename(ext: string): string {
54
+ const stamp = Date.now().toString(36);
55
+ const rand = crypto.randomBytes(4).toString("hex");
56
+ return `prompt-${stamp}-${rand}${ext}`;
57
+ }
58
+
59
+ export async function saveRemoteImageAsPrompt(
60
+ groupId: number,
61
+ sourceUrl: string,
62
+ ): Promise<SavedPromptImage | null> {
63
+ const trimmed = String(sourceUrl || "").trim();
64
+ if (!trimmed) return null;
65
+
66
+ let downloadUrl = trimmed;
67
+ if (/^file:\/\//i.test(trimmed)) {
68
+ try {
69
+ const filePath = decodeURIComponent(
70
+ trimmed.replace(/^file:\/\//i, ""),
71
+ );
72
+ return await copyLocalFileAsPrompt(groupId, filePath);
73
+ } catch (err) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ if (!/^https?:\/\//i.test(downloadUrl)) return null;
79
+
80
+ const dir = ensureGroupPromptImageDir(groupId);
81
+ const filename = generateFilename(inferExtension(downloadUrl));
82
+ const savePath = path.join(dir, filename);
83
+
84
+ try {
85
+ const size = await downloadToFile(downloadUrl, savePath);
86
+ if (size <= 0) {
87
+ await fsp.unlink(savePath).catch(() => {});
88
+ return null;
89
+ }
90
+ return { filename, sourceUrl: trimmed, size };
91
+ } catch {
92
+ await fsp.unlink(savePath).catch(() => {});
93
+ return null;
94
+ }
95
+ }
96
+
97
+ async function copyLocalFileAsPrompt(
98
+ groupId: number,
99
+ filePath: string,
100
+ ): Promise<SavedPromptImage | null> {
101
+ try {
102
+ const stat = await fsp.stat(filePath);
103
+ if (!stat.isFile() || stat.size <= 0) return null;
104
+ const dir = ensureGroupPromptImageDir(groupId);
105
+ const filename = generateFilename(inferExtension(filePath));
106
+ const dest = path.join(dir, filename);
107
+ await fsp.copyFile(filePath, dest);
108
+ return { filename, sourceUrl: filePath, size: stat.size };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function downloadToFile(url: string, savePath: string): Promise<number> {
115
+ return new Promise((resolve, reject) => {
116
+ let parsed: URL;
117
+ try {
118
+ parsed = new URL(url);
119
+ } catch (err) {
120
+ reject(err);
121
+ return;
122
+ }
123
+ const client = parsed.protocol === "https:" ? https : http;
124
+ const req = client.get(
125
+ url,
126
+ {
127
+ headers: {
128
+ "User-Agent":
129
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
130
+ Referer: "https://q.qq.com/",
131
+ },
132
+ },
133
+ (res) => {
134
+ if (
135
+ res.statusCode &&
136
+ res.statusCode >= 300 &&
137
+ res.statusCode < 400 &&
138
+ res.headers.location
139
+ ) {
140
+ res.resume();
141
+ const redirected = new URL(res.headers.location, url).toString();
142
+ downloadToFile(redirected, savePath).then(resolve, reject);
143
+ return;
144
+ }
145
+ if (!res.statusCode || res.statusCode >= 400) {
146
+ res.resume();
147
+ reject(new Error(`HTTP ${res.statusCode}`));
148
+ return;
149
+ }
150
+ const file = createWriteStream(savePath);
151
+ let total = 0;
152
+ res.on("data", (chunk: Buffer) => {
153
+ total += chunk.length;
154
+ });
155
+ res.pipe(file);
156
+ file.on("finish", () => {
157
+ file.close(() => resolve(total));
158
+ });
159
+ file.on("error", (err) => {
160
+ reject(err);
161
+ });
162
+ },
163
+ );
164
+ req.on("error", (err) => {
165
+ reject(err);
166
+ });
167
+ req.setTimeout(20000, () => {
168
+ req.destroy(new Error("download timeout"));
169
+ });
170
+ });
171
+ }
172
+
173
+ export async function deletePromptImage(
174
+ groupId: number,
175
+ filename: string,
176
+ ): Promise<void> {
177
+ if (!filename) return;
178
+ const safe = path.basename(filename);
179
+ if (!safe || safe !== filename) return;
180
+ const target = getGroupPromptImagePath(groupId, safe);
181
+ await fsp.unlink(target).catch(() => {});
182
+ }
183
+
184
+ export async function pruneGroupPromptImages(
185
+ groupId: number,
186
+ keep: readonly string[],
187
+ ): Promise<void> {
188
+ const dir = getGroupPromptImageDir(groupId);
189
+ if (!existsSync(dir)) return;
190
+ const keepSet = new Set(keep.map((name) => path.basename(name)));
191
+ let entries: string[];
192
+ try {
193
+ entries = await fsp.readdir(dir);
194
+ } catch {
195
+ return;
196
+ }
197
+ await Promise.all(
198
+ entries
199
+ .filter((name) => !keepSet.has(name))
200
+ .map((name) => fsp.unlink(path.join(dir, name)).catch(() => {})),
201
+ );
202
+ }
package/verify/config.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export type VerifyMode = "reaction" | "number" | "chiral";
2
2
 
3
+ export const MAX_CUSTOM_PROMPT_LENGTH = 50;
4
+
3
5
  export interface VerifyGroupConfig {
4
6
  groupId: number;
5
7
  enabled: boolean;
6
8
  mode: VerifyMode;
9
+ customPrompt: string;
10
+ promptImages: string[];
7
11
  }
8
12
 
9
13
  export interface VerifyConfig {
@@ -39,6 +43,35 @@ export const DEFAULT_VERIFY_CONFIG: VerifyConfig = {
39
43
  kickOnTimeout: true,
40
44
  };
41
45
 
46
+ export function normalizeCustomPrompt(value: unknown): string {
47
+ if (typeof value !== "string") return "";
48
+ const trimmed = value.trim();
49
+ if (!trimmed) return "";
50
+ return Array.from(trimmed).slice(0, MAX_CUSTOM_PROMPT_LENGTH).join("");
51
+ }
52
+
53
+ export function normalizePromptImages(value: unknown): string[] {
54
+ if (!Array.isArray(value)) return [];
55
+ const seen = new Set<string>();
56
+ const result: string[] = [];
57
+ for (const item of value) {
58
+ if (typeof item !== "string") continue;
59
+ const name = item.trim();
60
+ if (!name || seen.has(name)) continue;
61
+ seen.add(name);
62
+ result.push(name);
63
+ }
64
+ return result;
65
+ }
66
+
67
+ export function hasCustomPrompt(groupCfg: VerifyGroupConfig | undefined): boolean {
68
+ if (!groupCfg) return false;
69
+ return (
70
+ Boolean(groupCfg.customPrompt && groupCfg.customPrompt.trim()) ||
71
+ groupCfg.promptImages.length > 0
72
+ );
73
+ }
74
+
42
75
  export function normalizeVerifyMode(value: unknown): VerifyMode {
43
76
  const v = String(value || "").trim();
44
77
  if (v === "number" || v === "数字") return "number";
@@ -52,6 +85,12 @@ function normalizeVerifyGroup(raw: any): VerifyGroupConfig {
52
85
  groupId: groupId > 0 ? groupId : 0,
53
86
  enabled: raw?.enabled === true,
54
87
  mode: normalizeVerifyMode(raw?.mode),
88
+ customPrompt: normalizeCustomPrompt(
89
+ raw?.customPrompt ?? raw?.custom_prompt ?? raw?.extraPrompt,
90
+ ),
91
+ promptImages: normalizePromptImages(
92
+ raw?.promptImages ?? raw?.prompt_images ?? raw?.images,
93
+ ),
55
94
  };
56
95
  }
57
96
 
@@ -113,7 +152,13 @@ export function getGroupVerifyConfig(
113
152
  ): VerifyGroupConfig {
114
153
  const found = config.groups.find((g) => g.groupId === groupId);
115
154
  if (found) return found;
116
- return { groupId, enabled: false, mode: "reaction" };
155
+ return {
156
+ groupId,
157
+ enabled: false,
158
+ mode: "reaction",
159
+ customPrompt: "",
160
+ promptImages: [],
161
+ };
117
162
  }
118
163
 
119
164
  export function upsertGroupVerifyConfig(
@@ -123,14 +168,30 @@ export function upsertGroupVerifyConfig(
123
168
  ): VerifyConfig {
124
169
  const idx = config.groups.findIndex((g) => g.groupId === groupId);
125
170
  const next = { ...config };
171
+ const normalizedPatch: Partial<Omit<VerifyGroupConfig, "groupId">> = {
172
+ ...patch,
173
+ };
174
+ if (patch.customPrompt !== undefined) {
175
+ normalizedPatch.customPrompt = normalizeCustomPrompt(patch.customPrompt);
176
+ }
177
+ if (patch.promptImages !== undefined) {
178
+ normalizedPatch.promptImages = normalizePromptImages(patch.promptImages);
179
+ }
126
180
  if (idx >= 0) {
127
181
  next.groups = config.groups.map((g, i) =>
128
- i === idx ? { ...g, ...patch } : g,
182
+ i === idx ? { ...g, ...normalizedPatch } : g,
129
183
  );
130
184
  } else {
131
185
  next.groups = [
132
186
  ...config.groups,
133
- { groupId, enabled: false, mode: "reaction", ...patch },
187
+ {
188
+ groupId,
189
+ enabled: false,
190
+ mode: "reaction",
191
+ customPrompt: "",
192
+ promptImages: [],
193
+ ...normalizedPatch,
194
+ },
134
195
  ];
135
196
  }
136
197
  return next;
package/verify/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { MiokiContext } from "mioki";
2
+ import { existsSync } from "fs";
2
3
  import { getMemberRole } from "../config";
3
4
  import { resolveMemberName, triggerSingleWelcome } from "../notify/welcome";
4
- import { getGroupVerifyConfig, upsertGroupVerifyConfig } from "./config";
5
+ import {
6
+ getGroupVerifyConfig,
7
+ hasCustomPrompt,
8
+ upsertGroupVerifyConfig,
9
+ } from "./config";
5
10
  import type {
6
11
  MemberJoinInfo,
7
12
  PendingVerify,
@@ -12,6 +17,7 @@ import { clearTimers, getPendingMap, pendingKey } from "./state";
12
17
  import { isReactionPass, sendReactionPrompt } from "./reaction";
13
18
  import { isNumberAnswerCorrect, sendNumberPrompt } from "./number";
14
19
  import { checkChiralAnswer, prepareChiral } from "./chiral";
20
+ import { getGroupPromptImagePath } from "../utils/prompt-image-store";
15
21
 
16
22
  const PASS_REACTION_EMOJI_ID = "144";
17
23
 
@@ -91,6 +97,13 @@ export function createVerifyController(
91
97
 
92
98
  if (!getWelcomeEnabled()) return;
93
99
  try {
100
+ const customSent = await trySendCustomWelcome({
101
+ selfId: p.selfId,
102
+ groupId: p.groupId,
103
+ userId: p.userId,
104
+ groupName: p.groupName,
105
+ });
106
+ if (customSent) return;
94
107
  await triggerSingleWelcome({
95
108
  ctx,
96
109
  aiService,
@@ -344,10 +357,41 @@ export function createVerifyController(
344
357
  removePending(pendingKey(info.selfId, info.groupId, info.userId));
345
358
  }
346
359
 
360
+ async function trySendCustomWelcome(info: MemberJoinInfo): Promise<boolean> {
361
+ const groupCfg = getGroupVerifyConfig(getVerifyConfig(), info.groupId);
362
+ if (!hasCustomPrompt(groupCfg)) return false;
363
+ const bot = ctx.pickBot(info.selfId);
364
+ if (!bot) return false;
365
+ const segments: any[] = [];
366
+ const prompt = String(groupCfg.customPrompt || "").trim();
367
+ if (prompt) {
368
+ segments.push(ctx.segment.text(prompt));
369
+ }
370
+ for (const filename of groupCfg.promptImages) {
371
+ const imagePath = getGroupPromptImagePath(info.groupId, filename);
372
+ if (!existsSync(imagePath)) {
373
+ ctx.logger.warn(
374
+ `admin verify 自定义入群提示图片缺失: ${imagePath}`,
375
+ );
376
+ continue;
377
+ }
378
+ segments.push(ctx.segment.image(`file://${imagePath}`));
379
+ }
380
+ if (!segments.length) return false;
381
+ try {
382
+ await bot.sendGroupMsg(info.groupId, segments);
383
+ return true;
384
+ } catch (err) {
385
+ ctx.logger.error(`admin verify 发送自定义入群提示失败: ${err}`);
386
+ return false;
387
+ }
388
+ }
389
+
347
390
  return {
348
391
  handleMemberJoin: startVerification,
349
392
  restartVerification,
350
393
  bypassVerification,
394
+ trySendCustomWelcome,
351
395
  dispose() {
352
396
  for (const p of pending.values()) clearTimers(p);
353
397
  pending.clear();
package/verify/types.ts CHANGED
@@ -42,5 +42,6 @@ export interface VerifyController {
42
42
  handleMemberJoin(info: MemberJoinInfo): Promise<boolean>;
43
43
  restartVerification(info: MemberJoinInfo): Promise<boolean>;
44
44
  bypassVerification(info: MemberJoinInfo): Promise<void>;
45
+ trySendCustomWelcome(info: MemberJoinInfo): Promise<boolean>;
45
46
  dispose(): void;
46
47
  }