dsh-qq-tools 0.8.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/lib/shared.js +238 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -99,7 +99,7 @@ tools/qq-send-poke/
99
99
 
100
100
  私聊和群聊在程序里是分开的两条路径:群聊靠轮询 @,私聊靠 WebSocket 的 `message_type=private` 事件。私聊没有观察窗,每收到一条私聊消息都会直接唤醒目标会话。
101
101
 
102
- - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(GIF 会拆成首/中/尾最多 3 帧,和群聊记录的处理一致)。
102
+ - 唤醒提示里会带发送者昵称、QQ 号、`message_id` 和文字;如果消息里有图片,也会把图片一起给模型看(有 sharp 时 GIF 会拆成首/中/尾最多 3 帧;没有 sharp 时交给 DSH 显示首帧,和群聊记录的处理一致)。
103
103
  - 模型用 `qq_get_private_history` 看和对方的最近记录,用 `qq_send_private_message` 回文字,用 `qq_send_face` 回表情。
104
104
  - `qq_list_friends` 列好友,可以按昵称、备注或 QQ 号搜;`privateUsers` 非空时只列白名单里的好友。
105
105
  - `qq_get_private_history` 的消息带 `#message_id`,可以传给 `qq_send_private_message` / `qq_send_face` 的 `reply_to` 做引用回复。
@@ -128,7 +128,7 @@ dsh plugin --profile <profile> add dsh-qq-tools
128
128
  需要固定版本时:
129
129
 
130
130
  ```sh
131
- dsh plugin --profile <profile> add dsh-qq-tools@0.8.0
131
+ dsh plugin --profile <profile> add dsh-qq-tools@0.8.2
132
132
  ```
133
133
 
134
134
  ### 本地开发 / 自己改代码
@@ -141,7 +141,7 @@ dsh plugin --profile <profile> add link:/path/to/dsh-qq-tools
141
141
 
142
142
  ```sh
143
143
  pnpm pack
144
- dsh plugin --profile <profile> add ./dsh-qq-tools-0.8.0.tgz
144
+ dsh plugin --profile <profile> add ./dsh-qq-tools-0.8.2.tgz
145
145
  ```
146
146
 
147
147
  `dsh plugin` 会在安装后自动把这个包追加进 profile 的 `dsh.profile.bundles`,不用手改 `package.json`。卸载用:
@@ -155,8 +155,8 @@ dsh plugin --profile <profile> remove dsh-qq-tools
155
155
  - DSH 0.2.x(cordis 插件体系)
156
156
  - Node.js 22+(内置 `fetch` / `WebSocket`)
157
157
  - SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件(收藏表情需要 SnowLuma;系统表情 NapCat 也能发)
158
- - 可选:`sharp`(GIF 帧拆分)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理
158
+ - 可选:`sharp`(GIF 拆帧、尺寸被拒时的兜底重编码)。会从 `SHARP_PATH`、插件自己的 `node_modules`、DSH 宿主 runtimeDir(桌面版通过 `process.argv[2]` 传入)等位置自动查找;找不到时 GIF 交给 DSH 显示首帧,大图仍由 DSH 自动归一化(默认单张源图上限 20MiB),图片不会因此丢失。
159
159
 
160
160
  ---
161
161
 
162
- 版本:0.8.0
162
+ 版本:0.8.2
package/lib/shared.js CHANGED
@@ -34,7 +34,7 @@
34
34
  //
35
35
  // 工具本身的开关在 DSH 插件页的组件列表里:关掉某个组件,它就不再注册对应工具。
36
36
 
37
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs'
37
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync, statSync } from 'node:fs'
38
38
  import { join } from 'node:path'
39
39
  import { homedir } from 'node:os'
40
40
  import { randomUUID } from 'node:crypto'
@@ -47,34 +47,82 @@ const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
47
47
 
48
48
 
49
49
  const requireFromHere = createRequire(import.meta.url)
50
+ let sharpSource = ''
50
51
 
51
- // sharp 是可选的,按这个顺序找:SHARP_PATH → 插件自己装的 → 宿主进程能解析到的。
52
- // DSH 桌面版把 sharp 打在 resources/app.asar/dsh/node_modules 下,CLI 版跟着
53
- // @deepseek-ai/dsh 的依赖走,所以从宿主的启动入口解析最稳,不用写死安装路径。
52
+ function sharpFromDir(dir) {
53
+ try {
54
+ const s = createRequire(join(String(dir), 'noop.js'))('sharp')
55
+ return s || null
56
+ } catch (error) {
57
+ return null
58
+ }
59
+ }
60
+
61
+ function sharpFromCache() {
62
+ try {
63
+ const cache = requireFromHere.cache || {}
64
+ for (const key of Object.keys(cache)) {
65
+ if (!/[\\/]node_modules[\\/]sharp[\\/]/.test(key)) continue
66
+ const mod = cache[key]
67
+ if (mod && mod.exports) return mod.exports
68
+ }
69
+ } catch (error) {}
70
+ return null
71
+ }
72
+
73
+ // sharp 是可选的,按顺序找:SHARP_PATH → 插件自己装的 → 宿主 runtime → 常见宿主目录。
74
+ // 关键的一条是 process.argv[2]:DSH 桌面版的 dsh-desktop-host 用 argv[2] 接 runtimeDir,
75
+ // 插件和宿主在同一个进程里,所以 runtimeDir/node_modules/sharp 就是宿主实际加载的那份。
76
+ // 不写死安装路径;找不到时核心图片逻辑照常工作,只是不拆 GIF 帧,
77
+ // 尺寸被宿主拒绝时也没法兜底重编码。
54
78
  function loadSharp() {
55
79
  const probes = []
56
- if (process.env.SHARP_PATH) probes.push(() => requireFromHere(process.env.SHARP_PATH))
57
- probes.push(() => requireFromHere('sharp'))
58
- if (process.argv[1]) probes.push(() => createRequire(process.argv[1])('sharp'))
59
- if (process.resourcesPath) {
60
- probes.push(() => createRequire(join(process.resourcesPath, 'app.asar', 'dsh', 'package.json'))('sharp'))
80
+ const add = (label, fn) => probes.push({ label, fn })
81
+
82
+ if (process.env.SHARP_PATH) add('SHARP_PATH', () => requireFromHere(process.env.SHARP_PATH))
83
+ add('插件 node_modules', () => requireFromHere('sharp'))
84
+ if (process.argv[1]) add('宿主入口', () => createRequire(process.argv[1])('sharp'))
85
+ if (process.argv[2]) add('宿主 runtimeDir', () => sharpFromDir(process.argv[2]))
86
+
87
+ // 不同启动方式下 runtimeDir 的位置可能变;把 argv 里看起来像目录的路径都试一遍。
88
+ for (let i = 1; i < process.argv.length; i++) {
89
+ const raw = String(process.argv[i] || '')
90
+ if (!raw) continue
91
+ try {
92
+ if (existsSync(raw) && statSync(raw).isDirectory()) add('argv 目录 ' + raw, () => sharpFromDir(raw))
93
+ } catch (error) {}
61
94
  }
62
- for (const p of [
63
- '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
64
- '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/sharp',
65
- ]) {
66
- probes.push(() => requireFromHere(p))
95
+
96
+ if (process.cwd()) add('当前目录', () => sharpFromDir(process.cwd()))
97
+ if (process.resourcesPath) {
98
+ for (const root of [
99
+ join(process.resourcesPath, 'app.asar', 'dsh'),
100
+ join(process.resourcesPath, 'app.asar.unpacked', 'dsh'),
101
+ join(process.resourcesPath, 'app.asar'),
102
+ join(process.resourcesPath, 'app.asar.unpacked'),
103
+ join(process.resourcesPath, 'runtime'),
104
+ ]) {
105
+ add('resources ' + root, () => sharpFromDir(root))
106
+ }
67
107
  }
108
+ add('已加载模块缓存', sharpFromCache)
109
+
68
110
  for (const probe of probes) {
69
111
  try {
70
- const s = probe()
71
- if (s) return s
112
+ const s = probe.fn()
113
+ if (s) {
114
+ sharpSource = probe.label
115
+ return s
116
+ }
72
117
  } catch (error) {}
73
118
  }
74
119
  return null
75
120
  }
76
121
 
77
122
  const sharp = loadSharp()
123
+ diag('sharp:' + (sharp
124
+ ? '已加载(' + sharpSource + ')'
125
+ : '没找到;GIF 只取首帧,大图交给 DSH 归一化,图片不会因此丢失'))
78
126
  let gifWarned = false
79
127
 
80
128
  const DEFAULT_GROUPS = []
@@ -354,12 +402,14 @@ function segText(segs) {
354
402
  .join(''))
355
403
  }
356
404
 
357
- // mface(商城表情/表情包)基本都是动图;普通 image 段只能从名字和摘要里猜。
405
+ // 这里只做「优先展示动图」的排序提示,不决定 mediaType。
406
+ // QQ 的 [动画表情] 摘要经常贴在静态 PNG/JPEG 上,所以别再信 summary 里的「动画」;
407
+ // 真正是不是 GIF 由后面按字节嗅探,只有 mface 和 .gif 扩展名算动图候选。
358
408
  function animatedSeg(seg) {
359
409
  if (!seg || !seg.data) return false
410
+ if (seg.type === 'mface') return true
360
411
  const d = seg.data
361
- return seg.type === 'mface' ||
362
- /gif|动画/i.test(String(d.summary || '') + ' ' + String(d.file || '') + ' ' + String(d.url || ''))
412
+ return /\.gif(?:[?#]|$)/i.test(String(d.file || '')) || /\.gif(?:[?#]|$)/i.test(String(d.url || ''))
363
413
  }
364
414
 
365
415
  function fmtTime(sec) {
@@ -465,13 +515,81 @@ export function makeListTool() {
465
515
  }
466
516
 
467
517
 
468
- function mediaTypeOf(name) {
469
- const s = String(name || '').toLowerCase()
470
- if (s.endsWith('.png')) return 'image/png'
471
- if (s.endsWith('.gif')) return 'image/gif'
472
- if (s.endsWith('.webp')) return 'image/webp'
473
- if (s.endsWith('.bmp')) return 'image/bmp'
474
- return 'image/jpeg'
518
+ // ---------- 图片类型与尺寸 ----------
519
+ // DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
520
+ // animated 提示都可能撒谎,所以只信字节。大图不依赖 sharp:DSH 默认单张源图
521
+ // 上限 20MiB、归一化目标 4MiB,直接交给它处理;sharp 只用于 GIF 拆帧和
522
+ // admit 被尺寸拒绝后的兜底重编码。
523
+ const ADMIT_IMAGE_LIMIT = 6
524
+ const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
525
+ const IMAGE_MEDIA_TYPES = {
526
+ png: 'image/png',
527
+ jpeg: 'image/jpeg',
528
+ webp: 'image/webp',
529
+ gif: 'image/gif',
530
+ }
531
+
532
+ function sniffImageType(bytes) {
533
+ if (!bytes || bytes.length < 12) return ''
534
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
535
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'
536
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
537
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
538
+ (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif'
539
+ if (bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
540
+ return ''
541
+ }
542
+
543
+ async function sharpImageType(bytes) {
544
+ if (!sharp) return ''
545
+ try {
546
+ const meta = await sharp(bytes, { animated: true }).metadata()
547
+ return IMAGE_MEDIA_TYPES[String((meta && meta.format) || '').toLowerCase()] || ''
548
+ } catch (error) {
549
+ return ''
550
+ }
551
+ }
552
+
553
+ // DSH 只收 png/jpeg/webp/gif;sharp 能解的其他格式(TIFF 等)先转成它认识的格式。
554
+ async function convertImage(bytes) {
555
+ if (!sharp) return null
556
+ try {
557
+ const meta = await sharp(bytes).metadata()
558
+ const hasAlpha = !!meta.hasAlpha
559
+ const out = hasAlpha
560
+ ? await sharp(bytes).webp({ quality: 82 }).toBuffer()
561
+ : await sharp(bytes).jpeg({ quality: 82 }).toBuffer()
562
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
563
+ } catch (error) {
564
+ return null
565
+ }
566
+ }
567
+
568
+ // 大图不丢:超过 DSH 归一化目标就先缩到 2048 长边并重编码,通常能压到 1MiB 以内。
569
+ async function shrinkImage(bytes) {
570
+ if (!sharp || !bytes || !bytes.length) return null
571
+ try {
572
+ const meta = await sharp(bytes, { animated: false }).metadata()
573
+ const width = Number(meta && meta.width) || 0
574
+ const height = Number(meta && meta.height) || 0
575
+ if (!width || !height) return null
576
+ const hasAlpha = !!meta.hasAlpha
577
+ const scale = Math.min(1, 2048 / Math.max(width, height))
578
+ const targetWidth = Math.max(1, Math.round(width * scale))
579
+ const targetHeight = Math.max(1, Math.round(height * scale))
580
+ for (const quality of [82, 70, 58]) {
581
+ const pipeline = sharp(bytes, { animated: false })
582
+ .rotate()
583
+ .resize({ width: targetWidth, height: targetHeight, fit: 'inside', withoutEnlargement: true })
584
+ const out = hasAlpha
585
+ ? await pipeline.webp({ quality }).toBuffer()
586
+ : await pipeline.jpeg({ quality }).toBuffer()
587
+ if (out.length <= IMAGE_SOFT_LIMIT) {
588
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
589
+ }
590
+ }
591
+ } catch (error) {}
592
+ return null
475
593
  }
476
594
 
477
595
 
@@ -544,29 +662,111 @@ async function imageBytes(data, cfg) {
544
662
  return null
545
663
  }
546
664
 
665
+ function admitErrorText(error) {
666
+ if (!error) return '未知错误'
667
+ return (error.code ? String(error.code) + ':' : '') + String(error.message || error)
668
+ }
669
+
670
+ // 单张被拒时可以重编码再试;批量/存储层错误重试没有意义。
671
+ const RETRYABLE_IMAGE_ERRORS = new Set([
672
+ 'IMAGE_TYPE_MISMATCH',
673
+ 'INVALID_IMAGE',
674
+ 'UNSUPPORTED_IMAGE_TYPE',
675
+ 'IMAGE_TOO_LARGE',
676
+ 'IMAGE_TOO_MANY_PIXELS',
677
+ 'IMAGE_DIMENSION_TOO_LARGE',
678
+ ])
679
+
680
+ async function admitOneImage(attachments, block) {
681
+ try {
682
+ const out = await attachments.admitPromptContent([block])
683
+ const hit = (Array.isArray(out) ? out : []).find((x) => x && x.type === 'image')
684
+ return hit ? { block: hit } : { error: new Error('附件层没有返回图片') }
685
+ } catch (error) {
686
+ return { error }
687
+ }
688
+ }
689
+
690
+ async function retryImageBlock(block) {
691
+ let bytes
692
+ try {
693
+ bytes = Buffer.from(String((block && block.data) || ''), 'base64')
694
+ } catch (error) {
695
+ return null
696
+ }
697
+ if (!bytes || !bytes.length) return null
698
+ const type = sniffImageType(bytes)
699
+ const fixed = type ? await shrinkImage(bytes) : await convertImage(bytes)
700
+ if (!fixed) return null
701
+ return { ...block, data: fixed.bytes.toString('base64'), mediaType: fixed.mediaType }
702
+ }
703
+
704
+ // 一张坏图不能拖垮整批:先整批送,失败后退回逐张,逐张失败的再重编码试一次。
705
+ async function admitPreparedImages(attachments, prepared) {
706
+ const blocks = prepared.map((x) => x.block)
707
+ try {
708
+ const out = await attachments.admitPromptContent(blocks)
709
+ const images = (Array.isArray(out) ? out : []).filter((x) => x && x.type === 'image')
710
+ if (images.length === blocks.length) {
711
+ return images.map((block, i) => ({ caption: prepared[i].caption, block }))
712
+ }
713
+ diag('图片 admit 返回数量不符(' + images.length + '/' + blocks.length + '),改为逐张处理')
714
+ } catch (error) {
715
+ diag('图片批量 admit 失败,改为逐张重试:' + admitErrorText(error))
716
+ }
717
+ const admitted = []
718
+ let firstError = ''
719
+ for (const item of prepared) {
720
+ let result = await admitOneImage(attachments, item.block)
721
+ if (result.error && RETRYABLE_IMAGE_ERRORS.has(String(result.error.code || ''))) {
722
+ const retried = await retryImageBlock(item.block)
723
+ if (retried) result = await admitOneImage(attachments, retried)
724
+ }
725
+ if (result.block) admitted.push({ caption: item.caption, block: result.block })
726
+ else if (!firstError) firstError = admitErrorText(result.error)
727
+ }
728
+ if (firstError && admitted.length < prepared.length) {
729
+ diag('有图片没能进来(已跳过,其余保留):' + firstError)
730
+ }
731
+ return admitted
732
+ }
733
+
547
734
  export function admitImagesFor(ctx) {
548
735
  return async function admitImages(list) {
549
736
  const attachments = ctx.attachments || (typeof ctx.get === 'function' ? ctx.get('attachments') : null)
550
737
  if (!attachments || typeof attachments.admitPromptContent !== 'function') return []
551
738
  const prepared = []
552
739
  for (const x of Array.isArray(list) ? list : []) {
553
- const bytes = await imageBytes(x.data, x.cfg || loadCfg())
740
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
741
+ let bytes = await imageBytes(x.data, x.cfg || loadCfg())
554
742
  if (!bytes || !bytes.length) continue
555
- if (bytes.length > 5 * 1024 * 1024) continue
556
743
  const name = String(x.name || 'qq-image.jpg')
557
- const isGif = x.animated || /\.gif$/i.test(name) || (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46)
558
- if (isGif) {
744
+ // animated 只是提示,type 一律按字节来;猜错会被 DSH 按 IMAGE_TYPE_MISMATCH 整批拒掉。
745
+ let mediaType = sniffImageType(bytes) || (await sharpImageType(bytes))
746
+ if (!mediaType) {
747
+ const converted = await convertImage(bytes)
748
+ if (!converted) {
749
+ diag('图片跳过:认不出格式(' + name + ')')
750
+ continue
751
+ }
752
+ bytes = converted.bytes
753
+ mediaType = converted.mediaType
754
+ }
755
+ if (mediaType === 'image/gif') {
559
756
  const frames = await gifFrames(bytes, 3)
560
757
  if (frames) {
561
758
  for (const f of frames) {
562
- const frameName = name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`
563
759
  prepared.push({
564
760
  caption: String(x.caption || '') + `(GIF 第 ${f.page}/${f.pages} 帧)`,
565
- block: { type: 'image', data: f.bytes.toString('base64'), mediaType: 'image/png', name: frameName },
761
+ block: {
762
+ type: 'image',
763
+ data: f.bytes.toString('base64'),
764
+ mediaType: 'image/png',
765
+ name: name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`,
766
+ },
566
767
  })
567
- if (prepared.length >= 6) break
768
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
568
769
  }
569
- if (prepared.length >= 6) break
570
770
  continue
571
771
  }
572
772
  if (!sharp && !gifWarned) {
@@ -574,21 +774,15 @@ export function admitImagesFor(ctx) {
574
774
  diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
575
775
  }
576
776
  }
777
+ // 大图不在这里预缩放:DSH 自己会把源图归一化到 4MiB,20MiB 以内都能收。
778
+ // 只有 admit 因尺寸被拒时才在 retryImageBlock 里用 sharp 兜底重编码。
577
779
  prepared.push({
578
780
  caption: String(x.caption || ''),
579
- block: { type: 'image', data: bytes.toString('base64'), mediaType: isGif ? 'image/gif' : mediaTypeOf(name), name },
781
+ block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
580
782
  })
581
- if (prepared.length >= 6) break
582
783
  }
583
784
  if (!prepared.length) return []
584
- try {
585
- const out = await attachments.admitPromptContent(prepared.map((x) => x.block))
586
- const blocks = (out || []).filter((x) => x && x.type === 'image')
587
- return blocks.map((block, i) => ({ caption: prepared[i] ? prepared[i].caption : '', block }))
588
- } catch (error) {
589
- diag('图片 admit 失败:' + String((error && error.message) || error))
590
- return []
591
- }
785
+ return admitPreparedImages(attachments, prepared)
592
786
  }
593
787
  }
594
788
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qq-tools",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、挂载会话选择。8 个工具加 1 个设置组件,可在插件页逐个开关。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",