@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
@@ -0,0 +1,785 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { normalizeId, normalizeMessageId, sceneKey } from './storage.js'
3
+ import { sanitizeInboundSegments } from './onebot.js'
4
+ import { NAPCAT_ACTION_NAMES } from './napcat-action-names.js'
5
+
6
+ const BLOCKED = new Set([
7
+ 'get_cookies', 'get_csrf_token', 'get_credentials', 'get_clientkey',
8
+ 'send_packet', 'set_group_leave', 'bot_exit', 'set_restart', 'clean_cache',
9
+ 'nc_get_rkey', 'get_rkey', 'get_rkey_server', 'nc_get_packet_status',
10
+ 'unknown', '.handle_quick_operation',
11
+ ])
12
+
13
+ const EMPTY = Object.freeze({ type: 'object', additionalProperties: false, properties: {} })
14
+ const OBJECT = Object.freeze({ type: 'object', additionalProperties: true })
15
+ const PAGING = Object.freeze({
16
+ type: 'object',
17
+ additionalProperties: false,
18
+ properties: {
19
+ offset: { type: 'integer', minimum: 0 },
20
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
21
+ },
22
+ required: [],
23
+ })
24
+ const MAX_RESULT_CHARS = 64_000
25
+ const MAX_GROUP_READ_RESPONSE_BYTES = 128 * 1024
26
+ const ACTION_ID = Object.freeze({ type: 'string' })
27
+
28
+ function writeParameters(properties, required = []) {
29
+ return Object.freeze({
30
+ type: 'object',
31
+ additionalProperties: false,
32
+ properties: Object.freeze({ actionId: ACTION_ID, ...properties }),
33
+ required: Object.freeze(['actionId', ...required]),
34
+ })
35
+ }
36
+ const DEFAULT_PAGE_LIMIT = 50
37
+
38
+ function parsePaging(args) {
39
+ const offset = args.offset === undefined ? 0 : args.offset
40
+ const limit = args.limit === undefined ? DEFAULT_PAGE_LIMIT : args.limit
41
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError('offset must be a non-negative safe integer')
42
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new TypeError('limit must be an integer from 1 to 100')
43
+ return { offset, limit }
44
+ }
45
+
46
+ function pageDirectory(data, paging, projectItem) {
47
+ if (!Array.isArray(data) || typeof projectItem !== 'function') throw new Error('QQ_CAPABILITY_INVALID_RESPONSE')
48
+ const items = data.slice(paging.offset, paging.offset + paging.limit).map(projectItem)
49
+ return { items, total: data.length, partial: paging.offset + items.length < data.length }
50
+ }
51
+
52
+ function invalidResponse() {
53
+ return new Error('QQ_CAPABILITY_INVALID_RESPONSE')
54
+ }
55
+
56
+ function projectionObject(data) {
57
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw invalidResponse()
58
+ return data
59
+ }
60
+
61
+ function projectId(value) {
62
+ try { return normalizeId(value, 'id') } catch { throw invalidResponse() }
63
+ }
64
+
65
+ function projectString(value, max = 100) {
66
+ return typeof value === 'string' ? value.slice(0, max) : undefined
67
+ }
68
+
69
+ function projectCount(value) {
70
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
71
+ }
72
+
73
+ function requireSameId(actual, expected) {
74
+ if (typeof expected !== 'string' || actual !== expected) throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
75
+ }
76
+
77
+ function finishProjection(projected, data) {
78
+ if (Object.keys(projected).length === 0 && typeof data.ok === 'boolean') projected.ok = data.ok
79
+ return projected
80
+ }
81
+
82
+ function projectStatus(data) {
83
+ const source = projectionObject(data)
84
+ const projected = {}
85
+ if (typeof source.online === 'boolean') projected.online = source.online
86
+ if (typeof source.good === 'boolean') projected.good = source.good
87
+ return finishProjection(projected, source)
88
+ }
89
+
90
+ function projectVersion(data) {
91
+ const source = projectionObject(data)
92
+ const projected = {}
93
+ for (const key of ['app_name', 'app_version', 'protocol_version']) {
94
+ const value = projectString(source[key], 64)
95
+ if (value !== undefined) projected[key] = value
96
+ }
97
+ return finishProjection(projected, source)
98
+ }
99
+
100
+ function projectGroupInfo(data, expected) {
101
+ const source = projectionObject(data)
102
+ const projected = { group_id: projectId(source.group_id) }
103
+ requireSameId(projected.group_id, expected?.group_id)
104
+ const name = projectString(source.group_name)
105
+ if (name !== undefined) projected.group_name = name
106
+ const members = projectCount(source.member_count)
107
+ if (members !== undefined) projected.member_count = members
108
+ const maxMembers = projectCount(source.max_member_count)
109
+ if (maxMembers !== undefined) projected.max_member_count = maxMembers
110
+ return finishProjection(projected, source)
111
+ }
112
+
113
+ function projectMemberInfo(data, expected) {
114
+ const source = projectionObject(data)
115
+ const projected = {
116
+ group_id: projectId(source.group_id),
117
+ user_id: projectId(source.user_id),
118
+ }
119
+ requireSameId(projected.group_id, expected?.group_id)
120
+ requireSameId(projected.user_id, expected?.user_id)
121
+ const nickname = projectString(source.nickname)
122
+ if (nickname !== undefined) projected.nickname = nickname
123
+ const card = projectString(source.card)
124
+ if (card !== undefined) projected.card = card
125
+ const role = projectString(source.role, 32)
126
+ if (role !== undefined) projected.role = role
127
+ return finishProjection(projected, source)
128
+ }
129
+
130
+ function projectMember(data) {
131
+ const source = projectionObject(data)
132
+ const projected = { user_id: projectId(source.user_id) }
133
+ const nickname = projectString(source.nickname)
134
+ if (nickname !== undefined) projected.nickname = nickname
135
+ const card = projectString(source.card)
136
+ if (card !== undefined) projected.card = card
137
+ const role = projectString(source.role, 32)
138
+ if (role !== undefined) projected.role = role
139
+ return finishProjection(projected, source)
140
+ }
141
+
142
+ function projectStranger(data, expected) {
143
+ const source = projectionObject(data)
144
+ const projected = { user_id: projectId(source.user_id) }
145
+ requireSameId(projected.user_id, expected?.user_id)
146
+ const nickname = projectString(source.nickname)
147
+ if (nickname !== undefined) projected.nickname = nickname
148
+ const sex = projectString(source.sex, 16)
149
+ if (sex !== undefined) projected.sex = sex
150
+ return finishProjection(projected, source)
151
+ }
152
+
153
+ function projectLogin(data, expected) {
154
+ const source = projectionObject(data)
155
+ const projected = { user_id: projectId(source.user_id) }
156
+ requireSameId(projected.user_id, expected?.botId)
157
+ const nickname = projectString(source.nickname)
158
+ if (nickname !== undefined) projected.nickname = nickname
159
+ return finishProjection(projected, source)
160
+ }
161
+
162
+ function projectFriend(data) {
163
+ const source = projectionObject(data)
164
+ const projected = { user_id: projectId(source.user_id) }
165
+ const nickname = projectString(source.nickname)
166
+ if (nickname !== undefined) projected.nickname = nickname
167
+ const remark = projectString(source.remark)
168
+ if (remark !== undefined) projected.remark = remark
169
+ return finishProjection(projected, source)
170
+ }
171
+
172
+ function projectGroup(data) {
173
+ const source = projectionObject(data)
174
+ const projected = { group_id: projectId(source.group_id) }
175
+ const name = projectString(source.group_name)
176
+ if (name !== undefined) projected.group_name = name
177
+ const members = projectCount(source.member_count)
178
+ if (members !== undefined) projected.member_count = members
179
+ const maxMembers = projectCount(source.max_member_count)
180
+ if (maxMembers !== undefined) projected.max_member_count = maxMembers
181
+ return finishProjection(projected, source)
182
+ }
183
+
184
+ function projectSegments(value) {
185
+ if (value === undefined) return undefined
186
+ if (!Array.isArray(value)) throw invalidResponse()
187
+ try { return sanitizeInboundSegments(value.slice(0, 20)) } catch { throw invalidResponse() }
188
+ }
189
+
190
+ function projectEssenceItem(data) {
191
+ const source = projectionObject(data)
192
+ const projected = {
193
+ message_id: projectId(source.message_id),
194
+ sender_id: projectId(source.sender_id),
195
+ }
196
+ for (const key of ['msg_seq', 'msg_random', 'operator_time']) {
197
+ if (Number.isSafeInteger(source[key])) projected[key] = source[key]
198
+ }
199
+ const senderNick = projectString(source.sender_nick)
200
+ if (senderNick !== undefined) projected.sender_nick = senderNick
201
+ if (source.operator_id !== undefined) projected.operator_id = projectId(source.operator_id)
202
+ const operatorNick = projectString(source.operator_nick)
203
+ if (operatorNick !== undefined) projected.operator_nick = operatorNick
204
+ const content = projectSegments(source.content)
205
+ if (content !== undefined) projected.content = content
206
+ return projected
207
+ }
208
+
209
+ function projectHonorMember(data) {
210
+ const source = projectionObject(data)
211
+ const projected = { user_id: projectId(source.user_id) }
212
+ const nickname = projectString(source.nickname)
213
+ if (nickname !== undefined) projected.nickname = nickname
214
+ const description = projectString(source.description)
215
+ if (description !== undefined) projected.description = description
216
+ return projected
217
+ }
218
+
219
+ function projectHonorList(value) {
220
+ if (value === undefined) return []
221
+ if (!Array.isArray(value)) throw invalidResponse()
222
+ return value.slice(0, 50).map(projectHonorMember)
223
+ }
224
+
225
+ function projectHonor(data, expected) {
226
+ const source = projectionObject(data)
227
+ const projected = { group_id: projectId(source.group_id) }
228
+ requireSameId(projected.group_id, expected?.group_id)
229
+ if (source.current_talkative !== undefined && source.current_talkative !== null) {
230
+ if (typeof source.current_talkative !== 'object' || Array.isArray(source.current_talkative)) throw invalidResponse()
231
+ if (Object.keys(source.current_talkative).length > 0) {
232
+ projected.current_talkative = projectHonorMember(source.current_talkative)
233
+ }
234
+ }
235
+ for (const key of ['talkative_list', 'performer_list', 'legend_list', 'emotion_list', 'strong_newbie_list']) {
236
+ projected[key] = projectHonorList(source[key])
237
+ }
238
+ return projected
239
+ }
240
+
241
+ function projectNoticeItem(data) {
242
+ const source = projectionObject(data)
243
+ const projected = {}
244
+ const noticeId = projectString(source.notice_id, 100)
245
+ if (noticeId !== undefined) projected.notice_id = noticeId
246
+ if (source.sender_id !== undefined) projected.sender_id = projectId(source.sender_id)
247
+ if (Number.isSafeInteger(source.publish_time)) projected.publish_time = source.publish_time
248
+ if (typeof source.read_num === 'number' && Number.isSafeInteger(source.read_num) && source.read_num >= 0) {
249
+ projected.read_num = source.read_num
250
+ }
251
+ if (typeof source.send_to_new_member === 'boolean') projected.send_to_new_member = source.send_to_new_member
252
+ if (typeof source.pinned === 'boolean') projected.pinned = source.pinned
253
+ if (source.message !== null && typeof source.message === 'object' && !Array.isArray(source.message)) {
254
+ const text = projectString(source.message.text, 500)
255
+ if (text !== undefined) projected.text = text
256
+ }
257
+ return projected
258
+ }
259
+
260
+ function pageNotices(data) {
261
+ if (!Array.isArray(data)) throw invalidResponse()
262
+ const items = data.slice(0, 50).map(projectNoticeItem)
263
+ return { items, total: data.length, partial: data.length > 50 }
264
+ }
265
+
266
+ function requireObservedMessage(store, scene, rawId) {
267
+ let id
268
+ try { id = normalizeMessageId(rawId, 'messageId') } catch { throw invalidResponse() }
269
+ const row = store.getScene(scene)
270
+ if (!row?.messages?.some(message => message.messageId === id)) throw new Error('QQ_MESSAGE_SCOPE')
271
+ return id
272
+ }
273
+
274
+ function requireStableUserId(rawId) {
275
+ try { return normalizeId(rawId, 'userId') } catch { throw new TypeError('invalid userId') }
276
+ }
277
+
278
+ /**
279
+ * `get_msg` is the only capability that returns a platform message body. Build
280
+ * an explicit whitelist so `raw_message`, CQ strings, media URLs/paths and
281
+ * arbitrary platform fields never reach model context.
282
+ */
283
+ function projectPlatformMessage(data, expected) {
284
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('QQ_CAPABILITY_INVALID_RESPONSE')
285
+ const hasBody = data.message !== undefined || data.raw_message !== undefined
286
+ if (!hasBody) {
287
+ // Preserve the legacy boolean probe only. A bodyless response without an
288
+ // explicit status cannot be tied to the requested message.
289
+ if (typeof data.ok !== 'boolean') throw new Error('QQ_CAPABILITY_INVALID_RESPONSE')
290
+ return { data: { ok: data.ok } }
291
+ }
292
+
293
+ let messageId
294
+ try { messageId = normalizeMessageId(data.message_id, 'get_msg.message_id') } catch { throw new Error('QQ_CAPABILITY_INVALID_RESPONSE') }
295
+ if (messageId !== expected?.messageId) throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
296
+
297
+ if (data.message_type !== undefined) {
298
+ if (data.message_type !== expected.scene.kind) throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
299
+ }
300
+ if (data.group_id !== undefined) {
301
+ if (expected.scene.kind !== 'group') throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
302
+ let groupId
303
+ try { groupId = normalizeId(data.group_id, 'get_msg.group_id') } catch { throw new Error('QQ_CAPABILITY_INVALID_RESPONSE') }
304
+ if (groupId !== expected.scene.peerId) throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
305
+ }
306
+ if (data.sender !== null && typeof data.sender === 'object' && !Array.isArray(data.sender) &&
307
+ data.sender.user_id !== undefined) {
308
+ let senderId
309
+ try { senderId = normalizeId(data.sender.user_id, 'get_msg.sender.user_id') } catch { throw new Error('QQ_CAPABILITY_INVALID_RESPONSE') }
310
+ if (typeof expected.senderId !== 'string' || senderId !== expected.senderId) {
311
+ throw new Error('QQ_CAPABILITY_SCOPE_MISMATCH')
312
+ }
313
+ }
314
+
315
+ const projected = { message_id: messageId }
316
+ if (Number.isFinite(data.time)) projected.time = data.time
317
+ if (data.sender !== null && typeof data.sender === 'object' && !Array.isArray(data.sender)) {
318
+ const sender = {}
319
+ if (data.sender.user_id !== undefined) {
320
+ // Already validated above for a body response.
321
+ sender.user_id = normalizeId(data.sender.user_id, 'get_msg.sender.user_id')
322
+ }
323
+ if (typeof data.sender.nickname === 'string') sender.nickname = data.sender.nickname.slice(0, 100)
324
+ if (typeof data.sender.card === 'string') sender.card = data.sender.card.slice(0, 100)
325
+ if (Object.keys(sender).length) projected.sender = sender
326
+ }
327
+ if (Array.isArray(data.message)) projected.message = sanitizeInboundSegments(data.message)
328
+ else if (typeof data.message === 'string') {
329
+ projected.message = sanitizeInboundSegments([{ type: 'text', data: { text: data.message } }])
330
+ }
331
+ // Some OneBot implementations answer a probe with a bare boolean status.
332
+ // Retain that boolean only; every other unlisted field is discarded.
333
+ if (typeof data.ok === 'boolean') projected.ok = data.ok
334
+ return { data: projected }
335
+ }
336
+
337
+ function category(action) {
338
+ if (/qzone/.test(action)) return 'qzone'
339
+ if (/avatar|longnick|online_status|qq_profile|status|login_info/.test(action)) return 'profile'
340
+ if (/record|ptt|voice|ai_record/.test(action)) return 'voice'
341
+ if (/image|face|emoji|ocr|album/.test(action)) return 'image'
342
+ if (/video/.test(action)) return 'video'
343
+ if (/file|folder|fileset|flash/.test(action)) return 'file'
344
+ if (/group|guild/.test(action)) return 'group'
345
+ if (/friend|stranger|like|poke/.test(action)) return 'social'
346
+ return 'message'
347
+ }
348
+
349
+ function risk(action) {
350
+ if (BLOCKED.has(action)) return 'prohibited'
351
+ if (/^(delete|del|set_group_kick|set_group_ban|send_packet)/.test(action)) return 'high'
352
+ if (/^(set|send|upload|create|move|rename|forward|friend_poke|group_poke)/.test(action)) return 'medium'
353
+ return 'low'
354
+ }
355
+
356
+ const EXECUTABLE = new Map([
357
+ ['qq.get_status', { action: 'get_status', category: 'profile', description: 'Read account connection status.', parameters: EMPTY,
358
+ project: projectStatus,
359
+ execute: (_args, _scene) => ({}) }],
360
+ ['qq.get_version_info', { action: 'get_version_info', category: 'profile', description: 'Read OneBot implementation and protocol version.', parameters: EMPTY,
361
+ project: projectVersion,
362
+ execute: (_args, _scene) => ({}) }],
363
+ ['qq.get_group_info', { action: 'get_group_info', category: 'group', description: 'Read information for this Session’s group only.', parameters: EMPTY,
364
+ project: projectGroupInfo,
365
+ execute: (_args, scene) => { if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY'); return { group_id: scene.peerId } } }],
366
+ ['qq.get_group_member_info', { action: 'get_group_member_info', category: 'group', description: 'Read one stable member ID in this Session’s group.',
367
+ parameters: { type: 'object', additionalProperties: false, properties: { userId: { type: 'string' } }, required: ['userId'] },
368
+ project: projectMemberInfo,
369
+ execute: (args, scene) => { if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY'); return { group_id: scene.peerId, user_id: normalizeId(args.userId, 'userId') } } }],
370
+ ['qq.get_group_member_list', { action: 'get_group_member_list', category: 'group', description: 'Read a bounded list of members from this Session’s group.', parameters: EMPTY,
371
+ projectItem: projectMember,
372
+ execute: (_args, scene) => { if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY'); return { group_id: scene.peerId } } }],
373
+ ['qq.get_msg', { action: 'get_msg', category: category('get_msg'), risk: risk('get_msg'),
374
+ description: 'Read one message that already exists in this Session’s durable QQ inbox.',
375
+ parameters: { type: 'object', additionalProperties: false, properties: { messageId: { type: 'string' } }, required: ['messageId'] },
376
+ execute: (args, scene, { store }) => {
377
+ const messageId = normalizeMessageId(args.messageId, 'messageId')
378
+ const row = store.getScene(scene)
379
+ if (!row?.messages.some(message => message.messageId === messageId)) throw new Error('QQ_MESSAGE_SCOPE')
380
+ return { message_id: messageId }
381
+ } }],
382
+ ['qq.get_stranger_info', { action: 'get_stranger_info', category: category('get_stranger_info'), risk: risk('get_stranger_info'),
383
+ description: 'Read a stable QQ user ID already observed in this Session’s bound Scene.',
384
+ parameters: { type: 'object', additionalProperties: false, properties: { userId: { type: 'string' } }, required: ['userId'] },
385
+ project: projectStranger,
386
+ execute: (args, scene, { store }) => {
387
+ const userId = normalizeId(args.userId, 'userId')
388
+ const observed = scene.kind === 'private'
389
+ ? userId === scene.peerId
390
+ : (store.getScene(scene)?.messages.some(message => message.senderId === userId) ?? false)
391
+ if (!observed) throw new Error('QQ_PERSON_SCOPE')
392
+ return { user_id: userId }
393
+ } }],
394
+ ['qq.get_login_info', { action: 'get_login_info', category: category('get_login_info'), risk: risk('get_login_info'),
395
+ description: 'Read the logged-in QQ account summary. This capability accepts no target or scene arguments.',
396
+ parameters: EMPTY,
397
+ project: projectLogin,
398
+ execute: () => ({}) }],
399
+ ['qq.get_friend_list', { action: 'get_friend_list', category: category('get_friend_list'), risk: 'medium',
400
+ scenePermission: 'owner-private-explicit',
401
+ requiredCapability: 'qq.friends.read',
402
+ description: 'Read a bounded page of the account friend directory; requires an explicit owner private grant.',
403
+ parameters: PAGING,
404
+ projectItem: projectFriend,
405
+ execute: () => ({}) }],
406
+ ['qq.get_group_list', { action: 'get_group_list', category: category('get_group_list'), risk: 'medium',
407
+ scenePermission: 'owner-private-explicit',
408
+ requiredCapability: 'qq.groups.read',
409
+ description: 'Read a bounded page of the account group directory; requires an explicit owner private grant.',
410
+ parameters: PAGING,
411
+ projectItem: projectGroup,
412
+ execute: () => ({}) }],
413
+ ['qq.get_essence_msg_list', { action: 'get_essence_msg_list', category: 'group', risk: 'low',
414
+ description: 'Read a bounded page of this Session’s group essence messages.',
415
+ parameters: PAGING,
416
+ projectItem: projectEssenceItem,
417
+ precheckRaw: true,
418
+ execute: (_args, scene) => {
419
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
420
+ return { group_id: scene.peerId }
421
+ } }],
422
+ ['qq.get_group_honor_info', { action: 'get_group_honor_info', category: 'group', risk: 'low',
423
+ description: 'Read this Session’s group honor summary and bounded member lists.',
424
+ parameters: EMPTY,
425
+ project: projectHonor,
426
+ precheckRaw: true,
427
+ execute: (_args, scene) => {
428
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
429
+ return { group_id: scene.peerId, type: 'all' }
430
+ } }],
431
+ ['qq.get_group_notice', { action: '_get_group_notice', category: 'group', risk: 'low',
432
+ description: 'Read this Session’s group notices without retaining images or platform URLs.',
433
+ parameters: EMPTY,
434
+ project: pageNotices,
435
+ precheckRaw: true,
436
+ execute: (_args, scene) => {
437
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
438
+ return { group_id: scene.peerId }
439
+ } }],
440
+ ['qq.set_msg_emoji_like', { action: 'set_msg_emoji_like', category: 'message', risk: 'medium',
441
+ write: true, requiredCapability: 'qq.reaction.write',
442
+ description: 'Set one emoji reaction on a message observed in this bound Scene.',
443
+ parameters: writeParameters({
444
+ messageId: { type: 'string' },
445
+ emojiId: { type: 'string' },
446
+ }, ['messageId', 'emojiId']),
447
+ execute: (args, scene, { store }) => {
448
+ if (!/^[1-9]\d{0,11}$/.test(args.emojiId)) throw new TypeError('invalid emojiId')
449
+ return { message_id: requireObservedMessage(store, scene, args.messageId), emoji_id: args.emojiId, set: true }
450
+ } }],
451
+ ['qq.send_poke', { action: 'send_poke', category: 'social', risk: 'medium',
452
+ write: true, requiredCapability: 'qq.poke',
453
+ description: 'Poke one user already observed in this bound Scene.',
454
+ parameters: writeParameters({ userId: { type: 'string' } }, ['userId']),
455
+ execute: (args, scene, { store }) => {
456
+ const userId = projectId(args.userId)
457
+ const observed = scene.kind === 'private'
458
+ ? userId === scene.peerId
459
+ : store.getScene(scene)?.messages?.some(message => message.senderId === userId)
460
+ if (!observed) throw new Error('QQ_PERSON_SCOPE')
461
+ return scene.kind === 'group'
462
+ ? { group_id: scene.peerId, user_id: userId, target_id: userId }
463
+ : { user_id: userId, target_id: userId }
464
+ } }],
465
+ ['qq.delete_msg', { action: 'delete_msg', category: 'message', risk: 'medium',
466
+ write: true, requiredCapability: 'qq.recall',
467
+ description: 'Recall one message this bot delivered in the bound Scene.',
468
+ parameters: writeParameters({ messageId: { type: 'string' } }, ['messageId']),
469
+ execute: (args, scene, { store }) => {
470
+ let messageId
471
+ try { messageId = normalizeMessageId(args.messageId, 'messageId') } catch { throw invalidResponse() }
472
+ const row = store.getScene(scene)
473
+ const own = [...row.deliveries, ...(row.deliveryArchive ?? [])]
474
+ .some(item => item.status === 'delivered' && item.platformMessageId === messageId)
475
+ if (!own) throw new Error('QQ_RECALL_SCOPE')
476
+ return { message_id: messageId }
477
+ } }],
478
+ ['qq.set_essence_msg', { action: 'set_essence_msg', category: 'group', risk: 'medium',
479
+ write: true, requiredCapability: 'qq.group.essence',
480
+ description: 'Mark one observed group message as an essence message.',
481
+ parameters: writeParameters({ messageId: { type: 'string' } }, ['messageId']),
482
+ execute: (args, scene, { store }) => {
483
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
484
+ return { message_id: requireObservedMessage(store, scene, args.messageId) }
485
+ } }],
486
+ ['qq.delete_essence_msg', { action: 'delete_essence_msg', category: 'group', risk: 'medium',
487
+ write: true, requiredCapability: 'qq.group.essence',
488
+ description: 'Remove one observed group message from the essence list.',
489
+ parameters: writeParameters({ messageId: { type: 'string' } }, ['messageId']),
490
+ execute: (args, scene, { store }) => {
491
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
492
+ return { message_id: requireObservedMessage(store, scene, args.messageId) }
493
+ } }],
494
+ ['qq.set_group_card', { action: 'set_group_card', category: 'group', risk: 'medium',
495
+ write: true, requiredCapability: 'qq.group.card',
496
+ description: 'Set this bot’s group card in the bound group only.',
497
+ parameters: writeParameters({ card: { type: 'string' } }, ['card']),
498
+ execute: (args, scene) => {
499
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
500
+ if (typeof args.card !== 'string' || args.card.length > 30 || /[\u0000-\u001f\u007f]/.test(args.card)) {
501
+ throw new TypeError('invalid group card')
502
+ }
503
+ return { group_id: scene.peerId, user_id: scene.botId, card: args.card }
504
+ } }],
505
+ ['qq.send_group_notice', { action: '_send_group_notice', category: 'group', risk: 'medium',
506
+ write: true, requiredCapability: 'qq.group.notice',
507
+ description: 'Publish a text-only group notice in the bound group; images and paths are not accepted.',
508
+ parameters: writeParameters({
509
+ content: { type: 'string' },
510
+ pinned: { type: 'boolean' },
511
+ }, ['content']),
512
+ execute: (args, scene) => {
513
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
514
+ if (typeof args.content !== 'string' || args.content.trim().length === 0 ||
515
+ args.content.length > 500 || /[\u0000-\u001f\u007f]/.test(args.content)) {
516
+ throw new TypeError('invalid group notice content')
517
+ }
518
+ return {
519
+ group_id: scene.peerId,
520
+ content: args.content,
521
+ pinned: args.pinned === true ? 1 : 0,
522
+ type: 1,
523
+ confirm_required: 1,
524
+ is_show_edit_card: 0,
525
+ tip_window_type: 0,
526
+ }
527
+ } }],
528
+ ['qq.set_group_ban', { action: 'set_group_ban', category: 'group', risk: 'high',
529
+ write: true, requiredCapability: 'qq.group.ban', memberPreflight: true,
530
+ description: 'Mute one observed member in the bound group; duration is seconds (0 unmutes).',
531
+ parameters: writeParameters({
532
+ userId: { type: 'string' },
533
+ duration: { type: 'integer', minimum: 0, maximum: 2592000 },
534
+ }, ['userId', 'duration']),
535
+ execute: (args, scene, { store }) => {
536
+ if (!Number.isSafeInteger(args.duration) || args.duration < 0 || args.duration > 2592000) {
537
+ throw new TypeError('invalid mute duration')
538
+ }
539
+ return { group_id: scene.peerId, user_id: requireStableUserId(args.userId), duration: args.duration }
540
+ } }],
541
+ ['qq.set_group_whole_ban', { action: 'set_group_whole_ban', category: 'group', risk: 'high',
542
+ write: true, requiredCapability: 'qq.group.whole_ban',
543
+ description: 'Enable or disable whole-group mute in the bound group.',
544
+ parameters: writeParameters({ enable: { type: 'boolean' } }, ['enable']),
545
+ execute: (args, scene) => {
546
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
547
+ if (typeof args.enable !== 'boolean') throw new TypeError('invalid whole ban flag')
548
+ return { group_id: scene.peerId, enable: args.enable }
549
+ } }],
550
+ ['qq.set_group_kick', { action: 'set_group_kick', category: 'group', risk: 'high',
551
+ write: true, requiredCapability: 'qq.group.kick', memberPreflight: true,
552
+ description: 'Remove one observed member from the bound group; rejectAddRequest is optional.',
553
+ parameters: writeParameters({
554
+ userId: { type: 'string' },
555
+ rejectAddRequest: { type: 'boolean' },
556
+ }, ['userId']),
557
+ execute: (args, scene, { store }) => ({
558
+ group_id: scene.peerId,
559
+ user_id: requireStableUserId(args.userId),
560
+ reject_add_request: args.rejectAddRequest === true,
561
+ }) }],
562
+ ['qq.set_group_name', { action: 'set_group_name', category: 'group', risk: 'high',
563
+ write: true, requiredCapability: 'qq.group.name',
564
+ description: 'Rename the bound group; the model supplies only a bounded plain name.',
565
+ parameters: writeParameters({ name: { type: 'string' } }, ['name']),
566
+ execute: (args, scene) => {
567
+ if (scene.kind !== 'group') throw new Error('QQ_GROUP_ONLY')
568
+ if (typeof args.name !== 'string' || args.name.trim().length === 0 ||
569
+ args.name.length > 30 || /[\u0000-\u001f\u007f]/.test(args.name)) {
570
+ throw new TypeError('invalid group name')
571
+ }
572
+ return { group_id: scene.peerId, group_name: args.name }
573
+ } }],
574
+ ['qq.set_group_admin', { action: 'set_group_admin', category: 'group', risk: 'high',
575
+ write: true, requiredCapability: 'qq.group.admin', memberPreflight: true,
576
+ description: 'Set or unset administrator role for one observed member in the bound group.',
577
+ parameters: writeParameters({
578
+ userId: { type: 'string' },
579
+ enable: { type: 'boolean' },
580
+ }, ['userId', 'enable']),
581
+ execute: (args, scene, { store }) => {
582
+ if (typeof args.enable !== 'boolean') throw new TypeError('invalid admin flag')
583
+ return { group_id: scene.peerId, user_id: requireStableUserId(args.userId), enable: args.enable }
584
+ } }],
585
+ ['qq.set_group_special_title', { action: 'set_group_special_title', category: 'group', risk: 'medium',
586
+ write: true, requiredCapability: 'qq.group.special_title', memberPreflight: true,
587
+ description: 'Set one observed member’s special title in the bound group.',
588
+ parameters: writeParameters({
589
+ userId: { type: 'string' },
590
+ title: { type: 'string' },
591
+ }, ['userId', 'title']),
592
+ execute: (args, scene, { store }) => {
593
+ if (typeof args.title !== 'string' || args.title.length > 20 || /[\u0000-\u001f\u007f]/.test(args.title)) {
594
+ throw new TypeError('invalid special title')
595
+ }
596
+ return {
597
+ group_id: scene.peerId,
598
+ user_id: requireStableUserId(args.userId),
599
+ special_title: args.title,
600
+ }
601
+ } }],
602
+ ])
603
+
604
+ function validateArgs(args, schema) {
605
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new TypeError('capability arguments must be an object')
606
+ const keys = new Set(Object.keys(schema.properties ?? {}))
607
+ for (const key of Object.keys(args)) if (!keys.has(key)) throw new TypeError(`unexpected capability argument: ${key}`)
608
+ for (const key of schema.required ?? []) if (!(key in args)) throw new TypeError(`missing capability argument: ${key}`)
609
+ }
610
+
611
+ function inventoryDescriptor(action) {
612
+ return {
613
+ id: `napcat.${action}`,
614
+ provider: 'installed-napcat-bundle',
615
+ action,
616
+ category: category(action),
617
+ description: `Installed NapCat action identifier: ${action}`,
618
+ parameters: OBJECT,
619
+ result: OBJECT,
620
+ accountPermission: 'configured-bot',
621
+ scenePermission: 'unverified',
622
+ risk: risk(action),
623
+ status: BLOCKED.has(action) ? 'unsupported' : 'unverified',
624
+ schemaStatus: 'unverified',
625
+ executable: false,
626
+ }
627
+ }
628
+
629
+ export class CapabilityRegistry {
630
+ constructor({ core, store, transport }) {
631
+ this.core = core
632
+ this.store = store
633
+ this.transport = transport
634
+ }
635
+
636
+ list() {
637
+ const installed = NAPCAT_ACTION_NAMES.map(inventoryDescriptor)
638
+ const supported = [...EXECUTABLE].map(([id, definition]) => ({
639
+ id, provider: 'onebot-v11', action: definition.action, category: definition.category,
640
+ description: definition.description, parameters: definition.parameters, result: OBJECT,
641
+ accountPermission: 'configured-bot', scenePermission: definition.scenePermission ?? 'bound-scene', risk: definition.risk ?? 'low',
642
+ status: this.transport.status === 'connected' ? 'unverified' : 'unavailable',
643
+ schemaStatus: 'verified-local', executable: true,
644
+ }))
645
+ return [...supported, ...installed]
646
+ }
647
+
648
+ search(query = '', { category: categoryFilter, limit = 20 } = {}) {
649
+ if (typeof query !== 'string' || query.length > 100) throw new TypeError('invalid capability search query')
650
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) throw new TypeError('invalid capability search limit')
651
+ const needle = query.toLowerCase().trim()
652
+ const matches = this.list().filter(item =>
653
+ (!categoryFilter || item.category === categoryFilter) &&
654
+ (!needle || `${item.id} ${item.description} ${item.category}`.toLowerCase().includes(needle)))
655
+ return { items: matches.slice(0, limit).map(({ parameters, result, ...brief }) => brief), total: matches.length, partial: matches.length > limit }
656
+ }
657
+
658
+ describe(id) {
659
+ const found = this.list().find(item => item.id === id)
660
+ if (!found) throw new Error('QQ_CAPABILITY_UNKNOWN')
661
+ return found
662
+ }
663
+
664
+ async execute(sessionId, id, args = {}) {
665
+ const scene = this.core.authorizedScene(sessionId)
666
+ const definition = EXECUTABLE.get(id)
667
+ if (!definition) throw new Error('QQ_CAPABILITY_NOT_EXECUTABLE')
668
+ if (definition.write === true) return this.#executeWrite(sessionId, id, definition, args, scene)
669
+ const requiredCapability = definition.requiredCapability ?? 'qq.read'
670
+ if (requiredCapability !== 'qq.read' &&
671
+ this.core.authorizeSessionCapability(sessionId, requiredCapability) !== true) {
672
+ throw new Error(`QQ_PERMISSION_DENIED: ${requiredCapability}`)
673
+ }
674
+ validateArgs(args, definition.parameters)
675
+ const paging = id === 'qq.get_friend_list' || id === 'qq.get_group_list' || id === 'qq.get_essence_msg_list'
676
+ ? parsePaging(args)
677
+ : null
678
+ if (this.transport.status !== 'connected') throw new Error('QQ_TRANSPORT_UNAVAILABLE')
679
+ const params = definition.execute(args, scene, { store: this.store })
680
+ const data = definition.precheckRaw === true
681
+ ? await this.transport.action(definition.action, params, 10_000, MAX_GROUP_READ_RESPONSE_BYTES)
682
+ : await this.transport.action(definition.action, params)
683
+ // Reject an oversized raw platform response before shaping it. This keeps
684
+ // the bounded-result guarantee even when the whitelist would discard it.
685
+ if ((id === 'qq.get_msg' || definition.precheckRaw === true) &&
686
+ (JSON.stringify(data)?.length ?? 0) > MAX_RESULT_CHARS) {
687
+ throw new Error('QQ_CAPABILITY_RESULT_TOO_LARGE')
688
+ }
689
+ // Any Scene read grant may be revoked while the platform call is in flight.
690
+ // Re-check the current binding and capability before shaping model output.
691
+ if (this.core.authorizeSessionCapability(sessionId, requiredCapability) !== true) {
692
+ throw new Error(`QQ_PERMISSION_CHANGED: ${requiredCapability} was revoked during the read`)
693
+ }
694
+ let result
695
+ if (paging) {
696
+ if (typeof definition.projectItem !== 'function') throw new Error('QQ_CAPABILITY_PROJECTION_MISSING')
697
+ result = pageDirectory(data, paging, definition.projectItem)
698
+ } else if (id === 'qq.get_msg') {
699
+ const stored = this.store.getScene(scene)?.messages?.find(message => message.messageId === params.message_id)
700
+ result = projectPlatformMessage(data, {
701
+ messageId: params.message_id,
702
+ scene,
703
+ senderId: stored?.senderId,
704
+ })
705
+ }
706
+ else if (id === 'qq.get_group_member_list') {
707
+ if (!Array.isArray(data) || typeof definition.projectItem !== 'function') throw invalidResponse()
708
+ const items = data.slice(0, 200).map(definition.projectItem)
709
+ result = { items, total: data.length, partial: data.length > 200 }
710
+ } else {
711
+ if (typeof definition.project !== 'function') throw new Error('QQ_CAPABILITY_PROJECTION_MISSING')
712
+ result = { data: definition.project(data, { ...params, botId: scene.botId }) }
713
+ }
714
+ if (JSON.stringify(result).length > MAX_RESULT_CHARS) throw new Error('QQ_CAPABILITY_RESULT_TOO_LARGE')
715
+ return result
716
+ }
717
+
718
+ async #executeWrite(sessionId, id, definition, args, scene) {
719
+ const requiredCapability = definition.requiredCapability
720
+ const canWrite = () => this.core.authorizeSessionCapability(sessionId, requiredCapability) === true &&
721
+ this.core.authorizeSessionCapability(sessionId, 'qq.send') === true
722
+ if (!canWrite()) {
723
+ throw new Error(`QQ_PERMISSION_DENIED: ${requiredCapability}`)
724
+ }
725
+ validateArgs(args, definition.parameters)
726
+ if (typeof args.actionId !== 'string' || !/^[\w-]{1,100}$/.test(args.actionId)) {
727
+ throw new TypeError('invalid actionId')
728
+ }
729
+ const actionId = args.actionId
730
+ const payload = { ...args }
731
+ delete payload.actionId
732
+ const params = definition.execute(payload, scene, { store: this.store })
733
+ if (definition.memberPreflight === true) {
734
+ await this.#verifyGroupMember(scene, params.user_id)
735
+ // The membership query is external: revoke after it but before reserve.
736
+ if (!canWrite()) throw new Error(`QQ_PERMISSION_CHANGED: ${requiredCapability}`)
737
+ }
738
+ const fingerprint = createHash('sha256')
739
+ .update(JSON.stringify({ kind: 'capability', id, scene: sceneKey(scene), payload }))
740
+ .digest('hex')
741
+ const reserved = this.store.reserveDelivery(scene, actionId, fingerprint)
742
+ if (!reserved.fresh) {
743
+ return { status: reserved.delivery.status, repeated: true }
744
+ }
745
+ const inFlightKey = `${sceneKey(scene)}:${actionId}`
746
+ this.core.inFlightDeliveries.add(inFlightKey)
747
+ try {
748
+ if (!canWrite()) {
749
+ try { this.store.settleDelivery(scene, actionId, 'failed') } catch { /* keep permission failure */ }
750
+ throw new Error(`QQ_PERMISSION_CHANGED: ${requiredCapability}`)
751
+ }
752
+ if (this.transport.status !== 'connected') {
753
+ this.store.settleDelivery(scene, actionId, 'failed')
754
+ throw new Error('QQ_TRANSPORT_UNAVAILABLE')
755
+ }
756
+ try {
757
+ await this.transport.action(definition.action, params)
758
+ } catch (error) {
759
+ const status = error?.code === 'ONEBOT_REJECTED' ? 'failed' : 'unknown'
760
+ this.store.settleDelivery(scene, actionId, status)
761
+ return {
762
+ status,
763
+ repeated: false,
764
+ code: status === 'failed' ? 'ONEBOT_REJECTED' : 'QQ_DELIVERY_UNKNOWN',
765
+ }
766
+ }
767
+ this.store.settleDelivery(scene, actionId, 'delivered')
768
+ return { status: 'delivered', repeated: false }
769
+ } finally {
770
+ this.core.inFlightDeliveries.delete(inFlightKey)
771
+ }
772
+ }
773
+
774
+ async #verifyGroupMember(scene, userId) {
775
+ const row = this.store.getScene(scene)
776
+ if (row?.messages?.some(message => message.senderId === userId)) return
777
+ const data = await this.transport.action(
778
+ 'get_group_member_info',
779
+ { group_id: scene.peerId, user_id: userId },
780
+ 10_000,
781
+ MAX_GROUP_READ_RESPONSE_BYTES,
782
+ )
783
+ projectMemberInfo(data, { group_id: scene.peerId, user_id: userId })
784
+ }
785
+ }