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 +2 -0
- package/lib/index.js +199 -61
- package/package.json +1 -1
- package/src/index.ts +255 -120
package/lib/index.d.ts
CHANGED
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
|
|
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(
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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((
|
|
254
|
-
if (now -
|
|
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
package/src/index.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Context, Schema, h, Session } from 'koishi'
|
|
2
|
-
import 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
|
|
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(
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
132
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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((
|
|
291
|
-
if (now -
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 = {
|