@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/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
|
+
|