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.
- package/README.md +23 -11
- package/cordis.patch.yml +1 -1
- package/lib/config.js +96 -0
- package/lib/images.js +420 -0
- package/lib/index.js +2 -1
- package/lib/mount-all.js +59 -0
- package/lib/observation.js +24 -0
- package/lib/onebot.js +32 -0
- package/lib/prompts.js +56 -0
- package/lib/qq-state.js +51 -0
- package/lib/render.js +49 -0
- package/lib/session.js +201 -0
- package/lib/shared.js +26 -3377
- package/lib/tools/avatar.js +54 -0
- package/lib/tools/faces.js +296 -0
- package/lib/tools/friends.js +559 -0
- package/lib/tools/groups.js +220 -0
- package/lib/tools/poke.js +41 -0
- package/lib/tools/private.js +190 -0
- package/lib/tools/qzone.js +569 -0
- package/lib/utils.js +73 -0
- package/lib/watchers/mentions.js +167 -0
- package/lib/watchers/poke.js +87 -0
- package/lib/watchers/private.js +95 -0
- package/lib/watchers/ws.js +61 -0
- package/package.json +98 -103
- package/tools/qq-favorite-faces/index.js +11 -10
- package/tools/qq-favorite-faces/locale/en.json +1 -1
- package/tools/qq-favorite-faces/locale/zh.json +1 -1
- package/tools/qq-friend-management/index.js +28 -28
- package/tools/qq-get-group-history/index.js +10 -8
- package/tools/qq-get-group-members/index.js +9 -8
- package/tools/qq-list-groups/index.js +9 -8
- package/tools/qq-private-chat/index.js +16 -20
- package/tools/qq-qzone/index.js +22 -22
- package/tools/qq-send-face/index.js +11 -8
- package/tools/qq-send-face/locale/en.json +1 -1
- package/tools/qq-send-face/locale/zh.json +1 -1
- package/tools/qq-send-group-message/index.js +13 -10
- package/tools/qq-send-poke/index.js +13 -10
- 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
|
|
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` |
|
|
14
|
-
| 收藏表情 | `qq_list_faces` / `qq_favorite_face` |
|
|
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/
|
|
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
|
|
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
|
|
94
|
-
- `qq_send_face
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
# · 发表情 ——
|
|
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
|
|
8
|
+
import { mountAll } from './mount-all.js'
|
|
9
|
+
import { mountTargetSessionRoute } from './session.js'
|
|
9
10
|
|
|
10
11
|
export * from './shared.js'
|
|
11
12
|
|