koishi-plugin-kkk 3.7.1 → 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.
@@ -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
+ })
@@ -0,0 +1,263 @@
1
+ /**
2
+ * 图片段 → markdown 图片(`![#宽px #高px](url)`)。
3
+ *
4
+ * 用户反馈两条,这里一起解决:
5
+ * 1. **「所有的图片发送都要走 md,不然太模糊了」** —— QQ 的普通图片消息(image 段 → 适配器按
6
+ * MEDIA 上传)会被客户端二次压缩,卡片上的小字糊成一团;markdown 里的图片不会被压,字是清楚的。
7
+ * 2. **「![#px大小#px大小]() 必须要 #px,不然手机不会显示」** —— md 图片必须写死尺寸,
8
+ * 不写尺寸手机端干脆不显示这张图。
9
+ *
10
+ * 所以这里在**发送出口**统一改写:compat/node-karin 的 reply / sendMsg / sendMaster 四条路径都过一遍,
11
+ * 业务代码照旧 e.reply(segment.image(...)),不用一处处改。
12
+ *
13
+ * 只在「认 markdown 的平台」上生效(官方 QQ:`qq`):
14
+ * - OneBot 系(NapCat / Lagrange / go-cqhttp / Chronocat…)收到 markdown 只显示成一串文字、
15
+ * 图片全丢(用户实测),所以那条链路继续发图片段;
16
+ * - QQ 频道(`qqguild`)的适配器没有 markdown 分支,md 元素会被当纯文本渲染 → 也排除。
17
+ *
18
+ * 另外三种情况**不动**(动了只会更糟):
19
+ * - 消息里还有视频 / 语音 / 文件:适配器遇到附件会把整条消息转成 MEDIA,markdown 内容会被丢掉;
20
+ * - 合并转发收集中的消息:转发节点里塞 markdown 客户端不认;
21
+ * - 上传拿不到公网地址(没装 assets 服务等)、或者**读不出图片尺寸**:保持原来的图片段。
22
+ * 读不出尺寸是可以退回普通图片的,但**绝不能发一张不带尺寸的 markdown 图片** ——
23
+ * 用户实测「必须要 #px,不然手机不会显示」,那种图上手机就是一片空白。
24
+ */
25
+ import crypto from 'node:crypto'
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ import { logger } from './logger'
31
+ import { tryGetRuntime } from './runtime'
32
+ import { segment } from './segment'
33
+
34
+ /** OneBot 系:不渲染 markdown(见文件头) */
35
+ const ONEBOT_LIKE = /onebot|napcat|lagrange|go-?cqhttp|chronocat|mirai/i
36
+
37
+ /**
38
+ * 会渲染 markdown 图片的平台。
39
+ *
40
+ * 只认 `qq` 这一个平台名 —— QQ 的 markdown 是**官方机器人**能力,
41
+ * `qqguild`(频道)走的是另一套编码器,md 元素在那里会被当成纯文本原样发出去。
42
+ */
43
+ const canUseMarkdownImage = (platform: string): boolean => {
44
+ const name = String(platform || '').toLowerCase()
45
+ if (!name) return false
46
+ if (ONEBOT_LIKE.test(name)) return false
47
+ if (name.startsWith('qqguild')) return false
48
+ return name === 'qq' || name.startsWith('qq-') || name.startsWith('qqbot')
49
+ }
50
+
51
+ /** 媒体段:md 和附件不能塞进同一条消息(会被适配器吃掉),所以要跳过整条改写 */
52
+ const ATTACHMENT_TYPES = new Set(['video', 'audio', 'record', 'file'])
53
+
54
+ const isImageElement = (element: any): boolean =>
55
+ !!element && typeof element === 'object' && (element.type === 'img' || element.type === 'image')
56
+
57
+ const srcOf = (element: any): string => {
58
+ const attrs = element?.attrs ?? {}
59
+ const src = attrs.src ?? attrs.url ?? element?.data?.file ?? element?.data?.url
60
+ return typeof src === 'string' ? src : ''
61
+ }
62
+
63
+ /** 按二进制头认 mime(发给 assets 用;标错会让适配器按错格式处理) */
64
+ const mimeOfBuffer = (buffer: Buffer): string => {
65
+ if (buffer.length >= 8 && buffer[0] === 0x89 && buffer.toString('latin1', 1, 4) === 'PNG') return 'image/png'
66
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg'
67
+ if (buffer.length >= 6 && buffer.toString('latin1', 0, 3) === 'GIF') return 'image/gif'
68
+ if (buffer.length >= 12 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
69
+ return 'image/jpeg'
70
+ }
71
+
72
+ /**
73
+ * 读出图片的**真实像素尺寸** —— md 里的 `#宽px #高px` 就靠它。
74
+ *
75
+ * 不引第三方库:卡片是 JPEG、图集是 JPEG、二维码是 PNG、实况图封面是 WebP,四种头都自己认,
76
+ * 免得又出现「取不到尺寸 → 正方形拉伸」或「没写尺寸 → 手机不显示」。
77
+ */
78
+ const readImageSize = (buffer: Buffer): { width: number; height: number } => {
79
+ // PNG:IHDR 就在固定偏移
80
+ if (buffer.length >= 24 && buffer[0] === 0x89 && buffer.toString('latin1', 1, 4) === 'PNG') {
81
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
82
+ }
83
+ // GIF:逻辑屏幕描述符,小端
84
+ if (buffer.length >= 10 && buffer.toString('latin1', 0, 3) === 'GIF') {
85
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
86
+ }
87
+ // JPEG:扫到 SOF 段(SOF0..SOF15,跳过 DHT/DAC 这些非 SOF 标记)
88
+ if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
89
+ let offset = 2
90
+ while (offset + 9 < buffer.length) {
91
+ if (buffer[offset] !== 0xff) { offset++; continue }
92
+ const marker = buffer[offset + 1]
93
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
94
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
95
+ }
96
+ const length = buffer.readUInt16BE(offset + 2)
97
+ if (length <= 0) break
98
+ offset += 2 + length
99
+ }
100
+ }
101
+ // WebP:VP8X(扩展)/ VP8L(无损)/ VP8(有损)三种头的写法都不一样
102
+ if (buffer.length >= 30 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') {
103
+ const format = buffer.toString('latin1', 12, 16)
104
+ if (format === 'VP8X') {
105
+ return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) }
106
+ }
107
+ if (format === 'VP8L') {
108
+ const bits = buffer.readUInt32LE(21)
109
+ return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }
110
+ }
111
+ if (format === 'VP8 ') {
112
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
113
+ }
114
+ }
115
+ return { width: 0, height: 0 }
116
+ }
117
+
118
+ /** 把各种写法读成 Buffer:data URI / base64:// / file:// / 本地路径 / http(s) */
119
+ const loadImageBuffer = async (src: string): Promise<Buffer | null> => {
120
+ try {
121
+ if (src.startsWith('base64://')) return Buffer.from(src.slice('base64://'.length), 'base64')
122
+ if (src.startsWith('data:')) {
123
+ const comma = src.indexOf(',')
124
+ if (comma < 0) return null
125
+ return Buffer.from(src.slice(comma + 1), 'base64')
126
+ }
127
+ const localPath = src.startsWith('file://') ? fileURLToPath(src) : src
128
+ if (!/^https?:\/\//i.test(src)) {
129
+ if (fs.existsSync(localPath)) return fs.readFileSync(localPath)
130
+ return null
131
+ }
132
+ const response = await fetch(src)
133
+ if (!response.ok) {
134
+ logger.mark('[图片md] 下载失败 HTTP ' + response.status + ': ' + src.slice(0, 80))
135
+ return null
136
+ }
137
+ return Buffer.from(await response.arrayBuffer())
138
+ } catch (error: any) {
139
+ logger.mark('[图片md] 读取图片失败: ' + String(error?.message ?? error).slice(0, 120))
140
+ return null
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 上传到宿主的 assets,拿到 QQ 能直接取的 https 地址。
146
+ *
147
+ * md 里的图片地址**必须是公网 http(s)**:适配器只对 data: 图片做特殊处理
148
+ * (见 qq-crack 的 renderMarkdownDataImages —— data 图片会被它转回 MEDIA 上传,等于白改),
149
+ * 相对路径/本地路径手机端也取不到。
150
+ */
151
+ let warnedNoAssets = false
152
+
153
+ const uploadImage = async (buffer: Buffer): Promise<string | null> => {
154
+ const assets: any = (tryGetRuntime() as any)?.ctx?.assets
155
+ if (!assets?.upload) {
156
+ /** 没装 assets 服务就整条链路都用不了,提示一次就够,别每张图刷一遍日志 */
157
+ if (!warnedNoAssets) {
158
+ warnedNoAssets = true
159
+ logger.mark('[图片md] 宿主没有 assets 服务(拿不到公网图片地址),图片继续按普通图片段发送')
160
+ }
161
+ return null
162
+ }
163
+ const mime = mimeOfBuffer(buffer)
164
+ const ext = mime === 'image/png' ? '.png' : mime === 'image/gif' ? '.gif' : mime === 'image/webp' ? '.webp' : '.jpg'
165
+ try {
166
+ const uploaded: any = await assets.upload('data:' + mime + ';base64,' + buffer.toString('base64'), 'kkk-md' + ext)
167
+ const url = typeof uploaded === 'string' ? uploaded : uploaded?.url
168
+ return url && /^https?:\/\//i.test(String(url)) ? String(url) : null
169
+ } catch (error: any) {
170
+ logger.mark('[图片md] 上传失败: ' + String(error?.message ?? error).slice(0, 120))
171
+ return null
172
+ }
173
+ }
174
+
175
+ /**
176
+ * 同一张图反复发(推送里的封面、面板卡片…)不用反复上传:
177
+ * 按图片内容摘要缓存结果,顺带省掉重复的下载与解码。
178
+ */
179
+ const imageCache = new Map<string, string | null>()
180
+ const IMAGE_CACHE_MAX = 200
181
+
182
+ /** 图片段 → md 图片字符串;拿不到尺寸/地址就返回 null(调用方保持原样,别把图弄丢) */
183
+ export const markdownImageOf = async (src: string): Promise<string | null> => {
184
+ if (!src) return null
185
+ const key = crypto.createHash('md5').update(src).digest('hex')
186
+ const cached = imageCache.get(key)
187
+ if (cached) return cached
188
+ const buffer = await loadImageBuffer(src)
189
+ let result: string | null = null
190
+ if (buffer && buffer.length) {
191
+ const { width, height } = readImageSize(buffer)
192
+ /**
193
+ * **读不出尺寸就不换成 markdown**(退回普通图片段)。
194
+ *
195
+ * 用户实测:「必须要 #px,不然手机不会显示」—— 不带尺寸的 md 图片在手机端干脆是空白,
196
+ * 比糊更糟。所以这里宁可发一张(会被压糊但看得见的)普通图片,
197
+ * 也不发一张手机上什么都不显示的 markdown 图片。
198
+ */
199
+ if (width > 0 && height > 0) {
200
+ const url = await uploadImage(buffer)
201
+ if (url) result = '![#' + width + 'px #' + height + 'px](' + url + ')'
202
+ }
203
+ }
204
+ /**
205
+ * **只缓存成功的结果**:网络 / 图床抖动导致的失败要是也缓存下来,
206
+ * 这张图之后就一直按普通图片发(糊),得等重启才能恢复。
207
+ */
208
+ if (result) {
209
+ if (imageCache.size >= IMAGE_CACHE_MAX) imageCache.delete(imageCache.keys().next().value as string)
210
+ imageCache.set(key, result)
211
+ } else {
212
+ logger.debug('[图片md] 这张图这次没转成 markdown,按普通图片发送: ' + src.slice(0, 60))
213
+ }
214
+ return result
215
+ }
216
+
217
+ /** 清空缓存(测试用) */
218
+ export const clearMarkdownImageCache = (): void => { imageCache.clear() }
219
+
220
+ /**
221
+ * 把一条消息里的图片段改写成 markdown 图片(其它段原样保留、顺序不变)。
222
+ *
223
+ * 连续的图片合成**一个** markdown 元素(换行分隔)—— 和官方 QQ 的渲染习惯一致:
224
+ * 连续图片紧贴显示,视觉上仍是一整张卡片,而不是一张一张孤立的图。
225
+ *
226
+ * @param elements 已归一化的消息元素数组
227
+ * @param platform 适配器平台名(kompat 层从 bot.platform / session.platform 取)
228
+ * @returns 改写后的数组;不该改或改不动时**原样返回**,绝不让消息发不出去
229
+ */
230
+ export const imagesToMarkdown = async (elements: any[], platform: string): Promise<any[]> => {
231
+ const list = Array.isArray(elements) ? elements : []
232
+ if (!list.length) return list
233
+ if (!canUseMarkdownImage(platform)) return list
234
+ if (!list.some(isImageElement)) return list
235
+ if (list.some((element) => ATTACHMENT_TYPES.has(String(element?.type ?? '')))) return list
236
+
237
+ const out: any[] = []
238
+ /** 攒着的连续图片(遇到非图片段就冲出去) */
239
+ let run: string[] = []
240
+ const flushRun = () => {
241
+ // 有图转不动(上传失败等)时 run 里会是空串占位,这里只冲有内容的
242
+ if (run.length) out.push(segment.markdown(run.join(String.fromCharCode(10))))
243
+ run = []
244
+ }
245
+
246
+ for (const element of list) {
247
+ if (!isImageElement(element)) {
248
+ flushRun()
249
+ out.push(element)
250
+ continue
251
+ }
252
+ const markdown = await markdownImageOf(srcOf(element))
253
+ if (markdown) {
254
+ run.push(markdown)
255
+ } else {
256
+ // 转不了就把它自己按图片段发出去,别丢
257
+ flushRun()
258
+ out.push(element)
259
+ }
260
+ }
261
+ flushRun()
262
+ return out
263
+ }
@@ -20,6 +20,7 @@ import { isSemverGreater } from '../karin/module/utils/semver'
20
20
  import { resolveAdapterInfo } from './adapter-info'
21
21
  import { logger } from './logger'
22
22
  import { COLLECTED_MESSAGE_ID, collectForward } from './forward-collect'
23
+ import { imagesToMarkdown } from './imageMarkdown'
23
24
  import { UnconfirmedSendError, classifySendFailure, decorateSendError, describeSendFailure, isPassiveLimitFailure } from './sendError'
24
25
  import { commandQueue, eventQueue, getRuntime, karinPathBase, taskQueue, tryGetRuntime } from './runtime'
25
26
  import { segment } from './segment'
@@ -250,13 +251,15 @@ export class KkkBot {
250
251
  }
251
252
 
252
253
  // 退化路径:整条发一次;失败再逐个元素发,尽量把内容送出去
254
+ /** 退化发送 = 直接发消息,所以图片同样要改走 markdown(见 compat/imageMarkdown) */
255
+ const fallback = await imagesToMarkdown(payload.elements, platformOfBot(this.bot))
253
256
  try {
254
- const ids = await this.bot.sendMessage(channelId, payload.elements as any)
257
+ const ids = await this.bot.sendMessage(channelId, fallback as any)
255
258
  return { messageId: ids[ids.length - 1] ?? '' }
256
259
  } catch (error) {
257
260
  logger.warn('整条发送失败,改为逐个元素发送: ' + String((error as any)?.message ?? error))
258
261
  let lastId = ''
259
- for (const item of payload.elements) {
262
+ for (const item of fallback) {
260
263
  const ids = await this.bot.sendMessage(channelId, [item] as any)
261
264
  lastId = ids[ids.length - 1] ?? lastId
262
265
  }
@@ -351,10 +354,13 @@ export class KkkBot {
351
354
 
352
355
  /** 发送消息(karin 的 bot.sendMsg) */
353
356
  async sendMsg (contact: Contact | string, content: any, _options?: any): Promise<{ messageId: string }> {
357
+ const elements = normalizeContent(content)
354
358
  /** 解析结果合并转发:正在收集就攒起来(见 compat/forward-collect) */
355
- if (collectForward(peerOf(contact), content)) return { messageId: COLLECTED_MESSAGE_ID }
359
+ if (collectForward(peerOf(contact), elements)) return { messageId: COLLECTED_MESSAGE_ID }
356
360
  const channelId = typeof contact === 'string' ? contact : contact.peer
357
- const ids = await this.bot.sendMessage(channelId, normalizeContent(content) as any)
361
+ /** 图片统一改走 markdown(见 compat/imageMarkdown) */
362
+ const outgoing = await imagesToMarkdown(elements, platformOfBot(this.bot))
363
+ const ids = await this.bot.sendMessage(channelId, outgoing as any)
358
364
  // 同 reply/uploadFile:**没拿到消息 ID 就是没发出去**,别当成功返回
359
365
  const id = ids?.[ids.length - 1] ?? ''
360
366
  if (!id) {
@@ -480,13 +486,22 @@ export class Message {
480
486
  * 这时自动改用**主动消息**(不带引用,直接往频道里发),失败才把原错误抛出去。
481
487
  */
482
488
  async reply (content: any, _options?: any): Promise<{ messageId: string; rawData?: any }> {
483
- const elements = normalizeContent(content)
489
+ const raw = normalizeContent(content)
484
490
 
485
491
  /**
486
492
  * 解析结果合并转发:正在收集就攒起来,等解析结束发一条转发(见 compat/forward-collect)。
487
493
  * 回一个假的消息 ID:调用方普遍只看「有没有拿到 ID」(例如 uploadFile 判断发送成没成功)。
488
494
  */
489
- if (collectForward(this.contact?.peer ?? '', elements)) return { messageId: COLLECTED_MESSAGE_ID }
495
+ if (collectForward(this.contact?.peer ?? '', raw)) return { messageId: COLLECTED_MESSAGE_ID }
496
+
497
+ /**
498
+ * **图片统一改走 markdown**(上传拿公网地址 + 写死 `#宽px #高px`),见 compat/imageMarkdown。
499
+ *
500
+ * 放在这里是因为它是**所有回复的唯一出口** —— 业务代码一律 `e.reply(...)`,
501
+ * 不用一处处改业务代码。转换不成功会退回原来的图片段(绝不把图弄丢)。
502
+ * 上面的合并转发分支已经返回了,所以转发节点里仍然是普通图片段。
503
+ */
504
+ const elements = await imagesToMarkdown(raw, platformOfMessage(this))
490
505
 
491
506
  /**
492
507
  * 没拿到消息 ID 就是**没发出去**(对齐 qq-chat 的判法)。
@@ -585,6 +600,21 @@ function peerOf (contact: Contact | string): string {
585
600
  return typeof contact === 'string' ? contact : String((contact as Contact)?.peer ?? '')
586
601
  }
587
602
 
603
+ /**
604
+ * 适配器平台名。
605
+ *
606
+ * 发送出口要用它判断「这个平台认不认 markdown 图片」(见 compat/imageMarkdown):
607
+ * KkkBot 包装上直接转发真实 Bot 的 platform,传 KkkBot 或裸 Bot 都能取到。
608
+ */
609
+ function platformOfBot (bot: any): string {
610
+ return String(bot?.platform ?? bot?.bot?.platform ?? '')
611
+ }
612
+
613
+ /** 同上,从消息事件取(会话上就带 platform,拿不到再问 bot) */
614
+ function platformOfMessage (message: Message): string {
615
+ return String(message.session?.platform ?? platformOfBot(message.bot))
616
+ }
617
+
588
618
  /**
589
619
  * 合并转发的载荷。
590
620
  *
@@ -718,18 +748,22 @@ async function sendMsg (selfId: string, contact: Contact | string, content: any,
718
748
  + '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接')
719
749
  }
720
750
  const channelId = peerOf(contact)
751
+ const elements = normalizeContent(content)
721
752
  /** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
722
- if (collectForward(channelId, normalizeContent(content))) {
753
+ if (collectForward(channelId, elements)) {
723
754
  return { messageId: COLLECTED_MESSAGE_ID, rawData: [] }
724
755
  }
725
- const ids = await bot.bot.sendMessage(channelId, normalizeContent(content) as any)
756
+ /** 图片统一改走 markdown(见 compat/imageMarkdown) */
757
+ const outgoing = await imagesToMarkdown(elements, platformOfBot(bot.bot))
758
+ const ids = await bot.bot.sendMessage(channelId, outgoing as any)
726
759
  return { messageId: ids[ids.length - 1] ?? '', rawData: ids }
727
760
  }
728
761
 
729
762
  async function sendMaster (botId: string, master: string, content: any, _options?: any) {
730
763
  const bot = resolveBot(botId)
731
764
  if (!bot) throw new Error('[kkk] 没有可用的机器人实例,无法发送消息')
732
- const elements = normalizeContent(content)
765
+ // 图片统一改走 markdown(见 compat/imageMarkdown):私聊和群里一样,md 图片才不会被压糊
766
+ const elements = await imagesToMarkdown(normalizeContent(content), platformOfBot(bot.bot))
733
767
  const target = bot.bot as any
734
768
  // Satori 的 Bot 有 sendPrivateMessage;个别适配器没有,退化成私聊频道 id
735
769
  const ids = typeof target.sendPrivateMessage === 'function'