@fengye_nobug/dsh-qq-bot 0.0.0-stage → 0.2.4

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.
@@ -0,0 +1,1203 @@
1
+ /**
2
+ * dsh-qq-bot —— 浏览器半(client half)
3
+ *
4
+ * 作用:在 **设置 → 插件 → 插件配置** 里放一张名为“QQ 桥接”的卡片,
5
+ * 用来改昵称、白名单、唤醒概率、目标会话这些设置 —— 也就是用户要的那个界面。
6
+ *
7
+ * 为什么这里的东西长得不像源码:
8
+ * DSH 的官方文档明确说,本仓库之外没有现成的 client 打包预设
9
+ * (`packages/client/tsdown.client.ts` 不发布),外部插件得自己复刻产物格式。
10
+ * 好在格式很简单:`window.__ModuleLoader__.load({ id, factory })`,
11
+ * `factory(require)` 返回一个带 `apply` / `inject` 的 Cordis 插件。
12
+ * 这里就是**手写**这个格式,不引入任何构建步骤。
13
+ * 参考实现:第三方插件 `dshmarket` 的 `client/client.js`(同样是手写/预构建产物)。
14
+ *
15
+ * 与宿主半的约定:
16
+ * - `id` 必须等于包名 `dsh-qq-bot`;
17
+ * - 卡片注册进 `settings.plugin.item`,**key 必须等于宿主注册的 settings 命名空间** `qq-bridge`,
18
+ * 否则配置页那一栏里什么都不会出现(它按命名空间派发)。
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-qq-bot',
22
+ factory: (require) => {
23
+ var module = { exports: {} }
24
+ var exports = module.exports
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
26
+
27
+ // react 是模块表里的 baseline 依赖,不用在 package.json 里声明 external。
28
+ const react = require('react')
29
+ const h = react.createElement
30
+
31
+ /** settings 命名空间,必须与 index.js 里的 NS 一致。 */
32
+ const NS = 'qq-bridge'
33
+
34
+ // ── 表单描述 ──────────────────────────────────────────────────────────────
35
+ // 加一个设置项 = 在这里加一行。kind: text | list | number | boolean | select
36
+ const FIELDS = [
37
+ {
38
+ key: 'checkUpdateOnStart',
39
+ label: '启动时检查一次',
40
+ kind: 'boolean',
41
+ hint: '默认关。打开并保存后,下次启动 dsh-web 会向 GitHub 问一次有没有新版本,只写在日志里,不会自动安装。平时用上面的「检查更新」就够了。',
42
+ },
43
+ {
44
+ key: 'sessionScope',
45
+ label: '会话怎么分',
46
+ kind: 'select',
47
+ options: [
48
+ ['single', '所有群共用一个会话'],
49
+ ['perGroup', '每个群、每个私聊各记各的'],
50
+ ],
51
+ hint: '共用一个会话:所有 QQ 消息都进下面选的那一个会话。各记各的:每个群、每个私聊自动开一条上下文,互不抢回复。',
52
+ },
53
+ {
54
+ key: 'maxRecentTurns',
55
+ label: '每个会话最多留几轮',
56
+ kind: 'number',
57
+ hint: '默认 40。聊太长会尽量把更早的内容压缩掉,回复更快。填 0 表示不限制。',
58
+ },
59
+ {
60
+ key: 'targetSessionId',
61
+ label: '接到哪个会话',
62
+ kind: 'session',
63
+ hint: '按名字选就行,不用记那串 id。选「每个群、每个私聊各记各的」时可以留空,留空就用宿主当前的默认模型;填了则新建的会话沿用这个会话的模型。',
64
+ },
65
+ {
66
+ key: 'nicknames',
67
+ label: '听到这些名字就回应',
68
+ kind: 'list',
69
+ hint: '别人喊到这些名字就一定回话。用逗号分开,例如:244, 小助手。',
70
+ },
71
+ {
72
+ key: 'wakeProbability',
73
+ label: '没点名时的回应概率',
74
+ kind: 'number',
75
+ step: '0.01',
76
+ hint: '没 @、也没喊名字时,按这个概率回话(0 到 1)。默认 0.05,也就是大约二十条里回一条。想少说话就调小。',
77
+ },
78
+ {
79
+ key: 'whitelist',
80
+ label: '哪些私聊可以叫醒',
81
+ kind: 'list',
82
+ hint: '填 QQ 号,用逗号分开。留空 = 任何私聊都不会叫醒机器人。',
83
+ },
84
+ {
85
+ key: 'groupWhitelist',
86
+ label: '哪些群可以叫醒',
87
+ kind: 'list',
88
+ hint: '填群号,用逗号分开。留空 = 任何群都不会叫醒机器人。',
89
+ },
90
+ {
91
+ key: 'atOnlyInGroup',
92
+ label: '群里必须 @ 才理',
93
+ kind: 'boolean',
94
+ hint: '打开之后,群里没 @ 机器人就当没看见。比上面的概率更严。',
95
+ },
96
+ {
97
+ key: 'replyMode',
98
+ label: '群里发出去的是哪一段',
99
+ kind: 'select',
100
+ options: [
101
+ ['marker', '只发「发给群」里的那段'],
102
+ ['tool', '用发消息工具'],
103
+ ['always', '整段回答都进群(慎用)'],
104
+ ],
105
+ hint: '「发给群」的那段要用 [QQ] 和 [/QQ] 包起来,包外面的话不会进群。选「用发消息工具」就改调 qq_bridge_send。选「整段回答都进群」会把思考过程也发出去,一般别用。',
106
+ },
107
+ {
108
+ key: 'delivery',
109
+ label: '忙的时候怎么插话',
110
+ kind: 'select',
111
+ options: [
112
+ ['auto', '能插就插,不能就排队(推荐)'],
113
+ ['followup', '一律新开一轮'],
114
+ ['steer', '不管忙不忙都插'],
115
+ ],
116
+ hint: '推荐:会话正忙就插进去接着说,闲着就排队等下一轮。「一律新开一轮」每次都等当前这轮结束。「不管忙不忙都插」会打断正在做的事。',
117
+ },
118
+ { key: 'replyWithQuote', label: '回复时带上原消息', kind: 'boolean', hint: '回话时引用那条把机器人叫醒的消息。' },
119
+ { key: 'stripMarkdown', label: '去掉 Markdown 记号', kind: 'boolean', hint: '发出去之前拿掉 **粗体**、`代码` 这类记号。QQ 不会渲染它们。' },
120
+ {
121
+ key: 'replyMaxChars',
122
+ label: '一条最多多少字',
123
+ kind: 'number',
124
+ hint: '超过这个字数就整条不发,需要缩短后再发。',
125
+ },
126
+ {
127
+ key: 'onebotUrl',
128
+ label: 'OneBot 连接地址',
129
+ kind: 'text',
130
+ hint: 'NapCat 的 WebSocket 地址,例如 ws://127.0.0.1:3001。留空 = 先不连接。',
131
+ },
132
+ {
133
+ key: 'accessTokenEnv',
134
+ label: 'OneBot 令牌放在哪个环境变量',
135
+ kind: 'text',
136
+ hint: '填环境变量的名字(例如 NAPCAT_TOKEN),不要把令牌本身贴进来。这也不是模型的 API key。NapCat 没开令牌校验就留空。',
137
+ check: (v) => {
138
+ const s = String(v ?? '').trim()
139
+ if (s === '') return null
140
+ if (/^(sk-|Bearer\s)/i.test(s) || s.length > 64 || /[.\s]/.test(s)) {
141
+ return '这里要填的是环境变量名(例如 NAPCAT_TOKEN),不是令牌本身,也不是模型的 API key。'
142
+ }
143
+ return null
144
+ },
145
+ },
146
+ { key: 'selfId', label: '机器人自己的 QQ 号', kind: 'text', hint: '留空 = 连上之后自动读取。' },
147
+ {
148
+ key: 'visionModel',
149
+ label: '用来看图的模型',
150
+ kind: 'text',
151
+ hint: '写成 provider/model。留空 = 自动找一个能看图的模型。',
152
+ },
153
+ {
154
+ key: 'agentPreset',
155
+ label: '人设预设',
156
+ kind: 'text',
157
+ hint: '接到会话时用哪套人设。留空 = 沿用那个会话自己记着的。',
158
+ },
159
+ { key: 'attachRecentChat', label: '把没叫醒时的聊天一并带上', kind: 'boolean', hint: '下次叫醒时,把中间没理的那些话一起附上。' },
160
+ { key: 'recentChatLimit', label: '附带最近几条', kind: 'number', hint: '上面那段最多带最近几条(0–200)。' },
161
+ {
162
+ key: 'napcatInstallDir',
163
+ label: 'NapCat 安装目录',
164
+ kind: 'text',
165
+ hint: '留空 = $DSH_HOME/napcat。自己装的或 Docker 里的 NapCat 不在这个默认位置,请填实际目录。',
166
+ },
167
+ {
168
+ key: 'napcatWebuiUrl',
169
+ label: 'NapCat 网页管理地址',
170
+ kind: 'text',
171
+ hint: '可选。留空就读安装目录里的 webui.json。那边通常是 127.0.0.1,只有跑着 NapCat 的那台机器能打开。远程使用时填一个外面打得开的地址。',
172
+ },
173
+ { key: 'napcatVersion', label: '要下载的 NapCat 版本', kind: 'text', hint: '例如 v4.18.28。留空 = 下载最新版。' },
174
+ { key: 'downloadProxy', label: '下载时走的代理', kind: 'text', hint: '例如 http://127.0.0.1:7890。留空 = 先看环境变量,没有就直接下载。' },
175
+ {
176
+ key: 'onebotPort',
177
+ label: 'OneBot 端口',
178
+ kind: 'number',
179
+ hint: '写进 NapCat 配置、也用来连接的端口。NapCat 如果是自己另外部署的,这里要和那边的端口一致。',
180
+ },
181
+ { key: 'qqNumber', label: '快速登录用的 QQ 号', kind: 'text', hint: '需要先在 NapCat 里成功登录过一次。' },
182
+ ]
183
+
184
+ function format(field, raw) {
185
+ if (field.kind === 'list') return Array.isArray(raw) ? raw.join(', ') : raw == null ? '' : String(raw)
186
+ if (field.kind === 'boolean') return raw === true
187
+ if (field.kind === 'number') return raw == null || raw === '' ? '' : String(raw)
188
+ return raw == null ? '' : String(raw)
189
+ }
190
+
191
+ /** 表单值 → 要写进设置的 JSON;数字不合法返回 undefined(拦住保存)。 */
192
+ function parse(field, raw) {
193
+ if (field.kind === 'list') {
194
+ return String(raw == null ? '' : raw)
195
+ .split(/[,,\s]+/)
196
+ .map((s) => s.trim())
197
+ .filter((s) => s !== '')
198
+ }
199
+ if (field.kind === 'boolean') return raw === true
200
+ if (field.kind === 'number') {
201
+ const text = String(raw ?? '').trim()
202
+ // 清空输入框时浏览器给的是空串,Number('') === 0。不能把「留空」存成 0。
203
+ if (text === '') return undefined
204
+ const n = Number(text)
205
+ return Number.isFinite(n) ? n : undefined
206
+ }
207
+ return String(raw == null ? '' : raw).trim()
208
+ }
209
+
210
+ function draftFrom(value) {
211
+ const v = value && typeof value === 'object' ? value : {}
212
+ const draft = {}
213
+ for (const f of FIELDS) draft[f.key] = format(f, v[f.key])
214
+ return draft
215
+ }
216
+
217
+ function same(a, b) {
218
+ return JSON.stringify(a) === JSON.stringify(b)
219
+ }
220
+
221
+ /**
222
+ * 会话下拉的选项:真实的会话列表 + “不驱动”+ 一个兜底项。
223
+ *
224
+ * **归档的会话不列出来**(用户报的 bug:下拉里混进了一堆早就归档的会话)——
225
+ * 归档集在 workspace 域:`ctx.workspaces.list.getSnapshot().archivedSessionIds`。
226
+ *
227
+ * ⚠️ **TODO(下次修):子代理会话也会被列进来。**
228
+ * `SessionSummary` 上有 `origin?: 'subagent'` 与 `parentId?: SessionId`
229
+ * (见 `@deepseek-ai/dsh-api-session-controller` 的 `SessionSummary`),据此可以剔除。
230
+ * 剔除之后同样要给**当前选中项**兜底,规则与下面的归档处理一致 ——
231
+ * 否则子代理会话是当前目标时,一保存就把目标会话清空。
232
+ * 退化做法也可接受:把它们折叠进一个“显示子代理会话”的展开项,别一次铺开一长串。
233
+ *
234
+ * 两个兜底项都是必要的:
235
+ * - 配置里那个 id 可能**不在**列表里(别的工作区、宿主还没加载完);
236
+ * - 也可能**正好是归档的**(用户刚把它归档了)。
237
+ * 两种都必须保留成选项,否则用户一保存就把目标会话清空了。
238
+ */
239
+ function sessionOptions(state, current, archivedIds) {
240
+ const archived = new Set((Array.isArray(archivedIds) ? archivedIds : []).map((id) => String(id)))
241
+ const out = [{ value: '', label: '(不接到任何会话)' }]
242
+ const ids = Array.isArray(state?.ids) ? state.ids : []
243
+ const byId = state && typeof state.byId === 'object' && state.byId ? state.byId : {}
244
+ const cur = String(current ?? '')
245
+ const seen = new Set([''])
246
+ for (const id of ids) {
247
+ const key = String(id)
248
+ const isArchived = archived.has(key)
249
+ // 归档的一律不显示 —— 除非它就是当前选中的那个(不显示的话就没法保留/换掉了)
250
+ if (isArchived && key !== cur) continue
251
+ seen.add(key)
252
+ const row = byId[id] ?? {}
253
+ const title = typeof row.displayTitle === 'string' && row.displayTitle !== '' ? row.displayTitle : key
254
+ out.push({
255
+ value: key,
256
+ label: `${title}${row.running ? '(运行中)' : ''}${isArchived ? '(已归档)' : ''}`,
257
+ })
258
+ }
259
+ if (cur !== '' && !seen.has(cur)) {
260
+ out.push({ value: cur, label: `${cur}(${archived.has(cur) ? '已归档' : '不在会话列表里'})` })
261
+ }
262
+ return out
263
+ }
264
+
265
+ /** 没有 sessions / workspaces 服务(或它们还没就绪)时用的稳定空快照 —— 必须稳定,否则 useSyncExternalStore 会死循环。 */
266
+ const EMPTY_SESSION_LIST = { ids: [], byId: {} }
267
+ const EMPTY_WORKSPACES = { archivedSessionIds: [] }
268
+
269
+ // ── 样式(内联,跟随主题文字色) ──────────────────────────────────────────
270
+ const BORDER = '1px solid rgba(127,127,127,0.35)'
271
+ const S = {
272
+ wrap: { display: 'flex', flexDirection: 'column', gap: '10px' },
273
+ head: { display: 'flex', flexDirection: 'column', gap: '2px' },
274
+ title: { fontSize: '14px', fontWeight: 600 },
275
+ sub: { fontSize: '12px', opacity: 0.65, lineHeight: 1.5 },
276
+ grid: { display: 'grid', gridTemplateColumns: 'minmax(120px, 180px) 1fr', gap: '8px 12px', alignItems: 'start' },
277
+ label: { fontSize: '13px', paddingTop: '5px' },
278
+ input: {
279
+ width: '100%',
280
+ boxSizing: 'border-box',
281
+ padding: '5px 8px',
282
+ borderRadius: '6px',
283
+ border: BORDER,
284
+ background: 'transparent',
285
+ color: 'inherit',
286
+ font: 'inherit',
287
+ fontSize: '13px',
288
+ },
289
+ hint: { fontSize: '11px', opacity: 0.55, marginTop: '3px', lineHeight: 1.5 },
290
+ check: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', paddingTop: '4px' },
291
+ foot: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' },
292
+ btn: {
293
+ padding: '5px 14px',
294
+ borderRadius: '6px',
295
+ border: BORDER,
296
+ background: 'transparent',
297
+ color: 'inherit',
298
+ cursor: 'pointer',
299
+ font: 'inherit',
300
+ fontSize: '13px',
301
+ },
302
+ primary: { background: 'rgba(127,127,127,0.18)', fontWeight: 600 },
303
+ header: {
304
+ display: 'flex',
305
+ alignItems: 'center',
306
+ gap: '8px',
307
+ width: '100%',
308
+ padding: '8px 10px',
309
+ borderRadius: '6px',
310
+ border: BORDER,
311
+ background: 'transparent',
312
+ color: 'inherit',
313
+ cursor: 'pointer',
314
+ font: 'inherit',
315
+ textAlign: 'left',
316
+ },
317
+ chevron: { marginLeft: 'auto', opacity: 0.6 },
318
+ note: { fontSize: '12px', opacity: 0.7 },
319
+ err: { fontSize: '12px', color: '#e5534b' },
320
+ overridden: { fontSize: '11px', opacity: 0.5, marginLeft: '4px' },
321
+ // “撤销覆盖”:与“·已覆盖”并排的小按钮。文案本身就是动作,不加图标。
322
+ link: {
323
+ marginLeft: '4px',
324
+ padding: '0 4px',
325
+ border: 'none',
326
+ background: 'transparent',
327
+ color: 'inherit',
328
+ opacity: 0.75,
329
+ textDecoration: 'underline',
330
+ cursor: 'pointer',
331
+ font: 'inherit',
332
+ fontSize: '11px',
333
+ },
334
+ ops: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
335
+ }
336
+
337
+ function disabled(btn, off) {
338
+ return off ? { ...btn, opacity: 0.5, cursor: 'default' } : btn
339
+ }
340
+
341
+ // ── 卡片 ──────────────────────────────────────────────────────────────────
342
+
343
+ function Card(props) {
344
+ const scope = props.scope
345
+ const subscribe = react.useCallback((cb) => scope.subscribe(cb), [scope])
346
+ const getSnapshot = react.useCallback(() => scope.getSnapshot(), [scope])
347
+ const snap = react.useSyncExternalStore(subscribe, getSnapshot)
348
+
349
+ const [draft, setDraft] = react.useState(null)
350
+ const [busy, setBusy] = react.useState(false)
351
+ const [error, setError] = react.useState('')
352
+ const [note, setNote] = react.useState('')
353
+ // 默认展开:字段直接可见。标题仍可收起。
354
+ const [open, setOpen] = react.useState(true)
355
+ // NapCat 的三个操作按钮:展开时拉一次接口;拿不到就保持 null(按钮禁用)。
356
+ const [napcat, setNapcat] = react.useState(null)
357
+ const [napcatBusy, setNapcatBusy] = react.useState('')
358
+ const [pluginVersion, setPluginVersion] = react.useState('')
359
+ const [versionState, setVersionState] = react.useState('loading')
360
+ const [updateBusy, setUpdateBusy] = react.useState(false)
361
+ const [updateNote, setUpdateNote] = react.useState('')
362
+ const [updateIsError, setUpdateIsError] = react.useState(false)
363
+
364
+ const current = react.useMemo(() => draftFrom(snap.value), [snap.value])
365
+ const shown = draft ?? current
366
+ const dirty = draft !== null && !same(draft, current)
367
+ const user = snap.user && typeof snap.user === 'object' ? snap.user : {}
368
+
369
+ // ── 目标会话的下拉数据源 ──────────────────────────────────────────────
370
+ // ctx.sessions.list 是客户端 SDK 暴露的会话列表快照(ObservableSnapshot)。
371
+ // 取不到(这个部署没装 sessions 服务、或它还没就绪)就退回“只有兜底项”,
372
+ // 卡片本身照常渲染 —— 宁可少一个下拉,也不要整张卡片消失。
373
+ const sessions = props.sessionsOf ? props.sessionsOf() : undefined
374
+ const listStore = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list : undefined
375
+ const listSubscribe = react.useCallback((cb) => (listStore ? listStore.subscribe(cb) : () => {}), [listStore])
376
+ const listGetSnapshot = react.useCallback(
377
+ () => (listStore ? listStore.getSnapshot() : EMPTY_SESSION_LIST),
378
+ [listStore],
379
+ )
380
+ const sessionList = react.useSyncExternalStore(listSubscribe, listGetSnapshot)
381
+ react.useEffect(() => {
382
+ // 进卡片时拉一次,免得显示的是很久以前的标题
383
+ if (sessions && typeof sessions.refresh === 'function') Promise.resolve(sessions.refresh()).catch(() => {})
384
+ }, [sessions])
385
+
386
+ // 归档集在 workspace 域:ctx.workspaces.list 的快照里有 archivedSessionIds
387
+ //(“Complete registry-global archive set”)。取不到就当作“没有归档的会话”——
388
+ // 宁可多列几个,也别把用户能选的会话藏掉。
389
+ const workspaces = props.workspacesOf ? props.workspacesOf() : undefined
390
+ const wsStore =
391
+ workspaces && workspaces.list && typeof workspaces.list.getSnapshot === 'function' ? workspaces.list : undefined
392
+ const wsSubscribe = react.useCallback((cb) => (wsStore && typeof wsStore.subscribe === 'function' ? wsStore.subscribe(cb) : () => {}), [wsStore])
393
+ const wsGetSnapshot = react.useCallback(() => {
394
+ if (!wsStore) return EMPTY_WORKSPACES
395
+ try {
396
+ return wsStore.getSnapshot() ?? EMPTY_WORKSPACES
397
+ } catch {
398
+ return EMPTY_WORKSPACES
399
+ }
400
+ }, [wsStore])
401
+ const workspaceSnapshot = react.useSyncExternalStore(wsSubscribe, wsGetSnapshot)
402
+
403
+ /**
404
+ * 拉一次 NapCat 状态(WebUI 地址 + 是否在运行)。
405
+ * 没有 fetch 或响应不是 ok:true 时返回 undefined,调用方保持上一次的状态。
406
+ */
407
+ async function fetchNapcat() {
408
+ if (typeof fetch !== 'function') return undefined
409
+ const res = await fetch(NAPCAT_API, { credentials: 'include', headers: { accept: 'application/json' } })
410
+ const body = await res.json().catch(() => ({}))
411
+ return body && body.ok === true ? body : undefined
412
+ }
413
+
414
+ // 展开卡片时读一次 NapCat 状态,之后每 5 秒轮询一次。
415
+ // 轮询用于识别外部启动:用户在别处手动跑了 bat 时,只有重新拉取才能把按钮改成“已在运行”。
416
+ // 卡片收起或组件卸载时清掉定时器,不让它在后台一直跑。
417
+ react.useEffect(() => {
418
+ if (!open) return undefined
419
+ let alive = true
420
+ if (typeof fetch !== 'function') return undefined
421
+ fetch(UPDATE_API, { credentials: 'include', headers: { accept: 'application/json' } })
422
+ .then((res) => res.json().catch(() => null))
423
+ .then((body) => {
424
+ if (!alive) return
425
+ if (body && body.ok === true && body.current) {
426
+ setPluginVersion(String(body.current))
427
+ setVersionState('ready')
428
+ return
429
+ }
430
+ setVersionState('missing')
431
+ })
432
+ .catch(() => {
433
+ // 读不到版本就留一句白话,不把异常原文摆出来。
434
+ if (alive) setVersionState('missing')
435
+ })
436
+ return () => {
437
+ alive = false
438
+ }
439
+ }, [open])
440
+
441
+ function plainUpdateText(value, ok) {
442
+ const text = String(value ?? '').trim()
443
+ if (text === '') return ok ? '检查完成。' : '检查更新失败,稍后再试。'
444
+ if (!ok && (/TypeError|fetch failed|ECONN|ENOTFOUND|API rate limit/i.test(text) || /\n\s+at /.test(text))) {
445
+ if (/rate limit/i.test(text)) return 'GitHub 限流了,等一会儿再点「检查更新」。'
446
+ return '连不上 GitHub,这次没有检查到更新。稍后再试。'
447
+ }
448
+ return text
449
+ }
450
+
451
+ async function checkForUpdate() {
452
+ setUpdateBusy(true)
453
+ setUpdateNote('')
454
+ setUpdateIsError(false)
455
+ try {
456
+ if (typeof fetch !== 'function') {
457
+ setUpdateIsError(true)
458
+ setUpdateNote('这个页面没有网络接口,检查不了更新。')
459
+ return
460
+ }
461
+ const res = await fetch(UPDATE_API, {
462
+ method: 'POST',
463
+ credentials: 'include',
464
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
465
+ body: '{}',
466
+ })
467
+ const body = await res.json().catch(() => null)
468
+ if (!body || typeof body !== 'object') {
469
+ setUpdateIsError(true)
470
+ setUpdateNote('检查更新没有返回能看懂的结果。稍后再试。')
471
+ return
472
+ }
473
+ if (body.current) setPluginVersion(String(body.current))
474
+ const ok = body.ok === true
475
+ setUpdateIsError(!ok)
476
+ setUpdateNote(plainUpdateText(ok ? body.message : body.error, ok))
477
+ } catch {
478
+ setUpdateIsError(true)
479
+ setUpdateNote('连不上本机的检查更新接口。确认 dsh-web 在跑,稍后再试。')
480
+ } finally {
481
+ setUpdateBusy(false)
482
+ }
483
+ }
484
+
485
+ react.useEffect(() => {
486
+ if (!open) return undefined
487
+ let alive = true
488
+ const pull = () => {
489
+ fetchNapcat()
490
+ .then((body) => {
491
+ if (alive && body !== undefined) setNapcat(body)
492
+ })
493
+ .catch(() => {
494
+ // 单次失败保留上一次的状态:轮询会再试,不给按钮闪一下禁用。
495
+ })
496
+ }
497
+ pull()
498
+ const timer = setInterval(pull, 5000)
499
+ return () => {
500
+ alive = false
501
+ clearInterval(timer)
502
+ }
503
+ }, [open])
504
+
505
+ /** 两个 POST 按钮共用:动作没成(ok:false)也把宿主的 error 原文显示出来。 */
506
+ async function postNapcat(action, okNote) {
507
+ setNapcatBusy(action)
508
+ setError('')
509
+ setNote('')
510
+ try {
511
+ if (typeof fetch !== 'function') throw new Error('这个环境没有 fetch')
512
+ // 显式声明 JSON:宿主接口对**带请求体**的 POST 只认 application/json。
513
+ // 这两个动作本身没有请求体,服务端也放行无体的 POST —— 那条是给还没刷新的旧 bundle 兜底。
514
+ const res = await fetch(`${NAPCAT_API}/${action}`, {
515
+ method: 'POST',
516
+ credentials: 'include',
517
+ headers: { 'content-type': 'application/json' },
518
+ })
519
+ const body = await res.json().catch(() => ({}))
520
+ if (!res.ok || body?.ok !== true) throw new Error(String(body?.error || `HTTP ${res.status}`))
521
+ setNote(String(body?.message || okNote))
522
+ } catch (e) {
523
+ setError(String((e && e.message) || e))
524
+ } finally {
525
+ setNapcatBusy('')
526
+ // 成功或失败都重拉一次:启动成功后按钮要立刻变“已在运行”,
527
+ // 被宿主拒绝(例如已在运行)时也要把真实状态显示出来。
528
+ try {
529
+ const fresh = await fetchNapcat()
530
+ if (fresh !== undefined) setNapcat(fresh)
531
+ } catch {
532
+ // 拉取失败时保留上一次的状态
533
+ }
534
+ }
535
+ }
536
+
537
+ function change(key) {
538
+ return (event) => {
539
+ const next = { ...shown }
540
+ next[key] = event.target.type === 'checkbox' ? event.target.checked : event.target.value
541
+ setDraft(next)
542
+ setNote('')
543
+ setError('')
544
+ }
545
+ }
546
+
547
+ async function save() {
548
+ setBusy(true)
549
+ setError('')
550
+ setNote('')
551
+ try {
552
+ const writes = []
553
+ for (const f of FIELDS) {
554
+ if (same(current[f.key], shown[f.key])) continue
555
+ const next = parse(f, shown[f.key])
556
+ if (next === undefined) {
557
+ const blank = f.kind === 'number' && String(shown[f.key] ?? '').trim() === ''
558
+ throw new Error(
559
+ blank
560
+ ? `「${f.label}」留空不会当成 0。想恢复默认请点撤销覆盖。`
561
+ : `「${f.label}」不是合法数字`,
562
+ )
563
+ }
564
+ writes.push([f.key, next])
565
+ }
566
+ for (const [key, value] of writes) await scope.set(key, value)
567
+ setDraft(null)
568
+ setNote(writes.length === 0 ? '没有改动' : `已保存 ${writes.length} 项`)
569
+ } catch (e) {
570
+ setError(String((e && e.message) || e))
571
+ } finally {
572
+ setBusy(false)
573
+ }
574
+ }
575
+
576
+ /**
577
+ * 撤销一个字段的本机覆盖。
578
+ *
579
+ * 设置读取优先级是“设置文件 > composition config”。卡片只发 set 时,
580
+ * 用户从插件配置页改的值会被设置文件里的旧值盖住,且没有地方撤销。
581
+ * 这里走设置源的 unset:清掉那一项,该键回落到 composition config 的值,
582
+ * 刷新后“·已覆盖”标记消失。
583
+ */
584
+ async function unsetOverride(key) {
585
+ const field = FIELDS.find((f) => f.key === key)
586
+ setBusy(true)
587
+ setError('')
588
+ setNote('')
589
+ try {
590
+ if (typeof scope.unset !== 'function') throw new Error('这个设置源不支持撤销覆盖')
591
+ await scope.unset(key)
592
+ setDraft(null)
593
+ setNote(`已撤销「${field ? field.label : key}」的本机覆盖`)
594
+ } catch (e) {
595
+ setError(String((e && e.message) || e))
596
+ } finally {
597
+ setBusy(false)
598
+ }
599
+ }
600
+
601
+ if (snap.status === 'loading') {
602
+ return h('div', { style: S.wrap }, h('div', { style: S.sub }, '载入设置中…'))
603
+ }
604
+ if (snap.status === 'unavailable') {
605
+ return h(
606
+ 'div',
607
+ { style: S.wrap },
608
+ h('div', { style: S.title }, 'QQ 桥接'),
609
+ h(
610
+ 'div',
611
+ { style: S.sub },
612
+ '这个部署没有为 qq-bridge 提供可写的设置服务(settings 提供者没挂载,或连接处于内存模式)。',
613
+ ),
614
+ h(
615
+ 'div',
616
+ { style: S.hint },
617
+ '宿主的两条设置通道都没拿到:插件配置表单(DSH 0.2.0 的 #677 迁移未完成)与本插件的本机设置接口。',
618
+ ),
619
+ h(
620
+ 'div',
621
+ { style: S.hint },
622
+ '先看诊断里「本机接口」那项:若宿主没有 webServer 服务,接口挂不上(此时仍可点右上角「打开配置文件」,改 profile 的 cordis.patch.yml 里 `- id: qq-bridge` 的 config 后重启)。',
623
+ ),
624
+ h(
625
+ 'div',
626
+ { style: S.hint },
627
+ `诊断:${typeof scope.detail === 'function' ? scope.detail() : '(无诊断信息)'}`,
628
+ ),
629
+ )
630
+ }
631
+
632
+ const rows = []
633
+ for (const f of FIELDS) {
634
+ const value = shown[f.key]
635
+ const overridden = Object.prototype.hasOwnProperty.call(user, f.key)
636
+ let control
637
+ if (f.kind === 'boolean') {
638
+ control = h(
639
+ 'label',
640
+ { style: S.check },
641
+ h('input', {
642
+ type: 'checkbox',
643
+ checked: value === true,
644
+ disabled: !snap.writable || busy,
645
+ onChange: change(f.key),
646
+ }),
647
+ h('span', null, f.hint || ''),
648
+ )
649
+ } else if (f.kind === 'select') {
650
+ control = h(
651
+ 'select',
652
+ { style: S.input, value: String(value), disabled: !snap.writable || busy, onChange: change(f.key) },
653
+ (f.options || []).map(([v, text]) => h('option', { key: v, value: v }, text)),
654
+ )
655
+ } else if (f.kind === 'session') {
656
+ control = h(
657
+ 'select',
658
+ { style: S.input, value: String(value), disabled: !snap.writable || busy, onChange: change(f.key) },
659
+ sessionOptions(sessionList, value, workspaceSnapshot?.archivedSessionIds).map((o) =>
660
+ h('option', { key: o.value || '(none)', value: o.value }, o.label),
661
+ ),
662
+ )
663
+ } else {
664
+ control = h('input', {
665
+ type: f.kind === 'number' ? 'number' : 'text',
666
+ ...(f.step ? { step: f.step } : {}),
667
+ style: S.input,
668
+ value: String(value),
669
+ disabled: !snap.writable || busy,
670
+ onChange: change(f.key),
671
+ spellCheck: false,
672
+ })
673
+ }
674
+ rows.push(
675
+ h(
676
+ 'div',
677
+ { key: f.key, style: S.label },
678
+ f.label,
679
+ overridden ? h('span', { style: S.overridden }, '·已覆盖') : null,
680
+ // 每个字段都有自己的标签格,所以逐字段放按钮对所有类型都成立 —— 不必只给一个统一按钮。
681
+ overridden
682
+ ? h(
683
+ 'button',
684
+ {
685
+ type: 'button',
686
+ style: disabled(S.link, busy || !snap.writable),
687
+ disabled: busy || !snap.writable,
688
+ title: '清掉设置文件里的这一项,回落到 composition config 的值',
689
+ onClick: () => unsetOverride(f.key),
690
+ },
691
+ '撤销覆盖',
692
+ )
693
+ : null,
694
+ ),
695
+ )
696
+ rows.push(
697
+ h(
698
+ 'div',
699
+ { key: `${f.key}-ctl` },
700
+ control,
701
+ f.kind !== 'boolean' && f.hint
702
+ ? h(
703
+ 'div',
704
+ { style: S.hint },
705
+ // 会话下拉额外把真实 id 显示出来:看着名字选,但真要手抄/核对时 id 就在旁边
706
+ (f.kind === 'session' && String(value) !== '' ? `${f.hint}当前:${value}` : f.hint) +
707
+ (typeof f.check === 'function' && f.check(value) ? `\n⚠️ ${f.check(value)}` : ''),
708
+ )
709
+ : null,
710
+ ),
711
+ )
712
+ }
713
+
714
+ // 地址由宿主拼好:填了「NapCat 网页管理地址」就用它,否则用 webui.json。
715
+ // 回环地址按钮保持可点(本机用户需要),旁边说明只有跑着 NapCat 的机器打得开。
716
+ const webuiUrl = napcat && typeof napcat.webuiUrl === 'string' ? napcat.webuiUrl : ''
717
+ const webuiHint = napcat && typeof napcat.webuiHint === 'string' ? napcat.webuiHint : ''
718
+ const dirHint = napcat && typeof napcat.installDirMissingHint === 'string' ? napcat.installDirMissingHint : ''
719
+ // 宿主报的 running 是权威判据(含外部启动的 NapCat)。拿不到状态时按未运行处理。
720
+ const napcatRunning = napcat !== null && napcat.running === true
721
+ const napcatOps = h(
722
+ 'div',
723
+ { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
724
+ h(
725
+ 'div',
726
+ { style: S.ops },
727
+ h(
728
+ 'button',
729
+ {
730
+ type: 'button',
731
+ style: disabled(S.btn, webuiUrl === '' || napcatBusy !== ''),
732
+ disabled: webuiUrl === '' || napcatBusy !== '',
733
+ onClick: () => {
734
+ if (webuiUrl !== '') window.open(webuiUrl, '_blank', 'noopener')
735
+ },
736
+ },
737
+ '打开 WebUI',
738
+ ),
739
+ h(
740
+ 'button',
741
+ {
742
+ type: 'button',
743
+ style: disabled(S.btn, napcatRunning || napcatBusy !== ''),
744
+ disabled: napcatRunning || napcatBusy !== '',
745
+ onClick: () => postNapcat('launch', '已启动 NapCat'),
746
+ },
747
+ napcatRunning ? '已在运行' : napcatBusy === 'launch' ? '启动中…' : '启动 NapCat',
748
+ ),
749
+ h(
750
+ 'button',
751
+ {
752
+ type: 'button',
753
+ style: disabled(S.btn, napcatBusy !== ''),
754
+ disabled: napcatBusy !== '',
755
+ onClick: () =>
756
+ postNapcat('reveal', napcat && napcat.installDir ? `已打开文件夹:${napcat.installDir}` : '已打开文件夹'),
757
+ },
758
+ napcatBusy === 'reveal' ? '打开中…' : '打开文件夹',
759
+ ),
760
+ ),
761
+ webuiUrl === ''
762
+ ? h('div', { style: S.hint }, '没读到 webui.json。远程或自己部署时,请填写上面的「NapCat 网页管理地址」。')
763
+ : null,
764
+ webuiUrl !== '' && webuiHint !== '' ? h('div', { style: S.hint }, webuiHint) : null,
765
+ dirHint !== '' ? h('div', { style: S.hint }, dirHint) : null,
766
+ )
767
+
768
+ const header = h(
769
+ 'button',
770
+ { type: 'button', onClick: () => setOpen(!open), 'aria-expanded': open, style: S.header },
771
+ h('span', { style: S.title }, 'QQ 桥接'),
772
+ h('span', { style: S.sub }, dirty ? '有未保存的改动' : pluginVersion ? `当前版本 ${pluginVersion}` : '把固定的 DSH 会话接到 QQ'),
773
+ h('span', { style: S.chevron }, open ? '▴' : '▾'),
774
+ )
775
+ const versionLine = pluginVersion
776
+ ? `当前版本 ${pluginVersion}`
777
+ : versionState === 'loading'
778
+ ? '正在读取当前版本…'
779
+ : '当前版本暂时读不到。确认 dsh-web 已重启后再打开这张卡片。'
780
+ const updateBox = h(
781
+ 'div',
782
+ { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
783
+ h('div', { style: S.sub }, versionLine),
784
+ h(
785
+ 'div',
786
+ { style: S.ops },
787
+ h(
788
+ 'button',
789
+ {
790
+ type: 'button',
791
+ style: disabled(S.btn, updateBusy),
792
+ disabled: updateBusy,
793
+ onClick: () => {
794
+ checkForUpdate()
795
+ },
796
+ },
797
+ updateBusy ? '检查中…' : '检查更新',
798
+ ),
799
+ ),
800
+ h('div', { style: S.hint }, '向 GitHub 问一次有没有新版本。不会自动下载,也不会自动安装。'),
801
+ updateNote
802
+ ? h('div', { style: { ...(updateIsError ? S.err : S.note), whiteSpace: 'pre-wrap' } }, updateNote)
803
+ : null,
804
+ )
805
+
806
+ if (!open) return h('div', { style: S.wrap }, header)
807
+ return h(
808
+ 'div',
809
+ { style: S.wrap },
810
+ header,
811
+ updateBox,
812
+ h('div', { style: S.grid }, rows),
813
+ napcatOps,
814
+ h(
815
+ 'div',
816
+ { style: S.foot },
817
+ h(
818
+ 'button',
819
+ {
820
+ type: 'button',
821
+ style: disabled({ ...S.btn, ...S.primary }, !dirty || busy || !snap.writable),
822
+ disabled: !dirty || busy || !snap.writable,
823
+ onClick: save,
824
+ },
825
+ busy ? '保存中…' : '保存',
826
+ ),
827
+ h(
828
+ 'button',
829
+ {
830
+ type: 'button',
831
+ style: disabled(S.btn, !dirty || busy),
832
+ disabled: !dirty || busy,
833
+ onClick: () => {
834
+ setDraft(null)
835
+ setError('')
836
+ setNote('')
837
+ },
838
+ },
839
+ '放弃修改',
840
+ ),
841
+ !snap.writable ? h('span', { style: S.note }, '(只读:这个连接不改宿主文档)') : null,
842
+ error ? h('span', { style: S.err }, error) : null,
843
+ !error && note ? h('span', { style: S.note }, note) : null,
844
+ ),
845
+ )
846
+ }
847
+
848
+ // ── 插件 ──────────────────────────────────────────────────────────────────
849
+
850
+ /** 需要的浏览器服务:slots(注册卡片);settingsScope 在 apply 里等它出现。 */
851
+ const inject = ['slots']
852
+
853
+ // TODO(0.2.0): 平台把第三方插件的设置桥接上之后(web-all / DSH 修完 #677 的迁移),
854
+ // 可以砍掉多余候选 key 与卡片上那行“诊断”;`namespaces` / `detail()` 建议保留(排障用)。
855
+ // 详见 docs/dsh-0.2.0-migration.md 的 TODO 节。
856
+ /**
857
+ * 绑一个**可写的**设置域。导出(经 __test)以便离线测试。
858
+ *
859
+ * 两代 DSH 对“这个插件的设置”用的 key 不一样:
860
+ * 0.1.5-rc.3:settings 命名空间 —— `qq-bridge`(宿主半 register 出来的);
861
+ * 0.2.0 :profile **entry id** —— `include:qq-bridge`(由插件自己的 Config 投影而来)。
862
+ * 只绑前者的话,0.2.0 上永远拿到 `unavailable`,用户看到的就是“这个部署没有为 qq-bridge
863
+ * 提供可写的设置服务”(2026-10-01 桌面端真机)。所以**两个都绑,谁 ready 用谁**。
864
+ */
865
+ /** 0.2.0 的兜底数据源:宿主自己挂的本机接口(见宿主半的 handleSettingsRequest)。 */
866
+ const API_PATH = '/api/qq-bridge/settings'
867
+ /** NapCat 操作接口(宿主半不受 legacy 门控,两代宿主都挂)。 */
868
+ const NAPCAT_API = '/api/qq-bridge/napcat'
869
+ /** 检查更新。GET 只读当前版本,POST 才访问 GitHub。 */
870
+ const UPDATE_API = '/api/qq-bridge/update'
871
+ /** 稳定引用:useSyncExternalStore 的 getSnapshot 每次返回新对象会死循环。 */
872
+ const LOADING_SNAPSHOT = { status: 'loading' }
873
+ const UNAVAILABLE_SNAPSHOT = { status: 'unavailable' }
874
+
875
+ /**
876
+ * 走宿主本机接口的设置域(0.2.0 用)。
877
+ *
878
+ * 为什么需要它:0.2.0 的 `webUiSettings` 桥对第三方插件永远返回 `unavailable`
879
+ * (证据见 docs/dsh-0.2.0-migration.md)。范本:皮肤中心
880
+ * `@linxin666/dsh-client-ui-skin-center` 就是自己 `inject: ["webServer"]` 挂
881
+ * `/api/skin-center/...`,浏览器半直接 fetch 它,全程不碰宿主 settings 服务。
882
+ *
883
+ * 小 store:fetch 一次 → 缓存 snapshot → 通知订阅者;`set` 走 POST 再刷新。
884
+ * 快照形状与宿主 scope 对齐(`status` / `value` / `user` / `writable`),卡片不用改。
885
+ */
886
+ function createApiScope() {
887
+ const listeners = new Set()
888
+ let snapshot = { status: 'idle' }
889
+ let started = false
890
+ const publish = (next) => {
891
+ snapshot = next
892
+ for (const listener of [...listeners]) {
893
+ try {
894
+ listener()
895
+ } catch {
896
+ // 一个订阅者抛错不该影响别人
897
+ }
898
+ }
899
+ }
900
+ const accept = (body) => {
901
+ const overridden = Array.isArray(body?.overridden) ? body.overridden : []
902
+ publish({
903
+ status: 'ready',
904
+ value: body?.values && typeof body.values === 'object' ? body.values : {},
905
+ user: Object.fromEntries(overridden.map((key) => [key, true])),
906
+ writable: true,
907
+ })
908
+ }
909
+ const load = async () => {
910
+ if (typeof fetch !== 'function') {
911
+ publish({ status: 'unavailable', reason: 'no-fetch' })
912
+ return
913
+ }
914
+ publish({ status: 'loading' })
915
+ try {
916
+ const res = await fetch(API_PATH, { credentials: 'include', headers: { accept: 'application/json' } })
917
+ const body = await res.json().catch(() => ({}))
918
+ if (!res.ok || body?.ok !== true) throw new Error(body?.error || `HTTP ${res.status}`)
919
+ accept(body)
920
+ } catch (error) {
921
+ publish({ status: 'unavailable', reason: `api: ${error?.message ?? error}` })
922
+ }
923
+ }
924
+ return {
925
+ kind: 'api',
926
+ getSnapshot: () => snapshot,
927
+ start: () => {
928
+ if (started) return
929
+ started = true
930
+ load()
931
+ },
932
+ subscribe: (listener) => {
933
+ listeners.add(listener)
934
+ return () => listeners.delete(listener)
935
+ },
936
+ set: async (field, value) => {
937
+ const res = await fetch(API_PATH, {
938
+ method: 'POST',
939
+ credentials: 'include',
940
+ headers: { 'content-type': 'application/json' },
941
+ body: JSON.stringify({ set: { [field]: value } }),
942
+ })
943
+ const body = await res.json().catch(() => ({}))
944
+ if (!res.ok || body?.ok !== true) throw new Error(body?.error || `HTTP ${res.status}`)
945
+ accept(body)
946
+ },
947
+ /** 撤销一个字段的本机覆盖:接口本来就支持 `unset`,这里只是把它暴露给卡片。 */
948
+ unset: async (field) => {
949
+ if (typeof fetch !== 'function') throw new Error('qq-bridge: 这个环境没有 fetch')
950
+ const res = await fetch(API_PATH, {
951
+ method: 'POST',
952
+ credentials: 'include',
953
+ headers: { 'content-type': 'application/json' },
954
+ body: JSON.stringify({ unset: [field] }),
955
+ })
956
+ const body = await res.json().catch(() => ({}))
957
+ if (!res.ok || body?.ok !== true) throw new Error(body?.error || `HTTP ${res.status}`)
958
+ accept(body)
959
+ },
960
+ }
961
+ }
962
+
963
+ /**
964
+ * 绑一个**可写的**设置域。导出(经 __test)以便离线测试。
965
+ *
966
+ * 两代 DSH 对“这个插件的设置”用的 key 不一样:
967
+ * 0.1.5-rc.3:settings 命名空间 —— `qq-bridge`(宿主半 register 出来的);
968
+ * 0.2.0 :profile **entry id** —— `include:qq-bridge`(由插件自己的 Config 投影而来)。
969
+ * 只绑前者的话,0.2.0 上永远拿到 `unavailable`,用户看到的就是“这个部署没有为 qq-bridge
970
+ * 提供可写的设置服务”(2026-10-01 桌面端真机)。所以**两个都绑、谁 ready 用谁**;
971
+ * **全都不 ready** 时再退到宿主自己的本机接口(0.2.0 的兜底,见 createApiScope)。
972
+ */
973
+ function bindSettings(service) {
974
+ const unavailable = UNAVAILABLE_SNAPSHOT
975
+ const candidates = []
976
+ // 0.2.0 的表单 key 到底叫什么,源码里能看到的写法**全试一遍**(谁 ready 用谁):
977
+ // settings 命名空间 / profile entry id / 包名,以及带 include: 前缀的两种。
978
+ for (const namespace of [NS, `include:${NS}`, 'dsh-qq-bot', 'include:dsh-qq-bot', 'dsh-qq-bridge', 'include:dsh-qq-bridge']) {
979
+ try {
980
+ const scope = service.bind({ namespace })
981
+ if (scope && typeof scope === 'object') candidates.push({ namespace, scope })
982
+ } catch (error) {
983
+ // 某个候选绑不上不影响另一个。
984
+ console?.debug?.(`qq-bridge: bind(${namespace}) 失败:${error?.message ?? error}`)
985
+ }
986
+ }
987
+ const snapshotOf = (candidate) => {
988
+ try {
989
+ return candidate.scope.getSnapshot?.() ?? unavailable
990
+ } catch {
991
+ return unavailable
992
+ }
993
+ }
994
+ const readyScope = () => candidates.find((candidate) => snapshotOf(candidate).status === 'ready')
995
+ const anyLoading = () => candidates.some((candidate) => snapshotOf(candidate).status === 'loading')
996
+ const api = createApiScope()
997
+ return {
998
+ // 供排障/测试看:这次到底绑到了哪些 key
999
+ namespaces: candidates.map((candidate) => candidate.namespace),
1000
+ /** 这次数据从哪来(排障/测试用):'scope' | 'api' | 'none'。 */
1001
+ via: () => {
1002
+ if (readyScope()) return 'scope'
1003
+ return api.getSnapshot().status === 'ready' ? 'api' : 'none'
1004
+ },
1005
+ /**
1006
+ * 每个候选的当前状态。直接显示在卡片上 —— 0.2.0 的 key 叫什么,
1007
+ * 与其猜,不如让它自己报(2026-10-01 真机就是靠这个收敛的)。
1008
+ */
1009
+ detail: () => {
1010
+ const scopes =
1011
+ candidates.length === 0
1012
+ ? '一个候选都没绑上(webUiSettings.bind 全部抛错)'
1013
+ : candidates.map((candidate) => `${candidate.namespace}=${snapshotOf(candidate).status}`).join('、')
1014
+ const apiSnap = api.getSnapshot()
1015
+ return `${scopes};本机接口=${apiSnap.status}${apiSnap.reason ? `(${apiSnap.reason})` : ''}`
1016
+ },
1017
+ getSnapshot: () => {
1018
+ const candidate = readyScope()
1019
+ if (candidate) return snapshotOf(candidate)
1020
+ // scope 全不可用 → 退到宿主本机接口;第一次问就把它跑起来。
1021
+ if (!anyLoading()) api.start()
1022
+ const apiSnap = api.getSnapshot()
1023
+ if (apiSnap.status === 'ready') return apiSnap
1024
+ if (apiSnap.status === 'loading' || apiSnap.status === 'idle') return LOADING_SNAPSHOT
1025
+ return unavailable
1026
+ },
1027
+ subscribe: (listener) => {
1028
+ const offs = []
1029
+ for (const candidate of candidates) {
1030
+ try {
1031
+ const off = candidate.scope.subscribe?.(listener)
1032
+ if (typeof off === 'function') offs.push(off)
1033
+ } catch {
1034
+ // 订阅失败不影响其它候选
1035
+ }
1036
+ }
1037
+ offs.push(api.subscribe(listener))
1038
+ if (!readyScope() && !anyLoading()) api.start()
1039
+ return () => {
1040
+ for (const off of offs) off()
1041
+ }
1042
+ },
1043
+ set: (field, value) => {
1044
+ const candidate = readyScope()
1045
+ if (candidate && typeof candidate.scope.set === 'function') return candidate.scope.set(field, value)
1046
+ if (api.getSnapshot().status === 'ready') return api.set(field, value)
1047
+ throw new Error('qq-bridge: 没有可写的设置源')
1048
+ },
1049
+ /**
1050
+ * 撤销一个字段的本机覆盖。
1051
+ *
1052
+ * 优先用设置域自己的 `unset`(DSH 的 SettingsScope 有这个能力);
1053
+ * 设置域没有它时走本机接口的 `unset`(宿主半的 handleSettingsRequest 本来就支持)。
1054
+ */
1055
+ unset: (field) => {
1056
+ const candidate = readyScope()
1057
+ if (candidate && typeof candidate.scope.unset === 'function') return candidate.scope.unset(field)
1058
+ return api.unset(field)
1059
+ },
1060
+ }
1061
+ }
1062
+
1063
+ /** 插件详情页只在这一包上显示设置。旧包名和 Cordis id 也认。 */
1064
+ function subjectIsOurs(subject) {
1065
+ if (!subject || typeof subject !== 'object') return false
1066
+ const pkgName = subject.pkg && typeof subject.pkg === 'object' ? String(subject.pkg.name ?? '') : ''
1067
+ if (pkgName === 'dsh-qq-bot' || pkgName === 'dsh-qq-bridge') return true
1068
+ const id = String(subject.id ?? '')
1069
+ if (id === NS || id === 'dsh-qq-bot' || id === 'dsh-qq-bridge') return true
1070
+ if (subject.kind === 'item' && id.includes('qq-bridge')) return true
1071
+ const pkg = String(subject.package ?? subject.packageName ?? '')
1072
+ return pkg === 'dsh-qq-bot' || pkg === 'dsh-qq-bridge'
1073
+ }
1074
+
1075
+ /**
1076
+ * 不经过宿主设置服务,直接读写 `/api/qq-bridge/settings`。
1077
+ * DSH 0.2.x 上 `settingsScope` / `webUiSettings` 可能根本不存在;再等它们,卡片永远不会挂上。
1078
+ */
1079
+ function apiBoundScope() {
1080
+ const api = createApiScope()
1081
+ return {
1082
+ namespaces: [],
1083
+ via: () => (api.getSnapshot().status === 'ready' ? 'api' : 'none'),
1084
+ detail: () => {
1085
+ const apiSnap = api.getSnapshot()
1086
+ return `本机接口=${apiSnap.status}${apiSnap.reason ? `(${apiSnap.reason})` : ''}`
1087
+ },
1088
+ getSnapshot: () => {
1089
+ api.start()
1090
+ const apiSnap = api.getSnapshot()
1091
+ if (apiSnap.status === 'ready') return apiSnap
1092
+ if (apiSnap.status === 'loading' || apiSnap.status === 'idle') return LOADING_SNAPSHOT
1093
+ return UNAVAILABLE_SNAPSHOT
1094
+ },
1095
+ subscribe: (listener) => {
1096
+ const off = api.subscribe(listener)
1097
+ api.start()
1098
+ return off
1099
+ },
1100
+ set: (field, value) => api.set(field, value),
1101
+ unset: (field) => api.unset(field),
1102
+ }
1103
+ }
1104
+
1105
+ function apply(ctx) {
1106
+ // 卡片立刻挂上,不等 settingsScope / webUiSettings。
1107
+ // 这两个服务在部分 0.2.x 上不存在,ctx.inject 会一直等,回调永远不跑。
1108
+ // 设置数据:宿主设置域已经在就用它(0.1.5 的命名空间、以及还能 bind 的 webUiSettings);
1109
+ // 否则用本插件自己的设置接口。请求带 credentials: 'include',好把面板登录 cookie 带上。
1110
+ let registered = false
1111
+ let hostBound = false
1112
+ const listeners = new Set()
1113
+ let inner = null
1114
+ let innerOff = () => {}
1115
+ const notify = () => {
1116
+ for (const listener of [...listeners]) {
1117
+ try {
1118
+ listener()
1119
+ } catch {
1120
+ // 一个订阅者抛错不影响别人
1121
+ }
1122
+ }
1123
+ }
1124
+ const adopt = (next) => {
1125
+ try {
1126
+ innerOff()
1127
+ } catch {
1128
+ // 旧订阅卸不掉就盖掉
1129
+ }
1130
+ inner = next
1131
+ const off = inner.subscribe?.(notify)
1132
+ innerOff = typeof off === 'function' ? off : () => {}
1133
+ notify()
1134
+ }
1135
+ const scope = {
1136
+ via: () => (typeof inner?.via === 'function' ? inner.via() : 'none'),
1137
+ detail: () => (typeof inner?.detail === 'function' ? inner.detail() : ''),
1138
+ getSnapshot: () => inner.getSnapshot(),
1139
+ subscribe: (listener) => {
1140
+ listeners.add(listener)
1141
+ return () => listeners.delete(listener)
1142
+ },
1143
+ set: (field, value) => inner.set(field, value),
1144
+ unset: (field) => inner.unset(field),
1145
+ }
1146
+ const mount = (scoped) => {
1147
+ if (registered) return
1148
+ if (!scoped?.slots || typeof scoped.slots.inject !== 'function') return
1149
+ registered = true
1150
+ const card = () =>
1151
+ h(Card, { scope, sessionsOf: () => ctx.get('sessions'), workspacesOf: () => ctx.get('workspaces') })
1152
+ // slots.inject 只在那个 slot 存在时才跑回调,多注册一个是安全的。
1153
+ const registerCard = (slot, options, render) => {
1154
+ try {
1155
+ scoped.slots.inject(slot, () => scoped.slots.register({ ...options, name: slot }, render || card))
1156
+ } catch (error) {
1157
+ console?.debug?.(`qq-bridge: slot ${slot} unavailable (${error?.message ?? error})`)
1158
+ }
1159
+ }
1160
+ registerCard('settings.plugins.tab', { id: NS, order: 100, label: () => 'QQ 桥接' })
1161
+ registerCard('settings.plugin.item', { key: NS })
1162
+ registerCard('plugins.tab', { id: NS, order: 100, label: () => 'QQ 桥接' })
1163
+ registerCard(
1164
+ 'plugins.detail.section',
1165
+ { id: 'qq-bridge-settings', order: 50, label: () => 'QQ 桥接设置' },
1166
+ (props) => {
1167
+ const subject = props && props.subject
1168
+ if (subject && !subjectIsOurs(subject)) return null
1169
+ return card()
1170
+ },
1171
+ )
1172
+ }
1173
+ const preferHost = (service) => {
1174
+ if (hostBound) return
1175
+ if (!service || typeof service.bind !== 'function') return
1176
+ hostBound = true
1177
+ adopt(bindSettings(service))
1178
+ }
1179
+ const tryInject = (name, pick) => {
1180
+ if (typeof ctx.inject !== 'function') return
1181
+ try {
1182
+ ctx.inject([name], (scoped) => preferHost(pick(scoped)))
1183
+ } catch {
1184
+ // 没有这扇门:下面会改走设置接口。
1185
+ }
1186
+ }
1187
+ tryInject('webUiSettings', (scoped) => scoped.webUiSettings ?? scoped.get?.('settingsScope'))
1188
+ tryInject('settingsScope', (scoped) => scoped.settingsScope ?? scoped.get?.('webUiSettings'))
1189
+ if (!hostBound) adopt(apiBoundScope())
1190
+ mount(ctx)
1191
+ }
1192
+
1193
+ exports.apply = apply
1194
+ exports.inject = inject
1195
+ // 导出 name:Cordis 用它做插件标识,Slots inspect 里的 registrant 会显示成它
1196
+ // (不导的话那边显示的是产物里的 fallback 短名,排障时认不出是谁注册的卡片)。
1197
+ exports.name = 'dsh-qq-bot'
1198
+ // 纯函数给离线测试用(test-smoke.mjs 会在假 ctx 里真跑这个 bundle)。
1199
+ // Cordis 只认 apply / inject / name / Config,多余的导出它不管。
1200
+ exports.__test = { sessionOptions, draftFrom, parse, fields: FIELDS, bindSettings }
1201
+ return module.exports
1202
+ },
1203
+ })