@fg-lab/amadeus-qq 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -3
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +26 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +91 -6
- package/preset.patch.yml +103 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +48 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +538 -0
- package/src/console/index.html +67 -0
- package/src/core.js +892 -0
- package/src/files.js +230 -0
- package/src/index.js +867 -0
- package/src/lease.js +42 -0
- package/src/media.js +246 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +250 -0
- package/src/preset-guard.mjs +579 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- package/src/web-console.js +9 -0
package/src/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')
|