@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.
- package/LICENSE +21 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- package/src/web-console.js +9 -9
package/src/outbound-media.js
CHANGED
|
@@ -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
|
+
}
|