mioku-plugin-admin 2.3.1 → 2.3.3

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,15 +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
- MAX_EXTRA_PROMPT_LENGTH,
6
- normalizeExtraPrompt,
5
+ hasCustomPrompt,
6
+ MAX_CUSTOM_PROMPT_LENGTH,
7
+ normalizeCustomPrompt,
7
8
  normalizeVerifyMode,
8
9
  upsertGroupVerifyConfig,
9
10
  type VerifyConfig,
10
11
  } from "../verify/config";
11
12
  import { replyAdminErrorNotice } from "./notice";
12
13
  import type { VerifyController } from "../verify/types";
14
+ import {
15
+ pruneGroupPromptImages,
16
+ saveRemoteImageAsPrompt,
17
+ } from "../utils/prompt-image-store";
13
18
 
14
19
  export interface VerifyCommandOptions {
15
20
  ctx: MiokiContext;
@@ -24,6 +29,13 @@ const VERIFY_MODE_LABELS: Record<string, string> = {
24
29
  chiral: "手性碳",
25
30
  };
26
31
 
32
+ async function extractQuoteImageUrls(event: any): Promise<string[]> {
33
+ if (!event || typeof event.getQuoteMsg !== "function") return [];
34
+ const quoteMsg = await event.getQuoteMsg().catch(() => null);
35
+ if (!quoteMsg || !Array.isArray(quoteMsg.message)) return [];
36
+ return extractImageUrls(quoteMsg.message);
37
+ }
38
+
27
39
  export function registerVerifyCommands(options: VerifyCommandOptions) {
28
40
  const { ctx, getVerifyConfig, setVerifyConfig, verifyController } = options;
29
41
 
@@ -244,44 +256,113 @@ export function registerVerifyCommands(options: VerifyCommandOptions) {
244
256
  }
245
257
 
246
258
  // /入群提示 xxx
259
+ // /入群提示 关闭
260
+ // /入群提示(带图片,可来自当前消息或引用消息)— 仅图片也可
247
261
  if (text.startsWith("/入群提示") || text.startsWith("#入群提示")) {
248
262
  if (!(await ensureAdminPermission())) return;
249
263
 
250
264
  const rawArg = text.replace(/^[/#]入群提示\s*/, "").trim();
251
265
  const current = getVerifyConfig();
252
266
  const groupCfg = getGroupVerifyConfig(current, groupId);
267
+ const directImageUrls = extractImageUrls(event.message);
268
+ const quoteImageUrls = await extractQuoteImageUrls(event).catch(() => []);
269
+ const imageUrls =
270
+ directImageUrls.length > 0 ? directImageUrls : quoteImageUrls;
253
271
 
254
- if (!rawArg) {
255
- if (!groupCfg.extraPrompt) {
256
- await event.reply("本群还没设置额外的入群提示词哦~", true);
272
+ if (rawArg === "关闭" || rawArg === "清空" || rawArg === "关") {
273
+ if (!hasCustomPrompt(groupCfg)) {
274
+ await event.reply("本群还没设置自定义入群提示哦~", true);
257
275
  return;
258
276
  }
259
- await setVerifyConfig(
260
- upsertGroupVerifyConfig(current, groupId, { extraPrompt: "" }),
277
+ const removedFile = groupCfg.promptImage;
278
+ const next = upsertGroupVerifyConfig(current, groupId, {
279
+ customPrompt: "",
280
+ promptImage: "",
281
+ });
282
+ await setVerifyConfig(next);
283
+ await pruneGroupPromptImages(groupId, []).catch(() => {});
284
+ ctx.logger.info(
285
+ `admin verify 关闭群 ${groupId} 自定义入群提示,清理图片 ${removedFile}`,
261
286
  );
262
- await event.reply("已清空本群额外入群提示词~", true);
287
+ await event.reply("已关闭本群自定义入群提示~", true);
263
288
  return;
264
289
  }
265
290
 
266
- const argLength = Array.from(rawArg).length;
267
- if (argLength > MAX_EXTRA_PROMPT_LENGTH) {
268
- await replyAdminErrorNotice({
269
- ctx,
270
- event,
271
- instruction: `用户想设置本群入群提示词,但内容长度 ${argLength} 超过了上限 ${MAX_EXTRA_PROMPT_LENGTH} 字,请明确告诉用户最多 ${MAX_EXTRA_PROMPT_LENGTH} 字。`,
272
- fallbackMessage: `最多 ${MAX_EXTRA_PROMPT_LENGTH} 字哦~当前 ${argLength} 字`,
273
- });
291
+ if (!rawArg && !imageUrls.length) {
292
+ if (!hasCustomPrompt(groupCfg)) {
293
+ await event.reply(
294
+ "用法:/入群提示 xxx(最多 50 字),可附带图片(当前消息或引用消息中的图片)一起发送;/入群提示 关闭 关闭自定义提示~",
295
+ true,
296
+ );
297
+ return;
298
+ }
299
+ const lines = ["已开启本群自定义入群提示"];
300
+ lines.push(
301
+ groupCfg.customPrompt
302
+ ? `文字:${groupCfg.customPrompt}`
303
+ : "文字:(未设置)",
304
+ );
305
+ lines.push(
306
+ groupCfg.promptImage
307
+ ? `图片:已设置(filename=${groupCfg.promptImage})`
308
+ : "图片:(未设置)",
309
+ );
310
+ await event.reply(lines.join("\n"), true);
274
311
  return;
275
312
  }
276
313
 
314
+ if (rawArg) {
315
+ const argLength = Array.from(rawArg).length;
316
+ if (argLength > MAX_CUSTOM_PROMPT_LENGTH) {
317
+ await replyAdminErrorNotice({
318
+ ctx,
319
+ event,
320
+ instruction: `用户想设置本群入群提示,但内容长度 ${argLength} 超过了上限 ${MAX_CUSTOM_PROMPT_LENGTH} 字,请明确告诉用户最多 ${MAX_CUSTOM_PROMPT_LENGTH} 字。`,
321
+ fallbackMessage: `最多 ${MAX_CUSTOM_PROMPT_LENGTH} 字哦~当前 ${argLength} 字`,
322
+ });
323
+ return;
324
+ }
325
+ }
326
+ const nextPrompt = rawArg
327
+ ? normalizeCustomPrompt(rawArg)
328
+ : groupCfg.customPrompt;
329
+
330
+ let nextImage = groupCfg.promptImage;
331
+ let imageNote = "";
332
+ if (imageUrls.length) {
333
+ const targetUrl = imageUrls[0];
334
+ const savedImage = await saveRemoteImageAsPrompt(groupId, targetUrl);
335
+ if (savedImage) {
336
+ const previousImage = groupCfg.promptImage;
337
+ nextImage = savedImage.filename;
338
+ imageNote = previousImage
339
+ ? `图片已替换(旧文件 ${previousImage} 已删除)`
340
+ : `图片已设置(${savedImage.filename})`;
341
+ if (previousImage) {
342
+ ctx.logger.info(
343
+ `admin verify 替换群 ${groupId} 入群提示图片:${previousImage} -> ${savedImage.filename}`,
344
+ );
345
+ }
346
+ } else {
347
+ imageNote = "图片下载失败,请稍后重试";
348
+ }
349
+ }
350
+
277
351
  const next = upsertGroupVerifyConfig(current, groupId, {
278
- extraPrompt: normalizeExtraPrompt(rawArg),
352
+ customPrompt: nextPrompt,
353
+ promptImage: nextImage,
279
354
  });
280
355
  await setVerifyConfig(next);
281
- await event.reply(
282
- `已设置本群额外入群提示词:${normalizeExtraPrompt(rawArg)}`,
283
- true,
356
+ await pruneGroupPromptImages(groupId, nextImage ? [nextImage] : []).catch(
357
+ () => {},
284
358
  );
359
+
360
+ const lines = ["已更新本群自定义入群提示"];
361
+ if (rawArg) lines.push(`文字:${nextPrompt}`);
362
+ else if (groupCfg.customPrompt)
363
+ lines.push(`文字(保持):${groupCfg.customPrompt}`);
364
+ if (imageUrls.length && imageNote) lines.push(imageNote);
365
+ await event.reply(lines.join("\n"), true);
285
366
  return;
286
367
  }
287
368
  } catch (err) {
package/config.md CHANGED
@@ -100,11 +100,17 @@ fields:
100
100
  label: 手性碳
101
101
  description: 该群入群验证使用的模式
102
102
 
103
- - key: extraPrompt
104
- label: 群额外入群提示词
103
+ - key: customPrompt
104
+ label: 群自定义入群提示词
105
105
  type: textarea
106
- description: 拼接在该群默认验证提示词之后发送,上限 50 字。可在群里直接用 /入群提示 xxx 设置或清空。
107
- placeholder: 例:本群禁止复读机器人消息,请自觉
106
+ description: 一旦设置该群将跳过 AI 生成欢迎,验证通过后直接发送这段文字(可附带本地图片一起)。上限 50 字,建议在群里直接用 /入群提示 xxx 设置或 /入群提示 关闭 关闭。
107
+ placeholder: 例:欢迎来到本群,请先看群公告~
108
+
109
+ - key: promptImage
110
+ label: 群自定义入群提示图片
111
+ type: text
112
+ description: 单张图片文件名(保存在 data/admin/<群号>/prompt-images/),与文字一起发送。在群里发送 /入群提示 时附带图片(当前消息或引用消息中的图片均可)即可替换;/入群提示 关闭 会一并清空。
113
+ placeholder: 例:prompt-l8t9a-3f2c.png
108
114
 
109
115
  - key: verify.reactionEmojiId
110
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,6 +74,12 @@ 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
84
  const {
79
85
  ctx,
@@ -84,9 +90,24 @@ async function flushBatch(options: {
84
90
  groupName,
85
91
  members,
86
92
  promptInjections,
93
+ tryCustomPrompt,
87
94
  } = options;
88
95
  if (!members.length) return "";
89
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
+
90
111
  const names = members.map((m) => m.memberName || String(m.userId));
91
112
  const userList = names.join("、");
92
113
  const userIdList = members.map((m) => String(m.userId)).join(", ");
@@ -140,6 +161,12 @@ async function sendSingleWelcome(options: {
140
161
  userId: number;
141
162
  memberName: string;
142
163
  promptInjections?: { content: string; title?: string }[];
164
+ tryCustomPrompt?: (info: {
165
+ selfId: number;
166
+ groupId: number;
167
+ userId: number;
168
+ groupName: string;
169
+ }) => Promise<boolean>;
143
170
  }): Promise<void> {
144
171
  const {
145
172
  ctx,
@@ -151,6 +178,7 @@ async function sendSingleWelcome(options: {
151
178
  userId,
152
179
  memberName,
153
180
  promptInjections,
181
+ tryCustomPrompt,
154
182
  } = options;
155
183
  const welcomeMessage = await flushBatch({
156
184
  ctx,
@@ -161,6 +189,7 @@ async function sendSingleWelcome(options: {
161
189
  groupName,
162
190
  members: [{ userId, memberName }],
163
191
  promptInjections,
192
+ tryCustomPrompt,
164
193
  });
165
194
  if (!welcomeMessage) return;
166
195
  const bot = ctx.pickBot(selfId);
@@ -182,6 +211,12 @@ export async function triggerSingleWelcome(options: {
182
211
  userId: number;
183
212
  memberName?: string;
184
213
  promptInjections?: { content: string; title?: string }[];
214
+ tryCustomPrompt?: (info: {
215
+ selfId: number;
216
+ groupId: number;
217
+ userId: number;
218
+ groupName: string;
219
+ }) => Promise<boolean>;
185
220
  }): Promise<void> {
186
221
  const memberName =
187
222
  options.memberName ||
@@ -201,6 +236,7 @@ export async function triggerSingleWelcome(options: {
201
236
  userId: options.userId,
202
237
  memberName,
203
238
  promptInjections: options.promptInjections,
239
+ tryCustomPrompt: options.tryCustomPrompt,
204
240
  });
205
241
  }
206
242
 
@@ -214,6 +250,12 @@ export function registerWelcomeHandler(
214
250
  userId: number;
215
251
  groupName: string;
216
252
  }) => Promise<boolean> | boolean,
253
+ tryCustomPrompt?: (info: {
254
+ selfId: number;
255
+ groupId: number;
256
+ userId: number;
257
+ groupName: string;
258
+ }) => Promise<boolean>,
217
259
  ): () => void {
218
260
  const batches = getBatchMap();
219
261
 
@@ -302,6 +344,7 @@ export function registerWelcomeHandler(
302
344
  groupId,
303
345
  groupName: pending.groupName,
304
346
  members: pending.members,
347
+ tryCustomPrompt,
305
348
  });
306
349
  if (!welcomeMessage) return;
307
350
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-admin",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
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/chiral.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import type { MiokiContext } from "mioki";
2
- import {
3
- resolveVerifyPrompt,
4
- type VerifyConfig,
5
- type VerifyGroupConfig,
6
- } from "./config";
2
+ import type { VerifyConfig } from "./config";
7
3
  import type { PendingVerify } from "./types";
8
4
 
9
5
  interface ChiralCaptcha {
@@ -47,7 +43,6 @@ async function fetchChiralCaptcha(
47
43
  export async function prepareChiral(
48
44
  ctx: MiokiContext,
49
45
  cfg: VerifyConfig,
50
- groupCfg: VerifyGroupConfig,
51
46
  p: PendingVerify,
52
47
  ): Promise<boolean> {
53
48
  const bot = ctx.pickBot(p.selfId);
@@ -56,11 +51,10 @@ export async function prepareChiral(
56
51
  const captcha = await fetchChiralCaptcha(cfg.chiralApiUrl, cfg.chiralDifficulty);
57
52
  p.requiredRegions = captcha.regions;
58
53
  p.matchedRegions = new Set<string>();
59
- const basePrompt = cfg.chiralPrompt.replace(
54
+ const prompt = cfg.chiralPrompt.replace(
60
55
  "{count}",
61
56
  String(captcha.regions.length),
62
57
  );
63
- const prompt = resolveVerifyPrompt(basePrompt, groupCfg);
64
58
  await bot.sendGroupMsg(p.groupId, [
65
59
  ctx.segment.at(String(p.userId)),
66
60
  ctx.segment.text(` ${prompt}`),
package/verify/config.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  export type VerifyMode = "reaction" | "number" | "chiral";
2
2
 
3
- export const MAX_EXTRA_PROMPT_LENGTH = 50;
3
+ export const MAX_CUSTOM_PROMPT_LENGTH = 50;
4
4
 
5
5
  export interface VerifyGroupConfig {
6
6
  groupId: number;
7
7
  enabled: boolean;
8
8
  mode: VerifyMode;
9
- extraPrompt: string;
9
+ customPrompt: string;
10
+ promptImage: string;
10
11
  }
11
12
 
12
13
  export interface VerifyConfig {
@@ -42,20 +43,24 @@ export const DEFAULT_VERIFY_CONFIG: VerifyConfig = {
42
43
  kickOnTimeout: true,
43
44
  };
44
45
 
45
- export function normalizeExtraPrompt(value: unknown): string {
46
+ export function normalizeCustomPrompt(value: unknown): string {
46
47
  if (typeof value !== "string") return "";
47
48
  const trimmed = value.trim();
48
49
  if (!trimmed) return "";
49
- return Array.from(trimmed).slice(0, MAX_EXTRA_PROMPT_LENGTH).join("");
50
+ return Array.from(trimmed).slice(0, MAX_CUSTOM_PROMPT_LENGTH).join("");
50
51
  }
51
52
 
52
- export function resolveVerifyPrompt(
53
- basePrompt: string,
54
- groupCfg: VerifyGroupConfig | undefined,
55
- ): string {
56
- const extra = String(groupCfg?.extraPrompt || "").trim();
57
- if (!extra) return basePrompt;
58
- return `${basePrompt}\n${extra}`;
53
+ export function normalizePromptImage(value: unknown): string {
54
+ if (typeof value !== "string") return "";
55
+ return value.trim();
56
+ }
57
+
58
+ export function hasCustomPrompt(groupCfg: VerifyGroupConfig | undefined): boolean {
59
+ if (!groupCfg) return false;
60
+ return (
61
+ Boolean(groupCfg.customPrompt && groupCfg.customPrompt.trim()) ||
62
+ Boolean(groupCfg.promptImage && groupCfg.promptImage.trim())
63
+ );
59
64
  }
60
65
 
61
66
  export function normalizeVerifyMode(value: unknown): VerifyMode {
@@ -67,11 +72,19 @@ export function normalizeVerifyMode(value: unknown): VerifyMode {
67
72
 
68
73
  function normalizeVerifyGroup(raw: any): VerifyGroupConfig {
69
74
  const groupId = Number(raw?.groupId || raw?.group_id || 0);
75
+ const promptImageRaw =
76
+ raw?.promptImage ??
77
+ raw?.prompt_image ??
78
+ (Array.isArray(raw?.promptImages) ? raw.promptImages[0] : undefined) ??
79
+ (Array.isArray(raw?.images) ? raw.images[0] : undefined);
70
80
  return {
71
81
  groupId: groupId > 0 ? groupId : 0,
72
82
  enabled: raw?.enabled === true,
73
83
  mode: normalizeVerifyMode(raw?.mode),
74
- extraPrompt: normalizeExtraPrompt(raw?.extraPrompt ?? raw?.extra_prompt),
84
+ customPrompt: normalizeCustomPrompt(
85
+ raw?.customPrompt ?? raw?.custom_prompt ?? raw?.extraPrompt,
86
+ ),
87
+ promptImage: normalizePromptImage(promptImageRaw),
75
88
  };
76
89
  }
77
90
 
@@ -133,7 +146,13 @@ export function getGroupVerifyConfig(
133
146
  ): VerifyGroupConfig {
134
147
  const found = config.groups.find((g) => g.groupId === groupId);
135
148
  if (found) return found;
136
- return { groupId, enabled: false, mode: "reaction", extraPrompt: "" };
149
+ return {
150
+ groupId,
151
+ enabled: false,
152
+ mode: "reaction",
153
+ customPrompt: "",
154
+ promptImage: "",
155
+ };
137
156
  }
138
157
 
139
158
  export function upsertGroupVerifyConfig(
@@ -146,8 +165,11 @@ export function upsertGroupVerifyConfig(
146
165
  const normalizedPatch: Partial<Omit<VerifyGroupConfig, "groupId">> = {
147
166
  ...patch,
148
167
  };
149
- if (patch.extraPrompt !== undefined) {
150
- normalizedPatch.extraPrompt = normalizeExtraPrompt(patch.extraPrompt);
168
+ if (patch.customPrompt !== undefined) {
169
+ normalizedPatch.customPrompt = normalizeCustomPrompt(patch.customPrompt);
170
+ }
171
+ if (patch.promptImage !== undefined) {
172
+ normalizedPatch.promptImage = normalizePromptImage(patch.promptImage);
151
173
  }
152
174
  if (idx >= 0) {
153
175
  next.groups = config.groups.map((g, i) =>
@@ -160,7 +182,8 @@ export function upsertGroupVerifyConfig(
160
182
  groupId,
161
183
  enabled: false,
162
184
  mode: "reaction",
163
- extraPrompt: "",
185
+ customPrompt: "",
186
+ promptImage: "",
164
187
  ...normalizedPatch,
165
188
  },
166
189
  ];
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,
@@ -204,12 +217,12 @@ export function createVerifyController(
204
217
  const delay = skipDelay ? 0 : Math.max(0, cfg.reactionDelayMs);
205
218
  entry.delayTimer = setTimeout(() => {
206
219
  entry.delayTimer = null;
207
- void sendReactionPrompt(ctx, cfg, groupCfg, entry);
220
+ void sendReactionPrompt(ctx, cfg, entry);
208
221
  }, delay);
209
222
  } else if (mode === "number") {
210
- void sendNumberPrompt(ctx, cfg, groupCfg, entry);
223
+ void sendNumberPrompt(ctx, cfg, entry);
211
224
  } else if (mode === "chiral") {
212
- const ok = await prepareChiral(ctx, cfg, groupCfg, entry);
225
+ const ok = await prepareChiral(ctx, cfg, entry);
213
226
  if (!ok) {
214
227
  // 验证服务不可用时放行,避免误伤
215
228
  removePending(key);
@@ -344,10 +357,42 @@ 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
+ const filename = String(groupCfg.promptImage || "").trim();
371
+ if (filename) {
372
+ const imagePath = getGroupPromptImagePath(info.groupId, filename);
373
+ if (!existsSync(imagePath)) {
374
+ ctx.logger.warn(
375
+ `admin verify 自定义入群提示图片缺失: ${imagePath}`,
376
+ );
377
+ } else {
378
+ segments.push(ctx.segment.image(`file://${imagePath}`));
379
+ }
380
+ }
381
+ if (!segments.length) return false;
382
+ try {
383
+ await bot.sendGroupMsg(info.groupId, segments);
384
+ return true;
385
+ } catch (err) {
386
+ ctx.logger.error(`admin verify 发送自定义入群提示失败: ${err}`);
387
+ return false;
388
+ }
389
+ }
390
+
347
391
  return {
348
392
  handleMemberJoin: startVerification,
349
393
  restartVerification,
350
394
  bypassVerification,
395
+ trySendCustomWelcome,
351
396
  dispose() {
352
397
  for (const p of pending.values()) clearTimers(p);
353
398
  pending.clear();
package/verify/number.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import type { MiokiContext } from "mioki";
2
- import {
3
- resolveVerifyPrompt,
4
- type VerifyConfig,
5
- type VerifyGroupConfig,
6
- } from "./config";
2
+ import type { VerifyConfig } from "./config";
7
3
  import type { PendingVerify } from "./types";
8
4
 
9
5
  function genNumberQuestion(): { question: string; answer: number } {
@@ -23,15 +19,13 @@ function extractNumbers(text: string): number[] {
23
19
  export async function sendNumberPrompt(
24
20
  ctx: MiokiContext,
25
21
  cfg: VerifyConfig,
26
- groupCfg: VerifyGroupConfig,
27
22
  p: PendingVerify,
28
23
  ): Promise<void> {
29
24
  const bot = ctx.pickBot(p.selfId);
30
25
  if (!bot) return;
31
26
  const { question, answer } = genNumberQuestion();
32
27
  p.numberAnswer = answer;
33
- const basePrompt = cfg.numberPrompt.replace("{question}", question);
34
- const prompt = resolveVerifyPrompt(basePrompt, groupCfg);
28
+ const prompt = cfg.numberPrompt.replace("{question}", question);
35
29
  try {
36
30
  await bot.sendGroupMsg(p.groupId, [
37
31
  ctx.segment.at(String(p.userId)),
@@ -1,25 +1,19 @@
1
1
  import type { MiokiContext } from "mioki";
2
- import {
3
- resolveVerifyPrompt,
4
- type VerifyConfig,
5
- type VerifyGroupConfig,
6
- } from "./config";
2
+ import type { VerifyConfig } from "./config";
7
3
  import type { PendingVerify } from "./types";
8
4
 
9
5
  export async function sendReactionPrompt(
10
6
  ctx: MiokiContext,
11
7
  cfg: VerifyConfig,
12
- groupCfg: VerifyGroupConfig,
13
8
  p: PendingVerify,
14
9
  ): Promise<void> {
15
10
  const bot = ctx.pickBot(p.selfId);
16
11
  if (!bot) return;
17
- const prompt = resolveVerifyPrompt(cfg.reactionPrompt, groupCfg);
18
12
  let messageId: number | undefined;
19
13
  try {
20
14
  const res = await bot.sendGroupMsg(p.groupId, [
21
15
  ctx.segment.at(String(p.userId)),
22
- ctx.segment.text(` ${prompt}`),
16
+ ctx.segment.text(` ${cfg.reactionPrompt}`),
23
17
  ]);
24
18
  messageId = Number(res?.message_id || 0) || undefined;
25
19
  } catch (err) {
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
  }