@fg-lab/amadeus-qq 0.0.0-stage → 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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +28 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  26. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  27. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  28. package/lib/client.js +380 -0
  29. package/package.json +92 -4
  30. package/preset.patch.yml +166 -0
  31. package/screenshots.json +6 -0
  32. package/scripts/build-client.mjs +52 -0
  33. package/scripts/registry-guard.mjs +12 -0
  34. package/scripts/release-audit.mjs +59 -0
  35. package/scripts/symlink-security.mjs +8 -0
  36. package/src/account-profile.js +223 -0
  37. package/src/admin-http.js +361 -0
  38. package/src/audio.js +428 -0
  39. package/src/capabilities.js +785 -0
  40. package/src/client/factory.template.js +82 -0
  41. package/src/client/logic.js +290 -0
  42. package/src/config.js +50 -0
  43. package/src/console/console.css +28 -0
  44. package/src/console/console.js +541 -0
  45. package/src/console/index.html +67 -0
  46. package/src/core.js +909 -0
  47. package/src/files.js +230 -0
  48. package/src/index.js +994 -0
  49. package/src/lease.js +42 -0
  50. package/src/media.js +309 -0
  51. package/src/napcat-action-names.js +186 -0
  52. package/src/native-asr.js +112 -0
  53. package/src/onebot.js +378 -0
  54. package/src/outbound-media.js +309 -0
  55. package/src/owner-workspace.js +394 -0
  56. package/src/permissions.js +270 -0
  57. package/src/preset-guard.mjs +593 -0
  58. package/src/qzone-provider.js +628 -0
  59. package/src/session-provenance.js +74 -0
  60. package/src/setup.js +255 -0
  61. package/src/storage.js +580 -0
  62. package/src/web-console.js +9 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ // This journal is separate from replaceable scene configuration. Never infer
6
+ // ordinary provenance from absence in a potentially incomplete historical index.
7
+ export class SessionProvenance {
8
+ constructor(file, { sessions, store, now = () => Date.now() }) {
9
+ this.file = file
10
+ this.sessions = sessions
11
+ this.store = store
12
+ this.now = now
13
+ this.observedSince = now()
14
+ this.pending = new Set()
15
+ this.state = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) :
16
+ { version: 1, observedSince: now(), reserved: [] }
17
+ if (this.state.version !== 1 || !Number.isSafeInteger(this.state.observedSince) ||
18
+ !Array.isArray(this.state.reserved) || this.state.reserved.some(id => typeof id !== 'string')) {
19
+ throw new Error('QQ_PROVENANCE_INVALID: restore the provenance backup; refusing reset')
20
+ }
21
+ if (!fs.existsSync(file)) this.persist(this.state)
22
+ this.state.ordinary ??= {}
23
+ if (typeof this.state.ordinary !== 'object' || Array.isArray(this.state.ordinary) || this.state.ordinary === null) {
24
+ throw new Error('QQ_PROVENANCE_INVALID: invalid ordinary attestations')
25
+ }
26
+ }
27
+ persist(state) {
28
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
29
+ const temp = `${this.file}.${randomUUID()}.tmp`
30
+ const fd = fs.openSync(temp, 'wx', 0o600)
31
+ try { fs.writeFileSync(fd, JSON.stringify(state)); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
32
+ fs.renameSync(temp, this.file)
33
+ }
34
+ reserve() {
35
+ const id = `session-${randomUUID()}`
36
+ const next = { ...this.state, reserved: [...this.state.reserved, id] }
37
+ this.persist(next) // Must precede even the first Host create await.
38
+ this.state = next
39
+ this.pending.add(id)
40
+ return id
41
+ }
42
+ complete(id, returnedId) {
43
+ if (returnedId !== undefined && returnedId !== id) {
44
+ const next = { ...this.state, unsupportedHost: true }
45
+ this.persist(next)
46
+ this.state = next
47
+ }
48
+ this.pending.delete(id)
49
+ }
50
+ classify(sessionId) {
51
+ const session = this.sessions()?.get?.(sessionId)
52
+ if (!session || session.id !== sessionId) return { kind: 'unknown', reason: 'session-unavailable' }
53
+ if (this.store.isKnownSession(sessionId) || this.state.reserved.includes(sessionId)) {
54
+ return { kind: 'qq', reason: 'durable-qq-reservation' }
55
+ }
56
+ const raw = session.header?.createdAt
57
+ const created = typeof raw === 'number' ? raw : typeof raw === 'string' ? Date.parse(raw) : NaN
58
+ // Before journal enrollment, failed/unbound historical QQ creation is not
59
+ // reconstructible. Require a fresh Host Session instead of guessing.
60
+ if (Number.isFinite(created) && Object.hasOwn(this.state.ordinary, sessionId) && this.state.ordinary[sessionId] === created) {
61
+ return { kind: 'ordinary', reason: 'durable-host-attestation' }
62
+ }
63
+ if (this.pending.size || this.state.unsupportedHost) return { kind: 'unknown', reason: 'qq-creation-contract-unverified' }
64
+ // A previous process's observation interval is not evidence for Sessions
65
+ // created while it was absent (or an older connector was running).
66
+ if (!Number.isFinite(created) || created <= this.observedSince || created > this.now()) {
67
+ return { kind: 'unknown', reason: 'historical-provenance-unverified' }
68
+ }
69
+ const next = { ...this.state, ordinary: { ...this.state.ordinary, [sessionId]: created } }
70
+ this.persist(next)
71
+ this.state = next
72
+ return { kind: 'ordinary', reason: 'host-created-after-reservation-contract' }
73
+ }
74
+ }