@fg-lab/amadeus-qq 0.0.0-stage → 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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +28 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  26. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  27. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  28. package/lib/client.js +380 -0
  29. package/package.json +92 -4
  30. package/preset.patch.yml +166 -0
  31. package/screenshots.json +6 -0
  32. package/scripts/build-client.mjs +52 -0
  33. package/scripts/registry-guard.mjs +12 -0
  34. package/scripts/release-audit.mjs +59 -0
  35. package/scripts/symlink-security.mjs +8 -0
  36. package/src/account-profile.js +223 -0
  37. package/src/admin-http.js +361 -0
  38. package/src/audio.js +428 -0
  39. package/src/capabilities.js +785 -0
  40. package/src/client/factory.template.js +82 -0
  41. package/src/client/logic.js +290 -0
  42. package/src/config.js +50 -0
  43. package/src/console/console.css +28 -0
  44. package/src/console/console.js +541 -0
  45. package/src/console/index.html +67 -0
  46. package/src/core.js +909 -0
  47. package/src/files.js +230 -0
  48. package/src/index.js +994 -0
  49. package/src/lease.js +42 -0
  50. package/src/media.js +309 -0
  51. package/src/napcat-action-names.js +186 -0
  52. package/src/native-asr.js +112 -0
  53. package/src/onebot.js +378 -0
  54. package/src/outbound-media.js +309 -0
  55. package/src/owner-workspace.js +394 -0
  56. package/src/permissions.js +270 -0
  57. package/src/preset-guard.mjs +593 -0
  58. package/src/qzone-provider.js +628 -0
  59. package/src/session-provenance.js +74 -0
  60. package/src/setup.js +255 -0
  61. package/src/storage.js +580 -0
  62. package/src/web-console.js +9 -0
package/src/onebot.js ADDED
@@ -0,0 +1,378 @@
1
+ import { normalizeId, normalizeMessageId, sceneIdentity } from './storage.js'
2
+
3
+ const SEGMENT_LIMIT = 100
4
+ const EVENT_SIZE_LIMIT = 256 * 1024
5
+ const MEDIA_SEGMENT_TYPES = new Set(['image', 'record', 'video', 'file'])
6
+ const MAX_MEDIA_ID_LENGTH = 256
7
+ const DIAGNOSTIC_INTERVAL_MS = 60_000
8
+
9
+ function plainObject(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ }
12
+
13
+ /**
14
+ * A media `file` field is an opaque platform handle, not a locator. Keep only
15
+ * short, single-token handles; every URL scheme, local path, UNC path, CQ code
16
+ * and control character is dropped so an inbound event can never carry a
17
+ * network or filesystem target into durable state or the model prompt.
18
+ */
19
+ function safeMediaId(value) {
20
+ if (typeof value !== 'string' || value.length < 1 || value.length > MAX_MEDIA_ID_LENGTH) return null
21
+ if (value.trim() !== value) return null
22
+ if (/[\u0000-\u001f\u007f]/.test(value)) return null
23
+ if (/\[CQ:/i.test(value)) return null
24
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null
25
+ if (/^[A-Za-z]:/.test(value)) return null
26
+ if (value.includes('/') || value.includes('\\')) return null
27
+ if (value === '.' || value === '..' || value.includes('..')) return null
28
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) return null
29
+ return value
30
+ }
31
+
32
+ function safeId(value, normalize) {
33
+ try { return normalize(value) } catch { return null }
34
+ }
35
+
36
+ /**
37
+ * String-format OneBot messages may contain arbitrary CQ payloads. Their
38
+ * parameters can hold locators or inline data, so remove each complete code
39
+ * while leaving ordinary words on either side intact.
40
+ */
41
+ function stripCqCodes(text) {
42
+ let removed = false
43
+ const stripped = text.replace(/\[CQ:[a-z_]+(?:,[^\]]*)?\]/gi, () => {
44
+ removed = true
45
+ return ' '
46
+ })
47
+ // Keep the words on both sides separate without carrying a CQ parameter.
48
+ return removed ? stripped.replace(/[ \t]{2,}/g, ' ').trim() : text
49
+ }
50
+
51
+ /**
52
+ * Every inbound segment is reduced to a per-type whitelist: text keeps only
53
+ * its text, at/reply keep only the IDs used for trigger and quote semantics,
54
+ * face keeps only a bounded face ID, media keep only a non-locating handle,
55
+ * and unknown segments keep their type with an empty data object. No URL,
56
+ * local path, base64/CQ locator or arbitrary platform field is retained.
57
+ */
58
+ export function sanitizeInboundSegments(source) {
59
+ if (!Array.isArray(source)) throw new TypeError('invalid OneBot message segments')
60
+ if (source.length > SEGMENT_LIMIT) throw new Error('OneBot message has too many segments')
61
+ return source.map(segment => {
62
+ if (!plainObject(segment) || typeof segment.type !== 'string' || !/^[a-z_]{1,32}$/i.test(segment.type) || !plainObject(segment.data)) {
63
+ throw new TypeError('invalid OneBot segment')
64
+ }
65
+ const type = segment.type.toLowerCase()
66
+ if (type === 'text') {
67
+ const text = typeof segment.data.text === 'string' ? stripCqCodes(segment.data.text) : ''
68
+ return { type, data: { text } }
69
+ }
70
+ if (type === 'at') {
71
+ const qq = safeId(segment.data.qq, value => normalizeId(value, 'at.qq'))
72
+ return { type, data: qq === null ? {} : { qq } }
73
+ }
74
+ if (type === 'reply') {
75
+ // Preserve the existing distinction: a missing ID means "no reply",
76
+ // while a malformed non-empty ID still rejects the event.
77
+ if (segment.data.id == null) return { type, data: {} }
78
+ return { type, data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
79
+ }
80
+ if (type === 'face') {
81
+ const id = safeId(segment.data.id, normalizeFaceId)
82
+ return { type, data: id === null ? {} : { id } }
83
+ }
84
+ if (MEDIA_SEGMENT_TYPES.has(type)) {
85
+ const file = safeMediaId(segment.data.file)
86
+ return { type, data: file === null ? {} : { file } }
87
+ }
88
+ return { type, data: {} }
89
+ })
90
+ }
91
+
92
+ export function normalizeOneBotMessage(raw) {
93
+ if (!plainObject(raw) || raw.post_type !== 'message') return null
94
+ if (raw.message_type !== 'group' && raw.message_type !== 'private') return null
95
+ if (JSON.stringify(raw).length > EVENT_SIZE_LIMIT) throw new Error('OneBot event exceeds size limit')
96
+ const scene = sceneIdentity({
97
+ botId: raw.self_id,
98
+ kind: raw.message_type,
99
+ peerId: raw.message_type === 'group' ? raw.group_id : raw.user_id,
100
+ })
101
+ const senderId = normalizeId(raw.user_id, 'senderId')
102
+ const messageId = normalizeMessageId(raw.message_id)
103
+ const source = Array.isArray(raw.message)
104
+ ? raw.message
105
+ : [{ type: 'text', data: { text: String(raw.message ?? '') } }]
106
+ const segments = sanitizeInboundSegments(source)
107
+ const atIds = segments.filter(item => item.type === 'at').map(item => {
108
+ try { return normalizeId(item.data.qq, 'at.qq') } catch { return null }
109
+ }).filter(Boolean)
110
+ const replyId = segments.find(item => item.type === 'reply')?.data?.id
111
+ return {
112
+ scene,
113
+ message: {
114
+ messageId,
115
+ senderId,
116
+ time: Number.isFinite(raw.time) ? raw.time : null,
117
+ segments,
118
+ replyToMessageId: replyId == null ? null : normalizeMessageId(replyId, 'reply.id'),
119
+ atIds,
120
+ sender: plainObject(raw.sender)
121
+ ? { nickname: typeof raw.sender.nickname === 'string' ? raw.sender.nickname.slice(0, 100) : null,
122
+ card: typeof raw.sender.card === 'string' ? raw.sender.card.slice(0, 100) : null }
123
+ : null,
124
+ },
125
+ }
126
+ }
127
+
128
+ const MAX_FACE_ID = 1_000_000
129
+
130
+ /**
131
+ * Normalize a standard QQ face ID. Only a canonical non-negative decimal ID
132
+ * within a small bound is accepted; any other data fields are intentionally
133
+ * dropped by the caller.
134
+ */
135
+ function normalizeFaceId(value) {
136
+ if (typeof value === 'number') {
137
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_FACE_ID) throw new TypeError('invalid face segment')
138
+ return String(value)
139
+ }
140
+ if (typeof value !== 'string' || !/^(0|[1-9]\d{0,6})$/.test(value)) throw new TypeError('invalid face segment')
141
+ const id = Number(value)
142
+ if (!Number.isSafeInteger(id) || id > MAX_FACE_ID) throw new TypeError('invalid face segment')
143
+ return String(id)
144
+ }
145
+
146
+ export function validateOutgoingSegments(raw) {
147
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > 20) throw new TypeError('message must contain 1–20 segments')
148
+ return raw.map(segment => {
149
+ if (!plainObject(segment) || !plainObject(segment.data)) throw new TypeError('invalid outgoing segment')
150
+ if (segment.type === 'text') {
151
+ if (typeof segment.data.text !== 'string' || !segment.data.text.trim() || segment.data.text.length > 4000) throw new TypeError('invalid text segment')
152
+ return { type: 'text', data: { text: segment.data.text } }
153
+ }
154
+ if (segment.type === 'at') return { type: 'at', data: { qq: normalizeId(segment.data.qq, 'at.qq') } }
155
+ if (segment.type === 'reply') return { type: 'reply', data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
156
+ if (segment.type === 'face') return { type: 'face', data: { id: normalizeFaceId(segment.data.id) } }
157
+ throw new TypeError(`unsupported outgoing segment: ${String(segment.type)}`)
158
+ })
159
+ }
160
+
161
+ function responseTooLarge() {
162
+ const error = new Error('ONEBOT_RESPONSE_TOO_LARGE')
163
+ error.code = 'ONEBOT_RESPONSE_TOO_LARGE'
164
+ return error
165
+ }
166
+
167
+ async function readBoundedJson(response, maxBytes) {
168
+ const declared = response.headers?.get?.('content-length')
169
+ if (declared !== null && declared !== undefined) {
170
+ const length = Number(declared)
171
+ if (Number.isFinite(length) && length > maxBytes) {
172
+ try { await response.body?.cancel?.() } catch { /* best effort */ }
173
+ throw responseTooLarge()
174
+ }
175
+ }
176
+ if (!response.body || typeof response.body.getReader !== 'function') {
177
+ throw new Error('ONEBOT_RESPONSE_UNSUPPORTED')
178
+ }
179
+ const reader = response.body.getReader()
180
+ const chunks = []
181
+ let total = 0
182
+ try {
183
+ for (;;) {
184
+ const { done, value } = await reader.read()
185
+ if (done) break
186
+ const chunk = Buffer.from(value)
187
+ total += chunk.length
188
+ if (total > maxBytes) {
189
+ try { await reader.cancel() } catch { /* best effort */ }
190
+ throw responseTooLarge()
191
+ }
192
+ chunks.push(chunk)
193
+ }
194
+ } finally {
195
+ try { reader.releaseLock() } catch { /* already closed */ }
196
+ }
197
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
198
+ }
199
+
200
+ export class OneBotAdapter {
201
+ constructor(config = {}, dependencies = {}) {
202
+ this.config = config
203
+ this.fetch = dependencies.fetch ?? globalThis.fetch
204
+ this.makeWebSocket = dependencies.makeWebSocket
205
+ this.socket = null
206
+ this.stopped = config.enabled !== true
207
+ this.pendingActions = new Set()
208
+ this.reconnectTimer = null
209
+ this.retryMs = 1000
210
+ this.onEvent = null
211
+ this.onDiagnostic = typeof dependencies.onDiagnostic === 'function' ? dependencies.onDiagnostic : null
212
+ this.diagnosticAt = new Map()
213
+ this.status = 'disabled'
214
+ }
215
+
216
+ /**
217
+ * Emit one fixed-code diagnostic per bounded interval. Callers pass only a
218
+ * constant code: exception messages, event bodies, URLs, credentials and
219
+ * paths never leave the adapter. A diagnostic sink failure cannot affect the
220
+ * connection or create an unhandled rejection.
221
+ */
222
+ #diagnose(code) {
223
+ if (!this.onDiagnostic) return
224
+ const now = Date.now()
225
+ if (now - (this.diagnosticAt.get(code) ?? 0) < DIAGNOSTIC_INTERVAL_MS) return
226
+ this.diagnosticAt.set(code, now)
227
+ try {
228
+ Promise.resolve(this.onDiagnostic(Object.freeze({ code }))).catch(() => {})
229
+ } catch {
230
+ // The diagnostic sink is best-effort and must never break transport.
231
+ }
232
+ }
233
+
234
+ get token() {
235
+ if (this.config.token) {
236
+ if (typeof this.config.token !== 'string' || this.config.token.length > 256 || /[\r\n]/.test(this.config.token)) throw new TypeError('invalid token')
237
+ return this.config.token
238
+ }
239
+ const name = this.config.tokenEnv
240
+ if (!name) return ''
241
+ if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name)) throw new TypeError('invalid tokenEnv')
242
+ return process.env[name] ?? ''
243
+ }
244
+
245
+ async start(onEvent) {
246
+ if (this.config.enabled !== true) { this.status = 'disabled'; return }
247
+ if (typeof onEvent !== 'function') throw new TypeError('onEvent callback required')
248
+ if (!/^wss?:\/\//.test(this.config.wsUrl ?? '') || !/^https?:\/\//.test(this.config.httpUrl ?? '')) {
249
+ throw new TypeError('OneBot WS and HTTP URLs are required')
250
+ }
251
+ this.onEvent = onEvent
252
+ this.stopped = false
253
+ await this.#connect()
254
+ }
255
+
256
+ async #connect() {
257
+ if (this.stopped) return
258
+ this.status = 'connecting'
259
+ try {
260
+ const factory = this.makeWebSocket ?? ((await import('ws')).default)
261
+ if (this.stopped) return
262
+ const headers = this.token ? { Authorization: `Bearer ${this.token}` } : {}
263
+ const ws = new factory(this.config.wsUrl, { headers })
264
+ this.socket = ws
265
+ ws.on('open', () => {
266
+ if (this.stopped) { ws.close?.(); return }
267
+ this.status = 'connected'; this.retryMs = 1000
268
+ })
269
+ ws.on('message', data => {
270
+ if (this.stopped) return
271
+ let event
272
+ try {
273
+ if (data.length > EVENT_SIZE_LIMIT) return
274
+ event = JSON.parse(String(data))
275
+ } catch {
276
+ // A malformed platform event is harmless and cannot stop the socket.
277
+ return
278
+ }
279
+ Promise.resolve()
280
+ .then(() => { if (!this.stopped) return this.onEvent(event) })
281
+ .catch(() => this.#diagnose('QQ_INBOUND_HANDLER_FAILED'))
282
+ })
283
+ ws.on('error', () => {
284
+ if (this.stopped) return
285
+ this.status = 'disconnected'
286
+ this.#diagnose('QQ_ONEBOT_SOCKET_ERROR')
287
+ })
288
+ ws.on('close', () => {
289
+ if (this.socket === ws) this.socket = null
290
+ this.status = this.stopped ? 'disabled' : 'disconnected'
291
+ if (!this.stopped) this.#scheduleReconnect()
292
+ })
293
+ } catch {
294
+ if (this.stopped) return
295
+ this.status = 'disconnected'
296
+ this.#diagnose('QQ_ONEBOT_CONNECT_FAILED')
297
+ this.#scheduleReconnect()
298
+ }
299
+ }
300
+
301
+ #scheduleReconnect() {
302
+ if (this.stopped || this.reconnectTimer) return
303
+ const delay = this.retryMs
304
+ this.retryMs = Math.min(this.retryMs * 2, 30_000)
305
+ this.reconnectTimer = setTimeout(() => {
306
+ this.reconnectTimer = null
307
+ void this.#connect()
308
+ }, delay)
309
+ this.reconnectTimer.unref?.()
310
+ }
311
+
312
+ async action(name, params, timeoutMs = 10_000, maxResponseBytes = undefined) {
313
+ if (this.config.enabled !== true || this.stopped) {
314
+ const error = new Error('QQ_HANDOFF_PAUSED: OneBot transport is disabled')
315
+ error.code = 'QQ_HANDOFF_PAUSED'
316
+ throw error
317
+ }
318
+ if (!/^[a-z][a-z0-9_]{1,80}$/.test(name)) throw new TypeError('invalid OneBot action')
319
+ if (maxResponseBytes !== undefined &&
320
+ (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1)) {
321
+ throw new TypeError('maxResponseBytes must be a positive safe integer')
322
+ }
323
+ const url = new URL(name, `${this.config.httpUrl.replace(/\/+$/, '')}/`)
324
+ const controller = new AbortController()
325
+ this.pendingActions.add(controller)
326
+ try {
327
+ const response = await this.fetch(url, {
328
+ method: 'POST',
329
+ headers: { 'content-type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) },
330
+ body: JSON.stringify(params),
331
+ signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), controller.signal]),
332
+ })
333
+ const result = maxResponseBytes === undefined
334
+ ? await response.json()
335
+ : await readBoundedJson(response, maxResponseBytes)
336
+ if (this.stopped) {
337
+ const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
338
+ error.code = 'QQ_HANDOFF_INTERRUPTED'
339
+ throw error
340
+ }
341
+ if (!response.ok || !plainObject(result) || result.status !== 'ok' || result.retcode !== 0) {
342
+ const error = new Error(`OneBot action ${name} rejected`)
343
+ error.code = 'ONEBOT_REJECTED'
344
+ throw error
345
+ }
346
+ return result.data
347
+ } catch (cause) {
348
+ if (this.stopped && cause?.code !== 'QQ_HANDOFF_INTERRUPTED') {
349
+ const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
350
+ error.code = 'QQ_HANDOFF_INTERRUPTED'
351
+ throw error
352
+ }
353
+ throw cause
354
+ } finally {
355
+ this.pendingActions.delete(controller)
356
+ }
357
+ }
358
+
359
+ async send(scene, segments) {
360
+ const identity = sceneIdentity(scene)
361
+ const action = identity.kind === 'group' ? 'send_group_msg' : 'send_private_msg'
362
+ const params = identity.kind === 'group'
363
+ ? { group_id: identity.peerId, message: segments }
364
+ : { user_id: identity.peerId, message: segments }
365
+ const result = await this.action(action, params)
366
+ return { messageId: normalizeMessageId(result?.message_id, 'send receipt message_id') }
367
+ }
368
+
369
+ stop() {
370
+ this.stopped = true
371
+ this.status = 'disabled'
372
+ for (const controller of this.pendingActions) controller.abort()
373
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
374
+ this.reconnectTimer = null
375
+ this.socket?.close?.()
376
+ this.socket = null
377
+ }
378
+ }
@@ -0,0 +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
+ }