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.
- package/commands/verify.ts +109 -2
- package/config.md +37 -3
- package/config.ts +13 -0
- package/index.ts +1 -0
- package/notify/welcome.ts +135 -76
- package/package.json +1 -1
- package/utils/prompt-image-store.ts +202 -0
- package/verify/config.ts +64 -3
- package/verify/index.ts +45 -1
- package/verify/types.ts +1 -0
package/commands/verify.ts
CHANGED
|
@@ -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:
|
|
78
|
-
description:
|
|
79
|
-
|
|
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
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 {
|
|
79
|
-
|
|
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(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
272
|
+
const groupName =
|
|
273
|
+
String(event?.group?.group_name || "").trim() || String(groupId);
|
|
233
274
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
296
|
+
config: cfg,
|
|
286
297
|
selfId,
|
|
287
298
|
groupId,
|
|
288
|
-
groupName
|
|
289
|
-
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
|
-
|
|
301
|
-
ctx.logger.error(`admin welcome 批次处理失败: ${error}`);
|
|
310
|
+
return;
|
|
302
311
|
}
|
|
303
|
-
|
|
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
|
@@ -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 {
|
|
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, ...
|
|
182
|
+
i === idx ? { ...g, ...normalizedPatch } : g,
|
|
129
183
|
);
|
|
130
184
|
} else {
|
|
131
185
|
next.groups = [
|
|
132
186
|
...config.groups,
|
|
133
|
-
{
|
|
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 {
|
|
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
|
}
|