@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
@@ -1,628 +1,628 @@
1
- import { createHash, randomBytes } from 'node:crypto'
2
- import { pathToFileURL } from 'node:url'
3
- import { snapshotControlledImage } from './outbound-media.js'
4
-
5
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost'])
6
- const DEFAULT_TIMEOUT_MS = 8_000
7
- const DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024
8
- const DEFAULT_MAX_SCENES = 64
9
- const DEFAULT_MAX_POSTS_PER_SCENE = 200
10
- const DEFAULT_MAX_COMMENTS_PER_SCENE = 500
11
- const DEFAULT_MAX_CURSORS_PER_SCENE = 8
12
- const DEFAULT_OBSERVATION_TTL_MS = 30 * 60 * 1000
13
- const MAX_RAW_CURSOR = 4096
14
- const CURSOR_TOKEN_PATTERN = /^c_[a-f0-9]{32}$/
15
- const MAX_TEXT = 4_000
16
- const MAX_CONTENT = 5_000
17
- const MAX_PUBLISH_TEXT = 1_000
18
- const MAX_QZONE_IMAGES = 9
19
- const QZONE_AUDIENCES = Object.freeze({ self: 64, friends: 4, public: 1 })
20
- const MAX_TARGET_ID = 200
21
-
22
- const ACTIONS = Object.freeze({
23
- ownPosts: 'get_emotion_list',
24
- friendFeeds: 'get_friend_feeds',
25
- comments: 'get_comment_list',
26
- publishText: 'send_private_msg',
27
- deleteOwn: 'delete_msg',
28
- like: 'send_like',
29
- unlike: 'unlike',
30
- comment: 'send_comment',
31
- forward: 'forward_msg',
32
- })
33
-
34
- function qzoneError(code, options = {}) {
35
- const error = new Error(code, options)
36
- error.code = code
37
- return error
38
- }
39
-
40
- function normalizeBaseUrl(value) {
41
- if (typeof value !== 'string') throw qzoneError('QQ_QZONE_URL_INVALID')
42
- let url
43
- try { url = new URL(value) } catch { throw qzoneError('QQ_QZONE_URL_INVALID') }
44
- if (!['http:', 'https:'].includes(url.protocol) ||
45
- !LOOPBACK_HOSTS.has(url.hostname) ||
46
- url.username !== '' || url.password !== '' ||
47
- (url.pathname !== '' && url.pathname !== '/') ||
48
- url.search !== '' || url.hash !== '') {
49
- throw qzoneError('QQ_QZONE_URL_INVALID')
50
- }
51
- return url.origin
52
- }
53
-
54
- function safeText(value, limit = MAX_CONTENT) {
55
- if (typeof value !== 'string') return ''
56
- return value
57
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
58
- .replace(/https?:\/\/\S+/gi, '[url omitted]')
59
- .replace(/base64:\/\/\S+/gi, '[base64 omitted]')
60
- .trim()
61
- .slice(0, limit)
62
- }
63
-
64
- function projectId(value) {
65
- const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
66
- if (typeof id !== 'string' || id.length === 0 || id.length > MAX_TARGET_ID ||
67
- !/^[A-Za-z0-9_.:-]+$/.test(id)) return null
68
- return id
69
- }
70
-
71
- function textField(value, limit = MAX_CONTENT) {
72
- const text = safeText(value, limit)
73
- if (!text) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
74
- return text
75
- }
76
-
77
- function publishTextField(value, allowEmpty) {
78
- if (value === undefined || value === null) {
79
- if (allowEmpty) return ''
80
- throw qzoneError('QQ_QZONE_CONTENT_INVALID')
81
- }
82
- if (typeof value !== 'string') throw qzoneError('QQ_QZONE_CONTENT_INVALID')
83
- const text = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim()
84
- if (text.length > MAX_PUBLISH_TEXT) throw qzoneError('QQ_QZONE_CONTENT_TOO_LONG')
85
- if (!allowEmpty && text.length === 0) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
86
- return text
87
- }
88
-
89
- function idField(value) {
90
- const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
91
- if (typeof id !== 'string' || id.length === 0 || id.length > MAX_TARGET_ID ||
92
- !/^[A-Za-z0-9_.:-]+$/.test(id)) {
93
- throw qzoneError('QQ_QZONE_TARGET_INVALID')
94
- }
95
- return id
96
- }
97
-
98
- function numberField(value) {
99
- const number = Number(value)
100
- return Number.isFinite(number) && number >= 0 ? number : 0
101
- }
102
-
103
- function arrayFrom(data) {
104
- if (Array.isArray(data)) return data
105
- if (!data || typeof data !== 'object') return []
106
- for (const key of ['msglist', 'feeds', 'items', 'list', 'comments', 'commentlist', 'comment_list', 'data']) {
107
- if (Array.isArray(data[key])) return data[key]
108
- }
109
- return []
110
- }
111
-
112
- function projectComment(raw, tid) {
113
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
114
- const commentId = projectId(raw.commentid ?? raw.comment_id ?? raw.id)
115
- const authorUin = projectId(raw.uin ?? raw.user_id ?? raw.author?.uin)
116
- const text = raw.content ?? raw.text
117
- if (commentId === null || text === undefined) return null
118
- return {
119
- tid,
120
- commentId,
121
- authorUin,
122
- content: safeText(String(text)),
123
- createdAt: numberField(raw.create_time ?? raw.created_time ?? raw.createdAt) || null,
124
- }
125
- }
126
-
127
- function projectPost(raw, { own, selfId }) {
128
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
129
- const tid = projectId(raw.tid ?? raw.message_id ?? raw.id)
130
- if (tid === null) return null
131
- const authorUin = projectId(raw.uin ?? raw.user_id ?? raw.author?.uin ?? raw.author?.id)
132
- const text = raw.content ?? raw.text ?? raw.summary
133
- const comments = Array.isArray(raw.comments) || Array.isArray(raw.commentlist)
134
- ? (raw.comments ?? raw.commentlist).map(item => projectComment(item, tid)).filter(Boolean)
135
- : []
136
- const imageList = raw.pic ?? raw.images
137
- return {
138
- tid,
139
- authorUin,
140
- content: safeText(text === undefined ? '' : String(text)),
141
- createdAt: numberField(raw.created_time ?? raw.createdTime ?? raw.createTime) || null,
142
- likeCount: numberField(raw.likenum ?? raw.like_count),
143
- commentCount: numberField(raw.commentnum ?? raw.comment_count),
144
- forwardCount: numberField(raw.forwardnum ?? raw.forward_count),
145
- isSelf: own === true || (selfId !== undefined && String(authorUin) === selfId),
146
- hasImages: Array.isArray(imageList) && imageList.length > 0,
147
- comments,
148
- }
149
- }
150
-
151
- function projectWrite(raw) {
152
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'ok' }
153
- const result = { status: 'ok' }
154
- const tid = projectId(raw.tid ?? raw.message_id ?? raw.id)
155
- if (tid !== null) result.tid = tid
156
- return result
157
- }
158
-
159
- function validRawCursor(value) {
160
- if (value === undefined || value === null || value === '') return null
161
- if (typeof value !== 'string' || value.length > MAX_RAW_CURSOR ||
162
- /[\u0000-\u001f\u007f]/.test(value)) {
163
- throw qzoneError('QQ_QZONE_CURSOR_INVALID')
164
- }
165
- return value
166
- }
167
-
168
- async function readBoundedJson(response, maxBytes) {
169
- const declared = response.headers?.get?.('content-length')
170
- if (declared !== null && declared !== undefined) {
171
- const length = Number(declared)
172
- if (Number.isFinite(length) && length > maxBytes) throw qzoneError('QQ_QZONE_RESPONSE_TOO_LARGE')
173
- }
174
- if (!response.body || typeof response.body.getReader !== 'function') {
175
- throw qzoneError('QQ_QZONE_RESPONSE_INVALID')
176
- }
177
- const reader = response.body.getReader()
178
- const chunks = []
179
- let total = 0
180
- try {
181
- for (;;) {
182
- const { done, value } = await reader.read()
183
- if (done) break
184
- const chunk = Buffer.from(value)
185
- total += chunk.length
186
- if (total > maxBytes) {
187
- try { await reader.cancel() } catch { /* best effort */ }
188
- throw qzoneError('QQ_QZONE_RESPONSE_TOO_LARGE')
189
- }
190
- chunks.push(chunk)
191
- }
192
- } finally {
193
- try { reader.releaseLock() } catch { /* already closed */ }
194
- }
195
- try {
196
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
197
- } catch {
198
- throw qzoneError('QQ_QZONE_RESPONSE_INVALID')
199
- }
200
- }
201
-
202
- export function createQzoneProvider(options = {}) {
203
- const nativeTransport = options.nativeTransport
204
- if (nativeTransport !== undefined &&
205
- (!nativeTransport || typeof nativeTransport.action !== 'function')) {
206
- throw qzoneError('QQ_QZONE_TRANSPORT_INVALID')
207
- }
208
- const bridgeConfigured = options.baseUrl !== undefined || options.tokenEnv !== undefined
209
- if (!bridgeConfigured && !nativeTransport) throw qzoneError('QQ_QZONE_URL_INVALID')
210
- const baseUrl = bridgeConfigured ? normalizeBaseUrl(options.baseUrl) : null
211
- const tokenEnv = options.tokenEnv
212
- if (!nativeTransport || bridgeConfigured) {
213
- if (typeof tokenEnv !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(tokenEnv)) {
214
- throw qzoneError('QQ_QZONE_TOKEN_ENV_INVALID')
215
- }
216
- }
217
- const env = options.env ?? process.env
218
- const fetchImpl = options.fetch ?? globalThis.fetch
219
- const sendRoot = options.sendRoot
220
- const tempRoot = options.tempRoot
221
- if (sendRoot !== undefined && (typeof sendRoot !== 'string' || !sendRoot)) {
222
- throw qzoneError('QQ_QZONE_CONFIG_INVALID')
223
- }
224
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
225
- const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
226
- const maxScenes = options.maxObservedScenes ?? DEFAULT_MAX_SCENES
227
- const maxPostsPerScene = options.maxObservedPosts ?? DEFAULT_MAX_POSTS_PER_SCENE
228
- const maxCommentsPerScene = options.maxObservedComments ?? DEFAULT_MAX_COMMENTS_PER_SCENE
229
- const maxCursorsPerScene = options.maxCursorTokens ?? DEFAULT_MAX_CURSORS_PER_SCENE
230
- const observationTtlMs = options.observationTtlMs ?? DEFAULT_OBSERVATION_TTL_MS
231
- const now = options.now ?? (() => Date.now())
232
- if (![timeoutMs, maxResponseBytes, maxScenes, maxPostsPerScene, maxCommentsPerScene, maxCursorsPerScene, observationTtlMs]
233
- .every(value => Number.isSafeInteger(value) && value > 0)) {
234
- throw qzoneError('QQ_QZONE_CONFIG_INVALID')
235
- }
236
- const selfId = options.selfId === undefined ? undefined : idField(options.selfId)
237
- const observations = new Map()
238
- let closed = false
239
-
240
- function token() {
241
- if (baseUrl === null) throw qzoneError('QQ_QZONE_BRIDGE_UNAVAILABLE')
242
- const value = env[tokenEnv]
243
- if (typeof value !== 'string' || value.trim().length === 0) throw qzoneError('QQ_QZONE_TOKEN_MISSING')
244
- return value.trim()
245
- }
246
-
247
- function requireOpen() {
248
- if (closed) throw qzoneError('QQ_QZONE_CLOSED')
249
- }
250
-
251
- function pruneScenes() {
252
- const timestamp = now()
253
- for (const [key, state] of observations) {
254
- if (timestamp - state.lastUsed > observationTtlMs) observations.delete(key)
255
- }
256
- while (observations.size >= maxScenes) {
257
- let oldestKey
258
- let oldest = Infinity
259
- for (const [key, state] of observations) {
260
- if (state.lastUsed < oldest) { oldest = state.lastUsed; oldestKey = key }
261
- }
262
- if (oldestKey === undefined) break
263
- observations.delete(oldestKey)
264
- }
265
- }
266
-
267
- function sceneState(sceneKey) {
268
- requireOpen()
269
- if (typeof sceneKey !== 'string' || sceneKey.length === 0 || sceneKey.length > 200) {
270
- throw qzoneError('QQ_QZONE_SCENE_INVALID')
271
- }
272
- pruneScenes()
273
- let state = observations.get(sceneKey)
274
- if (!state) {
275
- state = {
276
- posts: new Map(),
277
- comments: new Map(),
278
- cursors: new Map(),
279
- lastUsed: now(),
280
- }
281
- observations.set(sceneKey, state)
282
- } else {
283
- state.lastUsed = now()
284
- }
285
- return state
286
- }
287
-
288
- function rememberPost(state, post) {
289
- if (!state.posts.has(post.tid) && state.posts.size >= maxPostsPerScene) {
290
- const oldest = state.posts.keys().next().value
291
- if (oldest !== undefined) state.posts.delete(oldest)
292
- }
293
- state.posts.set(post.tid, post)
294
- }
295
-
296
- function rememberComment(state, key, comment) {
297
- if (!state.comments.has(key) && state.comments.size >= maxCommentsPerScene) {
298
- const oldest = state.comments.keys().next().value
299
- if (oldest !== undefined) state.comments.delete(oldest)
300
- }
301
- state.comments.set(key, comment)
302
- }
303
-
304
- function issueCursor(state, raw) {
305
- if (raw === null) return null
306
- const token = `c_${randomBytes(16).toString('hex')}`
307
- if (!state.cursors.has(token) && state.cursors.size >= maxCursorsPerScene) {
308
- const oldest = state.cursors.keys().next().value
309
- if (oldest !== undefined) state.cursors.delete(oldest)
310
- }
311
- state.cursors.set(token, raw)
312
- return token
313
- }
314
-
315
- function resolveCursor(state, token) {
316
- if (token === undefined || token === null || token === '') return undefined
317
- if (typeof token !== 'string' || !CURSOR_TOKEN_PATTERN.test(token)) {
318
- throw qzoneError('QQ_QZONE_CURSOR_INVALID')
319
- }
320
- const raw = state.cursors.get(token)
321
- if (raw === undefined) throw qzoneError('QQ_QZONE_CURSOR_INVALID')
322
- return raw
323
- }
324
-
325
- async function authorize(context, action, target, write) {
326
- if (!context || typeof context !== 'object' || typeof context.authorize !== 'function') {
327
- throw qzoneError('QQ_QZONE_UNAUTHORIZED')
328
- }
329
- sceneState(context.sceneKey)
330
- let allowed = false
331
- try {
332
- allowed = await context.authorize({
333
- action,
334
- write,
335
- sceneKey: context.sceneKey,
336
- sessionId: context.sessionId,
337
- target,
338
- }) === true
339
- } catch {
340
- allowed = false
341
- }
342
- if (!allowed) throw qzoneError('QQ_QZONE_UNAUTHORIZED')
343
- }
344
-
345
- async function call(action, params, write) {
346
- requireOpen()
347
- if (baseUrl === null) throw qzoneError('QQ_QZONE_BRIDGE_UNAVAILABLE')
348
- if (!Object.values(ACTIONS).includes(action)) throw qzoneError('QQ_QZONE_ACTION_INVALID')
349
- const auth = token()
350
- let response
351
- try {
352
- response = await fetchImpl(`${baseUrl}/${action}`, {
353
- method: 'POST',
354
- redirect: 'error',
355
- headers: { 'content-type': 'application/json', authorization: `Bearer ${auth}` },
356
- body: JSON.stringify(params),
357
- signal: AbortSignal.timeout(timeoutMs),
358
- })
359
- } catch (error) {
360
- if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
361
- throw qzoneError(write ? 'QQ_QZONE_UNKNOWN' : 'QQ_QZONE_TIMEOUT')
362
- }
363
- throw qzoneError(write ? 'QQ_QZONE_UNKNOWN' : 'QQ_QZONE_UNAVAILABLE')
364
- }
365
- const result = await readBoundedJson(response, maxResponseBytes)
366
- if (!response.ok || !result || typeof result !== 'object' ||
367
- result.status !== 'ok' || result.retcode !== 0) {
368
- throw qzoneError('QQ_QZONE_REJECTED')
369
- }
370
- return result.data
371
- }
372
-
373
- function observePosts(sceneKey, posts, own) {
374
- const state = sceneState(sceneKey)
375
- for (const post of posts) {
376
- if (!post) continue
377
- rememberPost(state, { ...post, isSelf: post.isSelf || own })
378
- for (const comment of post.comments) {
379
- rememberComment(state, `${post.tid}:${comment.commentId}`, comment)
380
- }
381
- }
382
- }
383
-
384
- async function readPosts(context, action, params, own) {
385
- await authorize(context, action, null, false)
386
- const state = sceneState(context.sceneKey)
387
- const rawCursor = resolveCursor(state, params.cursor)
388
- const bridgeParams = { ...params }
389
- delete bridgeParams.cursor
390
- if (rawCursor !== undefined) bridgeParams.cursor = rawCursor
391
- const data = await call(action, bridgeParams, false)
392
- await authorize(context, action, null, false)
393
- const posts = arrayFrom(data).map(raw => projectPost(raw, { own, selfId })).filter(Boolean)
394
- observePosts(context.sceneKey, posts, own)
395
- const nextRaw = validRawCursor(data?.next_cursor ?? data?.nextCursor ?? data?.cursor)
396
- const nextCursor = issueCursor(sceneState(context.sceneKey), nextRaw)
397
- return {
398
- posts,
399
- nextCursor,
400
- hasMore: nextCursor !== null,
401
- }
402
- }
403
-
404
- function targetPost(context, tid) {
405
- const id = idField(tid)
406
- const post = sceneState(context.sceneKey).posts.get(id)
407
- if (!post) throw qzoneError('QQ_QZONE_TARGET_UNOBSERVED')
408
- return post
409
- }
410
-
411
- function targetComment(context, tid, commentId) {
412
- const post = targetPost(context, tid)
413
- const id = idField(commentId)
414
- const comment = sceneState(context.sceneKey).comments.get(`${post.tid}:${id}`)
415
- if (!comment) throw qzoneError('QQ_QZONE_TARGET_UNOBSERVED')
416
- return { post, comment }
417
- }
418
-
419
- async function write(context, action, target, params, actionId) {
420
- await authorize(context, action, target, true)
421
- if (!context.ledger || typeof context.ledger.run !== 'function') {
422
- throw qzoneError('QQ_QZONE_LEDGER_REQUIRED')
423
- }
424
- if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) {
425
- throw qzoneError('QQ_QZONE_ACTION_ID_INVALID')
426
- }
427
- const fingerprint = createHash('sha256')
428
- .update(JSON.stringify({ action, sceneKey: context.sceneKey, target, params }))
429
- .digest('hex')
430
- return context.ledger.run({
431
- actionId,
432
- fingerprint,
433
- action,
434
- sceneKey: context.sceneKey,
435
- target,
436
- execute: async () => {
437
- const data = await call(action, params, true)
438
- await authorize(context, action, target, true)
439
- return projectWrite(data)
440
- },
441
- })
442
- }
443
-
444
- async function nativeCall(params) {
445
- requireOpen()
446
- if (!nativeTransport) throw qzoneError('QQ_QZONE_NATIVE_UNAVAILABLE')
447
- let data
448
- try {
449
- data = await nativeTransport.action('send_qzone_msg', params, timeoutMs, maxResponseBytes)
450
- } catch (error) {
451
- if (error?.code === 'ONEBOT_REJECTED') throw qzoneError('QQ_QZONE_REJECTED')
452
- throw qzoneError('QQ_QZONE_UNKNOWN')
453
- }
454
- return data
455
- }
456
-
457
- async function publishNative(context, { text, images, actionId, audience = 'self' }) {
458
- await authorize(context, 'send_qzone_msg', null, true)
459
- if (!context.ledger || typeof context.ledger.run !== 'function') {
460
- throw qzoneError('QQ_QZONE_LEDGER_REQUIRED')
461
- }
462
- if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) {
463
- throw qzoneError('QQ_QZONE_ACTION_ID_INVALID')
464
- }
465
- if (images !== undefined && !Array.isArray(images)) throw qzoneError('QQ_QZONE_IMAGE_INVALID')
466
- const paths = images ?? []
467
- if (paths.length > MAX_QZONE_IMAGES) throw qzoneError('QQ_QZONE_IMAGE_LIMIT')
468
- for (const relative of paths) {
469
- if (typeof relative !== 'string' || relative.length === 0) throw qzoneError('QQ_QZONE_IMAGE_INVALID')
470
- }
471
- const content = publishTextField(text, paths.length > 0)
472
- if (content.length === 0 && paths.length === 0) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
473
- if (!Object.hasOwn(QZONE_AUDIENCES, audience)) throw qzoneError('QQ_QZONE_AUDIENCE_INVALID')
474
- if (paths.length > 0 && (typeof sendRoot !== 'string' || !sendRoot)) {
475
- throw qzoneError('QQ_QZONE_SEND_ROOT_INVALID')
476
- }
477
-
478
- const snapshots = []
479
- try {
480
- for (const relative of paths) {
481
- try {
482
- snapshots.push(await snapshotControlledImage({ sendRoot, relativePath: relative, tempRoot }))
483
- } catch {
484
- throw qzoneError('QQ_QZONE_IMAGE_INVALID')
485
- }
486
- }
487
- const fileUrls = snapshots.map(snapshot => pathToFileURL(snapshot.path).href)
488
- const params = { content, ugc_right: QZONE_AUDIENCES[audience] }
489
- if (fileUrls.length > 0) params.images = fileUrls
490
- const fingerprint = createHash('sha256')
491
- .update(JSON.stringify({
492
- action: 'send_qzone_msg',
493
- sceneKey: context.sceneKey,
494
- content,
495
- audience,
496
- images: snapshots.map(snapshot => snapshot.sha256),
497
- }))
498
- .digest('hex')
499
- return await context.ledger.run({
500
- actionId,
501
- fingerprint,
502
- action: 'send_qzone_msg',
503
- sceneKey: context.sceneKey,
504
- target: null,
505
- execute: async () => {
506
- const data = await nativeCall(params)
507
- try {
508
- await authorize(context, 'send_qzone_msg', null, true)
509
- } catch {
510
- throw qzoneError('QQ_QZONE_UNKNOWN')
511
- }
512
- const tid = projectId(data?.tid)
513
- if (tid === null) throw qzoneError('QQ_QZONE_UNKNOWN')
514
- return { status: 'ok', tid, imageCount: fileUrls.length }
515
- },
516
- })
517
- } finally {
518
- for (const snapshot of snapshots) await snapshot.cleanup()
519
- }
520
- }
521
-
522
- return Object.freeze({
523
- async listOwnPosts(context, { cursor, limit = 20 } = {}) {
524
- return readPosts(context, ACTIONS.ownPosts, {
525
- ...(typeof cursor === 'string' && cursor ? { cursor } : {}),
526
- num: Math.min(Math.max(Number(limit) || 20, 1), 100),
527
- max_pages: 15,
528
- include_image_data: false,
529
- }, true)
530
- },
531
-
532
- async listFriendFeeds(context, { cursor, limit = 20 } = {}) {
533
- return readPosts(context, ACTIONS.friendFeeds, {
534
- ...(typeof cursor === 'string' && cursor ? { cursor } : {}),
535
- num: Math.min(Math.max(Number(limit) || 20, 1), 100),
536
- fast_mode: 1,
537
- include_image_data: false,
538
- }, false)
539
- },
540
-
541
- async listComments(context, { tid, limit = 20 } = {}) {
542
- const post = targetPost(context, tid)
543
- await authorize(context, ACTIONS.comments, { tid: post.tid }, false)
544
- const data = await call(ACTIONS.comments, {
545
- tid: post.tid,
546
- ...(post.authorUin ? { user_id: post.authorUin } : {}),
547
- num: Math.min(Math.max(Number(limit) || 20, 1), 100),
548
- pos: 0,
549
- }, false)
550
- await authorize(context, ACTIONS.comments, { tid: post.tid }, false)
551
- const comments = arrayFrom(data).map(raw => projectComment(raw, post.tid)).filter(Boolean)
552
- const state = sceneState(context.sceneKey)
553
- for (const comment of comments) rememberComment(state, `${post.tid}:${comment.commentId}`, comment)
554
- return { comments }
555
- },
556
-
557
- async publishText(context, { text, actionId, audience }) {
558
- if (nativeTransport) return publishNative(context, { text, images: [], actionId, audience })
559
- return write(context, ACTIONS.publishText, null, {
560
- message: [{ type: 'text', data: { text: textField(text) } }],
561
- }, actionId)
562
- },
563
-
564
- async publishMedia(context, { text, images, actionId, audience } = {}) {
565
- if (!nativeTransport) throw qzoneError('QQ_QZONE_MEDIA_UNSUPPORTED')
566
- return publishNative(context, { text, images, actionId, audience })
567
- },
568
-
569
- async likePost(context, { tid, actionId }) {
570
- const post = targetPost(context, tid)
571
- return write(context, ACTIONS.like, { tid: post.tid }, {
572
- tid: post.tid,
573
- ...(post.authorUin ? { user_id: post.authorUin } : {}),
574
- }, actionId)
575
- },
576
-
577
- async unlikePost(context, { tid, actionId }) {
578
- const post = targetPost(context, tid)
579
- return write(context, ACTIONS.unlike, { tid: post.tid }, {
580
- tid: post.tid,
581
- ...(post.authorUin ? { user_id: post.authorUin } : {}),
582
- }, actionId)
583
- },
584
-
585
- async commentPost(context, { tid, text, actionId }) {
586
- const post = targetPost(context, tid)
587
- return write(context, ACTIONS.comment, { tid: post.tid }, {
588
- target_uin: post.authorUin,
589
- target_tid: post.tid,
590
- content: textField(text),
591
- }, actionId)
592
- },
593
-
594
- async replyComment(context, { tid, commentId, text, actionId }) {
595
- const { post, comment } = targetComment(context, tid, commentId)
596
- return write(context, ACTIONS.comment, {
597
- tid: post.tid,
598
- commentId: comment.commentId,
599
- }, {
600
- target_uin: post.authorUin,
601
- target_tid: post.tid,
602
- content: textField(text),
603
- reply_comment_id: comment.commentId,
604
- ...(comment.authorUin ? { reply_uin: comment.authorUin } : {}),
605
- }, actionId)
606
- },
607
-
608
- async forwardPost(context, { tid, text = '', actionId }) {
609
- const post = targetPost(context, tid)
610
- return write(context, ACTIONS.forward, { tid: post.tid }, {
611
- user_id: post.authorUin,
612
- tid: post.tid,
613
- content: textField(text, MAX_TEXT),
614
- }, actionId)
615
- },
616
-
617
- async deleteOwnPost(context, { tid, actionId }) {
618
- const post = targetPost(context, tid)
619
- if (!post.isSelf) throw qzoneError('QQ_QZONE_TARGET_NOT_OWNED')
620
- return write(context, ACTIONS.deleteOwn, { tid: post.tid }, { message_id: post.tid }, actionId)
621
- },
622
-
623
- close() {
624
- closed = true
625
- observations.clear()
626
- },
627
- })
628
- }
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { snapshotControlledImage } from './outbound-media.js'
4
+
5
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost'])
6
+ const DEFAULT_TIMEOUT_MS = 8_000
7
+ const DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024
8
+ const DEFAULT_MAX_SCENES = 64
9
+ const DEFAULT_MAX_POSTS_PER_SCENE = 200
10
+ const DEFAULT_MAX_COMMENTS_PER_SCENE = 500
11
+ const DEFAULT_MAX_CURSORS_PER_SCENE = 8
12
+ const DEFAULT_OBSERVATION_TTL_MS = 30 * 60 * 1000
13
+ const MAX_RAW_CURSOR = 4096
14
+ const CURSOR_TOKEN_PATTERN = /^c_[a-f0-9]{32}$/
15
+ const MAX_TEXT = 4_000
16
+ const MAX_CONTENT = 5_000
17
+ const MAX_PUBLISH_TEXT = 1_000
18
+ const MAX_QZONE_IMAGES = 9
19
+ const QZONE_AUDIENCES = Object.freeze({ self: 64, friends: 4, public: 1 })
20
+ const MAX_TARGET_ID = 200
21
+
22
+ const ACTIONS = Object.freeze({
23
+ ownPosts: 'get_emotion_list',
24
+ friendFeeds: 'get_friend_feeds',
25
+ comments: 'get_comment_list',
26
+ publishText: 'send_private_msg',
27
+ deleteOwn: 'delete_msg',
28
+ like: 'send_like',
29
+ unlike: 'unlike',
30
+ comment: 'send_comment',
31
+ forward: 'forward_msg',
32
+ })
33
+
34
+ function qzoneError(code, options = {}) {
35
+ const error = new Error(code, options)
36
+ error.code = code
37
+ return error
38
+ }
39
+
40
+ function normalizeBaseUrl(value) {
41
+ if (typeof value !== 'string') throw qzoneError('QQ_QZONE_URL_INVALID')
42
+ let url
43
+ try { url = new URL(value) } catch { throw qzoneError('QQ_QZONE_URL_INVALID') }
44
+ if (!['http:', 'https:'].includes(url.protocol) ||
45
+ !LOOPBACK_HOSTS.has(url.hostname) ||
46
+ url.username !== '' || url.password !== '' ||
47
+ (url.pathname !== '' && url.pathname !== '/') ||
48
+ url.search !== '' || url.hash !== '') {
49
+ throw qzoneError('QQ_QZONE_URL_INVALID')
50
+ }
51
+ return url.origin
52
+ }
53
+
54
+ function safeText(value, limit = MAX_CONTENT) {
55
+ if (typeof value !== 'string') return ''
56
+ return value
57
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
58
+ .replace(/https?:\/\/\S+/gi, '[url omitted]')
59
+ .replace(/base64:\/\/\S+/gi, '[base64 omitted]')
60
+ .trim()
61
+ .slice(0, limit)
62
+ }
63
+
64
+ function projectId(value) {
65
+ const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
66
+ if (typeof id !== 'string' || id.length === 0 || id.length > MAX_TARGET_ID ||
67
+ !/^[A-Za-z0-9_.:-]+$/.test(id)) return null
68
+ return id
69
+ }
70
+
71
+ function textField(value, limit = MAX_CONTENT) {
72
+ const text = safeText(value, limit)
73
+ if (!text) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
74
+ return text
75
+ }
76
+
77
+ function publishTextField(value, allowEmpty) {
78
+ if (value === undefined || value === null) {
79
+ if (allowEmpty) return ''
80
+ throw qzoneError('QQ_QZONE_CONTENT_INVALID')
81
+ }
82
+ if (typeof value !== 'string') throw qzoneError('QQ_QZONE_CONTENT_INVALID')
83
+ const text = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim()
84
+ if (text.length > MAX_PUBLISH_TEXT) throw qzoneError('QQ_QZONE_CONTENT_TOO_LONG')
85
+ if (!allowEmpty && text.length === 0) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
86
+ return text
87
+ }
88
+
89
+ function idField(value) {
90
+ const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
91
+ if (typeof id !== 'string' || id.length === 0 || id.length > MAX_TARGET_ID ||
92
+ !/^[A-Za-z0-9_.:-]+$/.test(id)) {
93
+ throw qzoneError('QQ_QZONE_TARGET_INVALID')
94
+ }
95
+ return id
96
+ }
97
+
98
+ function numberField(value) {
99
+ const number = Number(value)
100
+ return Number.isFinite(number) && number >= 0 ? number : 0
101
+ }
102
+
103
+ function arrayFrom(data) {
104
+ if (Array.isArray(data)) return data
105
+ if (!data || typeof data !== 'object') return []
106
+ for (const key of ['msglist', 'feeds', 'items', 'list', 'comments', 'commentlist', 'comment_list', 'data']) {
107
+ if (Array.isArray(data[key])) return data[key]
108
+ }
109
+ return []
110
+ }
111
+
112
+ function projectComment(raw, tid) {
113
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
114
+ const commentId = projectId(raw.commentid ?? raw.comment_id ?? raw.id)
115
+ const authorUin = projectId(raw.uin ?? raw.user_id ?? raw.author?.uin)
116
+ const text = raw.content ?? raw.text
117
+ if (commentId === null || text === undefined) return null
118
+ return {
119
+ tid,
120
+ commentId,
121
+ authorUin,
122
+ content: safeText(String(text)),
123
+ createdAt: numberField(raw.create_time ?? raw.created_time ?? raw.createdAt) || null,
124
+ }
125
+ }
126
+
127
+ function projectPost(raw, { own, selfId }) {
128
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
129
+ const tid = projectId(raw.tid ?? raw.message_id ?? raw.id)
130
+ if (tid === null) return null
131
+ const authorUin = projectId(raw.uin ?? raw.user_id ?? raw.author?.uin ?? raw.author?.id)
132
+ const text = raw.content ?? raw.text ?? raw.summary
133
+ const comments = Array.isArray(raw.comments) || Array.isArray(raw.commentlist)
134
+ ? (raw.comments ?? raw.commentlist).map(item => projectComment(item, tid)).filter(Boolean)
135
+ : []
136
+ const imageList = raw.pic ?? raw.images
137
+ return {
138
+ tid,
139
+ authorUin,
140
+ content: safeText(text === undefined ? '' : String(text)),
141
+ createdAt: numberField(raw.created_time ?? raw.createdTime ?? raw.createTime) || null,
142
+ likeCount: numberField(raw.likenum ?? raw.like_count),
143
+ commentCount: numberField(raw.commentnum ?? raw.comment_count),
144
+ forwardCount: numberField(raw.forwardnum ?? raw.forward_count),
145
+ isSelf: own === true || (selfId !== undefined && String(authorUin) === selfId),
146
+ hasImages: Array.isArray(imageList) && imageList.length > 0,
147
+ comments,
148
+ }
149
+ }
150
+
151
+ function projectWrite(raw) {
152
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { status: 'ok' }
153
+ const result = { status: 'ok' }
154
+ const tid = projectId(raw.tid ?? raw.message_id ?? raw.id)
155
+ if (tid !== null) result.tid = tid
156
+ return result
157
+ }
158
+
159
+ function validRawCursor(value) {
160
+ if (value === undefined || value === null || value === '') return null
161
+ if (typeof value !== 'string' || value.length > MAX_RAW_CURSOR ||
162
+ /[\u0000-\u001f\u007f]/.test(value)) {
163
+ throw qzoneError('QQ_QZONE_CURSOR_INVALID')
164
+ }
165
+ return value
166
+ }
167
+
168
+ async function readBoundedJson(response, maxBytes) {
169
+ const declared = response.headers?.get?.('content-length')
170
+ if (declared !== null && declared !== undefined) {
171
+ const length = Number(declared)
172
+ if (Number.isFinite(length) && length > maxBytes) throw qzoneError('QQ_QZONE_RESPONSE_TOO_LARGE')
173
+ }
174
+ if (!response.body || typeof response.body.getReader !== 'function') {
175
+ throw qzoneError('QQ_QZONE_RESPONSE_INVALID')
176
+ }
177
+ const reader = response.body.getReader()
178
+ const chunks = []
179
+ let total = 0
180
+ try {
181
+ for (;;) {
182
+ const { done, value } = await reader.read()
183
+ if (done) break
184
+ const chunk = Buffer.from(value)
185
+ total += chunk.length
186
+ if (total > maxBytes) {
187
+ try { await reader.cancel() } catch { /* best effort */ }
188
+ throw qzoneError('QQ_QZONE_RESPONSE_TOO_LARGE')
189
+ }
190
+ chunks.push(chunk)
191
+ }
192
+ } finally {
193
+ try { reader.releaseLock() } catch { /* already closed */ }
194
+ }
195
+ try {
196
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
197
+ } catch {
198
+ throw qzoneError('QQ_QZONE_RESPONSE_INVALID')
199
+ }
200
+ }
201
+
202
+ export function createQzoneProvider(options = {}) {
203
+ const nativeTransport = options.nativeTransport
204
+ if (nativeTransport !== undefined &&
205
+ (!nativeTransport || typeof nativeTransport.action !== 'function')) {
206
+ throw qzoneError('QQ_QZONE_TRANSPORT_INVALID')
207
+ }
208
+ const bridgeConfigured = options.baseUrl !== undefined || options.tokenEnv !== undefined
209
+ if (!bridgeConfigured && !nativeTransport) throw qzoneError('QQ_QZONE_URL_INVALID')
210
+ const baseUrl = bridgeConfigured ? normalizeBaseUrl(options.baseUrl) : null
211
+ const tokenEnv = options.tokenEnv
212
+ if (!nativeTransport || bridgeConfigured) {
213
+ if (typeof tokenEnv !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(tokenEnv)) {
214
+ throw qzoneError('QQ_QZONE_TOKEN_ENV_INVALID')
215
+ }
216
+ }
217
+ const env = options.env ?? process.env
218
+ const fetchImpl = options.fetch ?? globalThis.fetch
219
+ const sendRoot = options.sendRoot
220
+ const tempRoot = options.tempRoot
221
+ if (sendRoot !== undefined && (typeof sendRoot !== 'string' || !sendRoot)) {
222
+ throw qzoneError('QQ_QZONE_CONFIG_INVALID')
223
+ }
224
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
225
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
226
+ const maxScenes = options.maxObservedScenes ?? DEFAULT_MAX_SCENES
227
+ const maxPostsPerScene = options.maxObservedPosts ?? DEFAULT_MAX_POSTS_PER_SCENE
228
+ const maxCommentsPerScene = options.maxObservedComments ?? DEFAULT_MAX_COMMENTS_PER_SCENE
229
+ const maxCursorsPerScene = options.maxCursorTokens ?? DEFAULT_MAX_CURSORS_PER_SCENE
230
+ const observationTtlMs = options.observationTtlMs ?? DEFAULT_OBSERVATION_TTL_MS
231
+ const now = options.now ?? (() => Date.now())
232
+ if (![timeoutMs, maxResponseBytes, maxScenes, maxPostsPerScene, maxCommentsPerScene, maxCursorsPerScene, observationTtlMs]
233
+ .every(value => Number.isSafeInteger(value) && value > 0)) {
234
+ throw qzoneError('QQ_QZONE_CONFIG_INVALID')
235
+ }
236
+ const selfId = options.selfId === undefined ? undefined : idField(options.selfId)
237
+ const observations = new Map()
238
+ let closed = false
239
+
240
+ function token() {
241
+ if (baseUrl === null) throw qzoneError('QQ_QZONE_BRIDGE_UNAVAILABLE')
242
+ const value = env[tokenEnv]
243
+ if (typeof value !== 'string' || value.trim().length === 0) throw qzoneError('QQ_QZONE_TOKEN_MISSING')
244
+ return value.trim()
245
+ }
246
+
247
+ function requireOpen() {
248
+ if (closed) throw qzoneError('QQ_QZONE_CLOSED')
249
+ }
250
+
251
+ function pruneScenes() {
252
+ const timestamp = now()
253
+ for (const [key, state] of observations) {
254
+ if (timestamp - state.lastUsed > observationTtlMs) observations.delete(key)
255
+ }
256
+ while (observations.size >= maxScenes) {
257
+ let oldestKey
258
+ let oldest = Infinity
259
+ for (const [key, state] of observations) {
260
+ if (state.lastUsed < oldest) { oldest = state.lastUsed; oldestKey = key }
261
+ }
262
+ if (oldestKey === undefined) break
263
+ observations.delete(oldestKey)
264
+ }
265
+ }
266
+
267
+ function sceneState(sceneKey) {
268
+ requireOpen()
269
+ if (typeof sceneKey !== 'string' || sceneKey.length === 0 || sceneKey.length > 200) {
270
+ throw qzoneError('QQ_QZONE_SCENE_INVALID')
271
+ }
272
+ pruneScenes()
273
+ let state = observations.get(sceneKey)
274
+ if (!state) {
275
+ state = {
276
+ posts: new Map(),
277
+ comments: new Map(),
278
+ cursors: new Map(),
279
+ lastUsed: now(),
280
+ }
281
+ observations.set(sceneKey, state)
282
+ } else {
283
+ state.lastUsed = now()
284
+ }
285
+ return state
286
+ }
287
+
288
+ function rememberPost(state, post) {
289
+ if (!state.posts.has(post.tid) && state.posts.size >= maxPostsPerScene) {
290
+ const oldest = state.posts.keys().next().value
291
+ if (oldest !== undefined) state.posts.delete(oldest)
292
+ }
293
+ state.posts.set(post.tid, post)
294
+ }
295
+
296
+ function rememberComment(state, key, comment) {
297
+ if (!state.comments.has(key) && state.comments.size >= maxCommentsPerScene) {
298
+ const oldest = state.comments.keys().next().value
299
+ if (oldest !== undefined) state.comments.delete(oldest)
300
+ }
301
+ state.comments.set(key, comment)
302
+ }
303
+
304
+ function issueCursor(state, raw) {
305
+ if (raw === null) return null
306
+ const token = `c_${randomBytes(16).toString('hex')}`
307
+ if (!state.cursors.has(token) && state.cursors.size >= maxCursorsPerScene) {
308
+ const oldest = state.cursors.keys().next().value
309
+ if (oldest !== undefined) state.cursors.delete(oldest)
310
+ }
311
+ state.cursors.set(token, raw)
312
+ return token
313
+ }
314
+
315
+ function resolveCursor(state, token) {
316
+ if (token === undefined || token === null || token === '') return undefined
317
+ if (typeof token !== 'string' || !CURSOR_TOKEN_PATTERN.test(token)) {
318
+ throw qzoneError('QQ_QZONE_CURSOR_INVALID')
319
+ }
320
+ const raw = state.cursors.get(token)
321
+ if (raw === undefined) throw qzoneError('QQ_QZONE_CURSOR_INVALID')
322
+ return raw
323
+ }
324
+
325
+ async function authorize(context, action, target, write) {
326
+ if (!context || typeof context !== 'object' || typeof context.authorize !== 'function') {
327
+ throw qzoneError('QQ_QZONE_UNAUTHORIZED')
328
+ }
329
+ sceneState(context.sceneKey)
330
+ let allowed = false
331
+ try {
332
+ allowed = await context.authorize({
333
+ action,
334
+ write,
335
+ sceneKey: context.sceneKey,
336
+ sessionId: context.sessionId,
337
+ target,
338
+ }) === true
339
+ } catch {
340
+ allowed = false
341
+ }
342
+ if (!allowed) throw qzoneError('QQ_QZONE_UNAUTHORIZED')
343
+ }
344
+
345
+ async function call(action, params, write) {
346
+ requireOpen()
347
+ if (baseUrl === null) throw qzoneError('QQ_QZONE_BRIDGE_UNAVAILABLE')
348
+ if (!Object.values(ACTIONS).includes(action)) throw qzoneError('QQ_QZONE_ACTION_INVALID')
349
+ const auth = token()
350
+ let response
351
+ try {
352
+ response = await fetchImpl(`${baseUrl}/${action}`, {
353
+ method: 'POST',
354
+ redirect: 'error',
355
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${auth}` },
356
+ body: JSON.stringify(params),
357
+ signal: AbortSignal.timeout(timeoutMs),
358
+ })
359
+ } catch (error) {
360
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
361
+ throw qzoneError(write ? 'QQ_QZONE_UNKNOWN' : 'QQ_QZONE_TIMEOUT')
362
+ }
363
+ throw qzoneError(write ? 'QQ_QZONE_UNKNOWN' : 'QQ_QZONE_UNAVAILABLE')
364
+ }
365
+ const result = await readBoundedJson(response, maxResponseBytes)
366
+ if (!response.ok || !result || typeof result !== 'object' ||
367
+ result.status !== 'ok' || result.retcode !== 0) {
368
+ throw qzoneError('QQ_QZONE_REJECTED')
369
+ }
370
+ return result.data
371
+ }
372
+
373
+ function observePosts(sceneKey, posts, own) {
374
+ const state = sceneState(sceneKey)
375
+ for (const post of posts) {
376
+ if (!post) continue
377
+ rememberPost(state, { ...post, isSelf: post.isSelf || own })
378
+ for (const comment of post.comments) {
379
+ rememberComment(state, `${post.tid}:${comment.commentId}`, comment)
380
+ }
381
+ }
382
+ }
383
+
384
+ async function readPosts(context, action, params, own) {
385
+ await authorize(context, action, null, false)
386
+ const state = sceneState(context.sceneKey)
387
+ const rawCursor = resolveCursor(state, params.cursor)
388
+ const bridgeParams = { ...params }
389
+ delete bridgeParams.cursor
390
+ if (rawCursor !== undefined) bridgeParams.cursor = rawCursor
391
+ const data = await call(action, bridgeParams, false)
392
+ await authorize(context, action, null, false)
393
+ const posts = arrayFrom(data).map(raw => projectPost(raw, { own, selfId })).filter(Boolean)
394
+ observePosts(context.sceneKey, posts, own)
395
+ const nextRaw = validRawCursor(data?.next_cursor ?? data?.nextCursor ?? data?.cursor)
396
+ const nextCursor = issueCursor(sceneState(context.sceneKey), nextRaw)
397
+ return {
398
+ posts,
399
+ nextCursor,
400
+ hasMore: nextCursor !== null,
401
+ }
402
+ }
403
+
404
+ function targetPost(context, tid) {
405
+ const id = idField(tid)
406
+ const post = sceneState(context.sceneKey).posts.get(id)
407
+ if (!post) throw qzoneError('QQ_QZONE_TARGET_UNOBSERVED')
408
+ return post
409
+ }
410
+
411
+ function targetComment(context, tid, commentId) {
412
+ const post = targetPost(context, tid)
413
+ const id = idField(commentId)
414
+ const comment = sceneState(context.sceneKey).comments.get(`${post.tid}:${id}`)
415
+ if (!comment) throw qzoneError('QQ_QZONE_TARGET_UNOBSERVED')
416
+ return { post, comment }
417
+ }
418
+
419
+ async function write(context, action, target, params, actionId) {
420
+ await authorize(context, action, target, true)
421
+ if (!context.ledger || typeof context.ledger.run !== 'function') {
422
+ throw qzoneError('QQ_QZONE_LEDGER_REQUIRED')
423
+ }
424
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) {
425
+ throw qzoneError('QQ_QZONE_ACTION_ID_INVALID')
426
+ }
427
+ const fingerprint = createHash('sha256')
428
+ .update(JSON.stringify({ action, sceneKey: context.sceneKey, target, params }))
429
+ .digest('hex')
430
+ return context.ledger.run({
431
+ actionId,
432
+ fingerprint,
433
+ action,
434
+ sceneKey: context.sceneKey,
435
+ target,
436
+ execute: async () => {
437
+ const data = await call(action, params, true)
438
+ await authorize(context, action, target, true)
439
+ return projectWrite(data)
440
+ },
441
+ })
442
+ }
443
+
444
+ async function nativeCall(params) {
445
+ requireOpen()
446
+ if (!nativeTransport) throw qzoneError('QQ_QZONE_NATIVE_UNAVAILABLE')
447
+ let data
448
+ try {
449
+ data = await nativeTransport.action('send_qzone_msg', params, timeoutMs, maxResponseBytes)
450
+ } catch (error) {
451
+ if (error?.code === 'ONEBOT_REJECTED') throw qzoneError('QQ_QZONE_REJECTED')
452
+ throw qzoneError('QQ_QZONE_UNKNOWN')
453
+ }
454
+ return data
455
+ }
456
+
457
+ async function publishNative(context, { text, images, actionId, audience = 'self' }) {
458
+ await authorize(context, 'send_qzone_msg', null, true)
459
+ if (!context.ledger || typeof context.ledger.run !== 'function') {
460
+ throw qzoneError('QQ_QZONE_LEDGER_REQUIRED')
461
+ }
462
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) {
463
+ throw qzoneError('QQ_QZONE_ACTION_ID_INVALID')
464
+ }
465
+ if (images !== undefined && !Array.isArray(images)) throw qzoneError('QQ_QZONE_IMAGE_INVALID')
466
+ const paths = images ?? []
467
+ if (paths.length > MAX_QZONE_IMAGES) throw qzoneError('QQ_QZONE_IMAGE_LIMIT')
468
+ for (const relative of paths) {
469
+ if (typeof relative !== 'string' || relative.length === 0) throw qzoneError('QQ_QZONE_IMAGE_INVALID')
470
+ }
471
+ const content = publishTextField(text, paths.length > 0)
472
+ if (content.length === 0 && paths.length === 0) throw qzoneError('QQ_QZONE_CONTENT_INVALID')
473
+ if (!Object.hasOwn(QZONE_AUDIENCES, audience)) throw qzoneError('QQ_QZONE_AUDIENCE_INVALID')
474
+ if (paths.length > 0 && (typeof sendRoot !== 'string' || !sendRoot)) {
475
+ throw qzoneError('QQ_QZONE_SEND_ROOT_INVALID')
476
+ }
477
+
478
+ const snapshots = []
479
+ try {
480
+ for (const relative of paths) {
481
+ try {
482
+ snapshots.push(await snapshotControlledImage({ sendRoot, relativePath: relative, tempRoot }))
483
+ } catch {
484
+ throw qzoneError('QQ_QZONE_IMAGE_INVALID')
485
+ }
486
+ }
487
+ const fileUrls = snapshots.map(snapshot => pathToFileURL(snapshot.path).href)
488
+ const params = { content, ugc_right: QZONE_AUDIENCES[audience] }
489
+ if (fileUrls.length > 0) params.images = fileUrls
490
+ const fingerprint = createHash('sha256')
491
+ .update(JSON.stringify({
492
+ action: 'send_qzone_msg',
493
+ sceneKey: context.sceneKey,
494
+ content,
495
+ audience,
496
+ images: snapshots.map(snapshot => snapshot.sha256),
497
+ }))
498
+ .digest('hex')
499
+ return await context.ledger.run({
500
+ actionId,
501
+ fingerprint,
502
+ action: 'send_qzone_msg',
503
+ sceneKey: context.sceneKey,
504
+ target: null,
505
+ execute: async () => {
506
+ const data = await nativeCall(params)
507
+ try {
508
+ await authorize(context, 'send_qzone_msg', null, true)
509
+ } catch {
510
+ throw qzoneError('QQ_QZONE_UNKNOWN')
511
+ }
512
+ const tid = projectId(data?.tid)
513
+ if (tid === null) throw qzoneError('QQ_QZONE_UNKNOWN')
514
+ return { status: 'ok', tid, imageCount: fileUrls.length }
515
+ },
516
+ })
517
+ } finally {
518
+ for (const snapshot of snapshots) await snapshot.cleanup()
519
+ }
520
+ }
521
+
522
+ return Object.freeze({
523
+ async listOwnPosts(context, { cursor, limit = 20 } = {}) {
524
+ return readPosts(context, ACTIONS.ownPosts, {
525
+ ...(typeof cursor === 'string' && cursor ? { cursor } : {}),
526
+ num: Math.min(Math.max(Number(limit) || 20, 1), 100),
527
+ max_pages: 15,
528
+ include_image_data: false,
529
+ }, true)
530
+ },
531
+
532
+ async listFriendFeeds(context, { cursor, limit = 20 } = {}) {
533
+ return readPosts(context, ACTIONS.friendFeeds, {
534
+ ...(typeof cursor === 'string' && cursor ? { cursor } : {}),
535
+ num: Math.min(Math.max(Number(limit) || 20, 1), 100),
536
+ fast_mode: 1,
537
+ include_image_data: false,
538
+ }, false)
539
+ },
540
+
541
+ async listComments(context, { tid, limit = 20 } = {}) {
542
+ const post = targetPost(context, tid)
543
+ await authorize(context, ACTIONS.comments, { tid: post.tid }, false)
544
+ const data = await call(ACTIONS.comments, {
545
+ tid: post.tid,
546
+ ...(post.authorUin ? { user_id: post.authorUin } : {}),
547
+ num: Math.min(Math.max(Number(limit) || 20, 1), 100),
548
+ pos: 0,
549
+ }, false)
550
+ await authorize(context, ACTIONS.comments, { tid: post.tid }, false)
551
+ const comments = arrayFrom(data).map(raw => projectComment(raw, post.tid)).filter(Boolean)
552
+ const state = sceneState(context.sceneKey)
553
+ for (const comment of comments) rememberComment(state, `${post.tid}:${comment.commentId}`, comment)
554
+ return { comments }
555
+ },
556
+
557
+ async publishText(context, { text, actionId, audience }) {
558
+ if (nativeTransport) return publishNative(context, { text, images: [], actionId, audience })
559
+ return write(context, ACTIONS.publishText, null, {
560
+ message: [{ type: 'text', data: { text: textField(text) } }],
561
+ }, actionId)
562
+ },
563
+
564
+ async publishMedia(context, { text, images, actionId, audience } = {}) {
565
+ if (!nativeTransport) throw qzoneError('QQ_QZONE_MEDIA_UNSUPPORTED')
566
+ return publishNative(context, { text, images, actionId, audience })
567
+ },
568
+
569
+ async likePost(context, { tid, actionId }) {
570
+ const post = targetPost(context, tid)
571
+ return write(context, ACTIONS.like, { tid: post.tid }, {
572
+ tid: post.tid,
573
+ ...(post.authorUin ? { user_id: post.authorUin } : {}),
574
+ }, actionId)
575
+ },
576
+
577
+ async unlikePost(context, { tid, actionId }) {
578
+ const post = targetPost(context, tid)
579
+ return write(context, ACTIONS.unlike, { tid: post.tid }, {
580
+ tid: post.tid,
581
+ ...(post.authorUin ? { user_id: post.authorUin } : {}),
582
+ }, actionId)
583
+ },
584
+
585
+ async commentPost(context, { tid, text, actionId }) {
586
+ const post = targetPost(context, tid)
587
+ return write(context, ACTIONS.comment, { tid: post.tid }, {
588
+ target_uin: post.authorUin,
589
+ target_tid: post.tid,
590
+ content: textField(text),
591
+ }, actionId)
592
+ },
593
+
594
+ async replyComment(context, { tid, commentId, text, actionId }) {
595
+ const { post, comment } = targetComment(context, tid, commentId)
596
+ return write(context, ACTIONS.comment, {
597
+ tid: post.tid,
598
+ commentId: comment.commentId,
599
+ }, {
600
+ target_uin: post.authorUin,
601
+ target_tid: post.tid,
602
+ content: textField(text),
603
+ reply_comment_id: comment.commentId,
604
+ ...(comment.authorUin ? { reply_uin: comment.authorUin } : {}),
605
+ }, actionId)
606
+ },
607
+
608
+ async forwardPost(context, { tid, text = '', actionId }) {
609
+ const post = targetPost(context, tid)
610
+ return write(context, ACTIONS.forward, { tid: post.tid }, {
611
+ user_id: post.authorUin,
612
+ tid: post.tid,
613
+ content: textField(text, MAX_TEXT),
614
+ }, actionId)
615
+ },
616
+
617
+ async deleteOwnPost(context, { tid, actionId }) {
618
+ const post = targetPost(context, tid)
619
+ if (!post.isSelf) throw qzoneError('QQ_QZONE_TARGET_NOT_OWNED')
620
+ return write(context, ACTIONS.deleteOwn, { tid: post.tid }, { message_id: post.tid }, actionId)
621
+ },
622
+
623
+ close() {
624
+ closed = true
625
+ observations.clear()
626
+ },
627
+ })
628
+ }