@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
package/src/storage.js ADDED
@@ -0,0 +1,580 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { validateScenePermissions } from './permissions.js'
5
+
6
+ const SCHEMA_VERSION = 1
7
+ const ID_PATTERN = /^(0|[1-9]\d{0,19})$/
8
+ const MESSAGE_ID_PATTERN = /^-?[1-9]\d{0,19}$/
9
+ const DEFAULT_MAX_MESSAGES = 500
10
+ const DEFAULT_MAX_SEEN = 2048
11
+ const DEFAULT_MAX_DELIVERIES = 500
12
+ const DEFAULT_MAX_ARCHIVED_DELIVERIES = 4096
13
+ const MAX_TRANSCRIPT_CHARS = 4000
14
+ const TERMINAL_DELIVERY_STATUSES = new Set(['delivered', 'failed'])
15
+
16
+ export function normalizeId(value, label = 'id') {
17
+ const text = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
18
+ if (typeof text !== 'string' || !ID_PATTERN.test(text) || text === '0') {
19
+ throw new TypeError(`${label} must be a positive decimal platform ID`)
20
+ }
21
+ return text
22
+ }
23
+
24
+ export function normalizeMessageId(value, label = 'messageId') {
25
+ const text = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
26
+ if (typeof text !== 'string' || !MESSAGE_ID_PATTERN.test(text)) {
27
+ throw new TypeError(`${label} must be a nonzero decimal message ID`)
28
+ }
29
+ return text
30
+ }
31
+
32
+ export function sceneIdentity(raw) {
33
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError('scene must be an object')
34
+ const botId = normalizeId(raw.botId, 'botId')
35
+ if (raw.kind !== 'group' && raw.kind !== 'private') throw new TypeError('scene kind must be group or private')
36
+ const peerId = normalizeId(raw.peerId, 'peerId')
37
+ return Object.freeze({ botId, kind: raw.kind, peerId })
38
+ }
39
+
40
+ export function sceneKey(raw) {
41
+ const scene = sceneIdentity(raw)
42
+ return `${scene.botId}:${scene.kind}:${scene.peerId}`
43
+ }
44
+
45
+ function emptyState() {
46
+ return { schemaVersion: SCHEMA_VERSION, scenes: {}, retiredSessions: [] }
47
+ }
48
+
49
+ function clone(value) {
50
+ return structuredClone(value)
51
+ }
52
+
53
+ /**
54
+ * A message is protected until DSH delivery has been recorded. Reading it from
55
+ * the durable inbox does not make an undelivered addressed/wait event safe to
56
+ * evict: the read waterline can move past it while the prompt is still pending.
57
+ */
58
+ function isPendingDelivery(message) {
59
+ return message?.needsPrompt === true && message?.prompted !== true
60
+ }
61
+
62
+ function validateState(state) {
63
+ if (!state || state.schemaVersion !== SCHEMA_VERSION || !state.scenes || typeof state.scenes !== 'object' || Array.isArray(state.scenes)) {
64
+ throw new Error('QQ state schema is invalid; refusing to reset durable state')
65
+ }
66
+ const boundSessions = new Set()
67
+ if (!Array.isArray(state.retiredSessions)) throw new Error('QQ state has an invalid retired Session index')
68
+ for (const sessionId of state.retiredSessions) {
69
+ if (typeof sessionId !== 'string' || !sessionId || boundSessions.has(sessionId)) throw new Error('QQ state has an invalid retired Session')
70
+ boundSessions.add(sessionId)
71
+ }
72
+ for (const [key, row] of Object.entries(state.scenes)) {
73
+ if (sceneKey(row) !== key || !Array.isArray(row.messages) || !Array.isArray(row.seenIds) || !Array.isArray(row.deliveries)) {
74
+ throw new Error(`QQ state contains an invalid scene row: ${key}`)
75
+ }
76
+ if (row.deliveryArchive !== undefined && !Array.isArray(row.deliveryArchive)) {
77
+ throw new Error(`QQ state contains an invalid delivery archive: ${key}`)
78
+ }
79
+ if (row.pendingPromptBatch != null &&
80
+ (!Array.isArray(row.pendingPromptBatch) || row.pendingPromptBatch.length < 1 ||
81
+ row.pendingPromptBatch.length > 8 || row.pendingPromptBatch.some(seq => !Number.isSafeInteger(seq) || seq < 1))) {
82
+ throw new Error(`QQ state contains an invalid prompt batch: ${key}`)
83
+ }
84
+ if (row.lastAutoPromptAt !== undefined &&
85
+ (!Number.isSafeInteger(row.lastAutoPromptAt) || row.lastAutoPromptAt < 0)) {
86
+ throw new Error(`QQ state contains an invalid autonomous prompt timestamp: ${key}`)
87
+ }
88
+ if (row.permissions !== undefined) {
89
+ try { validateScenePermissions(row.permissions, { requireRevision: true }) }
90
+ catch { throw new Error(`QQ state contains invalid Scene permissions: ${key}`) }
91
+ }
92
+ for (const message of row.messages) {
93
+ if (message.needsPrompt !== undefined && typeof message.needsPrompt !== 'boolean') {
94
+ throw new Error(`QQ state contains an invalid message delivery flag: ${key}`)
95
+ }
96
+ if (message.transcript !== undefined &&
97
+ (typeof message.transcript !== 'string' || message.transcript.length < 1 ||
98
+ message.transcript.length > MAX_TRANSCRIPT_CHARS)) {
99
+ throw new Error(`QQ state contains an invalid message transcript: ${key}`)
100
+ }
101
+ }
102
+ const actionIds = new Set()
103
+ for (const delivery of [...row.deliveries, ...(row.deliveryArchive ?? [])]) {
104
+ if (typeof delivery?.actionId !== 'string' || !/^[\w-]{1,100}$/.test(delivery.actionId)) {
105
+ throw new Error(`QQ state contains an invalid delivery action: ${key}`)
106
+ }
107
+ if (actionIds.has(delivery.actionId)) throw new Error(`QQ state repeats a delivery action: ${key}`)
108
+ actionIds.add(delivery.actionId)
109
+ if (typeof delivery.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(delivery.fingerprint)) {
110
+ throw new Error(`QQ state contains an invalid delivery fingerprint: ${key}`)
111
+ }
112
+ if (!['delivered', 'failed', 'unknown'].includes(delivery.status)) {
113
+ throw new Error(`QQ state contains an invalid delivery status: ${key}`)
114
+ }
115
+ if (delivery.platformFileId !== undefined && delivery.platformFileId !== null &&
116
+ (typeof delivery.platformFileId !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(delivery.platformFileId))) {
117
+ throw new Error(`QQ state contains an invalid delivery file receipt: ${key}`)
118
+ }
119
+ }
120
+ for (const delivery of row.deliveryArchive ?? []) {
121
+ // Archived entries are terminal. An unknown action must stay in the live
122
+ // ledger: moving it would hide an unresolved external write.
123
+ if (!TERMINAL_DELIVERY_STATUSES.has(delivery.status)) {
124
+ throw new Error(`QQ state archives an unsettled delivery: ${key}`)
125
+ }
126
+ }
127
+ if (row.sessionId !== null && (typeof row.sessionId !== 'string' || !row.sessionId)) {
128
+ throw new Error(`QQ state contains an invalid Session: ${key}`)
129
+ }
130
+ if (row.workspaceId !== undefined && (typeof row.workspaceId !== 'string' || row.workspaceId.length > 200)) {
131
+ throw new Error(`QQ state contains an invalid Workspace binding: ${key}`)
132
+ }
133
+ if (row.boundPermissionRevision !== undefined &&
134
+ (!Number.isSafeInteger(row.boundPermissionRevision) || row.boundPermissionRevision < 0)) {
135
+ throw new Error(`QQ state contains an invalid bound permission revision: ${key}`)
136
+ }
137
+ if (row.boundSecurityFingerprint !== undefined &&
138
+ (typeof row.boundSecurityFingerprint !== 'string' ||
139
+ (row.boundSecurityFingerprint !== '' && !/^[a-f0-9]{64}$/.test(row.boundSecurityFingerprint)))) {
140
+ throw new Error(`QQ state contains an invalid bound security fingerprint: ${key}`)
141
+ }
142
+ if (row.sessionId) {
143
+ if (boundSessions.has(row.sessionId)) throw new Error('QQ state binds one DSH Session to multiple scenes')
144
+ boundSessions.add(row.sessionId)
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Read schema-v1 rows written before the capacity revisions. Missing archives
151
+ * are empty; a message without an explicit delivery flag is treated as pending
152
+ * unless it was already prompted, so an upgrade cannot silently drop it.
153
+ */
154
+ function normalizeLoadedState(state) {
155
+ if (!state || typeof state !== 'object' || !state.scenes || typeof state.scenes !== 'object' || Array.isArray(state.scenes)) {
156
+ return state
157
+ }
158
+ state.retiredSessions ??= []
159
+ for (const row of Object.values(state.scenes)) {
160
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue
161
+ row.deliveryArchive ??= []
162
+ if (!Array.isArray(row.messages)) continue
163
+ for (const message of row.messages) {
164
+ if (message.needsPrompt === undefined) message.needsPrompt = message.prompted !== true
165
+ }
166
+ }
167
+ return state
168
+ }
169
+
170
+ function durableWrite(file, state) {
171
+ const dir = path.dirname(file)
172
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
173
+ const temp = path.join(dir, `.${path.basename(file)}.${randomUUID()}.tmp`)
174
+ let created = false
175
+ try {
176
+ const fd = fs.openSync(temp, 'wx', 0o600)
177
+ created = true
178
+ try {
179
+ fs.writeFileSync(fd, JSON.stringify(state))
180
+ fs.fsyncSync(fd)
181
+ } finally {
182
+ fs.closeSync(fd)
183
+ }
184
+ fs.renameSync(temp, file)
185
+ try {
186
+ const dirFd = fs.openSync(dir, 'r')
187
+ try { fs.fsyncSync(dirFd) } finally { fs.closeSync(dirFd) }
188
+ } catch {
189
+ // Windows does not always permit directory fsync. File fsync + atomic rename remain required.
190
+ }
191
+ } catch (error) {
192
+ if (created) { try { fs.unlinkSync(temp) } catch {} }
193
+ throw error
194
+ }
195
+ }
196
+
197
+ export class QQStore {
198
+ constructor(file, limits = {}) {
199
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw new TypeError('state file must be absolute')
200
+ this.file = file
201
+ this.maxMessages = limits.maxMessages ?? DEFAULT_MAX_MESSAGES
202
+ this.maxSeen = limits.maxSeen ?? DEFAULT_MAX_SEEN
203
+ this.maxDeliveries = limits.maxDeliveries ?? DEFAULT_MAX_DELIVERIES
204
+ this.maxArchivedDeliveries = limits.maxArchivedDeliveries ?? DEFAULT_MAX_ARCHIVED_DELIVERIES
205
+ for (const n of [this.maxMessages, this.maxSeen, this.maxDeliveries, this.maxArchivedDeliveries]) {
206
+ if (!Number.isSafeInteger(n) || n < 1) throw new TypeError('state limits must be positive integers')
207
+ }
208
+ this.state = fs.existsSync(file) ? normalizeLoadedState(JSON.parse(fs.readFileSync(file, 'utf8'))) : emptyState()
209
+ validateState(this.state)
210
+ }
211
+
212
+ snapshot() { return clone(this.state) }
213
+
214
+ /** Keep received messages, but do not wake an old conversation after a
215
+ * deliberate handoff to another QQ client. This is one durable write. */
216
+ suppressPendingPrompts() {
217
+ const pending = Object.values(this.state.scenes).some(row =>
218
+ row.pendingPromptBatch?.length || row.messages.some(item => item.needsPrompt && !item.prompted))
219
+ if (!pending) return 0
220
+ return this.#change(state => {
221
+ let suppressed = 0
222
+ for (const row of Object.values(state.scenes)) {
223
+ for (const message of row.messages) {
224
+ if (!message.needsPrompt || message.prompted) continue
225
+ message.prompted = true
226
+ row.promptedSeq = Math.max(row.promptedSeq, message.seq)
227
+ row.presentedSeq = Math.max(row.presentedSeq, message.seq)
228
+ suppressed++
229
+ }
230
+ row.pendingPromptBatch = null
231
+ }
232
+ return suppressed
233
+ })
234
+ }
235
+
236
+ #change(edit) {
237
+ const next = clone(this.state)
238
+ const result = edit(next)
239
+ validateState(next)
240
+ durableWrite(this.file, next)
241
+ this.state = next
242
+ return result
243
+ }
244
+
245
+ #row(state, identity) {
246
+ const scene = sceneIdentity(identity)
247
+ const key = sceneKey(scene)
248
+ state.scenes[key] ??= { ...scene, sessionId: null, authEpoch: 0, boundPermissionRevision: 0, boundSecurityFingerprint: '', nextSeq: 1, readSeq: 0, presentedSeq: 0, promptedSeq: 0, messages: [], seenIds: [], deliveries: [], deliveryArchive: [] }
249
+ return state.scenes[key]
250
+ }
251
+
252
+ getScene(identity) {
253
+ const row = this.state.scenes[sceneKey(identity)]
254
+ return row ? clone(row) : null
255
+ }
256
+
257
+ getScenePermissions(identity) {
258
+ const row = this.state.scenes[sceneKey(identity)]
259
+ return row?.permissions ? clone(row.permissions) : null
260
+ }
261
+
262
+ setScenePermissions(identity, access) {
263
+ const normalized = validateScenePermissions(access, { allowRevision: false })
264
+ return this.#change(state => {
265
+ const row = this.#row(state, identity)
266
+ const revision = (row.permissions?.revision ?? 0) + 1
267
+ row.permissions = { ...normalized, revision }
268
+ return clone(row.permissions)
269
+ })
270
+ }
271
+
272
+ markAutoPromptAt(identity, timestamp) {
273
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new TypeError('invalid autonomous prompt timestamp')
274
+ return this.#change(state => {
275
+ const row = this.#row(state, identity)
276
+ row.lastAutoPromptAt = timestamp
277
+ return timestamp
278
+ })
279
+ }
280
+
281
+ sceneForSession(sessionId) {
282
+ if (typeof sessionId !== 'string' || !sessionId) return null
283
+ const matches = Object.values(this.state.scenes).filter(row => row.sessionId === sessionId)
284
+ if (matches.length > 1) throw new Error('one DSH Session is bound to multiple QQ scenes')
285
+ return matches.length ? clone(matches[0]) : null
286
+ }
287
+
288
+ isKnownSession(sessionId) {
289
+ if (typeof sessionId !== 'string' || !sessionId) return false
290
+ return this.state.retiredSessions.includes(sessionId) || this.sceneForSession(sessionId) !== null
291
+ }
292
+
293
+ retireSession(identity, sessionId) {
294
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid DSH Session ID')
295
+ return this.#change(state => {
296
+ const row = this.#row(state, identity)
297
+ if (row.sessionId !== sessionId) throw new Error('QQ_SESSION_CHANGED: expected Session is no longer bound')
298
+ if (!state.retiredSessions.includes(sessionId)) state.retiredSessions.push(sessionId)
299
+ row.sessionId = null
300
+ row.boundPermissionRevision = 0
301
+ row.boundSecurityFingerprint = ''
302
+ return clone(row)
303
+ })
304
+ }
305
+
306
+ recordRetiredSession(sessionId) {
307
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid DSH Session ID')
308
+ return this.#change(state => {
309
+ if (Object.values(state.scenes).some(row => row.sessionId === sessionId)) throw new Error('cannot retire an active QQ Session')
310
+ if (!state.retiredSessions.includes(sessionId)) state.retiredSessions.push(sessionId)
311
+ return sessionId
312
+ })
313
+ }
314
+
315
+ bindSession(identity, sessionId, authEpoch = 0, workspaceId = '', securityFingerprint = '') {
316
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 200) throw new TypeError('invalid DSH Session ID')
317
+ if (!Number.isSafeInteger(authEpoch) || authEpoch < 0) throw new TypeError('invalid authorization epoch')
318
+ if (typeof workspaceId !== 'string' || workspaceId.length > 200) throw new TypeError('invalid DSH Workspace ID')
319
+ if (typeof securityFingerprint !== 'string' ||
320
+ (securityFingerprint !== '' && !/^[a-f0-9]{64}$/.test(securityFingerprint))) {
321
+ throw new TypeError('invalid DSH Session security fingerprint')
322
+ }
323
+ return this.#change(state => {
324
+ const row = this.#row(state, identity)
325
+ if (state.retiredSessions.includes(sessionId)) throw new Error('retired DSH Session cannot be rebound')
326
+ if (row.sessionId && row.sessionId !== sessionId) throw new Error('scene already has a DSH Session')
327
+ if (Object.values(state.scenes).some(other => other !== row && other.sessionId === sessionId)) {
328
+ throw new Error('DSH Session is already bound to another scene')
329
+ }
330
+ row.sessionId = sessionId
331
+ row.authEpoch = authEpoch
332
+ row.workspaceId = workspaceId
333
+ row.boundPermissionRevision = row.permissions?.revision ?? 0
334
+ row.boundSecurityFingerprint = securityFingerprint
335
+ return clone(row)
336
+ })
337
+ }
338
+
339
+ updateAuthEpoch(identity, authEpoch) {
340
+ if (!Number.isSafeInteger(authEpoch) || authEpoch < 0) throw new TypeError('invalid authorization epoch')
341
+ return this.#change(state => { const row = this.#row(state, identity); row.authEpoch = authEpoch; return clone(row) })
342
+ }
343
+
344
+ markPresented(identity, throughSeq) {
345
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < 0) throw new TypeError('invalid throughSeq')
346
+ return this.#change(state => {
347
+ const row = this.#row(state, identity)
348
+ if (throughSeq >= row.nextSeq) throw new RangeError('presentation exceeds received message waterline')
349
+ row.presentedSeq = Math.max(row.presentedSeq, throughSeq)
350
+ return row.presentedSeq
351
+ })
352
+ }
353
+
354
+ markPrompted(identity, throughSeq) {
355
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < 0) throw new TypeError('invalid throughSeq')
356
+ return this.#change(state => {
357
+ const row = this.#row(state, identity)
358
+ if (throughSeq >= row.nextSeq) throw new RangeError('prompt exceeds received message waterline')
359
+ const message = row.messages.find(item => item.seq === throughSeq)
360
+ if (!message) throw new Error('prompted message is no longer stored')
361
+ message.prompted = true
362
+ row.promptedSeq = Math.max(row.promptedSeq, throughSeq)
363
+ row.presentedSeq = Math.max(row.presentedSeq, throughSeq)
364
+ return row.promptedSeq
365
+ })
366
+ }
367
+
368
+ reservePromptBatch(identity) {
369
+ return this.#change(state => {
370
+ const row = this.#row(state, identity)
371
+ if (row.pendingPromptBatch?.length) return [...row.pendingPromptBatch]
372
+ const seqs = row.messages.filter(item => item.needsPrompt === true && item.prompted !== true)
373
+ .slice(0, 8).map(item => item.seq)
374
+ if (!seqs.length) return []
375
+ row.pendingPromptBatch = seqs
376
+ return [...seqs]
377
+ })
378
+ }
379
+
380
+ markPromptBatch(identity, seqs) {
381
+ if (!Array.isArray(seqs) || !seqs.length) throw new TypeError('invalid prompt batch')
382
+ return this.#change(state => {
383
+ const row = this.#row(state, identity)
384
+ if (JSON.stringify(row.pendingPromptBatch) !== JSON.stringify(seqs)) throw new Error('QQ_PROMPT_BATCH_CHANGED')
385
+ for (const seq of seqs) {
386
+ const item = row.messages.find(message => message.seq === seq)
387
+ if (!item || item.needsPrompt !== true) throw new Error('QQ_PROMPT_BATCH_MISSING')
388
+ item.prompted = true
389
+ }
390
+ row.promptedSeq = Math.max(row.promptedSeq, ...seqs)
391
+ row.presentedSeq = Math.max(row.presentedSeq, ...seqs)
392
+ row.pendingPromptBatch = null
393
+ return row.promptedSeq
394
+ })
395
+ }
396
+
397
+ /**
398
+ * Free exactly one message slot. Passive (never-addressed) messages roll off
399
+ * first as a short-term window; already-read messages are next; messages that
400
+ * were handed to the model but not read are last. A message still waiting for
401
+ * delivery is never evicted here.
402
+ */
403
+ #evictMessage(row) {
404
+ let index = row.messages.findIndex(item => item.needsPrompt !== true)
405
+ if (index < 0) index = row.messages.findIndex(item => item.seq <= row.readSeq && !isPendingDelivery(item))
406
+ if (index < 0) index = row.messages.findIndex(item => item.prompted === true)
407
+ if (index < 0) return false
408
+ row.messages.splice(index, 1)
409
+ return true
410
+ }
411
+
412
+ appendMessage(identity, message) {
413
+ const messageId = normalizeMessageId(message?.messageId, 'messageId')
414
+ const needsPrompt = message?.needsPrompt === true
415
+ return this.#change(state => {
416
+ const row = this.#row(state, identity)
417
+ if (row.seenIds.includes(messageId)) return { duplicate: true, seq: null }
418
+ while (row.messages.length >= this.maxMessages && this.#evictMessage(row)) {}
419
+ if (row.messages.length >= this.maxMessages) {
420
+ // Do not advance nextSeq and do not invent a slot: a protected message
421
+ // that cannot be retained must be reported, not silently lost.
422
+ if (needsPrompt) {
423
+ const pending = row.messages.filter(isPendingDelivery).length
424
+ throw new Error(`QQ_QUEUE_FULL: ${row.messages.length}/${this.maxMessages} messages retained, ${pending} pending delivery; acknowledge or read earlier messages first`)
425
+ }
426
+ row.seenIds.push(messageId)
427
+ if (row.seenIds.length > this.maxSeen) row.seenIds.splice(0, row.seenIds.length - this.maxSeen)
428
+ return { duplicate: false, seq: null, dropped: 'passive_window' }
429
+ }
430
+ const seq = row.nextSeq++
431
+ row.messages.push({ ...clone(message), messageId, seq, needsPrompt, prompted: false })
432
+ row.seenIds.push(messageId)
433
+ if (row.seenIds.length > this.maxSeen) row.seenIds.splice(0, row.seenIds.length - this.maxSeen)
434
+ return { duplicate: false, seq }
435
+ })
436
+ }
437
+
438
+ /**
439
+ * Attach a bounded native transcript to an existing message without creating
440
+ * a new message or advancing any cursor. Repeated writes are idempotent.
441
+ */
442
+ setMessageTranscript(identity, messageId, transcript) {
443
+ const normalizedId = normalizeMessageId(messageId, 'messageId')
444
+ if (typeof transcript !== 'string') throw new TypeError('transcript must be a string')
445
+ const text = transcript.trim()
446
+ if (text.length < 1 || text.length > MAX_TRANSCRIPT_CHARS) throw new RangeError('invalid transcript length')
447
+ return this.#change(state => {
448
+ const row = this.#row(state, identity)
449
+ const message = row.messages.find(item => item.messageId === normalizedId)
450
+ if (!message) throw new Error('QQ_MESSAGE_SCOPE: transcript target is not in this scene')
451
+ message.transcript = text
452
+ return clone({ messageId: message.messageId, senderId: message.senderId, transcript: message.transcript })
453
+ })
454
+ }
455
+
456
+ unread(identity, afterSeq = 0, limit = 50) {
457
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new TypeError('invalid afterSeq')
458
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError('invalid limit')
459
+ const row = this.getScene(identity)
460
+ if (!row) return { messages: [], readThroughSeq: 0, unreadCount: 0 }
461
+ if (afterSeq >= row.nextSeq) throw new RangeError('afterSeq exceeds received message waterline')
462
+ const pending = row.messages.filter(item => item.seq > Math.max(row.readSeq, afterSeq))
463
+ const messages = pending.slice(0, limit)
464
+ return { messages, readThroughSeq: messages.at(-1)?.seq ?? Math.max(row.readSeq, afterSeq), unreadCount: pending.length, partial: pending.length > messages.length }
465
+ }
466
+
467
+ markRead(identity, throughSeq) {
468
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < 0) throw new TypeError('invalid throughSeq')
469
+ return this.#change(state => {
470
+ const row = this.#row(state, identity)
471
+ if (throughSeq > row.presentedSeq) throw new RangeError('cannot mark unread unseen messages as read')
472
+ row.readSeq = Math.max(row.readSeq, throughSeq)
473
+ return row.readSeq
474
+ })
475
+ }
476
+
477
+ hasDelivered(identity, platformMessageId) {
478
+ const row = this.state.scenes[sceneKey(identity)]
479
+ if (!row) return false
480
+ return [...row.deliveries, ...(row.deliveryArchive ?? [])]
481
+ .some(item => item.status === 'delivered' && item.platformMessageId === platformMessageId)
482
+ }
483
+
484
+ reserveDelivery(identity, actionId, fingerprint) {
485
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
486
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(fingerprint)) throw new TypeError('invalid fingerprint')
487
+ return this.#change(state => {
488
+ const row = this.#row(state, identity)
489
+ const prior = row.deliveries.find(item => item.actionId === actionId) ??
490
+ row.deliveryArchive.find(item => item.actionId === actionId)
491
+ if (prior) {
492
+ if (prior.fingerprint !== fingerprint) throw new Error('QQ_ACTION_CONFLICT: actionId reused with different content')
493
+ return { fresh: false, delivery: clone(prior) }
494
+ }
495
+ // A different actionId must not retry bytes whose earlier send has an
496
+ // unresolved outcome. This check runs inside the serialized state change
497
+ // so two concurrent new actionIds cannot both pass before either writes.
498
+ if (row.deliveries.some(item => item.status === 'unknown' && item.fingerprint === fingerprint)) {
499
+ throw new Error('QQ_DELIVERY_UNRESOLVED: identical content already has an unknown delivery')
500
+ }
501
+ if (row.deliveries.length >= this.maxDeliveries) {
502
+ // Only terminal actions may leave the live ledger. Unknown actions keep
503
+ // an unresolved external write; forgetting one would permit a blind retry.
504
+ const index = row.deliveries.findIndex(item => TERMINAL_DELIVERY_STATUSES.has(item.status))
505
+ if (index < 0) {
506
+ const unsettled = row.deliveries.filter(item => item.status === 'unknown').length
507
+ throw new Error(`QQ_OUTBOX_FULL: ${row.deliveries.length}/${this.maxDeliveries} actions are unsettled (${unsettled} unknown); refusing to forget an in-flight send`)
508
+ }
509
+ const [archived] = row.deliveries.splice(index, 1)
510
+ // Terminal actions move to a bounded archive. The newest terminal
511
+ // actionIds keep deduplicating across restarts; the oldest expire after
512
+ // maxArchivedDeliveries newer settlements and may be sent again in a far
513
+ // later activity. This is bounded retry protection, not exactly-once.
514
+ row.deliveryArchive.push({ ...archived, archivedAt: Date.now() })
515
+ while (row.deliveryArchive.length > this.maxArchivedDeliveries) row.deliveryArchive.shift()
516
+ }
517
+ const delivery = {
518
+ actionId,
519
+ fingerprint,
520
+ status: 'unknown',
521
+ platformMessageId: null,
522
+ platformFileId: null,
523
+ updatedAt: Date.now(),
524
+ }
525
+ row.deliveries.push(delivery)
526
+ return { fresh: true, delivery: clone(delivery) }
527
+ })
528
+ }
529
+
530
+ settleDelivery(identity, actionId, status, platformMessageId = null, platformFileId = null) {
531
+ if (!['delivered', 'failed', 'unknown'].includes(status)) throw new TypeError('invalid delivery status')
532
+ if (platformFileId !== null &&
533
+ (typeof platformFileId !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(platformFileId))) {
534
+ throw new TypeError('invalid platformFileId')
535
+ }
536
+ return this.#change(state => {
537
+ const row = this.#row(state, identity)
538
+ const delivery = row.deliveries.find(item => item.actionId === actionId)
539
+ if (!delivery) throw new Error('delivery not reserved')
540
+ if (delivery.status === 'delivered' && status !== 'delivered') throw new Error('delivered action cannot be downgraded')
541
+ delivery.status = status
542
+ delivery.platformMessageId = platformMessageId === null ? null : normalizeMessageId(platformMessageId, 'platformMessageId')
543
+ delivery.platformFileId = platformFileId
544
+ delivery.updatedAt = Date.now()
545
+ return clone(delivery)
546
+ })
547
+ }
548
+
549
+ /**
550
+ * Atomically close one settled unknown delivery as failed after explicit owner
551
+ * reconciliation. The compare-and-set lives inside the serialized state change:
552
+ * a concurrent settle or a second resolution cannot be overwritten.
553
+ */
554
+ resolveUnknownDelivery(identity, actionId, outcome = 'failed') {
555
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
556
+ if (outcome !== 'failed') throw new TypeError('only failed reconciliation is supported')
557
+ return this.#change(state => {
558
+ const row = state.scenes[sceneKey(identity)]
559
+ if (!row) {
560
+ const error = new Error('QQ_DELIVERY_NOT_FOUND')
561
+ error.code = 'QQ_DELIVERY_NOT_FOUND'
562
+ throw error
563
+ }
564
+ const delivery = row.deliveries.find(item => item.actionId === actionId)
565
+ if (!delivery) {
566
+ const error = new Error('QQ_DELIVERY_NOT_FOUND')
567
+ error.code = 'QQ_DELIVERY_NOT_FOUND'
568
+ throw error
569
+ }
570
+ if (delivery.status !== 'unknown') {
571
+ const error = new Error('QQ_DELIVERY_NOT_UNKNOWN')
572
+ error.code = 'QQ_DELIVERY_NOT_UNKNOWN'
573
+ throw error
574
+ }
575
+ delivery.status = 'failed'
576
+ delivery.updatedAt = Date.now()
577
+ return clone(delivery)
578
+ })
579
+ }
580
+ }
@@ -0,0 +1,9 @@
1
+ import fs from 'node:fs'
2
+
3
+ // Shipped, static assets. Runtime values are fetched only after the Host's
4
+ // same-origin DSH Owner admission in admin-http.js.
5
+ const asset = name => fs.readFileSync(new URL(`./console/${name}`, import.meta.url), 'utf8')
6
+
7
+ export const CONSOLE_HTML = asset('index.html')
8
+ export const CONSOLE_CSS = asset('console.css')
9
+ export const CONSOLE_JS = asset('console.js')