koishi-plugin-vsinger-gacha 1.1.2 → 1.1.4
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/README.md +14 -0
- package/lib/index.js +41 -20
- package/package.json +2 -2
- package/scripts/ci-load.cjs +17 -0
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# koishi-plugin-vsinger-gacha
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/koishi-plugin-vsinger-gacha)
|
|
4
|
+
[](https://github.com/zb23333ww/koishi-plugin-vsinger-gacha/actions/workflows/ci.yml)
|
|
4
5
|
[](LICENSE)
|
|
5
6
|
|
|
6
7
|
> 六位 VSinger 角色的 B 站收藏集抽卡插件:423 张官方卡、43 个奖池、真实概率 + 保底、镭射版、签到积分与图鉴长图。
|
|
@@ -61,6 +62,19 @@ npm i @napi-rs/canvas
|
|
|
61
62
|
- 想一次性缓存全部:`npm run fetch-assets -- --dir data/vsinger-gacha/cards`
|
|
62
63
|
- 要动态卡 mp4(1.1GB):下载 Release 的 `assets-videos.tar` 解压到 `cards/`
|
|
63
64
|
|
|
65
|
+
## 兼容性
|
|
66
|
+
|
|
67
|
+
| 项 | 说明 |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| **Koishi** | `>=4.17.0 <6`(4.18 与 5.x RC 均验证过) |
|
|
70
|
+
| **Node** | `>=18` |
|
|
71
|
+
| **适配器** | 任意 Satori 适配器可用;OneBot 额外支持**合并转发**,其它适配器自动退回普通消息 |
|
|
72
|
+
| **发图** | 默认 `buffer`(base64,跨容器最稳);可切 `file`(适配器能读同机文件时更省内存) |
|
|
73
|
+
| **数据库** | 不需要,数据存 `data/vsinger-gacha/*.json` |
|
|
74
|
+
| **图鉴** | 需要可选依赖 `@napi-rs/canvas`;没装时其它功能不受影响 |
|
|
75
|
+
| **字体** | 包内自带中文子集,不依赖系统字体 |
|
|
76
|
+
| **合并转发** | 配置 `forwardMode: auto / off` |
|
|
77
|
+
|
|
64
78
|
## 指令
|
|
65
79
|
|
|
66
80
|
| 指令 | 说明 |
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,10 @@ const Config = Schema.intersect([
|
|
|
31
31
|
.description('可选:openid ↔ QQ 映射表(如 /root/qqbot/data/id-map.json),用于统一用户标识'),
|
|
32
32
|
debug: Schema.boolean().default(false)
|
|
33
33
|
.description('调试模式:把收到的每条消息打到日志里'),
|
|
34
|
+
forwardMode: Schema.union(['auto', 'off']).default('auto')
|
|
35
|
+
.description('合并转发:auto=支持就转发、不支持自动退回普通消息;off=永远普通消息'),
|
|
36
|
+
imageMode: Schema.union(['auto', 'buffer', 'file']).default('auto')
|
|
37
|
+
.description('发图方式:auto=buffer(base64,跨容器最稳);file=发本地路径(需适配器能读同机文件)'),
|
|
34
38
|
sendVideos: Schema.boolean().default(true)
|
|
35
39
|
.description('动态卡单独发送 mp4(需要本地 videos 目录有对应文件)'),
|
|
36
40
|
autoDownload: Schema.boolean().default(true)
|
|
@@ -114,16 +118,32 @@ const normUser = (raw, session) => {
|
|
|
114
118
|
* ★ 不用 file:// 路径:机器人框架 / OneBot 实现在容器里读不到宿主机路径
|
|
115
119
|
* → 直接用 buffer(Koishi 会编码成 data/base64,跨进程可用)
|
|
116
120
|
*/
|
|
117
|
-
|
|
121
|
+
/** 机器人自身 uin / 显示名(不同适配器字段不一样) */
|
|
122
|
+
function botUin(session) {
|
|
123
|
+
return String(session.selfId || (session.bot && session.bot.selfId) || (session.bot && session.bot.config && session.bot.config.selfId) || '');
|
|
124
|
+
}
|
|
125
|
+
function botName(session) {
|
|
126
|
+
return String((session.bot && (session.bot.username || session.bot.name)) || 'Bot');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fileEl(file, mime, mode) {
|
|
118
130
|
const mt = mime || (/\.jpe?g$/i.test(file) ? 'image/jpeg' : 'image/png');
|
|
131
|
+
if (mode === 'file') {
|
|
132
|
+
try {
|
|
133
|
+
return h.image(require('node:url').pathToFileURL(file).href);
|
|
134
|
+
} catch { /* 退回 buffer */ }
|
|
135
|
+
}
|
|
119
136
|
return h.image(fs.readFileSync(file), mt);
|
|
120
|
-
}
|
|
137
|
+
}
|
|
121
138
|
|
|
122
139
|
|
|
123
140
|
/** 发合并转发(OneBot 私有 API);失败返回 false */
|
|
124
141
|
async function sendForward(session, nodes) {
|
|
125
142
|
const bot = session.bot;
|
|
126
|
-
if (!bot || !bot.internal)
|
|
143
|
+
if (!bot || !bot.internal) {
|
|
144
|
+
sendForward.lastError = 'adapter 不支持 internal API';
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
127
147
|
const gid = session.guildId || session.channelId;
|
|
128
148
|
if (session.isDirect || !gid) return false;
|
|
129
149
|
const internal = bot.internal;
|
|
@@ -151,9 +171,10 @@ async function sendForward(session, nodes) {
|
|
|
151
171
|
/** 本地视频 → 消息元素(OneBot 要求视频单独一条消息) */
|
|
152
172
|
function videoEl(file) {
|
|
153
173
|
const buf = fs.readFileSync(file);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
if (typeof h.video === 'function') {
|
|
175
|
+
try { return h.video(buf, 'video/mp4'); } catch { /* 退回通用元素 */ }
|
|
176
|
+
}
|
|
177
|
+
return h('video', { src: 'data:video/mp4;base64,' + buf.toString('base64') });
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
/** 本地图片 → OneBot base64 段 */
|
|
@@ -294,17 +315,17 @@ function apply(ctx, config) {
|
|
|
294
315
|
if (session.isDirect === false || session.guildId) {
|
|
295
316
|
nodes.push({
|
|
296
317
|
type: 'node',
|
|
297
|
-
data: { name:
|
|
318
|
+
data: { name: botName(session), uin: botUin(session), content: [{ type: 'text', data: { text: title } }] },
|
|
298
319
|
});
|
|
299
320
|
results.forEach((r, i) => {
|
|
300
321
|
const rate = r.card.rate_pct == null ? '' : ` ${r.card.rate_pct}%`;
|
|
301
322
|
const desc = `${r.tier}|${r.card.card_name}${rate}${r.laser ? '|✦镭射版' : ''}${r.card.video ? '|动态' : ''}`;
|
|
302
323
|
const content = [{ type: 'text', data: { text: desc } }];
|
|
303
324
|
if (pics[i]) content.push(imgSeg(pics[i]));
|
|
304
|
-
nodes.push({ type: 'node', data: { name:
|
|
325
|
+
nodes.push({ type: 'node', data: { name: botName(session), uin: botUin(session), content } });
|
|
305
326
|
});
|
|
306
327
|
}
|
|
307
|
-
const ok = nodes.length && await sendForward(session, nodes);
|
|
328
|
+
const ok = config.forwardMode !== 'off' && nodes.length && await sendForward(session, nodes);
|
|
308
329
|
if (!ok && nodes.length) logger.warn('合并转发不可用(%s),退回普通发送', sendForward.lastError || 'unknown');
|
|
309
330
|
|
|
310
331
|
// ★ 动态卡:单独发 mp4(OneBot 要求视频独占一条消息)
|
|
@@ -327,7 +348,7 @@ function apply(ctx, config) {
|
|
|
327
348
|
await session.send(title + '\n' + lines.join('\n'));
|
|
328
349
|
for (const f of pics) {
|
|
329
350
|
if (!f) continue;
|
|
330
|
-
try { await session.send(fileEl(f)); } catch (e) { logger.warn('发卡图失败:%s', e.message); }
|
|
351
|
+
try { await session.send(fileEl(f, null, config.imageMode)); } catch (e) { logger.warn('发卡图失败:%s', e.message); }
|
|
331
352
|
}
|
|
332
353
|
}
|
|
333
354
|
return;
|
|
@@ -359,18 +380,18 @@ function apply(ctx, config) {
|
|
|
359
380
|
}
|
|
360
381
|
lines.push('', '切换:/换池 序号(00 = 全部卡池)', '说明:/抽卡帮助');
|
|
361
382
|
|
|
362
|
-
const uin =
|
|
383
|
+
const uin = botUin(session);
|
|
363
384
|
const nodes = [];
|
|
364
385
|
const chunk = 18;
|
|
365
386
|
for (let k = 0; k < lines.length; k += chunk) {
|
|
366
|
-
nodes.push({ type: 'node', data: { name:
|
|
387
|
+
nodes.push({ type: 'node', data: { name: botName(session), uin, content: [{ type: 'text', data: { text: lines.slice(k, k + chunk).join('\n') } }] } });
|
|
367
388
|
}
|
|
368
389
|
if (nodes.length < 2) {
|
|
369
|
-
nodes.push({ type: 'node', data: { name:
|
|
390
|
+
nodes.push({ type: 'node', data: { name: botName(session), uin, content: [{ type: 'text', data: { text: '切换:/换池 序号(00 = 全部卡池)' } }] } });
|
|
370
391
|
}
|
|
371
|
-
const ok = await sendForward(session, nodes);
|
|
392
|
+
const ok = config.forwardMode !== 'off' && await sendForward(session, nodes);
|
|
372
393
|
if (!ok) {
|
|
373
|
-
logger.warn('卡池合并转发失败(%s),改发文本', sendForward.lastError);
|
|
394
|
+
if (config.forwardMode !== 'off') logger.warn('卡池合并转发失败(%s),改发文本', sendForward.lastError);
|
|
374
395
|
return lines.join('\n');
|
|
375
396
|
}
|
|
376
397
|
return;
|
|
@@ -414,7 +435,7 @@ function apply(ctx, config) {
|
|
|
414
435
|
const f = game.holoFile(c)
|
|
415
436
|
|| (config.autoDownload !== false ? await game.ensureImage(c) : (fs.existsSync(game.cardFile(c)) ? game.cardFile(c) : null));
|
|
416
437
|
if (!f) return `「${c.v.card_name}」的卡图下载失败(网络问题),稍后再试`;
|
|
417
|
-
await session.send(fileEl(f, /\.jpe?g$/i.test(f) ? 'image/jpeg' : 'image/png'));
|
|
438
|
+
await session.send(fileEl(f, /\.jpe?g$/i.test(f) ? 'image/jpeg' : 'image/png', config.imageMode));
|
|
418
439
|
return `🎴 ${c.v.card_name}\n档位:${tierName(c.v)} | 概率:${c.v.rate_pct}% | 你拥有 ×${u.owned[c.h]}${c.v.video ? ' | 动态卡' : ''}`;
|
|
419
440
|
});
|
|
420
441
|
|
|
@@ -431,10 +452,10 @@ function apply(ctx, config) {
|
|
|
431
452
|
tagsDir,
|
|
432
453
|
thumbsDir: path.join(assetsDir, 'thumbs'),
|
|
433
454
|
});
|
|
434
|
-
const nodes = [{ type: 'node', data: { name:
|
|
435
|
-
for (const f of files) nodes.push({ type: 'node', data: { name:
|
|
436
|
-
const ok = await sendForward(session, nodes);
|
|
437
|
-
if (!ok) for (const f of files) await session.send(fileEl(f, 'image/jpeg'));
|
|
455
|
+
const nodes = [{ type: 'node', data: { name: botName(session), uin: botUin(session), content: [{ type: 'text', data: { text: `${session.username || '你'} 的收藏集图鉴(${files.length} 张)` } }] } }];
|
|
456
|
+
for (const f of files) nodes.push({ type: 'node', data: { name: botName(session), uin: botUin(session), content: [imgSeg(f)] } });
|
|
457
|
+
const ok = config.forwardMode !== 'off' && await sendForward(session, nodes);
|
|
458
|
+
if (!ok) for (const f of files) await session.send(fileEl(f, 'image/jpeg', config.imageMode));
|
|
438
459
|
} catch (e) {
|
|
439
460
|
logger.warn('图鉴失败:%s', e.stack || e.message);
|
|
440
461
|
return `图鉴生成失败:${String(e.message).slice(0, 120)}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-vsinger-gacha",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "VSinger 收藏集抽卡 / 签到积分 / 图鉴(Koishi 插件,一次性安装)",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"koishi": "
|
|
25
|
+
"koishi": ">=4.17.0 <6"
|
|
26
26
|
},
|
|
27
27
|
"koishi": {
|
|
28
28
|
"description": {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** CI 用:真实 Koishi + mock 适配器,确认插件能加载并注册指令 */
|
|
2
|
+
const { Context } = require('koishi');
|
|
3
|
+
const mockMod = require('@koishijs/plugin-mock');
|
|
4
|
+
const plugin = require('../lib/index.js');
|
|
5
|
+
|
|
6
|
+
const app = new Context({ prefix: ['/'] });
|
|
7
|
+
app.plugin(mockMod.default ?? mockMod);
|
|
8
|
+
app.plugin(plugin, { cardsDir: './data/cards' });
|
|
9
|
+
|
|
10
|
+
app.start().then(() => {
|
|
11
|
+
const n = app.$commander._commandList.length;
|
|
12
|
+
console.log('指令数', n);
|
|
13
|
+
process.exit(n > 10 ? 0 : 1);
|
|
14
|
+
}).catch((e) => {
|
|
15
|
+
console.error(e);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|