mioku-plugin-meme 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/config.md +103 -0
- package/configs/base.ts +25 -0
- package/configs/filters.ts +26 -0
- package/index.ts +374 -0
- package/package.json +61 -0
- package/runtime.ts +26 -0
- package/shared.ts +1160 -0
- package/skills.ts +157 -0
- package/types.ts +93 -0
package/shared.ts
ADDED
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
MemeBaseConfig,
|
|
5
|
+
MemeFilterConfig,
|
|
6
|
+
MemeGenerateOptions,
|
|
7
|
+
MemeGenerateResult,
|
|
8
|
+
MemeImageSourceOptions,
|
|
9
|
+
MemeInfo,
|
|
10
|
+
MemeInfoParams,
|
|
11
|
+
MemeKeywordMatch,
|
|
12
|
+
MemeUserInfo,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
interface LoggerLike {
|
|
16
|
+
info(message: string): void;
|
|
17
|
+
warn(message: string): void;
|
|
18
|
+
error(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cloneJson<T>(value: T): T {
|
|
22
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function randomInt(min: number, max: number): number {
|
|
26
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sampleOne<T>(items: T[]): T | undefined {
|
|
30
|
+
if (items.length === 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return items[randomInt(0, items.length - 1)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeApiBaseUrl(baseUrl: string): string {
|
|
37
|
+
return String(baseUrl || "").replace(/\/+$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toMemesApiBase(baseUrl: string): string {
|
|
41
|
+
return `${normalizeApiBaseUrl(baseUrl)}/memes`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function uniqueStrings(items: string[]): string[] {
|
|
45
|
+
return Array.from(new Set(items.filter(Boolean)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLocalFilePath(value: string): boolean {
|
|
49
|
+
if (!value) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (value.startsWith("base64://")) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (/^https?:\/\//i.test(value)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return path.isAbsolute(value) || value.startsWith(".");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function imageBufferToBase64(buffer: Buffer): Promise<string> {
|
|
62
|
+
return `base64://${buffer.toString("base64")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveMemeParams(info: MemeInfo): MemeInfoParams {
|
|
66
|
+
return info.params || info.params_type || {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getSenderName(event: any): string {
|
|
70
|
+
return (
|
|
71
|
+
event?.sender?.card ||
|
|
72
|
+
event?.sender?.nickname ||
|
|
73
|
+
String(event?.user_id || "用户")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getAvatarUrl(userId: number | string | undefined): string {
|
|
78
|
+
return `https://q1.qlogo.cn/g?b=qq&s=640&nk=${userId || 0}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractTextAndArgs(input: string): { text: string; args: string } {
|
|
82
|
+
const raw = String(input || "").trim();
|
|
83
|
+
if (!raw) {
|
|
84
|
+
return { text: "", args: "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hashIndex = raw.indexOf("#");
|
|
88
|
+
if (hashIndex >= 0) {
|
|
89
|
+
return {
|
|
90
|
+
text: raw.slice(0, hashIndex).trim(),
|
|
91
|
+
args: raw.slice(hashIndex + 1).trim(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pipeIndex = raw.indexOf("|");
|
|
96
|
+
if (pipeIndex >= 0) {
|
|
97
|
+
return {
|
|
98
|
+
text: raw.slice(0, pipeIndex).trim(),
|
|
99
|
+
args: raw.slice(pipeIndex + 1).trim(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { text: raw, args: "" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getAtUserIds(message: any[]): number[] {
|
|
107
|
+
const ids: number[] = [];
|
|
108
|
+
for (const seg of message || []) {
|
|
109
|
+
if (seg?.type !== "at") {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const rawValue =
|
|
113
|
+
seg.qq ?? seg.data?.qq ?? seg.data?.id ?? seg.data?.user_id;
|
|
114
|
+
if (rawValue == null || rawValue === "all" || rawValue === "everyone") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const id = Number(rawValue);
|
|
118
|
+
if (Number.isFinite(id)) {
|
|
119
|
+
ids.push(id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return uniqueStrings(ids.map(String)).map((value) => Number(value));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extractImageUrls(message: any[]): string[] {
|
|
126
|
+
const urls: string[] = [];
|
|
127
|
+
for (const seg of message || []) {
|
|
128
|
+
if (seg?.type !== "image") {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const url = seg.url || seg.data?.url;
|
|
132
|
+
if (typeof url === "string" && url.trim()) {
|
|
133
|
+
urls.push(url.trim());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return urls;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getSupportedArgsText(code: string): string {
|
|
140
|
+
const common = {
|
|
141
|
+
yuan: "是否圆形头像,输入圆即可。如:#圆",
|
|
142
|
+
pos: "位置参数,支持左、右、两边。如:#两边",
|
|
143
|
+
direction: "方向参数,支持上、下、左、右。如:#下",
|
|
144
|
+
time: "指定时间文本。如:#2020/02/02",
|
|
145
|
+
name: "指定名字文本。如:#Miku",
|
|
146
|
+
message: "指定扫码或消息内容。如:#你干嘛",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
{
|
|
151
|
+
alipay: common.message,
|
|
152
|
+
always: "模式参数,支持循环、套娃、默认。如:#循环",
|
|
153
|
+
atri_pillow: "模式参数,支持 yes 或 no。如:#yes",
|
|
154
|
+
bubble_tea: common.pos,
|
|
155
|
+
certificate: common.time,
|
|
156
|
+
clown: "输入爷可启用爷爷头轮廓。如:#爷",
|
|
157
|
+
clown_mask: "遮罩位置,支持前、后。如:#前",
|
|
158
|
+
crawl: "图片编号 1-92。如:#11",
|
|
159
|
+
dog_dislike: common.yuan,
|
|
160
|
+
firefly_holdsign: "图片编号 1-21。如:#11",
|
|
161
|
+
genshin_eat: "角色支持八重、胡桃、妮露、可莉、刻晴、钟离。如:#刻晴",
|
|
162
|
+
guichu: common.direction,
|
|
163
|
+
gun: common.pos,
|
|
164
|
+
jiji_king: common.yuan,
|
|
165
|
+
kaleidoscope: common.yuan,
|
|
166
|
+
kirby_hammer: common.yuan,
|
|
167
|
+
left_right_jump: "跑动方向,支持左右、右左。如:#左右",
|
|
168
|
+
look_flat: "看扁率数字。如:#3",
|
|
169
|
+
loop: common.direction,
|
|
170
|
+
mourning: "输入黑白或灰启用黑白图。如:#灰",
|
|
171
|
+
my_friend: common.name,
|
|
172
|
+
my_wife: "格式为 受益人/称呼。如:#我/老婆",
|
|
173
|
+
note_for_leave: common.time,
|
|
174
|
+
panda_dragon_figure: common.name,
|
|
175
|
+
petpet: common.yuan,
|
|
176
|
+
pixelate: "像素化大小,默认 10。如:#22",
|
|
177
|
+
steam_message: common.name,
|
|
178
|
+
symmetric: common.direction,
|
|
179
|
+
wechat_pay: common.message,
|
|
180
|
+
}[code] || ""
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function replyWithParts(options: {
|
|
185
|
+
ctx: any;
|
|
186
|
+
event: any;
|
|
187
|
+
parts: any[];
|
|
188
|
+
quoteReply?: boolean;
|
|
189
|
+
}): Promise<void> {
|
|
190
|
+
const { event, parts, quoteReply = false } = options;
|
|
191
|
+
const payload = [...parts];
|
|
192
|
+
if (quoteReply && event?.message_id != null) {
|
|
193
|
+
payload.unshift({ type: "reply", id: String(event.message_id) });
|
|
194
|
+
}
|
|
195
|
+
await event.reply(payload.length === 1 ? payload[0] : payload);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function replyWithImage(options: {
|
|
199
|
+
ctx: any;
|
|
200
|
+
event: any;
|
|
201
|
+
image: string;
|
|
202
|
+
caption?: string;
|
|
203
|
+
quoteReply?: boolean;
|
|
204
|
+
}): Promise<void> {
|
|
205
|
+
const { ctx, event, image, caption, quoteReply = false } = options;
|
|
206
|
+
const parts: any[] = [];
|
|
207
|
+
if (caption) {
|
|
208
|
+
parts.push(caption);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const imageSegment = ctx?.segment?.image
|
|
212
|
+
? ctx.segment.image(image)
|
|
213
|
+
: { type: "image", file: image };
|
|
214
|
+
parts.push(imageSegment);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await replyWithParts({ ctx, event, parts, quoteReply });
|
|
218
|
+
return;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isLocalFilePath(image)) {
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const imageBuffer = await fs.promises.readFile(image);
|
|
226
|
+
const base64Image = await imageBufferToBase64(imageBuffer);
|
|
227
|
+
const fallbackParts: any[] = [];
|
|
228
|
+
if (caption) {
|
|
229
|
+
fallbackParts.push(caption);
|
|
230
|
+
}
|
|
231
|
+
fallbackParts.push(
|
|
232
|
+
ctx?.segment?.image
|
|
233
|
+
? ctx.segment.image(base64Image)
|
|
234
|
+
: { type: "image", file: base64Image },
|
|
235
|
+
);
|
|
236
|
+
await replyWithParts({ ctx, event, parts: fallbackParts, quoteReply });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function sendSkillImage(options: {
|
|
240
|
+
ctx: any;
|
|
241
|
+
event: any;
|
|
242
|
+
image: string;
|
|
243
|
+
caption?: string;
|
|
244
|
+
quoteReply?: boolean;
|
|
245
|
+
}): Promise<void> {
|
|
246
|
+
const { ctx, event, image, caption, quoteReply = false } = options;
|
|
247
|
+
const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
|
|
248
|
+
const bot =
|
|
249
|
+
selfId != null && typeof ctx?.pickBot === "function"
|
|
250
|
+
? ctx.pickBot(selfId)
|
|
251
|
+
: undefined;
|
|
252
|
+
|
|
253
|
+
if (!bot) {
|
|
254
|
+
throw new Error("当前上下文不支持发送图片");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const buildPayload = (file: string) => {
|
|
258
|
+
const payload: any[] = [];
|
|
259
|
+
if (quoteReply && event?.message_id != null) {
|
|
260
|
+
payload.push({ type: "reply", id: String(event.message_id) });
|
|
261
|
+
}
|
|
262
|
+
if (caption) {
|
|
263
|
+
payload.push(caption);
|
|
264
|
+
}
|
|
265
|
+
payload.push(
|
|
266
|
+
ctx?.segment?.image ? ctx.segment.image(file) : { type: "image", file },
|
|
267
|
+
);
|
|
268
|
+
return payload;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const sendPayload = async (file: string) => {
|
|
272
|
+
const payload = buildPayload(file);
|
|
273
|
+
if (event?.message_type === "group" && event?.group_id != null) {
|
|
274
|
+
await bot.sendGroupMsg(event.group_id, payload);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (event?.user_id != null) {
|
|
278
|
+
await bot.sendPrivateMsg(event.user_id, payload);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
throw new Error("当前上下文不支持发送图片");
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await sendPayload(image);
|
|
286
|
+
return;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (!isLocalFilePath(image)) {
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const imageBuffer = await fs.promises.readFile(image);
|
|
294
|
+
const base64Image = await imageBufferToBase64(imageBuffer);
|
|
295
|
+
await sendPayload(base64Image);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export class MemePluginRuntime {
|
|
299
|
+
private baseConfig: MemeBaseConfig;
|
|
300
|
+
private filterConfig: MemeFilterConfig;
|
|
301
|
+
private readonly dataDir: string;
|
|
302
|
+
private readonly keyMapPath: string;
|
|
303
|
+
private readonly infosPath: string;
|
|
304
|
+
private readonly menuImagePath: string;
|
|
305
|
+
private keyMap: Record<string, string> = {};
|
|
306
|
+
private infos: Record<string, MemeInfo> = {};
|
|
307
|
+
|
|
308
|
+
constructor(options: {
|
|
309
|
+
logger: LoggerLike;
|
|
310
|
+
baseConfig: MemeBaseConfig;
|
|
311
|
+
filterConfig: MemeFilterConfig;
|
|
312
|
+
}) {
|
|
313
|
+
this.logger = options.logger;
|
|
314
|
+
this.baseConfig = cloneJson(options.baseConfig);
|
|
315
|
+
this.filterConfig = cloneJson(options.filterConfig);
|
|
316
|
+
this.dataDir = path.join(process.cwd(), "data", "meme");
|
|
317
|
+
this.keyMapPath = path.join(this.dataDir, "key-map.json");
|
|
318
|
+
this.infosPath = path.join(this.dataDir, "infos.json");
|
|
319
|
+
this.menuImagePath = path.join(this.dataDir, "menu.jpg");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private readonly logger: LoggerLike;
|
|
323
|
+
|
|
324
|
+
updateBaseConfig(baseConfig: MemeBaseConfig): void {
|
|
325
|
+
this.baseConfig = cloneJson(baseConfig);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
updateFilterConfig(filterConfig: MemeFilterConfig): void {
|
|
329
|
+
this.filterConfig = cloneJson(filterConfig);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
getBaseConfig(): MemeBaseConfig {
|
|
333
|
+
return cloneJson(this.baseConfig);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
getFilterConfig(): MemeFilterConfig {
|
|
337
|
+
return cloneJson(this.filterConfig);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getMenuImagePath(): string {
|
|
341
|
+
return this.menuImagePath;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
getMemeCount(): number {
|
|
345
|
+
return Object.keys(this.infos).length;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
getKeywordCount(): number {
|
|
349
|
+
return Object.keys(this.keyMap).length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async initialize(): Promise<void> {
|
|
353
|
+
await this.ensureDataDir();
|
|
354
|
+
const loaded = await this.loadCacheFromDisk();
|
|
355
|
+
if (!loaded || this.baseConfig.cache.refreshOnStartup) {
|
|
356
|
+
await this.refreshCache();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async ensureCache(forceRefresh: boolean = false): Promise<void> {
|
|
361
|
+
await this.ensureDataDir();
|
|
362
|
+
const hasMemoryCache =
|
|
363
|
+
Object.keys(this.infos).length > 0 && Object.keys(this.keyMap).length > 0;
|
|
364
|
+
if (forceRefresh) {
|
|
365
|
+
await this.refreshCache();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (hasMemoryCache) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const loaded = await this.loadCacheFromDisk();
|
|
372
|
+
if (!loaded) {
|
|
373
|
+
await this.refreshCache();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async refreshCache(): Promise<void> {
|
|
378
|
+
await this.ensureDataDir();
|
|
379
|
+
this.logger.info("[meme] 开始刷新表情缓存");
|
|
380
|
+
|
|
381
|
+
const memeBase = toMemesApiBase(this.baseConfig.api.baseUrl);
|
|
382
|
+
const keys = await this.fetchJson<string[]>(`${memeBase}/keys`);
|
|
383
|
+
const infosEntries = await Promise.all(
|
|
384
|
+
keys.map(async (key) => {
|
|
385
|
+
const info = await this.fetchJson<MemeInfo>(`${memeBase}/${key}/info`);
|
|
386
|
+
return [key, info] as const;
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const nextInfos: Record<string, MemeInfo> = {};
|
|
391
|
+
const nextKeyMap: Record<string, string> = {};
|
|
392
|
+
for (const [key, info] of infosEntries) {
|
|
393
|
+
nextInfos[key] = info;
|
|
394
|
+
for (const keyword of info.keywords || []) {
|
|
395
|
+
nextKeyMap[String(keyword).trim()] = key;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const menuResponse = await this.fetchResponse(`${memeBase}/render_list`, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
});
|
|
402
|
+
const menuBuffer = Buffer.from(await menuResponse.arrayBuffer());
|
|
403
|
+
|
|
404
|
+
await fs.promises.writeFile(
|
|
405
|
+
this.keyMapPath,
|
|
406
|
+
JSON.stringify(nextKeyMap, null, 2),
|
|
407
|
+
"utf-8",
|
|
408
|
+
);
|
|
409
|
+
await fs.promises.writeFile(
|
|
410
|
+
this.infosPath,
|
|
411
|
+
JSON.stringify(nextInfos, null, 2),
|
|
412
|
+
"utf-8",
|
|
413
|
+
);
|
|
414
|
+
await fs.promises.writeFile(this.menuImagePath, menuBuffer);
|
|
415
|
+
|
|
416
|
+
this.keyMap = nextKeyMap;
|
|
417
|
+
this.infos = nextInfos;
|
|
418
|
+
this.logger.info(
|
|
419
|
+
`[meme] 表情缓存刷新完成: ${Object.keys(nextInfos).length} 个表情, ${Object.keys(nextKeyMap).length} 个关键词`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
getHelpText(): string {
|
|
424
|
+
return [
|
|
425
|
+
"meme 插件命令:",
|
|
426
|
+
"1. /meme 搜索 关键词",
|
|
427
|
+
"2. /meme 详情 关键词",
|
|
428
|
+
"3. /随机表情",
|
|
429
|
+
"",
|
|
430
|
+
"说明:",
|
|
431
|
+
"- 文本段用 / 分隔,例如:/喜报 第一行/第二行",
|
|
432
|
+
"- 额外参数用 # 或 | 分隔,例如:/rua 群主#圆",
|
|
433
|
+
"- 引用一张图片、直接发图、或 @ 某人时,会自动尝试取图",
|
|
434
|
+
].join("\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
buildMenuCaption(): string {
|
|
438
|
+
return `Memes 已同步 ${this.getMemeCount()} 个表情,${this.getKeywordCount()} 个关键词`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
searchKeywords(query: string): string[] {
|
|
442
|
+
const normalized = String(query || "").trim();
|
|
443
|
+
if (!normalized) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return Object.keys(this.keyMap)
|
|
448
|
+
.filter((keyword) => keyword.includes(normalized))
|
|
449
|
+
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"))
|
|
450
|
+
.slice(0, this.baseConfig.behavior.maxSearchResults);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
findMatch(input: string): MemeKeywordMatch | null {
|
|
454
|
+
const text = String(input || "").trim();
|
|
455
|
+
if (!text) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const keywords = Object.keys(this.keyMap).sort(
|
|
460
|
+
(a, b) => b.length - a.length,
|
|
461
|
+
);
|
|
462
|
+
const keyword = keywords.find((candidate) => text.startsWith(candidate));
|
|
463
|
+
if (!keyword) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const key = this.keyMap[keyword];
|
|
468
|
+
const info = this.infos[key];
|
|
469
|
+
if (!info) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
keyword,
|
|
475
|
+
key,
|
|
476
|
+
info,
|
|
477
|
+
rest: text.slice(keyword.length).trim(),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
getDetail(keywordOrKey: string): {
|
|
482
|
+
key: string;
|
|
483
|
+
keyword: string;
|
|
484
|
+
info: MemeInfo;
|
|
485
|
+
detailText: string;
|
|
486
|
+
} | null {
|
|
487
|
+
const normalized = String(keywordOrKey || "").trim();
|
|
488
|
+
if (!normalized) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let key = this.keyMap[normalized];
|
|
493
|
+
let keyword = normalized;
|
|
494
|
+
|
|
495
|
+
if (!key && this.infos[normalized]) {
|
|
496
|
+
key = normalized;
|
|
497
|
+
keyword = this.infos[normalized].keywords?.[0] || normalized;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!key) {
|
|
501
|
+
const match = this.findMatch(normalized);
|
|
502
|
+
if (match) {
|
|
503
|
+
key = match.key;
|
|
504
|
+
keyword = match.keyword;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!key || !this.infos[key]) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const info = this.infos[key];
|
|
513
|
+
const params = resolveMemeParams(info);
|
|
514
|
+
const defaultTexts =
|
|
515
|
+
params.default_texts && params.default_texts.length > 0
|
|
516
|
+
? params.default_texts.join("/")
|
|
517
|
+
: "无";
|
|
518
|
+
const supportArgs = getSupportedArgsText(key);
|
|
519
|
+
|
|
520
|
+
const detailText = [
|
|
521
|
+
`代码:${info.key}`,
|
|
522
|
+
`指令:${(info.keywords || []).join("、") || keyword}`,
|
|
523
|
+
`图片数量:${params.min_images || 0} - ${params.max_images || 0}`,
|
|
524
|
+
`文本段数:${params.min_texts || 0} - ${params.max_texts || 0}`,
|
|
525
|
+
`默认文本:${defaultTexts}`,
|
|
526
|
+
supportArgs ? `支持参数:${supportArgs}` : "",
|
|
527
|
+
]
|
|
528
|
+
.filter(Boolean)
|
|
529
|
+
.join("\n");
|
|
530
|
+
|
|
531
|
+
return { key, keyword, info, detailText };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getPreviewUrl(key: string): string {
|
|
535
|
+
return `${toMemesApiBase(this.baseConfig.api.baseUrl)}/${key}/preview`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
pickRandomKeyword(): string | null {
|
|
539
|
+
const candidates = Object.values(this.infos)
|
|
540
|
+
.filter((info) => {
|
|
541
|
+
const params = resolveMemeParams(info);
|
|
542
|
+
return (params.min_images || 0) === 1 && (params.min_texts || 0) === 0;
|
|
543
|
+
})
|
|
544
|
+
.map((info) => info.keywords?.[0])
|
|
545
|
+
.filter((keyword): keyword is string => Boolean(keyword));
|
|
546
|
+
|
|
547
|
+
const selected = sampleOne(candidates);
|
|
548
|
+
if (selected) {
|
|
549
|
+
return selected;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const fallback = sampleOne(Object.keys(this.keyMap));
|
|
553
|
+
return fallback || null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async sendMenu(ctx: any, event: any): Promise<void> {
|
|
557
|
+
await this.ensureCache();
|
|
558
|
+
const caption = this.buildMenuCaption();
|
|
559
|
+
if (fs.existsSync(this.menuImagePath)) {
|
|
560
|
+
await replyWithImage({
|
|
561
|
+
ctx,
|
|
562
|
+
event,
|
|
563
|
+
image: this.menuImagePath,
|
|
564
|
+
caption,
|
|
565
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
await replyWithParts({
|
|
571
|
+
ctx,
|
|
572
|
+
event,
|
|
573
|
+
parts: [caption],
|
|
574
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async sendHelp(ctx: any, event: any): Promise<void> {
|
|
579
|
+
await replyWithParts({
|
|
580
|
+
ctx,
|
|
581
|
+
event,
|
|
582
|
+
parts: [this.getHelpText()],
|
|
583
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async sendSearch(ctx: any, event: any, query: string): Promise<void> {
|
|
588
|
+
await this.ensureCache();
|
|
589
|
+
const hits = this.searchKeywords(query);
|
|
590
|
+
const text = !query.trim()
|
|
591
|
+
? "你要搜什么?"
|
|
592
|
+
: hits.length > 0
|
|
593
|
+
? `搜索结果:\n${hits.map((hit, index) => `${index + 1}. ${hit}`).join("\n")}`
|
|
594
|
+
: "搜索结果:无";
|
|
595
|
+
|
|
596
|
+
await replyWithParts({
|
|
597
|
+
ctx,
|
|
598
|
+
event,
|
|
599
|
+
parts: [text],
|
|
600
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async sendDetail(ctx: any, event: any, keywordOrKey: string): Promise<void> {
|
|
605
|
+
await this.ensureCache();
|
|
606
|
+
const detail = this.getDetail(keywordOrKey);
|
|
607
|
+
if (!detail) {
|
|
608
|
+
await replyWithParts({
|
|
609
|
+
ctx,
|
|
610
|
+
event,
|
|
611
|
+
parts: [`未找到表情关键词:${keywordOrKey}`],
|
|
612
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
613
|
+
});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const parts: any[] = [detail.detailText];
|
|
618
|
+
if (this.baseConfig.behavior.includePreviewInDetail) {
|
|
619
|
+
try {
|
|
620
|
+
const previewResponse = await this.fetchResponse(
|
|
621
|
+
this.getPreviewUrl(detail.key),
|
|
622
|
+
);
|
|
623
|
+
const previewBuffer = Buffer.from(await previewResponse.arrayBuffer());
|
|
624
|
+
const previewBase64 = await imageBufferToBase64(previewBuffer);
|
|
625
|
+
parts.push(
|
|
626
|
+
ctx?.segment?.image
|
|
627
|
+
? ctx.segment.image(previewBase64)
|
|
628
|
+
: { type: "image", file: previewBase64 },
|
|
629
|
+
);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
this.logger.warn(`[meme] 获取预览失败: ${error}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await replyWithParts({
|
|
636
|
+
ctx,
|
|
637
|
+
event,
|
|
638
|
+
parts,
|
|
639
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async sendRandom(ctx: any, event: any): Promise<void> {
|
|
644
|
+
await this.ensureCache();
|
|
645
|
+
const keyword = this.pickRandomKeyword();
|
|
646
|
+
if (!keyword) {
|
|
647
|
+
await replyWithParts({
|
|
648
|
+
ctx,
|
|
649
|
+
event,
|
|
650
|
+
parts: ["当前没有可用的随机表情"],
|
|
651
|
+
quoteReply: this.baseConfig.behavior.quoteReply,
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
await this.generateFromInput(ctx, event, keyword, {
|
|
657
|
+
send: true,
|
|
658
|
+
randomLabel: keyword,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async generateFromInput(
|
|
663
|
+
ctx: any,
|
|
664
|
+
event: any,
|
|
665
|
+
input: string,
|
|
666
|
+
options: Partial<MemeGenerateOptions> = {},
|
|
667
|
+
): Promise<MemeGenerateResult> {
|
|
668
|
+
await this.ensureCache();
|
|
669
|
+
const match = this.findMatch(input);
|
|
670
|
+
if (!match) {
|
|
671
|
+
return {
|
|
672
|
+
ok: false,
|
|
673
|
+
message: `未匹配到表情关键词:${input}`,
|
|
674
|
+
shouldNotice: true,
|
|
675
|
+
noticeInstruction: `用户输入了 "${input}",但没有匹配到任何可用表情关键词。请自然提醒用户先执行“meme 搜索 关键词”再尝试生成`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const rest = options.text != null || options.args != null ? "" : match.rest;
|
|
680
|
+
const parsed = extractTextAndArgs(rest);
|
|
681
|
+
|
|
682
|
+
return this.generateByKeyword(ctx, event, {
|
|
683
|
+
keyword: match.keyword,
|
|
684
|
+
text: options.text ?? parsed.text,
|
|
685
|
+
args: options.args ?? parsed.args,
|
|
686
|
+
send: options.send,
|
|
687
|
+
quoteReply: options.quoteReply,
|
|
688
|
+
randomLabel: options.randomLabel,
|
|
689
|
+
imageSource: options.imageSource,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async generateByKeyword(
|
|
694
|
+
ctx: any,
|
|
695
|
+
event: any,
|
|
696
|
+
options: MemeGenerateOptions,
|
|
697
|
+
): Promise<MemeGenerateResult> {
|
|
698
|
+
await this.ensureCache();
|
|
699
|
+
const detail = this.getDetail(options.keyword);
|
|
700
|
+
if (!detail) {
|
|
701
|
+
return {
|
|
702
|
+
ok: false,
|
|
703
|
+
message: `未找到表情关键词:${options.keyword}`,
|
|
704
|
+
shouldNotice: true,
|
|
705
|
+
noticeInstruction: `用户请求生成表情,但关键词 "${options.keyword}" 不存在。请自然提醒先搜索可用关键词后再生成`,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const blockedKeyword = this.findBlockedKeyword(detail.keyword);
|
|
710
|
+
if (blockedKeyword) {
|
|
711
|
+
return {
|
|
712
|
+
ok: false,
|
|
713
|
+
message: `关键词 ${detail.keyword} 被拦截规则命中:${blockedKeyword}`,
|
|
714
|
+
keyword: detail.keyword,
|
|
715
|
+
key: detail.key,
|
|
716
|
+
shouldNotice: this.filterConfig.replyOnBlocked,
|
|
717
|
+
noticeInstruction: `用户触发了黑名单表情关键词 "${detail.keyword}"。请自然拒绝本次请求,简短说明该关键词当前不可用。`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const params = resolveMemeParams(detail.info);
|
|
722
|
+
const textInput = String(options.text || "").trim();
|
|
723
|
+
const argsInput = String(options.args || "").trim();
|
|
724
|
+
|
|
725
|
+
if (textInput && (params.max_texts || 0) === 0) {
|
|
726
|
+
return {
|
|
727
|
+
ok: false,
|
|
728
|
+
message: `表情 ${detail.keyword} 不接受文字参数`,
|
|
729
|
+
keyword: detail.keyword,
|
|
730
|
+
key: detail.key,
|
|
731
|
+
shouldNotice: false,
|
|
732
|
+
noticeInstruction: `用户要生成表情 "${detail.keyword}",但这个表情不支持文字参数。请提醒用户直接提供图片或改用支持文本的表情。`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const userInfos = await this.collectUserInfos(ctx, event);
|
|
737
|
+
const textSegments = this.resolveTextSegments(textInput, params, userInfos);
|
|
738
|
+
if (textSegments.length < (params.min_texts || 0)) {
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
message: `表情 ${detail.keyword} 需要至少 ${params.min_texts || 0} 段文本`,
|
|
742
|
+
keyword: detail.keyword,
|
|
743
|
+
key: detail.key,
|
|
744
|
+
shouldNotice: true,
|
|
745
|
+
noticeInstruction: `用户要生成表情 "${detail.keyword}",但文本参数不足。请自然提醒至少需要 ${params.min_texts || 0} 段文本`,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const imageUrls = await this.collectImageUrls(
|
|
750
|
+
ctx,
|
|
751
|
+
event,
|
|
752
|
+
params,
|
|
753
|
+
options.imageSource,
|
|
754
|
+
);
|
|
755
|
+
if (imageUrls.length < (params.min_images || 0)) {
|
|
756
|
+
const sourceType = options.imageSource?.type || "auto";
|
|
757
|
+
const sourceHint =
|
|
758
|
+
sourceType === "user_avatar"
|
|
759
|
+
? "指定的 QQ 头像不可用"
|
|
760
|
+
: sourceType === "message_image"
|
|
761
|
+
? "指定 messageId 未找到图片"
|
|
762
|
+
: "当前消息里没有可用图片";
|
|
763
|
+
return {
|
|
764
|
+
ok: false,
|
|
765
|
+
message: `表情 ${detail.keyword} 需要至少 ${params.min_images || 0} 张图片(${sourceHint})`,
|
|
766
|
+
keyword: detail.keyword,
|
|
767
|
+
key: detail.key,
|
|
768
|
+
shouldNotice: true,
|
|
769
|
+
noticeInstruction: `用户要生成表情 "${detail.keyword}",但图片参数不足。请自然提醒至少需要 ${params.min_images || 0} 张图片`,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const formData = new FormData();
|
|
774
|
+
const limitedImages = imageUrls.slice(
|
|
775
|
+
0,
|
|
776
|
+
Math.max(0, params.max_images || imageUrls.length),
|
|
777
|
+
);
|
|
778
|
+
for (const imageUrl of limitedImages) {
|
|
779
|
+
const response = await this.fetchResponse(imageUrl);
|
|
780
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
781
|
+
formData.append("images", new Blob([buffer]));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const limitedTexts = textSegments.slice(
|
|
785
|
+
0,
|
|
786
|
+
Math.max(0, params.max_texts || textSegments.length),
|
|
787
|
+
);
|
|
788
|
+
for (const text of limitedTexts) {
|
|
789
|
+
formData.append("texts", text);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const argsPayload = this.buildArgsPayload(detail.key, argsInput, userInfos);
|
|
793
|
+
if (argsPayload) {
|
|
794
|
+
formData.set("args", argsPayload);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const renderResponse = await this.fetchResponse(
|
|
798
|
+
`${toMemesApiBase(this.baseConfig.api.baseUrl)}/${detail.key}/`,
|
|
799
|
+
{
|
|
800
|
+
method: "POST",
|
|
801
|
+
body: formData,
|
|
802
|
+
},
|
|
803
|
+
);
|
|
804
|
+
const resultBuffer = Buffer.from(await renderResponse.arrayBuffer());
|
|
805
|
+
const resultImage = await imageBufferToBase64(resultBuffer);
|
|
806
|
+
|
|
807
|
+
if (options.send !== false) {
|
|
808
|
+
const caption = options.randomLabel
|
|
809
|
+
? `随机表情:${options.randomLabel}`
|
|
810
|
+
: undefined;
|
|
811
|
+
await sendSkillImage({
|
|
812
|
+
ctx,
|
|
813
|
+
event,
|
|
814
|
+
image: resultImage,
|
|
815
|
+
caption,
|
|
816
|
+
quoteReply: options.quoteReply ?? this.baseConfig.behavior.quoteReply,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
ok: true,
|
|
822
|
+
message: `表情 ${detail.keyword} 生成成功`,
|
|
823
|
+
keyword: detail.keyword,
|
|
824
|
+
key: detail.key,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private async ensureDataDir(): Promise<void> {
|
|
829
|
+
await fs.promises.mkdir(this.dataDir, { recursive: true });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private async loadCacheFromDisk(): Promise<boolean> {
|
|
833
|
+
try {
|
|
834
|
+
if (!fs.existsSync(this.keyMapPath) || !fs.existsSync(this.infosPath)) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const [keyMapRaw, infosRaw] = await Promise.all([
|
|
839
|
+
fs.promises.readFile(this.keyMapPath, "utf-8"),
|
|
840
|
+
fs.promises.readFile(this.infosPath, "utf-8"),
|
|
841
|
+
]);
|
|
842
|
+
|
|
843
|
+
this.keyMap = JSON.parse(keyMapRaw) as Record<string, string>;
|
|
844
|
+
this.infos = JSON.parse(infosRaw) as Record<string, MemeInfo>;
|
|
845
|
+
return (
|
|
846
|
+
Object.keys(this.keyMap).length > 0 &&
|
|
847
|
+
Object.keys(this.infos).length > 0
|
|
848
|
+
);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
this.logger.warn(`[meme] 读取本地缓存失败,将重新拉取: ${error}`);
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async fetchResponse(
|
|
856
|
+
url: string,
|
|
857
|
+
init?: RequestInit,
|
|
858
|
+
): Promise<Response> {
|
|
859
|
+
const controller = new AbortController();
|
|
860
|
+
const timeout = setTimeout(
|
|
861
|
+
() => controller.abort(),
|
|
862
|
+
this.baseConfig.api.timeoutMs,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const response = await fetch(url, {
|
|
867
|
+
...init,
|
|
868
|
+
signal: controller.signal,
|
|
869
|
+
});
|
|
870
|
+
if (!response.ok) {
|
|
871
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
872
|
+
}
|
|
873
|
+
return response;
|
|
874
|
+
} finally {
|
|
875
|
+
clearTimeout(timeout);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
880
|
+
const response = await this.fetchResponse(url, init);
|
|
881
|
+
return (await response.json()) as T;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private findBlockedKeyword(keyword: string): string | null {
|
|
885
|
+
if (!this.filterConfig.enabled) {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const normalized = keyword.toLowerCase();
|
|
889
|
+
const hit = this.filterConfig.blockedKeywords.find((item) =>
|
|
890
|
+
normalized.includes(String(item || "").toLowerCase()),
|
|
891
|
+
);
|
|
892
|
+
return hit || null;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private resolveTextSegments(
|
|
896
|
+
textInput: string,
|
|
897
|
+
params: MemeInfoParams,
|
|
898
|
+
userInfos: MemeUserInfo[],
|
|
899
|
+
): string[] {
|
|
900
|
+
let value = String(textInput || "").trim();
|
|
901
|
+
if (!value && (params.min_texts || 0) > 0) {
|
|
902
|
+
if ((params.default_texts || []).length > 0) {
|
|
903
|
+
value = (params.default_texts || []).join("/");
|
|
904
|
+
} else if (userInfos.length > 0) {
|
|
905
|
+
value = userInfos[0].name;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!value) {
|
|
909
|
+
return [];
|
|
910
|
+
}
|
|
911
|
+
return value
|
|
912
|
+
.split("/")
|
|
913
|
+
.map((item) => item.trim())
|
|
914
|
+
.filter(Boolean);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private async collectImageUrls(
|
|
918
|
+
ctx: any,
|
|
919
|
+
event: any,
|
|
920
|
+
params: MemeInfoParams,
|
|
921
|
+
source?: MemeImageSourceOptions,
|
|
922
|
+
): Promise<string[]> {
|
|
923
|
+
const sourceType = source?.type || "auto";
|
|
924
|
+
if (sourceType === "user_avatar") {
|
|
925
|
+
const qq = Number(source?.qq);
|
|
926
|
+
if (!Number.isFinite(qq) || qq <= 0) {
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
return [getAvatarUrl(qq)].slice(0, params.max_images || 1);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (sourceType === "message_image") {
|
|
933
|
+
const messageId = Number(source?.messageId);
|
|
934
|
+
if (!Number.isFinite(messageId) || messageId <= 0) {
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
const imageUrl = await this.getImageUrlByMessageId(ctx, event, messageId);
|
|
938
|
+
return imageUrl ? [imageUrl].slice(0, params.max_images || 1) : [];
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const urls: string[] = [];
|
|
942
|
+
const maxImages = params.max_images || 0;
|
|
943
|
+
|
|
944
|
+
if (maxImages <= 0) {
|
|
945
|
+
return urls;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (event?.quote_id && typeof ctx?.getQuoteMsg === "function") {
|
|
949
|
+
try {
|
|
950
|
+
const quoted = await ctx.getQuoteMsg(event);
|
|
951
|
+
urls.push(...extractImageUrls(quoted?.message || []));
|
|
952
|
+
} catch (error) {
|
|
953
|
+
this.logger.warn(`[meme] 获取引用消息失败: ${error}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
urls.push(...extractImageUrls(event?.message || []));
|
|
958
|
+
|
|
959
|
+
const atUserIds = getAtUserIds(event?.message || []);
|
|
960
|
+
if (urls.length < (params.min_images || 0) && atUserIds.length > 0) {
|
|
961
|
+
urls.push(...atUserIds.map((userId) => getAvatarUrl(userId)));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (
|
|
965
|
+
urls.length === 0 &&
|
|
966
|
+
this.baseConfig.behavior.useSenderAvatarFallback &&
|
|
967
|
+
event?.user_id != null
|
|
968
|
+
) {
|
|
969
|
+
urls.push(getAvatarUrl(event.user_id));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return uniqueStrings(urls).slice(0, maxImages);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async getImageUrlByMessageId(
|
|
976
|
+
ctx: any,
|
|
977
|
+
event: any,
|
|
978
|
+
messageId: number,
|
|
979
|
+
): Promise<string | null> {
|
|
980
|
+
try {
|
|
981
|
+
const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
|
|
982
|
+
if (selfId == null || !ctx?.pickBot) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
const msg = await ctx.pickBot(selfId).getMsg(messageId);
|
|
986
|
+
if (!msg?.message || !Array.isArray(msg.message)) {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
const imageSeg = msg.message.find((seg: any) => seg?.type === "image");
|
|
990
|
+
if (!imageSeg) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
return (imageSeg as any).url || (imageSeg as any).data?.url || null;
|
|
994
|
+
} catch (error) {
|
|
995
|
+
this.logger.warn(
|
|
996
|
+
`[meme] 通过 messageId ${messageId} 获取图片失败: ${error}`,
|
|
997
|
+
);
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private async collectUserInfos(
|
|
1003
|
+
ctx: any,
|
|
1004
|
+
event: any,
|
|
1005
|
+
): Promise<MemeUserInfo[]> {
|
|
1006
|
+
const atUserIds = getAtUserIds(event?.message || []);
|
|
1007
|
+
if (event?.message_type !== "group" || atUserIds.length === 0) {
|
|
1008
|
+
return [
|
|
1009
|
+
{
|
|
1010
|
+
name: getSenderName(event),
|
|
1011
|
+
gender: String(event?.sender?.sex || "unknown"),
|
|
1012
|
+
},
|
|
1013
|
+
];
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const bot =
|
|
1017
|
+
event?.self_id != null && typeof ctx?.pickBot === "function"
|
|
1018
|
+
? ctx.pickBot(event.self_id)
|
|
1019
|
+
: undefined;
|
|
1020
|
+
if (!bot?.getGroupMemberInfo) {
|
|
1021
|
+
return atUserIds.map((userId) => ({
|
|
1022
|
+
name: String(userId),
|
|
1023
|
+
gender: "unknown",
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const infos = await Promise.all(
|
|
1028
|
+
atUserIds.map(async (userId) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const member = await bot.getGroupMemberInfo(event.group_id, userId);
|
|
1031
|
+
return {
|
|
1032
|
+
name: member?.card || member?.nickname || String(userId),
|
|
1033
|
+
gender: String(member?.sex || "unknown"),
|
|
1034
|
+
} satisfies MemeUserInfo;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return {
|
|
1037
|
+
name: String(userId),
|
|
1038
|
+
gender: "unknown",
|
|
1039
|
+
} satisfies MemeUserInfo;
|
|
1040
|
+
}
|
|
1041
|
+
}),
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
return infos.length > 0
|
|
1045
|
+
? infos
|
|
1046
|
+
: [
|
|
1047
|
+
{
|
|
1048
|
+
name: getSenderName(event),
|
|
1049
|
+
gender: String(event?.sender?.sex || "unknown"),
|
|
1050
|
+
},
|
|
1051
|
+
];
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private buildArgsPayload(
|
|
1055
|
+
key: string,
|
|
1056
|
+
args: string,
|
|
1057
|
+
userInfos: MemeUserInfo[],
|
|
1058
|
+
): string {
|
|
1059
|
+
const value = String(args || "").trim();
|
|
1060
|
+
const maps = {
|
|
1061
|
+
dir: {
|
|
1062
|
+
左: "left",
|
|
1063
|
+
右: "right",
|
|
1064
|
+
上: "top",
|
|
1065
|
+
下: "bottom",
|
|
1066
|
+
左右: "left_right",
|
|
1067
|
+
右左: "right_left",
|
|
1068
|
+
},
|
|
1069
|
+
mode: {
|
|
1070
|
+
循环: "loop",
|
|
1071
|
+
套娃: "circle",
|
|
1072
|
+
yes: "yes",
|
|
1073
|
+
no: "no",
|
|
1074
|
+
前: "front",
|
|
1075
|
+
后: "behind",
|
|
1076
|
+
},
|
|
1077
|
+
pos: {
|
|
1078
|
+
左: "left",
|
|
1079
|
+
右: "right",
|
|
1080
|
+
两边: "both",
|
|
1081
|
+
双手: "both",
|
|
1082
|
+
双枪: "both",
|
|
1083
|
+
},
|
|
1084
|
+
role: {
|
|
1085
|
+
八重: 1,
|
|
1086
|
+
胡桃: 2,
|
|
1087
|
+
妮露: 3,
|
|
1088
|
+
可莉: 4,
|
|
1089
|
+
刻晴: 5,
|
|
1090
|
+
钟离: 6,
|
|
1091
|
+
},
|
|
1092
|
+
} as const;
|
|
1093
|
+
|
|
1094
|
+
const getCircle = () => ({ circle: /^圆/.test(value) });
|
|
1095
|
+
const getNumber = (min: number, max: number) => ({
|
|
1096
|
+
number: Number.parseInt(value, 10) || randomInt(min, max),
|
|
1097
|
+
});
|
|
1098
|
+
const getMode = (fallback: string = "normal") => ({
|
|
1099
|
+
mode: maps.mode[value as keyof typeof maps.mode] || fallback,
|
|
1100
|
+
});
|
|
1101
|
+
const getPosition = (fallback: string = "right") => ({
|
|
1102
|
+
position: maps.pos[value as keyof typeof maps.pos] || fallback,
|
|
1103
|
+
});
|
|
1104
|
+
const getDirection = (fallback: string = "left") => ({
|
|
1105
|
+
direction: maps.dir[value as keyof typeof maps.dir] || fallback,
|
|
1106
|
+
});
|
|
1107
|
+
const getName = () => ({
|
|
1108
|
+
name: value || userInfos[0]?.name || "用户",
|
|
1109
|
+
});
|
|
1110
|
+
const getMessage = () => ({
|
|
1111
|
+
message: value || "https://ys.mihoyo.com/cloud",
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const payload =
|
|
1115
|
+
{
|
|
1116
|
+
alipay: getMessage(),
|
|
1117
|
+
always: getMode(),
|
|
1118
|
+
atri_pillow: getMode("random"),
|
|
1119
|
+
bubble_tea: getPosition(),
|
|
1120
|
+
certificate: value ? { time: value } : {},
|
|
1121
|
+
clown: { person: /^爷/.test(value) },
|
|
1122
|
+
clown_mask: getMode("front"),
|
|
1123
|
+
crawl: getNumber(1, 92),
|
|
1124
|
+
dog_dislike: getCircle(),
|
|
1125
|
+
firefly_holdsign: getNumber(1, 21),
|
|
1126
|
+
genshin_eat: {
|
|
1127
|
+
character: maps.role[value as keyof typeof maps.role] || 0,
|
|
1128
|
+
},
|
|
1129
|
+
guichu: getDirection(),
|
|
1130
|
+
gun: getPosition(),
|
|
1131
|
+
jiji_king: getCircle(),
|
|
1132
|
+
kaleidoscope: getCircle(),
|
|
1133
|
+
kirby_hammer: getCircle(),
|
|
1134
|
+
left_right_jump: getDirection("left_right"),
|
|
1135
|
+
look_flat: { ratio: Number.parseInt(value, 10) || 2 },
|
|
1136
|
+
loop: getDirection("top"),
|
|
1137
|
+
mourning: { black: /^(黑白|灰)/.test(value) },
|
|
1138
|
+
my_friend: getName(),
|
|
1139
|
+
my_wife: {
|
|
1140
|
+
pronoun: value.split("/")[0]?.replace("煌", "🐔") || "我",
|
|
1141
|
+
name: value.split("/")[1] || "老婆",
|
|
1142
|
+
},
|
|
1143
|
+
note_for_leave: value ? { time: value } : {},
|
|
1144
|
+
panda_dragon_figure: getName(),
|
|
1145
|
+
petpet: getCircle(),
|
|
1146
|
+
pixelate: { number: Number.parseInt(value, 10) || 10 },
|
|
1147
|
+
steam_message: getName(),
|
|
1148
|
+
symmetric: getDirection(),
|
|
1149
|
+
wechat_pay: getMessage(),
|
|
1150
|
+
}[key] || {};
|
|
1151
|
+
|
|
1152
|
+
return JSON.stringify({
|
|
1153
|
+
...payload,
|
|
1154
|
+
user_infos: userInfos.map((item) => ({
|
|
1155
|
+
name: item.name.replace(/^@/, ""),
|
|
1156
|
+
gender: item.gender || "unknown",
|
|
1157
|
+
})),
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|