@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
package/src/onebot.js
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const MIB = 1024 * 1024
|
|
7
|
+
export const OUTBOUND_MEDIA_LIMITS = Object.freeze({
|
|
8
|
+
image: 5 * MIB,
|
|
9
|
+
gif: 5 * MIB,
|
|
10
|
+
video: 20 * MIB,
|
|
11
|
+
file: 20 * MIB,
|
|
12
|
+
audio: 4 * MIB,
|
|
13
|
+
})
|
|
14
|
+
export const TTS_UNAVAILABLE_CODE = 'QQ_TTS_UNAVAILABLE'
|
|
15
|
+
|
|
16
|
+
const BLOCKED_FILE_EXTENSIONS = new Set([
|
|
17
|
+
'.bat', '.cmd', '.com', '.dll', '.exe', '.hta', '.js', '.jse', '.lnk',
|
|
18
|
+
'.msi', '.msp', '.ps1', '.scr', '.vbe', '.vbs', '.wsf', '.wsh',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
function mediaError(code) {
|
|
22
|
+
const error = new Error(code)
|
|
23
|
+
error.code = code
|
|
24
|
+
return error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertSend(core, sessionId) {
|
|
28
|
+
let scene
|
|
29
|
+
try {
|
|
30
|
+
scene = core.authorizedScene(sessionId)
|
|
31
|
+
} catch {
|
|
32
|
+
throw mediaError('QQ_PERMISSION_DENIED: qq.send')
|
|
33
|
+
}
|
|
34
|
+
let allowed = false
|
|
35
|
+
try {
|
|
36
|
+
allowed = core.authorizeSessionCapability(sessionId, 'qq.send') === true
|
|
37
|
+
} catch {
|
|
38
|
+
allowed = false
|
|
39
|
+
}
|
|
40
|
+
if (!allowed) throw mediaError('QQ_PERMISSION_DENIED: qq.send')
|
|
41
|
+
return scene
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveSendRoot(sendRoot) {
|
|
45
|
+
if (typeof sendRoot !== 'string' || !path.isAbsolute(sendRoot)) throw mediaError('QQ_MEDIA_ROOT_INVALID')
|
|
46
|
+
let root
|
|
47
|
+
try {
|
|
48
|
+
root = fs.realpathSync(sendRoot)
|
|
49
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('not a directory')
|
|
50
|
+
} catch {
|
|
51
|
+
throw mediaError('QQ_MEDIA_ROOT_INVALID')
|
|
52
|
+
}
|
|
53
|
+
return root
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveRootSource(relative, root, sendRoot) {
|
|
57
|
+
if (typeof relative !== 'string' || relative.length === 0 || relative.includes('\u0000')) {
|
|
58
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
59
|
+
}
|
|
60
|
+
if (path.isAbsolute(relative)) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
61
|
+
const candidate = path.resolve(sendRoot, relative)
|
|
62
|
+
const lexical = path.relative(sendRoot, candidate)
|
|
63
|
+
if (lexical === '' || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
|
|
64
|
+
throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let entry
|
|
68
|
+
try {
|
|
69
|
+
entry = fs.lstatSync(candidate)
|
|
70
|
+
} catch {
|
|
71
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
72
|
+
}
|
|
73
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
74
|
+
|
|
75
|
+
let real
|
|
76
|
+
try {
|
|
77
|
+
real = fs.realpathSync(candidate)
|
|
78
|
+
} catch {
|
|
79
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
80
|
+
}
|
|
81
|
+
const relativeReal = path.relative(root, real)
|
|
82
|
+
if (relativeReal === '' || relativeReal === '..' ||
|
|
83
|
+
relativeReal.startsWith(`..${path.sep}`) || path.isAbsolute(relativeReal)) {
|
|
84
|
+
throw mediaError('QQ_MEDIA_PATH_OUTSIDE_ROOT')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cursor = sendRoot
|
|
88
|
+
for (const part of lexical.split(path.sep)) {
|
|
89
|
+
cursor = path.join(cursor, part)
|
|
90
|
+
try {
|
|
91
|
+
if (fs.lstatSync(cursor).isSymbolicLink()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code === 'QQ_MEDIA_PATH_INVALID') throw error
|
|
94
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return real
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveHostAttachment(source, kind, attachments) {
|
|
101
|
+
const ref = source.attachment
|
|
102
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref) ||
|
|
103
|
+
typeof ref.attachmentId !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(ref.attachmentId) ||
|
|
104
|
+
typeof ref.name !== 'string' || typeof ref.bytes !== 'number' ||
|
|
105
|
+
!Number.isSafeInteger(ref.bytes) || ref.bytes < 1) {
|
|
106
|
+
throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
|
|
107
|
+
}
|
|
108
|
+
if (!attachments || typeof attachments !== 'object') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
|
|
109
|
+
const resolver = kind === 'image' || kind === 'gif' ? attachments.imageHostPath : attachments.fileHostPath
|
|
110
|
+
if (typeof resolver !== 'function') throw mediaError('QQ_MEDIA_ATTACHMENTS_UNAVAILABLE')
|
|
111
|
+
let hostPath
|
|
112
|
+
try {
|
|
113
|
+
hostPath = resolver.call(attachments, ref)
|
|
114
|
+
} catch {
|
|
115
|
+
throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
|
|
116
|
+
}
|
|
117
|
+
if (typeof hostPath !== 'string' || !path.isAbsolute(hostPath)) throw mediaError('QQ_MEDIA_ATTACHMENT_INVALID')
|
|
118
|
+
return hostPath
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function statRegularFile(file) {
|
|
122
|
+
let entry
|
|
123
|
+
try {
|
|
124
|
+
entry = fs.lstatSync(file)
|
|
125
|
+
} catch {
|
|
126
|
+
throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
127
|
+
}
|
|
128
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
129
|
+
return entry
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sniffKind(kind, data, name) {
|
|
133
|
+
if (kind === 'image') {
|
|
134
|
+
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
|
135
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg'
|
|
136
|
+
if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
|
137
|
+
throw mediaError('QQ_MEDIA_FORMAT_INVALID')
|
|
138
|
+
}
|
|
139
|
+
if (kind === 'gif') {
|
|
140
|
+
if (data.length >= 6 && (data.toString('ascii', 0, 6) === 'GIF87a' || data.toString('ascii', 0, 6) === 'GIF89a')) return 'image/gif'
|
|
141
|
+
throw mediaError('QQ_MEDIA_FORMAT_INVALID')
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'video') {
|
|
144
|
+
if (data.length >= 12 && data.toString('ascii', 4, 8) === 'ftyp') return 'video/mp4'
|
|
145
|
+
if (data.length >= 4 && data[0] === 0x1a && data[1] === 0x45 && data[2] === 0xdf && data[3] === 0xa3) return 'video/webm'
|
|
146
|
+
throw mediaError('QQ_MEDIA_FORMAT_INVALID')
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'audio') {
|
|
149
|
+
if (data.length >= 12 && data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav'
|
|
150
|
+
if (data.length >= 3 && data.toString('ascii', 0, 3) === 'ID3') return 'audio/mpeg'
|
|
151
|
+
if (data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0) return 'audio/mpeg'
|
|
152
|
+
if (data.length >= 4 && data.toString('ascii', 0, 4) === 'OggS') return 'audio/ogg'
|
|
153
|
+
if (data.length >= 4 && data.toString('ascii', 0, 4) === 'fLaC') return 'audio/flac'
|
|
154
|
+
if (data.length >= 6 && data.toString('ascii', 0, 6) === '#!AMR\n') return 'audio/amr'
|
|
155
|
+
throw mediaError('QQ_MEDIA_FORMAT_INVALID')
|
|
156
|
+
}
|
|
157
|
+
if (kind === 'file') {
|
|
158
|
+
const extension = path.extname(name).toLowerCase()
|
|
159
|
+
if (!extension || BLOCKED_FILE_EXTENSIONS.has(extension)) throw mediaError('QQ_MEDIA_FORMAT_INVALID')
|
|
160
|
+
return 'application/octet-stream'
|
|
161
|
+
}
|
|
162
|
+
throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function readAndHash(file, maxBytes) {
|
|
166
|
+
let handle
|
|
167
|
+
try {
|
|
168
|
+
handle = await fs.promises.open(file, 'r')
|
|
169
|
+
const stats = await handle.stat()
|
|
170
|
+
if (!stats.isFile()) throw mediaError('QQ_MEDIA_PATH_INVALID')
|
|
171
|
+
if (stats.size <= 0) throw mediaError('QQ_MEDIA_EMPTY')
|
|
172
|
+
if (stats.size > maxBytes) throw mediaError('QQ_MEDIA_TOO_LARGE')
|
|
173
|
+
const data = Buffer.alloc(stats.size)
|
|
174
|
+
let offset = 0
|
|
175
|
+
while (offset < data.length) {
|
|
176
|
+
const { bytesRead } = await handle.read(data, offset, data.length - offset, offset)
|
|
177
|
+
if (bytesRead <= 0) throw mediaError('QQ_MEDIA_READ_FAILED')
|
|
178
|
+
offset += bytesRead
|
|
179
|
+
}
|
|
180
|
+
return { data, sha256: createHash('sha256').update(data).digest('hex') }
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
|
|
183
|
+
throw mediaError('QQ_MEDIA_READ_FAILED')
|
|
184
|
+
} finally {
|
|
185
|
+
if (handle) await handle.close().catch(() => {})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function safeName(value, kind) {
|
|
190
|
+
const base = path.basename(String(value ?? '')).replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
191
|
+
if (base.length === 0) return kind === 'video' ? 'qq-video' : 'qq-file'
|
|
192
|
+
return base.slice(0, 128)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validate one controlled PNG/JPEG/GIF below sendRoot and copy the exact bytes
|
|
197
|
+
* into a private temporary snapshot for one platform call. The caller owns
|
|
198
|
+
* cleanup and must never expose the snapshot path to model output.
|
|
199
|
+
*/
|
|
200
|
+
export async function snapshotControlledImage({ sendRoot, relativePath, tempRoot } = {}) {
|
|
201
|
+
const root = resolveSendRoot(sendRoot)
|
|
202
|
+
const source = resolveRootSource(relativePath, root, sendRoot)
|
|
203
|
+
const { data, sha256 } = await readAndHash(source, 5 * MIB)
|
|
204
|
+
let mediaType
|
|
205
|
+
try {
|
|
206
|
+
mediaType = sniffKind('image', data, path.basename(source))
|
|
207
|
+
} catch {
|
|
208
|
+
mediaType = sniffKind('gif', data, path.basename(source))
|
|
209
|
+
}
|
|
210
|
+
if (tempRoot !== undefined && (typeof tempRoot !== 'string' || !path.isAbsolute(tempRoot))) {
|
|
211
|
+
throw mediaError('QQ_MEDIA_TEMP_INVALID')
|
|
212
|
+
}
|
|
213
|
+
let tempDir
|
|
214
|
+
try {
|
|
215
|
+
if (tempRoot !== undefined && !fs.statSync(tempRoot).isDirectory()) throw new Error('not a directory')
|
|
216
|
+
tempDir = await fs.promises.mkdtemp(path.join(tempRoot ?? os.tmpdir(), 'amadeus-qq-avatar-'))
|
|
217
|
+
await fs.promises.chmod(tempDir, 0o700)
|
|
218
|
+
const extension = mediaType === 'image/png' ? '.png' : mediaType === 'image/jpeg' ? '.jpg' : '.gif'
|
|
219
|
+
const snapshot = path.join(tempDir, `avatar${extension}`)
|
|
220
|
+
await fs.promises.writeFile(snapshot, data, { flag: 'wx', mode: 0o600 })
|
|
221
|
+
const written = await fs.promises.readFile(snapshot)
|
|
222
|
+
if (written.length !== data.length || createHash('sha256').update(written).digest('hex') !== sha256) {
|
|
223
|
+
throw mediaError('QQ_MEDIA_SNAPSHOT_INVALID')
|
|
224
|
+
}
|
|
225
|
+
let cleaned = false
|
|
226
|
+
return {
|
|
227
|
+
path: snapshot,
|
|
228
|
+
mediaType,
|
|
229
|
+
bytes: data.length,
|
|
230
|
+
sha256,
|
|
231
|
+
async cleanup() {
|
|
232
|
+
if (cleaned) return
|
|
233
|
+
cleaned = true
|
|
234
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (tempDir) await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
239
|
+
if (typeof error?.code === 'string' && error.code.startsWith('QQ_MEDIA_')) throw error
|
|
240
|
+
throw mediaError('QQ_MEDIA_SNAPSHOT_FAILED')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build one controlled outbound media plan. `source` must be either a relative
|
|
246
|
+
* path below the configured sendRoot, or an already-validated Host attachment
|
|
247
|
+
* reference owned by the caller. Never pass model-supplied paths, URLs or
|
|
248
|
+
* base64 into this helper. Message plans are ready for core.send(); upload
|
|
249
|
+
* plans require the core's delivery ledger with the returned fingerprint.
|
|
250
|
+
*/
|
|
251
|
+
export async function prepareOutboundMedia({
|
|
252
|
+
core,
|
|
253
|
+
sessionId,
|
|
254
|
+
kind,
|
|
255
|
+
source,
|
|
256
|
+
sendRoot,
|
|
257
|
+
attachments,
|
|
258
|
+
}) {
|
|
259
|
+
if (kind === 'voice' || kind === 'record') throw mediaError(TTS_UNAVAILABLE_CODE)
|
|
260
|
+
if (!Object.hasOwn(OUTBOUND_MEDIA_LIMITS, kind)) throw mediaError('QQ_MEDIA_UNSUPPORTED_KIND')
|
|
261
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) throw mediaError('QQ_MEDIA_SOURCE_INVALID')
|
|
262
|
+
|
|
263
|
+
const scene = assertSend(core, sessionId)
|
|
264
|
+
const maxBytes = OUTBOUND_MEDIA_LIMITS[kind]
|
|
265
|
+
let root
|
|
266
|
+
let file
|
|
267
|
+
if (source.type === 'send-root') {
|
|
268
|
+
if (Object.keys(source).sort().join(',') !== 'path,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
|
|
269
|
+
root = resolveSendRoot(sendRoot)
|
|
270
|
+
file = resolveRootSource(source.path, root, sendRoot)
|
|
271
|
+
} else if (source.type === 'host-attachment') {
|
|
272
|
+
if (Object.keys(source).sort().join(',') !== 'attachment,type') throw mediaError('QQ_MEDIA_SOURCE_INVALID')
|
|
273
|
+
file = resolveHostAttachment(source, kind, attachments)
|
|
274
|
+
statRegularFile(file)
|
|
275
|
+
} else {
|
|
276
|
+
throw mediaError('QQ_MEDIA_SOURCE_INVALID')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { data, sha256 } = await readAndHash(file, maxBytes)
|
|
280
|
+
const name = safeName(path.basename(file), kind)
|
|
281
|
+
const mediaType = sniffKind(kind, data, name)
|
|
282
|
+
|
|
283
|
+
// Re-check the bound Scene immediately before returning an executable plan.
|
|
284
|
+
assertSend(core, sessionId)
|
|
285
|
+
|
|
286
|
+
if (kind === 'file') {
|
|
287
|
+
const group = scene.kind === 'group'
|
|
288
|
+
return {
|
|
289
|
+
mode: 'upload',
|
|
290
|
+
action: group ? 'upload_group_file' : 'upload_private_file',
|
|
291
|
+
params: group
|
|
292
|
+
? { group_id: scene.peerId, file, name, upload_file: true }
|
|
293
|
+
: { user_id: scene.peerId, file, name, upload_file: true },
|
|
294
|
+
mediaType,
|
|
295
|
+
bytes: data.length,
|
|
296
|
+
fingerprint: sha256,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
mode: 'message',
|
|
301
|
+
segments: [{
|
|
302
|
+
type: kind === 'video' ? 'video' : kind === 'audio' ? 'record' : 'image',
|
|
303
|
+
data: { file },
|
|
304
|
+
}],
|
|
305
|
+
mediaType,
|
|
306
|
+
bytes: data.length,
|
|
307
|
+
fingerprint: sha256,
|
|
308
|
+
}
|
|
309
|
+
}
|