@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/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
+ }