@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.
- package/LICENSE +21 -0
- package/README.md +53 -3
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +26 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +91 -6
- package/preset.patch.yml +103 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +48 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +538 -0
- package/src/console/index.html +67 -0
- package/src/core.js +892 -0
- package/src/files.js +230 -0
- package/src/index.js +867 -0
- package/src/lease.js +42 -0
- package/src/media.js +246 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +250 -0
- package/src/preset-guard.mjs +579 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- 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
|
+
}
|