@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.
- package/LICENSE +21 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- 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
|
+
}
|