koishi-plugin-kkk 3.4.2 → 3.6.0

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/assets/web/assets/index-BRROK0vh.js +1 -1
  3. package/config/default_config/app.yaml +4 -6
  4. package/config/default_config/bilibili.yaml +1 -1
  5. package/config/default_config/config.json +2 -2
  6. package/lib/build-metadata.json +10 -0
  7. package/lib/compat/adapter-info.js +333 -0
  8. package/lib/compat/forward-collect.js +57 -4
  9. package/lib/compat/node-karin.js +25 -56
  10. package/lib/compat/sendError.js +27 -0
  11. package/lib/index.js +15 -8
  12. package/lib/karin/apps/help.js +23 -2
  13. package/lib/karin/module/utils/Base.js +30 -8
  14. package/lib/karin/module/utils/ErrorHandler/render.js +8 -2
  15. package/lib/karin/module/utils/InteractiveGraph.js +164 -0
  16. package/lib/karin/module/utils/InteractiveVideo.js +316 -0
  17. package/lib/karin/module/utils/Network/Downloader.js +159 -13
  18. package/lib/karin/module/utils/Network/Network.js +5 -1
  19. package/lib/karin/module/utils/ParseForward.js +12 -2
  20. package/lib/karin/module/utils/ParseSteps.js +1 -1
  21. package/lib/karin/module/utils/Render/index.js +10 -110
  22. package/lib/karin/module/utils/build-metadata.js +64 -19
  23. package/lib/karin/module/utils/runtime-report.js +16 -4
  24. package/lib/karin/platform/bilibili/bilibili.js +137 -11
  25. package/lib/karin/platform/bilibili/interactive-story.js +463 -0
  26. package/lib/karin/platform/bilibili/push.js +12 -5
  27. package/lib/ktr/registry.js +1 -0
  28. package/lib/ktr/template/bilibili/interactive/components/interactive.js +137 -0
  29. package/lib/ktr/template/bilibili/interactive/components/types.js +3 -0
  30. package/lib/ktr/template/bilibili/interactive/index.js +13 -0
  31. package/lib/ktr/template/other/handlerError/components/handlerError.js +1 -1
  32. package/lib/webui.js +40 -18
  33. package/package.json +1 -1
  34. package/scripts/build.mjs +36 -0
  35. package/scripts/patch-webui.mjs +126 -40
  36. package/scripts/probe-edge-cookie.cjs +50 -0
  37. package/scripts/probe-panel-options.cjs +15 -0
  38. package/scripts/probe-schema-options.cjs +12 -0
  39. package/scripts/smoke-adapter-info.cjs +149 -0
  40. package/scripts/smoke-config-schema.cjs +62 -0
  41. package/scripts/smoke-download-source-fallback.cjs +166 -0
  42. package/scripts/smoke-forward-chart.cjs +139 -0
  43. package/scripts/smoke-interactive-card.cjs +87 -0
  44. package/scripts/smoke-interactive-story.cjs +285 -0
  45. package/scripts/smoke-interactive.cjs +298 -0
  46. package/scripts/smoke-onebot-forward.cjs +10 -5
  47. package/scripts/smoke-panel-chart.cjs +92 -0
  48. package/scripts/smoke-parse-forward.cjs +1 -1
  49. package/scripts/smoke-render-path.cjs +44 -55
  50. package/src/compat/adapter-info.ts +346 -0
  51. package/src/compat/forward-collect.ts +69 -4
  52. package/src/compat/node-karin.ts +24 -54
  53. package/src/compat/sendError.ts +26 -0
  54. package/src/index.ts +15 -8
  55. package/src/karin/apps/help.ts +26 -3
  56. package/src/karin/module/utils/Base.ts +39 -9
  57. package/src/karin/module/utils/ErrorHandler/render.ts +8 -2
  58. package/src/karin/module/utils/InteractiveGraph.ts +199 -0
  59. package/src/karin/module/utils/InteractiveVideo.ts +400 -0
  60. package/src/karin/module/utils/Network/Downloader.ts +164 -14
  61. package/src/karin/module/utils/Network/Network.ts +6 -2
  62. package/src/karin/module/utils/ParseForward.ts +12 -3
  63. package/src/karin/module/utils/ParseSteps.ts +1 -1
  64. package/src/karin/module/utils/Render/index.ts +10 -113
  65. package/src/karin/module/utils/build-metadata.ts +61 -19
  66. package/src/karin/module/utils/runtime-report.ts +16 -4
  67. package/src/karin/platform/bilibili/bilibili.ts +149 -20
  68. package/src/karin/platform/bilibili/interactive-story.ts +519 -0
  69. package/src/karin/platform/bilibili/push.ts +12 -6
  70. package/src/karin/types/config/app.ts +7 -15
  71. package/src/karin/types/config/bilibili.ts +1 -1
  72. package/src/karin/types/netrorks/NetworksConfigType.ts +5 -0
  73. package/src/ktr/registry.ts +1 -0
  74. package/src/ktr/template/bilibili/interactive/components/interactive.tsx +202 -0
  75. package/src/ktr/template/bilibili/interactive/components/types.ts +37 -0
  76. package/src/ktr/template/bilibili/interactive/index.tsx +15 -0
  77. package/src/ktr/template/other/handlerError/components/handlerError.tsx +4 -1
  78. package/src/webui.ts +38 -16
  79. package/scripts/smoke-shotkit-compat.cjs +0 -102
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 控制台配置项冒烟:bilibili.sendContent 的候选项里必须有 chart(互动视频流程图)。
3
+ *
4
+ * 机制:schema 是插件加载时按 config/default_config/*.yaml 的注释(「可选值:…」)现算的,
5
+ * 所以这个脚本直接调插件自己的 schema 构建函数,把这一项的候选项打出来 ——
6
+ * 以后遇到「面板里少一个勾选项」,跑它就能自证是代码问题还是没重启。
7
+ */
8
+ const path = require('node:path')
9
+ const pluginRoot = path.resolve(__dirname, '..')
10
+ const libRoot = path.join(pluginRoot, 'lib')
11
+ const noop = () => {}
12
+ const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
13
+ runtime.bindRuntime({ ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map(), on: noop, middleware: noop }, config: { app: {} }, dataRoot: path.join(pluginRoot, 'data-smoke-config-schema'), pluginRoot, master: () => [] })
14
+
15
+ const mod = require(path.join(libRoot, 'schema.js'))
16
+ const names = Object.keys(mod).filter((k) => typeof mod[k] === 'function')
17
+ console.log('schema.js 导出的函数: ' + names.join(', '))
18
+
19
+ /**
20
+ * 在返回的 schema / 元数据里找 sendContent 的候选项。
21
+ *
22
+ * ⚠️ cordis 的 Schema 实例是**可调用对象**:`typeof schema === 'function'`,
23
+ * 而候选值藏在它的 `.dict` 里。只认 `typeof 'object'` 的话第一步就被挡回来,
24
+ * 「明明有 chart」也会报成没有 —— 这个脚本自己踩过一次,别再退回去。
25
+ */
26
+ const isSchemaNode = (item) => !!item && (typeof item === 'object' || typeof item === 'function')
27
+ const collect = (value, out = [], depth = 0) => {
28
+ if (depth > 8 || !isSchemaNode(value)) return out
29
+ try {
30
+ for (const key of Object.keys(value)) {
31
+ const item = value[key]
32
+ if (key === 'sendContent') out.push(item)
33
+ else if (isSchemaNode(item)) collect(item, out, depth + 1)
34
+ }
35
+ } catch { /* 忽略不可枚举的东西 */ }
36
+ return out
37
+ }
38
+
39
+ let best = null
40
+ for (const name of names) {
41
+ for (const args of [[pluginRoot], [pluginRoot, {}], []]) {
42
+ try {
43
+ const value = mod[name](...args)
44
+ const hits = collect(value)
45
+ if (hits.length) { best = { name, args: args.length, hits }; break }
46
+ } catch { /* 换下一个签名 */ }
47
+ }
48
+ if (best) break
49
+ }
50
+
51
+ if (!best) {
52
+ console.log('❌ 没能从 schema 里取到 sendContent(导出名: ' + names.join(',') + ')')
53
+ process.exit(1)
54
+ }
55
+ const text = JSON.stringify(best.hits)
56
+ console.log('取到 ' + best.hits.length + ' 处 sendContent(来自 ' + best.name + ')')
57
+ const hasChart = text.includes('chart')
58
+ console.log('bilibili.sendContent 候选项: ' + (text.match(/bilibili[^]{0,400}/) ?? ['(未匹配)'])[0].slice(0, 220))
59
+ console.log('')
60
+ if (hasChart) { console.log('=== 通过:schema 里已经有 chart,重启 Koishi 后控制台就会出现这个勾选项 ==='); process.exit(0) }
61
+ console.log('=== 失败:schema 里没有 chart,说明没读到 YAML 里那句「可选值」注释 ===')
62
+ process.exit(1)
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 冒烟测试:下载器遇到「连得上但不吐数据」的坏源时的行为。
3
+ *
4
+ * 背景(用户实测):B站 playurl 把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点,
5
+ * TCP 连得上、响应头也正常,就是 60 秒只吐 118 KB;而旧实现的定时器是
6
+ * **从请求开始算的 60 秒总超时**,于是任何大文件都永远下不完,
7
+ * 报错还被 Node 报成 `Error: aborted`(code ECONNRESET)→ 日志写成「连接被重置」。
8
+ *
9
+ * 这里用本地 HTTP 服务复现四种场景,全程只依赖本机、不碰外网:
10
+ * 1. 坏源 + 备用镜像:应当自动换源并下完(新增行为)
11
+ * 2. 只有坏源、没有备用:应当报「下载超时」,**不能**再报「连接被重置」
12
+ * 3. 健康但慢的源(总耗时远超 timeout):应当下完(旧的 60 秒总超时会掐死它)
13
+ * 4. 涓流源(一直有数据但速度远低于阈值)+ 备用镜像:应当被看门狗换掉
14
+ *
15
+ * 走的是 lib 产物(真正跑的就是它),不是 src。
16
+ * 用法:node scripts/smoke-download-source-fallback.cjs
17
+ */
18
+ const http = require('node:http')
19
+ const fs = require('node:fs')
20
+ const os = require('node:os')
21
+ const path = require('node:path')
22
+ const axios = require('axios')
23
+
24
+ const { Downloader } = require('../lib/karin/module/utils/Network/Downloader.js')
25
+
26
+ /** 目标文件大小:8 MB(远大于 x64 回退的 256 KB 安全裕量) */
27
+ const TOTAL = 8 * 1024 * 1024
28
+
29
+ let failures = 0
30
+ const check = (name, ok, extra = '') => {
31
+ console.log((ok ? '✅' : '❌') + ' ' + name + (extra ? ' | ' + extra : ''))
32
+ if (!ok) failures++
33
+ }
34
+
35
+ /** 收集日志,用于断言「换源」「超时」这类提示文案 */
36
+ const logs = []
37
+ const rawLog = console.log.bind(console)
38
+ const rawWarn = console.warn.bind(console)
39
+ console.log = (...args) => { logs.push(args.join(' ')); rawLog(...args) }
40
+ console.warn = (...args) => { logs.push(args.join(' ')); rawWarn(...args) }
41
+
42
+ const listen = (handler) => new Promise((resolve) => {
43
+ const server = http.createServer(handler)
44
+ server.listen(0, '127.0.0.1', () => resolve(server))
45
+ })
46
+ const urlOf = (server) => 'http://127.0.0.1:' + server.address().port + '/file'
47
+
48
+ const parseRangeStart = (header) => {
49
+ if (!header) return 0
50
+ const raw = String(header).replace('bytes=', '').split('-')[0]
51
+ const start = Number(raw)
52
+ return Number.isFinite(start) && start > 0 ? start : 0
53
+ }
54
+
55
+ /** 正常源:支持 Range,快速把整份数据写完 */
56
+ const goodHandler = (req, res) => {
57
+ const start = parseRangeStart(req.headers.range)
58
+ const length = TOTAL - start
59
+ res.writeHead(start > 0 ? 206 : 200, {
60
+ 'content-type': 'application/octet-stream',
61
+ 'content-length': String(length),
62
+ ...(start > 0 ? { 'content-range': 'bytes ' + start + '-' + (TOTAL - 1) + '/' + TOTAL } : {})
63
+ })
64
+ const chunk = Buffer.alloc(256 * 1024)
65
+ let sent = start
66
+ const pump = () => {
67
+ while (sent < TOTAL) {
68
+ const size = Math.min(chunk.length, TOTAL - sent)
69
+ sent += size
70
+ if (!res.write(chunk.subarray(0, size))) {
71
+ res.once('drain', pump)
72
+ return
73
+ }
74
+ }
75
+ res.end()
76
+ }
77
+ pump()
78
+ }
79
+
80
+ /** 坏源:发完响应头和 64 KB 就再也不吐数据,且不关连接(mcdn/PCDN 的典型表现) */
81
+ const badHandler = (req, res) => {
82
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
83
+ res.write(Buffer.alloc(64 * 1024))
84
+ }
85
+
86
+ /** 涓流源:一直有数据,但只有 ~5 KB/s,远低于坏源看门狗阈值 */
87
+ const trickleHandler = (req, res) => {
88
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
89
+ const timer = setInterval(() => { res.write(Buffer.alloc(1024)) }, 200)
90
+ res.on('close', () => clearInterval(timer))
91
+ }
92
+
93
+ /** 健康但慢的源:约 4 秒传完 8 MB(每 200 ms 一块),总耗时远超 timeout */
94
+ const slowHandler = (req, res) => {
95
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
96
+ let sent = 0
97
+ const timer = setInterval(() => {
98
+ if (sent >= TOTAL) { clearInterval(timer); res.end(); return }
99
+ const size = Math.min(512 * 1024, TOTAL - sent)
100
+ sent += size
101
+ res.write(Buffer.alloc(size))
102
+ }, 250)
103
+ res.on('close', () => clearInterval(timer))
104
+ }
105
+
106
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-dl-'))
107
+
108
+ const runDownload = async (label, url, { timeout, retries = 3, backups = [] }) => {
109
+ const filepath = path.join(tmp, label + '.bin')
110
+ const instance = axios.create({ timeout, maxRedirects: 5, validateStatus: () => true })
111
+ const downloader = new Downloader(instance, url, filepath, {}, timeout, retries, { enabled: false }, backups)
112
+ const started = Date.now()
113
+ try {
114
+ const result = await downloader.download(() => {})
115
+ return { ok: true, bytes: fs.statSync(result.filepath).size, elapsed: Date.now() - started }
116
+ } catch (error) {
117
+ return { ok: false, message: String(error && error.message), bytes: fs.existsSync(filepath) ? fs.statSync(filepath).size : 0, elapsed: Date.now() - started }
118
+ }
119
+ }
120
+
121
+ const main = async () => {
122
+ const bad = await listen(badHandler)
123
+ const good = await listen(goodHandler)
124
+ const trickle = await listen(trickleHandler)
125
+ const slow = await listen(slowHandler)
126
+
127
+ // 1. 坏源 + 备用镜像 → 自动换源下完
128
+ logs.length = 0
129
+ const switched = await runDownload('switch', urlOf(bad), { timeout: 2000, backups: [urlOf(good)] })
130
+ check('坏源 + 备用镜像:下载成功', switched.ok, JSON.stringify(switched))
131
+ check('坏源 + 备用镜像:文件完整', switched.bytes === TOTAL, switched.bytes + '/' + TOTAL)
132
+ check('坏源 + 备用镜像:日志里有换源提示', logs.some((line) => line.includes('换备用下载源')), logs.filter((l) => l.includes('备用下载源')).join(' / '))
133
+
134
+ // 2. 只有坏源、没有备用 → 报「下载超时」,不报「连接被重置」
135
+ logs.length = 0
136
+ const timedOut = await runDownload('timeout', urlOf(bad), { timeout: 1500, retries: 0 })
137
+ check('无备用源:如预期失败', !timedOut.ok, JSON.stringify(timedOut))
138
+ check('无备用源:报「下载超时」', timedOut.message.includes('下载超时'), timedOut.message.slice(0, 160))
139
+ check('无备用源:不再误报「连接被重置」', !timedOut.message.includes('连接被重置'), timedOut.message.slice(0, 160))
140
+ check('无备用源:超时按空闲判定(约等于 timeout)', timedOut.elapsed < 1500 + 1500, timedOut.elapsed + 'ms')
141
+
142
+ // 3. 健康但慢的源:总耗时远超 timeout 也要下完(旧的 60 秒总超时会掐死它)
143
+ const slowRun = await runDownload('slow', urlOf(slow), { timeout: 1500, retries: 0 })
144
+ check('健康慢源:下载成功', slowRun.ok, JSON.stringify(slowRun))
145
+ check('健康慢源:文件完整', slowRun.bytes === TOTAL, slowRun.bytes + '/' + TOTAL)
146
+ check('健康慢源:确实是「比 timeout 慢」的场景', slowRun.elapsed > 1500 * 2, slowRun.elapsed + 'ms > 2x timeout')
147
+
148
+ // 4. 涓流源 + 备用镜像 → 看门狗换源(热身期 20s,这条最慢)
149
+ logs.length = 0
150
+ const watchdog = await runDownload('watchdog', urlOf(trickle), { timeout: 60000, retries: 1, backups: [urlOf(good)] })
151
+ check('涓流源:被看门狗换掉后下载成功', watchdog.ok, JSON.stringify(watchdog))
152
+ check('涓流源:文件完整', watchdog.bytes === TOTAL, watchdog.bytes + '/' + TOTAL)
153
+ check('涓流源:日志提示速度过低', logs.some((line) => line.includes('速度过低')), logs.filter((l) => l.includes('速度过低')).join(' / '))
154
+
155
+ for (const server of [bad, good, trickle, slow]) server.close()
156
+ fs.rmSync(tmp, { recursive: true, force: true })
157
+
158
+ console.log('')
159
+ console.log(failures ? '失败 ' + failures + ' 项' : '全部通过')
160
+ process.exit(failures ? 1 : 0)
161
+ }
162
+
163
+ main().catch((error) => {
164
+ console.error(error)
165
+ process.exit(1)
166
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 冒烟测试:互动视频的**剧情流程图**在「合并转发」里的归属。
3
+ *
4
+ * 用户要求:「流程图」要能像文字 / 图片 / 视频 / 文件那样在「合并转发内容」里单独勾。
5
+ * 于是它必须能被认出来 —— 剧情图本身是 `img` 段,跟普通图片长得一模一样,
6
+ * 所以发送方用 `withForwardKind('chart', …)` 给它打了个类别标签(见 compat/forward-collect)。
7
+ *
8
+ * 这个测试守三件事:
9
+ * 1. **没勾「流程图」**:它不进聊天记录,而是**单独发出去**(不许丢内容);
10
+ * 2. **勾了「流程图」**:它进聊天记录;
11
+ * 3. 解析结束(袋子已经冲刷)之后才画出来的剧情图**照样发得出去** ——
12
+ * 以前这里有个坑:收集袋 drain 之后还继续收,收进去就再也没人发了,内容凭空消失。
13
+ *
14
+ * 用法:node scripts/smoke-forward-chart.cjs
15
+ */
16
+ const path = require('node:path')
17
+ const { Context } = require('koishi')
18
+
19
+ const pluginRoot = path.resolve(__dirname, '..')
20
+ const dataRoot = path.resolve(pluginRoot, 'data-smoke-forward-chart')
21
+
22
+ const plugin = require(path.join(pluginRoot, 'lib/index.js'))
23
+ const ctx = new Context()
24
+ ctx.plugin(plugin, { dataPath: dataRoot, debug: true })
25
+
26
+ let failures = 0
27
+ const check = (name, ok, extra = '') => {
28
+ console.log((ok ? '✅' : '❌') + ' ' + name + (extra ? ' | ' + extra : ''))
29
+ if (!ok) failures++
30
+ }
31
+
32
+ const NORMAL = 'https://example.com/normal.png'
33
+ const CHART = 'https://example.com/chart.png'
34
+ const LATE = 'https://example.com/late-chart.png'
35
+
36
+ setTimeout(async () => {
37
+ try {
38
+ const { Message, segment, karin } = require(path.join(pluginRoot, 'lib/compat/node-karin.js'))
39
+ const { withForwardKind } = require(path.join(pluginRoot, 'lib/compat/node-karin.js'))
40
+ const { withParseForward } = require(path.join(pluginRoot, 'lib/karin/module/utils/ParseForward.js'))
41
+ const { Config } = require(path.join(pluginRoot, 'lib/karin/module/utils/Config.js'))
42
+
43
+ /** 造一个假会话(onebot = 支持合并转发),发出去的内容都收进 sent */
44
+ const makeEvent = (sent) => Message.fromSession({
45
+ content: 'https://www.bilibili.com/video/BV1xx411c7mD',
46
+ selfId: '10000', userId: '12345', guildId: '456', channelId: '456', messageId: 'm1',
47
+ bot: {
48
+ selfId: '10000', platform: 'onebot', status: 1, user: { id: '10000', name: 'smoke-bot' }, ctx,
49
+ sendMessage: async (channel, payload) => { sent.push({ channel: String(channel), payload }); return ['msg-1'] },
50
+ getGuild: async () => ({ name: 'smoke-guild' })
51
+ },
52
+ author: { nick: 'smoke' }, username: 'smoke', event: {},
53
+ send: async (payload) => { sent.push({ channel: '456', payload }); return ['msg-2'] }
54
+ })
55
+
56
+ /** 一次解析:普通图片 + 剧情图(带标签)+ 文字;解析结束后再补画一张剧情图 */
57
+ const runParse = async (kinds) => {
58
+ await Config.Modify('app', 'forwardContent', kinds)
59
+ const sent = []
60
+ const e = makeEvent(sent)
61
+ await withParseForward(async (ev) => {
62
+ await ev.reply(segment.image(NORMAL))
63
+ await withForwardKind('chart', () => ev.reply(segment.image(CHART)))
64
+ await ev.reply(segment.text('解析结果文字'))
65
+ })(e, () => Symbol('next'))
66
+ /** 剧情是**后台**跑的:解析早就结束了,图这时才画出来 */
67
+ await withForwardKind('chart', () => e.reply(segment.image(LATE)))
68
+ return sent
69
+ }
70
+
71
+ /** 取合并转发节点里的内容(没有转发就返回空串) */
72
+ const forwardTextOf = (sent) => {
73
+ const forward = sent.find((item) => {
74
+ const list = Array.isArray(item.payload) ? item.payload : [item.payload]
75
+ return list.some((el) => el && el.type === 'message')
76
+ })
77
+ if (!forward) return ''
78
+ return JSON.stringify(forward.payload)
79
+ }
80
+ /** 单独直发出去的内容 */
81
+ const directTextOf = (sent) => {
82
+ const forward = sent.filter((item) => {
83
+ const list = Array.isArray(item.payload) ? item.payload : [item.payload]
84
+ return list.some((el) => el && el.type === 'message')
85
+ })
86
+ const rest = sent.filter((item) => !forward.includes(item))
87
+ return JSON.stringify(rest)
88
+ }
89
+
90
+ const savedFake = Config.app.fakeForward
91
+ const savedContent = Config.app.forwardContent
92
+ await Config.Modify('app', 'fakeForward', true)
93
+ const savedNames = { forward: [], direct: [] }
94
+
95
+ console.log('\n[1] 没勾「流程图」:不进聊天记录,单独发出去')
96
+ {
97
+ const sent = await runParse(['text', 'image'])
98
+ const forward = forwardTextOf(sent)
99
+ const direct = directTextOf(sent)
100
+ check('合并转发里有普通图片与文字', forward.includes(NORMAL) && forward.includes('解析结果文字'), forward.slice(0, 120))
101
+ check('剧情图**不在**聊天记录里', !forward.includes(CHART))
102
+ check('剧情图单独发出来了(没被丢掉)', direct.includes(CHART), direct.slice(0, 160))
103
+ savedNames.forward = forward
104
+ savedNames.direct = direct
105
+ }
106
+
107
+ console.log('\n[2] 勾了「流程图」:进聊天记录')
108
+ {
109
+ const sent = await runParse(['text', 'image', 'chart'])
110
+ const forward = forwardTextOf(sent)
111
+ const direct = directTextOf(sent)
112
+ check('聊天记录里有剧情图', forward.includes(CHART), forward.slice(0, 160))
113
+ check('聊天记录里也有普通图片', forward.includes(NORMAL))
114
+ check('勾上之后不会再单独发一遍', !direct.includes(CHART), direct.slice(0, 160))
115
+ }
116
+
117
+ console.log('\n[3] 解析结束之后才画出来的剧情图照样发得出去')
118
+ {
119
+ const sent = await runParse(['text', 'image'])
120
+ const forward = forwardTextOf(sent)
121
+ check('晚到的那张没有混进已经发出去的聊天记录', !forward.includes(LATE))
122
+ check('晚到的那张单独发出去了(收集袋已经关闭,不许再吞)', directTextOf(sent).includes(LATE), directTextOf(sent).slice(0, 200))
123
+ }
124
+
125
+ console.log('\n[4] 收尾:配置恢复原样')
126
+ {
127
+ await Config.Modify('app', 'fakeForward', savedFake)
128
+ await Config.Modify('app', 'forwardContent', savedContent)
129
+ check('已恢复', JSON.stringify(Config.app.forwardContent) === JSON.stringify(savedContent))
130
+ }
131
+
132
+ void karin
133
+ } catch (error) {
134
+ console.log('❌ 冒烟测试抛异常: ' + String(error && error.stack ? error.stack : error))
135
+ failures++
136
+ }
137
+ console.log('\n=== ' + (failures ? failures + ' 项失败' : '全部通过') + ' ===')
138
+ process.exit(failures ? 1 : 0)
139
+ }, 3000)
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 互动视频「剧情图」卡片:直接跑 SSR,确认卡片拼得出来。
3
+ *
4
+ * 卡片是给用户看的图,不能在冒烟里截图(生产才有 puppeteer),所以这里退一步:
5
+ * 用 react-dom/server 把模板渲成 HTML,检查标题、选项、页脚版本信息都在。
6
+ *
7
+ * 用法:node scripts/smoke-interactive-card.cjs
8
+ */
9
+ const path = require('node:path')
10
+ const pluginRoot = path.resolve(__dirname, '..')
11
+ const libRoot = path.join(pluginRoot, 'lib')
12
+ const noop = () => {}
13
+ const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
14
+ runtime.bindRuntime({ ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map(), on: noop, middleware: noop }, config: { app: {} }, dataRoot: path.join(pluginRoot, 'data-smoke-interactive-card'), pluginRoot, master: () => [] })
15
+
16
+ const React = require('react')
17
+ const { renderToStaticMarkup } = require('react-dom/server')
18
+ const template = require(path.join(libRoot, 'ktr', 'template', 'bilibili', 'interactive', 'index.js'))
19
+ const { Root } = require(path.join(libRoot, 'karin', 'root.js'))
20
+
21
+ const failures = []
22
+ let passed = 0
23
+ const check = (name, condition, detail) => {
24
+ if (condition) { passed++; console.log(' ✓ ' + name) } else { failures.push(name + (detail ? ' → ' + detail : '')); console.log(' ✗ ' + name + (detail ? ' → ' + detail : '')) }
25
+ }
26
+
27
+ const data = {
28
+ title: '点进来帮史蒂夫做出选择!不用下载点击就玩!【互动视频+3种真结局+10个成就】',
29
+ step: 2,
30
+ path: ['向左走'],
31
+ rootLabel: '当前 · 第二题',
32
+ question: '第一题',
33
+ notice: '登录后才能体验全部结局哦~',
34
+ graph: {
35
+ question: '开场',
36
+ isLeaf: false,
37
+ choices: [
38
+ { label: 'A', text: '向左走', isDefault: true, children: [ { question: '第二题', isLeaf: false, choices: [ { label: 'A', text: '向右挖', children: [ { question: '结局一', isLeaf: true, choices: [] } ] }, { label: 'B', text: '向下挖', children: [ { question: '结局二', isLeaf: true, choices: [] } ] } ] } ] },
39
+ { label: 'B', text: '向右挖', children: [ { question: '另一条线', isLeaf: false, choices: [ { label: 'A', text: '继续', children: [ { question: '结局三', isLeaf: true, choices: [] } ] } ] } ] }
40
+ ]
41
+ }
42
+ }
43
+ const ctx = {
44
+ theme: 'light',
45
+ scale: 1,
46
+ version: {
47
+ plugin: 'koishi-plugin',
48
+ pluginName: 'kkk',
49
+ pluginVersion: Root.pluginVersion,
50
+ releaseType: 'Stable',
51
+ poweredBy: 'Koishi',
52
+ frameworkVersion: Root.karinVersion
53
+ }
54
+ }
55
+
56
+ const main = () => {
57
+ const def = template.default ?? template
58
+ check('模板能加载', !!def && !!def.component, Object.keys(template).join(','))
59
+ check('validate 认这份数据', typeof def.validate === 'function' && def.validate(data) === true)
60
+ check('validate 会挡住空数据', def.validate({}) === false)
61
+
62
+ const html = renderToStaticMarkup(React.createElement(def.component, { data, ctx }))
63
+ check('渲染出标题', html.includes('帮史蒂夫做出选择'), html.length)
64
+ check('渲染出选项编号与文字', html.includes('向左走') && html.includes('向右挖') && html.includes('向下挖'))
65
+ check('画的是思维导图(svg + 曲线)', html.includes('<svg') && html.includes('<path') && html.includes(' C '), html.slice(0, 120))
66
+ check('全部剧情都画上了(含深层结局)', html.includes('结局一') && html.includes('结局二') && html.includes('结局三'), html.slice(0, 200))
67
+ check('每一段剧情都有落点圆圈', (html.match(/<circle/g) || []).length >= 8, String((html.match(/<circle/g) || []).length))
68
+ check('走过的路用高亮标出来', html.includes('高亮的是你已经走过的路'))
69
+ check('渲染出每段的题目/落点', html.includes('第二题') && html.includes('结局一'))
70
+ check('补画时根节点是「当前 · 这一段」', html.includes('当前 · 第二题'), html.slice(0, 200))
71
+ check('渲染出已选剧情', html.includes('已选:') && html.includes('向左走'))
72
+ check('标出 B站 默认分支', html.includes('(默认)'))
73
+ check('带上 B站 的提示语', html.includes('登录后才能体验全部结局'))
74
+ check('页脚有插件版本(和作品信息卡同一套)', html.includes('v' + Root.pluginVersion), html.slice(-400))
75
+ check('页脚有框架版本', html.includes(String(Root.karinVersion)), html.slice(-400))
76
+ check('没有噪音层(feTurbulence 很慢,这张卡不铺)', !html.includes('feTurbulence'))
77
+ check('没有把未定义的选项渲染成 undefined', !html.includes('undefined'), (html.match(/.{0,40}undefined.{0,40}/) || [''])[0])
78
+
79
+ console.log('')
80
+ console.log('通过 ' + passed + ' 项,失败 ' + failures.length + ' 项')
81
+ if (failures.length === 0) { console.log('=== 通过:剧情图卡片拼得出来,页脚版本信息齐全 ==='); process.exit(0) }
82
+ for (const item of failures) console.log(' ❌ ' + item)
83
+ console.log('=== 失败 ===')
84
+ process.exit(1)
85
+ }
86
+
87
+ try { main() } catch (error) { console.log('测试自身抛错: ' + (error && error.stack ? error.stack : error)); process.exit(1) }