@fg-lab/amadeus-qq 1.2.1 → 1.2.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 (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
@@ -1,309 +1,309 @@
1
- import fs from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { createHash } from 'node:crypto'
5
-
6
- const MIB = 1024 * 1024
7
- export const OUTBOUND_MEDIA_LIMITS = Object.freeze({
8
- image: 5 * MIB,
9
- gif: 5 * MIB,
10
- video: 20 * MIB,
11
- file: 20 * MIB,
12
- audio: 4 * MIB,
13
- })
14
- export const TTS_UNAVAILABLE_CODE = 'QQ_TTS_UNAVAILABLE'
15
-
16
- const BLOCKED_FILE_EXTENSIONS = new Set([
17
- '.bat', '.cmd', '.com', '.dll', '.exe', '.hta', '.js', '.jse', '.lnk',
18
- '.msi', '.msp', '.ps1', '.scr', '.vbe', '.vbs', '.wsf', '.wsh',
19
- ])
20
-
21
- function mediaError(code) {
22
- const error = new Error(code)
23
- error.code = code
24
- return error
25
- }
26
-
27
- function assertSend(core, sessionId) {
28
- let scene
29
- try {
30
- scene = core.authorizedScene(sessionId)
31
- } catch {
32
- throw mediaError('QQ_PERMISSION_DENIED: qq.send')
33
- }
34
- let allowed = false
35
- try {
36
- allowed = core.authorizeSessionCapability(sessionId, 'qq.send') === true
37
- } catch {
38
- allowed = false
39
- }
40
- if (!allowed) throw mediaError('QQ_PERMISSION_DENIED: qq.send')
41
- return scene
42
- }
43
-
44
- function resolveSendRoot(sendRoot) {
45
- if (typeof sendRoot !== 'string' || !path.isAbsolute(sendRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
46
- let root
47
- try {
48
- root = fs.realpathSync(sendRoot)
49
- if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
50
- } catch {
51
- throw mediaError('QQ_MEDIA_ROOT_INVALID')
52
- }
53
- return root
54
- }
55
-
56
- function resolveRootSource(relative, root, sendRoot) {
57
- if (typeof relative !== 'string' || relative.length === 0 || relative.includes('\u0000')) {
58
- throw mediaError('QQ_MEDIA_PATH_INVALID')
59
- }
60
- if (path.isAbsolute(relative)) throw mediaError('QQ_MEDIA_PATH_INVALID')
61
- const candidate = path.resolve(sendRoot, relative)
62
- const lexical = path.relative(sendRoot, candidate)
63
- if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
64
- throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
65
- }
66
-
67
- let entry
68
- try {
69
- entry = fs.lstatSync(candidate)
70
- } catch {
71
- throw mediaError('QQ_MEDIA_PATH_INVALID')
72
- }
73
- if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
74
-
75
- let real
76
- try {
77
- real = fs.realpathSync(candidate)
78
- } catch {
79
- throw mediaError('QQ_MEDIA_PATH_INVALID')
80
- }
81
- const relativeReal = path.relative(root, real)
82
- if (relativeReal === '' || relativeReal === '..' ||
83
- relativeReal.startsWith(`..${path.sep}`) || path.isAbsolute(relativeReal)) {
84
- throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
85
- }
86
-
87
- let cursor = sendRoot
88
- for (const part of lexical.split(path.sep)) {
89
- cursor = path.join(cursor, part)
90
- try {
91
- if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
92
- } catch (error) {
93
- if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
94
- throw mediaError('QQ_MEDIA_PATH_INVALID')
95
- }
96
- }
97
- return real
98
- }
99
-
100
- function resolveHostAttachment(source, kind, attachments) {
101
- const ref = source.attachment
102
- if (!ref || typeof ref !== 'object' || Array.isArray(ref) ||
103
- typeof ref.attachmentId !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(ref.attachmentId) ||
104
- typeof ref.name !== 'string' || typeof ref.bytes !== 'number' ||
105
- !Number.isSafeInteger(ref.bytes) || ref.bytes < 1) {
106
- throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
107
- }
108
- if (!attachments || typeof attachments !== 'object') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
109
- const resolver = kind === 'image' || kind === 'gif' ? attachments.imageHostPath : attachments.fileHostPath
110
- if (typeof resolver !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
111
- let hostPath
112
- try {
113
- hostPath = resolver.call(attachments, ref)
114
- } catch {
115
- throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
116
- }
117
- if (typeof hostPath !== 'string' || !path.isAbsolute(hostPath)) throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
118
- return hostPath
119
- }
120
-
121
- function statRegularFile(file) {
122
- let entry
123
- try {
124
- entry = fs.lstatSync(file)
125
- } catch {
126
- throw mediaError('QQ_MEDIA_PATH_INVALID')
127
- }
128
- if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
129
- return entry
130
- }
131
-
132
- function sniffKind(kind, data, name) {
133
- if (kind === 'image') {
134
- if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
135
- if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
136
- if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
137
- throw mediaError('QQ_MEDIA_FORMAT_INVALID')
138
- }
139
- if (kind === 'gif') {
140
- if (data.length >= 6 && (data.toString('ascii', 0, 6) === 'GIF87a' || data.toString('ascii', 0, 6) === 'GIF89a')) return 'image/gif'
141
- throw mediaError('QQ_MEDIA_FORMAT_INVALID')
142
- }
143
- if (kind === 'video') {
144
- if (data.length >= 12 && data.toString('ascii', 4, 8) === 'ftyp') return 'video/mp4'
145
- if (data.length >= 4 && data[0] === 0x1a && data[1] === 0x45 && data[2] === 0xdf && data[3] === 0xa3) return 'video/webm'
146
- throw mediaError('QQ_MEDIA_FORMAT_INVALID')
147
- }
148
- if (kind === 'audio') {
149
- if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav'
150
- if (data.length >= 3 && data.toString('ascii', 0, 3) === 'ID3') return 'audio/mpeg'
151
- if (data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) return 'audio/mpeg'
152
- if (data.length >= 4 && data.toString('ascii', 0, 4) === 'OggS') return 'audio/ogg'
153
- if (data.length >= 4 && data.toString('ascii', 0, 4) === 'fLaC') return 'audio/flac'
154
- if (data.length >= 6 && data.toString('ascii', 0, 6) === '#!AMR\n') return 'audio/amr'
155
- throw mediaError('QQ_MEDIA_FORMAT_INVALID')
156
- }
157
- if (kind === 'file') {
158
- const extension = path.extname(name).toLowerCase()
159
- if (!extension || BLOCKED_FILE_EXTENSIONS.has(extension)) throw mediaError('QQ_MEDIA_FORMAT_INVALID')
160
- return 'application/octet-stream'
161
- }
162
- throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
163
- }
164
-
165
- async function readAndHash(file, maxBytes) {
166
- let handle
167
- try {
168
- handle = await fs.promises.open(file, 'r')
169
- const stats = await handle.stat()
170
- if (!stats.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
171
- if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
172
- if (stats.size > maxBytes) throw mediaError('QQ_MEDIA_TOO_LARGE')
173
- const data = Buffer.alloc(stats.size)
174
- let offset = 0
175
- while (offset < data.length) {
176
- const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
177
- if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
178
- offset += bytesRead
179
- }
180
- return { data, sha256: createHash('sha256').update(data).digest('hex') }
181
- } catch (error) {
182
- if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
183
- throw mediaError('QQ_MEDIA_READ_FAILED')
184
- } finally {
185
- if (handle) await handle.close().catch(() => {})
186
- }
187
- }
188
-
189
- function safeName(value, kind) {
190
- const base = path.basename(String(value ?? '')).replace(/[\u0000-\u001f\u007f]/g, '').trim()
191
- if (base.length === 0) return kind === 'video' ? 'qq-video' : 'qq-file'
192
- return base.slice(0, 128)
193
- }
194
-
195
- /**
196
- * Validate one controlled PNG/JPEG/GIF below sendRoot and copy the exact bytes
197
- * into a private temporary snapshot for one platform call. The caller owns
198
- * cleanup and must never expose the snapshot path to model output.
199
- */
200
- export async function snapshotControlledImage({ sendRoot, relativePath, tempRoot } = {}) {
201
- const root = resolveSendRoot(sendRoot)
202
- const source = resolveRootSource(relativePath, root, sendRoot)
203
- const { data, sha256 } = await readAndHash(source, 5 * MIB)
204
- let mediaType
205
- try {
206
- mediaType = sniffKind('image', data, path.basename(source))
207
- } catch {
208
- mediaType = sniffKind('gif', data, path.basename(source))
209
- }
210
- if (tempRoot !== undefined && (typeof tempRoot !== 'string' || !path.isAbsolute(tempRoot))) {
211
- throw mediaError('QQ_MEDIA_TEMP_INVALID')
212
- }
213
- let tempDir
214
- try {
215
- if (tempRoot !== undefined && !fs.statSync(tempRoot).isDirectory()) throw new Error('not a directory')
216
- tempDir = await fs.promises.mkdtemp(path.join(tempRoot ?? os.tmpdir(), 'amadeus-qq-avatar-'))
217
- await fs.promises.chmod(tempDir, 0o700)
218
- const extension = mediaType === 'image/png' ? '.png' : mediaType === 'image/jpeg' ? '.jpg' : '.gif'
219
- const snapshot = path.join(tempDir, `avatar${extension}`)
220
- await fs.promises.writeFile(snapshot, data, { flag: 'wx', mode: 0o600 })
221
- const written = await fs.promises.readFile(snapshot)
222
- if (written.length !== data.length || createHash('sha256').update(written).digest('hex') !== sha256) {
223
- throw mediaError('QQ_MEDIA_SNAPSHOT_INVALID')
224
- }
225
- let cleaned = false
226
- return {
227
- path: snapshot,
228
- mediaType,
229
- bytes: data.length,
230
- sha256,
231
- async cleanup() {
232
- if (cleaned) return
233
- cleaned = true
234
- await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
235
- },
236
- }
237
- } catch (error) {
238
- if (tempDir) await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
239
- if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
240
- throw mediaError('QQ_MEDIA_SNAPSHOT_FAILED')
241
- }
242
- }
243
-
244
- /**
245
- * Build one controlled outbound media plan. `source` must be either a relative
246
- * path below the configured sendRoot, or an already-validated Host attachment
247
- * reference owned by the caller. Never pass model-supplied paths, URLs or
248
- * base64 into this helper. Message plans are ready for core.send(); upload
249
- * plans require the core's delivery ledger with the returned fingerprint.
250
- */
251
- export async function prepareOutboundMedia({
252
- core,
253
- sessionId,
254
- kind,
255
- source,
256
- sendRoot,
257
- attachments,
258
- }) {
259
- if (kind === 'voice' || kind === 'record') throw mediaError(TTS_UNAVAILABLE_CODE)
260
- if (!Object.hasOwn(OUTBOUND_MEDIA_LIMITS, kind)) throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
261
- if (!source || typeof source !== 'object' || Array.isArray(source)) throw mediaError('QQ_MEDIA_SOURCE_INVALID')
262
-
263
- const scene = assertSend(core, sessionId)
264
- const maxBytes = OUTBOUND_MEDIA_LIMITS[kind]
265
- let root
266
- let file
267
- if (source.type === 'send-root') {
268
- if (Object.keys(source).sort().join(',') !== 'path,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
269
- root = resolveSendRoot(sendRoot)
270
- file = resolveRootSource(source.path, root, sendRoot)
271
- } else if (source.type === 'host-attachment') {
272
- if (Object.keys(source).sort().join(',') !== 'attachment,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
273
- file = resolveHostAttachment(source, kind, attachments)
274
- statRegularFile(file)
275
- } else {
276
- throw mediaError('QQ_MEDIA_SOURCE_INVALID')
277
- }
278
-
279
- const { data, sha256 } = await readAndHash(file, maxBytes)
280
- const name = safeName(path.basename(file), kind)
281
- const mediaType = sniffKind(kind, data, name)
282
-
283
- // Re-check the bound Scene immediately before returning an executable plan.
284
- assertSend(core, sessionId)
285
-
286
- if (kind === 'file') {
287
- const group = scene.kind === 'group'
288
- return {
289
- mode: 'upload',
290
- action: group ? 'upload_group_file' : 'upload_private_file',
291
- params: group
292
- ? { group_id: scene.peerId, file, name, upload_file: true }
293
- : { user_id: scene.peerId, file, name, upload_file: true },
294
- mediaType,
295
- bytes: data.length,
296
- fingerprint: sha256,
297
- }
298
- }
299
- return {
300
- mode: 'message',
301
- segments: [{
302
- type: kind === 'video' ? 'video' : kind === 'audio' ? 'record' : 'image',
303
- data: { file },
304
- }],
305
- mediaType,
306
- bytes: data.length,
307
- fingerprint: sha256,
308
- }
309
- }
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ const MIB = 1024 * 1024
7
+ export const OUTBOUND_MEDIA_LIMITS = Object.freeze({
8
+ image: 5 * MIB,
9
+ gif: 5 * MIB,
10
+ video: 20 * MIB,
11
+ file: 20 * MIB,
12
+ audio: 4 * MIB,
13
+ })
14
+ export const TTS_UNAVAILABLE_CODE = 'QQ_TTS_UNAVAILABLE'
15
+
16
+ const BLOCKED_FILE_EXTENSIONS = new Set([
17
+ '.bat', '.cmd', '.com', '.dll', '.exe', '.hta', '.js', '.jse', '.lnk',
18
+ '.msi', '.msp', '.ps1', '.scr', '.vbe', '.vbs', '.wsf', '.wsh',
19
+ ])
20
+
21
+ function mediaError(code) {
22
+ const error = new Error(code)
23
+ error.code = code
24
+ return error
25
+ }
26
+
27
+ function assertSend(core, sessionId) {
28
+ let scene
29
+ try {
30
+ scene = core.authorizedScene(sessionId)
31
+ } catch {
32
+ throw mediaError('QQ_PERMISSION_DENIED: qq.send')
33
+ }
34
+ let allowed = false
35
+ try {
36
+ allowed = core.authorizeSessionCapability(sessionId, 'qq.send') === true
37
+ } catch {
38
+ allowed = false
39
+ }
40
+ if (!allowed) throw mediaError('QQ_PERMISSION_DENIED: qq.send')
41
+ return scene
42
+ }
43
+
44
+ function resolveSendRoot(sendRoot) {
45
+ if (typeof sendRoot !== 'string' || !path.isAbsolute(sendRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
46
+ let root
47
+ try {
48
+ root = fs.realpathSync(sendRoot)
49
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
50
+ } catch {
51
+ throw mediaError('QQ_MEDIA_ROOT_INVALID')
52
+ }
53
+ return root
54
+ }
55
+
56
+ function resolveRootSource(relative, root, sendRoot) {
57
+ if (typeof relative !== 'string' || relative.length === 0 || relative.includes('\u0000')) {
58
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
59
+ }
60
+ if (path.isAbsolute(relative)) throw mediaError('QQ_MEDIA_PATH_INVALID')
61
+ const candidate = path.resolve(sendRoot, relative)
62
+ const lexical = path.relative(sendRoot, candidate)
63
+ if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
64
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
65
+ }
66
+
67
+ let entry
68
+ try {
69
+ entry = fs.lstatSync(candidate)
70
+ } catch {
71
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
72
+ }
73
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
74
+
75
+ let real
76
+ try {
77
+ real = fs.realpathSync(candidate)
78
+ } catch {
79
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
80
+ }
81
+ const relativeReal = path.relative(root, real)
82
+ if (relativeReal === '' || relativeReal === '..' ||
83
+ relativeReal.startsWith(`..${path.sep}`) || path.isAbsolute(relativeReal)) {
84
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
85
+ }
86
+
87
+ let cursor = sendRoot
88
+ for (const part of lexical.split(path.sep)) {
89
+ cursor = path.join(cursor, part)
90
+ try {
91
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
92
+ } catch (error) {
93
+ if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
94
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
95
+ }
96
+ }
97
+ return real
98
+ }
99
+
100
+ function resolveHostAttachment(source, kind, attachments) {
101
+ const ref = source.attachment
102
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref) ||
103
+ typeof ref.attachmentId !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(ref.attachmentId) ||
104
+ typeof ref.name !== 'string' || typeof ref.bytes !== 'number' ||
105
+ !Number.isSafeInteger(ref.bytes) || ref.bytes < 1) {
106
+ throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
107
+ }
108
+ if (!attachments || typeof attachments !== 'object') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
109
+ const resolver = kind === 'image' || kind === 'gif' ? attachments.imageHostPath : attachments.fileHostPath
110
+ if (typeof resolver !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
111
+ let hostPath
112
+ try {
113
+ hostPath = resolver.call(attachments, ref)
114
+ } catch {
115
+ throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
116
+ }
117
+ if (typeof hostPath !== 'string' || !path.isAbsolute(hostPath)) throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
118
+ return hostPath
119
+ }
120
+
121
+ function statRegularFile(file) {
122
+ let entry
123
+ try {
124
+ entry = fs.lstatSync(file)
125
+ } catch {
126
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
127
+ }
128
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
129
+ return entry
130
+ }
131
+
132
+ function sniffKind(kind, data, name) {
133
+ if (kind === 'image') {
134
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
135
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
136
+ if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
137
+ throw mediaError('QQ_MEDIA_FORMAT_INVALID')
138
+ }
139
+ if (kind === 'gif') {
140
+ if (data.length >= 6 && (data.toString('ascii', 0, 6) === 'GIF87a' || data.toString('ascii', 0, 6) === 'GIF89a')) return 'image/gif'
141
+ throw mediaError('QQ_MEDIA_FORMAT_INVALID')
142
+ }
143
+ if (kind === 'video') {
144
+ if (data.length >= 12 && data.toString('ascii', 4, 8) === 'ftyp') return 'video/mp4'
145
+ if (data.length >= 4 && data[0] === 0x1a && data[1] === 0x45 && data[2] === 0xdf && data[3] === 0xa3) return 'video/webm'
146
+ throw mediaError('QQ_MEDIA_FORMAT_INVALID')
147
+ }
148
+ if (kind === 'audio') {
149
+ if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav'
150
+ if (data.length >= 3 && data.toString('ascii', 0, 3) === 'ID3') return 'audio/mpeg'
151
+ if (data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) return 'audio/mpeg'
152
+ if (data.length >= 4 && data.toString('ascii', 0, 4) === 'OggS') return 'audio/ogg'
153
+ if (data.length >= 4 && data.toString('ascii', 0, 4) === 'fLaC') return 'audio/flac'
154
+ if (data.length >= 6 && data.toString('ascii', 0, 6) === '#!AMR\n') return 'audio/amr'
155
+ throw mediaError('QQ_MEDIA_FORMAT_INVALID')
156
+ }
157
+ if (kind === 'file') {
158
+ const extension = path.extname(name).toLowerCase()
159
+ if (!extension || BLOCKED_FILE_EXTENSIONS.has(extension)) throw mediaError('QQ_MEDIA_FORMAT_INVALID')
160
+ return 'application/octet-stream'
161
+ }
162
+ throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
163
+ }
164
+
165
+ async function readAndHash(file, maxBytes) {
166
+ let handle
167
+ try {
168
+ handle = await fs.promises.open(file, 'r')
169
+ const stats = await handle.stat()
170
+ if (!stats.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
171
+ if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
172
+ if (stats.size > maxBytes) throw mediaError('QQ_MEDIA_TOO_LARGE')
173
+ const data = Buffer.alloc(stats.size)
174
+ let offset = 0
175
+ while (offset < data.length) {
176
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
177
+ if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
178
+ offset += bytesRead
179
+ }
180
+ return { data, sha256: createHash('sha256').update(data).digest('hex') }
181
+ } catch (error) {
182
+ if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
183
+ throw mediaError('QQ_MEDIA_READ_FAILED')
184
+ } finally {
185
+ if (handle) await handle.close().catch(() => {})
186
+ }
187
+ }
188
+
189
+ function safeName(value, kind) {
190
+ const base = path.basename(String(value ?? '')).replace(/[\u0000-\u001f\u007f]/g, '').trim()
191
+ if (base.length === 0) return kind === 'video' ? 'qq-video' : 'qq-file'
192
+ return base.slice(0, 128)
193
+ }
194
+
195
+ /**
196
+ * Validate one controlled PNG/JPEG/GIF below sendRoot and copy the exact bytes
197
+ * into a private temporary snapshot for one platform call. The caller owns
198
+ * cleanup and must never expose the snapshot path to model output.
199
+ */
200
+ export async function snapshotControlledImage({ sendRoot, relativePath, tempRoot } = {}) {
201
+ const root = resolveSendRoot(sendRoot)
202
+ const source = resolveRootSource(relativePath, root, sendRoot)
203
+ const { data, sha256 } = await readAndHash(source, 5 * MIB)
204
+ let mediaType
205
+ try {
206
+ mediaType = sniffKind('image', data, path.basename(source))
207
+ } catch {
208
+ mediaType = sniffKind('gif', data, path.basename(source))
209
+ }
210
+ if (tempRoot !== undefined && (typeof tempRoot !== 'string' || !path.isAbsolute(tempRoot))) {
211
+ throw mediaError('QQ_MEDIA_TEMP_INVALID')
212
+ }
213
+ let tempDir
214
+ try {
215
+ if (tempRoot !== undefined && !fs.statSync(tempRoot).isDirectory()) throw new Error('not a directory')
216
+ tempDir = await fs.promises.mkdtemp(path.join(tempRoot ?? os.tmpdir(), 'amadeus-qq-avatar-'))
217
+ await fs.promises.chmod(tempDir, 0o700)
218
+ const extension = mediaType === 'image/png' ? '.png' : mediaType === 'image/jpeg' ? '.jpg' : '.gif'
219
+ const snapshot = path.join(tempDir, `avatar${extension}`)
220
+ await fs.promises.writeFile(snapshot, data, { flag: 'wx', mode: 0o600 })
221
+ const written = await fs.promises.readFile(snapshot)
222
+ if (written.length !== data.length || createHash('sha256').update(written).digest('hex') !== sha256) {
223
+ throw mediaError('QQ_MEDIA_SNAPSHOT_INVALID')
224
+ }
225
+ let cleaned = false
226
+ return {
227
+ path: snapshot,
228
+ mediaType,
229
+ bytes: data.length,
230
+ sha256,
231
+ async cleanup() {
232
+ if (cleaned) return
233
+ cleaned = true
234
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
235
+ },
236
+ }
237
+ } catch (error) {
238
+ if (tempDir) await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
239
+ if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
240
+ throw mediaError('QQ_MEDIA_SNAPSHOT_FAILED')
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Build one controlled outbound media plan. `source` must be either a relative
246
+ * path below the configured sendRoot, or an already-validated Host attachment
247
+ * reference owned by the caller. Never pass model-supplied paths, URLs or
248
+ * base64 into this helper. Message plans are ready for core.send(); upload
249
+ * plans require the core's delivery ledger with the returned fingerprint.
250
+ */
251
+ export async function prepareOutboundMedia({
252
+ core,
253
+ sessionId,
254
+ kind,
255
+ source,
256
+ sendRoot,
257
+ attachments,
258
+ }) {
259
+ if (kind === 'voice' || kind === 'record') throw mediaError(TTS_UNAVAILABLE_CODE)
260
+ if (!Object.hasOwn(OUTBOUND_MEDIA_LIMITS, kind)) throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
261
+ if (!source || typeof source !== 'object' || Array.isArray(source)) throw mediaError('QQ_MEDIA_SOURCE_INVALID')
262
+
263
+ const scene = assertSend(core, sessionId)
264
+ const maxBytes = OUTBOUND_MEDIA_LIMITS[kind]
265
+ let root
266
+ let file
267
+ if (source.type === 'send-root') {
268
+ if (Object.keys(source).sort().join(',') !== 'path,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
269
+ root = resolveSendRoot(sendRoot)
270
+ file = resolveRootSource(source.path, root, sendRoot)
271
+ } else if (source.type === 'host-attachment') {
272
+ if (Object.keys(source).sort().join(',') !== 'attachment,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
273
+ file = resolveHostAttachment(source, kind, attachments)
274
+ statRegularFile(file)
275
+ } else {
276
+ throw mediaError('QQ_MEDIA_SOURCE_INVALID')
277
+ }
278
+
279
+ const { data, sha256 } = await readAndHash(file, maxBytes)
280
+ const name = safeName(path.basename(file), kind)
281
+ const mediaType = sniffKind(kind, data, name)
282
+
283
+ // Re-check the bound Scene immediately before returning an executable plan.
284
+ assertSend(core, sessionId)
285
+
286
+ if (kind === 'file') {
287
+ const group = scene.kind === 'group'
288
+ return {
289
+ mode: 'upload',
290
+ action: group ? 'upload_group_file' : 'upload_private_file',
291
+ params: group
292
+ ? { group_id: scene.peerId, file, name, upload_file: true }
293
+ : { user_id: scene.peerId, file, name, upload_file: true },
294
+ mediaType,
295
+ bytes: data.length,
296
+ fingerprint: sha256,
297
+ }
298
+ }
299
+ return {
300
+ mode: 'message',
301
+ segments: [{
302
+ type: kind === 'video' ? 'video' : kind === 'audio' ? 'record' : 'image',
303
+ data: { file },
304
+ }],
305
+ mediaType,
306
+ bytes: data.length,
307
+ fingerprint: sha256,
308
+ }
309
+ }