@fg-lab/amadeus-qq 0.0.0-stage → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +28 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  26. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  27. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  28. package/lib/client.js +380 -0
  29. package/package.json +92 -4
  30. package/preset.patch.yml +166 -0
  31. package/screenshots.json +6 -0
  32. package/scripts/build-client.mjs +52 -0
  33. package/scripts/registry-guard.mjs +12 -0
  34. package/scripts/release-audit.mjs +59 -0
  35. package/scripts/symlink-security.mjs +8 -0
  36. package/src/account-profile.js +223 -0
  37. package/src/admin-http.js +361 -0
  38. package/src/audio.js +428 -0
  39. package/src/capabilities.js +785 -0
  40. package/src/client/factory.template.js +82 -0
  41. package/src/client/logic.js +290 -0
  42. package/src/config.js +50 -0
  43. package/src/console/console.css +28 -0
  44. package/src/console/console.js +541 -0
  45. package/src/console/index.html +67 -0
  46. package/src/core.js +909 -0
  47. package/src/files.js +230 -0
  48. package/src/index.js +994 -0
  49. package/src/lease.js +42 -0
  50. package/src/media.js +309 -0
  51. package/src/napcat-action-names.js +186 -0
  52. package/src/native-asr.js +112 -0
  53. package/src/onebot.js +378 -0
  54. package/src/outbound-media.js +309 -0
  55. package/src/owner-workspace.js +394 -0
  56. package/src/permissions.js +270 -0
  57. package/src/preset-guard.mjs +593 -0
  58. package/src/qzone-provider.js +628 -0
  59. package/src/session-provenance.js +74 -0
  60. package/src/setup.js +255 -0
  61. package/src/storage.js +580 -0
  62. package/src/web-console.js +9 -0
package/src/index.js ADDED
@@ -0,0 +1,994 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import { createHash } from 'node:crypto'
5
+ import { QQStore } from './storage.js'
6
+ import { SessionProvenance } from './session-provenance.js'
7
+ import { sceneKey } from './storage.js'
8
+ import { QQCore } from './core.js'
9
+ import { OneBotAdapter } from './onebot.js'
10
+ import { acquireInstanceLease } from './lease.js'
11
+ import { CapabilityRegistry } from './capabilities.js'
12
+ import { readMessageImage } from './media.js'
13
+ import { readMessageFile, readMessageVideo } from './files.js'
14
+ import { transcribeMessageRecord } from './audio.js'
15
+ import { transcribeMessageRecordNative } from './native-asr.js'
16
+ import { prepareOutboundMedia } from './outbound-media.js'
17
+ import { createQzoneProvider } from './qzone-provider.js'
18
+ import { createAccountProfile } from './account-profile.js'
19
+ import {
20
+ createOwnerWorkspaceTool,
21
+ resolveOwnerWorkspacePolicy,
22
+ setOwnerWorkspaceStatus,
23
+ } from './owner-workspace.js'
24
+ import { Config } from './config.js'
25
+ import {
26
+ apply as applyPresetGuard,
27
+ isMemoryReadTool,
28
+ memorySceneAclReadGranted,
29
+ OWNER_NATIVE_TOOLS,
30
+ } from './preset-guard.mjs'
31
+ import { LIFE_READ_CAPABILITY, LIFE_READ_TOOLS } from './permissions.js'
32
+ import { ADMIN_PREFIX, createAdminHandler } from './admin-http.js'
33
+ import { createSetupService } from './setup.js'
34
+
35
+ export const name = 'amadeus-qq'
36
+ export const inject = ['tools', 'sessionController']
37
+ export { Config }
38
+
39
+ const QQ_TOOL_NAMES = Object.freeze([
40
+ 'qq_send_message', 'qq_wait_for_messages', 'qq_get_unread_messages',
41
+ 'qq_mark_read', 'qq_connection_status', 'qq_capability_search',
42
+ 'qq_capability_describe', 'qq_capability_execute', 'qq_read_message_image',
43
+ 'qq_transcribe_message_record', 'qq_read_message_file', 'qq_read_message_video',
44
+ 'qq_transcribe_message_record_native',
45
+ 'qq_send_media', 'qq_upload_file', 'qq_qzone', 'qq_owner_workspace', 'qq_account_profile',
46
+ ])
47
+ const QQ_TOOL_SET = new Set(QQ_TOOL_NAMES)
48
+ const JSON_OBJECT = Object.freeze({ type: 'object', additionalProperties: true })
49
+
50
+ function sessionIdOf(exec) {
51
+ const sessionId = exec?.agent?.session?.id
52
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('QQ tools require a live DSH Session')
53
+ return sessionId
54
+ }
55
+
56
+ function registerTool(ctx, definition) {
57
+ const allowed = new Set(Object.keys(definition.parameters?.properties ?? {}))
58
+ const output = definition.output ?? {
59
+ schema: JSON_OBJECT,
60
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
61
+ }
62
+ ctx.tools.register({
63
+ ...definition,
64
+ execute: async (args, exec) => {
65
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new TypeError('QQ tool arguments must be an object')
66
+ for (const key of Object.keys(args)) if (!allowed.has(key)) throw new TypeError(`unknown QQ tool argument: ${key}`)
67
+ return definition.execute(args, exec)
68
+ },
69
+ output,
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Snapshot an already-validated outbound plan into an owner-only temp file and
75
+ * verify the copied bytes still match the helper's SHA-256. NapCat then reads
76
+ * the private snapshot, not the original path, closing the read-to-send window.
77
+ */
78
+ async function snapshotOutboundPlan(plan) {
79
+ const source = plan.mode === 'upload' ? plan.params?.file : plan.segments?.[0]?.data?.file
80
+ if (typeof source !== 'string' || !path.isAbsolute(source)) throw new Error('QQ_MEDIA_PLAN_INVALID')
81
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'amadeus-qq-send-'))
82
+ const snapshot = path.join(dir, path.basename(source))
83
+ try {
84
+ await fs.promises.copyFile(source, snapshot, fs.constants.COPYFILE_EXCL)
85
+ await fs.promises.chmod(snapshot, 0o600)
86
+ const bytes = await fs.promises.readFile(snapshot)
87
+ const digest = createHash('sha256').update(bytes).digest('hex')
88
+ if (digest !== plan.fingerprint) {
89
+ const error = new Error('QQ_MEDIA_FINGERPRINT_MISMATCH')
90
+ error.code = 'QQ_MEDIA_FINGERPRINT_MISMATCH'
91
+ throw error
92
+ }
93
+ const prepared = plan.mode === 'upload'
94
+ ? { ...plan, params: { ...plan.params, file: snapshot } }
95
+ : { ...plan, segments: [{ ...plan.segments[0], data: { ...plan.segments[0].data, file: snapshot } }] }
96
+ return {
97
+ plan: prepared,
98
+ cleanup: () => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}),
99
+ }
100
+ } catch (error) {
101
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
102
+ throw error
103
+ }
104
+ }
105
+
106
+ export function makeDshHost(ctx, config, provenance = null) {
107
+ const controller = ctx.sessionController
108
+ if (typeof controller?.create !== 'function' || typeof controller?.prompt !== 'function') {
109
+ throw new Error('DSH sessionController create/prompt is required')
110
+ }
111
+ if (typeof config.agentPreset !== 'string' || !config.agentPreset) throw new Error('A restricted QQ agentPreset is required')
112
+ // The runtime guard only exists while this plugin is enabled. A persisted QQ
113
+ // Session must therefore have a restricted preset of its own, including
114
+ // after the plugin is disabled or removed. Never create one from a general
115
+ // coding preset and rely on a temporary per-Agent restriction alone.
116
+ if (config.agentPreset !== 'amadeus-qq') {
117
+ throw new Error('QQ_UNSAFE_PRESET: the packaged amadeus-qq agentPreset is required')
118
+ }
119
+ if (config.workspaceId !== undefined && typeof config.workspaceId !== 'string') throw new Error('QQ workspaceId must be a string')
120
+ if (!config.workspaceId && (typeof config.sessionCwd !== 'string' || !path.isAbsolute(config.sessionCwd))) {
121
+ throw new Error('QQ sessionCwd must be absolute for legacy configuration')
122
+ }
123
+ return {
124
+ async sessionExists(sessionId) {
125
+ if (typeof sessionId !== 'string' || !sessionId) return false
126
+ // Real current Hosts expose the non-activating persistence projection
127
+ // read. Older mocks without it remain exempt from the existence check.
128
+ if (typeof controller.projections !== 'function') return null
129
+ let observed
130
+ try {
131
+ observed = await controller.projections({ sessionId }, AbortSignal.timeout(5000))
132
+ } catch (error) {
133
+ if (error?.code === 'session/not-found' || error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return false
134
+ throw error
135
+ }
136
+ if (observed === null) return false
137
+ if (observed === undefined || typeof observed !== 'object' || Array.isArray(observed) ||
138
+ (observed.kind !== 'sequenced' && observed.kind !== 'migration-required') ||
139
+ observed.values === undefined || observed.values === null ||
140
+ typeof observed.values !== 'object' || Array.isArray(observed.values)) {
141
+ const error = new Error('QQ_SESSION_LOOKUP_INVALID')
142
+ error.code = 'QQ_SESSION_LOOKUP_INVALID'
143
+ throw error
144
+ }
145
+ if (config.workspaceId) {
146
+ let registry
147
+ try {
148
+ registry = typeof ctx.reflect?.get === 'function'
149
+ ? ctx.reflect.get('workspaceRegistry')
150
+ : undefined
151
+ } catch {
152
+ registry = undefined
153
+ }
154
+ if (registry && typeof registry.list === 'function') {
155
+ const workspaces = await registry.list()
156
+ if (!Array.isArray(workspaces)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
157
+ const workspace = workspaces.find(item => item?.id === config.workspaceId)
158
+ if (!workspace) throw new Error('QQ_WORKSPACE_UNAVAILABLE')
159
+ if (!Array.isArray(workspace.sessionIds)) throw new Error('QQ_SESSION_LOOKUP_INVALID')
160
+ if (!workspace.sessionIds.includes(sessionId)) throw new Error('QQ_WORKSPACE_MISMATCH')
161
+ }
162
+ }
163
+ return true
164
+ },
165
+ async cancelSceneSession(sessionId) {
166
+ if (typeof controller.cancel !== 'function') throw new Error('DSH Session cancellation is unavailable')
167
+ try {
168
+ return await controller.cancel({ sessionId })
169
+ } catch (error) {
170
+ if (error?.code === 'session/not-found') return { accepted: true, alreadyInactive: true }
171
+ throw error
172
+ }
173
+ },
174
+ async createSceneSession(_scene, grant) {
175
+ let requestedPreset = config.agentPreset
176
+ if (grant?.ownerAgent === true) requestedPreset = 'amadeus-qq-owner'
177
+ else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-media-search'
178
+ else if (grant?.lifeAllowed === true && grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-life-media'
179
+ else if (grant?.lifeAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-life-search'
180
+ else if (grant?.lifeAllowed === true) requestedPreset = 'amadeus-qq-life'
181
+ else if (grant?.mediaAllowed === true && grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-media-search'
182
+ else if (grant?.mediaAllowed === true) requestedPreset = 'amadeus-qq-media'
183
+ else if (grant?.searchAllowed === true) requestedPreset = 'amadeus-qq-search'
184
+ const location = config.workspaceId ? { workspaceId: config.workspaceId } : { cwd: config.sessionCwd }
185
+ const reservedId = provenance?.reserve()
186
+ let created
187
+ try {
188
+ created = await controller.create({ ...location, agentPreset: requestedPreset,
189
+ ...(reservedId ? { sessionId: reservedId } : {}) })
190
+ } catch (error) {
191
+ provenance?.complete?.(reservedId)
192
+ throw error
193
+ }
194
+ provenance?.complete?.(reservedId, created?.sessionId)
195
+ if (reservedId && created?.sessionId !== reservedId) {
196
+ throw new Error('QQ_PROVENANCE_UNSUPPORTED: Host did not honor reserved Session ID')
197
+ }
198
+ if (created?.agentPreset !== requestedPreset) {
199
+ throw new Error('QQ_UNSAFE_PRESET: DSH did not create the requested packaged preset')
200
+ }
201
+ if (typeof controller.rename === 'function') {
202
+ const title = _scene.kind === 'group' ? `群聊 ${_scene.peerId}` : `私聊 ${_scene.peerId}`
203
+ try { await controller.rename({ sessionId: created.sessionId, title }) } catch { /* Session remains usable; title can be repaired later. */ }
204
+ }
205
+ return created
206
+ },
207
+ async prompt(request) {
208
+ const signal = AbortSignal.timeout(15_000)
209
+ const accepted = await controller.prompt(request, signal)
210
+ if (accepted?.accepted !== true) throw new Error('DSH did not accept the QQ prompt')
211
+ return accepted
212
+ },
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Emit one bounded startup diagnostic. Never include the raw error, URLs,
218
+ * credentials, state paths, message bodies, or the logger's own failure.
219
+ * @param {Record<string, unknown>} ctx - plugin context.
220
+ * @param {'resume'|'transport'} stage - failed startup stage.
221
+ */
222
+ function reportStartupFailure(ctx, stage) {
223
+ const message = stage === 'transport'
224
+ ? 'amadeus-qq: OneBot transport startup failed; connector remains unavailable'
225
+ : 'amadeus-qq: pending QQ Session resume failed; queued messages remain pending'
226
+ try {
227
+ const logger = ctx?.logger
228
+ if (logger && typeof logger.error === 'function') logger.error(message)
229
+ else if (logger && typeof logger.warn === 'function') logger.warn(message)
230
+ else console.error(message)
231
+ } catch {
232
+ // Logging must never turn a bounded failure into an unhandled rejection.
233
+ try { console.error(message) } catch { /* no available diagnostic sink */ }
234
+ }
235
+ }
236
+
237
+ const TRANSPORT_DIAGNOSTICS = Object.freeze({
238
+ QQ_INBOUND_HANDLER_FAILED: 'amadeus-qq: inbound QQ event handling failed',
239
+ QQ_ONEBOT_CONNECT_FAILED: 'amadeus-qq: OneBot connection attempt failed',
240
+ QQ_ONEBOT_SOCKET_ERROR: 'amadeus-qq: OneBot socket error',
241
+ })
242
+
243
+ /**
244
+ * Map an adapter diagnostic code to one static, bounded Host log line. Unknown
245
+ * codes and any attached detail are ignored; the adapter never supplies event
246
+ * content, URLs, credentials or paths.
247
+ * @param {Record<string, unknown>} ctx - plugin context.
248
+ * @param {{code?: unknown}} diagnostic - adapter diagnostic.
249
+ */
250
+ function reportTransportDiagnostic(ctx, diagnostic) {
251
+ const code = diagnostic?.code
252
+ const message = typeof code === 'string' ? TRANSPORT_DIAGNOSTICS[code] : undefined
253
+ if (!message) return
254
+ const level = code === 'QQ_INBOUND_HANDLER_FAILED' ? 'error' : 'warn'
255
+ try {
256
+ const logger = ctx?.logger
257
+ if (logger && typeof logger[level] === 'function') logger[level](message)
258
+ else if (logger && typeof logger.warn === 'function') logger.warn(message)
259
+ else console.warn(message)
260
+ } catch {
261
+ try { console.warn(message) } catch { /* no available diagnostic sink */ }
262
+ }
263
+ }
264
+
265
+ function registerAdmin(ctx, core = null) {
266
+ if (typeof ctx.inject !== 'function') return
267
+ try {
268
+ ctx.inject(['webServer', 'connection'], child => {
269
+ if (!child?.webServer || typeof child.webServer.register !== 'function' || typeof child.effect !== 'function') return
270
+ const getService = name => {
271
+ try { return child.get?.(name) ?? ctx.get?.(name) } catch { return undefined }
272
+ }
273
+ const setup = createSetupService(getService('configEditor'), getService('workspaceRegistry'))
274
+ const handler = createAdminHandler(core, getService('connection'), setup)
275
+ child.effect(() => child.webServer.register({ kind: 'prefix', path: ADMIN_PREFIX, handler }))
276
+ })
277
+ } catch {
278
+ try { ctx.logger?.warn?.('amadeus-qq: native management API unavailable') } catch {}
279
+ }
280
+ }
281
+
282
+ export function apply(ctx, config = {}) {
283
+ // The preset rows mount this same package root with `presetGuard: true`.
284
+ // Branch before any connector startup requirement so the guard is owned by
285
+ // the preset, not by the connector's enabled/statePath configuration.
286
+ if (config.presetGuard === true) {
287
+ return applyPresetGuard(ctx, {
288
+ level: config.level ?? 1,
289
+ allowSearch: config.allowSearch === true,
290
+ allowLifeRead: config.allowLifeRead === true,
291
+ })
292
+ }
293
+ if (config.enabled !== true) {
294
+ registerAdmin(ctx)
295
+ // Keep identity verification alive without starting any QQ transport.
296
+ let provenance
297
+ if (typeof config.statePath === 'string' && path.isAbsolute(config.statePath)) {
298
+ const release = acquireInstanceLease(config.statePath)
299
+ try {
300
+ provenance = new SessionProvenance(`${config.statePath}.provenance.json`, {
301
+ store: new QQStore(config.statePath),
302
+ sessions: () => typeof ctx.reflect?.get === 'function' ? ctx.reflect.get('sessions') : ctx.sessions,
303
+ })
304
+ ctx.effect(() => release)
305
+ } catch (error) { release(); throw error }
306
+ }
307
+ ctx.provide('qq', Object.freeze({
308
+ sessionProvenanceVersion: 1,
309
+ sessionCreationDisabled: true,
310
+ classifySession: id => provenance?.classify(id) ?? ({ kind: 'unknown', reason: 'qq-unconfigured' }), getHealth: () => ({ enabled: false, status: 'disabled' }) }))
311
+ return
312
+ }
313
+ if (typeof config.statePath !== 'string' || !path.isAbsolute(config.statePath)) {
314
+ throw new Error('QQ statePath must be explicitly configured as an absolute isolated path')
315
+ }
316
+ // Create the isolated inbound-media root on first run. media.js still
317
+ // enforces containment and file-type/size checks for every local result.
318
+ if (typeof config.mediaRoot === 'string' && path.isAbsolute(config.mediaRoot)) {
319
+ fs.mkdirSync(config.mediaRoot, { recursive: true })
320
+ }
321
+ const releaseLease = acquireInstanceLease(config.statePath)
322
+ try {
323
+ ctx.effect(() => releaseLease)
324
+ const store = new QQStore(config.statePath)
325
+ const provenance = new SessionProvenance(`${config.statePath}.provenance.json`, {
326
+ store,
327
+ sessions: () => typeof ctx.reflect?.get === 'function' ? ctx.reflect.get('sessions') : ctx.sessions,
328
+ })
329
+ const dsh = makeDshHost(ctx, config, provenance)
330
+ const transport = new OneBotAdapter({
331
+ enabled: config.connect === true,
332
+ httpUrl: config.httpUrl,
333
+ wsUrl: config.wsUrl,
334
+ tokenEnv: config.tokenEnv,
335
+ token: config.token,
336
+ }, {
337
+ onDiagnostic: diagnostic => reportTransportDiagnostic(ctx, diagnostic),
338
+ })
339
+ const core = new QQCore({ store, dsh, transport, policy: config.policy, permissionPolicy: config.permissions,
340
+ workspaceId: config.workspaceId ?? '', handoffPaused: config.connect !== true })
341
+ const capabilities = new CapabilityRegistry({ core, store, transport })
342
+
343
+ // Some DSH builds apply the QQ preset allow-mask before the optional Life
344
+ // plugin publishes its native tools. Register a narrow same-name bridge
345
+ // only when the host has not registered them yet; execution still resolves
346
+ // the official service and checks the explicit Scene capability.
347
+ const lifeServiceName = ['l', 'i', 'f', 'e'].join('')
348
+ const lifeSharedTool = ['life', 'shared', 'read'].join('_')
349
+ const lifeHealthTool = ['life', 'health'].join('_')
350
+ const registeredToolNames = () => {
351
+ try { return new Set((ctx.tools.schemas?.() ?? []).map(schema => schema?.name)) } catch { return new Set() }
352
+ }
353
+ const lifeService = () => {
354
+ try { return ctx.get?.(lifeServiceName) ?? ctx.reflect?.get?.(lifeServiceName) ?? null } catch { return null }
355
+ }
356
+ const registerLifeBridge = (name, description, execute) => {
357
+ if (registeredToolNames().has(name)) return
358
+ registerTool(ctx, {
359
+ name,
360
+ description,
361
+ parameters: { type: 'object', additionalProperties: false, properties: {}, required: [] },
362
+ execute,
363
+ })
364
+ }
365
+ const requireLifeSession = exec => {
366
+ const sid = sessionIdOf(exec)
367
+ core.authorizedScene(sid)
368
+ if (!core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)) {
369
+ throw new Error('QQ_PERMISSION_DENIED: amadeus.life.read')
370
+ }
371
+ return sid
372
+ }
373
+ registerLifeBridge(lifeSharedTool,
374
+ 'Read the bound Session Life shared view. Requires the explicit QQ Life read grant.',
375
+ (_args, exec) => {
376
+ const service = lifeService()
377
+ if (!service || typeof service.getSharedLifeView !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
378
+ return service.getSharedLifeView({ targetSessionId: requireLifeSession(exec) })
379
+ })
380
+ registerLifeBridge(lifeHealthTool,
381
+ 'Read bounded Life health for the bound Session. Requires the explicit QQ Life read grant.',
382
+ (_args, exec) => {
383
+ const service = lifeService()
384
+ if (!service || typeof service.getScopedHealth !== 'function') throw new Error('QQ_LIFE_UNAVAILABLE')
385
+ return service.getScopedHealth({ scope: `session:${requireLifeSession(exec)}` })
386
+ })
387
+
388
+ // Native management is an optional Host capability. Without a web server or
389
+ // DSH Connection the API stays unavailable; the QQ connector keeps running.
390
+ registerAdmin(ctx, core)
391
+
392
+ // The guard is global so it also covers a QQ Session restored by DSH before
393
+ // the plugin can attach a fresh per-Agent tool schema restriction.
394
+ ctx.tools.guard(exec => {
395
+ const sid = exec?.agent?.session?.id
396
+ if (typeof sid !== 'string' || !store.isKnownSession(sid)) return undefined
397
+ if (QQ_TOOL_SET.has(exec.name)) return undefined
398
+ // Memory readers are not admitted by name. Ordinary QQ Sessions need the
399
+ // explicit trusted-Scene ACL declaration from Amadeus Memory; an old or
400
+ // upstream Mneme without it stays closed. The Owner private capability
401
+ // keeps its pre-existing generic host-tool path unchanged.
402
+ if (isMemoryReadTool(exec.name)) {
403
+ let ownerAuthorized = false
404
+ try {
405
+ ownerAuthorized = core.authorizeSessionCapability(sid, 'qq.owner.tools') === true
406
+ } catch { /* fail closed for the ordinary path */ }
407
+ if (ownerAuthorized) return undefined
408
+ return memorySceneAclReadGranted(ctx)
409
+ ? undefined
410
+ : 'QQ Memory trusted Scene ACL read contract is unavailable'
411
+ }
412
+ if (exec.name === 'web_search') {
413
+ return core.authorizeSessionCapability(sid, 'dsh.search')
414
+ ? undefined
415
+ : 'QQ Session search permission is not authorized'
416
+ }
417
+ if (LIFE_READ_TOOLS.includes(exec.name)) {
418
+ return core.authorizeSessionCapability(sid, LIFE_READ_CAPABILITY)
419
+ ? undefined
420
+ : 'QQ Session Life read permission is not authorized'
421
+ }
422
+ // The Owner preset may expose tools registered later by other plugins
423
+ // (for example Life). The connector guard must use the same live Owner
424
+ // grant while refusing unknown names and the PTC code transport.
425
+ let registeredOwnerTool = false
426
+ if (typeof exec.name === 'string' && exec.name !== 'run_code' &&
427
+ !exec.name.startsWith('qq_') && typeof ctx.tools.schemas === 'function') {
428
+ try {
429
+ registeredOwnerTool = (ctx.tools.schemas() ?? []).some(schema => schema?.name === exec.name)
430
+ } catch { /* fail closed for dynamic tools */ }
431
+ }
432
+ if (OWNER_NATIVE_TOOLS.includes(exec.name) || registeredOwnerTool) {
433
+ return core.authorizeSessionCapability(sid, 'qq.owner.tools')
434
+ ? undefined
435
+ : 'QQ Session cannot use non-QQ tools'
436
+ }
437
+ return 'QQ Session cannot use non-QQ tools'
438
+ })
439
+
440
+ registerTool(ctx, {
441
+ name: 'qq_send_message',
442
+ description: 'Send one structured QQ message to this Session’s authorized scene. Requires a fresh actionId; unknown delivery is never retried automatically.',
443
+ parameters: { type: 'object', additionalProperties: false, properties: {
444
+ actionId: { type: 'string' },
445
+ segments: { type: 'array', items: { type: 'object' } },
446
+ }, required: ['actionId', 'segments'] },
447
+ timeoutMs: 20_000,
448
+ execute: (args, exec) => core.send(sessionIdOf(exec), args),
449
+ })
450
+ registerTool(ctx, {
451
+ name: 'qq_get_unread_messages',
452
+ description: 'Read unread structured messages in this Session’s QQ scene.',
453
+ parameters: { type: 'object', additionalProperties: false, properties: {
454
+ afterSeq: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 200 },
455
+ } },
456
+ execute: (args, exec) => core.read(sessionIdOf(exec), args),
457
+ })
458
+ registerTool(ctx, {
459
+ name: 'qq_mark_read',
460
+ description: 'Mark only messages already presented to this Session as read.',
461
+ parameters: { type: 'object', additionalProperties: false, properties: {
462
+ throughSeq: { type: 'integer', minimum: 0 },
463
+ }, required: ['throughSeq'] },
464
+ execute: (args, exec) => core.markRead(sessionIdOf(exec), args.throughSeq),
465
+ })
466
+ registerTool(ctx, {
467
+ name: 'qq_wait_for_messages',
468
+ description: 'Wait without model polling for new messages in this Session’s QQ scene; then continue the same DSH activity.',
469
+ parameters: { type: 'object', additionalProperties: false, properties: {
470
+ afterSeq: { type: 'integer', minimum: 0 }, timeoutMs: { type: 'integer', minimum: 100, maximum: 300000 },
471
+ } },
472
+ timeoutMs: 310_000,
473
+ execute: (args, exec) => core.wait(sessionIdOf(exec), args),
474
+ })
475
+ registerTool(ctx, {
476
+ name: 'qq_connection_status',
477
+ description: 'Read this Session’s QQ connection health without account secrets.',
478
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
479
+ execute: (_args, exec) => core.statusFor(sessionIdOf(exec)),
480
+ })
481
+ registerTool(ctx, {
482
+ name: 'qq_capability_search',
483
+ description: 'Search the on-demand QQ capability catalog. Catalog presence is not proof of runtime availability.',
484
+ parameters: { type: 'object', additionalProperties: false, properties: {
485
+ query: { type: 'string' }, category: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 50 },
486
+ } },
487
+ execute: (args, exec) => { core.authorizedScene(sessionIdOf(exec)); return capabilities.search(args.query ?? '', args) },
488
+ })
489
+ registerTool(ctx, {
490
+ name: 'qq_capability_describe',
491
+ description: 'Inspect one QQ capability, schema, permissions, risk and availability.',
492
+ parameters: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' } }, required: ['id'] },
493
+ execute: (args, exec) => { core.authorizedScene(sessionIdOf(exec)); return capabilities.describe(args.id) },
494
+ })
495
+ registerTool(ctx, {
496
+ name: 'qq_capability_execute',
497
+ description: 'Execute only a registered safe QQ capability in this bound scene; arbitrary OneBot actions are rejected.',
498
+ parameters: { type: 'object', additionalProperties: false, properties: {
499
+ id: { type: 'string' }, args: { type: 'object' },
500
+ }, required: ['id'] },
501
+ timeoutMs: 20_000,
502
+ execute: (args, exec) => capabilities.execute(sessionIdOf(exec), args.id, args.args ?? {}),
503
+ })
504
+
505
+ registerTool(ctx, {
506
+ name: 'qq_transcribe_message_record_native',
507
+ description: 'Transcribe one voice record already present in this Session’s bound QQ message using QQ native PTT transcription; only a message ID is accepted.',
508
+ parameters: { type: 'object', additionalProperties: false, properties: {
509
+ messageId: { type: 'string' },
510
+ }, required: ['messageId'] },
511
+ timeoutMs: 20_000,
512
+ execute: (args, exec) => transcribeMessageRecordNative({
513
+ core,
514
+ store,
515
+ transport,
516
+ sessionId: sessionIdOf(exec),
517
+ messageId: args.messageId,
518
+ }),
519
+ })
520
+
521
+ // Outbound media is restricted to a configured directory. The helper owns
522
+ // root/type/size validation; snapshotOutboundPlan then seals the exact bytes
523
+ // NapCat will read. No model-supplied URL, absolute path or attachment ref.
524
+ if (typeof config.sendRoot === 'string' && path.isAbsolute(config.sendRoot)) {
525
+ registerTool(ctx, {
526
+ name: 'qq_send_media',
527
+ description: 'Send one image, GIF or video from a relative path under the configured media send root.',
528
+ parameters: { type: 'object', additionalProperties: false, properties: {
529
+ actionId: { type: 'string' },
530
+ kind: { type: 'string', enum: ['image', 'gif', 'video', 'audio'] },
531
+ path: { type: 'string' },
532
+ }, required: ['actionId', 'kind', 'path'] },
533
+ timeoutMs: 30_000,
534
+ execute: async (args, exec) => {
535
+ const sessionId = sessionIdOf(exec)
536
+ const plan = await prepareOutboundMedia({
537
+ core,
538
+ sessionId,
539
+ kind: args.kind,
540
+ source: { type: 'send-root', path: args.path },
541
+ sendRoot: config.sendRoot,
542
+ attachments: null,
543
+ })
544
+ if (plan.mode !== 'message') throw new Error('QQ_MEDIA_PLAN_INVALID')
545
+ const snapshot = await snapshotOutboundPlan(plan)
546
+ try {
547
+ return await core.sendPreparedMedia(sessionId, args.actionId, snapshot.plan)
548
+ } finally {
549
+ await snapshot.cleanup()
550
+ }
551
+ },
552
+ })
553
+ registerTool(ctx, {
554
+ name: 'qq_upload_file',
555
+ description: 'Upload one file from a relative path under the configured media send root to this bound QQ scene.',
556
+ parameters: { type: 'object', additionalProperties: false, properties: {
557
+ actionId: { type: 'string' },
558
+ path: { type: 'string' },
559
+ }, required: ['actionId', 'path'] },
560
+ timeoutMs: 60_000,
561
+ execute: async (args, exec) => {
562
+ const sessionId = sessionIdOf(exec)
563
+ const plan = await prepareOutboundMedia({
564
+ core,
565
+ sessionId,
566
+ kind: 'file',
567
+ source: { type: 'send-root', path: args.path },
568
+ sendRoot: config.sendRoot,
569
+ attachments: null,
570
+ })
571
+ if (plan.mode !== 'upload') throw new Error('QQ_MEDIA_PLAN_INVALID')
572
+ const snapshot = await snapshotOutboundPlan(plan)
573
+ try {
574
+ return await core.uploadSceneFile(sessionId, args.actionId, snapshot.plan)
575
+ } finally {
576
+ await snapshot.cleanup()
577
+ }
578
+ },
579
+ })
580
+ }
581
+
582
+ // QZone is optional. Native NapCat publishes posts; a configured loopback
583
+ // provider can supply the remaining read and interaction operations. Every operation re-checks the bound
584
+ // Scene and capability; writes go through the same durable delivery ledger.
585
+ const qzoneAllowed = (sessionId, write) =>
586
+ core.authorizeSessionCapability(sessionId, 'qq.qzone.use') === true ||
587
+ core.authorizeSessionCapability(sessionId, write ? 'qq.qzone.publish' : 'qq.qzone.read') === true
588
+ let qzoneProvider
589
+ if (config.qzoneEnabled === true) {
590
+ try {
591
+ qzoneProvider = createQzoneProvider({
592
+ ...(config.qzoneLoopbackUrl && config.qzoneTokenEnv
593
+ ? { baseUrl: config.qzoneLoopbackUrl, tokenEnv: config.qzoneTokenEnv }
594
+ : {}),
595
+ env: process.env,
596
+ nativeTransport: { action: (name, params, timeoutMs, maxResponseBytes) =>
597
+ transport.action(name, params, timeoutMs, maxResponseBytes) },
598
+ sendRoot: config.sendRoot || undefined,
599
+ })
600
+ } catch {
601
+ ctx.logger?.warn?.('amadeus-qq: QZone provider configuration is unavailable')
602
+ }
603
+ }
604
+ if (qzoneProvider) {
605
+ registerTool(ctx, {
606
+ name: 'qq_qzone',
607
+ description: 'Publish text or controlled images through NapCat; optional loopback provider supplies QZone reads and interactions. Targets must come from this Scene’s observed posts.',
608
+ parameters: { type: 'object', additionalProperties: false, properties: {
609
+ op: { type: 'string', enum: ['own_posts', 'friend_feeds', 'comments', 'publish_text', 'publish_media', 'like', 'unlike', 'comment', 'reply', 'forward', 'delete'] },
610
+ tid: { type: 'string' },
611
+ commentId: { type: 'string' },
612
+ text: { type: 'string' },
613
+ images: { type: 'array', maxItems: 9, items: { type: 'string' } },
614
+ audience: { type: 'string', enum: ['self', 'friends', 'public'] },
615
+ cursor: { type: 'string' },
616
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
617
+ actionId: { type: 'string' },
618
+ }, required: ['op'] },
619
+ timeoutMs: 20_000,
620
+ execute: async (args, exec) => {
621
+ const sessionId = sessionIdOf(exec)
622
+ const scene = core.authorizedScene(sessionId)
623
+ const context = {
624
+ sceneKey: sceneKey(scene),
625
+ sessionId,
626
+ authorize: async request => qzoneAllowed(sessionId, request?.write === true),
627
+ ledger: {
628
+ run: async request => {
629
+ const reserved = store.reserveDelivery(scene, request.actionId, request.fingerprint)
630
+ if (!reserved.fresh) {
631
+ return { status: reserved.delivery.status, repeated: true, result: null }
632
+ }
633
+ if (!qzoneAllowed(sessionId, true)) {
634
+ try { store.settleDelivery(scene, request.actionId, 'failed') } catch { /* keep refusal */ }
635
+ throw new Error('QQ_PERMISSION_CHANGED: qq.qzone.publish')
636
+ }
637
+ const key = `${sceneKey(scene)}:${request.actionId}`
638
+ core.inFlightDeliveries.add(key)
639
+ try {
640
+ const result = await request.execute()
641
+ store.settleDelivery(scene, request.actionId, 'delivered')
642
+ return { status: 'delivered', repeated: false, result }
643
+ } catch (error) {
644
+ const status = error?.code === 'QQ_QZONE_REJECTED' ? 'failed' : 'unknown'
645
+ store.settleDelivery(scene, request.actionId, status)
646
+ throw error
647
+ } finally {
648
+ core.inFlightDeliveries.delete(key)
649
+ }
650
+ },
651
+ },
652
+ }
653
+ switch (args.op) {
654
+ case 'own_posts': return qzoneProvider.listOwnPosts(context, args)
655
+ case 'friend_feeds': return qzoneProvider.listFriendFeeds(context, args)
656
+ case 'comments': return qzoneProvider.listComments(context, args)
657
+ case 'publish_text': return qzoneProvider.publishText(context, args)
658
+ case 'publish_media': return qzoneProvider.publishMedia(context, args)
659
+ case 'like': return qzoneProvider.likePost(context, args)
660
+ case 'unlike': return qzoneProvider.unlikePost(context, args)
661
+ case 'comment': return qzoneProvider.commentPost(context, args)
662
+ case 'reply': return qzoneProvider.replyComment(context, args)
663
+ case 'forward': return qzoneProvider.forwardPost(context, args)
664
+ case 'delete': return qzoneProvider.deleteOwnPost(context, args)
665
+ default: throw new Error('QQ_QZONE_OP_INVALID')
666
+ }
667
+ },
668
+ })
669
+ ctx.effect(() => () => qzoneProvider.close())
670
+ }
671
+
672
+ // Account profile is an Owner-private global action. Reads require a verified
673
+ // Owner Scene; writes additionally require qq.profile.write and an actionId.
674
+ const accountProfile = createAccountProfile({
675
+ core,
676
+ transport,
677
+ sendRoot: config.sendRoot,
678
+ authorize: async request => {
679
+ const sessionId = request?.sessionId
680
+ if (typeof sessionId !== 'string') return false
681
+ try {
682
+ if (core.trustedSceneForSession(sessionId).admin !== true) return false
683
+ } catch {
684
+ return false
685
+ }
686
+ return core.authorizeSessionCapability(
687
+ sessionId,
688
+ request?.write === true ? 'qq.profile.write' : 'qq.read',
689
+ ) === true
690
+ },
691
+ ledger: {
692
+ run: async request => {
693
+ const scene = core.authorizedScene(request.sessionId)
694
+ const reserved = store.reserveDelivery(scene, request.actionId, request.fingerprint)
695
+ if (!reserved.fresh) {
696
+ return { status: reserved.delivery.status, repeated: true, profile: null }
697
+ }
698
+ const key = `${sceneKey(scene)}:${request.actionId}`
699
+ core.inFlightDeliveries.add(key)
700
+ let dispatched = false
701
+ try {
702
+ if (core.authorizeSessionCapability(request.sessionId, 'qq.profile.write') !== true ||
703
+ core.trustedSceneForSession(request.sessionId).admin !== true) {
704
+ store.settleDelivery(scene, request.actionId, 'failed')
705
+ throw new Error('QQ_PROFILE_UNAUTHORIZED')
706
+ }
707
+ dispatched = true
708
+ const profile = await request.execute()
709
+ store.settleDelivery(scene, request.actionId, 'delivered')
710
+ return { status: 'delivered', repeated: false, profile }
711
+ } catch (error) {
712
+ try {
713
+ store.settleDelivery(scene, request.actionId, dispatched ? 'unknown' : 'failed')
714
+ } catch { /* already terminal */ }
715
+ throw error
716
+ } finally {
717
+ core.inFlightDeliveries.delete(key)
718
+ }
719
+ },
720
+ },
721
+ })
722
+ registerTool(ctx, {
723
+ name: 'qq_account_profile',
724
+ description: 'Read or update the current QQ account profile. Only a verified Owner private Scene may use this; writes require an actionId. Avatar changes return platform confirmation without visual readback.',
725
+ parameters: { type: 'object', additionalProperties: false, properties: {
726
+ op: { type: 'string', enum: ['read', 'set_signature', 'set_nickname', 'set_online_status', 'set_avatar'] },
727
+ signature: { type: 'string' },
728
+ nickname: { type: 'string' },
729
+ path: { type: 'string' },
730
+ status: { type: 'integer' },
731
+ extStatus: { type: 'integer' },
732
+ batteryStatus: { type: 'integer' },
733
+ actionId: { type: 'string' },
734
+ }, required: ['op'] },
735
+ timeoutMs: 20_000,
736
+ execute: async (args, exec) => {
737
+ const sessionId = sessionIdOf(exec)
738
+ switch (args.op) {
739
+ case 'read': return accountProfile.readProfile({ sessionId })
740
+ case 'set_signature': return accountProfile.setSignature({ sessionId, signature: args.signature, actionId: args.actionId })
741
+ case 'set_nickname': return accountProfile.setNickname({ sessionId, nickname: args.nickname, actionId: args.actionId })
742
+ case 'set_avatar': return accountProfile.setAvatar({ sessionId, path: args.path, actionId: args.actionId })
743
+ case 'set_online_status': return accountProfile.setOnlineStatus({
744
+ sessionId,
745
+ status: args.status,
746
+ extStatus: args.extStatus,
747
+ batteryStatus: args.batteryStatus,
748
+ actionId: args.actionId,
749
+ })
750
+ default: throw new Error('QQ_PROFILE_OP_INVALID')
751
+ }
752
+ },
753
+ })
754
+
755
+ // Media is optional: wait for the official attachment service without making
756
+ // it a mandatory connector dependency. A text-only Host simply never gets
757
+ // this tool; a configured mediaRoot is still required for a successful read.
758
+ if (typeof ctx.inject === 'function' || typeof ctx.tools?.register === 'function') {
759
+ let imageToolRegistered = false
760
+ let imageAttachments = null
761
+ const resolveImageAttachments = () => {
762
+ if (imageAttachments && typeof imageAttachments.saveImage === 'function') return imageAttachments
763
+ try {
764
+ const provided = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
765
+ if (provided && typeof provided.saveImage === 'function') return provided
766
+ } catch { /* optional service */ }
767
+ const fallback = ctx.attachments
768
+ return fallback && typeof fallback.saveImage === 'function' ? fallback : null
769
+ }
770
+ const registerImageTool = () => {
771
+ if (imageToolRegistered) return
772
+ try {
773
+ imageToolRegistered = true
774
+ registerTool(ctx, {
775
+ name: 'qq_read_message_image',
776
+ description: 'Read one image already present in this Session’s bound QQ message, using only the message ID and image ordinal.',
777
+ parameters: { type: 'object', additionalProperties: false, properties: {
778
+ messageId: { type: 'string' },
779
+ index: { type: 'integer', minimum: 0 },
780
+ }, required: ['messageId'] },
781
+ output: {
782
+ schema: JSON_OBJECT,
783
+ render: (_args, value) => {
784
+ const blocks = [{ type: 'text', text: 'QQ image loaded as an attachment.' }]
785
+ if (value?.attachment && typeof value.attachment === 'object') {
786
+ blocks.push({ type: 'image', attachment: value.attachment })
787
+ }
788
+ return blocks
789
+ },
790
+ },
791
+ execute: (args, exec) => readMessageImage({
792
+ core,
793
+ store,
794
+ transport,
795
+ attachments: resolveImageAttachments(),
796
+ mediaRoot: config.mediaRoot,
797
+ providerCacheRoot: config.providerCacheRoot,
798
+ sessionId: sessionIdOf(exec),
799
+ messageId: args.messageId,
800
+ index: args.index ?? 0,
801
+ }),
802
+ })
803
+ } catch (error) {
804
+ imageToolRegistered = false
805
+ throw error
806
+ }
807
+ }
808
+ let fileToolRegistered = false
809
+ try {
810
+ registerImageTool()
811
+ if (typeof ctx.inject !== 'function') {
812
+ // The image tool remains visible; it will resolve the Host service lazily
813
+ // when the Session actually invokes it.
814
+ } else ctx.inject(['attachments'], scope => {
815
+ const attachments = scope?.attachments ?? ctx.attachments
816
+ if (!attachments) return
817
+ if (attachments && typeof attachments.saveImage === 'function') imageAttachments = attachments
818
+ if (!fileToolRegistered && typeof attachments.saveFile === 'function') {
819
+ fileToolRegistered = true
820
+ registerTool(ctx, {
821
+ name: 'qq_read_message_file',
822
+ description: 'Read one file already present in this Session’s bound QQ message, using only the message ID and file ordinal.',
823
+ parameters: { type: 'object', additionalProperties: false, properties: {
824
+ messageId: { type: 'string' },
825
+ index: { type: 'integer', minimum: 0 },
826
+ }, required: ['messageId'] },
827
+ timeoutMs: 60_000,
828
+ output: {
829
+ schema: JSON_OBJECT,
830
+ render: (_args, value) => {
831
+ const blocks = [{ type: 'text', text: 'QQ file loaded as an attachment.' }]
832
+ if (value?.attachment && typeof value.attachment === 'object') {
833
+ blocks.push({ type: 'file', attachment: value.attachment })
834
+ }
835
+ return blocks
836
+ },
837
+ },
838
+ execute: (args, exec) => readMessageFile({
839
+ core,
840
+ store,
841
+ transport,
842
+ attachments,
843
+ mediaRoot: config.mediaRoot,
844
+ sessionId: sessionIdOf(exec),
845
+ messageId: args.messageId,
846
+ index: args.index ?? 0,
847
+ }),
848
+ })
849
+ registerTool(ctx, {
850
+ name: 'qq_read_message_video',
851
+ description: 'Read one video already present in this Session’s bound QQ message, using only the message ID and video ordinal.',
852
+ parameters: { type: 'object', additionalProperties: false, properties: {
853
+ messageId: { type: 'string' },
854
+ index: { type: 'integer', minimum: 0 },
855
+ }, required: ['messageId'] },
856
+ timeoutMs: 60_000,
857
+ output: {
858
+ schema: JSON_OBJECT,
859
+ render: (_args, value) => {
860
+ const blocks = [{ type: 'text', text: 'QQ video loaded as an attachment.' }]
861
+ if (value?.attachment && typeof value.attachment === 'object') {
862
+ blocks.push({ type: 'file', attachment: value.attachment })
863
+ }
864
+ return blocks
865
+ },
866
+ },
867
+ execute: (args, exec) => readMessageVideo({
868
+ core,
869
+ store,
870
+ transport,
871
+ attachments,
872
+ mediaRoot: config.mediaRoot,
873
+ sessionId: sessionIdOf(exec),
874
+ messageId: args.messageId,
875
+ index: args.index ?? 0,
876
+ }),
877
+ })
878
+ }
879
+ })
880
+ } catch {
881
+ // Missing optional Host media support must not disable ordinary QQ text.
882
+ try { ctx.logger?.warn?.('amadeus-qq: attachment tools unavailable; attachment service could not be injected') } catch { /* optional diagnostic sink */ }
883
+ }
884
+ }
885
+
886
+ // Local record transcription is optional and only registered when an ASR
887
+ // provider id is explicitly configured and the Host speech service is live.
888
+ // The provider id comes from config; the model can supply only message
889
+ // identity and the record ordinal.
890
+ const asrProviderId = typeof config.asrProviderId === 'string' ? config.asrProviderId.trim() : ''
891
+ if (asrProviderId && typeof ctx.inject === 'function') {
892
+ let audioToolRegistered = false
893
+ try {
894
+ ctx.inject(['speechToText'], scope => {
895
+ const speechToText = scope?.speechToText ?? ctx.speechToText
896
+ if (audioToolRegistered ||
897
+ !speechToText ||
898
+ typeof speechToText.listProviders !== 'function' ||
899
+ typeof speechToText.resolve !== 'function' ||
900
+ typeof speechToText.transcribe !== 'function') return
901
+ audioToolRegistered = true
902
+ registerTool(ctx, {
903
+ name: 'qq_transcribe_message_record',
904
+ description: 'Transcribe one voice record already present in this Session’s bound QQ message, using only the message ID and record ordinal.',
905
+ parameters: { type: 'object', additionalProperties: false, properties: {
906
+ messageId: { type: 'string' },
907
+ index: { type: 'integer', minimum: 0 },
908
+ }, required: ['messageId'] },
909
+ timeoutMs: 140_000,
910
+ execute: (args, exec) => transcribeMessageRecord({
911
+ core,
912
+ store,
913
+ transport,
914
+ speechToText,
915
+ mediaRoot: config.mediaRoot,
916
+ providerId: asrProviderId,
917
+ ffmpegPath: config.ffmpegPath,
918
+ sessionId: sessionIdOf(exec),
919
+ messageId: args.messageId,
920
+ index: args.index ?? 0,
921
+ }),
922
+ })
923
+ })
924
+ } catch {
925
+ try { ctx.logger?.warn?.('amadeus-qq: record transcription unavailable; speechToText service could not be injected') } catch { /* optional diagnostic sink */ }
926
+ }
927
+ }
928
+
929
+ // The connector owns the real Owner workspace settings. The preset guard only
930
+ // controls visibility at level 4; it no longer registers this tool itself.
931
+ let ownerWorkspacePolicy
932
+ try {
933
+ ownerWorkspacePolicy = resolveOwnerWorkspacePolicy(config)
934
+ } catch {
935
+ ownerWorkspacePolicy = null
936
+ }
937
+ if (ownerWorkspacePolicy && ownerWorkspacePolicy.root !== '') {
938
+ const getService = () => {
939
+ try {
940
+ const service = typeof ctx.get === 'function' ? ctx.get('qq') : undefined
941
+ if (service) return service
942
+ } catch { /* fall through to the in-process adapter */ }
943
+ return {
944
+ resolveTrustedSessionScene: sessionId => core.trustedSceneForSession(sessionId),
945
+ authorizeSessionCapability: (sessionId, capability) => core.authorizeSessionCapability(sessionId, capability),
946
+ }
947
+ }
948
+ const getApproval = () => {
949
+ try {
950
+ const approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
951
+ return approval ?? ctx.approval
952
+ } catch {
953
+ return undefined
954
+ }
955
+ }
956
+ registerTool(ctx, createOwnerWorkspaceTool({
957
+ root: ownerWorkspacePolicy.root,
958
+ allowWrite: ownerWorkspacePolicy.allowWrite,
959
+ maxBytes: ownerWorkspacePolicy.maxBytes,
960
+ getService,
961
+ getApproval,
962
+ }))
963
+ setOwnerWorkspaceStatus({
964
+ configured: true,
965
+ writes: ownerWorkspacePolicy.allowWrite,
966
+ maxBytes: ownerWorkspacePolicy.maxBytes,
967
+ })
968
+ }
969
+
970
+ ctx.provide('qq', Object.freeze({
971
+ sessionProvenanceVersion: 1,
972
+ classifySession: sessionId => provenance.classify(sessionId),
973
+ getHealth: () => core.health(),
974
+ listAuthorizedScenes: () => core.listAuthorizedScenes(),
975
+ // In-process Host contract for cooperating plugins. The Session ID must
976
+ // come from the active DSH Agent, never from model or HTTP arguments.
977
+ resolveTrustedSessionScene: sessionId => core.trustedSceneForSession(sessionId),
978
+ authorizeSessionCapability: (sessionId, capability) => core.authorizeSessionCapability(sessionId, capability),
979
+ }))
980
+ ctx.effect(() => () => core.close())
981
+ void Promise.resolve().then(async () => {
982
+ try { await transport.start(event => core.ingest(event)) }
983
+ catch { reportStartupFailure(ctx, 'transport'); return }
984
+ try {
985
+ await core.provisionAuthorizedScenes()
986
+ await core.resumePending()
987
+ } catch { reportStartupFailure(ctx, 'session') }
988
+ }).catch(() => reportStartupFailure(ctx, 'session'))
989
+ } catch (error) {
990
+ releaseLease()
991
+ throw error
992
+ }
993
+ }
994
+