@fg-lab/amadeus-qq 1.2.1 → 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 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- 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/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- package/src/web-console.js +9 -9
package/src/setup.js
CHANGED
|
@@ -1,255 +1,255 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
|
-
import { normalizeId, sceneKey } from './storage.js'
|
|
3
|
-
import { CAPABILITY_CATALOG } from './permissions.js'
|
|
4
|
-
|
|
5
|
-
const ID_RE = /^[1-9]\d{0,19}$/
|
|
6
|
-
const MAX_SCENES = 200
|
|
7
|
-
const CAPABILITIES = new Set(CAPABILITY_CATALOG.map(item => item.id))
|
|
8
|
-
|
|
9
|
-
function requiredId(value, label) {
|
|
10
|
-
if (typeof value !== 'string' || !ID_RE.test(value)) throw new TypeError(`${label} must be a QQ number`)
|
|
11
|
-
return normalizeId(value, label)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function ids(value, label) {
|
|
15
|
-
if (!Array.isArray(value) || value.length > MAX_SCENES) throw new TypeError(`${label} must be a bounded list`)
|
|
16
|
-
return [...new Set(value.map(item => requiredId(item, label)))]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function localUrl(value, protocol) {
|
|
20
|
-
if (typeof value !== 'string' || value.length > 300) throw new TypeError('NapCat URL is invalid')
|
|
21
|
-
let url
|
|
22
|
-
try { url = new URL(value) } catch { throw new TypeError('NapCat URL is invalid') }
|
|
23
|
-
if (!protocol.includes(url.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash) {
|
|
24
|
-
throw new TypeError('NapCat must use a local HTTP or WebSocket address')
|
|
25
|
-
}
|
|
26
|
-
return url.href.replace(/\/$/, '')
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function entryFor(configEditor) {
|
|
30
|
-
if (!configEditor || typeof configEditor.entries !== 'function' || typeof configEditor.edit !== 'function') {
|
|
31
|
-
throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
32
|
-
}
|
|
33
|
-
const entry = configEditor.entries().find(item => item.options?.id === 'amadeus-qq')
|
|
34
|
-
if (!entry) throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
35
|
-
return entry
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function revisionOf(config) {
|
|
39
|
-
return Number.isSafeInteger(config?.setupRevision) && config.setupRevision >= 0 ? config.setupRevision : 0
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function createSetupService(configEditor, workspaceRegistry) {
|
|
43
|
-
function listWorkspaces() {
|
|
44
|
-
if (!workspaceRegistry || typeof workspaceRegistry.list !== 'function') throw new Error('QQ_WORKSPACE_UNAVAILABLE')
|
|
45
|
-
return workspaceRegistry.list().map(item => ({ id: item.id, title: item.title, path: item.path }))
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function status() {
|
|
49
|
-
const config = entryFor(configEditor).options.config ?? {}
|
|
50
|
-
return {
|
|
51
|
-
revision: revisionOf(config),
|
|
52
|
-
enabled: config.enabled === true,
|
|
53
|
-
connect: config.connect === true,
|
|
54
|
-
workspaceId: config.workspaceId ?? '',
|
|
55
|
-
botId: config.policy?.botIds?.[0] ?? '',
|
|
56
|
-
ownerId: config.permissions?.ownerPrivateIds?.[0] ?? '',
|
|
57
|
-
globalDeny: config.permissions?.globalDeny ?? [],
|
|
58
|
-
httpUrl: config.httpUrl ?? '',
|
|
59
|
-
wsUrl: config.wsUrl ?? '',
|
|
60
|
-
tokenConfigured: Boolean(config.token || config.tokenEnv),
|
|
61
|
-
qzoneEnabled: config.qzoneEnabled === true,
|
|
62
|
-
qzonePublish: config.permissions?.accountGrants?.byBotId?.[config.policy?.botIds?.[0]]?.qzonePublish === true,
|
|
63
|
-
qzoneLoopbackUrl: config.qzoneLoopbackUrl ?? '',
|
|
64
|
-
qzoneTokenEnv: config.qzoneTokenEnv ?? '',
|
|
65
|
-
groups: config.policy?.groupIds ?? [],
|
|
66
|
-
privatePeers: config.policy?.privateIds ?? [],
|
|
67
|
-
pausedScenes: config.policy?.pausedScenes ?? [],
|
|
68
|
-
workspaces: listWorkspaces(),
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function save(body, beforePause = null) {
|
|
73
|
-
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('setup request is invalid')
|
|
74
|
-
const allowed = new Set(['expectedRevision', 'workspaceId', 'botId', 'ownerId', 'httpUrl', 'wsUrl', 'token', 'groups', 'privatePeers', 'connect', 'globalDeny'])
|
|
75
|
-
if (Object.keys(body).some(key => !allowed.has(key))) throw new TypeError('setup contains unsupported fields')
|
|
76
|
-
if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new TypeError('setup revision is invalid')
|
|
77
|
-
if (typeof body.workspaceId !== 'string' || !listWorkspaces().some(item => item.id === body.workspaceId)) {
|
|
78
|
-
throw new TypeError('Select an existing DSH Workspace')
|
|
79
|
-
}
|
|
80
|
-
const botId = requiredId(body.botId, 'botId')
|
|
81
|
-
const ownerId = requiredId(body.ownerId, 'ownerId')
|
|
82
|
-
if (ownerId === botId) throw new TypeError('Owner QQ number must differ from Bot QQ number')
|
|
83
|
-
if (body.globalDeny !== undefined && (!Array.isArray(body.globalDeny) || body.globalDeny.length > CAPABILITIES.size ||
|
|
84
|
-
body.globalDeny.some(item => typeof item !== 'string' || !CAPABILITIES.has(item)))) {
|
|
85
|
-
throw new TypeError('globalDeny contains an unknown capability')
|
|
86
|
-
}
|
|
87
|
-
const groups = ids(body.groups ?? [], 'groups')
|
|
88
|
-
const privatePeers = ids(body.privatePeers ?? [], 'privatePeers')
|
|
89
|
-
if (!privatePeers.includes(ownerId)) privatePeers.unshift(ownerId)
|
|
90
|
-
const httpUrl = localUrl(body.httpUrl, ['http:', 'https:'])
|
|
91
|
-
const wsUrl = localUrl(body.wsUrl, ['ws:', 'wss:'])
|
|
92
|
-
if (body.connect !== true && body.connect !== false) throw new TypeError('connect must be a boolean')
|
|
93
|
-
if (body.token !== undefined && (typeof body.token !== 'string' || body.token.length > 256 || /[\r\n]/.test(body.token))) {
|
|
94
|
-
throw new TypeError('NapCat token is invalid')
|
|
95
|
-
}
|
|
96
|
-
const entry = entryFor(configEditor)
|
|
97
|
-
const profileDir = path.dirname(configEditor.documentPath)
|
|
98
|
-
const existing = entry.options.config ?? {}
|
|
99
|
-
if (revisionOf(existing) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
100
|
-
if (existing.connect === true && !body.connect) await beforePause?.()
|
|
101
|
-
await configEditor.edit(entry, current => {
|
|
102
|
-
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
103
|
-
const oldPolicy = current.policy ?? {}
|
|
104
|
-
const oldBot = oldPolicy.botIds ?? []
|
|
105
|
-
const changed = JSON.stringify([oldBot, oldPolicy.groupIds ?? [], oldPolicy.privateIds ?? []]) !==
|
|
106
|
-
JSON.stringify([[botId], groups, privatePeers])
|
|
107
|
-
const oldScenes = oldBot.flatMap(id => [
|
|
108
|
-
...(oldPolicy.groupIds ?? []).map(peerId => sceneKey({ botId: id, kind: 'group', peerId })),
|
|
109
|
-
...(oldPolicy.privateIds ?? []).map(peerId => sceneKey({ botId: id, kind: 'private', peerId })),
|
|
110
|
-
])
|
|
111
|
-
const newScenes = new Set([
|
|
112
|
-
...groups.map(peerId => sceneKey({ botId, kind: 'group', peerId })),
|
|
113
|
-
...privatePeers.map(peerId => sceneKey({ botId, kind: 'private', peerId })),
|
|
114
|
-
])
|
|
115
|
-
const sceneRevisions = { ...(oldPolicy.sceneRevisions ?? {}) }
|
|
116
|
-
for (const key of oldScenes) {
|
|
117
|
-
if (!newScenes.has(key)) sceneRevisions[key] = (sceneRevisions[key] ?? 0) + 1
|
|
118
|
-
}
|
|
119
|
-
const nextPolicy = {
|
|
120
|
-
...oldPolicy,
|
|
121
|
-
sceneRevisions,
|
|
122
|
-
botIds: [botId], groupIds: groups, privateIds: privatePeers,
|
|
123
|
-
pausedScenes: (oldPolicy.pausedScenes ?? []).filter(key => {
|
|
124
|
-
const [b, kind, peer] = key.split(':')
|
|
125
|
-
return b === botId && (kind === 'group' ? groups.includes(peer) : kind === 'private' && privatePeers.includes(peer))
|
|
126
|
-
}),
|
|
127
|
-
epoch: (oldPolicy.epoch ?? 0) + Number(changed),
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
...current,
|
|
131
|
-
setupRevision: body.expectedRevision + 1,
|
|
132
|
-
enabled: true,
|
|
133
|
-
connect: body.connect,
|
|
134
|
-
workspaceId: body.workspaceId,
|
|
135
|
-
agentPreset: 'amadeus-qq',
|
|
136
|
-
statePath: current.statePath || path.join(profileDir, 'data', 'amadeus-qq', 'state.json'),
|
|
137
|
-
sendRoot: current.sendRoot || path.join(profileDir, 'data', 'amadeus-qq', 'send'),
|
|
138
|
-
mediaRoot: current.mediaRoot || path.join(profileDir, 'data', 'amadeus-qq', 'media'),
|
|
139
|
-
httpUrl, wsUrl,
|
|
140
|
-
...(body.token === undefined ? {} : { token: body.token, tokenEnv: '' }),
|
|
141
|
-
policy: nextPolicy,
|
|
142
|
-
permissions: { ...(current.permissions ?? {}), ownerPrivateIds: [ownerId],
|
|
143
|
-
...(body.globalDeny === undefined ? {} : { globalDeny: [...new Set(body.globalDeny)] }) },
|
|
144
|
-
}
|
|
145
|
-
})
|
|
146
|
-
return status()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function setScene(body) {
|
|
150
|
-
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
151
|
-
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
152
|
-
typeof body.allowed !== 'boolean') throw new TypeError('Scene request is invalid')
|
|
153
|
-
const scene = { botId: requiredId(body.botId, 'botId'), kind: body.kind, peerId: requiredId(body.peerId, 'peerId') }
|
|
154
|
-
if (!['group', 'private'].includes(scene.kind)) throw new TypeError('Scene kind is invalid')
|
|
155
|
-
const entry = entryFor(configEditor)
|
|
156
|
-
await configEditor.edit(entry, current => {
|
|
157
|
-
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
158
|
-
if (!(current.policy?.botIds ?? []).includes(scene.botId)) throw new TypeError('Scene bot is not configured')
|
|
159
|
-
const field = scene.kind === 'group' ? 'groupIds' : 'privateIds'
|
|
160
|
-
const ownerId = current.permissions?.ownerPrivateIds?.[0]
|
|
161
|
-
if (!body.allowed && scene.kind === 'private' && scene.peerId === ownerId) throw new TypeError('Owner private Scene cannot be removed')
|
|
162
|
-
const values = new Set(current.policy?.[field] ?? [])
|
|
163
|
-
const wasAllowed = values.has(scene.peerId)
|
|
164
|
-
if (body.allowed) values.add(scene.peerId)
|
|
165
|
-
else values.delete(scene.peerId)
|
|
166
|
-
if (values.size > MAX_SCENES) throw new TypeError('Scene list is full')
|
|
167
|
-
const sceneRevisions = { ...(current.policy?.sceneRevisions ?? {}) }
|
|
168
|
-
if (!body.allowed && wasAllowed) {
|
|
169
|
-
const key = sceneKey(scene)
|
|
170
|
-
sceneRevisions[key] = (sceneRevisions[key] ?? 0) + 1
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
...current, setupRevision: body.expectedRevision + 1,
|
|
174
|
-
policy: { ...current.policy, [field]: [...values], sceneRevisions,
|
|
175
|
-
epoch: (current.policy?.epoch ?? 0) + 1,
|
|
176
|
-
pausedScenes: (current.policy?.pausedScenes ?? []).filter(key => key !== sceneKey(scene)) },
|
|
177
|
-
}
|
|
178
|
-
})
|
|
179
|
-
return status()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** One-click Owner handoff: change only the connection bit. In particular,
|
|
183
|
-
* no Scene ACL, Workspace or permission revision changes here. */
|
|
184
|
-
async function setConnection(body, beforePause = null) {
|
|
185
|
-
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
186
|
-
Object.keys(body).some(key => !['expectedRevision', 'connect'].includes(key)) ||
|
|
187
|
-
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
188
|
-
typeof body.connect !== 'boolean') throw new TypeError('handoff request is invalid')
|
|
189
|
-
const entry = entryFor(configEditor)
|
|
190
|
-
const existing = entry.options.config ?? {}
|
|
191
|
-
if (revisionOf(existing) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
192
|
-
if (!body.connect) await beforePause?.()
|
|
193
|
-
await configEditor.edit(entry, current => {
|
|
194
|
-
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
195
|
-
if (current.enabled !== true || !current.workspaceId) throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
196
|
-
if (current.connect === body.connect) return current
|
|
197
|
-
return { ...current, setupRevision: body.expectedRevision + 1, connect: body.connect }
|
|
198
|
-
})
|
|
199
|
-
return status()
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function pauseScene(body) {
|
|
203
|
-
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
204
|
-
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
205
|
-
typeof body.paused !== 'boolean') throw new TypeError('Pause request is invalid')
|
|
206
|
-
const scene = { botId: requiredId(body.botId, 'botId'), kind: body.kind, peerId: requiredId(body.peerId, 'peerId') }
|
|
207
|
-
if (!['group', 'private'].includes(scene.kind)) throw new TypeError('Scene kind is invalid')
|
|
208
|
-
const entry = entryFor(configEditor)
|
|
209
|
-
await configEditor.edit(entry, current => {
|
|
210
|
-
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
211
|
-
if (!(current.policy?.botIds ?? []).includes(scene.botId) ||
|
|
212
|
-
!(current.policy?.[scene.kind === 'group' ? 'groupIds' : 'privateIds'] ?? []).includes(scene.peerId)) {
|
|
213
|
-
throw new TypeError('Scene is not authorized')
|
|
214
|
-
}
|
|
215
|
-
const paused = new Set(current.policy?.pausedScenes ?? [])
|
|
216
|
-
if (body.paused) paused.add(sceneKey(scene))
|
|
217
|
-
else paused.delete(sceneKey(scene))
|
|
218
|
-
return { ...current, setupRevision: body.expectedRevision + 1,
|
|
219
|
-
policy: { ...current.policy, pausedScenes: [...paused] } }
|
|
220
|
-
})
|
|
221
|
-
return status()
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function saveAccount(body) {
|
|
225
|
-
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
226
|
-
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
227
|
-
typeof body.qzoneEnabled !== 'boolean' || typeof body.qzonePublish !== 'boolean' ||
|
|
228
|
-
typeof body.qzoneLoopbackUrl !== 'string' || typeof body.qzoneTokenEnv !== 'string') {
|
|
229
|
-
throw new TypeError('Account settings are invalid')
|
|
230
|
-
}
|
|
231
|
-
const url = body.qzoneLoopbackUrl ? localUrl(body.qzoneLoopbackUrl, ['http:', 'https:']) : ''
|
|
232
|
-
if (body.qzoneTokenEnv && !/^[A-Z][A-Z0-9_]{0,99}$/.test(body.qzoneTokenEnv)) {
|
|
233
|
-
throw new TypeError('QZone token environment variable name is invalid')
|
|
234
|
-
}
|
|
235
|
-
const entry = entryFor(configEditor)
|
|
236
|
-
await configEditor.edit(entry, current => {
|
|
237
|
-
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
238
|
-
const botId = current.policy?.botIds?.[0]
|
|
239
|
-
if (!botId) throw new TypeError('Configure Bot before account settings')
|
|
240
|
-
const permissions = current.permissions ?? {}
|
|
241
|
-
const accountGrants = permissions.accountGrants ?? {}
|
|
242
|
-
return {
|
|
243
|
-
...current, setupRevision: body.expectedRevision + 1,
|
|
244
|
-
qzoneEnabled: body.qzoneEnabled, qzoneLoopbackUrl: url, qzoneTokenEnv: body.qzoneTokenEnv,
|
|
245
|
-
permissions: { ...permissions, accountGrants: {
|
|
246
|
-
...accountGrants, byBotId: { ...(accountGrants.byBotId ?? {}),
|
|
247
|
-
[botId]: { ...(accountGrants.byBotId?.[botId] ?? {}), qzonePublish: body.qzonePublish } },
|
|
248
|
-
} },
|
|
249
|
-
}
|
|
250
|
-
})
|
|
251
|
-
return status()
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return { status, save, setScene, pauseScene, saveAccount, setConnection }
|
|
255
|
-
}
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { normalizeId, sceneKey } from './storage.js'
|
|
3
|
+
import { CAPABILITY_CATALOG } from './permissions.js'
|
|
4
|
+
|
|
5
|
+
const ID_RE = /^[1-9]\d{0,19}$/
|
|
6
|
+
const MAX_SCENES = 200
|
|
7
|
+
const CAPABILITIES = new Set(CAPABILITY_CATALOG.map(item => item.id))
|
|
8
|
+
|
|
9
|
+
function requiredId(value, label) {
|
|
10
|
+
if (typeof value !== 'string' || !ID_RE.test(value)) throw new TypeError(`${label} must be a QQ number`)
|
|
11
|
+
return normalizeId(value, label)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ids(value, label) {
|
|
15
|
+
if (!Array.isArray(value) || value.length > MAX_SCENES) throw new TypeError(`${label} must be a bounded list`)
|
|
16
|
+
return [...new Set(value.map(item => requiredId(item, label)))]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function localUrl(value, protocol) {
|
|
20
|
+
if (typeof value !== 'string' || value.length > 300) throw new TypeError('NapCat URL is invalid')
|
|
21
|
+
let url
|
|
22
|
+
try { url = new URL(value) } catch { throw new TypeError('NapCat URL is invalid') }
|
|
23
|
+
if (!protocol.includes(url.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash) {
|
|
24
|
+
throw new TypeError('NapCat must use a local HTTP or WebSocket address')
|
|
25
|
+
}
|
|
26
|
+
return url.href.replace(/\/$/, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function entryFor(configEditor) {
|
|
30
|
+
if (!configEditor || typeof configEditor.entries !== 'function' || typeof configEditor.edit !== 'function') {
|
|
31
|
+
throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
32
|
+
}
|
|
33
|
+
const entry = configEditor.entries().find(item => item.options?.id === 'amadeus-qq')
|
|
34
|
+
if (!entry) throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
35
|
+
return entry
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function revisionOf(config) {
|
|
39
|
+
return Number.isSafeInteger(config?.setupRevision) && config.setupRevision >= 0 ? config.setupRevision : 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createSetupService(configEditor, workspaceRegistry) {
|
|
43
|
+
function listWorkspaces() {
|
|
44
|
+
if (!workspaceRegistry || typeof workspaceRegistry.list !== 'function') throw new Error('QQ_WORKSPACE_UNAVAILABLE')
|
|
45
|
+
return workspaceRegistry.list().map(item => ({ id: item.id, title: item.title, path: item.path }))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function status() {
|
|
49
|
+
const config = entryFor(configEditor).options.config ?? {}
|
|
50
|
+
return {
|
|
51
|
+
revision: revisionOf(config),
|
|
52
|
+
enabled: config.enabled === true,
|
|
53
|
+
connect: config.connect === true,
|
|
54
|
+
workspaceId: config.workspaceId ?? '',
|
|
55
|
+
botId: config.policy?.botIds?.[0] ?? '',
|
|
56
|
+
ownerId: config.permissions?.ownerPrivateIds?.[0] ?? '',
|
|
57
|
+
globalDeny: config.permissions?.globalDeny ?? [],
|
|
58
|
+
httpUrl: config.httpUrl ?? '',
|
|
59
|
+
wsUrl: config.wsUrl ?? '',
|
|
60
|
+
tokenConfigured: Boolean(config.token || config.tokenEnv),
|
|
61
|
+
qzoneEnabled: config.qzoneEnabled === true,
|
|
62
|
+
qzonePublish: config.permissions?.accountGrants?.byBotId?.[config.policy?.botIds?.[0]]?.qzonePublish === true,
|
|
63
|
+
qzoneLoopbackUrl: config.qzoneLoopbackUrl ?? '',
|
|
64
|
+
qzoneTokenEnv: config.qzoneTokenEnv ?? '',
|
|
65
|
+
groups: config.policy?.groupIds ?? [],
|
|
66
|
+
privatePeers: config.policy?.privateIds ?? [],
|
|
67
|
+
pausedScenes: config.policy?.pausedScenes ?? [],
|
|
68
|
+
workspaces: listWorkspaces(),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function save(body, beforePause = null) {
|
|
73
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('setup request is invalid')
|
|
74
|
+
const allowed = new Set(['expectedRevision', 'workspaceId', 'botId', 'ownerId', 'httpUrl', 'wsUrl', 'token', 'groups', 'privatePeers', 'connect', 'globalDeny'])
|
|
75
|
+
if (Object.keys(body).some(key => !allowed.has(key))) throw new TypeError('setup contains unsupported fields')
|
|
76
|
+
if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) throw new TypeError('setup revision is invalid')
|
|
77
|
+
if (typeof body.workspaceId !== 'string' || !listWorkspaces().some(item => item.id === body.workspaceId)) {
|
|
78
|
+
throw new TypeError('Select an existing DSH Workspace')
|
|
79
|
+
}
|
|
80
|
+
const botId = requiredId(body.botId, 'botId')
|
|
81
|
+
const ownerId = requiredId(body.ownerId, 'ownerId')
|
|
82
|
+
if (ownerId === botId) throw new TypeError('Owner QQ number must differ from Bot QQ number')
|
|
83
|
+
if (body.globalDeny !== undefined && (!Array.isArray(body.globalDeny) || body.globalDeny.length > CAPABILITIES.size ||
|
|
84
|
+
body.globalDeny.some(item => typeof item !== 'string' || !CAPABILITIES.has(item)))) {
|
|
85
|
+
throw new TypeError('globalDeny contains an unknown capability')
|
|
86
|
+
}
|
|
87
|
+
const groups = ids(body.groups ?? [], 'groups')
|
|
88
|
+
const privatePeers = ids(body.privatePeers ?? [], 'privatePeers')
|
|
89
|
+
if (!privatePeers.includes(ownerId)) privatePeers.unshift(ownerId)
|
|
90
|
+
const httpUrl = localUrl(body.httpUrl, ['http:', 'https:'])
|
|
91
|
+
const wsUrl = localUrl(body.wsUrl, ['ws:', 'wss:'])
|
|
92
|
+
if (body.connect !== true && body.connect !== false) throw new TypeError('connect must be a boolean')
|
|
93
|
+
if (body.token !== undefined && (typeof body.token !== 'string' || body.token.length > 256 || /[\r\n]/.test(body.token))) {
|
|
94
|
+
throw new TypeError('NapCat token is invalid')
|
|
95
|
+
}
|
|
96
|
+
const entry = entryFor(configEditor)
|
|
97
|
+
const profileDir = path.dirname(configEditor.documentPath)
|
|
98
|
+
const existing = entry.options.config ?? {}
|
|
99
|
+
if (revisionOf(existing) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
100
|
+
if (existing.connect === true && !body.connect) await beforePause?.()
|
|
101
|
+
await configEditor.edit(entry, current => {
|
|
102
|
+
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
103
|
+
const oldPolicy = current.policy ?? {}
|
|
104
|
+
const oldBot = oldPolicy.botIds ?? []
|
|
105
|
+
const changed = JSON.stringify([oldBot, oldPolicy.groupIds ?? [], oldPolicy.privateIds ?? []]) !==
|
|
106
|
+
JSON.stringify([[botId], groups, privatePeers])
|
|
107
|
+
const oldScenes = oldBot.flatMap(id => [
|
|
108
|
+
...(oldPolicy.groupIds ?? []).map(peerId => sceneKey({ botId: id, kind: 'group', peerId })),
|
|
109
|
+
...(oldPolicy.privateIds ?? []).map(peerId => sceneKey({ botId: id, kind: 'private', peerId })),
|
|
110
|
+
])
|
|
111
|
+
const newScenes = new Set([
|
|
112
|
+
...groups.map(peerId => sceneKey({ botId, kind: 'group', peerId })),
|
|
113
|
+
...privatePeers.map(peerId => sceneKey({ botId, kind: 'private', peerId })),
|
|
114
|
+
])
|
|
115
|
+
const sceneRevisions = { ...(oldPolicy.sceneRevisions ?? {}) }
|
|
116
|
+
for (const key of oldScenes) {
|
|
117
|
+
if (!newScenes.has(key)) sceneRevisions[key] = (sceneRevisions[key] ?? 0) + 1
|
|
118
|
+
}
|
|
119
|
+
const nextPolicy = {
|
|
120
|
+
...oldPolicy,
|
|
121
|
+
sceneRevisions,
|
|
122
|
+
botIds: [botId], groupIds: groups, privateIds: privatePeers,
|
|
123
|
+
pausedScenes: (oldPolicy.pausedScenes ?? []).filter(key => {
|
|
124
|
+
const [b, kind, peer] = key.split(':')
|
|
125
|
+
return b === botId && (kind === 'group' ? groups.includes(peer) : kind === 'private' && privatePeers.includes(peer))
|
|
126
|
+
}),
|
|
127
|
+
epoch: (oldPolicy.epoch ?? 0) + Number(changed),
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...current,
|
|
131
|
+
setupRevision: body.expectedRevision + 1,
|
|
132
|
+
enabled: true,
|
|
133
|
+
connect: body.connect,
|
|
134
|
+
workspaceId: body.workspaceId,
|
|
135
|
+
agentPreset: 'amadeus-qq',
|
|
136
|
+
statePath: current.statePath || path.join(profileDir, 'data', 'amadeus-qq', 'state.json'),
|
|
137
|
+
sendRoot: current.sendRoot || path.join(profileDir, 'data', 'amadeus-qq', 'send'),
|
|
138
|
+
mediaRoot: current.mediaRoot || path.join(profileDir, 'data', 'amadeus-qq', 'media'),
|
|
139
|
+
httpUrl, wsUrl,
|
|
140
|
+
...(body.token === undefined ? {} : { token: body.token, tokenEnv: '' }),
|
|
141
|
+
policy: nextPolicy,
|
|
142
|
+
permissions: { ...(current.permissions ?? {}), ownerPrivateIds: [ownerId],
|
|
143
|
+
...(body.globalDeny === undefined ? {} : { globalDeny: [...new Set(body.globalDeny)] }) },
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
return status()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function setScene(body) {
|
|
150
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
151
|
+
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
152
|
+
typeof body.allowed !== 'boolean') throw new TypeError('Scene request is invalid')
|
|
153
|
+
const scene = { botId: requiredId(body.botId, 'botId'), kind: body.kind, peerId: requiredId(body.peerId, 'peerId') }
|
|
154
|
+
if (!['group', 'private'].includes(scene.kind)) throw new TypeError('Scene kind is invalid')
|
|
155
|
+
const entry = entryFor(configEditor)
|
|
156
|
+
await configEditor.edit(entry, current => {
|
|
157
|
+
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
158
|
+
if (!(current.policy?.botIds ?? []).includes(scene.botId)) throw new TypeError('Scene bot is not configured')
|
|
159
|
+
const field = scene.kind === 'group' ? 'groupIds' : 'privateIds'
|
|
160
|
+
const ownerId = current.permissions?.ownerPrivateIds?.[0]
|
|
161
|
+
if (!body.allowed && scene.kind === 'private' && scene.peerId === ownerId) throw new TypeError('Owner private Scene cannot be removed')
|
|
162
|
+
const values = new Set(current.policy?.[field] ?? [])
|
|
163
|
+
const wasAllowed = values.has(scene.peerId)
|
|
164
|
+
if (body.allowed) values.add(scene.peerId)
|
|
165
|
+
else values.delete(scene.peerId)
|
|
166
|
+
if (values.size > MAX_SCENES) throw new TypeError('Scene list is full')
|
|
167
|
+
const sceneRevisions = { ...(current.policy?.sceneRevisions ?? {}) }
|
|
168
|
+
if (!body.allowed && wasAllowed) {
|
|
169
|
+
const key = sceneKey(scene)
|
|
170
|
+
sceneRevisions[key] = (sceneRevisions[key] ?? 0) + 1
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
...current, setupRevision: body.expectedRevision + 1,
|
|
174
|
+
policy: { ...current.policy, [field]: [...values], sceneRevisions,
|
|
175
|
+
epoch: (current.policy?.epoch ?? 0) + 1,
|
|
176
|
+
pausedScenes: (current.policy?.pausedScenes ?? []).filter(key => key !== sceneKey(scene)) },
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
return status()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One-click Owner handoff: change only the connection bit. In particular,
|
|
183
|
+
* no Scene ACL, Workspace or permission revision changes here. */
|
|
184
|
+
async function setConnection(body, beforePause = null) {
|
|
185
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
186
|
+
Object.keys(body).some(key => !['expectedRevision', 'connect'].includes(key)) ||
|
|
187
|
+
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
188
|
+
typeof body.connect !== 'boolean') throw new TypeError('handoff request is invalid')
|
|
189
|
+
const entry = entryFor(configEditor)
|
|
190
|
+
const existing = entry.options.config ?? {}
|
|
191
|
+
if (revisionOf(existing) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
192
|
+
if (!body.connect) await beforePause?.()
|
|
193
|
+
await configEditor.edit(entry, current => {
|
|
194
|
+
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
195
|
+
if (current.enabled !== true || !current.workspaceId) throw new Error('QQ_SETUP_UNAVAILABLE')
|
|
196
|
+
if (current.connect === body.connect) return current
|
|
197
|
+
return { ...current, setupRevision: body.expectedRevision + 1, connect: body.connect }
|
|
198
|
+
})
|
|
199
|
+
return status()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function pauseScene(body) {
|
|
203
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
204
|
+
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
205
|
+
typeof body.paused !== 'boolean') throw new TypeError('Pause request is invalid')
|
|
206
|
+
const scene = { botId: requiredId(body.botId, 'botId'), kind: body.kind, peerId: requiredId(body.peerId, 'peerId') }
|
|
207
|
+
if (!['group', 'private'].includes(scene.kind)) throw new TypeError('Scene kind is invalid')
|
|
208
|
+
const entry = entryFor(configEditor)
|
|
209
|
+
await configEditor.edit(entry, current => {
|
|
210
|
+
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
211
|
+
if (!(current.policy?.botIds ?? []).includes(scene.botId) ||
|
|
212
|
+
!(current.policy?.[scene.kind === 'group' ? 'groupIds' : 'privateIds'] ?? []).includes(scene.peerId)) {
|
|
213
|
+
throw new TypeError('Scene is not authorized')
|
|
214
|
+
}
|
|
215
|
+
const paused = new Set(current.policy?.pausedScenes ?? [])
|
|
216
|
+
if (body.paused) paused.add(sceneKey(scene))
|
|
217
|
+
else paused.delete(sceneKey(scene))
|
|
218
|
+
return { ...current, setupRevision: body.expectedRevision + 1,
|
|
219
|
+
policy: { ...current.policy, pausedScenes: [...paused] } }
|
|
220
|
+
})
|
|
221
|
+
return status()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function saveAccount(body) {
|
|
225
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) ||
|
|
226
|
+
!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0 ||
|
|
227
|
+
typeof body.qzoneEnabled !== 'boolean' || typeof body.qzonePublish !== 'boolean' ||
|
|
228
|
+
typeof body.qzoneLoopbackUrl !== 'string' || typeof body.qzoneTokenEnv !== 'string') {
|
|
229
|
+
throw new TypeError('Account settings are invalid')
|
|
230
|
+
}
|
|
231
|
+
const url = body.qzoneLoopbackUrl ? localUrl(body.qzoneLoopbackUrl, ['http:', 'https:']) : ''
|
|
232
|
+
if (body.qzoneTokenEnv && !/^[A-Z][A-Z0-9_]{0,99}$/.test(body.qzoneTokenEnv)) {
|
|
233
|
+
throw new TypeError('QZone token environment variable name is invalid')
|
|
234
|
+
}
|
|
235
|
+
const entry = entryFor(configEditor)
|
|
236
|
+
await configEditor.edit(entry, current => {
|
|
237
|
+
if (revisionOf(current) !== body.expectedRevision) throw new Error('QQ_SETUP_CONFLICT')
|
|
238
|
+
const botId = current.policy?.botIds?.[0]
|
|
239
|
+
if (!botId) throw new TypeError('Configure Bot before account settings')
|
|
240
|
+
const permissions = current.permissions ?? {}
|
|
241
|
+
const accountGrants = permissions.accountGrants ?? {}
|
|
242
|
+
return {
|
|
243
|
+
...current, setupRevision: body.expectedRevision + 1,
|
|
244
|
+
qzoneEnabled: body.qzoneEnabled, qzoneLoopbackUrl: url, qzoneTokenEnv: body.qzoneTokenEnv,
|
|
245
|
+
permissions: { ...permissions, accountGrants: {
|
|
246
|
+
...accountGrants, byBotId: { ...(accountGrants.byBotId ?? {}),
|
|
247
|
+
[botId]: { ...(accountGrants.byBotId?.[botId] ?? {}), qzonePublish: body.qzonePublish } },
|
|
248
|
+
} },
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
return status()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { status, save, setScene, pauseScene, saveAccount, setConnection }
|
|
255
|
+
}
|
package/src/web-console.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
|
|
3
|
-
// Shipped, static assets. Runtime values are fetched only after the Host's
|
|
4
|
-
// same-origin DSH Owner admission in admin-http.js.
|
|
5
|
-
const asset = name => fs.readFileSync(new URL(`./console/${name}`, import.meta.url), 'utf8')
|
|
6
|
-
|
|
7
|
-
export const CONSOLE_HTML = asset('index.html')
|
|
8
|
-
export const CONSOLE_CSS = asset('console.css')
|
|
9
|
-
export const CONSOLE_JS = asset('console.js')
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// Shipped, static assets. Runtime values are fetched only after the Host's
|
|
4
|
+
// same-origin DSH Owner admission in admin-http.js.
|
|
5
|
+
const asset = name => fs.readFileSync(new URL(`./console/${name}`, import.meta.url), 'utf8')
|
|
6
|
+
|
|
7
|
+
export const CONSOLE_HTML = asset('index.html')
|
|
8
|
+
export const CONSOLE_CSS = asset('console.css')
|
|
9
|
+
export const CONSOLE_JS = asset('console.js')
|