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