@fg-lab/amadeus-qq 0.0.0-stage → 1.2.1

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -3
  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 +26 -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/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  26. package/lib/client.js +380 -0
  27. package/package.json +91 -6
  28. package/preset.patch.yml +103 -0
  29. package/screenshots.json +6 -0
  30. package/scripts/build-client.mjs +52 -0
  31. package/scripts/registry-guard.mjs +12 -0
  32. package/scripts/release-audit.mjs +59 -0
  33. package/scripts/symlink-security.mjs +8 -0
  34. package/src/account-profile.js +223 -0
  35. package/src/admin-http.js +361 -0
  36. package/src/audio.js +428 -0
  37. package/src/capabilities.js +785 -0
  38. package/src/client/factory.template.js +82 -0
  39. package/src/client/logic.js +290 -0
  40. package/src/config.js +48 -0
  41. package/src/console/console.css +28 -0
  42. package/src/console/console.js +538 -0
  43. package/src/console/index.html +67 -0
  44. package/src/core.js +892 -0
  45. package/src/files.js +230 -0
  46. package/src/index.js +867 -0
  47. package/src/lease.js +42 -0
  48. package/src/media.js +246 -0
  49. package/src/napcat-action-names.js +186 -0
  50. package/src/native-asr.js +112 -0
  51. package/src/onebot.js +378 -0
  52. package/src/outbound-media.js +309 -0
  53. package/src/owner-workspace.js +394 -0
  54. package/src/permissions.js +250 -0
  55. package/src/preset-guard.mjs +579 -0
  56. package/src/qzone-provider.js +628 -0
  57. package/src/session-provenance.js +74 -0
  58. package/src/setup.js +255 -0
  59. package/src/storage.js +580 -0
  60. package/src/web-console.js +9 -0
package/src/files.js ADDED
@@ -0,0 +1,230 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { normalizeMessageId } from './storage.js'
4
+ import { sanitizeInboundSegments } from './onebot.js'
5
+
6
+ const MAX_FILE_BYTES = 20 * 1024 * 1024
7
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024
8
+ const MAX_BASE64_CHARS = Math.ceil(MAX_FILE_BYTES / 3) * 4
9
+ const READ_TIMEOUT_MS = 5_000
10
+ const PLATFORM_TIMEOUT_MS = 10_000
11
+
12
+ function mediaError(code) {
13
+ const error = new Error(code)
14
+ error.code = code
15
+ return error
16
+ }
17
+
18
+ function permissionError(code = 'QQ_PERMISSION_DENIED') {
19
+ return mediaError(`${code}: qq.media.read`)
20
+ }
21
+
22
+ function assertMediaRead(core, sessionId, changed = false) {
23
+ let allowed = false
24
+ try {
25
+ allowed = core.authorizeSessionCapability(sessionId, 'qq.media.read') === true
26
+ } catch {
27
+ allowed = false
28
+ }
29
+ if (!allowed) throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
30
+ }
31
+
32
+ function authorizeScene(core, sessionId) {
33
+ try {
34
+ return core.authorizedScene(sessionId)
35
+ } catch (error) {
36
+ const changed = String(error?.message ?? '').startsWith('QQ_PERMISSION_CHANGED')
37
+ throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
38
+ }
39
+ }
40
+
41
+ function resolveMediaRoot(mediaRoot) {
42
+ if (typeof mediaRoot !== 'string' || !path.isAbsolute(mediaRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
43
+ let root
44
+ try {
45
+ root = fs.realpathSync(mediaRoot)
46
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
47
+ } catch {
48
+ throw mediaError('QQ_MEDIA_ROOT_INVALID')
49
+ }
50
+ return root
51
+ }
52
+
53
+ function resolveLocalFile(file, root, mediaRoot) {
54
+ if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
55
+ if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
56
+ if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
57
+ if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
58
+ const lexical = path.relative(mediaRoot, file)
59
+ if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
60
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
61
+ }
62
+
63
+ let entry
64
+ try {
65
+ entry = fs.lstatSync(file)
66
+ } catch {
67
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
68
+ }
69
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
70
+
71
+ let real
72
+ try {
73
+ real = fs.realpathSync(file)
74
+ } catch {
75
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
76
+ }
77
+ const relative = path.relative(root, real)
78
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
79
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
80
+ }
81
+
82
+ let cursor = mediaRoot
83
+ for (const part of lexical.split(path.sep)) {
84
+ cursor = path.join(cursor, part)
85
+ try {
86
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
87
+ } catch (error) {
88
+ if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
89
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
90
+ }
91
+ }
92
+ return real
93
+ }
94
+
95
+ function withTimeout(promise, timeoutMs, code) {
96
+ let timer
97
+ const timeout = new Promise((_, reject) => {
98
+ timer = setTimeout(() => reject(mediaError(code)), timeoutMs)
99
+ timer.unref?.()
100
+ })
101
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
102
+ }
103
+
104
+ async function readBoundedFile(file) {
105
+ let handle
106
+ try {
107
+ handle = await fs.promises.open(file, 'r')
108
+ const stats = await handle.stat()
109
+ if (!stats.isFile()) throw mediaError('QQ_MEDIA_READ_FAILED')
110
+ if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
111
+ if (stats.size > MAX_FILE_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
112
+ const data = Buffer.alloc(stats.size)
113
+ let offset = 0
114
+ while (offset < data.length) {
115
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
116
+ if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
117
+ offset += bytesRead
118
+ }
119
+ return data
120
+ } catch (error) {
121
+ if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
122
+ throw mediaError('QQ_MEDIA_READ_FAILED')
123
+ } finally {
124
+ if (handle) await handle.close().catch(() => {})
125
+ }
126
+ }
127
+
128
+ function decodeInlineFile(value) {
129
+ if (typeof value !== 'string' || value.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
130
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^data:/i.test(value)) {
131
+ throw mediaError('QQ_MEDIA_INLINE_INVALID')
132
+ }
133
+ if (value.length > MAX_BASE64_CHARS) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
134
+ let data
135
+ try {
136
+ data = Buffer.from(value, 'base64')
137
+ } catch {
138
+ throw mediaError('QQ_MEDIA_INLINE_INVALID')
139
+ }
140
+ if (data.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
141
+ if (data.length > MAX_FILE_BYTES) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
142
+ if (data.toString('base64') !== value) throw mediaError('QQ_MEDIA_INLINE_INVALID')
143
+ return data
144
+ }
145
+
146
+ async function readMessageAttachment({
147
+ core,
148
+ store,
149
+ transport,
150
+ attachments,
151
+ mediaRoot,
152
+ sessionId,
153
+ messageId,
154
+ index = 0,
155
+ kind,
156
+ }) {
157
+ if (!Number.isSafeInteger(index) || index < 0) throw mediaError('QQ_MEDIA_INDEX_INVALID')
158
+ if (kind !== 'file' && kind !== 'video') throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
159
+ if (!attachments || typeof attachments.saveFile !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
160
+ if (!transport || typeof transport.action !== 'function') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
161
+ if (transport.status !== undefined && transport.status !== 'connected') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
162
+
163
+ const scene = authorizeScene(core, sessionId)
164
+ assertMediaRead(core, sessionId)
165
+ const root = resolveMediaRoot(mediaRoot)
166
+
167
+ let id
168
+ try {
169
+ id = normalizeMessageId(messageId, 'messageId')
170
+ } catch {
171
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
172
+ }
173
+ const row = store.getScene(scene)
174
+ const message = row?.messages?.find(item => item.messageId === id)
175
+ if (!message) throw mediaError('QQ_MESSAGE_SCOPE')
176
+
177
+ let segments
178
+ try {
179
+ segments = sanitizeInboundSegments(message.segments)
180
+ } catch {
181
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
182
+ }
183
+ const segment = segments.filter(item => item.type === kind)[index]
184
+ const handle = segment?.data?.file
185
+ if (typeof handle !== 'string' || handle.length === 0) throw mediaError('QQ_MEDIA_UNAVAILABLE')
186
+
187
+ let response
188
+ try {
189
+ response = await transport.action('get_file', { file: handle }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
190
+ } catch (error) {
191
+ throw mediaError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
192
+ ? 'QQ_MEDIA_RESPONSE_TOO_LARGE'
193
+ : 'QQ_MEDIA_BACKEND_UNAVAILABLE')
194
+ }
195
+ assertMediaRead(core, sessionId, true)
196
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
197
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
198
+ }
199
+
200
+ let data
201
+ if (typeof response.file === 'string') {
202
+ const localFile = resolveLocalFile(response.file, root, mediaRoot)
203
+ data = await withTimeout(readBoundedFile(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
204
+ } else if (typeof response.base64 === 'string') {
205
+ data = decodeInlineFile(response.base64)
206
+ } else {
207
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
208
+ }
209
+
210
+ assertMediaRead(core, sessionId, true)
211
+ let attachment
212
+ try {
213
+ attachment = await attachments.saveFile({ data, name: kind === 'video' ? 'qq-video' : 'qq-file' })
214
+ } catch {
215
+ throw mediaError('QQ_MEDIA_ATTACHMENT_REJECTED')
216
+ }
217
+ assertMediaRead(core, sessionId, true)
218
+ if (!attachment || typeof attachment !== 'object' || typeof attachment.attachmentId !== 'string') {
219
+ throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
220
+ }
221
+ return { attachment }
222
+ }
223
+
224
+ export function readMessageFile(input) {
225
+ return readMessageAttachment({ ...input, kind: 'file' })
226
+ }
227
+
228
+ export function readMessageVideo(input) {
229
+ return readMessageAttachment({ ...input, kind: 'video' })
230
+ }