@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.
- package/LICENSE +21 -0
- package/README.md +54 -2
- 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 +28 -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/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +92 -4
- package/preset.patch.yml +166 -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 +50 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +541 -0
- package/src/console/index.html +67 -0
- package/src/core.js +909 -0
- package/src/files.js +230 -0
- package/src/index.js +994 -0
- package/src/lease.js +42 -0
- package/src/media.js +309 -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 +270 -0
- package/src/preset-guard.mjs +593 -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
|
@@ -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
|
+
}
|