koishi-plugin-custom-image 0.1.7 → 0.1.9

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/lib/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export interface Config {
16
16
  imageSendTimeout: number;
17
17
  autoClearCacheInterval: number;
18
18
  imageParseFormat: string;
19
+ downloadImageBeforeSend: boolean;
20
+ messageBufferDelay: number;
19
21
  tempDir: string;
20
22
  }
21
23
  export declare const Config: Schema<Config>;
package/lib/index.js CHANGED
@@ -10,7 +10,9 @@ const axios_1 = __importDefault(require("axios"));
10
10
  const crypto_1 = __importDefault(require("crypto"));
11
11
  const fs_1 = __importDefault(require("fs"));
12
12
  const path_1 = __importDefault(require("path"));
13
- const os_1 = __importDefault(require("os"));
13
+ const promises_1 = require("stream/promises");
14
+ const worker_threads_1 = require("worker_threads");
15
+ const currentFilePath = path_1.default.join(process.cwd(), 'src', 'index.ts');
14
16
  exports.name = 'custom-image';
15
17
  exports.Config = koishi_1.Schema.object({
16
18
  enable: koishi_1.Schema.boolean()
@@ -65,13 +67,40 @@ exports.Config = koishi_1.Schema.object({
65
67
  imageParseFormat: koishi_1.Schema.string()
66
68
  .default('✅ 图片获取成功')
67
69
  .description('【格式设置】图片发送前的提示文本'),
70
+ downloadImageBeforeSend: koishi_1.Schema.boolean()
71
+ .default(true)
72
+ .description('【展示设置】发送前下载图片:发送前先下载图片到本地,再发送文件(仅OneBot)'),
73
+ messageBufferDelay: koishi_1.Schema.number()
74
+ .default(0)
75
+ .min(0)
76
+ .description('【性能设置】消息缓冲延迟:合并短时间内的多个图片请求(秒)'),
68
77
  tempDir: koishi_1.Schema.string()
69
- .default(path_1.default.join(os_1.default.tmpdir(), 'koishi-custom-image'))
78
+ .default(path_1.default.join(process.cwd(), 'temp_images'))
70
79
  .description('【文件设置】临时图片保存目录')
71
80
  });
81
+ if (!worker_threads_1.isMainThread) {
82
+ const { url, filePath } = worker_threads_1.workerData;
83
+ (async () => {
84
+ try {
85
+ const response = await (0, axios_1.default)({
86
+ url,
87
+ method: 'GET',
88
+ responseType: 'stream',
89
+ timeout: 60000,
90
+ headers: {
91
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
92
+ }
93
+ });
94
+ await (0, promises_1.pipeline)(response.data, fs_1.default.createWriteStream(filePath));
95
+ worker_threads_1.parentPort?.postMessage({ success: true, filePath });
96
+ }
97
+ catch (error) {
98
+ worker_threads_1.parentPort?.postMessage({ success: false, error: error.message });
99
+ }
100
+ })();
101
+ }
72
102
  const processedApi = new Map();
73
103
  const imageBuffer = new Map();
74
- const tempFiles = new Set();
75
104
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
76
105
  async function sendTimeout(session, content, config) {
77
106
  if (config.imageSendTimeout <= 0) {
@@ -79,37 +108,54 @@ async function sendTimeout(session, content, config) {
79
108
  }
80
109
  return Promise.race([
81
110
  session.send(content),
82
- new Promise((_, reject) => setTimeout(() => reject(new Error()), config.imageSendTimeout))
111
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), config.imageSendTimeout))
83
112
  ]).catch(() => null);
84
113
  }
85
- function clearAllCache() {
86
- processedApi.clear();
87
- imageBuffer.forEach(buf => clearTimeout(buf.timer));
88
- imageBuffer.clear();
89
- tempFiles.forEach(file => {
90
- try {
91
- fs_1.default.unlinkSync(file);
114
+ async function downloadImageWithThreads(url, filename, config) {
115
+ return new Promise((resolve, reject) => {
116
+ if (!fs_1.default.existsSync(config.tempDir)) {
117
+ fs_1.default.mkdirSync(config.tempDir, { recursive: true });
92
118
  }
93
- catch { }
119
+ const filePath = path_1.default.join(config.tempDir, `${filename}.jpg`);
120
+ const worker = new worker_threads_1.Worker(currentFilePath, { workerData: { url, filePath } });
121
+ worker.on('message', (result) => {
122
+ if (result.success && result.filePath) {
123
+ resolve(result.filePath);
124
+ }
125
+ else {
126
+ reject(new Error(result.error || '图片下载失败'));
127
+ }
128
+ });
129
+ worker.on('error', reject);
130
+ worker.on('exit', (code) => {
131
+ if (code !== 0)
132
+ reject(new Error('图片下载线程异常'));
133
+ });
94
134
  });
95
- tempFiles.clear();
96
- return true;
97
135
  }
98
- function createTempDir(config) {
99
- if (!fs_1.default.existsSync(config.tempDir)) {
100
- fs_1.default.mkdirSync(config.tempDir, { recursive: true });
136
+ function clearAllCache(config) {
137
+ processedApi.clear();
138
+ imageBuffer.forEach(buf => clearTimeout(buf.timer));
139
+ imageBuffer.clear();
140
+ if (fs_1.default.existsSync(config.tempDir)) {
141
+ fs_1.default.readdirSync(config.tempDir).forEach(file => {
142
+ try {
143
+ const filePath = path_1.default.join(config.tempDir, file);
144
+ const stat = fs_1.default.statSync(filePath);
145
+ if (Date.now() - stat.mtimeMs > 3600000) {
146
+ fs_1.default.unlinkSync(filePath);
147
+ }
148
+ }
149
+ catch (error) { }
150
+ });
101
151
  }
152
+ return true;
102
153
  }
103
- async function saveImageToTemp(buffer, config) {
104
- createTempDir(config);
105
- const filename = `${crypto_1.default.randomUUID()}.jpg`;
106
- const filepath = path_1.default.join(config.tempDir, filename);
107
- await fs_1.default.promises.writeFile(filepath, buffer);
108
- tempFiles.add(filepath);
109
- return filepath;
110
- }
111
- function isImageUrl(url) {
112
- return /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)/i.test(url);
154
+ function randomSelectApi(customImageApis) {
155
+ const validApis = customImageApis.filter(api => api.trim());
156
+ if (validApis.length === 0)
157
+ return null;
158
+ return validApis[Math.floor(Math.random() * validApis.length)];
113
159
  }
114
160
  async function fetchImage(url, config) {
115
161
  const http = axios_1.default.create({
@@ -120,16 +166,15 @@ async function fetchImage(url, config) {
120
166
  });
121
167
  for (let retry = 0; retry <= config.retryTimes; retry++) {
122
168
  try {
123
- if (isImageUrl(url)) {
169
+ if (/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)/i.test(url)) {
124
170
  return { success: true, type: 'url', data: url };
125
171
  }
126
172
  const res = await http.get(url, { responseType: 'arraybuffer' });
127
173
  if (res.status === 200 && res.data) {
128
- const filepath = await saveImageToTemp(Buffer.from(res.data), config);
129
- return { success: true, type: 'file', data: filepath };
174
+ return { success: true, type: 'buffer', data: res.data };
130
175
  }
131
176
  }
132
- catch {
177
+ catch (error) {
133
178
  if (retry === config.retryTimes)
134
179
  return { success: false, type: '', data: '' };
135
180
  await delay(config.retryInterval);
@@ -153,16 +198,34 @@ async function processCustomImage(session, apiUrl, config) {
153
198
  const hash = crypto_1.default.createHash('md5').update(apiUrl).digest('hex');
154
199
  const now = Date.now();
155
200
  if (processedApi.get(hash) && now - processedApi.get(hash) < config.sameImageApiInterval * 1000) {
156
- return { success: false };
201
+ return { success: false, msg: '请勿重复请求' };
157
202
  }
158
203
  processedApi.set(hash, now);
159
204
  const result = await fetchImage(apiUrl, config);
160
- if (result.success && result.data) {
161
- await sendTimeout(session, config.imageParseFormat, config);
162
- await sendTimeout(session, koishi_1.h.image(result.data), config);
163
- return { success: true };
205
+ if (!result.success) {
206
+ return { success: false, msg: '图片获取失败' };
207
+ }
208
+ let imageElem;
209
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
210
+ try {
211
+ const filename = crypto_1.default.createHash('md5').update(result.data).digest('hex');
212
+ const filePath = await downloadImageWithThreads(result.data, filename, config);
213
+ imageElem = koishi_1.h.file(filePath);
214
+ }
215
+ catch (error) {
216
+ imageElem = koishi_1.h.image(result.data);
217
+ }
164
218
  }
165
- return { success: false };
219
+ else if (result.type === 'buffer') {
220
+ const filename = crypto_1.default.randomUUID();
221
+ const filePath = path_1.default.join(config.tempDir, `${filename}.jpg`);
222
+ fs_1.default.writeFileSync(filePath, result.data);
223
+ imageElem = koishi_1.h.file(filePath);
224
+ }
225
+ else {
226
+ imageElem = koishi_1.h.image(result.data);
227
+ }
228
+ return { success: true, msg: 'ok', data: { text: config.imageParseFormat, image: imageElem } };
166
229
  }
167
230
  async function flush(session, config, manualApi) {
168
231
  const key = `${session.platform}:${session.userId}:${session.channelId}`;
@@ -172,21 +235,42 @@ async function flush(session, config, manualApi) {
172
235
  clearTimeout(buf.timer);
173
236
  imageBuffer.delete(key);
174
237
  }
175
- if (config.customImageEnabled && apis.length) {
176
- const apiUrl = randomSelectApi(apis);
177
- if (apiUrl)
178
- await processCustomImage(session, apiUrl, config);
238
+ if (!config.customImageEnabled || !apis.length)
239
+ return;
240
+ const items = [];
241
+ const errs = [];
242
+ for (const apiUrl of apis) {
243
+ const result = await processCustomImage(session, apiUrl, config);
244
+ if (result.success && result.data) {
245
+ items.push(result.data);
246
+ }
247
+ else {
248
+ errs.push(`【${apiUrl.slice(0, 22)}...】:${result.msg}`);
249
+ }
250
+ }
251
+ if (errs.length && !config.ignoreSendError) {
252
+ const errorMsg = `⚠️ 部分图片获取失败\n${errs.join('\n')}`;
253
+ await sendTimeout(session, errorMsg, config);
254
+ await delay(600);
255
+ }
256
+ if (items.length === 0) {
257
+ const failMsg = `❌ 所有图片获取失败\n${errs.join('\n')}`;
258
+ if (!config.ignoreSendError) {
259
+ await sendTimeout(session, failMsg, config);
260
+ }
261
+ return;
262
+ }
263
+ for (const item of items) {
264
+ await sendTimeout(session, item.text, config);
265
+ await delay(300);
266
+ await sendTimeout(session, item.image, config);
267
+ await delay(1000);
179
268
  }
180
- }
181
- function randomSelectApi(customImageApis) {
182
- const validApis = customImageApis.filter(api => api.trim());
183
- if (validApis.length === 0)
184
- return null;
185
- return validApis[Math.floor(Math.random() * validApis.length)];
186
269
  }
187
270
  function apply(ctx, config) {
188
- clearAllCache();
189
- createTempDir(config);
271
+ if (!worker_threads_1.isMainThread)
272
+ return;
273
+ clearAllCache(config);
190
274
  ctx.logger.info('[custom-image] 插件已加载');
191
275
  ctx.command('random-image [apis...]', '随机获取自定义图片')
192
276
  .option('api', '-a <api> 指定单个API地址')
@@ -207,12 +291,12 @@ function apply(ctx, config) {
207
291
  if (imageBuffer.has(key)) {
208
292
  const b = imageBuffer.get(key);
209
293
  clearTimeout(b.timer);
210
- b.timer = setTimeout(() => flush(session, config), 1000);
294
+ b.timer = setTimeout(() => flush(session, config), config.messageBufferDelay * 1000);
211
295
  }
212
296
  else {
213
297
  imageBuffer.set(key, {
214
298
  apis: config.customImageApis,
215
- timer: setTimeout(() => flush(session, config), 1000),
299
+ timer: setTimeout(() => flush(session, config), config.messageBufferDelay * 1000),
216
300
  tipMsgId: tipId
217
301
  });
218
302
  }
@@ -226,9 +310,29 @@ function apply(ctx, config) {
226
310
  await sendTimeout(session, config.waitingTipText, config);
227
311
  }
228
312
  const result = await fetchHsjpImage(msg, msg1, msg2, config);
229
- if (result.success && result.data) {
313
+ if (result.success) {
314
+ let imageElem;
315
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
316
+ try {
317
+ const filename = crypto_1.default.createHash('md5').update(result.data).digest('hex');
318
+ const filePath = await downloadImageWithThreads(result.data, filename, config);
319
+ imageElem = koishi_1.h.file(filePath);
320
+ }
321
+ catch (error) {
322
+ imageElem = koishi_1.h.image(result.data);
323
+ }
324
+ }
325
+ else if (result.type === 'buffer') {
326
+ const filename = crypto_1.default.randomUUID();
327
+ const filePath = path_1.default.join(config.tempDir, `${filename}.jpg`);
328
+ fs_1.default.writeFileSync(filePath, result.data);
329
+ imageElem = koishi_1.h.file(filePath);
330
+ }
331
+ else {
332
+ imageElem = koishi_1.h.image(result.data);
333
+ }
230
334
  await sendTimeout(session, config.imageParseFormat, config);
231
- await sendTimeout(session, koishi_1.h.image(result.data), config);
335
+ await sendTimeout(session, imageElem, config);
232
336
  }
233
337
  });
234
338
  ctx.command('dmjp <text>', '生成动漫举牌图片')
@@ -239,31 +343,65 @@ function apply(ctx, config) {
239
343
  await sendTimeout(session, config.waitingTipText, config);
240
344
  }
241
345
  const result = await fetchDmjpImage(text, config);
242
- if (result.success && result.data) {
346
+ if (result.success) {
347
+ let imageElem;
348
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
349
+ try {
350
+ const filename = crypto_1.default.createHash('md5').update(result.data).digest('hex');
351
+ const filePath = await downloadImageWithThreads(result.data, filename, config);
352
+ imageElem = koishi_1.h.file(filePath);
353
+ }
354
+ catch (error) {
355
+ imageElem = koishi_1.h.image(result.data);
356
+ }
357
+ }
358
+ else if (result.type === 'buffer') {
359
+ const filename = crypto_1.default.randomUUID();
360
+ const filePath = path_1.default.join(config.tempDir, `${filename}.jpg`);
361
+ fs_1.default.writeFileSync(filePath, result.data);
362
+ imageElem = koishi_1.h.file(filePath);
363
+ }
364
+ else {
365
+ imageElem = koishi_1.h.image(result.data);
366
+ }
243
367
  await sendTimeout(session, config.imageParseFormat, config);
244
- await sendTimeout(session, koishi_1.h.image(result.data), config);
368
+ await sendTimeout(session, imageElem, config);
245
369
  }
246
370
  });
247
371
  ctx.command('clear-image-cache', '清空图片缓存')
248
372
  .action(() => {
249
- clearAllCache();
373
+ clearAllCache(config);
374
+ return '✅ 图片缓存已清空';
250
375
  });
251
376
  setInterval(() => {
252
377
  const now = Date.now();
253
- processedApi.forEach((time, hash) => {
254
- if (now - time > 86400000) {
378
+ processedApi.forEach((timestamp, hash) => {
379
+ if (now - timestamp > 86400000)
255
380
  processedApi.delete(hash);
256
- }
257
381
  });
258
382
  }, 3600000);
383
+ setInterval(() => {
384
+ if (!fs_1.default.existsSync(config.tempDir))
385
+ return;
386
+ const now = Date.now();
387
+ fs_1.default.readdirSync(config.tempDir).forEach(file => {
388
+ try {
389
+ const stat = fs_1.default.statSync(path_1.default.join(config.tempDir, file));
390
+ if (now - stat.mtimeMs > 3600000) {
391
+ fs_1.default.unlinkSync(path_1.default.join(config.tempDir, file));
392
+ }
393
+ }
394
+ catch (error) { }
395
+ });
396
+ }, 1800000);
259
397
  if (config.autoClearCacheInterval > 0) {
260
398
  setInterval(() => {
261
- clearAllCache();
399
+ clearAllCache(config);
262
400
  ctx.logger.info('[custom-image] 缓存已自动清理');
263
401
  }, config.autoClearCacheInterval * 60000);
264
402
  }
265
403
  process.on('exit', () => {
266
- clearAllCache();
404
+ clearAllCache(config);
267
405
  ctx.logger.info('[custom-image] 插件缓存已清理');
268
406
  });
269
407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-custom-image",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Koishi自定义图片API插件(支持黑丝/动漫举牌)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
package/src/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { Context, Schema, h, Session } from 'koishi'
2
- import axios, { AxiosInstance } from 'axios'
2
+ import axios from 'axios'
3
3
  import crypto from 'crypto'
4
4
  import fs from 'fs'
5
5
  import path from 'path'
6
- import os from 'os'
6
+ import { pipeline } from 'stream/promises'
7
+ import { isMainThread, Worker, workerData, parentPort } from 'worker_threads'
8
+
9
+ const currentFilePath = path.join(process.cwd(), 'src', 'index.ts');
7
10
 
8
11
  export const name = 'custom-image'
9
12
 
@@ -23,6 +26,8 @@ export interface Config {
23
26
  imageSendTimeout: number
24
27
  autoClearCacheInterval: number
25
28
  imageParseFormat: string
29
+ downloadImageBeforeSend: boolean
30
+ messageBufferDelay: number
26
31
  tempDir: string
27
32
  }
28
33
 
@@ -79,232 +84,362 @@ export const Config: Schema<Config> = Schema.object({
79
84
  imageParseFormat: Schema.string()
80
85
  .default('✅ 图片获取成功')
81
86
  .description('【格式设置】图片发送前的提示文本'),
87
+ downloadImageBeforeSend: Schema.boolean()
88
+ .default(true)
89
+ .description('【展示设置】发送前下载图片:发送前先下载图片到本地,再发送文件(仅OneBot)'),
90
+ messageBufferDelay: Schema.number()
91
+ .default(0)
92
+ .min(0)
93
+ .description('【性能设置】消息缓冲延迟:合并短时间内的多个图片请求(秒)'),
82
94
  tempDir: Schema.string()
83
- .default(path.join(os.tmpdir(), 'koishi-custom-image'))
95
+ .default(path.join(process.cwd(), 'temp_images'))
84
96
  .description('【文件设置】临时图片保存目录')
85
97
  })
86
98
 
99
+ if (!isMainThread) {
100
+ const { url, filePath } = workerData;
101
+ (async () => {
102
+ try {
103
+ const response = await axios({
104
+ url,
105
+ method: 'GET',
106
+ responseType: 'stream',
107
+ timeout: 60000,
108
+ headers: {
109
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
110
+ }
111
+ });
112
+
113
+ await pipeline(response.data, fs.createWriteStream(filePath));
114
+ parentPort?.postMessage({ success: true, filePath });
115
+ } catch (error) {
116
+ parentPort?.postMessage({ success: false, error: (error as Error).message });
117
+ }
118
+ })();
119
+ }
120
+
87
121
  const processedApi = new Map<string, number>()
88
122
  const imageBuffer = new Map<string, { apis: string[], timer: NodeJS.Timeout, tipMsgId?: string | number }>()
89
- const tempFiles = new Set<string>()
90
-
91
123
  const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
92
124
 
93
125
  async function sendTimeout(session: Session, content: any, config: Config) {
94
126
  if (config.imageSendTimeout <= 0) {
95
- return session.send(content).catch(() => null)
127
+ return session.send(content).catch(() => null);
96
128
  }
97
129
  return Promise.race([
98
130
  session.send(content),
99
- new Promise((_, reject) => setTimeout(() => reject(new Error()), config.imageSendTimeout))
100
- ]).catch(() => null)
131
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), config.imageSendTimeout))
132
+ ]).catch(() => null);
101
133
  }
102
134
 
103
- function clearAllCache() {
104
- processedApi.clear()
105
- imageBuffer.forEach(buf => clearTimeout(buf.timer))
106
- imageBuffer.clear()
107
- tempFiles.forEach(file => {
108
- try {
109
- fs.unlinkSync(file)
110
- } catch {}
111
- })
112
- tempFiles.clear()
113
- return true
114
- }
135
+ async function downloadImageWithThreads(url: string, filename: string, config: Config): Promise<string> {
136
+ return new Promise((resolve, reject) => {
137
+ if (!fs.existsSync(config.tempDir)) {
138
+ fs.mkdirSync(config.tempDir, { recursive: true });
139
+ }
140
+ const filePath = path.join(config.tempDir, `${filename}.jpg`);
115
141
 
116
- function createTempDir(config: Config) {
117
- if (!fs.existsSync(config.tempDir)) {
118
- fs.mkdirSync(config.tempDir, { recursive: true })
119
- }
142
+ const worker = new Worker(currentFilePath, { workerData: { url, filePath } });
143
+ worker.on('message', (result: { success: boolean, filePath?: string, error?: string }) => {
144
+ if (result.success && result.filePath) {
145
+ resolve(result.filePath);
146
+ } else {
147
+ reject(new Error(result.error || '图片下载失败'));
148
+ }
149
+ });
150
+ worker.on('error', reject);
151
+ worker.on('exit', (code: number) => {
152
+ if (code !== 0) reject(new Error('图片下载线程异常'));
153
+ });
154
+ });
120
155
  }
121
156
 
122
- async function saveImageToTemp(buffer: Buffer, config: Config) {
123
- createTempDir(config)
124
- const filename = `${crypto.randomUUID()}.jpg`
125
- const filepath = path.join(config.tempDir, filename)
126
- await fs.promises.writeFile(filepath, buffer)
127
- tempFiles.add(filepath)
128
- return filepath
157
+ function clearAllCache(config: Config) {
158
+ processedApi.clear();
159
+ imageBuffer.forEach(buf => clearTimeout(buf.timer));
160
+ imageBuffer.clear();
161
+
162
+ if (fs.existsSync(config.tempDir)) {
163
+ fs.readdirSync(config.tempDir).forEach(file => {
164
+ try {
165
+ const filePath = path.join(config.tempDir, file);
166
+ const stat = fs.statSync(filePath);
167
+ if (Date.now() - stat.mtimeMs > 3600000) {
168
+ fs.unlinkSync(filePath);
169
+ }
170
+ } catch (error) {}
171
+ });
172
+ }
173
+ return true;
129
174
  }
130
175
 
131
- function isImageUrl(url: string) {
132
- return /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)/i.test(url)
176
+ function randomSelectApi(customImageApis: string[]): string | null {
177
+ const validApis = customImageApis.filter(api => api.trim());
178
+ if (validApis.length === 0) return null;
179
+ return validApis[Math.floor(Math.random() * validApis.length)];
133
180
  }
134
181
 
135
182
  async function fetchImage(url: string, config: Config) {
136
- const http: AxiosInstance = axios.create({
183
+ const http = axios.create({
137
184
  timeout: config.timeout,
138
185
  headers: {
139
186
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
140
187
  }
141
- })
188
+ });
142
189
 
143
190
  for (let retry = 0; retry <= config.retryTimes; retry++) {
144
191
  try {
145
- if (isImageUrl(url)) {
146
- return { success: true, type: 'url', data: url }
192
+ if (/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|bmp)/i.test(url)) {
193
+ return { success: true, type: 'url', data: url };
147
194
  }
148
195
 
149
- const res = await http.get(url, { responseType: 'arraybuffer' })
196
+ const res = await http.get(url, { responseType: 'arraybuffer' });
150
197
  if (res.status === 200 && res.data) {
151
- const filepath = await saveImageToTemp(Buffer.from(res.data), config)
152
- return { success: true, type: 'file', data: filepath }
198
+ return { success: true, type: 'buffer', data: res.data };
153
199
  }
154
- } catch {
155
- if (retry === config.retryTimes) return { success: false, type: '', data: '' }
156
- await delay(config.retryInterval)
200
+ } catch (error) {
201
+ if (retry === config.retryTimes) return { success: false, type: '', data: '' };
202
+ await delay(config.retryInterval);
157
203
  }
158
204
  }
159
205
 
160
- return { success: false, type: '', data: '' }
206
+ return { success: false, type: '', data: '' };
161
207
  }
162
208
 
163
209
  async function fetchHsjpImage(msg: string, msg1: string, msg2: string, config: Config) {
164
- const encodedMsg = encodeURIComponent(msg)
165
- const encodedMsg1 = encodeURIComponent(msg1)
166
- const encodedMsg2 = encodeURIComponent(msg2)
167
- const url = `https://api.suyanw.cn/api/hsjp/?msg=${encodedMsg}&msg1=${encodedMsg1}&msg2=${encodedMsg2}`
168
- return fetchImage(url, config)
210
+ const encodedMsg = encodeURIComponent(msg);
211
+ const encodedMsg1 = encodeURIComponent(msg1);
212
+ const encodedMsg2 = encodeURIComponent(msg2);
213
+ const url = `https://api.suyanw.cn/api/hsjp/?msg=${encodedMsg}&msg1=${encodedMsg1}&msg2=${encodedMsg2}`;
214
+ return fetchImage(url, config);
169
215
  }
170
216
 
171
217
  async function fetchDmjpImage(text: string, config: Config) {
172
- const encodedText = encodeURIComponent(text)
173
- const url = `https://api.suyanw.cn/api/dmjp.php?text=${encodedText}`
174
- return fetchImage(url, config)
218
+ const encodedText = encodeURIComponent(text);
219
+ const url = `https://api.suyanw.cn/api/dmjp.php?text=${encodedText}`;
220
+ return fetchImage(url, config);
175
221
  }
176
222
 
177
223
  async function processCustomImage(session: Session, apiUrl: string, config: Config) {
178
- const hash = crypto.createHash('md5').update(apiUrl).digest('hex')
179
- const now = Date.now()
224
+ const hash = crypto.createHash('md5').update(apiUrl).digest('hex');
225
+ const now = Date.now();
180
226
 
181
227
  if (processedApi.get(hash) && now - processedApi.get(hash)! < config.sameImageApiInterval * 1000) {
182
- return { success: false }
228
+ return { success: false, msg: '请勿重复请求' };
183
229
  }
184
- processedApi.set(hash, now)
230
+ processedApi.set(hash, now);
185
231
 
186
- const result = await fetchImage(apiUrl, config)
187
- if (result.success && result.data) {
188
- await sendTimeout(session, config.imageParseFormat, config)
189
- await sendTimeout(session, h.image(result.data), config)
190
- return { success: true }
232
+ const result = await fetchImage(apiUrl, config);
233
+ if (!result.success) {
234
+ return { success: false, msg: '图片获取失败' };
235
+ }
236
+
237
+ let imageElem: any;
238
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
239
+ try {
240
+ const filename = crypto.createHash('md5').update(result.data as string).digest('hex');
241
+ const filePath = await downloadImageWithThreads(result.data as string, filename, config);
242
+ imageElem = h.file(filePath);
243
+ } catch (error) {
244
+ imageElem = h.image(result.data as string);
245
+ }
246
+ } else if (result.type === 'buffer') {
247
+ const filename = crypto.randomUUID();
248
+ const filePath = path.join(config.tempDir, `${filename}.jpg`);
249
+ fs.writeFileSync(filePath, result.data as Buffer);
250
+ imageElem = h.file(filePath);
251
+ } else {
252
+ imageElem = h.image(result.data as string);
191
253
  }
192
- return { success: false }
254
+
255
+ return { success: true, msg: 'ok', data: { text: config.imageParseFormat, image: imageElem } };
193
256
  }
194
257
 
195
258
  async function flush(session: Session, config: Config, manualApi?: string) {
196
- const key = `${session.platform}:${session.userId}:${session.channelId}`
197
- const buf = imageBuffer.get(key)
198
- const apis = manualApi ? [manualApi] : buf?.apis || []
259
+ const key = `${session.platform}:${session.userId}:${session.channelId}`;
260
+ const buf = imageBuffer.get(key);
261
+ const apis = manualApi ? [manualApi] : buf?.apis || [];
199
262
 
200
263
  if (buf) {
201
- clearTimeout(buf.timer)
202
- imageBuffer.delete(key)
264
+ clearTimeout(buf.timer);
265
+ imageBuffer.delete(key);
203
266
  }
204
267
 
205
- if (config.customImageEnabled && apis.length) {
206
- const apiUrl = randomSelectApi(apis)
207
- if (apiUrl) await processCustomImage(session, apiUrl, config)
268
+ if (!config.customImageEnabled || !apis.length) return;
269
+
270
+ const items: any[] = [];
271
+ const errs: string[] = [];
272
+
273
+ for (const apiUrl of apis) {
274
+ const result = await processCustomImage(session, apiUrl, config);
275
+ if (result.success && result.data) {
276
+ items.push(result.data);
277
+ } else {
278
+ errs.push(`【${apiUrl.slice(0, 22)}...】:${result.msg}`);
279
+ }
208
280
  }
209
- }
210
281
 
211
- function randomSelectApi(customImageApis: string[]): string | null {
212
- const validApis = customImageApis.filter(api => api.trim())
213
- if (validApis.length === 0) return null
214
- return validApis[Math.floor(Math.random() * validApis.length)]
282
+ if (errs.length && !config.ignoreSendError) {
283
+ const errorMsg = `⚠️ 部分图片获取失败\n${errs.join('\n')}`;
284
+ await sendTimeout(session, errorMsg, config);
285
+ await delay(600);
286
+ }
287
+
288
+ if (items.length === 0) {
289
+ const failMsg = `❌ 所有图片获取失败\n${errs.join('\n')}`;
290
+ if (!config.ignoreSendError) {
291
+ await sendTimeout(session, failMsg, config);
292
+ }
293
+ return;
294
+ }
295
+
296
+ for (const item of items) {
297
+ await sendTimeout(session, item.text, config);
298
+ await delay(300);
299
+ await sendTimeout(session, item.image, config);
300
+ await delay(1000);
301
+ }
215
302
  }
216
303
 
217
304
  export function apply(ctx: Context, config: Config) {
218
- clearAllCache()
219
- createTempDir(config)
220
- ctx.logger.info('[custom-image] 插件已加载')
305
+ if (!isMainThread) return;
306
+
307
+ clearAllCache(config);
308
+ ctx.logger.info('[custom-image] 插件已加载');
221
309
 
222
310
  ctx.command('random-image [apis...]', '随机获取自定义图片')
223
311
  .option('api', '-a <api> 指定单个API地址')
224
312
  .action(async ({ session, options = {} }, ...apis) => {
225
- if (!config.enable || !config.customImageEnabled || !session) return
313
+ if (!config.enable || !config.customImageEnabled || !session) return;
226
314
 
227
- const targetApi = options.api || (apis.length ? apis.join(' ') : null)
315
+ const targetApi = options.api || (apis.length ? apis.join(' ') : null);
228
316
  if (targetApi) {
229
- await flush(session, config, targetApi)
317
+ await flush(session, config, targetApi);
230
318
  } else {
231
- const key = `${session.platform}:${session.userId}:${session.channelId}`
319
+ const key = `${session.platform}:${session.userId}:${session.channelId}`;
232
320
 
233
- let tipId
321
+ let tipId;
234
322
  if (config.showWaitingTip) {
235
- const tip = await sendTimeout(session, config.waitingTipText, config)
236
- tipId = (tip as any)?.messageId || (tip as any)?.id || tip
323
+ const tip = await sendTimeout(session, config.waitingTipText, config);
324
+ tipId = (tip as any)?.messageId || (tip as any)?.id || tip;
237
325
  }
238
326
 
239
327
  if (imageBuffer.has(key)) {
240
- const b = imageBuffer.get(key)!
241
- clearTimeout(b.timer)
242
- b.timer = setTimeout(() => flush(session, config), 1000)
328
+ const b = imageBuffer.get(key)!;
329
+ clearTimeout(b.timer);
330
+ b.timer = setTimeout(() => flush(session, config), config.messageBufferDelay * 1000);
243
331
  } else {
244
332
  imageBuffer.set(key, {
245
333
  apis: config.customImageApis,
246
- timer: setTimeout(() => flush(session, config), 1000),
334
+ timer: setTimeout(() => flush(session, config), config.messageBufferDelay * 1000),
247
335
  tipMsgId: tipId
248
- })
336
+ });
249
337
  }
250
338
  }
251
- })
339
+ });
252
340
 
253
341
  ctx.command('hsjp <msg> [msg1] [msg2]', '生成黑丝举牌图片')
254
342
  .action(async ({ session }, msg, msg1 = '', msg2 = '') => {
255
- if (!config.enable || !config.hsjpEnabled || !session || !msg) return
343
+ if (!config.enable || !config.hsjpEnabled || !session || !msg) return;
256
344
 
257
345
  if (config.showWaitingTip) {
258
- await sendTimeout(session, config.waitingTipText, config)
346
+ await sendTimeout(session, config.waitingTipText, config);
259
347
  }
260
348
 
261
- const result = await fetchHsjpImage(msg, msg1, msg2, config)
262
- if (result.success && result.data) {
263
- await sendTimeout(session, config.imageParseFormat, config)
264
- await sendTimeout(session, h.image(result.data), config)
349
+ const result = await fetchHsjpImage(msg, msg1, msg2, config);
350
+ if (result.success) {
351
+ let imageElem: any;
352
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
353
+ try {
354
+ const filename = crypto.createHash('md5').update(result.data as string).digest('hex');
355
+ const filePath = await downloadImageWithThreads(result.data as string, filename, config);
356
+ imageElem = h.file(filePath);
357
+ } catch (error) {
358
+ imageElem = h.image(result.data as string);
359
+ }
360
+ } else if (result.type === 'buffer') {
361
+ const filename = crypto.randomUUID();
362
+ const filePath = path.join(config.tempDir, `${filename}.jpg`);
363
+ fs.writeFileSync(filePath, result.data as Buffer);
364
+ imageElem = h.file(filePath);
365
+ } else {
366
+ imageElem = h.image(result.data as string);
367
+ }
368
+ await sendTimeout(session, config.imageParseFormat, config);
369
+ await sendTimeout(session, imageElem, config);
265
370
  }
266
- })
371
+ });
267
372
 
268
373
  ctx.command('dmjp <text>', '生成动漫举牌图片')
269
374
  .action(async ({ session }, text) => {
270
- if (!config.enable || !config.dmjpEnabled || !session || !text) return
375
+ if (!config.enable || !config.dmjpEnabled || !session || !text) return;
271
376
 
272
377
  if (config.showWaitingTip) {
273
- await sendTimeout(session, config.waitingTipText, config)
378
+ await sendTimeout(session, config.waitingTipText, config);
274
379
  }
275
380
 
276
- const result = await fetchDmjpImage(text, config)
277
- if (result.success && result.data) {
278
- await sendTimeout(session, config.imageParseFormat, config)
279
- await sendTimeout(session, h.image(result.data), config)
381
+ const result = await fetchDmjpImage(text, config);
382
+ if (result.success) {
383
+ let imageElem: any;
384
+ if (result.type === 'url' && config.downloadImageBeforeSend) {
385
+ try {
386
+ const filename = crypto.createHash('md5').update(result.data as string).digest('hex');
387
+ const filePath = await downloadImageWithThreads(result.data as string, filename, config);
388
+ imageElem = h.file(filePath);
389
+ } catch (error) {
390
+ imageElem = h.image(result.data as string);
391
+ }
392
+ } else if (result.type === 'buffer') {
393
+ const filename = crypto.randomUUID();
394
+ const filePath = path.join(config.tempDir, `${filename}.jpg`);
395
+ fs.writeFileSync(filePath, result.data as Buffer);
396
+ imageElem = h.file(filePath);
397
+ } else {
398
+ imageElem = h.image(result.data as string);
399
+ }
400
+ await sendTimeout(session, config.imageParseFormat, config);
401
+ await sendTimeout(session, imageElem, config);
280
402
  }
281
- })
403
+ });
282
404
 
283
405
  ctx.command('clear-image-cache', '清空图片缓存')
284
406
  .action(() => {
285
- clearAllCache()
286
- })
407
+ clearAllCache(config);
408
+ return '✅ 图片缓存已清空';
409
+ });
287
410
 
288
411
  setInterval(() => {
289
- const now = Date.now()
290
- processedApi.forEach((time, hash) => {
291
- if (now - time > 86400000) {
292
- processedApi.delete(hash)
293
- }
294
- })
295
- }, 3600000)
412
+ const now = Date.now();
413
+ processedApi.forEach((timestamp, hash) => {
414
+ if (now - timestamp > 86400000) processedApi.delete(hash);
415
+ });
416
+ }, 3600000);
417
+
418
+ setInterval(() => {
419
+ if (!fs.existsSync(config.tempDir)) return;
420
+
421
+ const now = Date.now();
422
+ fs.readdirSync(config.tempDir).forEach(file => {
423
+ try {
424
+ const stat = fs.statSync(path.join(config.tempDir, file));
425
+ if (now - stat.mtimeMs > 3600000) {
426
+ fs.unlinkSync(path.join(config.tempDir, file));
427
+ }
428
+ } catch (error) {}
429
+ });
430
+ }, 1800000);
296
431
 
297
432
  if (config.autoClearCacheInterval > 0) {
298
433
  setInterval(() => {
299
- clearAllCache()
300
- ctx.logger.info('[custom-image] 缓存已自动清理')
301
- }, config.autoClearCacheInterval * 60000)
434
+ clearAllCache(config);
435
+ ctx.logger.info('[custom-image] 缓存已自动清理');
436
+ }, config.autoClearCacheInterval * 60000);
302
437
  }
303
438
 
304
439
  process.on('exit', () => {
305
- clearAllCache()
306
- ctx.logger.info('[custom-image] 插件缓存已清理')
307
- })
440
+ clearAllCache(config);
441
+ ctx.logger.info('[custom-image] 插件缓存已清理');
442
+ });
308
443
  }
309
444
 
310
445
  export const inject = {