koishi-plugin-kkk 3.7.0 → 3.7.2

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.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.interactiveChart = exports.renderInteractiveChart = exports.forgetChoiceMessage = exports.toCardGraph = exports.findNode = exports.runInteractiveStory = exports.sendStoryTip = exports.sendInteractiveChoices = exports.buildChoiceMessage = exports.RENDER_CHART_COMMAND = exports.CHOICE_WAIT_SECONDS = void 0;
36
+ exports.interactiveChart = exports.renderInteractiveChart = exports.forgetChoiceMessage = exports.toCardGraph = exports.findNode = exports.runInteractiveStory = exports.sendStoryTip = exports.sendInteractiveChoices = exports.buildChoiceMessage = exports.rememberStoryVideo = exports.RENDER_CHART_COMMAND = exports.CHOICE_WAIT_SECONDS = void 0;
37
37
  /**
38
38
  * 互动视频的「发选项 → 等选择 → 播下一段」这一段流程。
39
39
  *
@@ -60,6 +60,43 @@ exports.CHOICE_WAIT_SECONDS = 180;
60
60
  exports.RENDER_CHART_COMMAND = '渲染流程图';
61
61
  /** 每个频道最近一条「选项消息」,用于发下一条之前撤回 */
62
62
  const lastChoiceMessages = new Map();
63
+ /**
64
+ * 每个频道最近一条「剧情视频」的消息 ID。
65
+ *
66
+ * 用户要求:点一下选项,**上一段视频也要撤回** —— 不然玩几段之后群里躺着一串视频。
67
+ * 视频是解析链路发的(不是这里发的),所以由那边把它发出去的消息 ID 记进来。
68
+ */
69
+ const lastStoryVideos = new Map();
70
+ /** 记下刚发出去的剧情视频(由解析链路在上传成功时调用) */
71
+ const rememberStoryVideo = (e, messageId) => {
72
+ if (!messageId)
73
+ return;
74
+ lastStoryVideos.set(sessionKeyOf(e), String(messageId));
75
+ };
76
+ exports.rememberStoryVideo = rememberStoryVideo;
77
+ /**
78
+ * 取出并清掉「上一条剧情视频」的消息 ID。
79
+ *
80
+ * 取出来就删:这条只该被撤回一次,撤回失败也不能反复去点。
81
+ */
82
+ const takeStoryVideo = (e) => {
83
+ const key = sessionKeyOf(e);
84
+ const messageId = lastStoryVideos.get(key);
85
+ if (messageId)
86
+ lastStoryVideos.delete(key);
87
+ return messageId;
88
+ };
89
+ /** 撤回某条消息(失败只记日志,绝不影响剧情) */
90
+ const recallMessage = async (e, messageId) => {
91
+ if (!messageId)
92
+ return;
93
+ try {
94
+ await e.bot?.recallMsg?.(messageId, channelOf(e));
95
+ }
96
+ catch (error) {
97
+ node_karin_1.logger.debug('[互动视频] 撤回消息失败: ' + String(error?.message ?? error));
98
+ }
99
+ };
63
100
  const platformOf = (e) => String(e?.bot?.bot?.platform ?? e?.bot?.platform ?? e?.platform ?? '');
64
101
  const channelOf = (e) => String(e?.contact?.peer ?? e?.guildId ?? '');
65
102
  /** 剧情会话 key(和 InteractiveVideo 里的口径一致) */
@@ -315,12 +352,20 @@ const runInteractiveStory = async (options) => {
315
352
  entry = { fromCid: currentCid, edgeId: choice.edgeId };
316
353
  currentCid = choice.cid;
317
354
  if (play) {
355
+ /**
356
+ * 用户要求:点一下选项,**上一段视频也要撤回**(群里只留当前这一段)。
357
+ *
358
+ * 顺序是「发新的 → 撤旧的」:先取走旧 ID(新的那条发出去时会被重新记上),
359
+ * 新的发完再撤旧的 —— 中间不会出现「一段视频都没有」的空档。
360
+ */
361
+ const previousVideo = takeStoryVideo(target);
318
362
  try {
319
363
  await play(currentCid, target);
320
364
  }
321
365
  catch (error) {
322
366
  node_karin_1.logger.warn('[互动视频] 播放下一段失败(cid=' + currentCid + '): ' + String(error?.message ?? error));
323
367
  }
368
+ await recallMessage(target, previousVideo);
324
369
  }
325
370
  node = { ...next, cid: currentCid };
326
371
  }
@@ -19,10 +19,98 @@ const DefaultLayout_1 = require("../../../components/DefaultLayout");
19
19
  const BILI_BLUE = '#00aeec';
20
20
  /** 顶层分支配色,和思维导图常见做法一致:一条主枝一个颜色 */
21
21
  const BRANCH_COLORS = ['#ff7a45', '#3fbf6f', '#22a6b3', '#a259ff', '#f0b429', '#5a8dee', '#e8608d', '#7f8c8d'];
22
+ /**
23
+ * 版面尺寸。
24
+ *
25
+ * **字要大**(用户要求):这张卡片是 1440px 宽的画布,到了手机聊天框里会被等比缩到
26
+ * 几百像素宽,图上文字的实际大小 = 字号 / 画布总宽 —— 所以「把字弄大」只能靠
27
+ * **字号占宽度的比例变大**:
28
+ * - 列宽不再跟着字号涨(涨了等于没变),只把字号提上去;
29
+ * - 每列能显示几个字 = 列宽 / 字号,字号大了就得少显示几个字,
30
+ * 标签改成**按像素宽度裁剪**(中文按 1 个字宽算),不让它压到下一列上去。
31
+ */
22
32
  const COL_WIDTH = 260;
23
- const ROW_HEIGHT = 62;
33
+ const ROW_HEIGHT = 88;
24
34
  const PAD_LEFT = 8;
25
- const PAD_TOP = 20;
35
+ const PAD_TOP = 24;
36
+ /** 节点文字的字号(选项 / 剧情节点) */
37
+ const CHOICE_FONT = 33;
38
+ const NODE_FONT = 34;
39
+ /** 一列文字最多能用到哪里:到下一列的圆圈之前就为止(别压到人家的圆上) */
40
+ const LABEL_WIDTH = COL_WIDTH - 45;
41
+ /**
42
+ * 一个字符大概占多少个字号宽:中文 / 全角算 1 个,空格 0.3,其余 0.5。
43
+ * (不用真的去量字体,够用就行 —— 目的是别让文字压到下一列的圆圈上。)
44
+ */
45
+ const charWidth = (char) => {
46
+ if (/[\u2e80-\ufffd\ufe30-\ufe4f]/.test(char))
47
+ return 1;
48
+ if (char === ' ')
49
+ return 0.3;
50
+ return 0.5;
51
+ };
52
+ /** 一段文字排出来大概多宽(像素) */
53
+ const lineWidth = (text, fontSize) => {
54
+ let total = 0;
55
+ for (const char of text)
56
+ total += charWidth(char) * fontSize;
57
+ return total;
58
+ };
59
+ /**
60
+ * 把标签排成**最多两行**,两行都装不下才截断加省略号。
61
+ *
62
+ * 以前是按「14 个字」裁的:中文一个字差不多一个字号宽,14 个字在 34px 字号下要 470 多个像素,
63
+ * 而列宽只有 260px —— 标签会直接压到下一列的圆圈上,字挤在一起更看不清。
64
+ *
65
+ * 为什么要折行而不是直接截断:字号调大以后一列只能放下六七个字,
66
+ * 直接砍会把「当前 · 第二题」这种关键信息切掉;折成两行既保住了字大,也保住了内容
67
+ * (行高已经在 ROW_HEIGHT 里留出来了)。
68
+ *
69
+ * @param label 原始文字
70
+ * @param fontSize 字号(越大能放的字越少)
71
+ * @param extra 额外可用宽度(最后一列右边没有东西,用它多占一点)
72
+ * @param suffix 后缀(「(默认)」这种)——**整块跟着最后一行,绝不从中间断开**
73
+ */
74
+ const wrapLabel = (label, fontSize, extra = 0, suffix = '') => {
75
+ const budget = LABEL_WIDTH + extra;
76
+ const lines = [];
77
+ let current = '';
78
+ let used = 0;
79
+ for (const char of label) {
80
+ const cost = charWidth(char) * fontSize;
81
+ if (current && used + cost > budget) {
82
+ lines.push(current);
83
+ current = '';
84
+ used = 0;
85
+ // 两行都满了:把第二行截断加省略号
86
+ if (lines.length >= 2) {
87
+ lines[1] = lines[1] + '…';
88
+ break;
89
+ }
90
+ }
91
+ current += char;
92
+ used += cost;
93
+ }
94
+ if (current) {
95
+ if (lines.length >= 2)
96
+ lines[1] = lines[1] + '…';
97
+ else
98
+ lines.push(current);
99
+ }
100
+ if (!lines.length)
101
+ lines.push('');
102
+ /** 后缀单独占一行也行(它很短),实在没地方了就跟在最后一行后面 */
103
+ if (suffix) {
104
+ const last = lines[lines.length - 1];
105
+ if (lineWidth(last + suffix, fontSize) <= budget)
106
+ lines[lines.length - 1] = last + suffix;
107
+ else if (lines.length < 2)
108
+ lines.push(suffix);
109
+ else
110
+ lines[lines.length - 1] = last + suffix;
111
+ }
112
+ return lines;
113
+ };
26
114
  /** 把剧情图摊平成「一行一项」的列表,并算好每个叶子占用的行号 */
27
115
  const flatten = (root, path, rootLabel) => {
28
116
  const items = [];
@@ -116,7 +204,7 @@ exports.BilibiliInteractive = react_1.default.memo((props) => {
116
204
  byId.set(item.id, item);
117
205
  const rowY = (row) => PAD_TOP + row * ROW_HEIGHT + ROW_HEIGHT / 2;
118
206
  const colX = (item) => PAD_LEFT + item.depth * COL_WIDTH;
119
- return ((0, jsx_runtime_1.jsx)(DefaultLayout_1.DefaultLayout, { ...props, children: (0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative', zIndex: 10, padding: '64px 56px 48px' }, children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: 24 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { borderRadius: 999, padding: '12px 28px', fontSize: 26, fontWeight: 900, letterSpacing: '0.18em', background: 'rgba(0,174,236,0.14)', color: BILI_BLUE }, children: "\u4E92\u52A8\u89C6\u9891" }), (0, jsx_runtime_1.jsxs)("span", { style: { fontSize: 26, fontWeight: 700, opacity: 0.42 }, children: ["\u7B2C ", data.step, " \u6BB5 \u00B7 \u5168\u90E8\u5267\u60C5\u6D41\u7A0B"] })] }), (0, jsx_runtime_1.jsx)("h1", { style: { margin: '28px 0 0', fontSize: 48, fontWeight: 900, lineHeight: 1.3, wordBreak: 'break-word' }, children: data.title }), path.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 18, fontSize: 27, fontWeight: 600, opacity: 0.5, wordBreak: 'break-word' }, children: ["\u5DF2\u9009\uFF1A", path.join(' → ')] })), flat && ((0, jsx_runtime_1.jsxs)("svg", { viewBox: '0 0 ' + width + ' ' + height, width: '100%', style: { marginTop: 26, display: 'block', overflow: 'visible' }, role: 'img', children: [flat.items.map((item) => {
207
+ return ((0, jsx_runtime_1.jsx)(DefaultLayout_1.DefaultLayout, { ...props, children: (0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative', zIndex: 10, padding: '64px 56px 48px' }, children: [(0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: 24 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { borderRadius: 999, padding: '12px 28px', fontSize: 26, fontWeight: 900, letterSpacing: '0.18em', background: 'rgba(0,174,236,0.14)', color: BILI_BLUE }, children: "\u4E92\u52A8\u89C6\u9891" }), (0, jsx_runtime_1.jsxs)("span", { style: { fontSize: 26, fontWeight: 700, opacity: 0.42 }, children: ["\u7B2C ", data.step, " \u6BB5 \u00B7 \u5168\u90E8\u5267\u60C5\u6D41\u7A0B"] })] }), (0, jsx_runtime_1.jsx)("h1", { style: { margin: '28px 0 0', fontSize: 48, fontWeight: 900, lineHeight: 1.3, wordBreak: 'break-word' }, children: data.title }), path.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 18, fontSize: 32, fontWeight: 600, opacity: 0.5, wordBreak: 'break-word' }, children: ["\u5DF2\u9009\uFF1A", path.join(' → ')] })), flat && ((0, jsx_runtime_1.jsxs)("svg", { viewBox: '0 0 ' + width + ' ' + height, width: '100%', style: { marginTop: 26, display: 'block', overflow: 'visible' }, role: 'img', children: [flat.items.map((item) => {
120
208
  if (!item.parent)
121
209
  return null;
122
210
  const parent = byId.get(item.parent);
@@ -131,7 +219,13 @@ exports.BilibiliInteractive = react_1.default.memo((props) => {
131
219
  const x = colX(item);
132
220
  const y = rowY(item.row);
133
221
  const isChoice = item.kind === 'choice';
134
- return ((0, jsx_runtime_1.jsxs)("g", { children: [(0, jsx_runtime_1.jsx)("circle", { cx: x, cy: y, r: isChoice ? 9 : 13, fill: item.walked || item.kind === 'root' ? item.color : '#ffffff', stroke: item.color, strokeWidth: 4 }), (0, jsx_runtime_1.jsxs)("text", { x: x + (isChoice ? 22 : 26), y: y + 9, fontSize: isChoice ? 27 : 29, fontWeight: item.walked || item.kind === 'root' ? 900 : 600, fill: item.walked ? item.color : 'currentColor', opacity: item.walked || item.kind === 'root' ? 1 : 0.82, children: [item.label.length > 14 ? item.label.slice(0, 14) + '…' : item.label, item.isDefault ? '(默认)' : ''] })] }, 'node-' + item.id));
135
- })] })), !flat && (data.choices ?? []).map((choice, index) => ((0, jsx_runtime_1.jsxs)("div", { style: { marginTop: index === 0 ? 30 : 18, fontSize: 36, fontWeight: 800 }, children: [choice.label, " ", choice.text, choice.isDefault ? '(默认)' : ''] }, choice.label + index))), (data.truncated || graph?.truncated) && ((0, jsx_runtime_1.jsx)("div", { style: { marginTop: 26, fontSize: 25, fontWeight: 600, opacity: 0.55 }, children: "\u5267\u60C5\u592A\u957F\uFF0C\u56FE\u4E0A\u53EA\u753B\u4E86\u524D\u534A\u90E8\u5206\u5206\u652F" })), (0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 22, display: 'flex', alignItems: 'center', gap: 14, fontSize: 25, fontWeight: 600, opacity: 0.52 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { display: 'inline-block', width: 26, height: 5, borderRadius: 999, background: BILI_BLUE } }), "\u9AD8\u4EAE\u7684\u662F\u4F60\u5DF2\u7ECF\u8D70\u8FC7\u7684\u8DEF"] }), data.notice && ((0, jsx_runtime_1.jsx)("div", { style: { marginTop: 18, borderRadius: 20, padding: '16px 28px', fontSize: 25, fontWeight: 600, opacity: 0.62, background: 'rgba(127,127,127,0.12)' }, children: data.notice }))] }) }));
222
+ const font = isChoice ? CHOICE_FONT : NODE_FONT;
223
+ const textX = x + (isChoice ? 22 : 26);
224
+ /** 「(默认)」也算进这一列的可视宽度里,免得它把第二行挤出去 */
225
+ const lines = wrapLabel(item.label, font, item.depth >= depth ? 56 : 0, item.isDefault ? '(默认)' : '');
226
+ /** 一行时基线和以前一样;两行时整块往上挪一点,让两行都贴着圆圈居中 */
227
+ const baseline = lines.length > 1 ? y - font * 0.15 : y + font * 0.35;
228
+ return ((0, jsx_runtime_1.jsxs)("g", { children: [(0, jsx_runtime_1.jsx)("circle", { cx: x, cy: y, r: isChoice ? 9 : 13, fill: item.walked || item.kind === 'root' ? item.color : '#ffffff', stroke: item.color, strokeWidth: 4 }), (0, jsx_runtime_1.jsx)("text", { x: textX, y: baseline, fontSize: font, fontWeight: item.walked || item.kind === 'root' ? 900 : 600, fill: item.walked ? item.color : 'currentColor', opacity: item.walked || item.kind === 'root' ? 1 : 0.82, children: lines.map((line, index) => ((0, jsx_runtime_1.jsx)("tspan", { x: textX, dy: index === 0 ? 0 : Math.round(font * 1.05), children: line }, 'line-' + index))) })] }, 'node-' + item.id));
229
+ })] })), !flat && (data.choices ?? []).map((choice, index) => ((0, jsx_runtime_1.jsxs)("div", { style: { marginTop: index === 0 ? 30 : 18, fontSize: 36, fontWeight: 800 }, children: [choice.label, " ", choice.text, choice.isDefault ? '(默认)' : ''] }, choice.label + index))), (data.truncated || graph?.truncated) && ((0, jsx_runtime_1.jsx)("div", { style: { marginTop: 26, fontSize: 30, fontWeight: 600, opacity: 0.55 }, children: "\u5267\u60C5\u592A\u957F\uFF0C\u56FE\u4E0A\u53EA\u753B\u4E86\u524D\u534A\u90E8\u5206\u5206\u652F" })), (0, jsx_runtime_1.jsxs)("div", { style: { marginTop: 22, display: 'flex', alignItems: 'center', gap: 14, fontSize: 30, fontWeight: 600, opacity: 0.52 }, children: [(0, jsx_runtime_1.jsx)("span", { style: { display: 'inline-block', width: 30, height: 6, borderRadius: 999, background: BILI_BLUE } }), "\u9AD8\u4EAE\u7684\u662F\u4F60\u5DF2\u7ECF\u8D70\u8FC7\u7684\u8DEF"] }), data.notice && ((0, jsx_runtime_1.jsx)("div", { style: { marginTop: 18, borderRadius: 20, padding: '16px 28px', fontSize: 30, fontWeight: 600, opacity: 0.62, background: 'rgba(127,127,127,0.12)' }, children: data.notice }))] }) }));
136
230
  });
137
231
  exports.BilibiliInteractive.displayName = 'BilibiliInteractive';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 临时探针:把剧情图用一个**很长的题目**渲染一遍,确认标签会折成两行、内容不丢。
3
+ */
4
+ const path = require('node:path')
5
+ const React = require('react')
6
+ const { renderToStaticMarkup } = require('react-dom/server')
7
+ const pluginRoot = path.resolve(__dirname, '..')
8
+ const runtime = require(path.join(pluginRoot, 'lib/compat/runtime.js'))
9
+ const noop = () => {}
10
+ runtime.bindRuntime({ ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), config: {}, pluginRoot, dataRoot: require('node:os').tmpdir() } })
11
+ const template = require(path.join(pluginRoot, 'lib/ktr/template/bilibili/interactive/index.js'))
12
+ const Root = require(path.join(pluginRoot, 'lib/karin/root.js')).Root
13
+ const data = {
14
+ title: '测试:很长的题目',
15
+ step: 1,
16
+ path: ['向左走'],
17
+ graph: {
18
+ question: '开场',
19
+ choices: [
20
+ { label: 'A', text: '向左走', isDefault: true, children: [{ question: '你要不要现在就跟着她一起上楼去看看那个房间', isLeaf: false, choices: [] }] },
21
+ { label: 'B', text: '向右挖', children: [{ question: '结局二', isLeaf: true, choices: [] }] }
22
+ ]
23
+ }
24
+ }
25
+ const ctx = { theme: 'light', scale: 1, version: { plugin: 'koishi-plugin', pluginName: 'kkk', pluginVersion: Root.pluginVersion, releaseType: 'Stable', poweredBy: 'Koishi', frameworkVersion: Root.karinVersion } }
26
+ const def = template.default ?? template
27
+ const html = renderToStaticMarkup(React.createElement(def.component, { data, ctx }))
28
+ const tspans = html.match(/<tspan[^>]*>[^<]*<\/tspan>/g) || []
29
+ console.log('tspan 数量:', tspans.length)
30
+ console.log('折行的标签:')
31
+ for (const item of tspans.slice(0, 12)) console.log(' ', item.replace(/<[^>]+>/g, ' | '))
32
+ console.log('(默认)完整出现:', html.includes('(默认)'))
33
+ console.log('长题目开头出现:', html.includes('你要不要现在就'))
@@ -145,6 +145,33 @@ const main = async () => {
145
145
  check('结局时撤回了上一条选项消息', state.recalled.length >= 1, JSON.stringify(state.recalled))
146
146
  check('结局有提示', state.replies.some((item) => String(item).includes('结局')), JSON.stringify(state.replies).slice(0, 200))
147
147
 
148
+ console.log('== 点一下选项:上一段视频也要撤回(用户要求)==')
149
+ {
150
+ const { e, state } = makeEvent('qqguild')
151
+ /** 解析链路发第一段视频时记下的消息 ID */
152
+ story.rememberStoryVideo(e, 'vid-root')
153
+ const playedVideo = []
154
+ const r = await runInteractiveStory({
155
+ e,
156
+ bvid: 'BV1xDgL6SEzk',
157
+ graphVersion: 1722536,
158
+ rootCid: 41156151510,
159
+ title: '史蒂夫的选择',
160
+ request: requestFor({ root: rootPayload, 11: leafPayload, 12: middlePayload }),
161
+ wait: async () => 'A',
162
+ play: async (cid) => {
163
+ playedVideo.push(cid)
164
+ /** 模拟解析链路:新一段视频发出去后把它的消息 ID 记进来 */
165
+ story.rememberStoryVideo(e, 'vid-' + cid)
166
+ }
167
+ })
168
+ check('选完播了下一段', playedVideo.join(',') === '101', playedVideo.join(','))
169
+ check('上一段视频被撤回了(群里只留当前这一段)', state.recalled.includes('vid-root'), JSON.stringify(state.recalled))
170
+ check('刚发出去的那一段没有被误撤', !state.recalled.includes('vid-101'), JSON.stringify(state.recalled))
171
+ check('同一条只撤一次(取走即清)', state.recalled.filter((id) => id === 'vid-root').length === 1, JSON.stringify(state.recalled))
172
+ check('剧情照常走完', r.ended === 'leaf', r.ended)
173
+ }
174
+
148
175
  console.log('== 结局那一段也要给上层画图的机会 ==')
149
176
  const endingHook = makeEvent('qqguild')
150
177
  const hookCalls = []
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 冒烟测试:**图片统一改走 markdown**(用户要求)。
3
+ *
4
+ * 背景(用户反馈):
5
+ * - 「所有的图片发送都要走 md,不然太模糊了」—— QQ 的普通图片消息会被压糊,md 图片不会;
6
+ * - 「![#px大小#px大小]() 必须要 #px,不然手机不会显示」—— md 图片必须写死尺寸。
7
+ * 所以 compat 的发送出口会把 image 段改写成 `![#宽px #高px](https://…)`(见 compat/imageMarkdown.ts)。
8
+ *
9
+ * 这里验四件事:
10
+ * 1. 尺寸真的读出来了(PNG / JPEG 两种头),并且写进了 md;
11
+ * 2. **只在认 markdown 的平台上改**(qq 改;onebot / qqguild / 未知平台一律不动);
12
+ * 3. 该跳过的都跳过(消息里有视频/语音/文件时不能改,否则 md 内容会被适配器丢掉);
13
+ * 4. 改不动就**原样发**(上传失败、没有 assets 服务),绝不把图弄丢;
14
+ * 5. 走的是真出口:`Message.reply` / `KkkBot.sendMsg` 里就要已经改好了。
15
+ *
16
+ * 用法:node scripts/smoke-md-image.cjs
17
+ */
18
+ const fs = require('node:fs')
19
+ const os = require('node:os')
20
+ const path = require('node:path')
21
+
22
+ const pluginRoot = path.resolve(__dirname, '..')
23
+
24
+ let failures = 0
25
+ const check = (name, ok, detail) => {
26
+ console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
27
+ if (!ok) failures++
28
+ }
29
+
30
+ const runtime = require(path.join(pluginRoot, 'lib/compat/runtime.js'))
31
+ const segment = require(path.join(pluginRoot, 'lib/compat/segment.js')).segment
32
+ const mdImage = require(path.join(pluginRoot, 'lib/compat/imageMarkdown.js'))
33
+ const nodeKarin = require(path.join(pluginRoot, 'lib/compat/node-karin.js'))
34
+
35
+ /** 一个「够用」的 PNG:只补到 readImageSize 要读的字段(IHDR 的宽高) */
36
+ const fakePng = (width, height) => {
37
+ const buffer = Buffer.alloc(32)
38
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0)
39
+ buffer.writeUInt32BE(13, 8)
40
+ buffer.write('IHDR', 12, 'latin1')
41
+ buffer.writeUInt32BE(width, 16)
42
+ buffer.writeUInt32BE(height, 20)
43
+ return buffer
44
+ }
45
+
46
+ /** 一个「够用」的 JPEG:SOF0 段里有高宽即可 */
47
+ const fakeJpeg = (width, height) => {
48
+ const buffer = Buffer.alloc(20)
49
+ buffer[0] = 0xff
50
+ buffer[1] = 0xd8
51
+ buffer[2] = 0xff
52
+ buffer[3] = 0xc0
53
+ buffer.writeUInt16BE(17, 4)
54
+ buffer[6] = 0x08
55
+ buffer.writeUInt16BE(height, 7)
56
+ buffer.writeUInt16BE(width, 9)
57
+ return buffer
58
+ }
59
+
60
+ const dataUri = (buffer) => 'data:image/png;base64,' + buffer.toString('base64')
61
+
62
+ let uploads = 0
63
+ let uploadShouldFail = false
64
+ const noop = () => {}
65
+ const ctx = {
66
+ get: () => undefined,
67
+ logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }),
68
+ assets: {
69
+ upload: async (dataUriText, name) => {
70
+ uploads++
71
+ if (uploadShouldFail) throw new Error('assets 挂了')
72
+ return 'https://assets.example.com/' + String(name)
73
+ }
74
+ }
75
+ }
76
+ runtime.bindRuntime({ ctx, config: {}, pluginRoot, dataRoot: os.tmpdir() })
77
+
78
+ const textOf = (element) => {
79
+ if (!element) return ''
80
+ if (element.type === 'text') return element.attrs?.content ?? ''
81
+ return (element.children ?? []).map(textOf).join('')
82
+ }
83
+
84
+ const run = async () => {
85
+ console.log('— 尺寸读得出来 —')
86
+ mdImage.clearMarkdownImageCache()
87
+ uploads = 0
88
+ const pngMd = await mdImage.markdownImageOf(dataUri(fakePng(600, 400)))
89
+ check('PNG 宽高写进了 md', pngMd === '![#600px #400px](https://assets.example.com/kkk-md.png)', pngMd)
90
+ check('PNG 按 image/png 上传(扩展名跟着走)', String(pngMd).endsWith('.png)'), pngMd)
91
+
92
+ const jpegMd = await mdImage.markdownImageOf(dataUri(fakeJpeg(2880, 1440)))
93
+ check('JPEG 宽高写进了 md', jpegMd === '![#2880px #1440px](https://assets.example.com/kkk-md.jpg)', jpegMd)
94
+
95
+ const base64Md = await mdImage.markdownImageOf('base64://' + fakePng(12, 34).toString('base64'))
96
+ check('base64:// 也认', base64Md === '![#12px #34px](https://assets.example.com/kkk-md.png)', base64Md)
97
+
98
+ const file = path.join(os.tmpdir(), 'kkk-md-smoke-' + Date.now() + '.png')
99
+ fs.writeFileSync(file, fakePng(8, 9))
100
+ const fileMd = await mdImage.markdownImageOf(file)
101
+ check('本地路径也认', fileMd === '![#8px #9px](https://assets.example.com/kkk-md.png)', fileMd)
102
+ fs.rmSync(file, { force: true })
103
+
104
+ console.log('— 同一张图不重复上传 —')
105
+ mdImage.clearMarkdownImageCache()
106
+ uploads = 0
107
+ const again = dataUri(fakePng(100, 50))
108
+ await mdImage.markdownImageOf(again)
109
+ await mdImage.markdownImageOf(again)
110
+ check('第二次走缓存(只上传一次)', uploads === 1, 'uploads=' + uploads)
111
+
112
+ console.log('— 只改认 markdown 的平台 —')
113
+ const image = () => segment.image(dataUri(fakePng(600, 400)))
114
+ for (const platform of ['onebot', 'napcat', 'lagrange', 'go-cqhttp', 'chronocat', 'mirai', 'qqguild', 'sandbox', '']) {
115
+ const list = [image()]
116
+ const out = await mdImage.imagesToMarkdown(list, platform)
117
+ check(platform + ' 平台保持图片段', out.length === 1 && out[0] === list[0] && out[0].type === 'img')
118
+ }
119
+ const qqOut = await mdImage.imagesToMarkdown([image()], 'qq')
120
+ check('qq 平台改成 markdown', qqOut.length === 1 && qqOut[0].type === 'markdown', qqOut[0]?.type)
121
+ check('qq 平台的 md 里带 #宽px #高px', /!\[#600px #400px\]\(https:\/\//.test(textOf(qqOut[0])), textOf(qqOut[0]))
122
+
123
+ console.log('— 该跳过的都跳过 —')
124
+ const withVideo = [image(), segment.video('data:video/mp4;base64,AAAA')]
125
+ const videoOut = await mdImage.imagesToMarkdown(withVideo, 'qq')
126
+ check('带视频的消息不动(md 会被适配器丢掉)', videoOut[0].type === 'img' && videoOut[1].type === 'video')
127
+ const withAudio = [image(), segment.audio('data:audio/mpeg;base64,AAAA')]
128
+ check('带语音的消息不动', (await mdImage.imagesToMarkdown(withAudio, 'qq'))[0].type === 'img')
129
+ const noImage = [segment.text('只有文字')]
130
+ const noImageOut = await mdImage.imagesToMarkdown(noImage, 'qq')
131
+ check('没有图片就不进这套逻辑', noImageOut[0] === noImage[0])
132
+
133
+ console.log('— 顺序与合并 —')
134
+ const mixed = [segment.text('看图:'), image(), segment.text('就这些')]
135
+ const mixedOut = await mdImage.imagesToMarkdown(mixed, 'qq')
136
+ check('文字 / md 图片 / 文字 顺序不变',
137
+ mixedOut.length === 3 && mixedOut[0].type === 'text' && mixedOut[1].type === 'markdown' && mixedOut[2].type === 'text',
138
+ mixedOut.map((element) => element.type).join(','))
139
+ const twice = await mdImage.imagesToMarkdown([image(), image(), segment.text('尾')], 'qq')
140
+ check('连续图片合成一条 markdown(两行)',
141
+ twice.length === 2 && twice[0].type === 'markdown' && String(textOf(twice[0])).split(String.fromCharCode(10)).length === 2,
142
+ twice.map((element) => element.type).join(','))
143
+
144
+ console.log('— 改不动就原样发,绝不丢图 —')
145
+ uploadShouldFail = true
146
+ mdImage.clearMarkdownImageCache()
147
+ const failedList = [image()]
148
+ const failedOut = await mdImage.imagesToMarkdown(failedList, 'qq')
149
+ check('上传失败时退回图片段', failedOut.length === 1 && failedOut[0] === failedList[0] && failedOut[0].type === 'img')
150
+ uploadShouldFail = false
151
+
152
+ const savedCtx = ctx.assets
153
+ delete ctx.assets
154
+ mdImage.clearMarkdownImageCache()
155
+ const noAssetsList = [image()]
156
+ const noAssetsOut = await mdImage.imagesToMarkdown(noAssetsList, 'qq')
157
+ check('没有 assets 服务时退回图片段', noAssetsOut[0] === noAssetsList[0] && noAssetsOut[0].type === 'img')
158
+ ctx.assets = savedCtx
159
+
160
+ console.log('— 读不出尺寸的图,宁可发普通图片 —')
161
+ mdImage.clearMarkdownImageCache()
162
+ const unknown = Buffer.alloc(64, 0x42)
163
+ const unknownList = [segment.image('data:application/octet-stream;base64,' + unknown.toString('base64'))]
164
+ const unknownOut = await mdImage.imagesToMarkdown(unknownList, 'qq')
165
+ check('认不出格式的图保持图片段(不带尺寸的 md 手机上不显示)',
166
+ unknownOut.length === 1 && unknownOut[0] === unknownList[0] && unknownOut[0].type === 'img')
167
+
168
+ console.log('— 真出口上就已经改好了(Message.reply / KkkBot.sendMsg) —')
169
+ const sent = []
170
+ const makeBot = (platform) => new nodeKarin.KkkBot({
171
+ platform,
172
+ selfId: '1',
173
+ sendMessage: async (channelId, elements) => {
174
+ sent.push({ channelId, elements })
175
+ return ['mid-1']
176
+ }
177
+ })
178
+
179
+ const qqMessage = new nodeKarin.Message({ bot: makeBot('qq'), contact: { peer: 'group:1' } })
180
+ await qqMessage.reply([segment.image(dataUri(fakePng(320, 240)))])
181
+ check('Message.reply 发出去的是 markdown',
182
+ sent.length === 1 && sent[0].elements.length === 1 && sent[0].elements[0].type === 'markdown',
183
+ sent[0]?.elements?.map((element) => element.type).join(','))
184
+ check('Message.reply 的 md 带尺寸', /!\[#320px #240px\]/.test(textOf(sent[0].elements[0])), textOf(sent[0].elements[0]))
185
+
186
+ sent.length = 0
187
+ const onebotBot = makeBot('onebot')
188
+ await onebotBot.sendMsg('group:1', [segment.image(dataUri(fakePng(320, 240)))])
189
+ check('OneBot 的 KkkBot.sendMsg 保持图片段',
190
+ sent.length === 1 && sent[0].elements[0].type === 'img', sent[0]?.elements?.[0]?.type)
191
+
192
+ sent.length = 0
193
+ const qqBot = makeBot('qq')
194
+ await qqBot.sendMsg('group:1', [segment.image(dataUri(fakePng(64, 64)))])
195
+ check('qq 的 KkkBot.sendMsg 也改成了 markdown',
196
+ sent[0]?.elements?.[0]?.type === 'markdown', sent[0]?.elements?.[0]?.type)
197
+
198
+ console.log('')
199
+ console.log(failures === 0 ? '全部通过' : failures + ' 项失败')
200
+ process.exit(failures === 0 ? 0 : 1)
201
+ }
202
+
203
+ run().catch((error) => {
204
+ console.error(error)
205
+ process.exit(1)
206
+ })