@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/core.js ADDED
@@ -0,0 +1,909 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { normalizeId, normalizeMessageId, sceneIdentity, sceneKey } from './storage.js'
4
+ import { normalizeOneBotMessage, sanitizeInboundSegments, validateOutgoingSegments } from './onebot.js'
5
+ import { CAPABILITY_CATALOG, LIFE_READ_CAPABILITY, PermissionEngine, validateScenePermissions } from './permissions.js'
6
+
7
+ const DEFAULT_WAIT_MS = 30_000
8
+ const MAX_WAIT_MS = 300_000
9
+ const AUTONOMOUS_QUIET_WINDOW_MS = 850
10
+ const IMPLEMENTED_CAPABILITIES = new Set(
11
+ CAPABILITY_CATALOG.filter(item => item.implemented === true).map(item => item.id),
12
+ )
13
+
14
+ function idSet(values, label) {
15
+ if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
16
+ return new Set(values.map(value => normalizeId(value, label)))
17
+ }
18
+
19
+ export function parsePolicy(raw = {}) {
20
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError('QQ policy must be an object')
21
+ const epoch = raw.epoch ?? 0
22
+ if (!Number.isSafeInteger(epoch) || epoch < 0) throw new TypeError('invalid QQ policy epoch')
23
+ const sceneRevisions = new Map()
24
+ if (raw.sceneRevisions !== undefined) {
25
+ if (!raw.sceneRevisions || typeof raw.sceneRevisions !== 'object' || Array.isArray(raw.sceneRevisions) ||
26
+ Object.keys(raw.sceneRevisions).length > 10000) throw new TypeError('invalid QQ scene revisions')
27
+ for (const [key, revision] of Object.entries(raw.sceneRevisions)) {
28
+ if (!/^[1-9]\d{0,19}:(group|private):[1-9]\d{0,19}$/.test(key) ||
29
+ !Number.isSafeInteger(revision) || revision < 0) throw new TypeError('invalid QQ scene revision')
30
+ sceneRevisions.set(key, revision)
31
+ }
32
+ }
33
+ return {
34
+ epoch,
35
+ sceneRevisions,
36
+ botIds: idSet(raw.botIds ?? [], 'botIds'),
37
+ groupIds: idSet(raw.groupIds ?? [], 'groupIds'),
38
+ privateIds: idSet(raw.privateIds ?? [], 'privateIds'),
39
+ pausedScenes: new Set(Array.isArray(raw.pausedScenes) ? raw.pausedScenes.filter(x => typeof x === 'string') : []),
40
+ triggerNames: Array.isArray(raw.triggerNames) ? raw.triggerNames.filter(x => typeof x === 'string' && x.length <= 30).slice(0, 10) : [],
41
+ }
42
+ }
43
+
44
+ function messageText(message) {
45
+ return message.segments.filter(item => item.type === 'text').map(item => String(item.data.text ?? '')).join(' ')
46
+ }
47
+
48
+ export class QQCore {
49
+ constructor({ store, dsh, transport, policy = {}, permissionPolicy = {}, workspaceId = '', handoffPaused = false, clock = () => Date.now() }) {
50
+ if (!store || !dsh || !transport) throw new TypeError('store, dsh and transport are required')
51
+ this.store = store
52
+ this.dsh = dsh
53
+ this.transport = transport
54
+ this.policy = parsePolicy(policy)
55
+ this.permissions = new PermissionEngine(permissionPolicy)
56
+ this.workspaceId = workspaceId
57
+ this.clock = clock
58
+ this.sceneChains = new Map()
59
+ this.waiters = new Map()
60
+ this.promptTimers = new Map()
61
+ this.inFlightDeliveries = new Set()
62
+ this.closed = false
63
+ this.handoffPaused = handoffPaused === true
64
+ if (this.handoffPaused) this.store.suppressPendingPrompts()
65
+ // A scene can be removed from the allowlist while the plugin is stopped.
66
+ // Retire any bound Session whose scene is no longer authorized before it
67
+ // can be reached by a tool or reused after the scene is re-added.
68
+ for (const row of Object.values(this.store.snapshot().scenes)) {
69
+ if (row.sessionId && !this.allowed(row)) this.#revokeDisallowedSession(row)
70
+ }
71
+ }
72
+
73
+ allowed(raw) {
74
+ const scene = sceneIdentity(raw)
75
+ return !this.policy.pausedScenes.has(sceneKey(scene)) && this.policy.botIds.has(scene.botId) &&
76
+ (scene.kind === 'group' ? this.policy.groupIds.has(scene.peerId) : this.policy.privateIds.has(scene.peerId))
77
+ }
78
+
79
+ #permissionRevision(row) { return row.permissions?.revision ?? 0 }
80
+
81
+ #can(scene, capability) {
82
+ if (!IMPLEMENTED_CAPABILITIES.has(capability)) return false
83
+ return this.permissions.evaluate(scene, this.store.getScenePermissions(scene), capability).allowed
84
+ }
85
+
86
+ /**
87
+ * Bind a Session only to the security posture that created it. Behavior
88
+ * preferences are deliberately excluded: they can change without replacing
89
+ * the DSH activity. A global policy epoch is also excluded because adding an
90
+ * unrelated allowlisted Scene must not interrupt existing conversations.
91
+ */
92
+ #securityFingerprint(scene, access = this.store.getScenePermissions(scene)) {
93
+ const ownerPreset = scene.kind === 'private' &&
94
+ this.permissions.ownerPrivateIds.has(scene.peerId) &&
95
+ this.permissions.evaluate(scene, access, 'qq.read').mode === 'owner-agent'
96
+ const decisions = CAPABILITY_CATALOG.map(capability => [
97
+ capability.id,
98
+ this.permissions.evaluate(scene, access, capability.id).allowed,
99
+ ])
100
+ return createHash('sha256').update(JSON.stringify({
101
+ scene: { botId: scene.botId, kind: scene.kind, peerId: scene.peerId },
102
+ sceneRevision: this.policy.sceneRevisions.get(sceneKey(scene)) ?? 0,
103
+ workspaceId: this.workspaceId ?? '',
104
+ agentPreset: ownerPreset ? 'amadeus-qq-owner' : 'amadeus-qq',
105
+ decisions,
106
+ })).digest('hex')
107
+ }
108
+
109
+ #bindingSecurityCurrent(row, scene = row) {
110
+ // Pre-1.1 bindings have no fingerprint. Their epoch still supplies the
111
+ // original fail-closed policy version check until a controlled update or
112
+ // a newly created Session records the stronger per-Scene fingerprint.
113
+ return row.boundSecurityFingerprint
114
+ ? row.boundSecurityFingerprint === this.#securityFingerprint(scene)
115
+ : row.authEpoch === this.policy.epoch
116
+ }
117
+
118
+ #revokeDisallowedSession(row) {
119
+ const sessionId = row?.sessionId
120
+ if (!sessionId) return
121
+ try {
122
+ this.store.retireSession(row, sessionId)
123
+ } catch {
124
+ // A concurrent state change already removed the binding; it is not
125
+ // authorized either way, so never restore it here.
126
+ return
127
+ }
128
+ this.#releaseWaiter(row, sessionId)
129
+ // The binding is already revoked and durable. Cancellation is best effort
130
+ // here because this path is synchronous; a failed cancel cannot reauthorize
131
+ // the retired Session.
132
+ try {
133
+ const result = this.dsh.cancelSceneSession?.(sessionId)
134
+ if (result && typeof result.then === 'function') result.catch(() => {})
135
+ } catch { /* revocation already recorded */ }
136
+ }
137
+
138
+ #requireAuthorizedSession(sessionId, capability = 'qq.read') {
139
+ if (this.closed) throw new Error('QQ plugin is stopped')
140
+ if (this.handoffPaused && capability.startsWith('qq.') &&
141
+ !['qq.read', 'qq.owner.tools', 'qq.owner.workspace'].includes(capability)) {
142
+ throw new Error('QQ_HANDOFF_PAUSED: Amadeus QQ is paused for another client')
143
+ }
144
+ const row = this.store.sceneForSession(sessionId)
145
+ if (!row) throw new Error('QQ_UNAUTHORIZED: Session or scene permission is invalid')
146
+ if (!this.allowed(row)) {
147
+ this.#revokeDisallowedSession(row)
148
+ throw new Error('QQ_UNAUTHORIZED: Session or scene permission is invalid')
149
+ }
150
+ if ((row.boundPermissionRevision ?? 0) !== this.#permissionRevision(row)) {
151
+ throw new Error('QQ_PERMISSION_CHANGED: old Session must be rebuilt after a Scene permission change')
152
+ }
153
+ if ((row.workspaceId ?? '') !== (this.workspaceId ?? '')) {
154
+ throw new Error('QQ_WORKSPACE_CHANGED: old Session belongs to another Workspace')
155
+ }
156
+ if (!this.#bindingSecurityCurrent(row)) {
157
+ throw new Error('QQ_PERMISSION_CHANGED: old Session must be rebuilt after a Scene permission change')
158
+ }
159
+ if (!this.#can(row, capability)) throw new Error(`QQ_PERMISSION_DENIED: ${capability}`)
160
+ return row
161
+ }
162
+
163
+ #serial(scene, work) {
164
+ const key = sceneKey(scene)
165
+ const previous = this.sceneChains.get(key) ?? Promise.resolve()
166
+ const task = previous.catch(() => {}).then(work)
167
+ this.sceneChains.set(key, task)
168
+ void task.finally(() => { if (this.sceneChains.get(key) === task) this.sceneChains.delete(key) }).catch(() => {})
169
+ return task
170
+ }
171
+
172
+ #releaseWaiter(scene, sessionId = null) {
173
+ const key = sceneKey(scene)
174
+ const waiter = this.waiters.get(key)
175
+ if (waiter && (sessionId === null || waiter.sessionId === sessionId)) {
176
+ this.waiters.delete(key)
177
+ waiter.resolve()
178
+ }
179
+ }
180
+
181
+ #shouldPrompt(scene, message, row) {
182
+ if (!this.#can(scene, 'qq.send')) return false
183
+ if (scene.kind === 'private') return true
184
+ if (message.atIds.includes(scene.botId)) return true
185
+ if (message.replyToMessageId && this.store.hasDelivered(row, message.replyToMessageId)) return true
186
+ const text = messageText(message)
187
+ if (this.policy.triggerNames.some(name => text.includes(name))) return true
188
+ if (!this.#autonomousGroup(scene)) return false
189
+ const cooldown = this.store.getScenePermissions(scene)?.behavior?.replyCooldownMs ?? 0
190
+ return !cooldown || !row?.lastAutoPromptAt || this.clock() - row.lastAutoPromptAt >= cooldown
191
+ }
192
+
193
+ #autonomousGroup(scene) {
194
+ return scene.kind === 'group' &&
195
+ this.permissions.evaluate(scene, this.store.getScenePermissions(scene), 'qq.read').mode === 'autonomous-social'
196
+ }
197
+
198
+ #behavior(scene) {
199
+ return this.store.getScenePermissions(scene)?.behavior ?? null
200
+ }
201
+
202
+ #shouldBatch(scene) {
203
+ return (this.#behavior(scene)?.quietWindowMs ?? 0) > 0
204
+ }
205
+
206
+ #schedulePromptBatch(scene, delay = this.#behavior(scene)?.quietWindowMs ?? AUTONOMOUS_QUIET_WINDOW_MS) {
207
+ if (this.closed || this.handoffPaused) return
208
+ const key = sceneKey(scene)
209
+ const prior = this.promptTimers.get(key)
210
+ if (prior) clearTimeout(prior.timer)
211
+ // Wait for a short quiet interval, but bound a busy room's first batch.
212
+ const firstAt = prior?.firstAt ?? this.clock()
213
+ const maxWindow = Math.max(delay * 2, AUTONOMOUS_QUIET_WINDOW_MS)
214
+ const remaining = Math.max(0, maxWindow - (this.clock() - firstAt))
215
+ const timer = setTimeout(() => {
216
+ this.promptTimers.delete(key)
217
+ void this.#serial(scene, () => this.#promptBatch(scene)).catch(() => {
218
+ // The durable batch remains pending with the same requestId. A later
219
+ // inbound event or restart can retry without inventing another turn.
220
+ })
221
+ }, Math.min(delay, remaining))
222
+ timer.unref?.()
223
+ this.promptTimers.set(key, { timer, firstAt })
224
+ }
225
+
226
+ async #promptBatch(scene) {
227
+ if (this.closed || this.handoffPaused) return null
228
+ if (!this.allowed(scene) || !this.#can(scene, 'qq.send')) return null
229
+ const seqs = this.store.reservePromptBatch(scene)
230
+ if (!seqs.length) return null
231
+ const sessionId = await this.#ensureSession(scene)
232
+ const row = this.store.getScene(scene)
233
+ const messages = seqs.map(seq => row.messages.find(item => item.seq === seq))
234
+ if (messages.some(item => !item)) throw new Error('QQ_PROMPT_BATCH_MISSING')
235
+ const key = sceneKey(scene)
236
+ const prompt = {
237
+ scene: { botId: scene.botId, kind: scene.kind, peerId: scene.peerId },
238
+ messages: messages.map(item => ({ ...item, segments: sanitizeInboundSegments(item.segments) })),
239
+ readThroughSeq: seqs.at(-1),
240
+ behavior: this.#behavior(scene) ?? undefined,
241
+ instruction: 'These messages are one current conversation. Decide whether to reply, use QQ tools, wait briefly for more messages, or remain silent. Use separate guarded sends only when natural. Plain assistant output is not QQ delivery. Life alone owns long-term autonomous wake.',
242
+ }
243
+ if (this.closed || this.handoffPaused) return null
244
+ await this.dsh.prompt({
245
+ sessionId,
246
+ requestId: `qq-${createHash('sha256').update(`${key}:batch:${seqs.join(',')}`).digest('hex')}`,
247
+ mode: 'queue',
248
+ content: [{ type: 'text', text: JSON.stringify(prompt) }],
249
+ })
250
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
251
+ this.store.markPromptBatch(scene, seqs)
252
+ if (this.#autonomousGroup(scene)) this.store.markAutoPromptAt(scene, this.clock())
253
+ if (this.store.getScene(scene).messages.some(item => item.needsPrompt && !item.prompted)) {
254
+ this.#schedulePromptBatch(scene, 0)
255
+ }
256
+ return sessionId
257
+ }
258
+
259
+ async setScenePermissions(rawScene, access, { expectedRevision } = {}) {
260
+ const scene = sceneIdentity(rawScene)
261
+ if (!this.allowed(scene)) throw new Error('QQ_UNAUTHORIZED: Scene is outside the configured allowlist')
262
+ if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) {
263
+ throw new TypeError('expectedRevision must be a non-negative integer')
264
+ }
265
+ const normalized = validateScenePermissions(access, { allowRevision: false })
266
+ // Owner Agent is a property of a verified owner private Scene, never of a
267
+ // group and never of an arbitrary private peer. Reject before persisting so
268
+ // a GUI cannot save and display an unusable high-privilege configuration.
269
+ if (normalized.mode === 'owner-agent' &&
270
+ (scene.kind !== 'private' || !this.permissions.ownerPrivateIds.has(scene.peerId))) {
271
+ const forbidden = new Error('QQ_OWNER_AGENT_FORBIDDEN: owner-agent requires a configured owner private Scene')
272
+ forbidden.code = 'QQ_OWNER_AGENT_FORBIDDEN'
273
+ throw forbidden
274
+ }
275
+ return this.#serial(scene, async () => {
276
+ const before = this.store.getScene(scene)
277
+ const currentRevision = before?.permissions?.revision ?? 0
278
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
279
+ const conflict = new Error('QQ_PERMISSION_CONFLICT: Scene permissions changed since the GUI read them')
280
+ conflict.code = 'QQ_PERMISSION_CONFLICT'
281
+ conflict.currentRevision = currentRevision
282
+ throw conflict
283
+ }
284
+ const beforeFingerprint = this.#securityFingerprint(scene, before?.permissions ?? null)
285
+ const bindingWasCurrent = Boolean(before?.sessionId) &&
286
+ (before.boundPermissionRevision ?? 0) === currentRevision &&
287
+ (before.workspaceId ?? '') === (this.workspaceId ?? '') &&
288
+ this.#bindingSecurityCurrent(before)
289
+ const permissions = this.store.setScenePermissions(scene, normalized)
290
+ const row = this.store.getScene(scene)
291
+ const securityChanged = this.#securityFingerprint(scene, row.permissions) !== beforeFingerprint
292
+ if (row.sessionId) {
293
+ if (securityChanged) {
294
+ if (typeof this.dsh.cancelSceneSession !== 'function') throw new Error('QQ_SESSION_CANCEL_UNAVAILABLE')
295
+ const cancelled = await this.dsh.cancelSceneSession(row.sessionId)
296
+ if (cancelled?.accepted !== true) throw new Error('QQ_SESSION_CANCEL_FAILED')
297
+ this.#releaseWaiter(scene, row.sessionId)
298
+ this.store.retireSession(scene, row.sessionId)
299
+ } else if (bindingWasCurrent) {
300
+ // A preference-only update still advances the permission revision.
301
+ // Rebind the same Session to that revision so a controlled behavior
302
+ // change is not mistaken for a direct, out-of-band mutation.
303
+ this.store.bindSession(scene, row.sessionId, this.policy.epoch, this.workspaceId,
304
+ this.#securityFingerprint(scene, row.permissions))
305
+ }
306
+ }
307
+ if (this.workspaceId && this.#can(scene, 'qq.read') && !this.store.getScene(scene).sessionId) {
308
+ await this.#ensureSession(scene)
309
+ }
310
+ return permissions
311
+ })
312
+ }
313
+
314
+ /**
315
+ * Safe metadata for the native management surface: only scenes already seen
316
+ * and inside the configured allowlist. Message bodies, credentials and
317
+ * configured paths never leave this Owner-authenticated boundary.
318
+ */
319
+ listScenePermissions() {
320
+ const snapshot = this.store.snapshot()
321
+ return Object.entries(snapshot.scenes)
322
+ .filter(([, row]) => this.policy.botIds.has(row.botId) &&
323
+ (row.kind === 'group' ? this.policy.groupIds.has(row.peerId) : this.policy.privateIds.has(row.peerId)))
324
+ .map(([key, row]) => {
325
+ const active = !this.handoffPaused && this.allowed(row)
326
+ const permissions = row.permissions ?? null
327
+ const access = permissions === null
328
+ ? null
329
+ : { mode: permissions.mode, overrides: permissions.overrides }
330
+ return {
331
+ key,
332
+ scene: { botId: row.botId, kind: row.kind, peerId: row.peerId },
333
+ permissions: permissions === null
334
+ ? null
335
+ : { mode: permissions.mode, overrides: { ...permissions.overrides },
336
+ ...(permissions.behavior ? { behavior: { ...permissions.behavior } } : {}), revision: permissions.revision },
337
+ revision: permissions?.revision ?? 0,
338
+ sessionId: row.sessionId,
339
+ workspaceId: row.workspaceId ?? '',
340
+ effectiveMode: this.permissions.evaluate(row, access, 'qq.read').mode,
341
+ ownerEligible: row.kind === 'private' && this.permissions.ownerPrivateIds.has(row.peerId),
342
+ capabilities: CAPABILITY_CATALOG.map(capability => {
343
+ const decision = this.permissions.evaluate(row, access, capability.id)
344
+ return {
345
+ id: capability.id,
346
+ implemented: capability.implemented,
347
+ override: access?.overrides?.[capability.id] ?? 'inherit',
348
+ effective: active && capability.implemented === true && decision.allowed,
349
+ reason: this.handoffPaused ? 'handoff-paused' : !active ? 'scene-paused' : capability.implemented === true ? decision.reason : 'unimplemented',
350
+ }
351
+ }),
352
+ health: {
353
+ sessionActive: Boolean(row.sessionId),
354
+ transport: this.transport.status ?? 'unknown',
355
+ },
356
+ activity: {
357
+ observedCount: row.messages.length,
358
+ // Only stable QQ IDs observed in this authorized Scene. Do not
359
+ // expose names, bodies, media locators or a platform-wide roster.
360
+ participantIds: [...new Set(row.messages.slice(-500).reverse()
361
+ .map(item => item.senderId)
362
+ .filter(id => typeof id === 'string' && /^[1-9]\d{0,19}$/.test(id)))].slice(0, 20),
363
+ },
364
+ unresolvedDeliveries: row.deliveries
365
+ .filter(delivery => delivery.status === 'unknown')
366
+ .map(delivery => ({
367
+ actionId: delivery.actionId,
368
+ status: delivery.status,
369
+ updatedAt: delivery.updatedAt,
370
+ })),
371
+ }
372
+ })
373
+ }
374
+
375
+ /**
376
+ * Read-only Host contract for cooperating plugins such as Persona. Enumerate
377
+ * current, readable allowlisted Scenes even before any message or Session
378
+ * exists. This never provisions a Session, exposes credentials or grants QQ
379
+ * permission. A paused, revoked or qq.read-denied Scene is omitted at once.
380
+ */
381
+ listAuthorizedScenes() {
382
+ if (this.closed || this.handoffPaused || !this.workspaceId) return Object.freeze([])
383
+ const scenes = []
384
+ for (const botId of this.policy.botIds) {
385
+ for (const [kind, peers] of [['group', this.policy.groupIds], ['private', this.policy.privateIds]]) {
386
+ for (const peerId of peers) {
387
+ const scene = { botId, kind, peerId }
388
+ if (!this.allowed(scene) || !this.#can(scene, 'qq.read')) continue
389
+ scenes.push(Object.freeze({
390
+ platform: 'qq', kind, id: `${botId}:${peerId}`,
391
+ workspaceId: this.workspaceId, authorized: true,
392
+ }))
393
+ }
394
+ }
395
+ }
396
+ return Object.freeze(scenes)
397
+ }
398
+
399
+ /**
400
+ * Explicit owner reconciliation for one settled unknown send. It only closes
401
+ * the ledger entry as failed; it never sends content, changes permissions, or
402
+ * touches an action whose transport call is still in flight.
403
+ */
404
+ resolveDelivery(rawScene, actionId, outcome = 'failed') {
405
+ const scene = sceneIdentity(rawScene)
406
+ if (!this.allowed(scene)) {
407
+ const error = new Error('QQ_SCENE_UNAUTHORIZED')
408
+ error.code = 'QQ_SCENE_UNAUTHORIZED'
409
+ throw error
410
+ }
411
+ if (!this.store.getScene(scene)) {
412
+ const error = new Error('QQ_DELIVERY_NOT_FOUND')
413
+ error.code = 'QQ_DELIVERY_NOT_FOUND'
414
+ throw error
415
+ }
416
+ if (outcome !== 'failed') throw new TypeError('only failed reconciliation is supported')
417
+ if (this.inFlightDeliveries.has(`${sceneKey(scene)}:${actionId}`)) {
418
+ const error = new Error('QQ_DELIVERY_IN_FLIGHT')
419
+ error.code = 'QQ_DELIVERY_IN_FLIGHT'
420
+ throw error
421
+ }
422
+ return this.store.resolveUnknownDelivery(scene, actionId, 'failed')
423
+ }
424
+
425
+ async #ensureSession(scene) {
426
+ if (this.closed || this.handoffPaused || !this.allowed(scene) || !this.#can(scene, 'qq.read')) {
427
+ throw new Error('QQ_UNAUTHORIZED: Scene is no longer active')
428
+ }
429
+ let row = this.store.getScene(scene)
430
+ if (row?.sessionId) {
431
+ if ((row.boundPermissionRevision ?? 0) !== this.#permissionRevision(row) ||
432
+ !this.#bindingSecurityCurrent(row, scene) ||
433
+ (row.workspaceId ?? '') !== (this.workspaceId ?? '')) {
434
+ if (typeof this.dsh.cancelSceneSession !== 'function') throw new Error('QQ_SESSION_CANCEL_UNAVAILABLE')
435
+ const cancelled = await this.dsh.cancelSceneSession(row.sessionId)
436
+ if (cancelled?.accepted !== true) throw new Error('QQ_SESSION_CANCEL_FAILED')
437
+ this.#releaseWaiter(scene, row.sessionId)
438
+ this.store.retireSession(scene, row.sessionId)
439
+ row = this.store.getScene(scene)
440
+ } else {
441
+ // A matching permission/workspace fingerprint does not prove the Host
442
+ // still knows this Session. Only an explicit false means missing; null
443
+ // (older Host mock without a lookup API) keeps prior behavior, while
444
+ // errors propagate and never trigger an identity rebuild.
445
+ const exists = typeof this.dsh.sessionExists === 'function'
446
+ ? await this.dsh.sessionExists(row.sessionId)
447
+ : null
448
+ if (exists !== false) return row.sessionId
449
+ if (typeof this.dsh.cancelSceneSession !== 'function') throw new Error('QQ_SESSION_CANCEL_UNAVAILABLE')
450
+ const cancelled = await this.dsh.cancelSceneSession(row.sessionId)
451
+ if (cancelled?.accepted !== true) throw new Error('QQ_SESSION_CANCEL_FAILED')
452
+ this.#releaseWaiter(scene, row.sessionId)
453
+ this.store.retireSession(scene, row.sessionId)
454
+ row = this.store.getScene(scene)
455
+ }
456
+ }
457
+ const revision = this.#permissionRevision(row ?? {})
458
+ const fingerprint = this.#securityFingerprint(scene)
459
+ const created = await this.dsh.createSceneSession(scene, {
460
+ searchAllowed: this.#can(scene, 'dsh.search'),
461
+ mediaAllowed: this.#can(scene, 'qq.media.read'),
462
+ lifeAllowed: this.#can(scene, LIFE_READ_CAPABILITY),
463
+ ownerAgent: scene.kind === 'private' &&
464
+ this.permissions.ownerPrivateIds.has(scene.peerId) &&
465
+ this.permissions.evaluate(scene, this.store.getScenePermissions(scene), 'qq.read').mode === 'owner-agent',
466
+ ownerWorkspaceAllowed: this.#can(scene, 'qq.owner.workspace'),
467
+ })
468
+ const sessionId = typeof created === 'string' ? created : created?.sessionId
469
+ if (!sessionId) throw new Error('DSH did not return a Session ID')
470
+ row = this.store.getScene(scene)
471
+ if (this.closed || this.handoffPaused || this.#permissionRevision(row ?? {}) !== revision ||
472
+ this.#securityFingerprint(scene) !== fingerprint || !this.#can(scene, 'qq.read') || !this.allowed(scene)) {
473
+ this.store.recordRetiredSession(sessionId)
474
+ await this.dsh.cancelSceneSession?.(sessionId)
475
+ throw new Error('QQ_PERMISSION_CHANGED: Scene changed during Session creation')
476
+ }
477
+ row = this.store.bindSession(scene, sessionId, this.policy.epoch, this.workspaceId, fingerprint)
478
+ return row.sessionId
479
+ }
480
+
481
+ /** Materialize authorized Workspace members even before the first message. */
482
+ async provisionAuthorizedScenes() {
483
+ if (this.handoffPaused || !this.workspaceId) return { provisioned: 0 }
484
+ let provisioned = 0
485
+ for (const botId of this.policy.botIds) {
486
+ for (const [kind, peers] of [['group', this.policy.groupIds], ['private', this.policy.privateIds]]) {
487
+ for (const peerId of peers) {
488
+ if (this.closed || this.handoffPaused) return { provisioned }
489
+ const scene = { botId, kind, peerId }
490
+ if (!this.allowed(scene) || !this.#can(scene, 'qq.read')) continue
491
+ await this.#serial(scene, () => this.#ensureSession(scene))
492
+ provisioned++
493
+ }
494
+ }
495
+ }
496
+ return { provisioned }
497
+ }
498
+
499
+ async #promptMessage(scene, seq) {
500
+ if (this.closed || this.handoffPaused) return null
501
+ const key = sceneKey(scene)
502
+ const sessionId = await this.#ensureSession(scene)
503
+ const stored = this.store.getScene(scene).messages.find(item => item.seq === seq)
504
+ if (!stored) throw new Error('QQ message is no longer in the durable inbox')
505
+ const prompt = {
506
+ scene: { botId: scene.botId, kind: scene.kind, peerId: scene.peerId },
507
+ messages: [{ ...stored, segments: sanitizeInboundSegments(stored.segments) }],
508
+ readThroughSeq: seq,
509
+ behavior: this.#behavior(scene) ?? undefined,
510
+ instruction: 'This is one current conversation. Use the activity preference only when deciding whether to participate. Use QQ tools for any QQ send. You may send, continue using tools, wait briefly for messages, or remain silent. Plain assistant output is not a QQ delivery. Life alone owns long-term autonomous wake.',
511
+ }
512
+ if (this.closed || this.handoffPaused) return null
513
+ await this.dsh.prompt({
514
+ sessionId,
515
+ requestId: `qq-${createHash('sha256').update(`${key}:${seq}`).digest('hex')}`,
516
+ mode: 'queue',
517
+ content: [{ type: 'text', text: JSON.stringify(prompt) }],
518
+ })
519
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
520
+ this.store.markPrompted(scene, seq)
521
+ return sessionId
522
+ }
523
+
524
+ async ingest(raw) {
525
+ if (this.closed || this.handoffPaused) return { status: 'paused' }
526
+ const normalized = normalizeOneBotMessage(raw)
527
+ if (!normalized) return { status: 'ignored' }
528
+ const { scene, message } = normalized
529
+ if (!this.allowed(scene)) {
530
+ const row = this.store.getScene(scene)
531
+ if (row?.sessionId) this.#revokeDisallowedSession(row)
532
+ return { status: 'unauthorized' }
533
+ }
534
+ if (message.senderId === scene.botId) return { status: 'self_ignored' }
535
+ return this.#serial(scene, async () => {
536
+ if (this.closed || this.handoffPaused) return { status: 'paused' }
537
+ const before = this.store.getScene(scene)
538
+ const key = sceneKey(scene)
539
+ let waiter = this.waiters.get(key)
540
+ if (waiter && (waiter.sessionId !== before?.sessionId ||
541
+ (before.boundPermissionRevision ?? 0) !== this.#permissionRevision(before))) {
542
+ this.#releaseWaiter(scene, waiter.sessionId)
543
+ waiter = null
544
+ }
545
+ const needsPrompt = this.#can(scene, 'qq.send') && (Boolean(waiter) || this.#shouldPrompt(scene, message, before))
546
+ const inserted = this.store.appendMessage(scene, { ...message, needsPrompt })
547
+ if (inserted.duplicate) return { status: 'duplicate' }
548
+ if (inserted.dropped) return { status: 'dropped', reason: inserted.dropped }
549
+ const row = this.store.getScene(scene)
550
+ if (waiter && inserted.seq > waiter.afterSeq) {
551
+ waiter.resolve()
552
+ return { status: 'continued', seq: inserted.seq, sessionId: row.sessionId }
553
+ }
554
+ if (!needsPrompt) return { status: 'stored', seq: inserted.seq }
555
+ if (this.#shouldBatch(scene) || this.#autonomousGroup(scene)) {
556
+ this.#schedulePromptBatch(scene)
557
+ return { status: 'queued', seq: inserted.seq }
558
+ }
559
+ const sessionId = await this.#promptMessage(scene, inserted.seq)
560
+ return { status: 'prompted', seq: inserted.seq, sessionId }
561
+ })
562
+ }
563
+
564
+ async resumePending() {
565
+ if (this.closed || this.handoffPaused) return { resumed: 0 }
566
+ const results = []
567
+ for (const row of Object.values(this.store.snapshot().scenes)) {
568
+ if (!this.allowed(row) || !this.#can(row, 'qq.send')) continue
569
+ if (this.#shouldBatch(row) || this.#autonomousGroup(row)) {
570
+ if (row.messages.some(item => item.needsPrompt && !item.prompted)) {
571
+ results.push(await this.#serial(row, () => this.#promptBatch(row)))
572
+ }
573
+ continue
574
+ }
575
+ for (const message of row.messages) {
576
+ if (!message.needsPrompt || message.prompted === true) continue
577
+ results.push(await this.#serial(row, () => this.#promptMessage(row, message.seq)))
578
+ }
579
+ }
580
+ return { resumed: results.length }
581
+ }
582
+
583
+ read(sessionId, { afterSeq = 0, limit = 50 } = {}) {
584
+ const row = this.#requireAuthorizedSession(sessionId)
585
+ const budget = this.#behavior(row)?.contextBudget
586
+ if (budget) limit = Math.min(limit, budget)
587
+ const result = this.store.unread(row, afterSeq, limit)
588
+ if (result.messages.length) this.store.markPresented(row, result.readThroughSeq)
589
+ // Older development state files may contain raw OneBot media locators.
590
+ // Apply the same projection at both model-facing exits without changing
591
+ // the durable cursor or silently rewriting a pre-existing state file.
592
+ return { ...result, messages: result.messages.map(message => ({
593
+ ...message, segments: sanitizeInboundSegments(message.segments),
594
+ })) }
595
+ }
596
+
597
+ markRead(sessionId, throughSeq) {
598
+ const row = this.#requireAuthorizedSession(sessionId)
599
+ return { readThroughSeq: this.store.markRead(row, throughSeq) }
600
+ }
601
+
602
+ async wait(sessionId, { afterSeq, timeoutMs = DEFAULT_WAIT_MS } = {}) {
603
+ if (this.handoffPaused) throw new Error('QQ_HANDOFF_PAUSED: QQ message waiting is paused')
604
+ const row = this.#requireAuthorizedSession(sessionId)
605
+ const behavior = this.#behavior(row)
606
+ if (behavior?.waitTimeoutMs === 0) throw new Error('QQ_PERMISSION_DENIED: observe cannot wait for conversation')
607
+ if (behavior) timeoutMs = Math.min(timeoutMs, behavior.waitTimeoutMs)
608
+ afterSeq ??= row.presentedSeq
609
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new TypeError('invalid afterSeq')
610
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > MAX_WAIT_MS) throw new TypeError('invalid timeoutMs')
611
+ const immediate = this.read(sessionId, { afterSeq })
612
+ if (immediate.messages.length) return { ...immediate, arrived: true, timeout: false }
613
+ const key = sceneKey(row)
614
+ if (this.waiters.has(key)) throw new Error('QQ_WAIT_BUSY: one wait is already active in this scene')
615
+ let waiter
616
+ await new Promise(resolve => {
617
+ const timer = setTimeout(resolve, timeoutMs)
618
+ waiter = { sessionId, afterSeq, resolve: () => { clearTimeout(timer); resolve() } }
619
+ this.waiters.set(key, waiter)
620
+ }).finally(() => { if (this.waiters.get(key) === waiter) this.waiters.delete(key) })
621
+ this.#requireAuthorizedSession(sessionId)
622
+ const result = this.read(sessionId, { afterSeq })
623
+ // An event handed to a live wait is already in this DSH activity. Leave
624
+ // the durable replay flag only when it was not actually returned to the
625
+ // tool; otherwise resumePending would start a duplicate turn on restart.
626
+ for (const message of result.messages) {
627
+ if (message.needsPrompt && !message.prompted) this.store.markPrompted(row, message.seq)
628
+ }
629
+ return { ...result, arrived: result.messages.length > 0, timeout: result.messages.length === 0 }
630
+ }
631
+
632
+ async send(sessionId, args) {
633
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
634
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new TypeError('send arguments must be an object')
635
+ const { actionId } = args
636
+ const segments = validateOutgoingSegments(args.segments)
637
+ for (const segment of segments) {
638
+ if (segment.type === 'reply') {
639
+ const target = this.store.getScene(row).messages.some(item => item.messageId === segment.data.id)
640
+ if (!target && !this.store.hasDelivered(row, segment.data.id)) {
641
+ throw new Error('QQ_REPLY_SCOPE: reply target is not in this scene')
642
+ }
643
+ }
644
+ if (segment.type === 'at') {
645
+ if (row.kind !== 'group' || !this.store.getScene(row).messages.some(item => item.senderId === segment.data.qq)) {
646
+ throw new Error('QQ_AT_SCOPE: mention target is not a known sender in this group')
647
+ }
648
+ }
649
+ }
650
+ const fingerprint = createHash('sha256').update(JSON.stringify({ scene: sceneKey(row), segments })).digest('hex')
651
+ const reserved = this.store.reserveDelivery(row, actionId, fingerprint)
652
+ if (!reserved.fresh) return { status: reserved.delivery.status, repeated: true, messageId: reserved.delivery.platformMessageId }
653
+ // The reservation write and the permission check are not one atomic step.
654
+ // Re-read the Session binding and qq.send immediately before the external
655
+ // call; a revoked Scene must not reach OneBot. No request was made here, so
656
+ // the reservation is settled as a terminal failure instead of lingering as
657
+ // an unsettled action that would consume outbox capacity forever.
658
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
659
+ this.inFlightDeliveries.add(inFlightKey)
660
+ try {
661
+ try {
662
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
663
+ } catch (error) {
664
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep the permission failure */ }
665
+ throw error
666
+ }
667
+ try {
668
+ const receipt = await this.transport.send(row, segments)
669
+ const messageId = normalizeMessageId(receipt.messageId)
670
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', messageId)
671
+ return { status: delivery.status, repeated: false, messageId }
672
+ } catch (error) {
673
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
674
+ this.store.settleDelivery(row, actionId, status)
675
+ return { status, repeated: false, messageId: null }
676
+ }
677
+ } finally {
678
+ this.inFlightDeliveries.delete(inFlightKey)
679
+ }
680
+ }
681
+
682
+ async sendPreparedMedia(sessionId, actionId, plan) {
683
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
684
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
685
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan) ||
686
+ plan.mode !== 'message' || !/^[a-f0-9]{64}$/.test(plan.fingerprint) ||
687
+ !Array.isArray(plan.segments) || plan.segments.length !== 1) {
688
+ throw new TypeError('invalid outbound media plan')
689
+ }
690
+ const segment = plan.segments[0]
691
+ if (!segment || !['image', 'video', 'record'].includes(segment.type) ||
692
+ !segment.data || typeof segment.data.file !== 'string' || !segment.data.file) {
693
+ throw new TypeError('invalid outbound media segment')
694
+ }
695
+ const ledgerFingerprint = createHash('sha256').update(JSON.stringify({
696
+ operation: 'send-media',
697
+ scene: sceneKey(row),
698
+ segmentType: segment.type,
699
+ mediaType: typeof plan.mediaType === 'string' ? plan.mediaType : null,
700
+ contentHash: plan.fingerprint,
701
+ })).digest('hex')
702
+ const reserved = this.store.reserveDelivery(row, actionId, ledgerFingerprint)
703
+ if (!reserved.fresh) {
704
+ return {
705
+ status: reserved.delivery.status,
706
+ repeated: true,
707
+ messageId: reserved.delivery.platformMessageId,
708
+ }
709
+ }
710
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
711
+ this.inFlightDeliveries.add(inFlightKey)
712
+ try {
713
+ try {
714
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
715
+ } catch (error) {
716
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
717
+ throw error
718
+ }
719
+ try {
720
+ const receipt = await this.transport.send(row, plan.segments)
721
+ const messageId = normalizeMessageId(receipt.messageId)
722
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', messageId)
723
+ return { status: delivery.status, repeated: false, messageId }
724
+ } catch (error) {
725
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
726
+ this.store.settleDelivery(row, actionId, status)
727
+ return { status, repeated: false, messageId: null }
728
+ }
729
+ } finally {
730
+ this.inFlightDeliveries.delete(inFlightKey)
731
+ }
732
+ }
733
+
734
+ async uploadSceneFile(sessionId, actionId, plan) {
735
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
736
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
737
+ const expectedAction = row.kind === 'group' ? 'upload_group_file' : 'upload_private_file'
738
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan) ||
739
+ plan.mode !== 'upload' || plan.action !== expectedAction ||
740
+ !/^[a-f0-9]{64}$/.test(plan.fingerprint) ||
741
+ !plan.params || typeof plan.params !== 'object' || Array.isArray(plan.params) ||
742
+ typeof plan.params.file !== 'string' || !path.isAbsolute(plan.params.file) ||
743
+ typeof plan.params.name !== 'string' || !plan.params.name ||
744
+ plan.params.upload_file !== true) {
745
+ throw new TypeError('invalid outbound upload plan')
746
+ }
747
+ const target = row.kind === 'group' ? plan.params.group_id : plan.params.user_id
748
+ if (String(target) !== String(row.peerId)) throw new Error('QQ_UPLOAD_SCOPE')
749
+ const ledgerFingerprint = createHash('sha256').update(JSON.stringify({
750
+ operation: 'upload-file',
751
+ scene: sceneKey(row),
752
+ action: plan.action,
753
+ name: plan.params.name,
754
+ mediaType: typeof plan.mediaType === 'string' ? plan.mediaType : null,
755
+ contentHash: plan.fingerprint,
756
+ })).digest('hex')
757
+ const reserved = this.store.reserveDelivery(row, actionId, ledgerFingerprint)
758
+ if (!reserved.fresh) {
759
+ return {
760
+ status: reserved.delivery.status,
761
+ repeated: true,
762
+ fileId: reserved.delivery.platformFileId ?? null,
763
+ }
764
+ }
765
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
766
+ this.inFlightDeliveries.add(inFlightKey)
767
+ try {
768
+ try {
769
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
770
+ } catch (error) {
771
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
772
+ throw error
773
+ }
774
+ // NapCat may complete a group upload but return no file_id. Its root
775
+ // listing also rotates file_id on every read, so compare stable file
776
+ // metadata and counts rather than treating a new id as a new file.
777
+ const rootFiles = async () => {
778
+ try {
779
+ const result = await this.transport.action('get_group_root_files',
780
+ { group_id: row.peerId }, 10_000, 2 * 1024 * 1024)
781
+ return Array.isArray(result?.files) && result.files.length <= 2000 ? result.files : null
782
+ } catch { return null }
783
+ }
784
+ const beforeFiles = row.kind === 'group' ? await rootFiles() : null
785
+ try {
786
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
787
+ } catch (error) {
788
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
789
+ throw error
790
+ }
791
+ try {
792
+ const result = await this.transport.action(plan.action, plan.params)
793
+ let fileId = typeof result?.file_id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(result.file_id)
794
+ ? result.file_id
795
+ : null
796
+ if (fileId === null && beforeFiles !== null && Number.isSafeInteger(plan.bytes) && plan.bytes > 0) {
797
+ const afterFiles = await rootFiles()
798
+ if (afterFiles !== null) {
799
+ const isMatch = file =>
800
+ typeof file?.file_id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(file.file_id) &&
801
+ String(file.group_id) === row.peerId &&
802
+ file.file_name === plan.params.name && Number(file.file_size ?? file.size) === plan.bytes &&
803
+ String(file.uploader) === row.botId
804
+ const beforeMatches = beforeFiles.filter(isMatch)
805
+ const afterMatches = afterFiles.filter(isMatch)
806
+ if (afterMatches.length === beforeMatches.length + 1) {
807
+ // A unique name/size/uploader is sufficient. For an existing
808
+ // same-name file, require a fresh unique platform modify_time;
809
+ // same-second or missing timestamps remain unknown.
810
+ const candidate = beforeMatches.length === 0 ? afterMatches[0] : (() => {
811
+ const oldTimes = new Set(beforeMatches.map(file => Number(file.modify_time)))
812
+ const fresh = afterMatches.filter(file => Number.isSafeInteger(Number(file.modify_time)) &&
813
+ Number(file.modify_time) > 0 && !oldTimes.has(Number(file.modify_time)))
814
+ return fresh.length === 1 ? fresh[0] : null
815
+ })()
816
+ if (candidate) fileId = candidate.file_id
817
+ }
818
+ }
819
+ }
820
+ if (fileId === null) {
821
+ this.store.settleDelivery(row, actionId, 'unknown')
822
+ return { status: 'unknown', repeated: false, fileId: null }
823
+ }
824
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', null, fileId)
825
+ return { status: delivery.status, repeated: false, fileId }
826
+ } catch (error) {
827
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
828
+ this.store.settleDelivery(row, actionId, status)
829
+ return { status, repeated: false, fileId: null }
830
+ }
831
+ } finally {
832
+ this.inFlightDeliveries.delete(inFlightKey)
833
+ }
834
+ }
835
+
836
+ health() {
837
+ return { enabled: !this.closed, handoffPaused: this.handoffPaused, transport: this.transport.status ?? 'unknown', scenes: Object.keys(this.store.snapshot().scenes).length, activeWaits: this.waiters.size }
838
+ }
839
+
840
+ /** Stop admission before the DSH config editor reloads this plugin. Existing
841
+ * Session bindings remain durable; only stale wake work is dismissed. */
842
+ async pauseForHandoff() {
843
+ let suppressed = 0
844
+ if (!this.handoffPaused) {
845
+ this.handoffPaused = true
846
+ for (const { timer } of this.promptTimers.values()) clearTimeout(timer)
847
+ this.promptTimers.clear()
848
+ for (const waiter of this.waiters.values()) waiter.resolve()
849
+ this.waiters.clear()
850
+ suppressed = this.store.suppressPendingPrompts()
851
+ this.transport.stop?.()
852
+ }
853
+ // An aborted HTTP request can have an unknown platform outcome. Wait for
854
+ // its local ledger settlement before the config edit reloads this store.
855
+ const inFlight = this.inFlightDeliveries.size
856
+ const deadline = Date.now() + 5_000
857
+ while (this.inFlightDeliveries.size || this.transport.pendingActions?.size) {
858
+ if (Date.now() >= deadline) throw new Error('QQ_HANDOFF_BUSY: an outgoing action has not settled')
859
+ await new Promise(resolve => setTimeout(resolve, 20))
860
+ }
861
+ return { suppressed, inFlight }
862
+ }
863
+
864
+ statusFor(sessionId) {
865
+ this.#requireAuthorizedSession(sessionId)
866
+ return this.health()
867
+ }
868
+
869
+ authorizedScene(sessionId) {
870
+ const row = this.#requireAuthorizedSession(sessionId)
871
+ return { botId: row.botId, kind: row.kind, peerId: row.peerId }
872
+ }
873
+
874
+ /**
875
+ * Synchronous execution-time capability check for the connector's Host
876
+ * service. The Scene comes only from the persisted Session binding; callers
877
+ * never supply it. Any stale binding, revision, allowlist or capability
878
+ * failure is reported as false.
879
+ */
880
+ authorizeSessionCapability(sessionId, capability) {
881
+ try {
882
+ this.#requireAuthorizedSession(sessionId, capability)
883
+ return true
884
+ } catch {
885
+ return false
886
+ }
887
+ }
888
+
889
+ trustedSceneForSession(sessionId) {
890
+ const row = this.#requireAuthorizedSession(sessionId)
891
+ return Object.freeze({
892
+ platform: 'qq',
893
+ kind: row.kind,
894
+ id: `${row.botId}:${row.peerId}`,
895
+ verified: true,
896
+ admin: row.kind === 'private' && this.permissions.ownerPrivateIds.has(row.peerId),
897
+ })
898
+ }
899
+
900
+ close() {
901
+ this.closed = true
902
+ for (const { timer } of this.promptTimers.values()) clearTimeout(timer)
903
+ this.promptTimers.clear()
904
+ for (const waiter of this.waiters.values()) waiter.resolve()
905
+ this.waiters.clear()
906
+ this.transport.stop?.()
907
+ }
908
+ }
909
+