koishi-plugin-custom-image 0.1.5
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 +32 -0
- package/lib/index.js +296 -0
- package/package.json +62 -0
- package/src/index.ts +351 -0
- package/src/locales/zh-CN.yml +9 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
declare module 'koishi' {
|
|
4
|
+
interface Context {
|
|
5
|
+
axios: typeof axios;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export declare const name = "custom-image";
|
|
9
|
+
export interface Config {
|
|
10
|
+
enable: boolean;
|
|
11
|
+
showWaitingTip: boolean;
|
|
12
|
+
waitingTipText: string;
|
|
13
|
+
sameImageApiInterval: number;
|
|
14
|
+
timeout: number;
|
|
15
|
+
customImageApis: string[];
|
|
16
|
+
customImageEnabled: boolean;
|
|
17
|
+
hsjpEnabled: boolean;
|
|
18
|
+
dmjpEnabled: boolean;
|
|
19
|
+
ignoreSendError: boolean;
|
|
20
|
+
retryTimes: number;
|
|
21
|
+
retryInterval: number;
|
|
22
|
+
imageSendTimeout: number;
|
|
23
|
+
autoClearCacheInterval: number;
|
|
24
|
+
imageParseFormat: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const Config: Schema<Config>;
|
|
27
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
28
|
+
export declare const inject: {
|
|
29
|
+
required: string[];
|
|
30
|
+
optional: string[];
|
|
31
|
+
};
|
|
32
|
+
export declare const using: readonly ["axios"];
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.using = exports.inject = exports.Config = exports.name = void 0;
|
|
7
|
+
exports.apply = apply;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
// 插件名称(必须导出)
|
|
11
|
+
exports.name = 'custom-image';
|
|
12
|
+
// ==================== 2. 配置 Schema 声明(关键修复:移除冗余断言) ====================
|
|
13
|
+
// 核心修复:去掉 as Schema<Config>,直接使用 Schema.object 构建,符合 Koishi 规范
|
|
14
|
+
exports.Config = koishi_1.Schema.object({
|
|
15
|
+
// 基础设置
|
|
16
|
+
enable: koishi_1.Schema.boolean()
|
|
17
|
+
.default(true)
|
|
18
|
+
.description('【基础设置】启用插件'),
|
|
19
|
+
showWaitingTip: koishi_1.Schema.boolean()
|
|
20
|
+
.default(true)
|
|
21
|
+
.description('【基础设置】请求图片时显示等待提示'),
|
|
22
|
+
waitingTipText: koishi_1.Schema.string()
|
|
23
|
+
.default('正在获取图片,请稍候...')
|
|
24
|
+
.description('【基础设置】请求等待时发送的提示文本'),
|
|
25
|
+
sameImageApiInterval: koishi_1.Schema.number()
|
|
26
|
+
.default(60)
|
|
27
|
+
.min(0)
|
|
28
|
+
.description('【基础设置】重复API调用间隔:相同API的最小调用间隔(秒)'),
|
|
29
|
+
timeout: koishi_1.Schema.number()
|
|
30
|
+
.default(10000)
|
|
31
|
+
.min(0)
|
|
32
|
+
.description('【基础设置】API请求超时时间(毫秒)'),
|
|
33
|
+
customImageApis: koishi_1.Schema.array(koishi_1.Schema.string())
|
|
34
|
+
.default([])
|
|
35
|
+
.role('textarea') // 控制台显示为多行文本框(更友好)
|
|
36
|
+
.description('【基础设置】自定义图片API列表(每行一个地址)'),
|
|
37
|
+
// 功能开关
|
|
38
|
+
customImageEnabled: koishi_1.Schema.boolean()
|
|
39
|
+
.default(true)
|
|
40
|
+
.description('【功能开关】启用自定义图片API功能'),
|
|
41
|
+
hsjpEnabled: koishi_1.Schema.boolean()
|
|
42
|
+
.default(true)
|
|
43
|
+
.description('【功能开关】启用黑丝举牌功能'),
|
|
44
|
+
dmjpEnabled: koishi_1.Schema.boolean()
|
|
45
|
+
.default(true)
|
|
46
|
+
.description('【功能开关】启用动漫举牌功能'),
|
|
47
|
+
// 容错设置
|
|
48
|
+
ignoreSendError: koishi_1.Schema.boolean()
|
|
49
|
+
.default(true)
|
|
50
|
+
.description('【容错设置】忽略发送错误:避免插件因消息发送失败崩溃'),
|
|
51
|
+
retryTimes: koishi_1.Schema.number()
|
|
52
|
+
.default(1)
|
|
53
|
+
.min(0)
|
|
54
|
+
.description('【容错设置】API重试次数:请求失败时的重试次数'),
|
|
55
|
+
retryInterval: koishi_1.Schema.number()
|
|
56
|
+
.default(500)
|
|
57
|
+
.min(0)
|
|
58
|
+
.description('【容错设置】重试间隔(毫秒)'),
|
|
59
|
+
imageSendTimeout: koishi_1.Schema.number()
|
|
60
|
+
.default(15000)
|
|
61
|
+
.min(0)
|
|
62
|
+
.description('【容错设置】图片发送超时(毫秒),0表示不限制'),
|
|
63
|
+
// 缓存设置
|
|
64
|
+
autoClearCacheInterval: koishi_1.Schema.number()
|
|
65
|
+
.default(60)
|
|
66
|
+
.min(0)
|
|
67
|
+
.description('【缓存设置】自动清理缓存间隔(分钟),0表示不自动清理'),
|
|
68
|
+
// 格式设置
|
|
69
|
+
imageParseFormat: koishi_1.Schema.string()
|
|
70
|
+
.default('✅ 图片获取成功')
|
|
71
|
+
.description('【格式设置】图片发送前的提示文本')
|
|
72
|
+
});
|
|
73
|
+
// ==================== 全局缓存与工具函数 ====================
|
|
74
|
+
const processedApi = new Map();
|
|
75
|
+
const imageBuffer = new Map();
|
|
76
|
+
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
77
|
+
async function sendTimeout(session, content, config) {
|
|
78
|
+
if (config.imageSendTimeout <= 0) {
|
|
79
|
+
return session.send(content).catch(err => {
|
|
80
|
+
if (config.ignoreSendError)
|
|
81
|
+
return null;
|
|
82
|
+
throw err;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return Promise.race([
|
|
86
|
+
session.send(content),
|
|
87
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('发送超时')), config.imageSendTimeout))
|
|
88
|
+
]).catch(err => {
|
|
89
|
+
if (config.ignoreSendError)
|
|
90
|
+
return null;
|
|
91
|
+
throw err;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function clearAllCache() {
|
|
95
|
+
processedApi.clear();
|
|
96
|
+
imageBuffer.forEach(buf => clearTimeout(buf.timer));
|
|
97
|
+
imageBuffer.clear();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// ==================== 核心业务函数 ====================
|
|
101
|
+
function randomSelectApi(customImageApis) {
|
|
102
|
+
const validApis = customImageApis.filter(api => api.trim());
|
|
103
|
+
if (validApis.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
return validApis[Math.floor(Math.random() * validApis.length)];
|
|
106
|
+
}
|
|
107
|
+
async function fetchImage(ctx, url, config) {
|
|
108
|
+
const http = ctx.axios.create({
|
|
109
|
+
timeout: config.timeout,
|
|
110
|
+
headers: {
|
|
111
|
+
'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'
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
for (let retry = 0; retry <= config.retryTimes; retry++) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await http.get(url, { responseType: 'arraybuffer' });
|
|
117
|
+
if (res.status === 200 && res.data) {
|
|
118
|
+
const imgBase64 = Buffer.from(res.data).toString('base64');
|
|
119
|
+
return { success: true, data: imgBase64, msg: '图片获取成功' };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (retry === config.retryTimes) {
|
|
124
|
+
return { success: false, data: null, msg: `请求失败:${err.message}` };
|
|
125
|
+
}
|
|
126
|
+
await delay(config.retryInterval);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { success: false, data: null, msg: '重试次数用尽,图片获取失败' };
|
|
130
|
+
}
|
|
131
|
+
async function fetchHsjpImage(ctx, msg, msg1, msg2, config) {
|
|
132
|
+
const encodedMsg = encodeURIComponent(msg);
|
|
133
|
+
const encodedMsg1 = encodeURIComponent(msg1);
|
|
134
|
+
const encodedMsg2 = encodeURIComponent(msg2);
|
|
135
|
+
const url = `https://api.suyanw.cn/api/hsjp/?msg=${encodedMsg}&msg1=${encodedMsg1}&msg2=${encodedMsg2}`;
|
|
136
|
+
return fetchImage(ctx, url, config);
|
|
137
|
+
}
|
|
138
|
+
async function fetchDmjpImage(ctx, text, config) {
|
|
139
|
+
const encodedText = encodeURIComponent(text);
|
|
140
|
+
const url = `https://api.suyanw.cn/api/dmjp.php?text=${encodedText}`;
|
|
141
|
+
return fetchImage(ctx, url, config);
|
|
142
|
+
}
|
|
143
|
+
async function processCustomImage(ctx, session, apiUrl, config) {
|
|
144
|
+
const hash = crypto_1.default.createHash('md5').update(apiUrl).digest('hex');
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (processedApi.get(hash) && now - processedApi.get(hash) < config.sameImageApiInterval * 1000) {
|
|
147
|
+
return { success: false, msg: '请勿重复调用该API' };
|
|
148
|
+
}
|
|
149
|
+
processedApi.set(hash, now);
|
|
150
|
+
const result = await fetchImage(ctx, apiUrl, config);
|
|
151
|
+
if (result.success && result.data) {
|
|
152
|
+
await sendTimeout(session, config.imageParseFormat, config);
|
|
153
|
+
await sendTimeout(session, koishi_1.h.image(result.data), config);
|
|
154
|
+
return { success: true, msg: result.msg };
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
await sendTimeout(session, `❌ ${result.msg}`, config);
|
|
158
|
+
return { success: false, msg: result.msg };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function flush(ctx, session, config, manualApi) {
|
|
162
|
+
const key = `${session.platform}:${session.userId}:${session.channelId}`;
|
|
163
|
+
const buf = imageBuffer.get(key);
|
|
164
|
+
const apis = manualApi ? [manualApi] : buf?.apis || [];
|
|
165
|
+
if (buf) {
|
|
166
|
+
clearTimeout(buf.timer);
|
|
167
|
+
imageBuffer.delete(key);
|
|
168
|
+
}
|
|
169
|
+
if (config.customImageEnabled && apis.length) {
|
|
170
|
+
const apiUrl = randomSelectApi(apis);
|
|
171
|
+
if (!apiUrl) {
|
|
172
|
+
await sendTimeout(session, '❌ 自定义图片API列表为空', config);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await processCustomImage(ctx, session, apiUrl, config);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ==================== 插件主逻辑 ====================
|
|
179
|
+
function apply(ctx, config) {
|
|
180
|
+
// 初始化:清空缓存
|
|
181
|
+
clearAllCache();
|
|
182
|
+
ctx.logger.info('[custom-image] 自定义图片插件已加载');
|
|
183
|
+
// 声明依赖服务(Koishi 会自动检查)
|
|
184
|
+
ctx.plugin(require('@koishijs/plugin-axios'));
|
|
185
|
+
// 随机图片指令
|
|
186
|
+
ctx.command('random-image [apis...]', '随机获取自定义图片API的图片')
|
|
187
|
+
.option('api', '-a <api> 指定单个API地址')
|
|
188
|
+
.action(async ({ session, options = {} }, ...apis) => {
|
|
189
|
+
if (!config.enable || !config.customImageEnabled) {
|
|
190
|
+
return '❌ 自定义图片功能已禁用';
|
|
191
|
+
}
|
|
192
|
+
if (!session)
|
|
193
|
+
return;
|
|
194
|
+
const targetApi = options.api || (apis.length ? apis.join(' ') : null);
|
|
195
|
+
if (targetApi) {
|
|
196
|
+
await flush(ctx, session, config, targetApi);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const key = `${session.platform}:${session.userId}:${session.channelId}`;
|
|
200
|
+
// 显示等待提示
|
|
201
|
+
let tipId;
|
|
202
|
+
if (config.showWaitingTip) {
|
|
203
|
+
const m = await sendTimeout(session, config.waitingTipText, config);
|
|
204
|
+
tipId = m?.messageId || m?.id || m;
|
|
205
|
+
}
|
|
206
|
+
// 消息缓冲
|
|
207
|
+
if (imageBuffer.has(key)) {
|
|
208
|
+
const b = imageBuffer.get(key);
|
|
209
|
+
clearTimeout(b.timer);
|
|
210
|
+
b.timer = setTimeout(() => flush(ctx, session, config), 1000);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
imageBuffer.set(key, {
|
|
214
|
+
apis: config.customImageApis,
|
|
215
|
+
timer: setTimeout(() => flush(ctx, session, config), 1000),
|
|
216
|
+
tipMsgId: tipId
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
// 黑丝举牌指令
|
|
222
|
+
ctx.command('hsjp <msg> [msg1] [msg2]', '生成黑丝举牌图片')
|
|
223
|
+
.action(async ({ session }, msg, msg1 = '', msg2 = '') => {
|
|
224
|
+
if (!config.enable || !config.hsjpEnabled) {
|
|
225
|
+
return '❌ 黑丝举牌功能已禁用';
|
|
226
|
+
}
|
|
227
|
+
if (!session || !msg) {
|
|
228
|
+
return '❌ 请输入举牌文字!格式:hsjp <第一行> [第二行] [第三行]';
|
|
229
|
+
}
|
|
230
|
+
if (config.showWaitingTip) {
|
|
231
|
+
await sendTimeout(session, config.waitingTipText, config);
|
|
232
|
+
}
|
|
233
|
+
const result = await fetchHsjpImage(ctx, msg, msg1, msg2, config);
|
|
234
|
+
if (result.success && result.data) {
|
|
235
|
+
await sendTimeout(session, config.imageParseFormat, config);
|
|
236
|
+
await sendTimeout(session, koishi_1.h.image(result.data), config);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await sendTimeout(session, `❌ ${result.msg}`, config);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
// 动漫举牌指令
|
|
243
|
+
ctx.command('dmjp <text>', '生成动漫举牌图片')
|
|
244
|
+
.action(async ({ session }, text) => {
|
|
245
|
+
if (!config.enable || !config.dmjpEnabled) {
|
|
246
|
+
return '❌ 动漫举牌功能已禁用';
|
|
247
|
+
}
|
|
248
|
+
if (!session || !text) {
|
|
249
|
+
return '❌ 请输入举牌文字!格式:dmjp <文本>';
|
|
250
|
+
}
|
|
251
|
+
if (config.showWaitingTip) {
|
|
252
|
+
await sendTimeout(session, config.waitingTipText, config);
|
|
253
|
+
}
|
|
254
|
+
const result = await fetchDmjpImage(ctx, text, config);
|
|
255
|
+
if (result.success && result.data) {
|
|
256
|
+
await sendTimeout(session, config.imageParseFormat, config);
|
|
257
|
+
await sendTimeout(session, koishi_1.h.image(result.data), config);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
await sendTimeout(session, `❌ ${result.msg}`, config);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
// 清空缓存指令
|
|
264
|
+
ctx.command('clear-image-cache', '清空图片插件缓存')
|
|
265
|
+
.action(() => {
|
|
266
|
+
clearAllCache();
|
|
267
|
+
return '✅ 图片插件缓存已清空';
|
|
268
|
+
});
|
|
269
|
+
// 定时清理过期缓存(每小时)
|
|
270
|
+
setInterval(() => {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
processedApi.forEach((time, hash) => {
|
|
273
|
+
if (now - time > 86400000) { // 24小时过期
|
|
274
|
+
processedApi.delete(hash);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}, 3600000);
|
|
278
|
+
// 自动清理缓存(按配置)
|
|
279
|
+
if (config.autoClearCacheInterval > 0) {
|
|
280
|
+
setInterval(() => {
|
|
281
|
+
clearAllCache();
|
|
282
|
+
ctx.logger.info('[custom-image] 自动清理缓存完成');
|
|
283
|
+
}, config.autoClearCacheInterval * 60000);
|
|
284
|
+
}
|
|
285
|
+
// 进程退出清理
|
|
286
|
+
process.on('exit', () => {
|
|
287
|
+
clearAllCache();
|
|
288
|
+
ctx.logger.info('[custom-image] 插件缓存已清理,进程退出');
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// ==================== 补充:插件元信息(Koishi 控制台显示) ====================
|
|
292
|
+
exports.inject = {
|
|
293
|
+
required: ['axios'],
|
|
294
|
+
optional: ['i18n']
|
|
295
|
+
};
|
|
296
|
+
exports.using = ['axios'];
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-custom-image",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Koishi插件:自定义图片API随机调用 + 内置黑丝/动漫举牌功能",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"src",
|
|
10
|
+
"package.json",
|
|
11
|
+
"README.md",
|
|
12
|
+
"src/locales"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc -w",
|
|
17
|
+
"prepack": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"koishi",
|
|
21
|
+
"plugin",
|
|
22
|
+
"image",
|
|
23
|
+
"api",
|
|
24
|
+
"sign",
|
|
25
|
+
"举牌",
|
|
26
|
+
"黑丝举牌",
|
|
27
|
+
"动漫举牌"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Minecraft-1314/koishi-plugin-custom-image-api.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Minecraft-1314/koishi-plugin-custom-image-api/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Minecraft-1314/koishi-plugin-custom-image-api#readme",
|
|
37
|
+
"author": "",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"koishi": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"yaml": "^2.3.4"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "^5.3.3",
|
|
47
|
+
"@types/node": "^20.11.17",
|
|
48
|
+
"@koishijs/typings": "^4.0.0",
|
|
49
|
+
"@types/js-yaml": "^4.0.9"
|
|
50
|
+
},
|
|
51
|
+
"koishi": {
|
|
52
|
+
"description": {
|
|
53
|
+
"zh-CN": "自定义图片API随机调用 + 内置黑丝/动漫举牌功能,支持本地化配置和独立开关控制"
|
|
54
|
+
},
|
|
55
|
+
"service": {
|
|
56
|
+
"required": [
|
|
57
|
+
"axios",
|
|
58
|
+
"i18n"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { Context, Schema, h, Session } from 'koishi'
|
|
2
|
+
import axios, { AxiosInstance } from 'axios'
|
|
3
|
+
import crypto from 'crypto'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
|
|
7
|
+
// 扩展 Context 类型,添加 axios 属性(解决 ctx.axios 类型问题)
|
|
8
|
+
declare module 'koishi' {
|
|
9
|
+
interface Context {
|
|
10
|
+
axios: typeof axios;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 插件名称(必须导出)
|
|
15
|
+
export const name = 'custom-image'
|
|
16
|
+
|
|
17
|
+
// ==================== 1. 配置接口声明(规范写法) ====================
|
|
18
|
+
export interface Config {
|
|
19
|
+
// 基础设置
|
|
20
|
+
enable: boolean
|
|
21
|
+
showWaitingTip: boolean
|
|
22
|
+
waitingTipText: string
|
|
23
|
+
sameImageApiInterval: number
|
|
24
|
+
timeout: number
|
|
25
|
+
customImageApis: string[] // 自定义图片API列表(核心配置项)
|
|
26
|
+
// 功能开关
|
|
27
|
+
customImageEnabled: boolean
|
|
28
|
+
hsjpEnabled: boolean
|
|
29
|
+
dmjpEnabled: boolean
|
|
30
|
+
// 容错设置
|
|
31
|
+
ignoreSendError: boolean
|
|
32
|
+
retryTimes: number
|
|
33
|
+
retryInterval: number
|
|
34
|
+
imageSendTimeout: number
|
|
35
|
+
// 缓存设置
|
|
36
|
+
autoClearCacheInterval: number
|
|
37
|
+
// 格式设置
|
|
38
|
+
imageParseFormat: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ==================== 2. 配置 Schema 声明(关键修复:移除冗余断言) ====================
|
|
42
|
+
// 核心修复:去掉 as Schema<Config>,直接使用 Schema.object 构建,符合 Koishi 规范
|
|
43
|
+
export const Config: Schema<Config> = Schema.object({
|
|
44
|
+
// 基础设置
|
|
45
|
+
enable: Schema.boolean()
|
|
46
|
+
.default(true)
|
|
47
|
+
.description('【基础设置】启用插件'),
|
|
48
|
+
showWaitingTip: Schema.boolean()
|
|
49
|
+
.default(true)
|
|
50
|
+
.description('【基础设置】请求图片时显示等待提示'),
|
|
51
|
+
waitingTipText: Schema.string()
|
|
52
|
+
.default('正在获取图片,请稍候...')
|
|
53
|
+
.description('【基础设置】请求等待时发送的提示文本'),
|
|
54
|
+
sameImageApiInterval: Schema.number()
|
|
55
|
+
.default(60)
|
|
56
|
+
.min(0)
|
|
57
|
+
.description('【基础设置】重复API调用间隔:相同API的最小调用间隔(秒)'),
|
|
58
|
+
timeout: Schema.number()
|
|
59
|
+
.default(10000)
|
|
60
|
+
.min(0)
|
|
61
|
+
.description('【基础设置】API请求超时时间(毫秒)'),
|
|
62
|
+
customImageApis: Schema.array(Schema.string())
|
|
63
|
+
.default([])
|
|
64
|
+
.role('textarea') // 控制台显示为多行文本框(更友好)
|
|
65
|
+
.description('【基础设置】自定义图片API列表(每行一个地址)'),
|
|
66
|
+
// 功能开关
|
|
67
|
+
customImageEnabled: Schema.boolean()
|
|
68
|
+
.default(true)
|
|
69
|
+
.description('【功能开关】启用自定义图片API功能'),
|
|
70
|
+
hsjpEnabled: Schema.boolean()
|
|
71
|
+
.default(true)
|
|
72
|
+
.description('【功能开关】启用黑丝举牌功能'),
|
|
73
|
+
dmjpEnabled: Schema.boolean()
|
|
74
|
+
.default(true)
|
|
75
|
+
.description('【功能开关】启用动漫举牌功能'),
|
|
76
|
+
// 容错设置
|
|
77
|
+
ignoreSendError: Schema.boolean()
|
|
78
|
+
.default(true)
|
|
79
|
+
.description('【容错设置】忽略发送错误:避免插件因消息发送失败崩溃'),
|
|
80
|
+
retryTimes: Schema.number()
|
|
81
|
+
.default(1)
|
|
82
|
+
.min(0)
|
|
83
|
+
.description('【容错设置】API重试次数:请求失败时的重试次数'),
|
|
84
|
+
retryInterval: Schema.number()
|
|
85
|
+
.default(500)
|
|
86
|
+
.min(0)
|
|
87
|
+
.description('【容错设置】重试间隔(毫秒)'),
|
|
88
|
+
imageSendTimeout: Schema.number()
|
|
89
|
+
.default(15000)
|
|
90
|
+
.min(0)
|
|
91
|
+
.description('【容错设置】图片发送超时(毫秒),0表示不限制'),
|
|
92
|
+
// 缓存设置
|
|
93
|
+
autoClearCacheInterval: Schema.number()
|
|
94
|
+
.default(60)
|
|
95
|
+
.min(0)
|
|
96
|
+
.description('【缓存设置】自动清理缓存间隔(分钟),0表示不自动清理'),
|
|
97
|
+
// 格式设置
|
|
98
|
+
imageParseFormat: Schema.string()
|
|
99
|
+
.default('✅ 图片获取成功')
|
|
100
|
+
.description('【格式设置】图片发送前的提示文本')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ==================== 全局缓存与工具函数 ====================
|
|
104
|
+
const processedApi = new Map<string, number>()
|
|
105
|
+
const imageBuffer = new Map<string, { apis: string[], timer: NodeJS.Timeout, tipMsgId?: string | number }>()
|
|
106
|
+
|
|
107
|
+
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
108
|
+
|
|
109
|
+
async function sendTimeout(session: Session, content: any, config: Config) {
|
|
110
|
+
if (config.imageSendTimeout <= 0) {
|
|
111
|
+
return session.send(content).catch(err => {
|
|
112
|
+
if (config.ignoreSendError) return null
|
|
113
|
+
throw err
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return Promise.race([
|
|
117
|
+
session.send(content),
|
|
118
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('发送超时')), config.imageSendTimeout))
|
|
119
|
+
]).catch(err => {
|
|
120
|
+
if (config.ignoreSendError) return null
|
|
121
|
+
throw err
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearAllCache() {
|
|
126
|
+
processedApi.clear()
|
|
127
|
+
imageBuffer.forEach(buf => clearTimeout(buf.timer))
|
|
128
|
+
imageBuffer.clear()
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ==================== 核心业务函数 ====================
|
|
133
|
+
function randomSelectApi(customImageApis: string[]): string | null {
|
|
134
|
+
const validApis = customImageApis.filter(api => api.trim())
|
|
135
|
+
if (validApis.length === 0) return null
|
|
136
|
+
return validApis[Math.floor(Math.random() * validApis.length)]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchImage(ctx: Context, url: string, config: Config) {
|
|
140
|
+
const http: AxiosInstance = ctx.axios.create({
|
|
141
|
+
timeout: config.timeout,
|
|
142
|
+
headers: {
|
|
143
|
+
'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'
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
for (let retry = 0; retry <= config.retryTimes; retry++) {
|
|
148
|
+
try {
|
|
149
|
+
const res = await http.get(url, { responseType: 'arraybuffer' })
|
|
150
|
+
if (res.status === 200 && res.data) {
|
|
151
|
+
const imgBase64 = Buffer.from(res.data).toString('base64')
|
|
152
|
+
return { success: true, data: imgBase64, msg: '图片获取成功' }
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (retry === config.retryTimes) {
|
|
156
|
+
return { success: false, data: null, msg: `请求失败:${(err as Error).message}` }
|
|
157
|
+
}
|
|
158
|
+
await delay(config.retryInterval)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { success: false, data: null, msg: '重试次数用尽,图片获取失败' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function fetchHsjpImage(ctx: Context, msg: string, msg1: string, msg2: string, config: Config) {
|
|
166
|
+
const encodedMsg = encodeURIComponent(msg)
|
|
167
|
+
const encodedMsg1 = encodeURIComponent(msg1)
|
|
168
|
+
const encodedMsg2 = encodeURIComponent(msg2)
|
|
169
|
+
const url = `https://api.suyanw.cn/api/hsjp/?msg=${encodedMsg}&msg1=${encodedMsg1}&msg2=${encodedMsg2}`
|
|
170
|
+
|
|
171
|
+
return fetchImage(ctx, url, config)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchDmjpImage(ctx: Context, text: string, config: Config) {
|
|
175
|
+
const encodedText = encodeURIComponent(text)
|
|
176
|
+
const url = `https://api.suyanw.cn/api/dmjp.php?text=${encodedText}`
|
|
177
|
+
|
|
178
|
+
return fetchImage(ctx, url, config)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function processCustomImage(ctx: Context, session: Session, apiUrl: string, config: Config) {
|
|
182
|
+
const hash = crypto.createHash('md5').update(apiUrl).digest('hex')
|
|
183
|
+
const now = Date.now()
|
|
184
|
+
|
|
185
|
+
if (processedApi.get(hash) && now - processedApi.get(hash)! < config.sameImageApiInterval * 1000) {
|
|
186
|
+
return { success: false, msg: '请勿重复调用该API' }
|
|
187
|
+
}
|
|
188
|
+
processedApi.set(hash, now)
|
|
189
|
+
|
|
190
|
+
const result = await fetchImage(ctx, apiUrl, config)
|
|
191
|
+
if (result.success && result.data) {
|
|
192
|
+
await sendTimeout(session, config.imageParseFormat, config)
|
|
193
|
+
await sendTimeout(session, h.image(result.data), config)
|
|
194
|
+
return { success: true, msg: result.msg }
|
|
195
|
+
} else {
|
|
196
|
+
await sendTimeout(session, `❌ ${result.msg}`, config)
|
|
197
|
+
return { success: false, msg: result.msg }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function flush(ctx: Context, session: Session, config: Config, manualApi?: string) {
|
|
202
|
+
const key = `${session.platform}:${session.userId}:${session.channelId}`
|
|
203
|
+
const buf = imageBuffer.get(key)
|
|
204
|
+
const apis = manualApi ? [manualApi] : buf?.apis || []
|
|
205
|
+
|
|
206
|
+
if (buf) {
|
|
207
|
+
clearTimeout(buf.timer)
|
|
208
|
+
imageBuffer.delete(key)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (config.customImageEnabled && apis.length) {
|
|
212
|
+
const apiUrl = randomSelectApi(apis)
|
|
213
|
+
if (!apiUrl) {
|
|
214
|
+
await sendTimeout(session, '❌ 自定义图片API列表为空', config)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
await processCustomImage(ctx, session, apiUrl, config)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ==================== 插件主逻辑 ====================
|
|
222
|
+
export function apply(ctx: Context, config: Config) {
|
|
223
|
+
// 初始化:清空缓存
|
|
224
|
+
clearAllCache()
|
|
225
|
+
ctx.logger.info('[custom-image] 自定义图片插件已加载')
|
|
226
|
+
|
|
227
|
+
// 声明依赖服务(Koishi 会自动检查)
|
|
228
|
+
ctx.plugin(require('@koishijs/plugin-axios'))
|
|
229
|
+
|
|
230
|
+
// 随机图片指令
|
|
231
|
+
ctx.command('random-image [apis...]', '随机获取自定义图片API的图片')
|
|
232
|
+
.option('api', '-a <api> 指定单个API地址')
|
|
233
|
+
.action(async ({ session, options = {} }, ...apis) => {
|
|
234
|
+
if (!config.enable || !config.customImageEnabled) {
|
|
235
|
+
return '❌ 自定义图片功能已禁用'
|
|
236
|
+
}
|
|
237
|
+
if (!session) return
|
|
238
|
+
|
|
239
|
+
const targetApi = options.api || (apis.length ? apis.join(' ') : null)
|
|
240
|
+
if (targetApi) {
|
|
241
|
+
await flush(ctx, session, config, targetApi)
|
|
242
|
+
} else {
|
|
243
|
+
const key = `${session.platform}:${session.userId}:${session.channelId}`
|
|
244
|
+
|
|
245
|
+
// 显示等待提示
|
|
246
|
+
let tipId
|
|
247
|
+
if (config.showWaitingTip) {
|
|
248
|
+
const m = await sendTimeout(session, config.waitingTipText, config)
|
|
249
|
+
tipId = (m as any)?.messageId || (m as any)?.id || m
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 消息缓冲
|
|
253
|
+
if (imageBuffer.has(key)) {
|
|
254
|
+
const b = imageBuffer.get(key)!
|
|
255
|
+
clearTimeout(b.timer)
|
|
256
|
+
b.timer = setTimeout(() => flush(ctx, session, config), 1000)
|
|
257
|
+
} else {
|
|
258
|
+
imageBuffer.set(key, {
|
|
259
|
+
apis: config.customImageApis,
|
|
260
|
+
timer: setTimeout(() => flush(ctx, session, config), 1000),
|
|
261
|
+
tipMsgId: tipId
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// 黑丝举牌指令
|
|
268
|
+
ctx.command('hsjp <msg> [msg1] [msg2]', '生成黑丝举牌图片')
|
|
269
|
+
.action(async ({ session }, msg, msg1 = '', msg2 = '') => {
|
|
270
|
+
if (!config.enable || !config.hsjpEnabled) {
|
|
271
|
+
return '❌ 黑丝举牌功能已禁用'
|
|
272
|
+
}
|
|
273
|
+
if (!session || !msg) {
|
|
274
|
+
return '❌ 请输入举牌文字!格式:hsjp <第一行> [第二行] [第三行]'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (config.showWaitingTip) {
|
|
278
|
+
await sendTimeout(session, config.waitingTipText, config)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const result = await fetchHsjpImage(ctx, msg, msg1, msg2, config)
|
|
282
|
+
if (result.success && result.data) {
|
|
283
|
+
await sendTimeout(session, config.imageParseFormat, config)
|
|
284
|
+
await sendTimeout(session, h.image(result.data), config)
|
|
285
|
+
} else {
|
|
286
|
+
await sendTimeout(session, `❌ ${result.msg}`, config)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// 动漫举牌指令
|
|
291
|
+
ctx.command('dmjp <text>', '生成动漫举牌图片')
|
|
292
|
+
.action(async ({ session }, text) => {
|
|
293
|
+
if (!config.enable || !config.dmjpEnabled) {
|
|
294
|
+
return '❌ 动漫举牌功能已禁用'
|
|
295
|
+
}
|
|
296
|
+
if (!session || !text) {
|
|
297
|
+
return '❌ 请输入举牌文字!格式:dmjp <文本>'
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (config.showWaitingTip) {
|
|
301
|
+
await sendTimeout(session, config.waitingTipText, config)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const result = await fetchDmjpImage(ctx, text, config)
|
|
305
|
+
if (result.success && result.data) {
|
|
306
|
+
await sendTimeout(session, config.imageParseFormat, config)
|
|
307
|
+
await sendTimeout(session, h.image(result.data), config)
|
|
308
|
+
} else {
|
|
309
|
+
await sendTimeout(session, `❌ ${result.msg}`, config)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// 清空缓存指令
|
|
314
|
+
ctx.command('clear-image-cache', '清空图片插件缓存')
|
|
315
|
+
.action(() => {
|
|
316
|
+
clearAllCache()
|
|
317
|
+
return '✅ 图片插件缓存已清空'
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// 定时清理过期缓存(每小时)
|
|
321
|
+
setInterval(() => {
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
processedApi.forEach((time, hash) => {
|
|
324
|
+
if (now - time > 86400000) { // 24小时过期
|
|
325
|
+
processedApi.delete(hash)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
}, 3600000)
|
|
329
|
+
|
|
330
|
+
// 自动清理缓存(按配置)
|
|
331
|
+
if (config.autoClearCacheInterval > 0) {
|
|
332
|
+
setInterval(() => {
|
|
333
|
+
clearAllCache()
|
|
334
|
+
ctx.logger.info('[custom-image] 自动清理缓存完成')
|
|
335
|
+
}, config.autoClearCacheInterval * 60000)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 进程退出清理
|
|
339
|
+
process.on('exit', () => {
|
|
340
|
+
clearAllCache()
|
|
341
|
+
ctx.logger.info('[custom-image] 插件缓存已清理,进程退出')
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ==================== 补充:插件元信息(Koishi 控制台显示) ====================
|
|
346
|
+
export const inject = {
|
|
347
|
+
required: ['axios'],
|
|
348
|
+
optional: ['i18n']
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export const using = ['axios'] as const
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
customImage:
|
|
2
|
+
noApi: 自定义图片API列表为空,请先在插件配置中添加!
|
|
3
|
+
error: 图片获取失败:{{msg}}
|
|
4
|
+
hsjpDisabled: 黑丝举牌功能已禁用,请联系管理员开启!
|
|
5
|
+
hsjpNoText: 请输入举牌文字!格式:hsjp <第一行> [第二行] [第三行]
|
|
6
|
+
hsjpError: 黑丝举牌生成失败:{{msg}}
|
|
7
|
+
dmjpDisabled: 动漫举牌功能已禁用,请联系管理员开启!
|
|
8
|
+
dmjpNoText: 请输入举牌文字!格式:dmjp <文本>
|
|
9
|
+
dmjpError: 动漫举牌生成失败:{{msg}}
|