@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
package/src/audio.js CHANGED
@@ -1,428 +1,428 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { spawn } from 'node:child_process'
4
- import { normalizeMessageId } from './storage.js'
5
- import { sanitizeInboundSegments } from './onebot.js'
6
-
7
- const MAX_AUDIO_BYTES = 4 * 1024 * 1024
8
- const MAX_RESPONSE_BYTES = 6 * 1024 * 1024
9
- const MAX_BASE64_CHARS = Math.ceil(MAX_AUDIO_BYTES / 3) * 4
10
- const MAX_DURATION_SECONDS = 120
11
- const MAX_PCM_BYTES = Math.min(MAX_AUDIO_BYTES - 44, MAX_DURATION_SECONDS * 32000)
12
- const TRANSCODE_TIMEOUT_MS = 10_000
13
- const READ_TIMEOUT_MS = 5_000
14
- const PLATFORM_TIMEOUT_MS = 10_000
15
- const ASR_TIMEOUT_MS = 120_000
16
- const MAX_TRANSCRIPT_CHARS = 16_384
17
- const FFMPEG_ARGS = Object.freeze(['-nostdin', '-hide_banner', '-loglevel', 'error'])
18
-
19
- function mediaError(code) {
20
- const error = new Error(code)
21
- error.code = code
22
- return error
23
- }
24
-
25
- function permissionError(code = 'QQ_PERMISSION_DENIED') {
26
- return mediaError(`${code}: qq.media.read`)
27
- }
28
-
29
- function assertMediaRead(core, sessionId, changed = false) {
30
- let allowed = false
31
- try {
32
- allowed = core.authorizeSessionCapability(sessionId, 'qq.media.read') === true
33
- } catch {
34
- allowed = false
35
- }
36
- if (!allowed) throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
37
- }
38
-
39
- /** Resolve only a Scene that the core currently authorizes for this Session. */
40
- function authorizeScene(core, sessionId) {
41
- try {
42
- return core.authorizedScene(sessionId)
43
- } catch (error) {
44
- const changed = String(error?.message ?? '').startsWith('QQ_PERMISSION_CHANGED')
45
- throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
46
- }
47
- }
48
-
49
- function resolveMediaRoot(mediaRoot) {
50
- if (typeof mediaRoot !== 'string' || !path.isAbsolute(mediaRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
51
- let root
52
- try {
53
- root = fs.realpathSync(mediaRoot)
54
- if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
55
- } catch {
56
- throw mediaError('QQ_MEDIA_ROOT_INVALID')
57
- }
58
- return root
59
- }
60
-
61
- function resolveLocalAudio(file, root, mediaRoot) {
62
- if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
63
- if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
64
- if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
65
- if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
66
- const lexical = path.relative(mediaRoot, file)
67
- if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
68
- throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
69
- }
70
-
71
- let entry
72
- try {
73
- entry = fs.lstatSync(file)
74
- } catch {
75
- throw mediaError('QQ_MEDIA_PATH_INVALID')
76
- }
77
- if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
78
-
79
- let real
80
- try {
81
- real = fs.realpathSync(file)
82
- } catch {
83
- throw mediaError('QQ_MEDIA_PATH_INVALID')
84
- }
85
- const relative = path.relative(root, real)
86
- if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
87
- throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
88
- }
89
-
90
- let cursor = mediaRoot
91
- for (const part of lexical.split(path.sep)) {
92
- cursor = path.join(cursor, part)
93
- try {
94
- if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
95
- } catch (error) {
96
- if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
97
- throw mediaError('QQ_MEDIA_PATH_INVALID')
98
- }
99
- }
100
- return real
101
- }
102
-
103
- function withTimeout(promise, timeoutMs, code) {
104
- let timer
105
- const timeout = new Promise((_, reject) => {
106
- timer = setTimeout(() => reject(mediaError(code)), timeoutMs)
107
- timer.unref?.()
108
- })
109
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
110
- }
111
-
112
- async function readBoundedAudio(file) {
113
- let handle
114
- try {
115
- handle = await fs.promises.open(file, 'r')
116
- const stats = await handle.stat()
117
- if (!stats.isFile()) throw mediaError('QQ_MEDIA_READ_FAILED')
118
- if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
119
- if (stats.size > MAX_AUDIO_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
120
- const data = Buffer.alloc(stats.size)
121
- let offset = 0
122
- while (offset < data.length) {
123
- const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
124
- if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
125
- offset += bytesRead
126
- }
127
- return data
128
- } catch (error) {
129
- if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
130
- throw mediaError('QQ_MEDIA_READ_FAILED')
131
- } finally {
132
- if (handle) await handle.close().catch(() => {})
133
- }
134
- }
135
-
136
- function decodeInlineWav(value) {
137
- if (typeof value !== 'string' || value.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
138
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^data:/i.test(value)) {
139
- throw mediaError('QQ_MEDIA_INLINE_INVALID')
140
- }
141
- if (value.length > MAX_BASE64_CHARS) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
142
- let data
143
- try {
144
- data = Buffer.from(value, 'base64')
145
- } catch {
146
- throw mediaError('QQ_MEDIA_INLINE_INVALID')
147
- }
148
- if (data.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
149
- if (data.length > MAX_AUDIO_BYTES) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
150
- if (data.toString('base64') !== value) throw mediaError('QQ_MEDIA_INLINE_INVALID')
151
- return data
152
- }
153
-
154
- function isMp3(data) {
155
- if (data.length >= 3 && data.toString('ascii', 0, 3) === 'ID3') return true
156
- return data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0
157
- }
158
-
159
- function resolveFfmpeg(value) {
160
- if (typeof value !== 'string' || !path.isAbsolute(value)) throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
161
- let entry
162
- try {
163
- entry = fs.lstatSync(value)
164
- } catch {
165
- throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
166
- }
167
- if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
168
- try {
169
- fs.accessSync(value, fs.constants.X_OK)
170
- } catch {
171
- throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
172
- }
173
- return value
174
- }
175
-
176
- function buildCanonicalWav(pcm) {
177
- if (pcm.length === 0 || pcm.length % 2 !== 0) throw mediaError('QQ_MEDIA_TRANSCODER_EMPTY')
178
- if (pcm.length > MAX_PCM_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
179
- const header = Buffer.alloc(44)
180
- header.write('RIFF', 0)
181
- header.writeUInt32LE(36 + pcm.length, 4)
182
- header.write('WAVEfmt ', 8)
183
- header.writeUInt32LE(16, 16)
184
- header.writeUInt16LE(1, 20)
185
- header.writeUInt16LE(1, 22)
186
- header.writeUInt32LE(16000, 24)
187
- header.writeUInt32LE(32000, 28)
188
- header.writeUInt16LE(2, 32)
189
- header.writeUInt16LE(16, 34)
190
- header.write('data', 36)
191
- header.writeUInt32LE(pcm.length, 40)
192
- return Buffer.concat([header, pcm])
193
- }
194
-
195
- function transcodeAudioToWav(input, ffmpegPath, inputFormat) {
196
- const executable = resolveFfmpeg(ffmpegPath)
197
- return new Promise((resolve, reject) => {
198
- let settled = false
199
- let timer
200
- const finish = (fn, value) => {
201
- if (settled) return
202
- settled = true
203
- clearTimeout(timer)
204
- fn(value)
205
- }
206
- let child
207
- try {
208
- child = spawn(executable, [
209
- ...FFMPEG_ARGS, '-f', inputFormat, '-i', 'pipe:0', '-vn',
210
- '-ac', '1', '-ar', '16000', '-sample_fmt', 's16', '-f', 's16le', 'pipe:1',
211
- ], {
212
- shell: false,
213
- windowsHide: true,
214
- stdio: ['pipe', 'pipe', 'ignore'],
215
- })
216
- } catch {
217
- finish(reject, mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE'))
218
- return
219
- }
220
- const chunks = []
221
- let total = 0
222
- timer = setTimeout(() => {
223
- try { child.kill('SIGKILL') } catch { /* already gone */ }
224
- finish(reject, mediaError('QQ_MEDIA_TRANSCODER_TIMEOUT'))
225
- }, TRANSCODE_TIMEOUT_MS)
226
- timer.unref?.()
227
-
228
- child.on('error', () => finish(reject, mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')))
229
- child.stdout.on('data', chunk => {
230
- if (settled) return
231
- total += chunk.length
232
- if (total > MAX_PCM_BYTES) {
233
- try { child.kill('SIGKILL') } catch { /* already gone */ }
234
- finish(reject, mediaError('QQ_MEDIA_TOO_LARGE'))
235
- return
236
- }
237
- chunks.push(chunk)
238
- })
239
- child.on('close', code => {
240
- if (settled) return
241
- if (code !== 0) {
242
- finish(reject, mediaError('QQ_MEDIA_TRANSCODER_FAILED'))
243
- return
244
- }
245
- try {
246
- finish(resolve, buildCanonicalWav(Buffer.concat(chunks)))
247
- } catch (error) {
248
- finish(reject, error)
249
- }
250
- })
251
- child.stdin.on('error', () => {})
252
- child.stdin.end(input)
253
- })
254
- }
255
-
256
- /** Only an intact, bounded PCM16 WAV with a different rate/channel is eligible for resampling. */
257
- function isConvertiblePcmWav(data) {
258
- if (data.length < 46 ||
259
- data.toString('ascii', 0, 4) !== 'RIFF' ||
260
- data.toString('ascii', 8, 12) !== 'WAVE' ||
261
- data.toString('ascii', 12, 16) !== 'fmt ' ||
262
- data.readUInt32LE(16) !== 16 ||
263
- data.readUInt16LE(20) !== 1 ||
264
- data.toString('ascii', 36, 40) !== 'data' ||
265
- data.readUInt32LE(4) !== data.length - 8 ||
266
- data.readUInt32LE(40) !== data.length - 44 ||
267
- data.readUInt16LE(34) !== 16) return false
268
- const channels = data.readUInt16LE(22)
269
- const rate = data.readUInt32LE(24)
270
- const blockAlign = channels * 2
271
- return (channels === 1 || channels === 2) &&
272
- rate >= 8000 && rate <= 48000 &&
273
- data.readUInt16LE(32) === blockAlign &&
274
- data.readUInt32LE(28) === rate * blockAlign &&
275
- (data.length - 44) % blockAlign === 0 &&
276
- (data.length - 44) / (rate * blockAlign) <= MAX_DURATION_SECONDS
277
- }
278
-
279
- /** Validate the canonical 16 kHz mono PCM16 WAV accepted by the local provider. */
280
- function validateWav(data) {
281
- if (data.length < 46 ||
282
- data.toString('ascii', 0, 4) !== 'RIFF' ||
283
- data.toString('ascii', 8, 12) !== 'WAVE' ||
284
- data.toString('ascii', 12, 16) !== 'fmt ' ||
285
- data.readUInt32LE(16) !== 16 ||
286
- data.readUInt16LE(20) !== 1 ||
287
- data.readUInt16LE(22) !== 1 ||
288
- data.readUInt32LE(24) !== 16000 ||
289
- data.readUInt32LE(28) !== 32000 ||
290
- data.readUInt16LE(32) !== 2 ||
291
- data.readUInt16LE(34) !== 16 ||
292
- data.toString('ascii', 36, 40) !== 'data' ||
293
- data.readUInt32LE(4) !== data.length - 8 ||
294
- data.readUInt32LE(40) !== data.length - 44 ||
295
- (data.length - 44) % 2 !== 0) {
296
- throw mediaError('QQ_MEDIA_INVALID_WAV')
297
- }
298
- const seconds = (data.length - 44) / 32000
299
- if (seconds > MAX_DURATION_SECONDS) throw mediaError('QQ_MEDIA_AUDIO_TOO_LONG')
300
- return seconds
301
- }
302
-
303
- function selectLocalProvider(speechToText, providerId) {
304
- if (typeof providerId !== 'string' || providerId.length === 0) throw mediaError('QQ_ASR_PROVIDER_REQUIRED')
305
- if (!speechToText ||
306
- typeof speechToText.listProviders !== 'function' ||
307
- typeof speechToText.resolve !== 'function' ||
308
- typeof speechToText.transcribe !== 'function') {
309
- throw mediaError('QQ_ASR_UNAVAILABLE')
310
- }
311
- let providers
312
- try {
313
- providers = speechToText.listProviders()
314
- } catch {
315
- throw mediaError('QQ_ASR_UNAVAILABLE')
316
- }
317
- const provider = Array.isArray(providers) ? providers.find(item => item?.id === providerId) : null
318
- if (!provider) throw mediaError('QQ_ASR_PROVIDER_UNAVAILABLE')
319
- // Local-only v1: sending a QQ recording to a remote recognizer is an explicit
320
- // deployment decision that this capability does not take implicitly.
321
- if (provider.location !== 'host-local') throw mediaError('QQ_ASR_PROVIDER_REMOTE_FORBIDDEN')
322
- }
323
-
324
- function sanitizeTranscript(text) {
325
- if (typeof text !== 'string') throw mediaError('QQ_ASR_INVALID_RESULT')
326
- const clean = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim()
327
- if (clean.length === 0) throw mediaError('QQ_ASR_EMPTY_RESULT')
328
- if (clean.length > MAX_TRANSCRIPT_CHARS) throw mediaError('QQ_ASR_RESULT_TOO_LARGE')
329
- return clean
330
- }
331
-
332
- /**
333
- * Transcribe one inbound QQ voice record through the explicitly configured
334
- * local ASR provider. The model supplies only messageId and record ordinal;
335
- * Scene, record handle, path, provider, and audio never come from model args.
336
- */
337
- export async function transcribeMessageRecord({
338
- core,
339
- store,
340
- transport,
341
- speechToText,
342
- mediaRoot,
343
- providerId,
344
- ffmpegPath = '',
345
- sessionId,
346
- messageId,
347
- index = 0,
348
- }) {
349
- if (!Number.isSafeInteger(index) || index < 0) throw mediaError('QQ_MEDIA_INDEX_INVALID')
350
- if (!transport || typeof transport.action !== 'function') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
351
- if (transport.status !== undefined && transport.status !== 'connected') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
352
-
353
- const scene = authorizeScene(core, sessionId)
354
- assertMediaRead(core, sessionId)
355
-
356
- const root = resolveMediaRoot(mediaRoot)
357
- selectLocalProvider(speechToText, providerId)
358
-
359
- let id
360
- try {
361
- id = normalizeMessageId(messageId, 'messageId')
362
- } catch {
363
- throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
364
- }
365
- const row = store.getScene(scene)
366
- const message = row?.messages?.find(item => item.messageId === id)
367
- if (!message) throw mediaError('QQ_MESSAGE_SCOPE')
368
-
369
- let segments
370
- try {
371
- segments = sanitizeInboundSegments(message.segments)
372
- } catch {
373
- throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
374
- }
375
- const record = segments.filter(segment => segment.type === 'record')[index]
376
- const file = record?.data?.file
377
- if (typeof file !== 'string' || file.length === 0) throw mediaError('QQ_MEDIA_RECORD_UNAVAILABLE')
378
-
379
- let response
380
- try {
381
- response = await transport.action('get_record', { file, out_format: 'wav' }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
382
- } catch (error) {
383
- throw mediaError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
384
- ? 'QQ_MEDIA_RESPONSE_TOO_LARGE'
385
- : 'QQ_MEDIA_BACKEND_UNAVAILABLE')
386
- }
387
- assertMediaRead(core, sessionId, true)
388
- if (!response || typeof response !== 'object' || Array.isArray(response)) {
389
- throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
390
- }
391
-
392
- let audio
393
- if (typeof response.file === 'string') {
394
- // A present file remains authoritative. An inline base64 field must never
395
- // bypass a rejected local path.
396
- const localFile = resolveLocalAudio(response.file, root, mediaRoot)
397
- audio = await withTimeout(readBoundedAudio(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
398
- } else if (typeof response.base64 === 'string') {
399
- audio = decodeInlineWav(response.base64)
400
- } else {
401
- throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
402
- }
403
- if (isMp3(audio)) audio = await transcodeAudioToWav(audio, ffmpegPath, 'mp3')
404
- else {
405
- try { validateWav(audio) } catch (error) {
406
- if (!isConvertiblePcmWav(audio)) throw error
407
- audio = await transcodeAudioToWav(audio, ffmpegPath, 'wav')
408
- }
409
- }
410
- validateWav(audio)
411
-
412
- assertMediaRead(core, sessionId, true)
413
- let spec
414
- try {
415
- spec = speechToText.resolve({ audio, providerId })
416
- } catch {
417
- throw mediaError('QQ_ASR_PROVIDER_UNAVAILABLE')
418
- }
419
-
420
- let result
421
- try {
422
- result = await speechToText.transcribe(spec, AbortSignal.timeout(ASR_TIMEOUT_MS))
423
- } catch {
424
- throw mediaError('QQ_ASR_FAILED')
425
- }
426
- assertMediaRead(core, sessionId, true)
427
- return { text: sanitizeTranscript(result?.text) }
428
- }
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import { normalizeMessageId } from './storage.js'
5
+ import { sanitizeInboundSegments } from './onebot.js'
6
+
7
+ const MAX_AUDIO_BYTES = 4 * 1024 * 1024
8
+ const MAX_RESPONSE_BYTES = 6 * 1024 * 1024
9
+ const MAX_BASE64_CHARS = Math.ceil(MAX_AUDIO_BYTES / 3) * 4
10
+ const MAX_DURATION_SECONDS = 120
11
+ const MAX_PCM_BYTES = Math.min(MAX_AUDIO_BYTES - 44, MAX_DURATION_SECONDS * 32000)
12
+ const TRANSCODE_TIMEOUT_MS = 10_000
13
+ const READ_TIMEOUT_MS = 5_000
14
+ const PLATFORM_TIMEOUT_MS = 10_000
15
+ const ASR_TIMEOUT_MS = 120_000
16
+ const MAX_TRANSCRIPT_CHARS = 16_384
17
+ const FFMPEG_ARGS = Object.freeze(['-nostdin', '-hide_banner', '-loglevel', 'error'])
18
+
19
+ function mediaError(code) {
20
+ const error = new Error(code)
21
+ error.code = code
22
+ return error
23
+ }
24
+
25
+ function permissionError(code = 'QQ_PERMISSION_DENIED') {
26
+ return mediaError(`${code}: qq.media.read`)
27
+ }
28
+
29
+ function assertMediaRead(core, sessionId, changed = false) {
30
+ let allowed = false
31
+ try {
32
+ allowed = core.authorizeSessionCapability(sessionId, 'qq.media.read') === true
33
+ } catch {
34
+ allowed = false
35
+ }
36
+ if (!allowed) throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
37
+ }
38
+
39
+ /** Resolve only a Scene that the core currently authorizes for this Session. */
40
+ function authorizeScene(core, sessionId) {
41
+ try {
42
+ return core.authorizedScene(sessionId)
43
+ } catch (error) {
44
+ const changed = String(error?.message ?? '').startsWith('QQ_PERMISSION_CHANGED')
45
+ throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
46
+ }
47
+ }
48
+
49
+ function resolveMediaRoot(mediaRoot) {
50
+ if (typeof mediaRoot !== 'string' || !path.isAbsolute(mediaRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
51
+ let root
52
+ try {
53
+ root = fs.realpathSync(mediaRoot)
54
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
55
+ } catch {
56
+ throw mediaError('QQ_MEDIA_ROOT_INVALID')
57
+ }
58
+ return root
59
+ }
60
+
61
+ function resolveLocalAudio(file, root, mediaRoot) {
62
+ if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
63
+ if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
64
+ if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
65
+ if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
66
+ const lexical = path.relative(mediaRoot, file)
67
+ if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
68
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
69
+ }
70
+
71
+ let entry
72
+ try {
73
+ entry = fs.lstatSync(file)
74
+ } catch {
75
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
76
+ }
77
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
78
+
79
+ let real
80
+ try {
81
+ real = fs.realpathSync(file)
82
+ } catch {
83
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
84
+ }
85
+ const relative = path.relative(root, real)
86
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
87
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
88
+ }
89
+
90
+ let cursor = mediaRoot
91
+ for (const part of lexical.split(path.sep)) {
92
+ cursor = path.join(cursor, part)
93
+ try {
94
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
95
+ } catch (error) {
96
+ if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
97
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
98
+ }
99
+ }
100
+ return real
101
+ }
102
+
103
+ function withTimeout(promise, timeoutMs, code) {
104
+ let timer
105
+ const timeout = new Promise((_, reject) => {
106
+ timer = setTimeout(() => reject(mediaError(code)), timeoutMs)
107
+ timer.unref?.()
108
+ })
109
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
110
+ }
111
+
112
+ async function readBoundedAudio(file) {
113
+ let handle
114
+ try {
115
+ handle = await fs.promises.open(file, 'r')
116
+ const stats = await handle.stat()
117
+ if (!stats.isFile()) throw mediaError('QQ_MEDIA_READ_FAILED')
118
+ if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
119
+ if (stats.size > MAX_AUDIO_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
120
+ const data = Buffer.alloc(stats.size)
121
+ let offset = 0
122
+ while (offset < data.length) {
123
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
124
+ if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
125
+ offset += bytesRead
126
+ }
127
+ return data
128
+ } catch (error) {
129
+ if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
130
+ throw mediaError('QQ_MEDIA_READ_FAILED')
131
+ } finally {
132
+ if (handle) await handle.close().catch(() => {})
133
+ }
134
+ }
135
+
136
+ function decodeInlineWav(value) {
137
+ if (typeof value !== 'string' || value.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
138
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^data:/i.test(value)) {
139
+ throw mediaError('QQ_MEDIA_INLINE_INVALID')
140
+ }
141
+ if (value.length > MAX_BASE64_CHARS) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
142
+ let data
143
+ try {
144
+ data = Buffer.from(value, 'base64')
145
+ } catch {
146
+ throw mediaError('QQ_MEDIA_INLINE_INVALID')
147
+ }
148
+ if (data.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
149
+ if (data.length > MAX_AUDIO_BYTES) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
150
+ if (data.toString('base64') !== value) throw mediaError('QQ_MEDIA_INLINE_INVALID')
151
+ return data
152
+ }
153
+
154
+ function isMp3(data) {
155
+ if (data.length >= 3 && data.toString('ascii', 0, 3) === 'ID3') return true
156
+ return data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0
157
+ }
158
+
159
+ function resolveFfmpeg(value) {
160
+ if (typeof value !== 'string' || !path.isAbsolute(value)) throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
161
+ let entry
162
+ try {
163
+ entry = fs.lstatSync(value)
164
+ } catch {
165
+ throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
166
+ }
167
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
168
+ try {
169
+ fs.accessSync(value, fs.constants.X_OK)
170
+ } catch {
171
+ throw mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')
172
+ }
173
+ return value
174
+ }
175
+
176
+ function buildCanonicalWav(pcm) {
177
+ if (pcm.length === 0 || pcm.length % 2 !== 0) throw mediaError('QQ_MEDIA_TRANSCODER_EMPTY')
178
+ if (pcm.length > MAX_PCM_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
179
+ const header = Buffer.alloc(44)
180
+ header.write('RIFF', 0)
181
+ header.writeUInt32LE(36 + pcm.length, 4)
182
+ header.write('WAVEfmt ', 8)
183
+ header.writeUInt32LE(16, 16)
184
+ header.writeUInt16LE(1, 20)
185
+ header.writeUInt16LE(1, 22)
186
+ header.writeUInt32LE(16000, 24)
187
+ header.writeUInt32LE(32000, 28)
188
+ header.writeUInt16LE(2, 32)
189
+ header.writeUInt16LE(16, 34)
190
+ header.write('data', 36)
191
+ header.writeUInt32LE(pcm.length, 40)
192
+ return Buffer.concat([header, pcm])
193
+ }
194
+
195
+ function transcodeAudioToWav(input, ffmpegPath, inputFormat) {
196
+ const executable = resolveFfmpeg(ffmpegPath)
197
+ return new Promise((resolve, reject) => {
198
+ let settled = false
199
+ let timer
200
+ const finish = (fn, value) => {
201
+ if (settled) return
202
+ settled = true
203
+ clearTimeout(timer)
204
+ fn(value)
205
+ }
206
+ let child
207
+ try {
208
+ child = spawn(executable, [
209
+ ...FFMPEG_ARGS, '-f', inputFormat, '-i', 'pipe:0', '-vn',
210
+ '-ac', '1', '-ar', '16000', '-sample_fmt', 's16', '-f', 's16le', 'pipe:1',
211
+ ], {
212
+ shell: false,
213
+ windowsHide: true,
214
+ stdio: ['pipe', 'pipe', 'ignore'],
215
+ })
216
+ } catch {
217
+ finish(reject, mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE'))
218
+ return
219
+ }
220
+ const chunks = []
221
+ let total = 0
222
+ timer = setTimeout(() => {
223
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
224
+ finish(reject, mediaError('QQ_MEDIA_TRANSCODER_TIMEOUT'))
225
+ }, TRANSCODE_TIMEOUT_MS)
226
+ timer.unref?.()
227
+
228
+ child.on('error', () => finish(reject, mediaError('QQ_MEDIA_TRANSCODER_UNAVAILABLE')))
229
+ child.stdout.on('data', chunk => {
230
+ if (settled) return
231
+ total += chunk.length
232
+ if (total > MAX_PCM_BYTES) {
233
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
234
+ finish(reject, mediaError('QQ_MEDIA_TOO_LARGE'))
235
+ return
236
+ }
237
+ chunks.push(chunk)
238
+ })
239
+ child.on('close', code => {
240
+ if (settled) return
241
+ if (code !== 0) {
242
+ finish(reject, mediaError('QQ_MEDIA_TRANSCODER_FAILED'))
243
+ return
244
+ }
245
+ try {
246
+ finish(resolve, buildCanonicalWav(Buffer.concat(chunks)))
247
+ } catch (error) {
248
+ finish(reject, error)
249
+ }
250
+ })
251
+ child.stdin.on('error', () => {})
252
+ child.stdin.end(input)
253
+ })
254
+ }
255
+
256
+ /** Only an intact, bounded PCM16 WAV with a different rate/channel is eligible for resampling. */
257
+ function isConvertiblePcmWav(data) {
258
+ if (data.length < 46 ||
259
+ data.toString('ascii', 0, 4) !== 'RIFF' ||
260
+ data.toString('ascii', 8, 12) !== 'WAVE' ||
261
+ data.toString('ascii', 12, 16) !== 'fmt ' ||
262
+ data.readUInt32LE(16) !== 16 ||
263
+ data.readUInt16LE(20) !== 1 ||
264
+ data.toString('ascii', 36, 40) !== 'data' ||
265
+ data.readUInt32LE(4) !== data.length - 8 ||
266
+ data.readUInt32LE(40) !== data.length - 44 ||
267
+ data.readUInt16LE(34) !== 16) return false
268
+ const channels = data.readUInt16LE(22)
269
+ const rate = data.readUInt32LE(24)
270
+ const blockAlign = channels * 2
271
+ return (channels === 1 || channels === 2) &&
272
+ rate >= 8000 && rate <= 48000 &&
273
+ data.readUInt16LE(32) === blockAlign &&
274
+ data.readUInt32LE(28) === rate * blockAlign &&
275
+ (data.length - 44) % blockAlign === 0 &&
276
+ (data.length - 44) / (rate * blockAlign) <= MAX_DURATION_SECONDS
277
+ }
278
+
279
+ /** Validate the canonical 16 kHz mono PCM16 WAV accepted by the local provider. */
280
+ function validateWav(data) {
281
+ if (data.length < 46 ||
282
+ data.toString('ascii', 0, 4) !== 'RIFF' ||
283
+ data.toString('ascii', 8, 12) !== 'WAVE' ||
284
+ data.toString('ascii', 12, 16) !== 'fmt ' ||
285
+ data.readUInt32LE(16) !== 16 ||
286
+ data.readUInt16LE(20) !== 1 ||
287
+ data.readUInt16LE(22) !== 1 ||
288
+ data.readUInt32LE(24) !== 16000 ||
289
+ data.readUInt32LE(28) !== 32000 ||
290
+ data.readUInt16LE(32) !== 2 ||
291
+ data.readUInt16LE(34) !== 16 ||
292
+ data.toString('ascii', 36, 40) !== 'data' ||
293
+ data.readUInt32LE(4) !== data.length - 8 ||
294
+ data.readUInt32LE(40) !== data.length - 44 ||
295
+ (data.length - 44) % 2 !== 0) {
296
+ throw mediaError('QQ_MEDIA_INVALID_WAV')
297
+ }
298
+ const seconds = (data.length - 44) / 32000
299
+ if (seconds > MAX_DURATION_SECONDS) throw mediaError('QQ_MEDIA_AUDIO_TOO_LONG')
300
+ return seconds
301
+ }
302
+
303
+ function selectLocalProvider(speechToText, providerId) {
304
+ if (typeof providerId !== 'string' || providerId.length === 0) throw mediaError('QQ_ASR_PROVIDER_REQUIRED')
305
+ if (!speechToText ||
306
+ typeof speechToText.listProviders !== 'function' ||
307
+ typeof speechToText.resolve !== 'function' ||
308
+ typeof speechToText.transcribe !== 'function') {
309
+ throw mediaError('QQ_ASR_UNAVAILABLE')
310
+ }
311
+ let providers
312
+ try {
313
+ providers = speechToText.listProviders()
314
+ } catch {
315
+ throw mediaError('QQ_ASR_UNAVAILABLE')
316
+ }
317
+ const provider = Array.isArray(providers) ? providers.find(item => item?.id === providerId) : null
318
+ if (!provider) throw mediaError('QQ_ASR_PROVIDER_UNAVAILABLE')
319
+ // Local-only v1: sending a QQ recording to a remote recognizer is an explicit
320
+ // deployment decision that this capability does not take implicitly.
321
+ if (provider.location !== 'host-local') throw mediaError('QQ_ASR_PROVIDER_REMOTE_FORBIDDEN')
322
+ }
323
+
324
+ function sanitizeTranscript(text) {
325
+ if (typeof text !== 'string') throw mediaError('QQ_ASR_INVALID_RESULT')
326
+ const clean = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim()
327
+ if (clean.length === 0) throw mediaError('QQ_ASR_EMPTY_RESULT')
328
+ if (clean.length > MAX_TRANSCRIPT_CHARS) throw mediaError('QQ_ASR_RESULT_TOO_LARGE')
329
+ return clean
330
+ }
331
+
332
+ /**
333
+ * Transcribe one inbound QQ voice record through the explicitly configured
334
+ * local ASR provider. The model supplies only messageId and record ordinal;
335
+ * Scene, record handle, path, provider, and audio never come from model args.
336
+ */
337
+ export async function transcribeMessageRecord({
338
+ core,
339
+ store,
340
+ transport,
341
+ speechToText,
342
+ mediaRoot,
343
+ providerId,
344
+ ffmpegPath = '',
345
+ sessionId,
346
+ messageId,
347
+ index = 0,
348
+ }) {
349
+ if (!Number.isSafeInteger(index) || index < 0) throw mediaError('QQ_MEDIA_INDEX_INVALID')
350
+ if (!transport || typeof transport.action !== 'function') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
351
+ if (transport.status !== undefined && transport.status !== 'connected') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
352
+
353
+ const scene = authorizeScene(core, sessionId)
354
+ assertMediaRead(core, sessionId)
355
+
356
+ const root = resolveMediaRoot(mediaRoot)
357
+ selectLocalProvider(speechToText, providerId)
358
+
359
+ let id
360
+ try {
361
+ id = normalizeMessageId(messageId, 'messageId')
362
+ } catch {
363
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
364
+ }
365
+ const row = store.getScene(scene)
366
+ const message = row?.messages?.find(item => item.messageId === id)
367
+ if (!message) throw mediaError('QQ_MESSAGE_SCOPE')
368
+
369
+ let segments
370
+ try {
371
+ segments = sanitizeInboundSegments(message.segments)
372
+ } catch {
373
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
374
+ }
375
+ const record = segments.filter(segment => segment.type === 'record')[index]
376
+ const file = record?.data?.file
377
+ if (typeof file !== 'string' || file.length === 0) throw mediaError('QQ_MEDIA_RECORD_UNAVAILABLE')
378
+
379
+ let response
380
+ try {
381
+ response = await transport.action('get_record', { file, out_format: 'wav' }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
382
+ } catch (error) {
383
+ throw mediaError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
384
+ ? 'QQ_MEDIA_RESPONSE_TOO_LARGE'
385
+ : 'QQ_MEDIA_BACKEND_UNAVAILABLE')
386
+ }
387
+ assertMediaRead(core, sessionId, true)
388
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
389
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
390
+ }
391
+
392
+ let audio
393
+ if (typeof response.file === 'string') {
394
+ // A present file remains authoritative. An inline base64 field must never
395
+ // bypass a rejected local path.
396
+ const localFile = resolveLocalAudio(response.file, root, mediaRoot)
397
+ audio = await withTimeout(readBoundedAudio(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
398
+ } else if (typeof response.base64 === 'string') {
399
+ audio = decodeInlineWav(response.base64)
400
+ } else {
401
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
402
+ }
403
+ if (isMp3(audio)) audio = await transcodeAudioToWav(audio, ffmpegPath, 'mp3')
404
+ else {
405
+ try { validateWav(audio) } catch (error) {
406
+ if (!isConvertiblePcmWav(audio)) throw error
407
+ audio = await transcodeAudioToWav(audio, ffmpegPath, 'wav')
408
+ }
409
+ }
410
+ validateWav(audio)
411
+
412
+ assertMediaRead(core, sessionId, true)
413
+ let spec
414
+ try {
415
+ spec = speechToText.resolve({ audio, providerId })
416
+ } catch {
417
+ throw mediaError('QQ_ASR_PROVIDER_UNAVAILABLE')
418
+ }
419
+
420
+ let result
421
+ try {
422
+ result = await speechToText.transcribe(spec, AbortSignal.timeout(ASR_TIMEOUT_MS))
423
+ } catch {
424
+ throw mediaError('QQ_ASR_FAILED')
425
+ }
426
+ assertMediaRead(core, sessionId, true)
427
+ return { text: sanitizeTranscript(result?.text) }
428
+ }