@fg-lab/amadeus-qq 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -3
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +26 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +91 -6
- package/preset.patch.yml +103 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +48 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +538 -0
- package/src/console/index.html +67 -0
- package/src/core.js +892 -0
- package/src/files.js +230 -0
- package/src/index.js +867 -0
- package/src/lease.js +42 -0
- package/src/media.js +246 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +250 -0
- package/src/preset-guard.mjs +579 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- package/src/web-console.js +9 -0
|
@@ -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
|
+
}
|