@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/client/logic.js
CHANGED
|
@@ -1,290 +1,290 @@
|
|
|
1
|
-
// Pure client-side helpers, inlined into the browser bundle by build-client.
|
|
2
|
-
// No ESM imports here: the bundle is a lazy-CJS factory with only React injected.
|
|
3
|
-
|
|
4
|
-
export const API_ROOT = '/api/amadeus-qq'
|
|
5
|
-
export const MODE_IDS = Object.freeze([
|
|
6
|
-
'observe', 'safe-chat', 'enhanced', 'autonomous-social', 'owner-agent',
|
|
7
|
-
])
|
|
8
|
-
const OVERRIDE_VALUES = new Set(['inherit', 'allow', 'deny'])
|
|
9
|
-
|
|
10
|
-
export function revisionOf(entry) {
|
|
11
|
-
return entry?.revision ?? 0
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function draftFromEntry(entry) {
|
|
15
|
-
const overrides = {}
|
|
16
|
-
for (const capability of entry?.capabilities ?? []) {
|
|
17
|
-
if (capability.override === 'allow' || capability.override === 'deny') {
|
|
18
|
-
overrides[capability.id] = capability.override
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
sceneKey: entry?.key ?? '',
|
|
23
|
-
mode: entry?.permissions?.mode ?? entry?.effectiveMode ?? 'safe-chat',
|
|
24
|
-
overrides,
|
|
25
|
-
expectedRevision: revisionOf(entry),
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function isDraftDirty(entry, draft) {
|
|
30
|
-
if (!entry || !draft) return false
|
|
31
|
-
const base = draftFromEntry(entry)
|
|
32
|
-
if (base.mode !== draft.mode) return true
|
|
33
|
-
const keys = new Set([...Object.keys(base.overrides), ...Object.keys(draft.overrides ?? {})])
|
|
34
|
-
for (const key of keys) {
|
|
35
|
-
if ((base.overrides[key] ?? 'inherit') !== (draft.overrides?.[key] ?? 'inherit')) return true
|
|
36
|
-
}
|
|
37
|
-
return false
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Decide which draft the editor should show after a Scene row is selected.
|
|
42
|
-
* A dirty draft is never replaced: the operator must save or cancel first, so
|
|
43
|
-
* an unsaved permission change can never be lost by an accidental click.
|
|
44
|
-
*/
|
|
45
|
-
export function nextDraftForSelection(currentEntry, currentDraft, targetEntry) {
|
|
46
|
-
if (currentDraft && isDraftDirty(currentEntry, currentDraft)) {
|
|
47
|
-
return { blocked: true, draft: currentDraft }
|
|
48
|
-
}
|
|
49
|
-
if (!targetEntry) return { blocked: false, draft: null }
|
|
50
|
-
const fresh = { ...draftFromEntry(targetEntry), sceneKey: targetEntry.key }
|
|
51
|
-
return { blocked: false, draft: fresh }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function validateDraft(entry, draft) {
|
|
55
|
-
if (!entry || !draft) return '未选择场景'
|
|
56
|
-
if (!MODE_IDS.includes(draft.mode)) return '模式无效'
|
|
57
|
-
if (draft.mode === 'owner-agent' && entry.ownerEligible !== true) return 'Owner Agent 仅可用于已配置的可信 Owner 私聊'
|
|
58
|
-
const known = new Set((entry.capabilities ?? []).map(capability => capability.id))
|
|
59
|
-
for (const [id, value] of Object.entries(draft.overrides ?? {})) {
|
|
60
|
-
if (!known.has(id)) return '包含未知能力:' + id
|
|
61
|
-
if (!OVERRIDE_VALUES.has(value)) return '覆盖值无效:' + id
|
|
62
|
-
}
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const CAPABILITY_REASON_HINTS = Object.freeze({
|
|
67
|
-
'owner-directory-required': '需在可信 Owner 私聊使用 Owner Agent 模式,并对该能力显式 allow。',
|
|
68
|
-
'global-deny': '宿主全局策略拒绝该能力,Scene 设置无法解除。',
|
|
69
|
-
'account-publish-required': '该 QQ 账号未获授权使用 QQ 空间发布。',
|
|
70
|
-
'scene-deny': '该 Scene 显式拒绝(deny)此能力。',
|
|
71
|
-
'owner-private-required': '仅可信 Owner 私聊的 Owner Agent 模式可用。',
|
|
72
|
-
'owner-tool-required': '宿主未为该 Owner 场景配置此工具。',
|
|
73
|
-
'group-only': '仅群聊 Scene 可用;私聊不适用,与 Owner 权限无关。',
|
|
74
|
-
'qzone-read-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能读取 QZone。',
|
|
75
|
-
'qzone-use-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能使用 QZone。',
|
|
76
|
-
'profile-write-explicit': '账号资料写入仅限可信 Owner 私聊的 Owner Agent 模式并显式 allow。',
|
|
77
|
-
'owner-workspace-required': 'Owner 工作区需可信 Owner 私聊、Owner Agent 模式并显式 allow。',
|
|
78
|
-
mode: '当前模式未包含此能力。',
|
|
79
|
-
'scene-allow': '该 Scene 显式允许,但保存后按最新模式重算。',
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Fixed Chinese guidance for an implemented-but-denied capability. Uses only
|
|
84
|
-
* known denial reasons; an unknown reason falls back to a generic sentence and
|
|
85
|
-
* never echoes the server value. `Object.hasOwn` keeps names such as
|
|
86
|
-
* `constructor` or `toString` from resolving to inherited Object members.
|
|
87
|
-
*/
|
|
88
|
-
export function capabilityStateHint(capability) {
|
|
89
|
-
if (!capability || capability.implemented !== true || capability.effective === true) return null
|
|
90
|
-
const reason = capability.reason
|
|
91
|
-
return typeof reason === 'string' && Object.hasOwn(CAPABILITY_REASON_HINTS, reason)
|
|
92
|
-
? CAPABILITY_REASON_HINTS[reason]
|
|
93
|
-
: '当前策略拒绝该能力。'
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Configuration hints; never a path, token or credential. */
|
|
97
|
-
export function capabilityConfigHint(capability, runtime = {}) {
|
|
98
|
-
if (!capability || capability.implemented !== true) return null
|
|
99
|
-
if (capability.id === 'qq.owner.workspace' && runtime.ownerWorkspace?.configured !== true) {
|
|
100
|
-
return '需要核心用实际 QQ 配置注册专用目录并授权后才可用。'
|
|
101
|
-
}
|
|
102
|
-
if ((capability.id === 'qq.qzone.use' || capability.id === 'qq.qzone.read' || capability.id === 'qq.qzone.publish')
|
|
103
|
-
&& runtime.providers?.qzone?.installed !== true) {
|
|
104
|
-
return '需要配置 QZone loopback Provider 后才可用;当前未观测到接线。'
|
|
105
|
-
}
|
|
106
|
-
return null
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function buildSaveRequest(entry, draft) {
|
|
110
|
-
const overrides = {}
|
|
111
|
-
for (const [id, value] of Object.entries(draft.overrides ?? {})) {
|
|
112
|
-
if (value === 'allow' || value === 'deny') overrides[id] = value
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
scene: {
|
|
116
|
-
botId: entry.scene.botId,
|
|
117
|
-
kind: entry.scene.kind,
|
|
118
|
-
peerId: entry.scene.peerId,
|
|
119
|
-
},
|
|
120
|
-
access: { mode: draft.mode, overrides },
|
|
121
|
-
expectedRevision: draft.expectedRevision,
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function readPayload(response) {
|
|
126
|
-
let payload
|
|
127
|
-
try {
|
|
128
|
-
payload = await response.json()
|
|
129
|
-
} catch {
|
|
130
|
-
const error = new Error('管理接口返回了无法解析的响应(HTTP ' + response.status + ')')
|
|
131
|
-
error.status = response.status
|
|
132
|
-
throw error
|
|
133
|
-
}
|
|
134
|
-
if (!response.ok || !payload || payload.ok !== true) {
|
|
135
|
-
const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
136
|
-
error.status = response.status
|
|
137
|
-
if (typeof payload?.code === 'string') error.code = payload.code
|
|
138
|
-
if (response.status === 409) error.conflict = true
|
|
139
|
-
throw error
|
|
140
|
-
}
|
|
141
|
-
return payload.data
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* True only when the management surface is genuinely unusable for this page:
|
|
146
|
-
* authentication/owner admission failed, or the Host lacks the management
|
|
147
|
-
* service. Only the explicitly listed policy rejections and session-cancel
|
|
148
|
-
* failures stay editable; any unknown code falls back to its HTTP status so an
|
|
149
|
-
* unrecognized 401/403/503 keeps the editor locked (fail closed).
|
|
150
|
-
*/
|
|
151
|
-
const EDITABLE_FAILURE_CODES = new Set([
|
|
152
|
-
'QQ_OWNER_AGENT_FORBIDDEN',
|
|
153
|
-
'QQ_SCENE_UNAUTHORIZED',
|
|
154
|
-
'QQ_SESSION_CANCEL_FAILED',
|
|
155
|
-
'QQ_SESSION_CANCEL_UNAVAILABLE',
|
|
156
|
-
'QQ_PERMISSION_CONFLICT',
|
|
157
|
-
])
|
|
158
|
-
|
|
159
|
-
export function requiresReadOnly(error) {
|
|
160
|
-
const code = typeof error?.code === 'string' ? error.code : ''
|
|
161
|
-
if (code === 'QQ_AUTH_REQUIRED' || code === 'QQ_MANAGEMENT_UNAVAILABLE') return true
|
|
162
|
-
if (EDITABLE_FAILURE_CODES.has(code)) return false
|
|
163
|
-
const status = Number(error?.status)
|
|
164
|
-
return status === 401 || status === 403 || status === 503
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Fixed Chinese guidance per management failure code; never echoes server internals. */
|
|
168
|
-
export function managementErrorMessage(error) {
|
|
169
|
-
const code = typeof error?.code === 'string' ? error.code : ''
|
|
170
|
-
if (code === 'QQ_PERMISSION_CONFLICT') {
|
|
171
|
-
return '权限已被其他窗口修改(409);请重新加载后再编辑。'
|
|
172
|
-
}
|
|
173
|
-
if (code === 'QQ_AUTH_REQUIRED') {
|
|
174
|
-
return '当前请求没有通过 DSH 认证或 Owner 授权;请从已认证的 DSH 页面访问。'
|
|
175
|
-
}
|
|
176
|
-
if (code === 'QQ_MANAGEMENT_UNAVAILABLE') {
|
|
177
|
-
return '管理接口不可用;QQ 插件可能未启用或缺少 Host 服务。'
|
|
178
|
-
}
|
|
179
|
-
if (code === 'QQ_OWNER_AGENT_FORBIDDEN') {
|
|
180
|
-
return 'Owner Agent 仅可用于已配置的可信 Owner 私聊;当前场景或配置不允许保存该模式。'
|
|
181
|
-
}
|
|
182
|
-
if (code === 'QQ_SCENE_UNAUTHORIZED') {
|
|
183
|
-
return '该场景已不在 QQ 允许列表内;请更新配置并刷新后再操作。'
|
|
184
|
-
}
|
|
185
|
-
if (code === 'QQ_SESSION_CANCEL_FAILED' || code === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
|
|
186
|
-
return '权限可能已写入,但旧 QQ Session 未能安全取消;请刷新确认最新 revision 后再编辑。'
|
|
187
|
-
}
|
|
188
|
-
if (code === 'QQ_DELIVERY_IN_FLIGHT') {
|
|
189
|
-
return '该投递仍在进行中,现在不能标记为失败;请稍后刷新重试。'
|
|
190
|
-
}
|
|
191
|
-
if (code === 'QQ_DELIVERY_NOT_UNKNOWN') {
|
|
192
|
-
return '该投递已不是 unknown 状态;请刷新确认后再操作。'
|
|
193
|
-
}
|
|
194
|
-
if (code === 'QQ_DELIVERY_NOT_FOUND') {
|
|
195
|
-
return '未找到该 actionId 的未知投递;请刷新后确认。'
|
|
196
|
-
}
|
|
197
|
-
if (code === 'QQ_REQUEST_INVALID') {
|
|
198
|
-
return '管理请求无效;请刷新后重试。'
|
|
199
|
-
}
|
|
200
|
-
const status = Number(error?.status)
|
|
201
|
-
if (status === 401 || status === 403) {
|
|
202
|
-
return '当前请求没有通过 DSH 认证或 Owner 授权(HTTP ' + status + ');请从已认证的 DSH 页面访问。'
|
|
203
|
-
}
|
|
204
|
-
if (status === 503) {
|
|
205
|
-
return '管理接口不可用(503);QQ 插件可能未启用或缺少 Host 服务。'
|
|
206
|
-
}
|
|
207
|
-
return 'QQ 管理请求失败' + (Number.isInteger(status) ? '(HTTP ' + status + ')' : '')
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function fetchScenes(fetchImpl = fetch) {
|
|
211
|
-
const response = await fetchImpl(API_ROOT + '/scenes', {
|
|
212
|
-
credentials: 'same-origin',
|
|
213
|
-
headers: { 'x-amadeus-qq': '1' },
|
|
214
|
-
})
|
|
215
|
-
return readPayload(response)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export async function savePermissions(entry, draft, fetchImpl = fetch) {
|
|
219
|
-
const response = await fetchImpl(API_ROOT + '/scenes/permissions', {
|
|
220
|
-
method: 'POST',
|
|
221
|
-
credentials: 'same-origin',
|
|
222
|
-
headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
|
|
223
|
-
body: JSON.stringify(buildSaveRequest(entry, draft)),
|
|
224
|
-
})
|
|
225
|
-
return readPayload(response)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Storage key for one operator confirmation input. It is scoped to the Scene
|
|
230
|
-
* and prefixed, so two Scenes with the same actionId never share a draft, and
|
|
231
|
-
* prototype names such as `__proto__` or `constructor` can never be the key.
|
|
232
|
-
* The API still receives the untouched actionId.
|
|
233
|
-
*/
|
|
234
|
-
export function deliveryInputKey(entry, actionId) {
|
|
235
|
-
return 'delivery-confirm:' + String(entry?.key ?? '') + '|' + String(actionId)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Fixed Settings copy for the Owner workspace. It never includes a directory
|
|
240
|
-
* path, token or provider URL; an unwired core keeps the feature "pending".
|
|
241
|
-
*/
|
|
242
|
-
export function ownerWorkspaceSummary(status) {
|
|
243
|
-
const configured = status?.configured === true
|
|
244
|
-
const writes = status?.writes === true
|
|
245
|
-
const maxBytes = Number.isSafeInteger(status?.maxBytes) && status.maxBytes > 0 ? status.maxBytes : 0
|
|
246
|
-
return {
|
|
247
|
-
state: configured ? '已接线' : 'pending(待核心接线)',
|
|
248
|
-
writes: writes ? '可写(逐次审批)' : '只读',
|
|
249
|
-
maxBytes,
|
|
250
|
-
note: configured
|
|
251
|
-
? '目录与写入授权由插件配置决定;本页不显示路径,也不保存配置。实机写入仍需人工审批,尚未完成真实验证。'
|
|
252
|
-
: 'Owner preset 已随包;核心尚未用实际 QQ 配置注册 Owner 工作区(未接线),当前不可读、不可写。',
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Provider labels only; never a root path, loopback URL or token value. */
|
|
257
|
-
export function providerSummary(providers) {
|
|
258
|
-
const stateOf = provider => {
|
|
259
|
-
if (provider?.installed !== true) return '未观测到接线'
|
|
260
|
-
if (provider?.configured !== true) return '已安装,未配置'
|
|
261
|
-
return provider?.enabled === true ? '已配置' : '已安装,未启用'
|
|
262
|
-
}
|
|
263
|
-
return {
|
|
264
|
-
mediaSend: stateOf(providers?.mediaSend),
|
|
265
|
-
qzone: stateOf(providers?.qzone),
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Manual reconciliation for one unknown delivery. Only the owner management
|
|
271
|
-
* surface can do this, and the body never carries message content: just the
|
|
272
|
-
* Scene identity, the operator-confirmed actionId and the fixed outcome.
|
|
273
|
-
*/
|
|
274
|
-
export async function resolveUnknownDelivery(entry, actionId, fetchImpl = fetch) {
|
|
275
|
-
const response = await fetchImpl(API_ROOT + '/deliveries/resolve', {
|
|
276
|
-
method: 'POST',
|
|
277
|
-
credentials: 'same-origin',
|
|
278
|
-
headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
|
|
279
|
-
body: JSON.stringify({
|
|
280
|
-
scene: {
|
|
281
|
-
botId: entry.scene.botId,
|
|
282
|
-
kind: entry.scene.kind,
|
|
283
|
-
peerId: entry.scene.peerId,
|
|
284
|
-
},
|
|
285
|
-
actionId,
|
|
286
|
-
outcome: 'failed',
|
|
287
|
-
}),
|
|
288
|
-
})
|
|
289
|
-
return readPayload(response)
|
|
290
|
-
}
|
|
1
|
+
// Pure client-side helpers, inlined into the browser bundle by build-client.
|
|
2
|
+
// No ESM imports here: the bundle is a lazy-CJS factory with only React injected.
|
|
3
|
+
|
|
4
|
+
export const API_ROOT = '/api/amadeus-qq'
|
|
5
|
+
export const MODE_IDS = Object.freeze([
|
|
6
|
+
'observe', 'safe-chat', 'enhanced', 'autonomous-social', 'owner-agent',
|
|
7
|
+
])
|
|
8
|
+
const OVERRIDE_VALUES = new Set(['inherit', 'allow', 'deny'])
|
|
9
|
+
|
|
10
|
+
export function revisionOf(entry) {
|
|
11
|
+
return entry?.revision ?? 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function draftFromEntry(entry) {
|
|
15
|
+
const overrides = {}
|
|
16
|
+
for (const capability of entry?.capabilities ?? []) {
|
|
17
|
+
if (capability.override === 'allow' || capability.override === 'deny') {
|
|
18
|
+
overrides[capability.id] = capability.override
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
sceneKey: entry?.key ?? '',
|
|
23
|
+
mode: entry?.permissions?.mode ?? entry?.effectiveMode ?? 'safe-chat',
|
|
24
|
+
overrides,
|
|
25
|
+
expectedRevision: revisionOf(entry),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isDraftDirty(entry, draft) {
|
|
30
|
+
if (!entry || !draft) return false
|
|
31
|
+
const base = draftFromEntry(entry)
|
|
32
|
+
if (base.mode !== draft.mode) return true
|
|
33
|
+
const keys = new Set([...Object.keys(base.overrides), ...Object.keys(draft.overrides ?? {})])
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
if ((base.overrides[key] ?? 'inherit') !== (draft.overrides?.[key] ?? 'inherit')) return true
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide which draft the editor should show after a Scene row is selected.
|
|
42
|
+
* A dirty draft is never replaced: the operator must save or cancel first, so
|
|
43
|
+
* an unsaved permission change can never be lost by an accidental click.
|
|
44
|
+
*/
|
|
45
|
+
export function nextDraftForSelection(currentEntry, currentDraft, targetEntry) {
|
|
46
|
+
if (currentDraft && isDraftDirty(currentEntry, currentDraft)) {
|
|
47
|
+
return { blocked: true, draft: currentDraft }
|
|
48
|
+
}
|
|
49
|
+
if (!targetEntry) return { blocked: false, draft: null }
|
|
50
|
+
const fresh = { ...draftFromEntry(targetEntry), sceneKey: targetEntry.key }
|
|
51
|
+
return { blocked: false, draft: fresh }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateDraft(entry, draft) {
|
|
55
|
+
if (!entry || !draft) return '未选择场景'
|
|
56
|
+
if (!MODE_IDS.includes(draft.mode)) return '模式无效'
|
|
57
|
+
if (draft.mode === 'owner-agent' && entry.ownerEligible !== true) return 'Owner Agent 仅可用于已配置的可信 Owner 私聊'
|
|
58
|
+
const known = new Set((entry.capabilities ?? []).map(capability => capability.id))
|
|
59
|
+
for (const [id, value] of Object.entries(draft.overrides ?? {})) {
|
|
60
|
+
if (!known.has(id)) return '包含未知能力:' + id
|
|
61
|
+
if (!OVERRIDE_VALUES.has(value)) return '覆盖值无效:' + id
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CAPABILITY_REASON_HINTS = Object.freeze({
|
|
67
|
+
'owner-directory-required': '需在可信 Owner 私聊使用 Owner Agent 模式,并对该能力显式 allow。',
|
|
68
|
+
'global-deny': '宿主全局策略拒绝该能力,Scene 设置无法解除。',
|
|
69
|
+
'account-publish-required': '该 QQ 账号未获授权使用 QQ 空间发布。',
|
|
70
|
+
'scene-deny': '该 Scene 显式拒绝(deny)此能力。',
|
|
71
|
+
'owner-private-required': '仅可信 Owner 私聊的 Owner Agent 模式可用。',
|
|
72
|
+
'owner-tool-required': '宿主未为该 Owner 场景配置此工具。',
|
|
73
|
+
'group-only': '仅群聊 Scene 可用;私聊不适用,与 Owner 权限无关。',
|
|
74
|
+
'qzone-read-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能读取 QZone。',
|
|
75
|
+
'qzone-use-explicit': '需在自主社交或可信 Owner Agent 模式显式 allow 才能使用 QZone。',
|
|
76
|
+
'profile-write-explicit': '账号资料写入仅限可信 Owner 私聊的 Owner Agent 模式并显式 allow。',
|
|
77
|
+
'owner-workspace-required': 'Owner 工作区需可信 Owner 私聊、Owner Agent 模式并显式 allow。',
|
|
78
|
+
mode: '当前模式未包含此能力。',
|
|
79
|
+
'scene-allow': '该 Scene 显式允许,但保存后按最新模式重算。',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fixed Chinese guidance for an implemented-but-denied capability. Uses only
|
|
84
|
+
* known denial reasons; an unknown reason falls back to a generic sentence and
|
|
85
|
+
* never echoes the server value. `Object.hasOwn` keeps names such as
|
|
86
|
+
* `constructor` or `toString` from resolving to inherited Object members.
|
|
87
|
+
*/
|
|
88
|
+
export function capabilityStateHint(capability) {
|
|
89
|
+
if (!capability || capability.implemented !== true || capability.effective === true) return null
|
|
90
|
+
const reason = capability.reason
|
|
91
|
+
return typeof reason === 'string' && Object.hasOwn(CAPABILITY_REASON_HINTS, reason)
|
|
92
|
+
? CAPABILITY_REASON_HINTS[reason]
|
|
93
|
+
: '当前策略拒绝该能力。'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Configuration hints; never a path, token or credential. */
|
|
97
|
+
export function capabilityConfigHint(capability, runtime = {}) {
|
|
98
|
+
if (!capability || capability.implemented !== true) return null
|
|
99
|
+
if (capability.id === 'qq.owner.workspace' && runtime.ownerWorkspace?.configured !== true) {
|
|
100
|
+
return '需要核心用实际 QQ 配置注册专用目录并授权后才可用。'
|
|
101
|
+
}
|
|
102
|
+
if ((capability.id === 'qq.qzone.use' || capability.id === 'qq.qzone.read' || capability.id === 'qq.qzone.publish')
|
|
103
|
+
&& runtime.providers?.qzone?.installed !== true) {
|
|
104
|
+
return '需要配置 QZone loopback Provider 后才可用;当前未观测到接线。'
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildSaveRequest(entry, draft) {
|
|
110
|
+
const overrides = {}
|
|
111
|
+
for (const [id, value] of Object.entries(draft.overrides ?? {})) {
|
|
112
|
+
if (value === 'allow' || value === 'deny') overrides[id] = value
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
scene: {
|
|
116
|
+
botId: entry.scene.botId,
|
|
117
|
+
kind: entry.scene.kind,
|
|
118
|
+
peerId: entry.scene.peerId,
|
|
119
|
+
},
|
|
120
|
+
access: { mode: draft.mode, overrides },
|
|
121
|
+
expectedRevision: draft.expectedRevision,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readPayload(response) {
|
|
126
|
+
let payload
|
|
127
|
+
try {
|
|
128
|
+
payload = await response.json()
|
|
129
|
+
} catch {
|
|
130
|
+
const error = new Error('管理接口返回了无法解析的响应(HTTP ' + response.status + ')')
|
|
131
|
+
error.status = response.status
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok || !payload || payload.ok !== true) {
|
|
135
|
+
const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
136
|
+
error.status = response.status
|
|
137
|
+
if (typeof payload?.code === 'string') error.code = payload.code
|
|
138
|
+
if (response.status === 409) error.conflict = true
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
return payload.data
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True only when the management surface is genuinely unusable for this page:
|
|
146
|
+
* authentication/owner admission failed, or the Host lacks the management
|
|
147
|
+
* service. Only the explicitly listed policy rejections and session-cancel
|
|
148
|
+
* failures stay editable; any unknown code falls back to its HTTP status so an
|
|
149
|
+
* unrecognized 401/403/503 keeps the editor locked (fail closed).
|
|
150
|
+
*/
|
|
151
|
+
const EDITABLE_FAILURE_CODES = new Set([
|
|
152
|
+
'QQ_OWNER_AGENT_FORBIDDEN',
|
|
153
|
+
'QQ_SCENE_UNAUTHORIZED',
|
|
154
|
+
'QQ_SESSION_CANCEL_FAILED',
|
|
155
|
+
'QQ_SESSION_CANCEL_UNAVAILABLE',
|
|
156
|
+
'QQ_PERMISSION_CONFLICT',
|
|
157
|
+
])
|
|
158
|
+
|
|
159
|
+
export function requiresReadOnly(error) {
|
|
160
|
+
const code = typeof error?.code === 'string' ? error.code : ''
|
|
161
|
+
if (code === 'QQ_AUTH_REQUIRED' || code === 'QQ_MANAGEMENT_UNAVAILABLE') return true
|
|
162
|
+
if (EDITABLE_FAILURE_CODES.has(code)) return false
|
|
163
|
+
const status = Number(error?.status)
|
|
164
|
+
return status === 401 || status === 403 || status === 503
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Fixed Chinese guidance per management failure code; never echoes server internals. */
|
|
168
|
+
export function managementErrorMessage(error) {
|
|
169
|
+
const code = typeof error?.code === 'string' ? error.code : ''
|
|
170
|
+
if (code === 'QQ_PERMISSION_CONFLICT') {
|
|
171
|
+
return '权限已被其他窗口修改(409);请重新加载后再编辑。'
|
|
172
|
+
}
|
|
173
|
+
if (code === 'QQ_AUTH_REQUIRED') {
|
|
174
|
+
return '当前请求没有通过 DSH 认证或 Owner 授权;请从已认证的 DSH 页面访问。'
|
|
175
|
+
}
|
|
176
|
+
if (code === 'QQ_MANAGEMENT_UNAVAILABLE') {
|
|
177
|
+
return '管理接口不可用;QQ 插件可能未启用或缺少 Host 服务。'
|
|
178
|
+
}
|
|
179
|
+
if (code === 'QQ_OWNER_AGENT_FORBIDDEN') {
|
|
180
|
+
return 'Owner Agent 仅可用于已配置的可信 Owner 私聊;当前场景或配置不允许保存该模式。'
|
|
181
|
+
}
|
|
182
|
+
if (code === 'QQ_SCENE_UNAUTHORIZED') {
|
|
183
|
+
return '该场景已不在 QQ 允许列表内;请更新配置并刷新后再操作。'
|
|
184
|
+
}
|
|
185
|
+
if (code === 'QQ_SESSION_CANCEL_FAILED' || code === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
|
|
186
|
+
return '权限可能已写入,但旧 QQ Session 未能安全取消;请刷新确认最新 revision 后再编辑。'
|
|
187
|
+
}
|
|
188
|
+
if (code === 'QQ_DELIVERY_IN_FLIGHT') {
|
|
189
|
+
return '该投递仍在进行中,现在不能标记为失败;请稍后刷新重试。'
|
|
190
|
+
}
|
|
191
|
+
if (code === 'QQ_DELIVERY_NOT_UNKNOWN') {
|
|
192
|
+
return '该投递已不是 unknown 状态;请刷新确认后再操作。'
|
|
193
|
+
}
|
|
194
|
+
if (code === 'QQ_DELIVERY_NOT_FOUND') {
|
|
195
|
+
return '未找到该 actionId 的未知投递;请刷新后确认。'
|
|
196
|
+
}
|
|
197
|
+
if (code === 'QQ_REQUEST_INVALID') {
|
|
198
|
+
return '管理请求无效;请刷新后重试。'
|
|
199
|
+
}
|
|
200
|
+
const status = Number(error?.status)
|
|
201
|
+
if (status === 401 || status === 403) {
|
|
202
|
+
return '当前请求没有通过 DSH 认证或 Owner 授权(HTTP ' + status + ');请从已认证的 DSH 页面访问。'
|
|
203
|
+
}
|
|
204
|
+
if (status === 503) {
|
|
205
|
+
return '管理接口不可用(503);QQ 插件可能未启用或缺少 Host 服务。'
|
|
206
|
+
}
|
|
207
|
+
return 'QQ 管理请求失败' + (Number.isInteger(status) ? '(HTTP ' + status + ')' : '')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function fetchScenes(fetchImpl = fetch) {
|
|
211
|
+
const response = await fetchImpl(API_ROOT + '/scenes', {
|
|
212
|
+
credentials: 'same-origin',
|
|
213
|
+
headers: { 'x-amadeus-qq': '1' },
|
|
214
|
+
})
|
|
215
|
+
return readPayload(response)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function savePermissions(entry, draft, fetchImpl = fetch) {
|
|
219
|
+
const response = await fetchImpl(API_ROOT + '/scenes/permissions', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
credentials: 'same-origin',
|
|
222
|
+
headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
|
|
223
|
+
body: JSON.stringify(buildSaveRequest(entry, draft)),
|
|
224
|
+
})
|
|
225
|
+
return readPayload(response)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Storage key for one operator confirmation input. It is scoped to the Scene
|
|
230
|
+
* and prefixed, so two Scenes with the same actionId never share a draft, and
|
|
231
|
+
* prototype names such as `__proto__` or `constructor` can never be the key.
|
|
232
|
+
* The API still receives the untouched actionId.
|
|
233
|
+
*/
|
|
234
|
+
export function deliveryInputKey(entry, actionId) {
|
|
235
|
+
return 'delivery-confirm:' + String(entry?.key ?? '') + '|' + String(actionId)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Fixed Settings copy for the Owner workspace. It never includes a directory
|
|
240
|
+
* path, token or provider URL; an unwired core keeps the feature "pending".
|
|
241
|
+
*/
|
|
242
|
+
export function ownerWorkspaceSummary(status) {
|
|
243
|
+
const configured = status?.configured === true
|
|
244
|
+
const writes = status?.writes === true
|
|
245
|
+
const maxBytes = Number.isSafeInteger(status?.maxBytes) && status.maxBytes > 0 ? status.maxBytes : 0
|
|
246
|
+
return {
|
|
247
|
+
state: configured ? '已接线' : 'pending(待核心接线)',
|
|
248
|
+
writes: writes ? '可写(逐次审批)' : '只读',
|
|
249
|
+
maxBytes,
|
|
250
|
+
note: configured
|
|
251
|
+
? '目录与写入授权由插件配置决定;本页不显示路径,也不保存配置。实机写入仍需人工审批,尚未完成真实验证。'
|
|
252
|
+
: 'Owner preset 已随包;核心尚未用实际 QQ 配置注册 Owner 工作区(未接线),当前不可读、不可写。',
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Provider labels only; never a root path, loopback URL or token value. */
|
|
257
|
+
export function providerSummary(providers) {
|
|
258
|
+
const stateOf = provider => {
|
|
259
|
+
if (provider?.installed !== true) return '未观测到接线'
|
|
260
|
+
if (provider?.configured !== true) return '已安装,未配置'
|
|
261
|
+
return provider?.enabled === true ? '已配置' : '已安装,未启用'
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
mediaSend: stateOf(providers?.mediaSend),
|
|
265
|
+
qzone: stateOf(providers?.qzone),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Manual reconciliation for one unknown delivery. Only the owner management
|
|
271
|
+
* surface can do this, and the body never carries message content: just the
|
|
272
|
+
* Scene identity, the operator-confirmed actionId and the fixed outcome.
|
|
273
|
+
*/
|
|
274
|
+
export async function resolveUnknownDelivery(entry, actionId, fetchImpl = fetch) {
|
|
275
|
+
const response = await fetchImpl(API_ROOT + '/deliveries/resolve', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
credentials: 'same-origin',
|
|
278
|
+
headers: { 'content-type': 'application/json', 'x-amadeus-qq': '1' },
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
scene: {
|
|
281
|
+
botId: entry.scene.botId,
|
|
282
|
+
kind: entry.scene.kind,
|
|
283
|
+
peerId: entry.scene.peerId,
|
|
284
|
+
},
|
|
285
|
+
actionId,
|
|
286
|
+
outcome: 'failed',
|
|
287
|
+
}),
|
|
288
|
+
})
|
|
289
|
+
return readPayload(response)
|
|
290
|
+
}
|
package/src/config.js
CHANGED
|
@@ -39,6 +39,7 @@ export const Config = z.object({
|
|
|
39
39
|
groupDefaultMode: z.string().default('safe-chat'),
|
|
40
40
|
privateDefaultMode: z.string().default('safe-chat'),
|
|
41
41
|
ownerPrivateIds: z.array(z.string()).default([]),
|
|
42
|
+
lifeReadPrivateIds: z.array(z.string()).default([]),
|
|
42
43
|
ownerTools: z.array(z.string()).default([]),
|
|
43
44
|
globalDeny: z.array(z.string()).default([]),
|
|
44
45
|
accountGrants: z.object({
|
|
@@ -46,3 +47,4 @@ export const Config = z.object({
|
|
|
46
47
|
}).default({}),
|
|
47
48
|
}).default({}),
|
|
48
49
|
})
|
|
50
|
+
|