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.
- package/commands/verify.ts +102 -21
- package/config.md +10 -4
- package/config.ts +13 -0
- package/index.ts +1 -0
- package/notify/welcome.ts +43 -0
- package/package.json +1 -1
- package/utils/prompt-image-store.ts +202 -0
- package/verify/chiral.ts +2 -8
- package/verify/config.ts +39 -16
- package/verify/index.ts +49 -4
- package/verify/number.ts +2 -8
- package/verify/reaction.ts +2 -8
- package/verify/types.ts +1 -0
package/commands/verify.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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 (
|
|
255
|
-
if (!groupCfg
|
|
256
|
-
await event.reply("
|
|
272
|
+
if (rawArg === "关闭" || rawArg === "清空" || rawArg === "关") {
|
|
273
|
+
if (!hasCustomPrompt(groupCfg)) {
|
|
274
|
+
await event.reply("本群还没设置自定义入群提示哦~", true);
|
|
257
275
|
return;
|
|
258
276
|
}
|
|
259
|
-
|
|
260
|
-
|
|
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("
|
|
287
|
+
await event.reply("已关闭本群自定义入群提示~", true);
|
|
263
288
|
return;
|
|
264
289
|
}
|
|
265
290
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
352
|
+
customPrompt: nextPrompt,
|
|
353
|
+
promptImage: nextImage,
|
|
279
354
|
});
|
|
280
355
|
await setVerifyConfig(next);
|
|
281
|
-
await
|
|
282
|
-
|
|
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:
|
|
104
|
-
label:
|
|
103
|
+
- key: customPrompt
|
|
104
|
+
label: 群自定义入群提示词
|
|
105
105
|
type: textarea
|
|
106
|
-
description:
|
|
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
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
50
|
+
return Array.from(trimmed).slice(0, MAX_CUSTOM_PROMPT_LENGTH).join("");
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
150
|
-
normalizedPatch.
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
220
|
+
void sendReactionPrompt(ctx, cfg, entry);
|
|
208
221
|
}, delay);
|
|
209
222
|
} else if (mode === "number") {
|
|
210
|
-
void sendNumberPrompt(ctx, cfg,
|
|
223
|
+
void sendNumberPrompt(ctx, cfg, entry);
|
|
211
224
|
} else if (mode === "chiral") {
|
|
212
|
-
const ok = await prepareChiral(ctx, cfg,
|
|
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
|
|
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)),
|
package/verify/reaction.ts
CHANGED
|
@@ -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(` ${
|
|
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
|
}
|