dsh-qq-tools 0.8.0 → 0.8.1

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 +4 -4
  2. package/lib/shared.js +179 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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.1
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.1.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 帧拆分、超过 4MiB 的图片缩放、其他格式转码)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理,大图直接交给 DSH 归一化(DSH 默认单张源图上限 20MiB)。
159
159
 
160
160
  ---
161
161
 
162
- 版本:0.8.0
162
+ 版本:0.8.1
package/lib/shared.js CHANGED
@@ -354,12 +354,14 @@ function segText(segs) {
354
354
  .join(''))
355
355
  }
356
356
 
357
- // mface(商城表情/表情包)基本都是动图;普通 image 段只能从名字和摘要里猜。
357
+ // 这里只做「优先展示动图」的排序提示,不决定 mediaType。
358
+ // QQ 的 [动画表情] 摘要经常贴在静态 PNG/JPEG 上,所以别再信 summary 里的「动画」;
359
+ // 真正是不是 GIF 由后面按字节嗅探,只有 mface 和 .gif 扩展名算动图候选。
358
360
  function animatedSeg(seg) {
359
361
  if (!seg || !seg.data) return false
362
+ if (seg.type === 'mface') return true
360
363
  const d = seg.data
361
- return seg.type === 'mface' ||
362
- /gif|动画/i.test(String(d.summary || '') + ' ' + String(d.file || '') + ' ' + String(d.url || ''))
364
+ return /\.gif(?:[?#]|$)/i.test(String(d.file || '')) || /\.gif(?:[?#]|$)/i.test(String(d.url || ''))
363
365
  }
364
366
 
365
367
  function fmtTime(sec) {
@@ -465,13 +467,80 @@ export function makeListTool() {
465
467
  }
466
468
 
467
469
 
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'
470
+ // ---------- 图片类型与尺寸 ----------
471
+ // DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
472
+ // animated 提示都可能撒谎,所以只信字节。DSH 默认单张源图上限 20MiB,
473
+ // 归一化目标 4MiB;源图超过 4MiB 时本地先缩一下,避免白白走一遍宿主重编码。
474
+ const ADMIT_IMAGE_LIMIT = 6
475
+ const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
476
+ const IMAGE_MEDIA_TYPES = {
477
+ png: 'image/png',
478
+ jpeg: 'image/jpeg',
479
+ webp: 'image/webp',
480
+ gif: 'image/gif',
481
+ }
482
+
483
+ function sniffImageType(bytes) {
484
+ if (!bytes || bytes.length < 12) return ''
485
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
486
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'
487
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
488
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
489
+ (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif'
490
+ if (bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
491
+ return ''
492
+ }
493
+
494
+ async function sharpImageType(bytes) {
495
+ if (!sharp) return ''
496
+ try {
497
+ const meta = await sharp(bytes, { animated: true }).metadata()
498
+ return IMAGE_MEDIA_TYPES[String((meta && meta.format) || '').toLowerCase()] || ''
499
+ } catch (error) {
500
+ return ''
501
+ }
502
+ }
503
+
504
+ // DSH 只收 png/jpeg/webp/gif;sharp 能解的其他格式(TIFF 等)先转成它认识的格式。
505
+ async function convertImage(bytes) {
506
+ if (!sharp) return null
507
+ try {
508
+ const meta = await sharp(bytes).metadata()
509
+ const hasAlpha = !!meta.hasAlpha
510
+ const out = hasAlpha
511
+ ? await sharp(bytes).webp({ quality: 82 }).toBuffer()
512
+ : await sharp(bytes).jpeg({ quality: 82 }).toBuffer()
513
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
514
+ } catch (error) {
515
+ return null
516
+ }
517
+ }
518
+
519
+ // 大图不丢:超过 DSH 归一化目标就先缩到 2048 长边并重编码,通常能压到 1MiB 以内。
520
+ async function shrinkImage(bytes) {
521
+ if (!sharp || !bytes || !bytes.length) return null
522
+ try {
523
+ const meta = await sharp(bytes, { animated: false }).metadata()
524
+ const width = Number(meta && meta.width) || 0
525
+ const height = Number(meta && meta.height) || 0
526
+ if (!width || !height) return null
527
+ const hasAlpha = !!meta.hasAlpha
528
+ const scale = Math.min(1, 2048 / Math.max(width, height))
529
+ const targetWidth = Math.max(1, Math.round(width * scale))
530
+ const targetHeight = Math.max(1, Math.round(height * scale))
531
+ for (const quality of [82, 70, 58]) {
532
+ const pipeline = sharp(bytes, { animated: false })
533
+ .rotate()
534
+ .resize({ width: targetWidth, height: targetHeight, fit: 'inside', withoutEnlargement: true })
535
+ const out = hasAlpha
536
+ ? await pipeline.webp({ quality }).toBuffer()
537
+ : await pipeline.jpeg({ quality }).toBuffer()
538
+ if (out.length <= IMAGE_SOFT_LIMIT) {
539
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
540
+ }
541
+ }
542
+ } catch (error) {}
543
+ return null
475
544
  }
476
545
 
477
546
 
@@ -544,29 +613,111 @@ async function imageBytes(data, cfg) {
544
613
  return null
545
614
  }
546
615
 
616
+ function admitErrorText(error) {
617
+ if (!error) return '未知错误'
618
+ return (error.code ? String(error.code) + ':' : '') + String(error.message || error)
619
+ }
620
+
621
+ // 单张被拒时可以重编码再试;批量/存储层错误重试没有意义。
622
+ const RETRYABLE_IMAGE_ERRORS = new Set([
623
+ 'IMAGE_TYPE_MISMATCH',
624
+ 'INVALID_IMAGE',
625
+ 'UNSUPPORTED_IMAGE_TYPE',
626
+ 'IMAGE_TOO_LARGE',
627
+ 'IMAGE_TOO_MANY_PIXELS',
628
+ 'IMAGE_DIMENSION_TOO_LARGE',
629
+ ])
630
+
631
+ async function admitOneImage(attachments, block) {
632
+ try {
633
+ const out = await attachments.admitPromptContent([block])
634
+ const hit = (Array.isArray(out) ? out : []).find((x) => x && x.type === 'image')
635
+ return hit ? { block: hit } : { error: new Error('附件层没有返回图片') }
636
+ } catch (error) {
637
+ return { error }
638
+ }
639
+ }
640
+
641
+ async function retryImageBlock(block) {
642
+ let bytes
643
+ try {
644
+ bytes = Buffer.from(String((block && block.data) || ''), 'base64')
645
+ } catch (error) {
646
+ return null
647
+ }
648
+ if (!bytes || !bytes.length) return null
649
+ const type = sniffImageType(bytes)
650
+ const fixed = type ? await shrinkImage(bytes) : await convertImage(bytes)
651
+ if (!fixed) return null
652
+ return { ...block, data: fixed.bytes.toString('base64'), mediaType: fixed.mediaType }
653
+ }
654
+
655
+ // 一张坏图不能拖垮整批:先整批送,失败后退回逐张,逐张失败的再重编码试一次。
656
+ async function admitPreparedImages(attachments, prepared) {
657
+ const blocks = prepared.map((x) => x.block)
658
+ try {
659
+ const out = await attachments.admitPromptContent(blocks)
660
+ const images = (Array.isArray(out) ? out : []).filter((x) => x && x.type === 'image')
661
+ if (images.length === blocks.length) {
662
+ return images.map((block, i) => ({ caption: prepared[i].caption, block }))
663
+ }
664
+ diag('图片 admit 返回数量不符(' + images.length + '/' + blocks.length + '),改为逐张处理')
665
+ } catch (error) {
666
+ diag('图片批量 admit 失败,改为逐张重试:' + admitErrorText(error))
667
+ }
668
+ const admitted = []
669
+ let firstError = ''
670
+ for (const item of prepared) {
671
+ let result = await admitOneImage(attachments, item.block)
672
+ if (result.error && RETRYABLE_IMAGE_ERRORS.has(String(result.error.code || ''))) {
673
+ const retried = await retryImageBlock(item.block)
674
+ if (retried) result = await admitOneImage(attachments, retried)
675
+ }
676
+ if (result.block) admitted.push({ caption: item.caption, block: result.block })
677
+ else if (!firstError) firstError = admitErrorText(result.error)
678
+ }
679
+ if (firstError && admitted.length < prepared.length) {
680
+ diag('有图片没能进来(已跳过,其余保留):' + firstError)
681
+ }
682
+ return admitted
683
+ }
684
+
547
685
  export function admitImagesFor(ctx) {
548
686
  return async function admitImages(list) {
549
687
  const attachments = ctx.attachments || (typeof ctx.get === 'function' ? ctx.get('attachments') : null)
550
688
  if (!attachments || typeof attachments.admitPromptContent !== 'function') return []
551
689
  const prepared = []
552
690
  for (const x of Array.isArray(list) ? list : []) {
553
- const bytes = await imageBytes(x.data, x.cfg || loadCfg())
691
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
692
+ let bytes = await imageBytes(x.data, x.cfg || loadCfg())
554
693
  if (!bytes || !bytes.length) continue
555
- if (bytes.length > 5 * 1024 * 1024) continue
556
694
  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) {
695
+ // animated 只是提示,type 一律按字节来;猜错会被 DSH 按 IMAGE_TYPE_MISMATCH 整批拒掉。
696
+ let mediaType = sniffImageType(bytes) || (await sharpImageType(bytes))
697
+ if (!mediaType) {
698
+ const converted = await convertImage(bytes)
699
+ if (!converted) {
700
+ diag('图片跳过:认不出格式(' + name + ')')
701
+ continue
702
+ }
703
+ bytes = converted.bytes
704
+ mediaType = converted.mediaType
705
+ }
706
+ if (mediaType === 'image/gif') {
559
707
  const frames = await gifFrames(bytes, 3)
560
708
  if (frames) {
561
709
  for (const f of frames) {
562
- const frameName = name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`
563
710
  prepared.push({
564
711
  caption: String(x.caption || '') + `(GIF 第 ${f.page}/${f.pages} 帧)`,
565
- block: { type: 'image', data: f.bytes.toString('base64'), mediaType: 'image/png', name: frameName },
712
+ block: {
713
+ type: 'image',
714
+ data: f.bytes.toString('base64'),
715
+ mediaType: 'image/png',
716
+ name: name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`,
717
+ },
566
718
  })
567
- if (prepared.length >= 6) break
719
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
568
720
  }
569
- if (prepared.length >= 6) break
570
721
  continue
571
722
  }
572
723
  if (!sharp && !gifWarned) {
@@ -574,21 +725,21 @@ export function admitImagesFor(ctx) {
574
725
  diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
575
726
  }
576
727
  }
728
+ // 以前这里直接丢弃 >5MiB 的图;DSH 实际能收 20MiB 并自动归一化,所以改成先缩放。
729
+ if (bytes.length > IMAGE_SOFT_LIMIT) {
730
+ const shrunk = await shrinkImage(bytes)
731
+ if (shrunk) {
732
+ bytes = shrunk.bytes
733
+ mediaType = shrunk.mediaType
734
+ }
735
+ }
577
736
  prepared.push({
578
737
  caption: String(x.caption || ''),
579
- block: { type: 'image', data: bytes.toString('base64'), mediaType: isGif ? 'image/gif' : mediaTypeOf(name), name },
738
+ block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
580
739
  })
581
- if (prepared.length >= 6) break
582
740
  }
583
741
  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
- }
742
+ return admitPreparedImages(attachments, prepared)
592
743
  }
593
744
  }
594
745
 
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.1",
4
4
  "description": "给大肥鱼会话用的 QQ 群/私聊工具:列群、看群记录、发群消息、发表情、收藏表情(SnowLuma 收藏夹)、好友/私聊记录、发私聊消息、私聊唤醒、群成员、戳一戳感知/戳回去、挂载会话选择。8 个工具加 1 个设置组件,可在插件页逐个开关。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",