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.
- package/README.md +4 -4
- package/lib/shared.js +179 -28
- 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.
|
|
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.
|
|
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
|
|
158
|
+
- 可选:`sharp`(GIF 帧拆分、超过 4MiB 的图片缩放、其他格式转码)。会自动使用插件自己装的、`SHARP_PATH` 指定的,或 DSH 宿主自带的 sharp;都找不到时 GIF 按静图处理,大图直接交给 DSH 归一化(DSH 默认单张源图上限 20MiB)。
|
|
159
159
|
|
|
160
160
|
---
|
|
161
161
|
|
|
162
|
-
版本:0.8.
|
|
162
|
+
版本:0.8.1
|
package/lib/shared.js
CHANGED
|
@@ -354,12 +354,14 @@ function segText(segs) {
|
|
|
354
354
|
.join(''))
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
//
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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: {
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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