dsh-qq-tools 0.9.5 → 0.10.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 (41) hide show
  1. package/README.md +23 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/config.js +96 -0
  4. package/lib/images.js +420 -0
  5. package/lib/index.js +2 -1
  6. package/lib/mount-all.js +59 -0
  7. package/lib/observation.js +24 -0
  8. package/lib/onebot.js +32 -0
  9. package/lib/prompts.js +56 -0
  10. package/lib/qq-state.js +51 -0
  11. package/lib/render.js +49 -0
  12. package/lib/session.js +201 -0
  13. package/lib/shared.js +26 -3377
  14. package/lib/tools/avatar.js +54 -0
  15. package/lib/tools/faces.js +296 -0
  16. package/lib/tools/friends.js +559 -0
  17. package/lib/tools/groups.js +220 -0
  18. package/lib/tools/poke.js +41 -0
  19. package/lib/tools/private.js +190 -0
  20. package/lib/tools/qzone.js +569 -0
  21. package/lib/utils.js +73 -0
  22. package/lib/watchers/mentions.js +167 -0
  23. package/lib/watchers/poke.js +87 -0
  24. package/lib/watchers/private.js +95 -0
  25. package/lib/watchers/ws.js +61 -0
  26. package/package.json +98 -103
  27. package/tools/qq-favorite-faces/index.js +11 -10
  28. package/tools/qq-favorite-faces/locale/en.json +1 -1
  29. package/tools/qq-favorite-faces/locale/zh.json +1 -1
  30. package/tools/qq-friend-management/index.js +28 -28
  31. package/tools/qq-get-group-history/index.js +10 -8
  32. package/tools/qq-get-group-members/index.js +9 -8
  33. package/tools/qq-list-groups/index.js +9 -8
  34. package/tools/qq-private-chat/index.js +16 -20
  35. package/tools/qq-qzone/index.js +22 -22
  36. package/tools/qq-send-face/index.js +11 -8
  37. package/tools/qq-send-face/locale/en.json +1 -1
  38. package/tools/qq-send-face/locale/zh.json +1 -1
  39. package/tools/qq-send-group-message/index.js +13 -10
  40. package/tools/qq-send-poke/index.js +13 -10
  41. package/tools/qq-set-avatar/index.js +9 -8
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-qq-tools
2
2
 
3
- 给 DSH(DeepSeek Harness)里的大肥鱼会话用的 QQ 群/私聊工具,走 OneBot 11 HTTP + WebSocket。系统表情在 SnowLuma / NapCat 上都能发;收藏表情、更改头像、好友管理和 QQ 空间走 SnowLuma 的扩展接口,和 QQ 客户端里的数据是同一份。
3
+ 给 DSH(DeepSeek Harness)里的大肥鱼会话用的 QQ 群/私聊工具。**只支持 SnowLuma**(通过它的 OneBot 11 HTTP + WebSocket 接口),NapCat 或其他 OneBot 实现都不支持。发表情只发 QQ 收藏夹里的自定义表情;收藏表情、更改头像、好友管理和 QQ 空间走 SnowLuma 的扩展接口,和 QQ 客户端里的数据是同一份。
4
4
 
5
5
  11 个工具组各自是一个独立的 cordis 组件,外加一个负责选择挂载会话的设置组件,在 DSH 插件页的「包含的组件」里可以逐个开关:
6
6
 
@@ -10,8 +10,8 @@
10
10
  | 看群聊天记录 | `qq_get_group_history` | 最近消息 + 图片,只读;消息带 `#message_id` |
11
11
  | 看群成员 | `qq_get_group_members` | 昵称、群名片、QQ 号、身份 |
12
12
  | 发群消息 | `qq_send_group_message` | 发群消息,可引用消息、@人、@全体;同时负责 @ 提醒和群后续轮询 |
13
- | 发表情 | `qq_send_face` | 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情,可带一句话 |
14
- | 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看/管理 QQ 账号收藏夹(SnowLuma):收藏图片、取消收藏、改备注、移到最前 |
13
+ | 发表情 | `qq_send_face` | 在群里或私聊里发 QQ 收藏夹里的自定义表情,可带一句话 |
14
+ | 收藏表情 | `qq_list_faces` / `qq_favorite_face` | 看收藏夹的 `emoji_id` 和文字备注、收藏图片、取消收藏、改备注、移到最前(SnowLuma) |
15
15
  | 私聊 | `qq_list_friends` / `qq_get_private_history` / `qq_send_private_message` | 好友列表、私聊记录、发私聊消息;同时负责私聊消息唤醒 |
16
16
  | 戳一戳 | `qq_send_poke` | 戳人;同时负责接收戳一戳事件 |
17
17
  | 更改头像 | `qq_set_avatar` | 换自己的 QQ 头像:链接、本机文件,或从聊天记录里取图 |
@@ -25,7 +25,17 @@
25
25
 
26
26
  ```
27
27
  cordis.patch.yml 12 个组件行
28
- lib/shared.js 共享核心:配置、OneBot、挂载、@ 轮询、私聊唤醒、戳一戳、表情、头像、好友管理、QQ 空间、观察窗
28
+ lib/config.js 配置读写、群/私聊白名单
29
+ lib/onebot.js SnowLuma HTTP 接口
30
+ lib/qq-state.js 登录号 / 群名缓存
31
+ lib/session.js 目标会话、工具挂载、挂载会话接口
32
+ lib/observation.js 观察窗
33
+ lib/images.js sharp、图片准入、GIF 拆帧、取图
34
+ lib/prompts.js 唤醒提示词与回复风格
35
+ lib/tools/ 按域拆开的工具工厂(groups/private/faces/avatar/poke/friends/qzone)
36
+ lib/watchers/ @ 轮询、戳一戳、私聊唤醒、通用 WS
37
+ lib/mount-all.js 旧的全量入口
38
+ lib/shared.js 兼容 barrel:re-export 上面所有模块,保持旧 import 路径可用
29
39
  lib/client.js 浏览器半侧:插件页里的挂载会话下拉框
30
40
  lib/index.js 包根 carrier(承载浏览器半侧和挂载会话接口),也是旧的全量入口
31
41
  locale/ 包根行在插件页显示的标题和说明
@@ -80,7 +90,7 @@ test/smoke.mjs 离线冒烟测试:假 OneBot HTTP 服务 +
80
90
  }
81
91
  ```
82
92
 
83
- - `napCat` 是 OneBot 11 HTTP 地址(SnowLuma 默认 `http://127.0.0.1:3000`,NapCat 也一样);`wsUrl` 是 WebSocket 事件地址(默认 `ws://127.0.0.1:3001`)。
93
+ - `napCat` 是 SnowLuma 的 OneBot 11 HTTP 地址(默认 `http://127.0.0.1:3000`;字段名沿用 `napCat`,但只填 SnowLuma,不支持 NapCat 或其他 OneBot 实现);`wsUrl` 是 SnowLuma 的 WebSocket 事件地址(默认 `ws://127.0.0.1:3001`)。
84
94
  - `targetSession` 是目标会话 ID;可以在插件页「挂载会话 → 配置」里下拉选择,也可以直接改这个文件;留空时工具不会挂到任何会话。
85
95
  - `groups` 是允许看/发的群;空数组表示不限制。
86
96
  - `mentionEnabled` / `pokeEnabled` / `privateEnabled` 分别是 @ 轮询、戳一戳事件、私聊唤醒的总闸,对应组件关掉时它们也不会运行。
@@ -90,8 +100,8 @@ test/smoke.mjs 离线冒烟测试:假 OneBot HTTP 服务 +
90
100
 
91
101
  发表情和收藏表情都走 SnowLuma 的扩展接口,收藏夹和 QQ 客户端里的是同一份数据。
92
102
 
93
- - `qq_list_faces`:列 QQ 收藏夹(`emoji_id` + 备注),默认给 3 张不同位置的预览图;想看指定表情就传 `preview`(列表序号或 `emoji_id`,最多 6 张)。传 `query` 时用 SnowLuma 的 `search_sys_faces` 按名字/别名/编号搜系统表情,搜不到再退回内置的常用表情表。
94
- - `qq_send_face`:`face_id` 发系统表情(OneBot 11 的 `face` 段,SnowLuma 会自动选 classic / small / super 编码);`emoji_id` 发收藏表情(`send_custom_face`)。两者二选一;带 `text` 时系统表情和文字同一条发出,收藏表情则先发文字再发图。群聊传 `group_id`,私聊传 `user_id`(可选 `group_id` 表示临时会话来源群)。
103
+ - `qq_list_faces`:列 QQ 收藏夹,每个只返回 `emoji_id` 和文字备注,不发预览图;传 `query` 按备注或 `emoji_id` 过滤,`count` 默认 200、最多 500。
104
+ - `qq_send_face`:只发收藏表情(`emoji_id`,走 SnowLuma 的 `send_custom_face`),不再支持系统表情编号。正式发之前先传 `preview=true`:只返回这一张表情图、不发送,确认合适再真发;这样列表阶段不用给模型灌图。带 `text` 时先发文字再发表情。群聊传 `group_id`,私聊传 `user_id`(可选 `group_id` 表示临时会话来源群)。
95
105
  - `qq_favorite_face`:
96
106
  - `file` 传 `http(s)` 链接、`base64://`、本机路径或 `data:image/...`,收藏一张新表情;
97
107
  - `message_id` + `index` 直接从聊天消息里的图片收藏(群记录里的 `[图片#0]` / `[表情包#0]` 就是索引);
@@ -100,7 +110,9 @@ test/smoke.mjs 离线冒烟测试:假 OneBot HTTP 服务 +
100
110
  - `emoji_id` + `move_to_front` 移到收藏夹最前。
101
111
  - 聊天记录里的 `#编号` 是 `message_id`,可以用 `qq_send_face` 的 `reply_to` 回复,也可以配合 `qq_favorite_face` 收藏消息里的图片。
102
112
 
103
- 这些自定义表情接口是 SnowLuma 扩展,NapCat 没有。用 NapCat 时系统表情照常能发,收藏表情工具会提示接口不可用。
113
+ 主动行为写进了唤醒提示词(@ 提醒、群后续、戳一戳、私聊):模型约有 25% 的概率主动回一个收藏表情(先看备注挑、再 `preview=true` 看一张图、最后正式发);收到带图片/表情包的消息时,也约有 25% 的概率用 `qq_favorite_face(message_id, index, desc)` 顺手收藏,`desc` 直接简短描述画面内容(如「闭眼安详」「锅铲敲地」「穿冬装的肥鱼」),否则以后只有备注可看、不知道用哪张。概率由模型按提示词自行把握,代码里不掷骰子。
114
+
115
+ 本插件只支持 SnowLuma;这些自定义表情接口是它的扩展,NapCat 等其他 OneBot 实现没有。
104
116
 
105
117
  ## 群消息:引用和 @
106
118
 
@@ -164,7 +176,7 @@ QQ空间组件提供 4 个工具,走 SnowLuma 的「空间」扩展:
164
176
  - `set_right`:改自己空间一条说说的可见范围,参数和发说说时的 `visibility` 一致;
165
177
  - `ban` / `unban`:拉黑或解除拉黑某人的空间访问(修改的是你自己空间的黑名单)。
166
178
 
167
- 这些接口是 SnowLuma 的扩展,NapCat 上不一定都有;接口不可用时工具会返回 OneBot 的错误原文,不会影响群聊/私聊工具。
179
+ 这些接口是 SnowLuma 的扩展(本插件只支持 SnowLuma,NapCat 等其他 OneBot 实现没有这些接口);接口不可用时工具会返回 SnowLuma 的错误原文,不会影响群聊/私聊工具。
168
180
 
169
181
  ## 安装
170
182
 
@@ -217,13 +229,13 @@ Node.js 要求为 `>=22`,DSH 支持范围为 `0.2.x`,与原有依赖说明
217
229
 
218
230
  本次声明依据、已获得的安装证据及未验证项见 [整改记录](docs/verification/README.md)。用户已自行测试现有功能;本次不重复功能测试,也不将手工使用情况冒充完整的商城生命周期验收。
219
231
 
220
- 本插件需要文件、网络和 OneBot token 权限。配置/诊断日志、本地图片读取、外部服务、账号写操作及失败边界详见 [SECURITY.md](SECURITY.md)。这些能力是 QQ 桥接的实际需要;补齐声明和验收证据不保证 DSH STORE 自动批准,商城可能仍要求 `user-reviewed` 专项审阅或保持 `blocked`。本仓库的验收记录不构成独立安全审计。
232
+ 本插件需要文件、网络和 SnowLuma 的 OneBot token 权限。配置/诊断日志、本地图片读取、外部服务、账号写操作及失败边界详见 [SECURITY.md](SECURITY.md)。这些能力是 QQ 桥接的实际需要;补齐声明和验收证据不保证 DSH STORE 自动批准,商城可能仍要求 `user-reviewed` 专项审阅或保持 `blocked`。本仓库的验收记录不构成独立安全审计。
221
233
 
222
234
  ## 依赖
223
235
 
224
236
  - DSH 0.2.x(cordis 插件体系)
225
237
  - Node.js 22+(内置 `fetch` / `WebSocket`)
226
- - SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件。系统表情、群聊和私聊 NapCat 也能用;收藏表情、更改头像、好友分组/备注/申请和 QQ 空间依赖 SnowLuma 的扩展接口,NapCat 上不保证可用。
238
+ - SnowLuma 提供 OneBot 11 HTTP 接口和 WebSocket 事件,本插件只支持 SnowLuma;NapCat 或其他 OneBot 实现不支持。发表情、收藏表情、更改头像、好友分组/备注/申请和 QQ 空间都依赖 SnowLuma 的扩展接口。
227
239
  - 可选:`sharp`(GIF 拆帧、尺寸被拒时的兜底重编码)。会从 `SHARP_PATH`、插件自己的 `node_modules`、DSH 宿主 runtimeDir(桌面版通过 `process.argv[2]` 传入)等位置自动查找;找不到时 GIF 交给 DSH 显示首帧,大图仍由 DSH 自动归一化(默认单张源图上限 20MiB),图片不会因此丢失。
228
240
 
229
241
  改完代码可以跑 `npm test`:它会起一个假 OneBot HTTP 服务、用临时 `DSH_HOME` 加载工具,检查头像、好友管理、好友申请和 QQ 空间的请求体与校验,不连真实 QQ,也不碰 `~/.dsh/qq-tools-config.json`。
package/cordis.patch.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  # DSH 大肥鱼 QQ 群/私聊工具:11 个组件,插件页里可以逐个开关
2
2
  # · 列出 QQ 群 / 看群聊天记录 / 看群成员 —— 只读工具
3
3
  # · 发群消息 —— 同时负责 @ 提醒与群后续轮询
4
- # · 发表情 —— 在群里或私聊里发系统表情或 QQ 收藏夹里的自定义表情
4
+ # · 发表情 —— 在群里或私聊里发 QQ 收藏夹里的自定义表情
5
5
  # · 收藏表情 —— QQ 收藏夹(SnowLuma):qq_list_faces + qq_favorite_face
6
6
  # · 私聊 —— 好友列表 / 私聊记录 / 发私聊消息,同时负责私聊消息唤醒
7
7
  # · 戳一戳 —— 同时负责接收戳一戳事件
package/lib/config.js ADDED
@@ -0,0 +1,96 @@
1
+ // QQ 工具配置:路径、默认值、读写,以及群/私聊白名单。
2
+
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+
7
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
8
+ const CFG_PATH = join(DSH_HOME, 'qq-tools-config.json')
9
+ const DIAG_PATH = join(DSH_HOME, 'qq-tools-diag.log')
10
+
11
+ const DEFAULT_GROUPS = []
12
+ const DEFAULT_CFG = {
13
+ napCat: 'http://127.0.0.1:3000',
14
+ accessToken: '',
15
+ targetSession: '',
16
+ groups: DEFAULT_GROUPS,
17
+ historyCount: 30,
18
+ maxHistoryCount: 50,
19
+ pollMs: 5000,
20
+ mentionEnabled: true,
21
+ wsUrl: 'ws://127.0.0.1:3001',
22
+ pokeEnabled: true,
23
+ privateEnabled: true,
24
+ privateUsers: [],
25
+ observationMs: 60000,
26
+ timeoutMs: 6000,
27
+ }
28
+
29
+ export function diag(msg) {
30
+ try {
31
+ appendFileSync(DIAG_PATH, new Date().toISOString() + ' ' + msg + '\n', 'utf8')
32
+ } catch (error) {}
33
+ }
34
+
35
+ export function ensureCfg() {
36
+ try {
37
+ if (existsSync(CFG_PATH)) return
38
+ mkdirSync(DSH_HOME, { recursive: true })
39
+ writeFileSync(CFG_PATH, JSON.stringify(DEFAULT_CFG, null, 2), 'utf8')
40
+ diag('已创建配置:' + CFG_PATH)
41
+ } catch (error) {}
42
+ }
43
+
44
+ export function loadCfg() {
45
+ try {
46
+ const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
47
+ const out = { ...DEFAULT_CFG, ...(raw && typeof raw === 'object' ? raw : {}) }
48
+ out.groups = Array.isArray(out.groups) ? out.groups.map((x) => Number(x)).filter(Number.isFinite) : DEFAULT_GROUPS
49
+ out.napCat = String(out.napCat || DEFAULT_CFG.napCat).replace(/\/+$/, '')
50
+ out.historyCount = Math.max(1, Math.min(Number(out.historyCount) || 30, Number(out.maxHistoryCount) || 50))
51
+ out.maxHistoryCount = Math.max(out.historyCount, Math.min(Number(out.maxHistoryCount) || 50, 100))
52
+ out.pollMs = Math.max(2000, Math.min(Number(out.pollMs) || 5000, 60000))
53
+ out.timeoutMs = Math.max(1000, Math.min(Number(out.timeoutMs) || 6000, 15000))
54
+ out.mentionEnabled = out.mentionEnabled !== false
55
+ out.wsUrl = String(out.wsUrl || DEFAULT_CFG.wsUrl)
56
+ out.pokeEnabled = out.pokeEnabled !== false
57
+ out.privateEnabled = out.privateEnabled !== false
58
+ out.privateUsers = Array.isArray(out.privateUsers) ? out.privateUsers.map((x) => Number(x)).filter(Number.isFinite) : []
59
+ out.observationMs = Math.max(30000, Math.min(Number(out.observationMs) || 60000, 600000))
60
+ return out
61
+ } catch (error) {
62
+ return { ...DEFAULT_CFG }
63
+ }
64
+ }
65
+
66
+ // UI 保存挂载会话时只覆盖传进来的字段,其余配置原样保留;先写临时文件再换名,
67
+ // 避免写一半留下坏 JSON 让后面所有工具都退回默认配置。
68
+ export function saveCfg(patch) {
69
+ ensureCfg()
70
+ let current = {}
71
+ try {
72
+ const raw = JSON.parse(readFileSync(CFG_PATH, 'utf8'))
73
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) current = raw
74
+ } catch (error) {}
75
+ const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
76
+ try {
77
+ mkdirSync(DSH_HOME, { recursive: true })
78
+ const tmp = CFG_PATH + '.tmp'
79
+ writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
80
+ renameSync(tmp, CFG_PATH)
81
+ } catch (error) {
82
+ diag('保存配置失败:' + String((error && error.message) || error))
83
+ throw error
84
+ }
85
+ return next
86
+ }
87
+
88
+ export function allowedGroup(groupId, cfg) {
89
+ if (!Array.isArray(cfg.groups) || cfg.groups.length === 0) return true
90
+ return cfg.groups.includes(Number(groupId))
91
+ }
92
+
93
+ export function allowedPrivate(userId, cfg) {
94
+ if (!Array.isArray(cfg.privateUsers) || cfg.privateUsers.length === 0) return true
95
+ return cfg.privateUsers.includes(Number(userId))
96
+ }
package/lib/images.js ADDED
@@ -0,0 +1,420 @@
1
+ // sharp 加载、图片准入(DSH 附件层)、GIF 拆帧,以及从消息/本机路径取图。
2
+
3
+ import { existsSync, statSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { createRequire } from 'node:module'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { onebot } from './onebot.js'
8
+ import { loadCfg, diag } from './config.js'
9
+
10
+ const requireFromHere = createRequire(import.meta.url)
11
+ let sharpSource = ''
12
+
13
+ function sharpFromDir(dir) {
14
+ try {
15
+ const s = createRequire(join(String(dir), 'noop.js'))('sharp')
16
+ return s || null
17
+ } catch (error) {
18
+ return null
19
+ }
20
+ }
21
+
22
+ function sharpFromCache() {
23
+ try {
24
+ const cache = requireFromHere.cache || {}
25
+ for (const key of Object.keys(cache)) {
26
+ if (!/[\\/]node_modules[\\/]sharp[\\/]/.test(key)) continue
27
+ const mod = cache[key]
28
+ if (mod && mod.exports) return mod.exports
29
+ }
30
+ } catch (error) {}
31
+ return null
32
+ }
33
+
34
+ // sharp 是可选的,按顺序找:SHARP_PATH → 插件自己装的 → 宿主 runtime → 常见宿主目录。
35
+ // 关键的一条是 process.argv[2]:DSH 桌面版的 dsh-desktop-host 用 argv[2] 接 runtimeDir,
36
+ // 插件和宿主在同一个进程里,所以 runtimeDir/node_modules/sharp 就是宿主实际加载的那份。
37
+ // 不写死安装路径;找不到时核心图片逻辑照常工作,只是不拆 GIF 帧,
38
+ // 尺寸被宿主拒绝时也没法兜底重编码。
39
+ function loadSharp() {
40
+ const probes = []
41
+ const add = (label, fn) => probes.push({ label, fn })
42
+
43
+ if (process.env.SHARP_PATH) add('SHARP_PATH', () => requireFromHere(process.env.SHARP_PATH))
44
+ add('插件 node_modules', () => requireFromHere('sharp'))
45
+ if (process.argv[1]) add('宿主入口', () => createRequire(process.argv[1])('sharp'))
46
+ if (process.argv[2]) add('宿主 runtimeDir', () => sharpFromDir(process.argv[2]))
47
+
48
+ // 不同启动方式下 runtimeDir 的位置可能变;把 argv 里看起来像目录的路径都试一遍。
49
+ for (let i = 1; i < process.argv.length; i++) {
50
+ const raw = String(process.argv[i] || '')
51
+ if (!raw) continue
52
+ try {
53
+ if (existsSync(raw) && statSync(raw).isDirectory()) add('argv 目录 ' + raw, () => sharpFromDir(raw))
54
+ } catch (error) {}
55
+ }
56
+
57
+ if (process.cwd()) add('当前目录', () => sharpFromDir(process.cwd()))
58
+ if (process.resourcesPath) {
59
+ for (const root of [
60
+ join(process.resourcesPath, 'app.asar', 'dsh'),
61
+ join(process.resourcesPath, 'app.asar.unpacked', 'dsh'),
62
+ join(process.resourcesPath, 'app.asar'),
63
+ join(process.resourcesPath, 'app.asar.unpacked'),
64
+ join(process.resourcesPath, 'runtime'),
65
+ ]) {
66
+ add('resources ' + root, () => sharpFromDir(root))
67
+ }
68
+ }
69
+ add('已加载模块缓存', sharpFromCache)
70
+
71
+ for (const probe of probes) {
72
+ try {
73
+ const s = probe.fn()
74
+ if (s) {
75
+ sharpSource = probe.label
76
+ return s
77
+ }
78
+ } catch (error) {}
79
+ }
80
+ return null
81
+ }
82
+
83
+ const sharp = loadSharp()
84
+ diag('sharp:' + (sharp
85
+ ? '已加载(' + sharpSource + ')'
86
+ : '没找到;GIF 只取首帧,大图交给 DSH 归一化,图片不会因此丢失'))
87
+ let gifWarned = false
88
+
89
+ // ---------- 图片类型与尺寸 ----------
90
+ // DSH 附件层按真实字节复核 mediaType(IMAGE_TYPE_MISMATCH),文件名和 QQ 的
91
+ // animated 提示都可能撒谎,所以只信字节。大图不依赖 sharp:DSH 默认单张源图
92
+ // 上限 20MiB、归一化目标 4MiB,直接交给它处理;sharp 只用于 GIF 拆帧和
93
+ // admit 被尺寸拒绝后的兜底重编码。
94
+ // 一次 admit 最多处理 10 张:聊天记录现在最多带 10 张图,GIF 拆帧也共用这个上限。
95
+ const ADMIT_IMAGE_LIMIT = 10
96
+ const IMAGE_SOFT_LIMIT = 4 * 1024 * 1024
97
+ const IMAGE_MEDIA_TYPES = {
98
+ png: 'image/png',
99
+ jpeg: 'image/jpeg',
100
+ webp: 'image/webp',
101
+ gif: 'image/gif',
102
+ }
103
+
104
+ function sniffImageType(bytes) {
105
+ if (!bytes || bytes.length < 12) return ''
106
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
107
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return 'image/png'
108
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
109
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
110
+ (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) return 'image/gif'
111
+ if (bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp'
112
+ return ''
113
+ }
114
+
115
+ async function sharpImageType(bytes) {
116
+ if (!sharp) return ''
117
+ try {
118
+ const meta = await sharp(bytes, { animated: true }).metadata()
119
+ return IMAGE_MEDIA_TYPES[String((meta && meta.format) || '').toLowerCase()] || ''
120
+ } catch (error) {
121
+ return ''
122
+ }
123
+ }
124
+
125
+ // DSH 只收 png/jpeg/webp/gif;sharp 能解的其他格式(TIFF 等)先转成它认识的格式。
126
+ async function convertImage(bytes) {
127
+ if (!sharp) return null
128
+ try {
129
+ const meta = await sharp(bytes).metadata()
130
+ const hasAlpha = !!meta.hasAlpha
131
+ const out = hasAlpha
132
+ ? await sharp(bytes).webp({ quality: 82 }).toBuffer()
133
+ : await sharp(bytes).jpeg({ quality: 82 }).toBuffer()
134
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
135
+ } catch (error) {
136
+ return null
137
+ }
138
+ }
139
+
140
+ // 大图不丢:超过 DSH 归一化目标就先缩到 2048 长边并重编码,通常能压到 1MiB 以内。
141
+ async function shrinkImage(bytes) {
142
+ if (!sharp || !bytes || !bytes.length) return null
143
+ try {
144
+ const meta = await sharp(bytes, { animated: false }).metadata()
145
+ const width = Number(meta && meta.width) || 0
146
+ const height = Number(meta && meta.height) || 0
147
+ if (!width || !height) return null
148
+ const hasAlpha = !!meta.hasAlpha
149
+ const scale = Math.min(1, 2048 / Math.max(width, height))
150
+ const targetWidth = Math.max(1, Math.round(width * scale))
151
+ const targetHeight = Math.max(1, Math.round(height * scale))
152
+ for (const quality of [82, 70, 58]) {
153
+ const pipeline = sharp(bytes, { animated: false })
154
+ .rotate()
155
+ .resize({ width: targetWidth, height: targetHeight, fit: 'inside', withoutEnlargement: true })
156
+ const out = hasAlpha
157
+ ? await pipeline.webp({ quality }).toBuffer()
158
+ : await pipeline.jpeg({ quality }).toBuffer()
159
+ if (out.length <= IMAGE_SOFT_LIMIT) {
160
+ return { bytes: out, mediaType: hasAlpha ? 'image/webp' : 'image/jpeg' }
161
+ }
162
+ }
163
+ } catch (error) {}
164
+ return null
165
+ }
166
+
167
+
168
+ async function gifFrames(bytes, max = 3) {
169
+ if (!sharp || !bytes || !bytes.length) return null
170
+ try {
171
+ const meta = await sharp(bytes, { animated: true }).metadata()
172
+ const pages = Number(meta.pages || 1)
173
+ if (!(pages > 1)) return null
174
+ const indices = pages <= max
175
+ ? Array.from({ length: pages }, (_, i) => i)
176
+ : [0, Math.floor((pages - 1) / 2), pages - 1]
177
+ const out = []
178
+ for (const i of indices) {
179
+ try {
180
+ const buf = await sharp(bytes, { page: i, pages: 1 })
181
+ .resize({ width: 1024, withoutEnlargement: true })
182
+ .png()
183
+ .toBuffer()
184
+ if (buf && buf.length) out.push({ bytes: buf, page: i + 1, pages })
185
+ } catch (error) {}
186
+ }
187
+ return out.length ? out : null
188
+ } catch (error) {
189
+ return null
190
+ }
191
+ }
192
+
193
+ async function imageBytes(data, cfg) {
194
+ const d = data || {}
195
+ const url = String(d.url || '').trim()
196
+ if (/^https?:/i.test(url)) {
197
+ try {
198
+ const ctrl = new AbortController()
199
+ const timer = setTimeout(() => ctrl.abort(), 8000)
200
+ try {
201
+ const r = await fetch(url, { signal: ctrl.signal })
202
+ if (r.ok) return Buffer.from(await r.arrayBuffer())
203
+ } finally {
204
+ clearTimeout(timer)
205
+ }
206
+ } catch (error) {}
207
+ }
208
+ const file = String(d.file || '').trim()
209
+ if (file.startsWith('base64://')) {
210
+ try { return Buffer.from(file.slice('base64://'.length), 'base64') } catch (error) {}
211
+ }
212
+ if (/^https?:/i.test(file)) {
213
+ try {
214
+ const r = await fetch(file)
215
+ if (r.ok) return Buffer.from(await r.arrayBuffer())
216
+ } catch (error) {}
217
+ }
218
+ if (file && (file.includes('/') || file.includes('\\'))) {
219
+ try { return readFileSync(file) } catch (error) {}
220
+ }
221
+ if (file) {
222
+ try {
223
+ const info = await onebot('get_image', { file }, cfg)
224
+ const u = String((info && (info.url || info.file)) || '').trim()
225
+ if (/^https?:/i.test(u)) {
226
+ const r = await fetch(u)
227
+ if (r.ok) return Buffer.from(await r.arrayBuffer())
228
+ }
229
+ if (u && (u.includes('/') || u.includes('\\'))) {
230
+ try { return readFileSync(u) } catch (error) {}
231
+ }
232
+ } catch (error) {}
233
+ }
234
+ return null
235
+ }
236
+
237
+ function admitErrorText(error) {
238
+ if (!error) return '未知错误'
239
+ return (error.code ? String(error.code) + ':' : '') + String(error.message || error)
240
+ }
241
+
242
+ // 单张被拒时可以重编码再试;批量/存储层错误重试没有意义。
243
+ const RETRYABLE_IMAGE_ERRORS = new Set([
244
+ 'IMAGE_TYPE_MISMATCH',
245
+ 'INVALID_IMAGE',
246
+ 'UNSUPPORTED_IMAGE_TYPE',
247
+ 'IMAGE_TOO_LARGE',
248
+ 'IMAGE_TOO_MANY_PIXELS',
249
+ 'IMAGE_DIMENSION_TOO_LARGE',
250
+ ])
251
+
252
+ async function admitOneImage(attachments, block) {
253
+ try {
254
+ const out = await attachments.admitPromptContent([block])
255
+ const hit = (Array.isArray(out) ? out : []).find((x) => x && x.type === 'image')
256
+ return hit ? { block: hit } : { error: new Error('附件层没有返回图片') }
257
+ } catch (error) {
258
+ return { error }
259
+ }
260
+ }
261
+
262
+ async function retryImageBlock(block) {
263
+ let bytes
264
+ try {
265
+ bytes = Buffer.from(String((block && block.data) || ''), 'base64')
266
+ } catch (error) {
267
+ return null
268
+ }
269
+ if (!bytes || !bytes.length) return null
270
+ const type = sniffImageType(bytes)
271
+ const fixed = type ? await shrinkImage(bytes) : await convertImage(bytes)
272
+ if (!fixed) return null
273
+ return { ...block, data: fixed.bytes.toString('base64'), mediaType: fixed.mediaType }
274
+ }
275
+
276
+ // 一张坏图不能拖垮整批:先整批送,失败后退回逐张,逐张失败的再重编码试一次。
277
+ async function admitPreparedImages(attachments, prepared) {
278
+ const blocks = prepared.map((x) => x.block)
279
+ try {
280
+ const out = await attachments.admitPromptContent(blocks)
281
+ const images = (Array.isArray(out) ? out : []).filter((x) => x && x.type === 'image')
282
+ if (images.length === blocks.length) {
283
+ return images.map((block, i) => ({ caption: prepared[i].caption, block }))
284
+ }
285
+ diag('图片 admit 返回数量不符(' + images.length + '/' + blocks.length + '),改为逐张处理')
286
+ } catch (error) {
287
+ diag('图片批量 admit 失败,改为逐张重试:' + admitErrorText(error))
288
+ }
289
+ const admitted = []
290
+ let firstError = ''
291
+ for (const item of prepared) {
292
+ let result = await admitOneImage(attachments, item.block)
293
+ if (result.error && RETRYABLE_IMAGE_ERRORS.has(String(result.error.code || ''))) {
294
+ const retried = await retryImageBlock(item.block)
295
+ if (retried) result = await admitOneImage(attachments, retried)
296
+ }
297
+ if (result.block) admitted.push({ caption: item.caption, block: result.block })
298
+ else if (!firstError) firstError = admitErrorText(result.error)
299
+ }
300
+ if (firstError && admitted.length < prepared.length) {
301
+ diag('有图片没能进来(已跳过,其余保留):' + firstError)
302
+ }
303
+ return admitted
304
+ }
305
+
306
+ export function admitImagesFor(ctx) {
307
+ return async function admitImages(list) {
308
+ const attachments = ctx.attachments || (typeof ctx.get === 'function' ? ctx.get('attachments') : null)
309
+ if (!attachments || typeof attachments.admitPromptContent !== 'function') return []
310
+ const prepared = []
311
+ for (const x of Array.isArray(list) ? list : []) {
312
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
313
+ let bytes = await imageBytes(x.data, x.cfg || loadCfg())
314
+ if (!bytes || !bytes.length) continue
315
+ const name = String(x.name || 'qq-image.jpg')
316
+ // animated 只是提示,type 一律按字节来;猜错会被 DSH 按 IMAGE_TYPE_MISMATCH 整批拒掉。
317
+ let mediaType = sniffImageType(bytes) || (await sharpImageType(bytes))
318
+ if (!mediaType) {
319
+ const converted = await convertImage(bytes)
320
+ if (!converted) {
321
+ diag('图片跳过:认不出格式(' + name + ')')
322
+ continue
323
+ }
324
+ bytes = converted.bytes
325
+ mediaType = converted.mediaType
326
+ }
327
+ if (mediaType === 'image/gif') {
328
+ const frames = await gifFrames(bytes, 3)
329
+ if (frames) {
330
+ for (const f of frames) {
331
+ prepared.push({
332
+ caption: String(x.caption || '') + `(GIF 第 ${f.page}/${f.pages} 帧)`,
333
+ block: {
334
+ type: 'image',
335
+ data: f.bytes.toString('base64'),
336
+ mediaType: 'image/png',
337
+ name: name.replace(/\.[^.]+$/, '') + `-gif-${f.page}.png`,
338
+ },
339
+ })
340
+ if (prepared.length >= ADMIT_IMAGE_LIMIT) break
341
+ }
342
+ continue
343
+ }
344
+ if (!sharp && !gifWarned) {
345
+ gifWarned = true
346
+ diag('GIF 帧拆分不可用:没有加载到 sharp,动图会按静图处理')
347
+ }
348
+ }
349
+ // 大图不在这里预缩放:DSH 自己会把源图归一化到 4MiB,20MiB 以内都能收。
350
+ // 只有 admit 因尺寸被拒时才在 retryImageBlock 里用 sharp 兜底重编码。
351
+ prepared.push({
352
+ caption: String(x.caption || ''),
353
+ block: { type: 'image', data: bytes.toString('base64'), mediaType, name },
354
+ })
355
+ }
356
+ if (!prepared.length) return []
357
+ return admitPreparedImages(attachments, prepared)
358
+ }
359
+ }
360
+
361
+ // SnowLuma 的 add_custom_face 只认 file:///、base64://、http(s)://,
362
+ // 本机路径要先转成 file:/// URL,data:image/... 转成 base64://。
363
+ export function imageSourceOf(raw) {
364
+ const s = String(raw || '').trim()
365
+ if (!s) return ''
366
+ if (/^(https?:\/\/|base64:\/\/|file:\/\/)/i.test(s)) return s
367
+ const dataUrl = s.match(/^data:[^;,]*;base64,(.*)$/is)
368
+ if (dataUrl) return 'base64://' + dataUrl[1].replace(/\s+/g, '')
369
+ try {
370
+ if (existsSync(s)) return pathToFileURL(s).href
371
+ } catch (error) {}
372
+ return s
373
+ }
374
+
375
+ // 从聊天消息里取图片:get_msg → 图片段 → 可直接交给 SnowLuma 的 file 来源。
376
+ // 收藏表情、换头像、发说说都用这一条路径,索引从 0 开始。
377
+ export async function imageSourceFromMessage(messageId, index, cfg) {
378
+ const msg = await onebot('get_msg', { message_id: messageId }, cfg)
379
+ const segs = Array.isArray(msg && msg.message) ? msg.message : []
380
+ const images = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
381
+ const idx = Number.isInteger(Number(index)) ? Math.max(0, Number(index)) : 0
382
+ const pick = images[idx]
383
+ if (!pick) return { error: `这条消息里没有第 ${idx} 张图片(共 ${images.length} 张)` }
384
+ const source = imageSourceOf(pick.data.url || pick.data.file)
385
+ if (!source) return { error: '拿不到这条消息里的图片地址' }
386
+ return { source, total: images.length }
387
+ }
388
+
389
+ export async function imageSourcesFromMessage(messageId, indexes, cfg, max = 9) {
390
+ const msg = await onebot('get_msg', { message_id: messageId }, cfg)
391
+ const segs = Array.isArray(msg && msg.message) ? msg.message : []
392
+ const picks = segs.filter((s) => s && (s.type === 'image' || s.type === 'mface') && s.data)
393
+ const wanted = Array.isArray(indexes) && indexes.length
394
+ ? indexes.map((x) => Number(x)).filter((x) => Number.isInteger(x))
395
+ : picks.map((_, i) => i)
396
+ const sources = []
397
+ for (const i of wanted) {
398
+ if (sources.length >= max) break
399
+ const pick = picks[i]
400
+ if (!pick) continue
401
+ const source = imageSourceOf(pick.data.url || pick.data.file)
402
+ if (source) sources.push(source)
403
+ }
404
+ return { sources, total: picks.length }
405
+ }
406
+
407
+ // 工具回执里不要把 base64 原样吐回去,不然一次换头像就能塞满上下文。
408
+ export function describeSource(source) {
409
+ const s = String(source || '')
410
+ if (/^base64:\/\//i.test(s)) return 'base64://…'
411
+ if (/^file:\/\//i.test(s)) {
412
+ try {
413
+ return decodeURIComponent(new URL(s).pathname).split('/').pop() || '本机图片'
414
+ } catch (error) {
415
+ return '本机图片'
416
+ }
417
+ }
418
+ if (/^https?:/i.test(s)) return s.length > 200 ? s.slice(0, 200) + '…' : s
419
+ return s.slice(0, 200)
420
+ }
package/lib/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  // 「挂载会话」的 HTTP 接口;浏览器半侧(lib/client.js)也只能从包根行
6
6
  // 被发现,子路径行不会被 dsh-client-modules 扫描到。
7
7
  // · 旧配置直接引用 `dsh-qq-tools` 时没有 carrier 标记,仍然挂全部工具。
8
- import { mountAll, mountTargetSessionRoute } from './shared.js'
8
+ import { mountAll } from './mount-all.js'
9
+ import { mountTargetSessionRoute } from './session.js'
9
10
 
10
11
  export * from './shared.js'
11
12