@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/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
|
+
}
|