@fg-lab/amadeus-qq 1.2.1 → 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 (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
package/src/onebot.js CHANGED
@@ -1,378 +1,378 @@
1
- import { normalizeId, normalizeMessageId, sceneIdentity } from './storage.js'
2
-
3
- const SEGMENT_LIMIT = 100
4
- const EVENT_SIZE_LIMIT = 256 * 1024
5
- const MEDIA_SEGMENT_TYPES = new Set(['image', 'record', 'video', 'file'])
6
- const MAX_MEDIA_ID_LENGTH = 256
7
- const DIAGNOSTIC_INTERVAL_MS = 60_000
8
-
9
- function plainObject(value) {
10
- return value !== null && typeof value === 'object' && !Array.isArray(value)
11
- }
12
-
13
- /**
14
- * A media `file` field is an opaque platform handle, not a locator. Keep only
15
- * short, single-token handles; every URL scheme, local path, UNC path, CQ code
16
- * and control character is dropped so an inbound event can never carry a
17
- * network or filesystem target into durable state or the model prompt.
18
- */
19
- function safeMediaId(value) {
20
- if (typeof value !== 'string' || value.length < 1 || value.length > MAX_MEDIA_ID_LENGTH) return null
21
- if (value.trim() !== value) return null
22
- if (/[\u0000-\u001f\u007f]/.test(value)) return null
23
- if (/\[CQ:/i.test(value)) return null
24
- if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null
25
- if (/^[A-Za-z]:/.test(value)) return null
26
- if (value.includes('/') || value.includes('\\')) return null
27
- if (value === '.' || value === '..' || value.includes('..')) return null
28
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) return null
29
- return value
30
- }
31
-
32
- function safeId(value, normalize) {
33
- try { return normalize(value) } catch { return null }
34
- }
35
-
36
- /**
37
- * String-format OneBot messages may contain arbitrary CQ payloads. Their
38
- * parameters can hold locators or inline data, so remove each complete code
39
- * while leaving ordinary words on either side intact.
40
- */
41
- function stripCqCodes(text) {
42
- let removed = false
43
- const stripped = text.replace(/\[CQ:[a-z_]+(?:,[^\]]*)?\]/gi, () => {
44
- removed = true
45
- return ' '
46
- })
47
- // Keep the words on both sides separate without carrying a CQ parameter.
48
- return removed ? stripped.replace(/[ \t]{2,}/g, ' ').trim() : text
49
- }
50
-
51
- /**
52
- * Every inbound segment is reduced to a per-type whitelist: text keeps only
53
- * its text, at/reply keep only the IDs used for trigger and quote semantics,
54
- * face keeps only a bounded face ID, media keep only a non-locating handle,
55
- * and unknown segments keep their type with an empty data object. No URL,
56
- * local path, base64/CQ locator or arbitrary platform field is retained.
57
- */
58
- export function sanitizeInboundSegments(source) {
59
- if (!Array.isArray(source)) throw new TypeError('invalid OneBot message segments')
60
- if (source.length > SEGMENT_LIMIT) throw new Error('OneBot message has too many segments')
61
- return source.map(segment => {
62
- if (!plainObject(segment) || typeof segment.type !== 'string' || !/^[a-z_]{1,32}$/i.test(segment.type) || !plainObject(segment.data)) {
63
- throw new TypeError('invalid OneBot segment')
64
- }
65
- const type = segment.type.toLowerCase()
66
- if (type === 'text') {
67
- const text = typeof segment.data.text === 'string' ? stripCqCodes(segment.data.text) : ''
68
- return { type, data: { text } }
69
- }
70
- if (type === 'at') {
71
- const qq = safeId(segment.data.qq, value => normalizeId(value, 'at.qq'))
72
- return { type, data: qq === null ? {} : { qq } }
73
- }
74
- if (type === 'reply') {
75
- // Preserve the existing distinction: a missing ID means "no reply",
76
- // while a malformed non-empty ID still rejects the event.
77
- if (segment.data.id == null) return { type, data: {} }
78
- return { type, data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
79
- }
80
- if (type === 'face') {
81
- const id = safeId(segment.data.id, normalizeFaceId)
82
- return { type, data: id === null ? {} : { id } }
83
- }
84
- if (MEDIA_SEGMENT_TYPES.has(type)) {
85
- const file = safeMediaId(segment.data.file)
86
- return { type, data: file === null ? {} : { file } }
87
- }
88
- return { type, data: {} }
89
- })
90
- }
91
-
92
- export function normalizeOneBotMessage(raw) {
93
- if (!plainObject(raw) || raw.post_type !== 'message') return null
94
- if (raw.message_type !== 'group' && raw.message_type !== 'private') return null
95
- if (JSON.stringify(raw).length > EVENT_SIZE_LIMIT) throw new Error('OneBot event exceeds size limit')
96
- const scene = sceneIdentity({
97
- botId: raw.self_id,
98
- kind: raw.message_type,
99
- peerId: raw.message_type === 'group' ? raw.group_id : raw.user_id,
100
- })
101
- const senderId = normalizeId(raw.user_id, 'senderId')
102
- const messageId = normalizeMessageId(raw.message_id)
103
- const source = Array.isArray(raw.message)
104
- ? raw.message
105
- : [{ type: 'text', data: { text: String(raw.message ?? '') } }]
106
- const segments = sanitizeInboundSegments(source)
107
- const atIds = segments.filter(item => item.type === 'at').map(item => {
108
- try { return normalizeId(item.data.qq, 'at.qq') } catch { return null }
109
- }).filter(Boolean)
110
- const replyId = segments.find(item => item.type === 'reply')?.data?.id
111
- return {
112
- scene,
113
- message: {
114
- messageId,
115
- senderId,
116
- time: Number.isFinite(raw.time) ? raw.time : null,
117
- segments,
118
- replyToMessageId: replyId == null ? null : normalizeMessageId(replyId, 'reply.id'),
119
- atIds,
120
- sender: plainObject(raw.sender)
121
- ? { nickname: typeof raw.sender.nickname === 'string' ? raw.sender.nickname.slice(0, 100) : null,
122
- card: typeof raw.sender.card === 'string' ? raw.sender.card.slice(0, 100) : null }
123
- : null,
124
- },
125
- }
126
- }
127
-
128
- const MAX_FACE_ID = 1_000_000
129
-
130
- /**
131
- * Normalize a standard QQ face ID. Only a canonical non-negative decimal ID
132
- * within a small bound is accepted; any other data fields are intentionally
133
- * dropped by the caller.
134
- */
135
- function normalizeFaceId(value) {
136
- if (typeof value === 'number') {
137
- if (!Number.isSafeInteger(value) || value < 0 || value > MAX_FACE_ID) throw new TypeError('invalid face segment')
138
- return String(value)
139
- }
140
- if (typeof value !== 'string' || !/^(0|[1-9]\d{0,6})$/.test(value)) throw new TypeError('invalid face segment')
141
- const id = Number(value)
142
- if (!Number.isSafeInteger(id) || id > MAX_FACE_ID) throw new TypeError('invalid face segment')
143
- return String(id)
144
- }
145
-
146
- export function validateOutgoingSegments(raw) {
147
- if (!Array.isArray(raw) || raw.length < 1 || raw.length > 20) throw new TypeError('message must contain 1–20 segments')
148
- return raw.map(segment => {
149
- if (!plainObject(segment) || !plainObject(segment.data)) throw new TypeError('invalid outgoing segment')
150
- if (segment.type === 'text') {
151
- if (typeof segment.data.text !== 'string' || !segment.data.text.trim() || segment.data.text.length > 4000) throw new TypeError('invalid text segment')
152
- return { type: 'text', data: { text: segment.data.text } }
153
- }
154
- if (segment.type === 'at') return { type: 'at', data: { qq: normalizeId(segment.data.qq, 'at.qq') } }
155
- if (segment.type === 'reply') return { type: 'reply', data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
156
- if (segment.type === 'face') return { type: 'face', data: { id: normalizeFaceId(segment.data.id) } }
157
- throw new TypeError(`unsupported outgoing segment: ${String(segment.type)}`)
158
- })
159
- }
160
-
161
- function responseTooLarge() {
162
- const error = new Error('ONEBOT_RESPONSE_TOO_LARGE')
163
- error.code = 'ONEBOT_RESPONSE_TOO_LARGE'
164
- return error
165
- }
166
-
167
- async function readBoundedJson(response, maxBytes) {
168
- const declared = response.headers?.get?.('content-length')
169
- if (declared !== null && declared !== undefined) {
170
- const length = Number(declared)
171
- if (Number.isFinite(length) && length > maxBytes) {
172
- try { await response.body?.cancel?.() } catch { /* best effort */ }
173
- throw responseTooLarge()
174
- }
175
- }
176
- if (!response.body || typeof response.body.getReader !== 'function') {
177
- throw new Error('ONEBOT_RESPONSE_UNSUPPORTED')
178
- }
179
- const reader = response.body.getReader()
180
- const chunks = []
181
- let total = 0
182
- try {
183
- for (;;) {
184
- const { done, value } = await reader.read()
185
- if (done) break
186
- const chunk = Buffer.from(value)
187
- total += chunk.length
188
- if (total > maxBytes) {
189
- try { await reader.cancel() } catch { /* best effort */ }
190
- throw responseTooLarge()
191
- }
192
- chunks.push(chunk)
193
- }
194
- } finally {
195
- try { reader.releaseLock() } catch { /* already closed */ }
196
- }
197
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
198
- }
199
-
200
- export class OneBotAdapter {
201
- constructor(config = {}, dependencies = {}) {
202
- this.config = config
203
- this.fetch = dependencies.fetch ?? globalThis.fetch
204
- this.makeWebSocket = dependencies.makeWebSocket
205
- this.socket = null
206
- this.stopped = config.enabled !== true
207
- this.pendingActions = new Set()
208
- this.reconnectTimer = null
209
- this.retryMs = 1000
210
- this.onEvent = null
211
- this.onDiagnostic = typeof dependencies.onDiagnostic === 'function' ? dependencies.onDiagnostic : null
212
- this.diagnosticAt = new Map()
213
- this.status = 'disabled'
214
- }
215
-
216
- /**
217
- * Emit one fixed-code diagnostic per bounded interval. Callers pass only a
218
- * constant code: exception messages, event bodies, URLs, credentials and
219
- * paths never leave the adapter. A diagnostic sink failure cannot affect the
220
- * connection or create an unhandled rejection.
221
- */
222
- #diagnose(code) {
223
- if (!this.onDiagnostic) return
224
- const now = Date.now()
225
- if (now - (this.diagnosticAt.get(code) ?? 0) < DIAGNOSTIC_INTERVAL_MS) return
226
- this.diagnosticAt.set(code, now)
227
- try {
228
- Promise.resolve(this.onDiagnostic(Object.freeze({ code }))).catch(() => {})
229
- } catch {
230
- // The diagnostic sink is best-effort and must never break transport.
231
- }
232
- }
233
-
234
- get token() {
235
- if (this.config.token) {
236
- if (typeof this.config.token !== 'string' || this.config.token.length > 256 || /[\r\n]/.test(this.config.token)) throw new TypeError('invalid token')
237
- return this.config.token
238
- }
239
- const name = this.config.tokenEnv
240
- if (!name) return ''
241
- if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name)) throw new TypeError('invalid tokenEnv')
242
- return process.env[name] ?? ''
243
- }
244
-
245
- async start(onEvent) {
246
- if (this.config.enabled !== true) { this.status = 'disabled'; return }
247
- if (typeof onEvent !== 'function') throw new TypeError('onEvent callback required')
248
- if (!/^wss?:\/\//.test(this.config.wsUrl ?? '') || !/^https?:\/\//.test(this.config.httpUrl ?? '')) {
249
- throw new TypeError('OneBot WS and HTTP URLs are required')
250
- }
251
- this.onEvent = onEvent
252
- this.stopped = false
253
- await this.#connect()
254
- }
255
-
256
- async #connect() {
257
- if (this.stopped) return
258
- this.status = 'connecting'
259
- try {
260
- const factory = this.makeWebSocket ?? ((await import('ws')).default)
261
- if (this.stopped) return
262
- const headers = this.token ? { Authorization: `Bearer ${this.token}` } : {}
263
- const ws = new factory(this.config.wsUrl, { headers })
264
- this.socket = ws
265
- ws.on('open', () => {
266
- if (this.stopped) { ws.close?.(); return }
267
- this.status = 'connected'; this.retryMs = 1000
268
- })
269
- ws.on('message', data => {
270
- if (this.stopped) return
271
- let event
272
- try {
273
- if (data.length > EVENT_SIZE_LIMIT) return
274
- event = JSON.parse(String(data))
275
- } catch {
276
- // A malformed platform event is harmless and cannot stop the socket.
277
- return
278
- }
279
- Promise.resolve()
280
- .then(() => { if (!this.stopped) return this.onEvent(event) })
281
- .catch(() => this.#diagnose('QQ_INBOUND_HANDLER_FAILED'))
282
- })
283
- ws.on('error', () => {
284
- if (this.stopped) return
285
- this.status = 'disconnected'
286
- this.#diagnose('QQ_ONEBOT_SOCKET_ERROR')
287
- })
288
- ws.on('close', () => {
289
- if (this.socket === ws) this.socket = null
290
- this.status = this.stopped ? 'disabled' : 'disconnected'
291
- if (!this.stopped) this.#scheduleReconnect()
292
- })
293
- } catch {
294
- if (this.stopped) return
295
- this.status = 'disconnected'
296
- this.#diagnose('QQ_ONEBOT_CONNECT_FAILED')
297
- this.#scheduleReconnect()
298
- }
299
- }
300
-
301
- #scheduleReconnect() {
302
- if (this.stopped || this.reconnectTimer) return
303
- const delay = this.retryMs
304
- this.retryMs = Math.min(this.retryMs * 2, 30_000)
305
- this.reconnectTimer = setTimeout(() => {
306
- this.reconnectTimer = null
307
- void this.#connect()
308
- }, delay)
309
- this.reconnectTimer.unref?.()
310
- }
311
-
312
- async action(name, params, timeoutMs = 10_000, maxResponseBytes = undefined) {
313
- if (this.config.enabled !== true || this.stopped) {
314
- const error = new Error('QQ_HANDOFF_PAUSED: OneBot transport is disabled')
315
- error.code = 'QQ_HANDOFF_PAUSED'
316
- throw error
317
- }
318
- if (!/^[a-z][a-z0-9_]{1,80}$/.test(name)) throw new TypeError('invalid OneBot action')
319
- if (maxResponseBytes !== undefined &&
320
- (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1)) {
321
- throw new TypeError('maxResponseBytes must be a positive safe integer')
322
- }
323
- const url = new URL(name, `${this.config.httpUrl.replace(/\/+$/, '')}/`)
324
- const controller = new AbortController()
325
- this.pendingActions.add(controller)
326
- try {
327
- const response = await this.fetch(url, {
328
- method: 'POST',
329
- headers: { 'content-type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) },
330
- body: JSON.stringify(params),
331
- signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), controller.signal]),
332
- })
333
- const result = maxResponseBytes === undefined
334
- ? await response.json()
335
- : await readBoundedJson(response, maxResponseBytes)
336
- if (this.stopped) {
337
- const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
338
- error.code = 'QQ_HANDOFF_INTERRUPTED'
339
- throw error
340
- }
341
- if (!response.ok || !plainObject(result) || result.status !== 'ok' || result.retcode !== 0) {
342
- const error = new Error(`OneBot action ${name} rejected`)
343
- error.code = 'ONEBOT_REJECTED'
344
- throw error
345
- }
346
- return result.data
347
- } catch (cause) {
348
- if (this.stopped && cause?.code !== 'QQ_HANDOFF_INTERRUPTED') {
349
- const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
350
- error.code = 'QQ_HANDOFF_INTERRUPTED'
351
- throw error
352
- }
353
- throw cause
354
- } finally {
355
- this.pendingActions.delete(controller)
356
- }
357
- }
358
-
359
- async send(scene, segments) {
360
- const identity = sceneIdentity(scene)
361
- const action = identity.kind === 'group' ? 'send_group_msg' : 'send_private_msg'
362
- const params = identity.kind === 'group'
363
- ? { group_id: identity.peerId, message: segments }
364
- : { user_id: identity.peerId, message: segments }
365
- const result = await this.action(action, params)
366
- return { messageId: normalizeMessageId(result?.message_id, 'send receipt message_id') }
367
- }
368
-
369
- stop() {
370
- this.stopped = true
371
- this.status = 'disabled'
372
- for (const controller of this.pendingActions) controller.abort()
373
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
374
- this.reconnectTimer = null
375
- this.socket?.close?.()
376
- this.socket = null
377
- }
378
- }
1
+ import { normalizeId, normalizeMessageId, sceneIdentity } from './storage.js'
2
+
3
+ const SEGMENT_LIMIT = 100
4
+ const EVENT_SIZE_LIMIT = 256 * 1024
5
+ const MEDIA_SEGMENT_TYPES = new Set(['image', 'record', 'video', 'file'])
6
+ const MAX_MEDIA_ID_LENGTH = 256
7
+ const DIAGNOSTIC_INTERVAL_MS = 60_000
8
+
9
+ function plainObject(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ }
12
+
13
+ /**
14
+ * A media `file` field is an opaque platform handle, not a locator. Keep only
15
+ * short, single-token handles; every URL scheme, local path, UNC path, CQ code
16
+ * and control character is dropped so an inbound event can never carry a
17
+ * network or filesystem target into durable state or the model prompt.
18
+ */
19
+ function safeMediaId(value) {
20
+ if (typeof value !== 'string' || value.length < 1 || value.length > MAX_MEDIA_ID_LENGTH) return null
21
+ if (value.trim() !== value) return null
22
+ if (/[\u0000-\u001f\u007f]/.test(value)) return null
23
+ if (/\[CQ:/i.test(value)) return null
24
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null
25
+ if (/^[A-Za-z]:/.test(value)) return null
26
+ if (value.includes('/') || value.includes('\\')) return null
27
+ if (value === '.' || value === '..' || value.includes('..')) return null
28
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value)) return null
29
+ return value
30
+ }
31
+
32
+ function safeId(value, normalize) {
33
+ try { return normalize(value) } catch { return null }
34
+ }
35
+
36
+ /**
37
+ * String-format OneBot messages may contain arbitrary CQ payloads. Their
38
+ * parameters can hold locators or inline data, so remove each complete code
39
+ * while leaving ordinary words on either side intact.
40
+ */
41
+ function stripCqCodes(text) {
42
+ let removed = false
43
+ const stripped = text.replace(/\[CQ:[a-z_]+(?:,[^\]]*)?\]/gi, () => {
44
+ removed = true
45
+ return ' '
46
+ })
47
+ // Keep the words on both sides separate without carrying a CQ parameter.
48
+ return removed ? stripped.replace(/[ \t]{2,}/g, ' ').trim() : text
49
+ }
50
+
51
+ /**
52
+ * Every inbound segment is reduced to a per-type whitelist: text keeps only
53
+ * its text, at/reply keep only the IDs used for trigger and quote semantics,
54
+ * face keeps only a bounded face ID, media keep only a non-locating handle,
55
+ * and unknown segments keep their type with an empty data object. No URL,
56
+ * local path, base64/CQ locator or arbitrary platform field is retained.
57
+ */
58
+ export function sanitizeInboundSegments(source) {
59
+ if (!Array.isArray(source)) throw new TypeError('invalid OneBot message segments')
60
+ if (source.length > SEGMENT_LIMIT) throw new Error('OneBot message has too many segments')
61
+ return source.map(segment => {
62
+ if (!plainObject(segment) || typeof segment.type !== 'string' || !/^[a-z_]{1,32}$/i.test(segment.type) || !plainObject(segment.data)) {
63
+ throw new TypeError('invalid OneBot segment')
64
+ }
65
+ const type = segment.type.toLowerCase()
66
+ if (type === 'text') {
67
+ const text = typeof segment.data.text === 'string' ? stripCqCodes(segment.data.text) : ''
68
+ return { type, data: { text } }
69
+ }
70
+ if (type === 'at') {
71
+ const qq = safeId(segment.data.qq, value => normalizeId(value, 'at.qq'))
72
+ return { type, data: qq === null ? {} : { qq } }
73
+ }
74
+ if (type === 'reply') {
75
+ // Preserve the existing distinction: a missing ID means "no reply",
76
+ // while a malformed non-empty ID still rejects the event.
77
+ if (segment.data.id == null) return { type, data: {} }
78
+ return { type, data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
79
+ }
80
+ if (type === 'face') {
81
+ const id = safeId(segment.data.id, normalizeFaceId)
82
+ return { type, data: id === null ? {} : { id } }
83
+ }
84
+ if (MEDIA_SEGMENT_TYPES.has(type)) {
85
+ const file = safeMediaId(segment.data.file)
86
+ return { type, data: file === null ? {} : { file } }
87
+ }
88
+ return { type, data: {} }
89
+ })
90
+ }
91
+
92
+ export function normalizeOneBotMessage(raw) {
93
+ if (!plainObject(raw) || raw.post_type !== 'message') return null
94
+ if (raw.message_type !== 'group' && raw.message_type !== 'private') return null
95
+ if (JSON.stringify(raw).length > EVENT_SIZE_LIMIT) throw new Error('OneBot event exceeds size limit')
96
+ const scene = sceneIdentity({
97
+ botId: raw.self_id,
98
+ kind: raw.message_type,
99
+ peerId: raw.message_type === 'group' ? raw.group_id : raw.user_id,
100
+ })
101
+ const senderId = normalizeId(raw.user_id, 'senderId')
102
+ const messageId = normalizeMessageId(raw.message_id)
103
+ const source = Array.isArray(raw.message)
104
+ ? raw.message
105
+ : [{ type: 'text', data: { text: String(raw.message ?? '') } }]
106
+ const segments = sanitizeInboundSegments(source)
107
+ const atIds = segments.filter(item => item.type === 'at').map(item => {
108
+ try { return normalizeId(item.data.qq, 'at.qq') } catch { return null }
109
+ }).filter(Boolean)
110
+ const replyId = segments.find(item => item.type === 'reply')?.data?.id
111
+ return {
112
+ scene,
113
+ message: {
114
+ messageId,
115
+ senderId,
116
+ time: Number.isFinite(raw.time) ? raw.time : null,
117
+ segments,
118
+ replyToMessageId: replyId == null ? null : normalizeMessageId(replyId, 'reply.id'),
119
+ atIds,
120
+ sender: plainObject(raw.sender)
121
+ ? { nickname: typeof raw.sender.nickname === 'string' ? raw.sender.nickname.slice(0, 100) : null,
122
+ card: typeof raw.sender.card === 'string' ? raw.sender.card.slice(0, 100) : null }
123
+ : null,
124
+ },
125
+ }
126
+ }
127
+
128
+ const MAX_FACE_ID = 1_000_000
129
+
130
+ /**
131
+ * Normalize a standard QQ face ID. Only a canonical non-negative decimal ID
132
+ * within a small bound is accepted; any other data fields are intentionally
133
+ * dropped by the caller.
134
+ */
135
+ function normalizeFaceId(value) {
136
+ if (typeof value === 'number') {
137
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_FACE_ID) throw new TypeError('invalid face segment')
138
+ return String(value)
139
+ }
140
+ if (typeof value !== 'string' || !/^(0|[1-9]\d{0,6})$/.test(value)) throw new TypeError('invalid face segment')
141
+ const id = Number(value)
142
+ if (!Number.isSafeInteger(id) || id > MAX_FACE_ID) throw new TypeError('invalid face segment')
143
+ return String(id)
144
+ }
145
+
146
+ export function validateOutgoingSegments(raw) {
147
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > 20) throw new TypeError('message must contain 1–20 segments')
148
+ return raw.map(segment => {
149
+ if (!plainObject(segment) || !plainObject(segment.data)) throw new TypeError('invalid outgoing segment')
150
+ if (segment.type === 'text') {
151
+ if (typeof segment.data.text !== 'string' || !segment.data.text.trim() || segment.data.text.length > 4000) throw new TypeError('invalid text segment')
152
+ return { type: 'text', data: { text: segment.data.text } }
153
+ }
154
+ if (segment.type === 'at') return { type: 'at', data: { qq: normalizeId(segment.data.qq, 'at.qq') } }
155
+ if (segment.type === 'reply') return { type: 'reply', data: { id: normalizeMessageId(segment.data.id, 'reply.id') } }
156
+ if (segment.type === 'face') return { type: 'face', data: { id: normalizeFaceId(segment.data.id) } }
157
+ throw new TypeError(`unsupported outgoing segment: ${String(segment.type)}`)
158
+ })
159
+ }
160
+
161
+ function responseTooLarge() {
162
+ const error = new Error('ONEBOT_RESPONSE_TOO_LARGE')
163
+ error.code = 'ONEBOT_RESPONSE_TOO_LARGE'
164
+ return error
165
+ }
166
+
167
+ async function readBoundedJson(response, maxBytes) {
168
+ const declared = response.headers?.get?.('content-length')
169
+ if (declared !== null && declared !== undefined) {
170
+ const length = Number(declared)
171
+ if (Number.isFinite(length) && length > maxBytes) {
172
+ try { await response.body?.cancel?.() } catch { /* best effort */ }
173
+ throw responseTooLarge()
174
+ }
175
+ }
176
+ if (!response.body || typeof response.body.getReader !== 'function') {
177
+ throw new Error('ONEBOT_RESPONSE_UNSUPPORTED')
178
+ }
179
+ const reader = response.body.getReader()
180
+ const chunks = []
181
+ let total = 0
182
+ try {
183
+ for (;;) {
184
+ const { done, value } = await reader.read()
185
+ if (done) break
186
+ const chunk = Buffer.from(value)
187
+ total += chunk.length
188
+ if (total > maxBytes) {
189
+ try { await reader.cancel() } catch { /* best effort */ }
190
+ throw responseTooLarge()
191
+ }
192
+ chunks.push(chunk)
193
+ }
194
+ } finally {
195
+ try { reader.releaseLock() } catch { /* already closed */ }
196
+ }
197
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
198
+ }
199
+
200
+ export class OneBotAdapter {
201
+ constructor(config = {}, dependencies = {}) {
202
+ this.config = config
203
+ this.fetch = dependencies.fetch ?? globalThis.fetch
204
+ this.makeWebSocket = dependencies.makeWebSocket
205
+ this.socket = null
206
+ this.stopped = config.enabled !== true
207
+ this.pendingActions = new Set()
208
+ this.reconnectTimer = null
209
+ this.retryMs = 1000
210
+ this.onEvent = null
211
+ this.onDiagnostic = typeof dependencies.onDiagnostic === 'function' ? dependencies.onDiagnostic : null
212
+ this.diagnosticAt = new Map()
213
+ this.status = 'disabled'
214
+ }
215
+
216
+ /**
217
+ * Emit one fixed-code diagnostic per bounded interval. Callers pass only a
218
+ * constant code: exception messages, event bodies, URLs, credentials and
219
+ * paths never leave the adapter. A diagnostic sink failure cannot affect the
220
+ * connection or create an unhandled rejection.
221
+ */
222
+ #diagnose(code) {
223
+ if (!this.onDiagnostic) return
224
+ const now = Date.now()
225
+ if (now - (this.diagnosticAt.get(code) ?? 0) < DIAGNOSTIC_INTERVAL_MS) return
226
+ this.diagnosticAt.set(code, now)
227
+ try {
228
+ Promise.resolve(this.onDiagnostic(Object.freeze({ code }))).catch(() => {})
229
+ } catch {
230
+ // The diagnostic sink is best-effort and must never break transport.
231
+ }
232
+ }
233
+
234
+ get token() {
235
+ if (this.config.token) {
236
+ if (typeof this.config.token !== 'string' || this.config.token.length > 256 || /[\r\n]/.test(this.config.token)) throw new TypeError('invalid token')
237
+ return this.config.token
238
+ }
239
+ const name = this.config.tokenEnv
240
+ if (!name) return ''
241
+ if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name)) throw new TypeError('invalid tokenEnv')
242
+ return process.env[name] ?? ''
243
+ }
244
+
245
+ async start(onEvent) {
246
+ if (this.config.enabled !== true) { this.status = 'disabled'; return }
247
+ if (typeof onEvent !== 'function') throw new TypeError('onEvent callback required')
248
+ if (!/^wss?:\/\//.test(this.config.wsUrl ?? '') || !/^https?:\/\//.test(this.config.httpUrl ?? '')) {
249
+ throw new TypeError('OneBot WS and HTTP URLs are required')
250
+ }
251
+ this.onEvent = onEvent
252
+ this.stopped = false
253
+ await this.#connect()
254
+ }
255
+
256
+ async #connect() {
257
+ if (this.stopped) return
258
+ this.status = 'connecting'
259
+ try {
260
+ const factory = this.makeWebSocket ?? ((await import('ws')).default)
261
+ if (this.stopped) return
262
+ const headers = this.token ? { Authorization: `Bearer ${this.token}` } : {}
263
+ const ws = new factory(this.config.wsUrl, { headers })
264
+ this.socket = ws
265
+ ws.on('open', () => {
266
+ if (this.stopped) { ws.close?.(); return }
267
+ this.status = 'connected'; this.retryMs = 1000
268
+ })
269
+ ws.on('message', data => {
270
+ if (this.stopped) return
271
+ let event
272
+ try {
273
+ if (data.length > EVENT_SIZE_LIMIT) return
274
+ event = JSON.parse(String(data))
275
+ } catch {
276
+ // A malformed platform event is harmless and cannot stop the socket.
277
+ return
278
+ }
279
+ Promise.resolve()
280
+ .then(() => { if (!this.stopped) return this.onEvent(event) })
281
+ .catch(() => this.#diagnose('QQ_INBOUND_HANDLER_FAILED'))
282
+ })
283
+ ws.on('error', () => {
284
+ if (this.stopped) return
285
+ this.status = 'disconnected'
286
+ this.#diagnose('QQ_ONEBOT_SOCKET_ERROR')
287
+ })
288
+ ws.on('close', () => {
289
+ if (this.socket === ws) this.socket = null
290
+ this.status = this.stopped ? 'disabled' : 'disconnected'
291
+ if (!this.stopped) this.#scheduleReconnect()
292
+ })
293
+ } catch {
294
+ if (this.stopped) return
295
+ this.status = 'disconnected'
296
+ this.#diagnose('QQ_ONEBOT_CONNECT_FAILED')
297
+ this.#scheduleReconnect()
298
+ }
299
+ }
300
+
301
+ #scheduleReconnect() {
302
+ if (this.stopped || this.reconnectTimer) return
303
+ const delay = this.retryMs
304
+ this.retryMs = Math.min(this.retryMs * 2, 30_000)
305
+ this.reconnectTimer = setTimeout(() => {
306
+ this.reconnectTimer = null
307
+ void this.#connect()
308
+ }, delay)
309
+ this.reconnectTimer.unref?.()
310
+ }
311
+
312
+ async action(name, params, timeoutMs = 10_000, maxResponseBytes = undefined) {
313
+ if (this.config.enabled !== true || this.stopped) {
314
+ const error = new Error('QQ_HANDOFF_PAUSED: OneBot transport is disabled')
315
+ error.code = 'QQ_HANDOFF_PAUSED'
316
+ throw error
317
+ }
318
+ if (!/^[a-z][a-z0-9_]{1,80}$/.test(name)) throw new TypeError('invalid OneBot action')
319
+ if (maxResponseBytes !== undefined &&
320
+ (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1)) {
321
+ throw new TypeError('maxResponseBytes must be a positive safe integer')
322
+ }
323
+ const url = new URL(name, `${this.config.httpUrl.replace(/\/+$/, '')}/`)
324
+ const controller = new AbortController()
325
+ this.pendingActions.add(controller)
326
+ try {
327
+ const response = await this.fetch(url, {
328
+ method: 'POST',
329
+ headers: { 'content-type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) },
330
+ body: JSON.stringify(params),
331
+ signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), controller.signal]),
332
+ })
333
+ const result = maxResponseBytes === undefined
334
+ ? await response.json()
335
+ : await readBoundedJson(response, maxResponseBytes)
336
+ if (this.stopped) {
337
+ const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
338
+ error.code = 'QQ_HANDOFF_INTERRUPTED'
339
+ throw error
340
+ }
341
+ if (!response.ok || !plainObject(result) || result.status !== 'ok' || result.retcode !== 0) {
342
+ const error = new Error(`OneBot action ${name} rejected`)
343
+ error.code = 'ONEBOT_REJECTED'
344
+ throw error
345
+ }
346
+ return result.data
347
+ } catch (cause) {
348
+ if (this.stopped && cause?.code !== 'QQ_HANDOFF_INTERRUPTED') {
349
+ const error = new Error('QQ_HANDOFF_INTERRUPTED: OneBot action outcome is unknown')
350
+ error.code = 'QQ_HANDOFF_INTERRUPTED'
351
+ throw error
352
+ }
353
+ throw cause
354
+ } finally {
355
+ this.pendingActions.delete(controller)
356
+ }
357
+ }
358
+
359
+ async send(scene, segments) {
360
+ const identity = sceneIdentity(scene)
361
+ const action = identity.kind === 'group' ? 'send_group_msg' : 'send_private_msg'
362
+ const params = identity.kind === 'group'
363
+ ? { group_id: identity.peerId, message: segments }
364
+ : { user_id: identity.peerId, message: segments }
365
+ const result = await this.action(action, params)
366
+ return { messageId: normalizeMessageId(result?.message_id, 'send receipt message_id') }
367
+ }
368
+
369
+ stop() {
370
+ this.stopped = true
371
+ this.status = 'disabled'
372
+ for (const controller of this.pendingActions) controller.abort()
373
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
374
+ this.reconnectTimer = null
375
+ this.socket?.close?.()
376
+ this.socket = null
377
+ }
378
+ }