@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/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,309 @@
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 resolveProviderRoot(providerCacheRoot) {
48
+ if (typeof providerCacheRoot !== 'string' || !path.isAbsolute(providerCacheRoot)) {
49
+ throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
50
+ }
51
+ try {
52
+ const root = fs.realpathSync(providerCacheRoot)
53
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
54
+ return root
55
+ } catch {
56
+ throw mediaError('QQ_MEDIA_PROVIDER_ROOT_INVALID')
57
+ }
58
+ }
59
+
60
+ function resolveLocalImage(file, root, mediaRoot) {
61
+ if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
62
+ // Reject UNC paths and URI schemes. A Windows drive path is the only
63
+ // scheme-like form accepted, and it must still be absolute.
64
+ if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
65
+ if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
66
+ if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
67
+ const lexical = path.relative(mediaRoot, file)
68
+ if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
69
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
70
+ }
71
+
72
+ let entry
73
+ try {
74
+ entry = fs.lstatSync(file)
75
+ } catch {
76
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
77
+ }
78
+ if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
79
+
80
+ let real
81
+ try {
82
+ real = fs.realpathSync(file)
83
+ } catch {
84
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
85
+ }
86
+ const relative = path.relative(root, real)
87
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
88
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
89
+ }
90
+
91
+ // A link anywhere on the submitted path makes the accepted object
92
+ // ambiguous. Check the lexical path below mediaRoot as well as the resolved
93
+ // path; realpath containment separately rejects any link that escapes root.
94
+ let cursor = mediaRoot
95
+ for (const part of lexical.split(path.sep)) {
96
+ cursor = path.join(cursor, part)
97
+ try {
98
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
99
+ } catch (error) {
100
+ if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
101
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
102
+ }
103
+ }
104
+ return real
105
+ }
106
+
107
+ // NapCat may return a canonical cache path outside the plugin's private media
108
+ // directory. The model never supplies this path: it is obtained only from the
109
+ // bound message handle and the OneBot get_image response. Keep the same strict
110
+ // file, symlink, size and signature checks while accepting that provider cache.
111
+ function resolvePlatformImage(file, providerRoot) {
112
+ if (typeof file !== 'string' || file.length === 0 || file.includes('\u0000')) throw mediaError('QQ_MEDIA_PATH_INVALID')
113
+ if (/^[\\/]{2}/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
114
+ if (!/^[A-Za-z]:[\\/]/.test(file) && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
115
+ if (!path.isAbsolute(file)) throw mediaError('QQ_MEDIA_PATH_INVALID')
116
+ const root = resolveProviderRoot(providerRoot)
117
+ let entry
118
+ try {
119
+ entry = fs.lstatSync(file)
120
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('not a regular file')
121
+ const real = fs.realpathSync(file)
122
+ const resolved = fs.lstatSync(real)
123
+ if (resolved.isSymbolicLink() || !resolved.isFile()) throw new Error('not a regular file')
124
+ const relative = path.relative(root, real)
125
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
126
+ throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
127
+ }
128
+ let cursor = root
129
+ for (const part of relative.split(path.sep)) {
130
+ cursor = path.join(cursor, part)
131
+ const item = fs.lstatSync(cursor)
132
+ if (item.isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
133
+ }
134
+ return real
135
+ } catch (error) {
136
+ if (error?.code === 'QQ_MEDIA_PATH_OUTSIDE_ROOT' || error?.code === 'QQ_MEDIA_PATH_INVALID') throw error
137
+ throw mediaError('QQ_MEDIA_PATH_INVALID')
138
+ }
139
+ }
140
+
141
+ function sniffImageMediaType(data) {
142
+ if (data.length >= PNG_SIGNATURE.length && data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return 'image/png'
143
+ if (data.length >= JPEG_SIGNATURE.length && data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return 'image/jpeg'
144
+ if (data.length >= 6) {
145
+ const header = data.subarray(0, 6).toString('latin1')
146
+ if (header === 'GIF87a' || header === 'GIF89a') return 'image/gif'
147
+ }
148
+ if (data.length >= 12 &&
149
+ data.subarray(0, 4).toString('latin1') === 'RIFF' &&
150
+ data.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
151
+ return null
152
+ }
153
+
154
+ function withTimeout(promise, timeoutMs, code) {
155
+ let timer
156
+ const timeout = new Promise((_, reject) => {
157
+ timer = setTimeout(() => reject(mediaError(code)), timeoutMs)
158
+ timer.unref?.()
159
+ })
160
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
161
+ }
162
+
163
+ async function readBoundedImage(file) {
164
+ let handle
165
+ try {
166
+ handle = await fs.promises.open(file, 'r')
167
+ const stats = await handle.stat()
168
+ if (!stats.isFile()) throw mediaError('QQ_MEDIA_READ_FAILED')
169
+ if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
170
+ if (stats.size > MAX_IMAGE_BYTES) throw mediaError('QQ_MEDIA_TOO_LARGE')
171
+ const data = Buffer.alloc(stats.size)
172
+ let offset = 0
173
+ while (offset < data.length) {
174
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
175
+ if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
176
+ offset += bytesRead
177
+ }
178
+ return data
179
+ } catch (error) {
180
+ if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
181
+ throw mediaError('QQ_MEDIA_READ_FAILED')
182
+ } finally {
183
+ if (handle) await handle.close().catch(() => {})
184
+ }
185
+ }
186
+
187
+ function decodeInlineImage(value) {
188
+ if (typeof value !== 'string' || value.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
189
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^data:/i.test(value)) {
190
+ throw mediaError('QQ_MEDIA_INLINE_INVALID')
191
+ }
192
+ if (value.length > MAX_BASE64_CHARS) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
193
+ let data
194
+ try { data = Buffer.from(value, 'base64') } catch { throw mediaError('QQ_MEDIA_INLINE_INVALID') }
195
+ if (data.length === 0) throw mediaError('QQ_MEDIA_INLINE_INVALID')
196
+ if (data.length > MAX_IMAGE_BYTES) throw mediaError('QQ_MEDIA_INLINE_TOO_LARGE')
197
+ if (data.toString('base64') !== value) throw mediaError('QQ_MEDIA_INLINE_INVALID')
198
+ return data
199
+ }
200
+
201
+ /**
202
+ * Read one inbound QQ image through the bound Scene and hand bounded bytes to
203
+ * the official attachment service. The model supplies only messageId and an
204
+ * image ordinal; Scene, file handle, and local path never come from model args.
205
+ */
206
+ export async function readMessageImage({
207
+ core,
208
+ store,
209
+ transport,
210
+ attachments,
211
+ mediaRoot,
212
+ providerCacheRoot,
213
+ sessionId,
214
+ messageId,
215
+ index = 0,
216
+ }) {
217
+ if (!Number.isSafeInteger(index) || index < 0) throw mediaError('QQ_MEDIA_INDEX_INVALID')
218
+ if (!attachments || typeof attachments.saveImage !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
219
+ if (!transport || typeof transport.action !== 'function') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
220
+ if (transport.status !== undefined && transport.status !== 'connected') throw mediaError('QQ_MEDIA_BACKEND_UNAVAILABLE')
221
+
222
+ let scene
223
+ try {
224
+ scene = core.authorizedScene(sessionId)
225
+ } catch {
226
+ throw permissionError('QQ_PERMISSION_DENIED')
227
+ }
228
+ assertMediaRead(core, sessionId)
229
+
230
+ let id
231
+ try {
232
+ id = normalizeMessageId(messageId, 'messageId')
233
+ } catch {
234
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
235
+ }
236
+ const row = store.getScene(scene)
237
+ const message = row?.messages?.find(item => item.messageId === id)
238
+ if (!message) throw mediaError('QQ_MESSAGE_SCOPE')
239
+
240
+ let segments
241
+ try {
242
+ segments = sanitizeInboundSegments(message.segments)
243
+ } catch {
244
+ throw mediaError('QQ_MEDIA_MESSAGE_INVALID')
245
+ }
246
+ const image = segments.filter(segment => segment.type === 'image')[index]
247
+ const file = image?.data?.file
248
+ if (typeof file !== 'string' || file.length === 0) throw mediaError('QQ_MEDIA_UNAVAILABLE')
249
+
250
+ let response
251
+ try {
252
+ response = await transport.action('get_image', { file }, PLATFORM_TIMEOUT_MS, MAX_RESPONSE_BYTES)
253
+ } catch (error) {
254
+ throw mediaError(error?.code === 'ONEBOT_RESPONSE_TOO_LARGE'
255
+ ? 'QQ_MEDIA_RESPONSE_TOO_LARGE'
256
+ : 'QQ_MEDIA_BACKEND_UNAVAILABLE')
257
+ }
258
+
259
+ // The platform call is the last unbounded external step: a Scene revocation
260
+ // while it was in flight must stop path access and attachment admission.
261
+ assertMediaRead(core, sessionId, true)
262
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
263
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
264
+ }
265
+
266
+ let data
267
+ if (typeof response.file === 'string') {
268
+ // NapCat returns a provider-owned absolute cache path. Prefer the
269
+ // configured private root when it contains the file, but do not make a
270
+ // working OneBot provider cache depend on that optional directory: the
271
+ // handle came from the bound message and is still revalidated as a
272
+ // canonical regular file before bounded reading.
273
+ let localFile
274
+ try {
275
+ const root = resolveMediaRoot(mediaRoot)
276
+ try { localFile = resolveLocalImage(response.file, root, mediaRoot) }
277
+ catch (error) {
278
+ if (error?.code !== 'QQ_MEDIA_PATH_OUTSIDE_ROOT') throw error
279
+ localFile = resolvePlatformImage(response.file, providerCacheRoot)
280
+ }
281
+ } catch (error) {
282
+ if (error?.code !== 'QQ_MEDIA_ROOT_INVALID') throw error
283
+ localFile = resolvePlatformImage(response.file, providerCacheRoot)
284
+ }
285
+ data = await withTimeout(readBoundedImage(localFile), READ_TIMEOUT_MS, 'QQ_MEDIA_TIMEOUT')
286
+ } else if (typeof response.base64 === 'string') {
287
+ data = decodeInlineImage(response.base64)
288
+ } else {
289
+ throw mediaError('QQ_MEDIA_LOCAL_FILE_UNAVAILABLE')
290
+ }
291
+ const mediaType = sniffImageMediaType(data)
292
+ if (mediaType === null) throw mediaError('QQ_MEDIA_UNSUPPORTED_IMAGE')
293
+
294
+ assertMediaRead(core, sessionId, true)
295
+ let attachment
296
+ try {
297
+ attachment = await attachments.saveImage({ data, mediaType })
298
+ } catch {
299
+ throw mediaError('QQ_MEDIA_ATTACHMENT_REJECTED')
300
+ }
301
+ // Host admission can itself be asynchronous. A revocation during that wait
302
+ // must not hand the image block to the Agent, even if the Host cached bytes.
303
+ assertMediaRead(core, sessionId, true)
304
+ if (!attachment || typeof attachment !== 'object' || typeof attachment.attachmentId !== 'string') {
305
+ throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
306
+ }
307
+ return { attachment }
308
+ }
309
+
@@ -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
+ }