@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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
package/lib/client.js CHANGED
@@ -3,378 +3,378 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  const React = require('react');
5
5
  const h = React.createElement;
6
- // Pure client-side helpers, inlined into the browser bundle by build-client.
7
- // No ESM imports here: the bundle is a lazy-CJS factory with only React injected.
8
-
9
- const API_ROOT = '/api/amadeus-qq'
10
- const MODE_IDS = Object.freeze([
11
- 'observe', 'safe-chat', 'enhanced', 'autonomous-social', 'owner-agent',
12
- ])
13
- const OVERRIDE_VALUES = new Set(['inherit', 'allow', 'deny'])
14
-
15
- function revisionOf(entry) {
16
- return entry?.revision ?? 0
17
- }
18
-
19
- function draftFromEntry(entry) {
20
- const overrides = {}
21
- for (const capability of entry?.capabilities ?? []) {
22
- if (capability.override === 'allow' || capability.override === 'deny') {
23
- overrides[capability.id] = capability.override
24
- }
25
- }
26
- return {
27
- sceneKey: entry?.key ?? '',
28
- mode: entry?.permissions?.mode ?? entry?.effectiveMode ?? 'safe-chat',
29
- overrides,
30
- expectedRevision: revisionOf(entry),
31
- }
32
- }
33
-
34
- function isDraftDirty(entry, draft) {
35
- if (!entry || !draft) return false
36
- const base = draftFromEntry(entry)
37
- if (base.mode !== draft.mode) return true
38
- const keys = new Set([...Object.keys(base.overrides), ...Object.keys(draft.overrides ?? {})])
39
- for (const key of keys) {
40
- if ((base.overrides[key] ?? 'inherit') !== (draft.overrides?.[key] ?? 'inherit')) return true
41
- }
42
- return false
43
- }
44
-
45
- /**
46
- * Decide which draft the editor should show after a Scene row is selected.
47
- * A dirty draft is never replaced: the operator must save or cancel first, so
48
- * an unsaved permission change can never be lost by an accidental click.
49
- */
50
- function nextDraftForSelection(currentEntry, currentDraft, targetEntry) {
51
- if (currentDraft && isDraftDirty(currentEntry, currentDraft)) {
52
- return { blocked: true, draft: currentDraft }
53
- }
54
- if (!targetEntry) return { blocked: false, draft: null }
55
- const fresh = { ...draftFromEntry(targetEntry), sceneKey: targetEntry.key }
56
- return { blocked: false, draft: fresh }
57
- }
58
-
59
- function validateDraft(entry, draft) {
60
- if (!entry || !draft) return '未选择场景'
61
- if (!MODE_IDS.includes(draft.mode)) return '模式无效'
62
- if (draft.mode === 'owner-agent' && entry.ownerEligible !== true) return 'Owner Agent 仅可用于已配置的可信 Owner 私聊'
63
- const known = new Set((entry.capabilities ?? []).map(capability => capability.id))
64
- for (const [id, value] of Object.entries(draft.overrides ?? {})) {
65
- if (!known.has(id)) return '包含未知能力:' + id
66
- if (!OVERRIDE_VALUES.has(value)) return '覆盖值无效:' + id
67
- }
68
- return null
69
- }
70
-
71
- const CAPABILITY_REASON_HINTS = Object.freeze({
72
- 'owner-directory-required': '需在可信 Owner 私聊使用 Owner Agent 模式,并对该能力显式 allow。',
73
- 'global-deny': '宿主全局策略拒绝该能力,Scene 设置无法解除。',
74
- 'account-publish-required': '该 QQ 账号未获授权使用 QQ 空间发布。',
75
- 'scene-deny': '该 Scene 显式拒绝(deny)此能力。',
76
- 'owner-private-required': '仅可信 Owner 私聊的 Owner Agent 模式可用。',
77
- 'owner-tool-required': '宿主未为该 Owner 场景配置此工具。',
78
- 'group-only': '仅群聊 Scene 可用;私聊不适用,与 Owner 权限无关。',
79
- 'qzone-read-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能读取 QZone。',
80
- 'qzone-use-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能使用 QZone。',
81
- 'profile-write-explicit': '账号资料写入仅限可信 Owner 私聊的 Owner Agent 模式并显式 allow。',
82
- 'owner-workspace-required': 'Owner 工作区需可信 Owner 私聊、Owner Agent 模式并显式 allow。',
83
- mode: '当前模式未包含此能力。',
84
- 'scene-allow': '该 Scene 显式允许,但保存后按最新模式重算。',
85
- })
86
-
87
- /**
88
- * Fixed Chinese guidance for an implemented-but-denied capability. Uses only
89
- * known denial reasons; an unknown reason falls back to a generic sentence and
90
- * never echoes the server value. `Object.hasOwn` keeps names such as
91
- * `constructor` or `toString` from resolving to inherited Object members.
92
- */
93
- function capabilityStateHint(capability) {
94
- if (!capability || capability.implemented !== true || capability.effective === true) return null
95
- const reason = capability.reason
96
- return typeof reason === 'string' && Object.hasOwn(CAPABILITY_REASON_HINTS, reason)
97
- ? CAPABILITY_REASON_HINTS[reason]
98
- : '当前策略拒绝该能力。'
99
- }
100
-
101
- /** Configuration hints; never a path, token or credential. */
102
- function capabilityConfigHint(capability, runtime = {}) {
103
- if (!capability || capability.implemented !== true) return null
104
- if (capability.id === 'qq.owner.workspace' && runtime.ownerWorkspace?.configured !== true) {
105
- return '需要核心用实际 QQ 配置注册专用目录并授权后才可用。'
106
- }
107
- if ((capability.id === 'qq.qzone.use' || capability.id === 'qq.qzone.read' || capability.id === 'qq.qzone.publish')
108
- && runtime.providers?.qzone?.installed !== true) {
109
- return '需要配置 QZone loopback Provider 后才可用;当前未观测到接线。'
110
- }
111
- return null
112
- }
113
-
114
- function buildSaveRequest(entry, draft) {
115
- const overrides = {}
116
- for (const [id, value] of Object.entries(draft.overrides ?? {})) {
117
- if (value === 'allow' || value === 'deny') overrides[id] = value
118
- }
119
- return {
120
- scene: {
121
- botId: entry.scene.botId,
122
- kind: entry.scene.kind,
123
- peerId: entry.scene.peerId,
124
- },
125
- access: { mode: draft.mode, overrides },
126
- expectedRevision: draft.expectedRevision,
127
- }
128
- }
129
-
130
- async function readPayload(response) {
131
- let payload
132
- try {
133
- payload = await response.json()
134
- } catch {
135
- const error = new Error('管理接口返回了无法解析的响应(HTTP ' + response.status + ')')
136
- error.status = response.status
137
- throw error
138
- }
139
- if (!response.ok || !payload || payload.ok !== true) {
140
- const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
141
- error.status = response.status
142
- if (typeof payload?.code === 'string') error.code = payload.code
143
- if (response.status === 409) error.conflict = true
144
- throw error
145
- }
146
- return payload.data
147
- }
148
-
149
- /**
150
- * True only when the management surface is genuinely unusable for this page:
151
- * authentication/owner admission failed, or the Host lacks the management
152
- * service. Only the explicitly listed policy rejections and session-cancel
153
- * failures stay editable; any unknown code falls back to its HTTP status so an
154
- * unrecognized 401/403/503 keeps the editor locked (fail closed).
155
- */
156
- const EDITABLE_FAILURE_CODES = new Set([
157
- 'QQ_OWNER_AGENT_FORBIDDEN',
158
- 'QQ_SCENE_UNAUTHORIZED',
159
- 'QQ_SESSION_CANCEL_FAILED',
160
- 'QQ_SESSION_CANCEL_UNAVAILABLE',
161
- 'QQ_PERMISSION_CONFLICT',
162
- ])
163
-
164
- function requiresReadOnly(error) {
165
- const code = typeof error?.code === 'string' ? error.code : ''
166
- if (code === 'QQ_AUTH_REQUIRED' || code === 'QQ_MANAGEMENT_UNAVAILABLE') return true
167
- if (EDITABLE_FAILURE_CODES.has(code)) return false
168
- const status = Number(error?.status)
169
- return status === 401 || status === 403 || status === 503
170
- }
171
-
172
- /** Fixed Chinese guidance per management failure code; never echoes server internals. */
173
- function managementErrorMessage(error) {
174
- const code = typeof error?.code === 'string' ? error.code : ''
175
- if (code === 'QQ_PERMISSION_CONFLICT') {
176
- return '权限已被其他窗口修改(409);请重新加载后再编辑。'
177
- }
178
- if (code === 'QQ_AUTH_REQUIRED') {
179
- return '当前请求没有通过 DSH 认证或 Owner 授权;请从已认证的 DSH 页面访问。'
180
- }
181
- if (code === 'QQ_MANAGEMENT_UNAVAILABLE') {
182
- return '管理接口不可用;QQ 插件可能未启用或缺少 Host 服务。'
183
- }
184
- if (code === 'QQ_OWNER_AGENT_FORBIDDEN') {
185
- return 'Owner Agent 仅可用于已配置的可信 Owner 私聊;当前场景或配置不允许保存该模式。'
186
- }
187
- if (code === 'QQ_SCENE_UNAUTHORIZED') {
188
- return '该场景已不在 QQ 允许列表内;请更新配置并刷新后再操作。'
189
- }
190
- if (code === 'QQ_SESSION_CANCEL_FAILED' || code === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
191
- return '权限可能已写入,但旧 QQ Session 未能安全取消;请刷新确认最新 revision 后再编辑。'
192
- }
193
- if (code === 'QQ_DELIVERY_IN_FLIGHT') {
194
- return '该投递仍在进行中,现在不能标记为失败;请稍后刷新重试。'
195
- }
196
- if (code === 'QQ_DELIVERY_NOT_UNKNOWN') {
197
- return '该投递已不是 unknown 状态;请刷新确认后再操作。'
198
- }
199
- if (code === 'QQ_DELIVERY_NOT_FOUND') {
200
- return '未找到该 actionId 的未知投递;请刷新后确认。'
201
- }
202
- if (code === 'QQ_REQUEST_INVALID') {
203
- return '管理请求无效;请刷新后重试。'
204
- }
205
- const status = Number(error?.status)
206
- if (status === 401 || status === 403) {
207
- return '当前请求没有通过 DSH 认证或 Owner 授权(HTTP ' + status + ');请从已认证的 DSH 页面访问。'
208
- }
209
- if (status === 503) {
210
- return '管理接口不可用(503);QQ 插件可能未启用或缺少 Host 服务。'
211
- }
212
- return 'QQ 管理请求失败' + (Number.isInteger(status) ? '(HTTP ' + status + ')' : '')
213
- }
214
-
215
- async function fetchScenes(fetchImpl = fetch) {
216
- const response = await fetchImpl(API_ROOT + '/scenes', {
217
- credentials: 'same-origin',
218
- headers: { 'x-amadeus-qq': '1' },
219
- })
220
- return readPayload(response)
221
- }
222
-
223
- async function savePermissions(entry, draft, fetchImpl = fetch) {
224
- const response = await fetchImpl(API_ROOT + '/scenes/permissions', {
225
- method: 'POST',
226
- credentials: 'same-origin',
227
- headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
228
- body: JSON.stringify(buildSaveRequest(entry, draft)),
229
- })
230
- return readPayload(response)
231
- }
232
-
233
- /**
234
- * Storage key for one operator confirmation input. It is scoped to the Scene
235
- * and prefixed, so two Scenes with the same actionId never share a draft, and
236
- * prototype names such as `__proto__` or `constructor` can never be the key.
237
- * The API still receives the untouched actionId.
238
- */
239
- function deliveryInputKey(entry, actionId) {
240
- return 'delivery-confirm:' + String(entry?.key ?? '') + '|' + String(actionId)
241
- }
242
-
243
- /**
244
- * Fixed Settings copy for the Owner workspace. It never includes a directory
245
- * path, token or provider URL; an unwired core keeps the feature "pending".
246
- */
247
- function ownerWorkspaceSummary(status) {
248
- const configured = status?.configured === true
249
- const writes = status?.writes === true
250
- const maxBytes = Number.isSafeInteger(status?.maxBytes) && status.maxBytes > 0 ? status.maxBytes : 0
251
- return {
252
- state: configured ? '已接线' : 'pending(待核心接线)',
253
- writes: writes ? '可写(逐次审批)' : '只读',
254
- maxBytes,
255
- note: configured
256
- ? '目录与写入授权由插件配置决定;本页不显示路径,也不保存配置。实机写入仍需人工审批,尚未完成真实验证。'
257
- : 'Owner preset 已随包;核心尚未用实际 QQ 配置注册 Owner 工作区(未接线),当前不可读、不可写。',
258
- }
259
- }
260
-
261
- /** Provider labels only; never a root path, loopback URL or token value. */
262
- function providerSummary(providers) {
263
- const stateOf = provider => {
264
- if (provider?.installed !== true) return '未观测到接线'
265
- if (provider?.configured !== true) return '已安装,未配置'
266
- return provider?.enabled === true ? '已配置' : '已安装,未启用'
267
- }
268
- return {
269
- mediaSend: stateOf(providers?.mediaSend),
270
- qzone: stateOf(providers?.qzone),
271
- }
272
- }
273
-
274
- /**
275
- * Manual reconciliation for one unknown delivery. Only the owner management
276
- * surface can do this, and the body never carries message content: just the
277
- * Scene identity, the operator-confirmed actionId and the fixed outcome.
278
- */
279
- async function resolveUnknownDelivery(entry, actionId, fetchImpl = fetch) {
280
- const response = await fetchImpl(API_ROOT + '/deliveries/resolve', {
281
- method: 'POST',
282
- credentials: 'same-origin',
283
- headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
284
- body: JSON.stringify({
285
- scene: {
286
- botId: entry.scene.botId,
287
- kind: entry.scene.kind,
288
- peerId: entry.scene.peerId,
289
- },
290
- actionId,
291
- outcome: 'failed',
292
- }),
293
- })
294
- return readPayload(response)
295
- }
296
-
297
-
298
- const PANEL_CSS = `
299
- [data-amadeus-qq]{box-sizing:border-box;color:var(--dsw-alias-label-primary,#203333);font:13px/1.6 system-ui,sans-serif;max-width:760px}
300
- [data-amadeus-qq] *{box-sizing:border-box}
301
- [data-amadeus-qq] h2{font-size:21px;margin:0 0 5px}
302
- [data-amadeus-qq] p{margin:0 0 14px;opacity:.75}
303
- [data-amadeus-qq] .summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin:14px 0}
304
- [data-amadeus-qq] .metric{border:1px solid var(--dsw-alias-border-secondary,#8c8c8c55);border-radius:12px;padding:12px;background:var(--dsw-alias-bg-layer-2,#8c8c8c10)}
305
- [data-amadeus-qq] .metric small{display:block;opacity:.68;font-size:11px}
306
- [data-amadeus-qq] .metric strong{display:block;font-size:14px;margin-top:3px;word-break:break-word}
307
- [data-amadeus-qq] button{border:1px solid #178a69;background:#178a69;color:white;border-radius:9px;padding:8px 14px;font:inherit;cursor:pointer}
308
- [data-amadeus-qq] button:focus-visible{outline:2px solid #178a69;outline-offset:2px}
309
- [data-amadeus-qq] .error{color:#ab3535;margin:12px 0}
310
- @media(max-width:560px){[data-amadeus-qq] .summary{grid-template-columns:1fr}}
311
- `
312
-
313
- function Panel() {
314
- const [setup, setSetup] = React.useState(null)
315
- const [runtime, setRuntime] = React.useState(null)
316
- const [error, setError] = React.useState('')
317
- React.useEffect(() => {
318
- let active = true
319
- const load = async () => {
320
- try {
321
- const [a, b] = await Promise.all([
322
- fetch('/api/amadeus-qq/setup', { credentials: 'same-origin' }),
323
- fetch('/api/amadeus-qq/scenes', { credentials: 'same-origin' }),
324
- ])
325
- const [left, right] = await Promise.all([a.json(), b.json()])
326
- if (!a.ok || !b.ok || !left.ok || !right.ok) throw new Error('请在 DSH Owner 会话中打开管理控制台')
327
- if (active) { setSetup(left.data); setRuntime(right.data); setError('') }
328
- } catch (failure) {
329
- if (active) setError(failure instanceof Error ? failure.message : '状态读取失败')
330
- }
331
- }
332
- void load()
333
- return () => { active = false }
334
- }, [])
335
- const workspace = setup?.workspaces?.find(item => item.id === setup.workspaceId)
336
- const rows = (setup?.groups?.length ?? 0) + (setup?.privatePeers?.length ?? 0)
337
- const transportText = { connected: '已连接', connecting: '连接中', disconnected: '未连接', disabled: '已暂停' }[runtime?.health?.transport] ?? '未连接'
338
- const metric = (label, value) => React.createElement('div', { className: 'metric' },
339
- React.createElement('small', null, label), React.createElement('strong', null, value))
340
- return React.createElement('div', { 'data-amadeus-qq': 'true' },
341
- React.createElement('style', null, PANEL_CSS),
342
- React.createElement('h2', null, 'Amadeus QQ'),
343
- React.createElement('p', null, '首次设置和详细权限在受 DSH Owner 保护的本地 Web Console 中完成。'),
344
- error ? React.createElement('div', { className: 'error', role: 'status' }, error) : null,
345
- React.createElement('div', { className: 'summary' },
346
- metric('插件', setup?.enabled ? '已启用' : '待配置'),
347
- metric('NapCat', transportText),
348
- metric('Bot / Owner', `${setup?.botId || '—'} / ${setup?.ownerId || '—'}`),
349
- metric('Workspace / 会话', `${workspace?.title || '未选择'} · ${rows} 个`)),
350
- React.createElement('button', { type: 'button', onClick: () => window.open('/api/amadeus-qq/console', '_blank', 'noopener,noreferrer') },
351
- setup?.enabled ? '打开 Web Console' : '开始首次设置'))
352
- }
353
-
354
- return {
355
- inject: ['slots'],
356
- apply(ctx) {
357
- let active
358
- const activate = () => {
359
- if (active) return active.dispose
360
- let dispose = () => {}
361
- try {
362
- dispose = ctx.slots.inject('settings.section', () => ctx.slots.register({
363
- name: 'settings.section', id: 'amadeus-qq', order: 30, label: () => 'Amadeus QQ',
364
- }, Panel)) || (() => {})
365
- } catch (error) { console.error('[amadeus-qq] settings.section unavailable', error) }
366
- const disposal = () => {
367
- if (!active || active.dispose !== disposal) return
368
- active = undefined
369
- try { dispose() } catch {}
370
- }
371
- active = { dispose: disposal }
372
- return disposal
373
- }
374
- if (typeof ctx.effect === 'function') ctx.effect(() => activate(), 'amadeus-qq: settings page')
375
- else activate()
376
- },
377
- }
6
+ // Pure client-side helpers, inlined into the browser bundle by build-client.
7
+ // No ESM imports here: the bundle is a lazy-CJS factory with only React injected.
8
+
9
+ const API_ROOT = '/api/amadeus-qq'
10
+ const MODE_IDS = Object.freeze([
11
+ 'observe', 'safe-chat', 'enhanced', 'autonomous-social', 'owner-agent',
12
+ ])
13
+ const OVERRIDE_VALUES = new Set(['inherit', 'allow', 'deny'])
14
+
15
+ function revisionOf(entry) {
16
+ return entry?.revision ?? 0
17
+ }
18
+
19
+ function draftFromEntry(entry) {
20
+ const overrides = {}
21
+ for (const capability of entry?.capabilities ?? []) {
22
+ if (capability.override === 'allow' || capability.override === 'deny') {
23
+ overrides[capability.id] = capability.override
24
+ }
25
+ }
26
+ return {
27
+ sceneKey: entry?.key ?? '',
28
+ mode: entry?.permissions?.mode ?? entry?.effectiveMode ?? 'safe-chat',
29
+ overrides,
30
+ expectedRevision: revisionOf(entry),
31
+ }
32
+ }
33
+
34
+ function isDraftDirty(entry, draft) {
35
+ if (!entry || !draft) return false
36
+ const base = draftFromEntry(entry)
37
+ if (base.mode !== draft.mode) return true
38
+ const keys = new Set([...Object.keys(base.overrides), ...Object.keys(draft.overrides ?? {})])
39
+ for (const key of keys) {
40
+ if ((base.overrides[key] ?? 'inherit') !== (draft.overrides?.[key] ?? 'inherit')) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ /**
46
+ * Decide which draft the editor should show after a Scene row is selected.
47
+ * A dirty draft is never replaced: the operator must save or cancel first, so
48
+ * an unsaved permission change can never be lost by an accidental click.
49
+ */
50
+ function nextDraftForSelection(currentEntry, currentDraft, targetEntry) {
51
+ if (currentDraft && isDraftDirty(currentEntry, currentDraft)) {
52
+ return { blocked: true, draft: currentDraft }
53
+ }
54
+ if (!targetEntry) return { blocked: false, draft: null }
55
+ const fresh = { ...draftFromEntry(targetEntry), sceneKey: targetEntry.key }
56
+ return { blocked: false, draft: fresh }
57
+ }
58
+
59
+ function validateDraft(entry, draft) {
60
+ if (!entry || !draft) return '未选择场景'
61
+ if (!MODE_IDS.includes(draft.mode)) return '模式无效'
62
+ if (draft.mode === 'owner-agent' && entry.ownerEligible !== true) return 'Owner Agent 仅可用于已配置的可信 Owner 私聊'
63
+ const known = new Set((entry.capabilities ?? []).map(capability => capability.id))
64
+ for (const [id, value] of Object.entries(draft.overrides ?? {})) {
65
+ if (!known.has(id)) return '包含未知能力:' + id
66
+ if (!OVERRIDE_VALUES.has(value)) return '覆盖值无效:' + id
67
+ }
68
+ return null
69
+ }
70
+
71
+ const CAPABILITY_REASON_HINTS = Object.freeze({
72
+ 'owner-directory-required': '需在可信 Owner 私聊使用 Owner Agent 模式,并对该能力显式 allow。',
73
+ 'global-deny': '宿主全局策略拒绝该能力,Scene 设置无法解除。',
74
+ 'account-publish-required': '该 QQ 账号未获授权使用 QQ 空间发布。',
75
+ 'scene-deny': '该 Scene 显式拒绝(deny)此能力。',
76
+ 'owner-private-required': '仅可信 Owner 私聊的 Owner Agent 模式可用。',
77
+ 'owner-tool-required': '宿主未为该 Owner 场景配置此工具。',
78
+ 'group-only': '仅群聊 Scene 可用;私聊不适用,与 Owner 权限无关。',
79
+ 'qzone-read-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能读取 QZone。',
80
+ 'qzone-use-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能使用 QZone。',
81
+ 'profile-write-explicit': '账号资料写入仅限可信 Owner 私聊的 Owner Agent 模式并显式 allow。',
82
+ 'owner-workspace-required': 'Owner 工作区需可信 Owner 私聊、Owner Agent 模式并显式 allow。',
83
+ mode: '当前模式未包含此能力。',
84
+ 'scene-allow': '该 Scene 显式允许,但保存后按最新模式重算。',
85
+ })
86
+
87
+ /**
88
+ * Fixed Chinese guidance for an implemented-but-denied capability. Uses only
89
+ * known denial reasons; an unknown reason falls back to a generic sentence and
90
+ * never echoes the server value. `Object.hasOwn` keeps names such as
91
+ * `constructor` or `toString` from resolving to inherited Object members.
92
+ */
93
+ function capabilityStateHint(capability) {
94
+ if (!capability || capability.implemented !== true || capability.effective === true) return null
95
+ const reason = capability.reason
96
+ return typeof reason === 'string' && Object.hasOwn(CAPABILITY_REASON_HINTS, reason)
97
+ ? CAPABILITY_REASON_HINTS[reason]
98
+ : '当前策略拒绝该能力。'
99
+ }
100
+
101
+ /** Configuration hints; never a path, token or credential. */
102
+ function capabilityConfigHint(capability, runtime = {}) {
103
+ if (!capability || capability.implemented !== true) return null
104
+ if (capability.id === 'qq.owner.workspace' && runtime.ownerWorkspace?.configured !== true) {
105
+ return '需要核心用实际 QQ 配置注册专用目录并授权后才可用。'
106
+ }
107
+ if ((capability.id === 'qq.qzone.use' || capability.id === 'qq.qzone.read' || capability.id === 'qq.qzone.publish')
108
+ && runtime.providers?.qzone?.installed !== true) {
109
+ return '需要配置 QZone loopback Provider 后才可用;当前未观测到接线。'
110
+ }
111
+ return null
112
+ }
113
+
114
+ function buildSaveRequest(entry, draft) {
115
+ const overrides = {}
116
+ for (const [id, value] of Object.entries(draft.overrides ?? {})) {
117
+ if (value === 'allow' || value === 'deny') overrides[id] = value
118
+ }
119
+ return {
120
+ scene: {
121
+ botId: entry.scene.botId,
122
+ kind: entry.scene.kind,
123
+ peerId: entry.scene.peerId,
124
+ },
125
+ access: { mode: draft.mode, overrides },
126
+ expectedRevision: draft.expectedRevision,
127
+ }
128
+ }
129
+
130
+ async function readPayload(response) {
131
+ let payload
132
+ try {
133
+ payload = await response.json()
134
+ } catch {
135
+ const error = new Error('管理接口返回了无法解析的响应(HTTP ' + response.status + ')')
136
+ error.status = response.status
137
+ throw error
138
+ }
139
+ if (!response.ok || !payload || payload.ok !== true) {
140
+ const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
141
+ error.status = response.status
142
+ if (typeof payload?.code === 'string') error.code = payload.code
143
+ if (response.status === 409) error.conflict = true
144
+ throw error
145
+ }
146
+ return payload.data
147
+ }
148
+
149
+ /**
150
+ * True only when the management surface is genuinely unusable for this page:
151
+ * authentication/owner admission failed, or the Host lacks the management
152
+ * service. Only the explicitly listed policy rejections and session-cancel
153
+ * failures stay editable; any unknown code falls back to its HTTP status so an
154
+ * unrecognized 401/403/503 keeps the editor locked (fail closed).
155
+ */
156
+ const EDITABLE_FAILURE_CODES = new Set([
157
+ 'QQ_OWNER_AGENT_FORBIDDEN',
158
+ 'QQ_SCENE_UNAUTHORIZED',
159
+ 'QQ_SESSION_CANCEL_FAILED',
160
+ 'QQ_SESSION_CANCEL_UNAVAILABLE',
161
+ 'QQ_PERMISSION_CONFLICT',
162
+ ])
163
+
164
+ function requiresReadOnly(error) {
165
+ const code = typeof error?.code === 'string' ? error.code : ''
166
+ if (code === 'QQ_AUTH_REQUIRED' || code === 'QQ_MANAGEMENT_UNAVAILABLE') return true
167
+ if (EDITABLE_FAILURE_CODES.has(code)) return false
168
+ const status = Number(error?.status)
169
+ return status === 401 || status === 403 || status === 503
170
+ }
171
+
172
+ /** Fixed Chinese guidance per management failure code; never echoes server internals. */
173
+ function managementErrorMessage(error) {
174
+ const code = typeof error?.code === 'string' ? error.code : ''
175
+ if (code === 'QQ_PERMISSION_CONFLICT') {
176
+ return '权限已被其他窗口修改(409);请重新加载后再编辑。'
177
+ }
178
+ if (code === 'QQ_AUTH_REQUIRED') {
179
+ return '当前请求没有通过 DSH 认证或 Owner 授权;请从已认证的 DSH 页面访问。'
180
+ }
181
+ if (code === 'QQ_MANAGEMENT_UNAVAILABLE') {
182
+ return '管理接口不可用;QQ 插件可能未启用或缺少 Host 服务。'
183
+ }
184
+ if (code === 'QQ_OWNER_AGENT_FORBIDDEN') {
185
+ return 'Owner Agent 仅可用于已配置的可信 Owner 私聊;当前场景或配置不允许保存该模式。'
186
+ }
187
+ if (code === 'QQ_SCENE_UNAUTHORIZED') {
188
+ return '该场景已不在 QQ 允许列表内;请更新配置并刷新后再操作。'
189
+ }
190
+ if (code === 'QQ_SESSION_CANCEL_FAILED' || code === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
191
+ return '权限可能已写入,但旧 QQ Session 未能安全取消;请刷新确认最新 revision 后再编辑。'
192
+ }
193
+ if (code === 'QQ_DELIVERY_IN_FLIGHT') {
194
+ return '该投递仍在进行中,现在不能标记为失败;请稍后刷新重试。'
195
+ }
196
+ if (code === 'QQ_DELIVERY_NOT_UNKNOWN') {
197
+ return '该投递已不是 unknown 状态;请刷新确认后再操作。'
198
+ }
199
+ if (code === 'QQ_DELIVERY_NOT_FOUND') {
200
+ return '未找到该 actionId 的未知投递;请刷新后确认。'
201
+ }
202
+ if (code === 'QQ_REQUEST_INVALID') {
203
+ return '管理请求无效;请刷新后重试。'
204
+ }
205
+ const status = Number(error?.status)
206
+ if (status === 401 || status === 403) {
207
+ return '当前请求没有通过 DSH 认证或 Owner 授权(HTTP ' + status + ');请从已认证的 DSH 页面访问。'
208
+ }
209
+ if (status === 503) {
210
+ return '管理接口不可用(503);QQ 插件可能未启用或缺少 Host 服务。'
211
+ }
212
+ return 'QQ 管理请求失败' + (Number.isInteger(status) ? '(HTTP ' + status + ')' : '')
213
+ }
214
+
215
+ async function fetchScenes(fetchImpl = fetch) {
216
+ const response = await fetchImpl(API_ROOT + '/scenes', {
217
+ credentials: 'same-origin',
218
+ headers: { 'x-amadeus-qq': '1' },
219
+ })
220
+ return readPayload(response)
221
+ }
222
+
223
+ async function savePermissions(entry, draft, fetchImpl = fetch) {
224
+ const response = await fetchImpl(API_ROOT + '/scenes/permissions', {
225
+ method: 'POST',
226
+ credentials: 'same-origin',
227
+ headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
228
+ body: JSON.stringify(buildSaveRequest(entry, draft)),
229
+ })
230
+ return readPayload(response)
231
+ }
232
+
233
+ /**
234
+ * Storage key for one operator confirmation input. It is scoped to the Scene
235
+ * and prefixed, so two Scenes with the same actionId never share a draft, and
236
+ * prototype names such as `__proto__` or `constructor` can never be the key.
237
+ * The API still receives the untouched actionId.
238
+ */
239
+ function deliveryInputKey(entry, actionId) {
240
+ return 'delivery-confirm:' + String(entry?.key ?? '') + '|' + String(actionId)
241
+ }
242
+
243
+ /**
244
+ * Fixed Settings copy for the Owner workspace. It never includes a directory
245
+ * path, token or provider URL; an unwired core keeps the feature "pending".
246
+ */
247
+ function ownerWorkspaceSummary(status) {
248
+ const configured = status?.configured === true
249
+ const writes = status?.writes === true
250
+ const maxBytes = Number.isSafeInteger(status?.maxBytes) && status.maxBytes > 0 ? status.maxBytes : 0
251
+ return {
252
+ state: configured ? '已接线' : 'pending(待核心接线)',
253
+ writes: writes ? '可写(逐次审批)' : '只读',
254
+ maxBytes,
255
+ note: configured
256
+ ? '目录与写入授权由插件配置决定;本页不显示路径,也不保存配置。实机写入仍需人工审批,尚未完成真实验证。'
257
+ : 'Owner preset 已随包;核心尚未用实际 QQ 配置注册 Owner 工作区(未接线),当前不可读、不可写。',
258
+ }
259
+ }
260
+
261
+ /** Provider labels only; never a root path, loopback URL or token value. */
262
+ function providerSummary(providers) {
263
+ const stateOf = provider => {
264
+ if (provider?.installed !== true) return '未观测到接线'
265
+ if (provider?.configured !== true) return '已安装,未配置'
266
+ return provider?.enabled === true ? '已配置' : '已安装,未启用'
267
+ }
268
+ return {
269
+ mediaSend: stateOf(providers?.mediaSend),
270
+ qzone: stateOf(providers?.qzone),
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Manual reconciliation for one unknown delivery. Only the owner management
276
+ * surface can do this, and the body never carries message content: just the
277
+ * Scene identity, the operator-confirmed actionId and the fixed outcome.
278
+ */
279
+ async function resolveUnknownDelivery(entry, actionId, fetchImpl = fetch) {
280
+ const response = await fetchImpl(API_ROOT + '/deliveries/resolve', {
281
+ method: 'POST',
282
+ credentials: 'same-origin',
283
+ headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
284
+ body: JSON.stringify({
285
+ scene: {
286
+ botId: entry.scene.botId,
287
+ kind: entry.scene.kind,
288
+ peerId: entry.scene.peerId,
289
+ },
290
+ actionId,
291
+ outcome: 'failed',
292
+ }),
293
+ })
294
+ return readPayload(response)
295
+ }
296
+
297
+
298
+ const PANEL_CSS = `
299
+ [data-amadeus-qq]{box-sizing:border-box;color:var(--dsw-alias-label-primary,#203333);font:13px/1.6 system-ui,sans-serif;max-width:760px}
300
+ [data-amadeus-qq] *{box-sizing:border-box}
301
+ [data-amadeus-qq] h2{font-size:21px;margin:0 0 5px}
302
+ [data-amadeus-qq] p{margin:0 0 14px;opacity:.75}
303
+ [data-amadeus-qq] .summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin:14px 0}
304
+ [data-amadeus-qq] .metric{border:1px solid var(--dsw-alias-border-secondary,#8c8c8c55);border-radius:12px;padding:12px;background:var(--dsw-alias-bg-layer-2,#8c8c8c10)}
305
+ [data-amadeus-qq] .metric small{display:block;opacity:.68;font-size:11px}
306
+ [data-amadeus-qq] .metric strong{display:block;font-size:14px;margin-top:3px;word-break:break-word}
307
+ [data-amadeus-qq] button{border:1px solid #178a69;background:#178a69;color:white;border-radius:9px;padding:8px 14px;font:inherit;cursor:pointer}
308
+ [data-amadeus-qq] button:focus-visible{outline:2px solid #178a69;outline-offset:2px}
309
+ [data-amadeus-qq] .error{color:#ab3535;margin:12px 0}
310
+ @media(max-width:560px){[data-amadeus-qq] .summary{grid-template-columns:1fr}}
311
+ `
312
+
313
+ function Panel() {
314
+ const [setup, setSetup] = React.useState(null)
315
+ const [runtime, setRuntime] = React.useState(null)
316
+ const [error, setError] = React.useState('')
317
+ React.useEffect(() => {
318
+ let active = true
319
+ const load = async () => {
320
+ try {
321
+ const [a, b] = await Promise.all([
322
+ fetch('/api/amadeus-qq/setup', { credentials: 'same-origin' }),
323
+ fetch('/api/amadeus-qq/scenes', { credentials: 'same-origin' }),
324
+ ])
325
+ const [left, right] = await Promise.all([a.json(), b.json()])
326
+ if (!a.ok || !b.ok || !left.ok || !right.ok) throw new Error('请在 DSH Owner 会话中打开管理控制台')
327
+ if (active) { setSetup(left.data); setRuntime(right.data); setError('') }
328
+ } catch (failure) {
329
+ if (active) setError(failure instanceof Error ? failure.message : '状态读取失败')
330
+ }
331
+ }
332
+ void load()
333
+ return () => { active = false }
334
+ }, [])
335
+ const workspace = setup?.workspaces?.find(item => item.id === setup.workspaceId)
336
+ const rows = (setup?.groups?.length ?? 0) + (setup?.privatePeers?.length ?? 0)
337
+ const transportText = { connected: '已连接', connecting: '连接中', disconnected: '未连接', disabled: '已暂停' }[runtime?.health?.transport] ?? '未连接'
338
+ const metric = (label, value) => React.createElement('div', { className: 'metric' },
339
+ React.createElement('small', null, label), React.createElement('strong', null, value))
340
+ return React.createElement('div', { 'data-amadeus-qq': 'true' },
341
+ React.createElement('style', null, PANEL_CSS),
342
+ React.createElement('h2', null, 'Amadeus QQ'),
343
+ React.createElement('p', null, '首次设置和详细权限在受 DSH Owner 保护的本地 Web Console 中完成。'),
344
+ error ? React.createElement('div', { className: 'error', role: 'status' }, error) : null,
345
+ React.createElement('div', { className: 'summary' },
346
+ metric('插件', setup?.enabled ? '已启用' : '待配置'),
347
+ metric('NapCat', transportText),
348
+ metric('Bot / Owner', `${setup?.botId || '—'} / ${setup?.ownerId || '—'}`),
349
+ metric('Workspace / 会话', `${workspace?.title || '未选择'} · ${rows} 个`)),
350
+ React.createElement('button', { type: 'button', onClick: () => window.open('/api/amadeus-qq/console', '_blank', 'noopener,noreferrer') },
351
+ setup?.enabled ? '打开 Web Console' : '开始首次设置'))
352
+ }
353
+
354
+ return {
355
+ inject: ['slots'],
356
+ apply(ctx) {
357
+ let active
358
+ const activate = () => {
359
+ if (active) return active.dispose
360
+ let dispose = () => {}
361
+ try {
362
+ dispose = ctx.slots.inject('settings.section', () => ctx.slots.register({
363
+ name: 'settings.section', id: 'amadeus-qq', order: 30, label: () => 'Amadeus QQ',
364
+ }, Panel)) || (() => {})
365
+ } catch (error) { console.error('[amadeus-qq] settings.section unavailable', error) }
366
+ const disposal = () => {
367
+ if (!active || active.dispose !== disposal) return
368
+ active = undefined
369
+ try { dispose() } catch {}
370
+ }
371
+ active = { dispose: disposal }
372
+ return disposal
373
+ }
374
+ if (typeof ctx.effect === 'function') ctx.effect(() => activate(), 'amadeus-qq: settings page')
375
+ else activate()
376
+ },
377
+ }
378
378
 
379
379
  }
380
380
  });