dsh-qq-tools 0.10.1 → 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 CHANGED
@@ -25,7 +25,17 @@
25
25
 
26
26
  ```
27
27
  cordis.patch.yml 12 个组件行
28
- lib/shared.js 共享核心:配置、SnowLuma 接口、挂载、@ 轮询、私聊唤醒、戳一戳、表情、头像、好友管理、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/ 包根行在插件页显示的标题和说明
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
 
@@ -0,0 +1,59 @@
1
+ // 兼容:老的整体插件入口(一个插件挂全部工具)。
2
+
3
+ import { admitImagesFor } from './images.js'
4
+ import { mountOneTool } from './session.js'
5
+ import { startObservation } from './observation.js'
6
+ import { makeListTool, makeHistoryTool, makeMembersTool, makeSendTool } from './tools/groups.js'
7
+ import { makeListFriendsTool, makePrivateHistoryTool, makeSendPrivateTool } from './tools/private.js'
8
+ import { makeSendFaceTool, makeListFacesTool, makeFavoriteFaceTool } from './tools/faces.js'
9
+ import { makePokeTool } from './tools/poke.js'
10
+ import { makeSetAvatarTool } from './tools/avatar.js'
11
+ import {
12
+ makeGetFriendInfoTool, makeListFriendCategoriesTool, makeUpdateFriendTool,
13
+ makeDeleteFriendTool, makeSendLikeTool, makeListFriendRequestsTool,
14
+ makeHandleFriendRequestTool, startFriendRequestWatcher,
15
+ } from './tools/friends.js'
16
+ import { makeListQzoneTool, makeGetQzonePostTool, makeSendQzonePostTool, makeManageQzoneTool } from './tools/qzone.js'
17
+ import { startMentionPolling } from './watchers/mentions.js'
18
+ import { startPokeWatcher } from './watchers/poke.js'
19
+ import { startPrivateWatcher } from './watchers/private.js'
20
+
21
+ export function mountAll(ctx) {
22
+ const admitImages = admitImagesFor(ctx)
23
+ const list = mountOneTool(ctx, makeListTool())
24
+ const history = mountOneTool(ctx, makeHistoryTool(admitImages))
25
+ const members = mountOneTool(ctx, makeMembersTool())
26
+ const send = mountOneTool(ctx, makeSendTool(startObservation))
27
+ const face = mountOneTool(ctx, makeSendFaceTool(startObservation, admitImages))
28
+ const faceList = mountOneTool(ctx, makeListFacesTool())
29
+ const faceFav = mountOneTool(ctx, makeFavoriteFaceTool())
30
+ const poke = mountOneTool(ctx, makePokeTool(startObservation))
31
+ const avatar = mountOneTool(ctx, makeSetAvatarTool())
32
+ const friends = mountOneTool(ctx, makeListFriendsTool())
33
+ const privateHistory = mountOneTool(ctx, makePrivateHistoryTool(admitImages))
34
+ const privateSend = mountOneTool(ctx, makeSendPrivateTool())
35
+ const friendInfo = mountOneTool(ctx, makeGetFriendInfoTool())
36
+ const friendCategories = mountOneTool(ctx, makeListFriendCategoriesTool())
37
+ const friendUpdate = mountOneTool(ctx, makeUpdateFriendTool())
38
+ const friendDelete = mountOneTool(ctx, makeDeleteFriendTool())
39
+ const friendLike = mountOneTool(ctx, makeSendLikeTool())
40
+ const friendRequests = mountOneTool(ctx, makeListFriendRequestsTool())
41
+ const friendHandle = mountOneTool(ctx, makeHandleFriendRequestTool())
42
+ const qzoneList = mountOneTool(ctx, makeListQzoneTool(admitImages))
43
+ const qzoneDetail = mountOneTool(ctx, makeGetQzonePostTool(admitImages))
44
+ const qzoneSend = mountOneTool(ctx, makeSendQzonePostTool())
45
+ const qzoneManage = mountOneTool(ctx, makeManageQzoneTool())
46
+ startMentionPolling(ctx)
47
+ startPokeWatcher(ctx)
48
+ startPrivateWatcher(ctx, admitImages)
49
+ startFriendRequestWatcher(ctx)
50
+ return {
51
+ tools: [
52
+ list, history, members, send, face, faceList, faceFav, poke, avatar,
53
+ friends, privateHistory, privateSend,
54
+ friendInfo, friendCategories, friendUpdate, friendDelete, friendLike, friendRequests, friendHandle,
55
+ qzoneList, qzoneDetail, qzoneSend, qzoneManage,
56
+ ],
57
+ startObservation,
58
+ }
59
+ }
@@ -0,0 +1,24 @@
1
+ // 观察窗:群聊发消息 / 戳回去之后的后续消息,只对群生效。
2
+ import { loadCfg, diag } from './config.js'
3
+
4
+ // ---------- 观察窗(群聊发消息 / 戳回去之后的后续消息) ----------
5
+ // 私聊不走观察窗:每条私聊消息都会直接唤醒目标会话。
6
+ const windows = new Map()
7
+
8
+ export function startObservation(groupId, reason) {
9
+ const cfg = loadCfg()
10
+ const id = String(groupId || '')
11
+ if (!id) return
12
+ const ms = Math.max(30000, Math.min(Number(cfg.observationMs) || 60000, 600000))
13
+ windows.set(id, Date.now() + ms)
14
+ diag('观察窗:群 ' + id + ' 保持 ' + Math.round(ms / 1000) + ' 秒(' + String(reason || '') + ')')
15
+ }
16
+
17
+ export function windowUntil(groupId) {
18
+ return windows.get(String(groupId)) || 0
19
+ }
20
+
21
+ export function pruneExpiredWindows() {
22
+ const now = Date.now()
23
+ for (const [gid, until] of windows) if (now >= until) windows.delete(gid)
24
+ }
package/lib/onebot.js ADDED
@@ -0,0 +1,32 @@
1
+ // SnowLuma(OneBot 11 风格)HTTP 接口封装。
2
+
3
+ export async function onebot(action, body, cfg) {
4
+ const base = String(cfg.napCat || '').replace(/\/+$/, '')
5
+ if (!base) throw new Error('没有配置 SnowLuma 地址')
6
+ const ctrl = new AbortController()
7
+ const timer = setTimeout(() => ctrl.abort(new Error('SnowLuma 请求超时')), cfg.timeoutMs || 6000)
8
+ try {
9
+ const headers = { 'content-type': 'application/json' }
10
+ if (cfg.accessToken) headers.authorization = 'Bearer ' + cfg.accessToken
11
+ const r = await fetch(base + '/' + action, {
12
+ method: 'POST',
13
+ headers,
14
+ body: JSON.stringify(body || {}),
15
+ signal: ctrl.signal,
16
+ })
17
+ const text = await r.text()
18
+ let data
19
+ try {
20
+ data = JSON.parse(text)
21
+ } catch (error) {
22
+ throw new Error('SnowLuma 返回不是 JSON:' + text.slice(0, 200))
23
+ }
24
+ if (!r.ok) throw new Error('SnowLuma HTTP ' + r.status + ':' + text.slice(0, 200))
25
+ if (data && data.status && data.status !== 'ok') {
26
+ throw new Error('SnowLuma 错误:' + String(data.message || data.wording || ('retcode ' + data.retcode)))
27
+ }
28
+ return data && data.data !== undefined ? data.data : data
29
+ } finally {
30
+ clearTimeout(timer)
31
+ }
32
+ }