@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -3
  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 +26 -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/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  26. package/lib/client.js +380 -0
  27. package/package.json +91 -6
  28. package/preset.patch.yml +103 -0
  29. package/screenshots.json +6 -0
  30. package/scripts/build-client.mjs +52 -0
  31. package/scripts/registry-guard.mjs +12 -0
  32. package/scripts/release-audit.mjs +59 -0
  33. package/scripts/symlink-security.mjs +8 -0
  34. package/src/account-profile.js +223 -0
  35. package/src/admin-http.js +361 -0
  36. package/src/audio.js +428 -0
  37. package/src/capabilities.js +785 -0
  38. package/src/client/factory.template.js +82 -0
  39. package/src/client/logic.js +290 -0
  40. package/src/config.js +48 -0
  41. package/src/console/console.css +28 -0
  42. package/src/console/console.js +538 -0
  43. package/src/console/index.html +67 -0
  44. package/src/core.js +892 -0
  45. package/src/files.js +230 -0
  46. package/src/index.js +867 -0
  47. package/src/lease.js +42 -0
  48. package/src/media.js +246 -0
  49. package/src/napcat-action-names.js +186 -0
  50. package/src/native-asr.js +112 -0
  51. package/src/onebot.js +378 -0
  52. package/src/outbound-media.js +309 -0
  53. package/src/owner-workspace.js +394 -0
  54. package/src/permissions.js +250 -0
  55. package/src/preset-guard.mjs +579 -0
  56. package/src/qzone-provider.js +628 -0
  57. package/src/session-provenance.js +74 -0
  58. package/src/setup.js +255 -0
  59. package/src/storage.js +580 -0
  60. package/src/web-console.js +9 -0
package/src/core.js ADDED
@@ -0,0 +1,892 @@
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, 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
+ return row.sessionId
442
+ }
443
+ }
444
+ const revision = this.#permissionRevision(row ?? {})
445
+ const fingerprint = this.#securityFingerprint(scene)
446
+ const created = await this.dsh.createSceneSession(scene, {
447
+ searchAllowed: this.#can(scene, 'dsh.search'),
448
+ mediaAllowed: this.#can(scene, 'qq.media.read'),
449
+ ownerAgent: scene.kind === 'private' &&
450
+ this.permissions.ownerPrivateIds.has(scene.peerId) &&
451
+ this.permissions.evaluate(scene, this.store.getScenePermissions(scene), 'qq.read').mode === 'owner-agent',
452
+ ownerWorkspaceAllowed: this.#can(scene, 'qq.owner.workspace'),
453
+ })
454
+ const sessionId = typeof created === 'string' ? created : created?.sessionId
455
+ if (!sessionId) throw new Error('DSH did not return a Session ID')
456
+ row = this.store.getScene(scene)
457
+ if (this.closed || this.handoffPaused || this.#permissionRevision(row ?? {}) !== revision ||
458
+ this.#securityFingerprint(scene) !== fingerprint || !this.#can(scene, 'qq.read') || !this.allowed(scene)) {
459
+ this.store.recordRetiredSession(sessionId)
460
+ await this.dsh.cancelSceneSession?.(sessionId)
461
+ throw new Error('QQ_PERMISSION_CHANGED: Scene changed during Session creation')
462
+ }
463
+ row = this.store.bindSession(scene, sessionId, this.policy.epoch, this.workspaceId, fingerprint)
464
+ return row.sessionId
465
+ }
466
+
467
+ /** Materialize authorized Workspace members even before the first message. */
468
+ async provisionAuthorizedScenes() {
469
+ if (this.handoffPaused || !this.workspaceId) return { provisioned: 0 }
470
+ let provisioned = 0
471
+ for (const botId of this.policy.botIds) {
472
+ for (const [kind, peers] of [['group', this.policy.groupIds], ['private', this.policy.privateIds]]) {
473
+ for (const peerId of peers) {
474
+ if (this.closed || this.handoffPaused) return { provisioned }
475
+ const scene = { botId, kind, peerId }
476
+ if (!this.allowed(scene) || !this.#can(scene, 'qq.read')) continue
477
+ await this.#serial(scene, () => this.#ensureSession(scene))
478
+ provisioned++
479
+ }
480
+ }
481
+ }
482
+ return { provisioned }
483
+ }
484
+
485
+ async #promptMessage(scene, seq) {
486
+ if (this.closed || this.handoffPaused) return null
487
+ const key = sceneKey(scene)
488
+ const sessionId = await this.#ensureSession(scene)
489
+ const stored = this.store.getScene(scene).messages.find(item => item.seq === seq)
490
+ if (!stored) throw new Error('QQ message is no longer in the durable inbox')
491
+ const prompt = {
492
+ scene: { botId: scene.botId, kind: scene.kind, peerId: scene.peerId },
493
+ messages: [{ ...stored, segments: sanitizeInboundSegments(stored.segments) }],
494
+ readThroughSeq: seq,
495
+ behavior: this.#behavior(scene) ?? undefined,
496
+ 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.',
497
+ }
498
+ if (this.closed || this.handoffPaused) return null
499
+ await this.dsh.prompt({
500
+ sessionId,
501
+ requestId: `qq-${createHash('sha256').update(`${key}:${seq}`).digest('hex')}`,
502
+ mode: 'queue',
503
+ content: [{ type: 'text', text: JSON.stringify(prompt) }],
504
+ })
505
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
506
+ this.store.markPrompted(scene, seq)
507
+ return sessionId
508
+ }
509
+
510
+ async ingest(raw) {
511
+ if (this.closed || this.handoffPaused) return { status: 'paused' }
512
+ const normalized = normalizeOneBotMessage(raw)
513
+ if (!normalized) return { status: 'ignored' }
514
+ const { scene, message } = normalized
515
+ if (!this.allowed(scene)) {
516
+ const row = this.store.getScene(scene)
517
+ if (row?.sessionId) this.#revokeDisallowedSession(row)
518
+ return { status: 'unauthorized' }
519
+ }
520
+ if (message.senderId === scene.botId) return { status: 'self_ignored' }
521
+ return this.#serial(scene, async () => {
522
+ if (this.closed || this.handoffPaused) return { status: 'paused' }
523
+ const before = this.store.getScene(scene)
524
+ const key = sceneKey(scene)
525
+ let waiter = this.waiters.get(key)
526
+ if (waiter && (waiter.sessionId !== before?.sessionId ||
527
+ (before.boundPermissionRevision ?? 0) !== this.#permissionRevision(before))) {
528
+ this.#releaseWaiter(scene, waiter.sessionId)
529
+ waiter = null
530
+ }
531
+ const needsPrompt = this.#can(scene, 'qq.send') && (Boolean(waiter) || this.#shouldPrompt(scene, message, before))
532
+ const inserted = this.store.appendMessage(scene, { ...message, needsPrompt })
533
+ if (inserted.duplicate) return { status: 'duplicate' }
534
+ if (inserted.dropped) return { status: 'dropped', reason: inserted.dropped }
535
+ const row = this.store.getScene(scene)
536
+ if (waiter && inserted.seq > waiter.afterSeq) {
537
+ waiter.resolve()
538
+ return { status: 'continued', seq: inserted.seq, sessionId: row.sessionId }
539
+ }
540
+ if (!needsPrompt) return { status: 'stored', seq: inserted.seq }
541
+ if (this.#shouldBatch(scene) || this.#autonomousGroup(scene)) {
542
+ this.#schedulePromptBatch(scene)
543
+ return { status: 'queued', seq: inserted.seq }
544
+ }
545
+ const sessionId = await this.#promptMessage(scene, inserted.seq)
546
+ return { status: 'prompted', seq: inserted.seq, sessionId }
547
+ })
548
+ }
549
+
550
+ async resumePending() {
551
+ if (this.closed || this.handoffPaused) return { resumed: 0 }
552
+ const results = []
553
+ for (const row of Object.values(this.store.snapshot().scenes)) {
554
+ if (!this.allowed(row) || !this.#can(row, 'qq.send')) continue
555
+ if (this.#shouldBatch(row) || this.#autonomousGroup(row)) {
556
+ if (row.messages.some(item => item.needsPrompt && !item.prompted)) {
557
+ results.push(await this.#serial(row, () => this.#promptBatch(row)))
558
+ }
559
+ continue
560
+ }
561
+ for (const message of row.messages) {
562
+ if (!message.needsPrompt || message.prompted === true) continue
563
+ results.push(await this.#serial(row, () => this.#promptMessage(row, message.seq)))
564
+ }
565
+ }
566
+ return { resumed: results.length }
567
+ }
568
+
569
+ read(sessionId, { afterSeq = 0, limit = 50 } = {}) {
570
+ const row = this.#requireAuthorizedSession(sessionId)
571
+ const budget = this.#behavior(row)?.contextBudget
572
+ if (budget) limit = Math.min(limit, budget)
573
+ const result = this.store.unread(row, afterSeq, limit)
574
+ if (result.messages.length) this.store.markPresented(row, result.readThroughSeq)
575
+ // Older development state files may contain raw OneBot media locators.
576
+ // Apply the same projection at both model-facing exits without changing
577
+ // the durable cursor or silently rewriting a pre-existing state file.
578
+ return { ...result, messages: result.messages.map(message => ({
579
+ ...message, segments: sanitizeInboundSegments(message.segments),
580
+ })) }
581
+ }
582
+
583
+ markRead(sessionId, throughSeq) {
584
+ const row = this.#requireAuthorizedSession(sessionId)
585
+ return { readThroughSeq: this.store.markRead(row, throughSeq) }
586
+ }
587
+
588
+ async wait(sessionId, { afterSeq, timeoutMs = DEFAULT_WAIT_MS } = {}) {
589
+ if (this.handoffPaused) throw new Error('QQ_HANDOFF_PAUSED: QQ message waiting is paused')
590
+ const row = this.#requireAuthorizedSession(sessionId)
591
+ const behavior = this.#behavior(row)
592
+ if (behavior?.waitTimeoutMs === 0) throw new Error('QQ_PERMISSION_DENIED: observe cannot wait for conversation')
593
+ if (behavior) timeoutMs = Math.min(timeoutMs, behavior.waitTimeoutMs)
594
+ afterSeq ??= row.presentedSeq
595
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new TypeError('invalid afterSeq')
596
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > MAX_WAIT_MS) throw new TypeError('invalid timeoutMs')
597
+ const immediate = this.read(sessionId, { afterSeq })
598
+ if (immediate.messages.length) return { ...immediate, arrived: true, timeout: false }
599
+ const key = sceneKey(row)
600
+ if (this.waiters.has(key)) throw new Error('QQ_WAIT_BUSY: one wait is already active in this scene')
601
+ let waiter
602
+ await new Promise(resolve => {
603
+ const timer = setTimeout(resolve, timeoutMs)
604
+ waiter = { sessionId, afterSeq, resolve: () => { clearTimeout(timer); resolve() } }
605
+ this.waiters.set(key, waiter)
606
+ }).finally(() => { if (this.waiters.get(key) === waiter) this.waiters.delete(key) })
607
+ this.#requireAuthorizedSession(sessionId)
608
+ const result = this.read(sessionId, { afterSeq })
609
+ // An event handed to a live wait is already in this DSH activity. Leave
610
+ // the durable replay flag only when it was not actually returned to the
611
+ // tool; otherwise resumePending would start a duplicate turn on restart.
612
+ for (const message of result.messages) {
613
+ if (message.needsPrompt && !message.prompted) this.store.markPrompted(row, message.seq)
614
+ }
615
+ return { ...result, arrived: result.messages.length > 0, timeout: result.messages.length === 0 }
616
+ }
617
+
618
+ async send(sessionId, args) {
619
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
620
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new TypeError('send arguments must be an object')
621
+ const { actionId } = args
622
+ const segments = validateOutgoingSegments(args.segments)
623
+ for (const segment of segments) {
624
+ if (segment.type === 'reply') {
625
+ const target = this.store.getScene(row).messages.find(item => item.messageId === segment.data.id)
626
+ if (!target) throw new Error('QQ_REPLY_SCOPE: reply target is not in this scene')
627
+ }
628
+ if (segment.type === 'at') {
629
+ if (row.kind !== 'group' || !this.store.getScene(row).messages.some(item => item.senderId === segment.data.qq)) {
630
+ throw new Error('QQ_AT_SCOPE: mention target is not a known sender in this group')
631
+ }
632
+ }
633
+ }
634
+ const fingerprint = createHash('sha256').update(JSON.stringify({ scene: sceneKey(row), segments })).digest('hex')
635
+ const reserved = this.store.reserveDelivery(row, actionId, fingerprint)
636
+ if (!reserved.fresh) return { status: reserved.delivery.status, repeated: true, messageId: reserved.delivery.platformMessageId }
637
+ // The reservation write and the permission check are not one atomic step.
638
+ // Re-read the Session binding and qq.send immediately before the external
639
+ // call; a revoked Scene must not reach OneBot. No request was made here, so
640
+ // the reservation is settled as a terminal failure instead of lingering as
641
+ // an unsettled action that would consume outbox capacity forever.
642
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
643
+ this.inFlightDeliveries.add(inFlightKey)
644
+ try {
645
+ try {
646
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
647
+ } catch (error) {
648
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep the permission failure */ }
649
+ throw error
650
+ }
651
+ try {
652
+ const receipt = await this.transport.send(row, segments)
653
+ const messageId = normalizeMessageId(receipt.messageId)
654
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', messageId)
655
+ return { status: delivery.status, repeated: false, messageId }
656
+ } catch (error) {
657
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
658
+ this.store.settleDelivery(row, actionId, status)
659
+ return { status, repeated: false, messageId: null }
660
+ }
661
+ } finally {
662
+ this.inFlightDeliveries.delete(inFlightKey)
663
+ }
664
+ }
665
+
666
+ async sendPreparedMedia(sessionId, actionId, plan) {
667
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
668
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
669
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan) ||
670
+ plan.mode !== 'message' || !/^[a-f0-9]{64}$/.test(plan.fingerprint) ||
671
+ !Array.isArray(plan.segments) || plan.segments.length !== 1) {
672
+ throw new TypeError('invalid outbound media plan')
673
+ }
674
+ const segment = plan.segments[0]
675
+ if (!segment || !['image', 'video', 'record'].includes(segment.type) ||
676
+ !segment.data || typeof segment.data.file !== 'string' || !segment.data.file) {
677
+ throw new TypeError('invalid outbound media segment')
678
+ }
679
+ const ledgerFingerprint = createHash('sha256').update(JSON.stringify({
680
+ operation: 'send-media',
681
+ scene: sceneKey(row),
682
+ segmentType: segment.type,
683
+ mediaType: typeof plan.mediaType === 'string' ? plan.mediaType : null,
684
+ contentHash: plan.fingerprint,
685
+ })).digest('hex')
686
+ const reserved = this.store.reserveDelivery(row, actionId, ledgerFingerprint)
687
+ if (!reserved.fresh) {
688
+ return {
689
+ status: reserved.delivery.status,
690
+ repeated: true,
691
+ messageId: reserved.delivery.platformMessageId,
692
+ }
693
+ }
694
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
695
+ this.inFlightDeliveries.add(inFlightKey)
696
+ try {
697
+ try {
698
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
699
+ } catch (error) {
700
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
701
+ throw error
702
+ }
703
+ try {
704
+ const receipt = await this.transport.send(row, plan.segments)
705
+ const messageId = normalizeMessageId(receipt.messageId)
706
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', messageId)
707
+ return { status: delivery.status, repeated: false, messageId }
708
+ } catch (error) {
709
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
710
+ this.store.settleDelivery(row, actionId, status)
711
+ return { status, repeated: false, messageId: null }
712
+ }
713
+ } finally {
714
+ this.inFlightDeliveries.delete(inFlightKey)
715
+ }
716
+ }
717
+
718
+ async uploadSceneFile(sessionId, actionId, plan) {
719
+ const row = this.#requireAuthorizedSession(sessionId, 'qq.send')
720
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw new TypeError('invalid actionId')
721
+ const expectedAction = row.kind === 'group' ? 'upload_group_file' : 'upload_private_file'
722
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan) ||
723
+ plan.mode !== 'upload' || plan.action !== expectedAction ||
724
+ !/^[a-f0-9]{64}$/.test(plan.fingerprint) ||
725
+ !plan.params || typeof plan.params !== 'object' || Array.isArray(plan.params) ||
726
+ typeof plan.params.file !== 'string' || !path.isAbsolute(plan.params.file) ||
727
+ typeof plan.params.name !== 'string' || !plan.params.name ||
728
+ plan.params.upload_file !== true) {
729
+ throw new TypeError('invalid outbound upload plan')
730
+ }
731
+ const target = row.kind === 'group' ? plan.params.group_id : plan.params.user_id
732
+ if (String(target) !== String(row.peerId)) throw new Error('QQ_UPLOAD_SCOPE')
733
+ const ledgerFingerprint = createHash('sha256').update(JSON.stringify({
734
+ operation: 'upload-file',
735
+ scene: sceneKey(row),
736
+ action: plan.action,
737
+ name: plan.params.name,
738
+ mediaType: typeof plan.mediaType === 'string' ? plan.mediaType : null,
739
+ contentHash: plan.fingerprint,
740
+ })).digest('hex')
741
+ const reserved = this.store.reserveDelivery(row, actionId, ledgerFingerprint)
742
+ if (!reserved.fresh) {
743
+ return {
744
+ status: reserved.delivery.status,
745
+ repeated: true,
746
+ fileId: reserved.delivery.platformFileId ?? null,
747
+ }
748
+ }
749
+ const inFlightKey = `${sceneKey(row)}:${actionId}`
750
+ this.inFlightDeliveries.add(inFlightKey)
751
+ try {
752
+ try {
753
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
754
+ } catch (error) {
755
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
756
+ throw error
757
+ }
758
+ // NapCat may complete a group upload but return no file_id. Its root
759
+ // listing also rotates file_id on every read, so compare stable file
760
+ // metadata and counts rather than treating a new id as a new file.
761
+ const rootFiles = async () => {
762
+ try {
763
+ const result = await this.transport.action('get_group_root_files',
764
+ { group_id: row.peerId }, 10_000, 2 * 1024 * 1024)
765
+ return Array.isArray(result?.files) && result.files.length <= 2000 ? result.files : null
766
+ } catch { return null }
767
+ }
768
+ const beforeFiles = row.kind === 'group' ? await rootFiles() : null
769
+ try {
770
+ this.#requireAuthorizedSession(sessionId, 'qq.send')
771
+ } catch (error) {
772
+ try { this.store.settleDelivery(row, actionId, 'failed') } catch { /* keep permission failure */ }
773
+ throw error
774
+ }
775
+ try {
776
+ const result = await this.transport.action(plan.action, plan.params)
777
+ let fileId = typeof result?.file_id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(result.file_id)
778
+ ? result.file_id
779
+ : null
780
+ if (fileId === null && beforeFiles !== null && Number.isSafeInteger(plan.bytes) && plan.bytes > 0) {
781
+ const afterFiles = await rootFiles()
782
+ if (afterFiles !== null) {
783
+ const isMatch = file =>
784
+ typeof file?.file_id === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(file.file_id) &&
785
+ String(file.group_id) === row.peerId &&
786
+ file.file_name === plan.params.name && Number(file.file_size ?? file.size) === plan.bytes &&
787
+ String(file.uploader) === row.botId
788
+ const beforeMatches = beforeFiles.filter(isMatch)
789
+ const afterMatches = afterFiles.filter(isMatch)
790
+ if (afterMatches.length === beforeMatches.length + 1) {
791
+ // A unique name/size/uploader is sufficient. For an existing
792
+ // same-name file, require a fresh unique platform modify_time;
793
+ // same-second or missing timestamps remain unknown.
794
+ const candidate = beforeMatches.length === 0 ? afterMatches[0] : (() => {
795
+ const oldTimes = new Set(beforeMatches.map(file => Number(file.modify_time)))
796
+ const fresh = afterMatches.filter(file => Number.isSafeInteger(Number(file.modify_time)) &&
797
+ Number(file.modify_time) > 0 && !oldTimes.has(Number(file.modify_time)))
798
+ return fresh.length === 1 ? fresh[0] : null
799
+ })()
800
+ if (candidate) fileId = candidate.file_id
801
+ }
802
+ }
803
+ }
804
+ if (fileId === null) {
805
+ this.store.settleDelivery(row, actionId, 'unknown')
806
+ return { status: 'unknown', repeated: false, fileId: null }
807
+ }
808
+ const delivery = this.store.settleDelivery(row, actionId, 'delivered', null, fileId)
809
+ return { status: delivery.status, repeated: false, fileId }
810
+ } catch (error) {
811
+ const status = error?.code === 'ONEBOT_REJECTED' || error?.code === 'QQ_HANDOFF_PAUSED' ? 'failed' : 'unknown'
812
+ this.store.settleDelivery(row, actionId, status)
813
+ return { status, repeated: false, fileId: null }
814
+ }
815
+ } finally {
816
+ this.inFlightDeliveries.delete(inFlightKey)
817
+ }
818
+ }
819
+
820
+ health() {
821
+ return { enabled: !this.closed, handoffPaused: this.handoffPaused, transport: this.transport.status ?? 'unknown', scenes: Object.keys(this.store.snapshot().scenes).length, activeWaits: this.waiters.size }
822
+ }
823
+
824
+ /** Stop admission before the DSH config editor reloads this plugin. Existing
825
+ * Session bindings remain durable; only stale wake work is dismissed. */
826
+ async pauseForHandoff() {
827
+ let suppressed = 0
828
+ if (!this.handoffPaused) {
829
+ this.handoffPaused = true
830
+ for (const { timer } of this.promptTimers.values()) clearTimeout(timer)
831
+ this.promptTimers.clear()
832
+ for (const waiter of this.waiters.values()) waiter.resolve()
833
+ this.waiters.clear()
834
+ suppressed = this.store.suppressPendingPrompts()
835
+ this.transport.stop?.()
836
+ }
837
+ // An aborted HTTP request can have an unknown platform outcome. Wait for
838
+ // its local ledger settlement before the config edit reloads this store.
839
+ const inFlight = this.inFlightDeliveries.size
840
+ const deadline = Date.now() + 5_000
841
+ while (this.inFlightDeliveries.size || this.transport.pendingActions?.size) {
842
+ if (Date.now() >= deadline) throw new Error('QQ_HANDOFF_BUSY: an outgoing action has not settled')
843
+ await new Promise(resolve => setTimeout(resolve, 20))
844
+ }
845
+ return { suppressed, inFlight }
846
+ }
847
+
848
+ statusFor(sessionId) {
849
+ this.#requireAuthorizedSession(sessionId)
850
+ return this.health()
851
+ }
852
+
853
+ authorizedScene(sessionId) {
854
+ const row = this.#requireAuthorizedSession(sessionId)
855
+ return { botId: row.botId, kind: row.kind, peerId: row.peerId }
856
+ }
857
+
858
+ /**
859
+ * Synchronous execution-time capability check for the connector's Host
860
+ * service. The Scene comes only from the persisted Session binding; callers
861
+ * never supply it. Any stale binding, revision, allowlist or capability
862
+ * failure is reported as false.
863
+ */
864
+ authorizeSessionCapability(sessionId, capability) {
865
+ try {
866
+ this.#requireAuthorizedSession(sessionId, capability)
867
+ return true
868
+ } catch {
869
+ return false
870
+ }
871
+ }
872
+
873
+ trustedSceneForSession(sessionId) {
874
+ const row = this.#requireAuthorizedSession(sessionId)
875
+ return Object.freeze({
876
+ platform: 'qq',
877
+ kind: row.kind,
878
+ id: `${row.botId}:${row.peerId}`,
879
+ verified: true,
880
+ admin: row.kind === 'private' && this.permissions.ownerPrivateIds.has(row.peerId),
881
+ })
882
+ }
883
+
884
+ close() {
885
+ this.closed = true
886
+ for (const { timer } of this.promptTimers.values()) clearTimeout(timer)
887
+ this.promptTimers.clear()
888
+ for (const waiter of this.waiters.values()) waiter.resolve()
889
+ this.waiters.clear()
890
+ this.transport.stop?.()
891
+ }
892
+ }