@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/lease.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
function processExists(pid) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false
|
|
7
|
+
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function acquireInstanceLease(stateFile) {
|
|
11
|
+
if (typeof stateFile !== 'string' || !path.isAbsolute(stateFile)) throw new TypeError('stateFile must be absolute')
|
|
12
|
+
const file = `${stateFile}.lock`
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
14
|
+
const token = randomUUID()
|
|
15
|
+
const record = JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() })
|
|
16
|
+
let fd
|
|
17
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
fd = fs.openSync(file, 'wx', 0o600)
|
|
20
|
+
break
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code !== 'EEXIST') throw error
|
|
23
|
+
let existing
|
|
24
|
+
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { throw new Error('QQ_INSTANCE_LOCKED: unreadable lock requires manual inspection') }
|
|
25
|
+
if (processExists(existing.pid)) throw new Error('QQ_INSTANCE_LOCKED: another QQ sender is active')
|
|
26
|
+
if (attempt > 0) throw new Error('QQ_INSTANCE_LOCKED: could not acquire after stale lock cleanup')
|
|
27
|
+
// Only a dead process's lock is removed; wx still arbitrates the new owner.
|
|
28
|
+
fs.unlinkSync(file)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (fd === undefined) throw new Error('QQ_INSTANCE_LOCKED')
|
|
32
|
+
try { fs.writeFileSync(fd, record); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
33
|
+
let released = false
|
|
34
|
+
return () => {
|
|
35
|
+
if (released) return
|
|
36
|
+
released = true
|
|
37
|
+
try {
|
|
38
|
+
const current = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
39
|
+
if (current.token === token) fs.unlinkSync(file)
|
|
40
|
+
} catch { /* never remove a lock that cannot be verified as ours */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/media.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
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_IMAGE_BYTES = 5 * 1024 * 1024
|
|
7
|
+
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
8
|
+
const MAX_BASE64_CHARS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4
|
|
9
|
+
const READ_TIMEOUT_MS = 5_000
|
|
10
|
+
const PLATFORM_TIMEOUT_MS = 10_000
|
|
11
|
+
|
|
12
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
13
|
+
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff])
|
|
14
|
+
|
|
15
|
+
function mediaError(code) {
|
|
16
|
+
const error = new Error(code)
|
|
17
|
+
error.code = code
|
|
18
|
+
return error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function permissionError(code = 'QQ_PERMISSION_DENIED') {
|
|
22
|
+
return mediaError(`${code}: qq.media.read`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertMediaRead(core, sessionId, changed = false) {
|
|
26
|
+
let allowed = false
|
|
27
|
+
try {
|
|
28
|
+
allowed = core.authorizeSessionCapability(sessionId, 'qq.media.read') === true
|
|
29
|
+
} catch {
|
|
30
|
+
allowed = false
|
|
31
|
+
}
|
|
32
|
+
if (!allowed) throw permissionError(changed ? 'QQ_PERMISSION_CHANGED' : 'QQ_PERMISSION_DENIED')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveMediaRoot(mediaRoot) {
|
|
36
|
+
if (typeof mediaRoot !== 'string' || !path.isAbsolute(mediaRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
|
|
37
|
+
let root
|
|
38
|
+
try {
|
|
39
|
+
root = fs.realpathSync(mediaRoot)
|
|
40
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
|
|
41
|
+
} catch {
|
|
42
|
+
throw mediaError('QQ_MEDIA_ROOT_INVALID')
|
|
43
|
+
}
|
|
44
|
+
return root
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveLocalImage(file, root, mediaRoot) {
|
|
48
|
+
if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
49
|
+
// Reject UNC paths and URI schemes. A Windows drive path is the only
|
|
50
|
+
// scheme-like form accepted, and it must still be absolute.
|
|
51
|
+
if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
52
|
+
if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
53
|
+
if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
54
|
+
const lexical = path.relative(mediaRoot, file)
|
|
55
|
+
if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
|
|
56
|
+
throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let entry
|
|
60
|
+
try {
|
|
61
|
+
entry = fs.lstatSync(file)
|
|
62
|
+
} catch {
|
|
63
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
64
|
+
}
|
|
65
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
66
|
+
|
|
67
|
+
let real
|
|
68
|
+
try {
|
|
69
|
+
real = fs.realpathSync(file)
|
|
70
|
+
} catch {
|
|
71
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
72
|
+
}
|
|
73
|
+
const relative = path.relative(root, real)
|
|
74
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
75
|
+
throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A link anywhere on the submitted path makes the accepted object
|
|
79
|
+
// ambiguous. Check the lexical path below mediaRoot as well as the resolved
|
|
80
|
+
// path; realpath containment separately rejects any link that escapes root.
|
|
81
|
+
let cursor = mediaRoot
|
|
82
|
+
for (const part of lexical.split(path.sep)) {
|
|
83
|
+
cursor = path.join(cursor, part)
|
|
84
|
+
try {
|
|
85
|
+
if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
|
|
88
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return real
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sniffImageMediaType(data) {
|
|
95
|
+
if (data.length >= PNG_SIGNATURE.length && data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return 'image/png'
|
|
96
|
+
if (data.length >= JPEG_SIGNATURE.length && data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return 'image/jpeg'
|
|
97
|
+
if (data.length >= 6) {
|
|
98
|
+
const header = data.subarray(0, 6).toString('latin1')
|
|
99
|
+
if (header === 'GIF87a' || header === 'GIF89a') return 'image/gif'
|
|
100
|
+
}
|
|
101
|
+
if (data.length >= 12 &&
|
|
102
|
+
data.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
103
|
+
data.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function withTimeout(promise, timeoutMs, code) {
|
|
108
|
+
let timer
|
|
109
|
+
const timeout = new Promise((_, reject) => {
|
|
110
|
+
timer = setTimeout(() => reject(mediaError(code)), timeoutMs)
|
|
111
|
+
timer.unref?.()
|
|
112
|
+
})
|
|
113
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readBoundedImage(file) {
|
|
117
|
+
let handle
|
|
118
|
+
try {
|
|
119
|
+
handle = await fs.promises.open(file, 'r')
|
|
120
|
+
const stats = await handle.stat()
|
|
121
|
+
if (!stats.isFile()) throw mediaError('QQ_MEDIA_READ_FAILED')
|
|
122
|
+
if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
|
|
123
|
+
if (stats.size > MAX_IMAGE_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
|
|
124
|
+
const data = Buffer.alloc(stats.size)
|
|
125
|
+
let offset = 0
|
|
126
|
+
while (offset < data.length) {
|
|
127
|
+
const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
|
|
128
|
+
if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
|
|
129
|
+
offset += bytesRead
|
|
130
|
+
}
|
|
131
|
+
return data
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
|
|
134
|
+
throw mediaError('QQ_MEDIA_READ_FAILED')
|
|
135
|
+
} finally {
|
|
136
|
+
if (handle) await handle.close().catch(() => {})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decodeInlineImage(value) {
|
|
141
|
+
if (typeof value !== 'string' || value.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
|
|
142
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^data:/i.test(value)) {
|
|
143
|
+
throw mediaError('QQ_MEDIA_INLINE_INVALID')
|
|
144
|
+
}
|
|
145
|
+
if (value.length > MAX_BASE64_CHARS) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
|
|
146
|
+
let data
|
|
147
|
+
try { data = Buffer.from(value, 'base64') } catch { throw mediaError('QQ_MEDIA_INLINE_INVALID') }
|
|
148
|
+
if (data.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
|
|
149
|
+
if (data.length > MAX_IMAGE_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
|
+
/**
|
|
155
|
+
* Read one inbound QQ image through the bound Scene and hand bounded bytes to
|
|
156
|
+
* the official attachment service. The model supplies only messageId and an
|
|
157
|
+
* image ordinal; Scene, file handle, and local path never come from model args.
|
|
158
|
+
*/
|
|
159
|
+
export async function readMessageImage({
|
|
160
|
+
core,
|
|
161
|
+
store,
|
|
162
|
+
transport,
|
|
163
|
+
attachments,
|
|
164
|
+
mediaRoot,
|
|
165
|
+
sessionId,
|
|
166
|
+
messageId,
|
|
167
|
+
index = 0,
|
|
168
|
+
}) {
|
|
169
|
+
if (!Number.isSafeInteger(index) || index < 0) throw mediaError('QQ_MEDIA_INDEX_INVALID')
|
|
170
|
+
if (!attachments || typeof attachments.saveImage !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
|
|
171
|
+
if (!transport || typeof transport.action !== 'function') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
|
|
172
|
+
if (transport.status !== undefined && transport.status !== 'connected') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
|
|
173
|
+
|
|
174
|
+
let scene
|
|
175
|
+
try {
|
|
176
|
+
scene = core.authorizedScene(sessionId)
|
|
177
|
+
} catch {
|
|
178
|
+
throw permissionError('QQ_PERMISSION_DENIED')
|
|
179
|
+
}
|
|
180
|
+
assertMediaRead(core, sessionId)
|
|
181
|
+
|
|
182
|
+
const root = resolveMediaRoot(mediaRoot)
|
|
183
|
+
|
|
184
|
+
let id
|
|
185
|
+
try {
|
|
186
|
+
id = normalizeMessageId(messageId, 'messageId')
|
|
187
|
+
} catch {
|
|
188
|
+
throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
|
|
189
|
+
}
|
|
190
|
+
const row = store.getScene(scene)
|
|
191
|
+
const message = row?.messages?.find(item => item.messageId === id)
|
|
192
|
+
if (!message) throw mediaError('QQ_MESSAGE_SCOPE')
|
|
193
|
+
|
|
194
|
+
let segments
|
|
195
|
+
try {
|
|
196
|
+
segments = sanitizeInboundSegments(message.segments)
|
|
197
|
+
} catch {
|
|
198
|
+
throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
|
|
199
|
+
}
|
|
200
|
+
const image = segments.filter(segment => segment.type === 'image')[index]
|
|
201
|
+
const file = image?.data?.file
|
|
202
|
+
if (typeof file !== 'string' || file.length === 0) throw mediaError('QQ_MEDIA_UNAVAILABLE')
|
|
203
|
+
|
|
204
|
+
let response
|
|
205
|
+
try {
|
|
206
|
+
response = await transport.action('get_image', { file }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw mediaError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
|
|
209
|
+
? 'QQ_MEDIA_RESPONSE_TOO_LARGE'
|
|
210
|
+
: 'QQ_MEDIA_BACKEND_UNAVAILABLE')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The platform call is the last unbounded external step: a Scene revocation
|
|
214
|
+
// while it was in flight must stop path access and attachment admission.
|
|
215
|
+
assertMediaRead(core, sessionId, true)
|
|
216
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
|
217
|
+
throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let data
|
|
221
|
+
if (typeof response.file === 'string') {
|
|
222
|
+
const localFile = resolveLocalImage(response.file, root, mediaRoot)
|
|
223
|
+
data = await withTimeout(readBoundedImage(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
|
|
224
|
+
} else if (typeof response.base64 === 'string') {
|
|
225
|
+
data = decodeInlineImage(response.base64)
|
|
226
|
+
} else {
|
|
227
|
+
throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
|
|
228
|
+
}
|
|
229
|
+
const mediaType = sniffImageMediaType(data)
|
|
230
|
+
if (mediaType === null) throw mediaError('QQ_MEDIA_UNSUPPORTED_IMAGE')
|
|
231
|
+
|
|
232
|
+
assertMediaRead(core, sessionId, true)
|
|
233
|
+
let attachment
|
|
234
|
+
try {
|
|
235
|
+
attachment = await attachments.saveImage({ data, mediaType })
|
|
236
|
+
} catch {
|
|
237
|
+
throw mediaError('QQ_MEDIA_ATTACHMENT_REJECTED')
|
|
238
|
+
}
|
|
239
|
+
// Host admission can itself be asynchronous. A revocation during that wait
|
|
240
|
+
// must not hand the image block to the Agent, even if the Host cached bytes.
|
|
241
|
+
assertMediaRead(core, sessionId, true)
|
|
242
|
+
if (!attachment || typeof attachment !== 'object' || typeof attachment.attachmentId !== 'string') {
|
|
243
|
+
throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
|
|
244
|
+
}
|
|
245
|
+
return { attachment }
|
|
246
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// API identifiers inventoried from the installed local NapCat bundle on 2026-10-26.
|
|
2
|
+
// Presence in the bundle does not prove that the currently running backend supports an action.
|
|
3
|
+
export const NAPCAT_ACTION_NAMES = Object.freeze([
|
|
4
|
+
"_del_group_notice",
|
|
5
|
+
"_get_group_notice",
|
|
6
|
+
"_get_model_show",
|
|
7
|
+
"_mark_all_as_read",
|
|
8
|
+
"_send_group_notice",
|
|
9
|
+
"_set_model_show",
|
|
10
|
+
".get_word_slices",
|
|
11
|
+
".handle_quick_operation",
|
|
12
|
+
".ocr_image",
|
|
13
|
+
"add_custom_face",
|
|
14
|
+
"ArkShareGroup",
|
|
15
|
+
"ArkSharePeer",
|
|
16
|
+
"bot_exit",
|
|
17
|
+
"can_send_image",
|
|
18
|
+
"can_send_record",
|
|
19
|
+
"cancel_group_album_media_like",
|
|
20
|
+
"cancel_group_todo",
|
|
21
|
+
"cancel_online_file",
|
|
22
|
+
"check_url_safely",
|
|
23
|
+
"clean_cache",
|
|
24
|
+
"clean_stream_temp_file",
|
|
25
|
+
"click_inline_keyboard_button",
|
|
26
|
+
"complete_group_todo",
|
|
27
|
+
"create_collection",
|
|
28
|
+
"create_flash_task",
|
|
29
|
+
"create_group_file_folder",
|
|
30
|
+
"del_group_album_media",
|
|
31
|
+
"delete_custom_face",
|
|
32
|
+
"delete_essence_msg",
|
|
33
|
+
"delete_friend",
|
|
34
|
+
"delete_group_file",
|
|
35
|
+
"delete_group_folder",
|
|
36
|
+
"delete_msg",
|
|
37
|
+
"delete_qzone_msg",
|
|
38
|
+
"do_group_album_comment",
|
|
39
|
+
"download_file",
|
|
40
|
+
"download_file_image_stream",
|
|
41
|
+
"download_file_record_stream",
|
|
42
|
+
"download_file_stream",
|
|
43
|
+
"download_fileset",
|
|
44
|
+
"fetch_custom_face",
|
|
45
|
+
"fetch_custom_face_detail",
|
|
46
|
+
"fetch_emoji_like",
|
|
47
|
+
"fetch_ptt_text",
|
|
48
|
+
"forward_friend_single_msg",
|
|
49
|
+
"forward_group_single_msg",
|
|
50
|
+
"friend_poke",
|
|
51
|
+
"get_ai_characters",
|
|
52
|
+
"get_ai_record",
|
|
53
|
+
"get_clientkey",
|
|
54
|
+
"get_collection_list",
|
|
55
|
+
"get_cookies",
|
|
56
|
+
"get_credentials",
|
|
57
|
+
"get_csrf_token",
|
|
58
|
+
"get_doubt_friends_add_request",
|
|
59
|
+
"get_emoji_likes",
|
|
60
|
+
"get_essence_msg_list",
|
|
61
|
+
"get_file",
|
|
62
|
+
"get_fileset_id",
|
|
63
|
+
"get_fileset_info",
|
|
64
|
+
"get_flash_file_list",
|
|
65
|
+
"get_flash_file_url",
|
|
66
|
+
"get_forward_msg",
|
|
67
|
+
"get_friend_list",
|
|
68
|
+
"get_friend_msg_history",
|
|
69
|
+
"get_friends_with_category",
|
|
70
|
+
"get_group_album_media_list",
|
|
71
|
+
"get_group_at_all_remain",
|
|
72
|
+
"get_group_detail_info",
|
|
73
|
+
"get_group_file_system_info",
|
|
74
|
+
"get_group_file_url",
|
|
75
|
+
"get_group_files_by_folder",
|
|
76
|
+
"get_group_honor_info",
|
|
77
|
+
"get_group_ignore_add_request",
|
|
78
|
+
"get_group_ignored_notifies",
|
|
79
|
+
"get_group_info",
|
|
80
|
+
"get_group_info_ex",
|
|
81
|
+
"get_group_list",
|
|
82
|
+
"get_group_member_info",
|
|
83
|
+
"get_group_member_list",
|
|
84
|
+
"get_group_msg_history",
|
|
85
|
+
"get_group_root_files",
|
|
86
|
+
"get_group_share_link",
|
|
87
|
+
"get_group_shut_list",
|
|
88
|
+
"get_group_signed_list",
|
|
89
|
+
"get_group_system_msg",
|
|
90
|
+
"get_guild_list",
|
|
91
|
+
"get_guild_service_profile",
|
|
92
|
+
"get_image",
|
|
93
|
+
"get_login_info",
|
|
94
|
+
"get_mini_app_ark",
|
|
95
|
+
"get_msg",
|
|
96
|
+
"get_online_clients",
|
|
97
|
+
"get_online_file_msg",
|
|
98
|
+
"get_private_file_url",
|
|
99
|
+
"get_profile_like",
|
|
100
|
+
"get_qun_album_list",
|
|
101
|
+
"get_recent_contact",
|
|
102
|
+
"get_record",
|
|
103
|
+
"get_rkey",
|
|
104
|
+
"get_rkey_server",
|
|
105
|
+
"get_robot_uin_range",
|
|
106
|
+
"get_share_link",
|
|
107
|
+
"get_status",
|
|
108
|
+
"get_stranger_info",
|
|
109
|
+
"get_unidirectional_friend_list",
|
|
110
|
+
"get_version_info",
|
|
111
|
+
"group_poke",
|
|
112
|
+
"mark_group_msg_as_read",
|
|
113
|
+
"mark_msg_as_read",
|
|
114
|
+
"mark_private_msg_as_read",
|
|
115
|
+
"move_group_file",
|
|
116
|
+
"nc_get_packet_status",
|
|
117
|
+
"nc_get_rkey",
|
|
118
|
+
"nc_get_user_status",
|
|
119
|
+
"ocr_image",
|
|
120
|
+
"receive_online_file",
|
|
121
|
+
"refuse_online_file",
|
|
122
|
+
"rename_group_file",
|
|
123
|
+
"send_ark_share",
|
|
124
|
+
"send_flash_msg",
|
|
125
|
+
"send_forward_msg",
|
|
126
|
+
"send_group_ai_record",
|
|
127
|
+
"send_group_ark_share",
|
|
128
|
+
"send_group_forward_msg",
|
|
129
|
+
"send_group_msg",
|
|
130
|
+
"send_group_sign",
|
|
131
|
+
"send_like",
|
|
132
|
+
"send_msg",
|
|
133
|
+
"send_online_file",
|
|
134
|
+
"send_online_folder",
|
|
135
|
+
"send_packet",
|
|
136
|
+
"send_poke",
|
|
137
|
+
"send_private_forward_msg",
|
|
138
|
+
"send_private_msg",
|
|
139
|
+
"send_qzone_msg",
|
|
140
|
+
"set_custom_face_desc",
|
|
141
|
+
"set_diy_online_status",
|
|
142
|
+
"set_doubt_friends_add_request",
|
|
143
|
+
"set_essence_msg",
|
|
144
|
+
"set_friend_add_request",
|
|
145
|
+
"set_friend_remark",
|
|
146
|
+
"set_group_add_option",
|
|
147
|
+
"set_group_add_request",
|
|
148
|
+
"set_group_admin",
|
|
149
|
+
"set_group_album_media_like",
|
|
150
|
+
"set_group_ban",
|
|
151
|
+
"set_group_card",
|
|
152
|
+
"set_group_kick",
|
|
153
|
+
"set_group_kick_members",
|
|
154
|
+
"set_group_leave",
|
|
155
|
+
"set_group_member_invite_policy",
|
|
156
|
+
"set_group_member_permissions",
|
|
157
|
+
"set_group_name",
|
|
158
|
+
"set_group_new_member_history_visibility",
|
|
159
|
+
"set_group_portrait",
|
|
160
|
+
"set_group_remark",
|
|
161
|
+
"set_group_robot_add_option",
|
|
162
|
+
"set_group_search",
|
|
163
|
+
"set_group_sign",
|
|
164
|
+
"set_group_special_title",
|
|
165
|
+
"set_group_todo",
|
|
166
|
+
"set_group_whole_ban",
|
|
167
|
+
"set_input_status",
|
|
168
|
+
"set_msg_emoji_like",
|
|
169
|
+
"set_online_status",
|
|
170
|
+
"set_qq_avatar",
|
|
171
|
+
"set_qq_profile",
|
|
172
|
+
"set_restart",
|
|
173
|
+
"set_self_longnick",
|
|
174
|
+
"test_auto_register_01",
|
|
175
|
+
"test_auto_register_02",
|
|
176
|
+
"test_download_stream",
|
|
177
|
+
"trans_group_file",
|
|
178
|
+
"translate_en2zh",
|
|
179
|
+
"unknown",
|
|
180
|
+
"upload_file_stream",
|
|
181
|
+
"upload_group_file",
|
|
182
|
+
"upload_image_to_qun_album",
|
|
183
|
+
"upload_images_to_qun_album",
|
|
184
|
+
"upload_private_file",
|
|
185
|
+
"upload_video_to_qun_album",
|
|
186
|
+
])
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { normalizeMessageId } from './storage.js'
|
|
2
|
+
import { sanitizeInboundSegments } from './onebot.js'
|
|
3
|
+
|
|
4
|
+
const MAX_TRANSCRIPT_CHARS = 4000
|
|
5
|
+
const MAX_RESPONSE_BYTES = 64 * 1024
|
|
6
|
+
const PLATFORM_TIMEOUT_MS = 10_000
|
|
7
|
+
|
|
8
|
+
function asrError(code) {
|
|
9
|
+
const error = new Error(code)
|
|
10
|
+
error.code = code
|
|
11
|
+
return error
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readPermissionError(changed = false) {
|
|
15
|
+
return asrError(changed ? 'QQ_PERMISSION_CHANGED: qq.read' : 'QQ_PERMISSION_DENIED: qq.read')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertRead(core, sessionId, changed = false) {
|
|
19
|
+
let allowed = false
|
|
20
|
+
try {
|
|
21
|
+
allowed = core.authorizeSessionCapability(sessionId, 'qq.read') === true
|
|
22
|
+
} catch {
|
|
23
|
+
allowed = false
|
|
24
|
+
}
|
|
25
|
+
if (!allowed) throw readPermissionError(changed)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authorizeScene(core, sessionId) {
|
|
29
|
+
try {
|
|
30
|
+
return core.authorizedScene(sessionId)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const changed = String(error?.message ?? '').startsWith('QQ_PERMISSION_CHANGED')
|
|
33
|
+
throw readPermissionError(changed)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundedText(value) {
|
|
38
|
+
if (typeof value !== 'string') throw asrError('QQ_NATIVE_ASR_INVALID_RESPONSE')
|
|
39
|
+
const text = value
|
|
40
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
|
41
|
+
.trim()
|
|
42
|
+
if (text.length === 0) throw asrError('QQ_NATIVE_ASR_EMPTY')
|
|
43
|
+
if (text.length > MAX_TRANSCRIPT_CHARS) throw asrError('QQ_NATIVE_ASR_TOO_LONG')
|
|
44
|
+
return text
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Use NapCat/QQ's native PTT transcript for one stored record message. The
|
|
49
|
+
* model supplies only messageId; Scene, sender and transcript storage target
|
|
50
|
+
* all come from the current authorized Session.
|
|
51
|
+
*/
|
|
52
|
+
export async function transcribeMessageRecordNative({
|
|
53
|
+
core,
|
|
54
|
+
store,
|
|
55
|
+
transport,
|
|
56
|
+
sessionId,
|
|
57
|
+
messageId,
|
|
58
|
+
}) {
|
|
59
|
+
if (!transport || typeof transport.action !== 'function') throw asrError('QQ_NATIVE_ASR_UNAVAILABLE')
|
|
60
|
+
if (transport.status !== undefined && transport.status !== 'connected') throw asrError('QQ_NATIVE_ASR_UNAVAILABLE')
|
|
61
|
+
|
|
62
|
+
let id
|
|
63
|
+
try {
|
|
64
|
+
id = normalizeMessageId(messageId, 'messageId')
|
|
65
|
+
} catch {
|
|
66
|
+
throw asrError('QQ_NATIVE_ASR_MESSAGE_INVALID')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const scene = authorizeScene(core, sessionId)
|
|
70
|
+
assertRead(core, sessionId)
|
|
71
|
+
|
|
72
|
+
const row = store.getScene(scene)
|
|
73
|
+
const message = row?.messages?.find(item => item.messageId === id)
|
|
74
|
+
if (!message) throw asrError('QQ_MESSAGE_SCOPE')
|
|
75
|
+
|
|
76
|
+
let segments
|
|
77
|
+
try {
|
|
78
|
+
segments = sanitizeInboundSegments(message.segments)
|
|
79
|
+
} catch {
|
|
80
|
+
throw asrError('QQ_NATIVE_ASR_MESSAGE_INVALID')
|
|
81
|
+
}
|
|
82
|
+
if (!segments.some(segment => segment.type === 'record')) throw asrError('QQ_NATIVE_ASR_NOT_RECORD')
|
|
83
|
+
|
|
84
|
+
let response
|
|
85
|
+
try {
|
|
86
|
+
response = await transport.action('fetch_ptt_text', { message_id: id }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw asrError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
|
|
89
|
+
? 'QQ_NATIVE_ASR_RESPONSE_TOO_LARGE'
|
|
90
|
+
: 'QQ_NATIVE_ASR_UNAVAILABLE')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
assertRead(core, sessionId, true)
|
|
94
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
|
95
|
+
throw asrError('QQ_NATIVE_ASR_INVALID_RESPONSE')
|
|
96
|
+
}
|
|
97
|
+
const text = boundedText(response.text)
|
|
98
|
+
assertRead(core, sessionId, true)
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
store.setMessageTranscript(scene, id, text)
|
|
102
|
+
} catch {
|
|
103
|
+
throw asrError('QQ_NATIVE_ASR_STORAGE_FAILED')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
text,
|
|
108
|
+
messageId: id,
|
|
109
|
+
senderId: message.senderId,
|
|
110
|
+
scene: { botId: scene.botId, kind: scene.kind, peerId: scene.peerId },
|
|
111
|
+
}
|
|
112
|
+
}
|