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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # koishi-plugin-vsinger-gacha
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-vsinger-gacha?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-vsinger-gacha)
4
+ [![CI](https://github.com/zb23333ww/koishi-plugin-vsinger-gacha/actions/workflows/ci.yml/badge.svg)](https://github.com/zb23333ww/koishi-plugin-vsinger-gacha/actions/workflows/ci.yml)
4
5
  [![license](https://img.shields.io/github/license/zb23333ww/koishi-plugin-vsinger-gacha?style=flat-square)](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
- const fileEl = (file, mime) => {
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) return false;
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
- const b64 = 'data:video/mp4;base64,' + buf.toString('base64');
155
- if (typeof h.video === 'function') return h.video(buf, 'video/mp4');
156
- return h('video', { src: b64 });
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: ctx.bots?.[0]?.username || '洛天依', uin: String(session.selfId || ''), content: [{ type: 'text', data: { text: title } }] },
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: '洛天依', uin: String(session.selfId || ''), content } });
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 = String(session.selfId || '');
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: '洛天依', uin, content: [{ type: 'text', data: { text: lines.slice(k, k + chunk).join('\n') } }] } });
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: '洛天依', uin, content: [{ type: 'text', data: { text: '切换:/换池 序号(00 = 全部卡池)' } }] } });
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: '洛天依', uin: String(session.selfId || ''), content: [{ type: 'text', data: { text: `${session.username || '你'} 的收藏集图鉴(${files.length} 张)` } }] } }];
435
- for (const f of files) nodes.push({ type: 'node', data: { name: '洛天依', uin: String(session.selfId || ''), content: [imgSeg(f)] } });
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.2",
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": "^4.17.0"
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
+ });