@zmainer/dsh-wx-bridge 1.0.14 → 1.0.15

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/README.md CHANGED
@@ -78,6 +78,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
78
78
  | `dataDir` | 桥的状态/日志/工作目录 | `$DSH_HOME/wxbridge` |
79
79
  | `cwd` | 微信任务的默认工作目录 | 宿主启动目录 |
80
80
  | `vault` | Obsidian 知识库绝对路径(可选,供提示词模板占位符使用) | 空 |
81
+ | `allowlist` | 准入模式:`strict`(默认)/ `auto` / `open` —— 面板「准入模式」亦可切换 | `strict` |
81
82
  | `dshBin` | DSH 运行时入口(`<安装目录>/resources/dsh-runtime/lib/bin.js`)。**留空=五级自动探测**:显式参数/环境变量 → 宿主自证快照 → PATH/npm 全局 → 桌面安装目录扫描 → 运行中进程嗅探 | 自动 |
82
83
  | `intervalMs` / `staleMs` | 自检间隔 / 心跳判新阈值 | 300000 |
83
84
  | `prompt` | 手机任务的**固定前置提示词**(兜底路径用;留空 = 不加任何前缀) | 空 |
@@ -96,7 +97,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
96
97
 
97
98
  1. 宿主启动后,桥会在 `dataDir/auth-token.txt` 生成一次性登记 token(ACL 限本人)。
98
99
  2. 在手机微信里把该 token 发给机器人(例如 `<token> /help`)完成登记;之后该设备免 token。
99
- 3. 未登记的发送者会被静默丢弃并写审计日志。
100
+ 3. 未登记的发送者**不会被执行**:桥回一次提示(告诉对方怎么登记、或提示你去面板切 `auto`)并写审计日志。
100
101
  4. 面板「扫码配对」可直接出二维码(需本机 `qrcode` 可用;扫码会**重新绑定** ClawBot)。
101
102
 
102
103
  ## HTTP 接口(宿主半)
@@ -108,6 +109,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
108
109
  | GET | `/wxbridge/log` | 宿主半操作日志(最近 60 条) |
109
110
  | GET | `/wxbridge/presets` | **预设名册**(id/name/description)+ 当前选中值 |
110
111
  | POST | `/wxbridge/preset` | 写入预设选择(`{ "preset": "<id|空>" }`) |
112
+ | POST | `/wxbridge/allowlist` | 写入准入模式(`{ "mode": "strict|auto|open", "restart": true }`) |
111
113
  | POST | `/wxbridge/start` · `/stop` · `/restart` · `/tick` | 桥生命周期(`tick` = 立即体检并按需自愈) |
112
114
  | POST | `/wxbridge/config` | 写配置文件(`dataDir`/`cwd`/`vault`/`intervalMs`/`staleMs`/`prompt`) |
113
115
  | POST | `/wxbridge/task` · `/scan` · `/attach` | 宿主内执行 / 会话归组 |
@@ -116,10 +118,12 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
116
118
 
117
119
  - **权限预设(1.0.7 起为 `danger-full-access`)**:手机端**没有可应答审批的界面**,
118
120
  而 DSH 的审批在无应答者时 fail-closed → 任何需要审批的操作都会直接失败。
119
- 因此本插件把**手机通道**的权限预设放到最宽;**这等于把本机交给能驱动该机器人的人**,
120
- 请配合白名单/凭据保管使用。收紧办法:叠层里 `permission.defaultPreset` 改
121
+ 因此本插件把**手机通道**的权限预设放到最宽;**这等于把本机交给能驱动该机器人的人**——
122
+ 所以准入默认是 `strict`(只有登记过的设备能驱动)。收紧办法:叠层里 `permission.defaultPreset` 改
121
123
  `workspace-write` 或 `read-only`(后者只读)。
122
- - 发送者白名单(TOFU)+ 一次性登记 token;未登记静默丢弃。
124
+ - **准入默认 `strict`**(TOFU 白名单 + 一次性登记 token):未登记的发送者**不执行**,但只回一次可操作提示
125
+ (10 分钟/人节流;提示里给出登记方式),并写审计日志。面板「准入模式」可切 `auto`(首次发言即登记)或
126
+ `open`(不检查,不建议)——`auto` 的边界是「谁拿到了这个机器人」,机器人被拉进群/被加好友会连带放权。
123
127
  - 子进程使用**专用 `DSH_HOME`**(`dataDir/dsh-home`),权限 `workspace-write`,**只拿模型密钥、不含微信 token**。
124
128
  - 状态文件原子写 + SHA256 校验,校验失败即隔离为 `.tampered-*`。
125
129
  - 输出审计:密钥形态脱敏、外发命令阻断、超大输出截断。
@@ -160,8 +164,50 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
160
164
 
161
165
  其余任意文本 → 交给 DSH 执行。
162
166
 
167
+ ## 常见问题 / Troubleshooting
168
+
169
+ ### 插件管理里点「更新」报 `Cannot switch a non-link plugin directory`
170
+
171
+ ```text
172
+ 更新失败: dsh-wx-bridge — 更新失败,且更新前的构建未能验证恢复
173
+ (Cannot switch a non-link plugin directory: <...>\profiles\web
174
+ ode_modules\@zmainer\dsh-wx-bridge)
175
+ ```
176
+
177
+ **含义**:桌面端的插件更新是**事务式**的——它把 `<profile>/node_modules/<包名>` 当作一个**链接**,
178
+ 更新时把这个链接从旧版本"切"到新版本;而该位置现在是**普通目录**(文件是被复制进去的,不是链接),
179
+ 无法切换 ⇒ 更新失败并回滚,回滚时又验证不了"更新前的构建已恢复",于是提示检查该 profile 再重启。
180
+
181
+ **原因**:该 profile 里的本插件**不是通过市场/CLI 的链接式安装装上去的**(手工复制、旧版本管理器、
182
+ 或中途失败留下的目录都会这样)。"更新"走链接式管理,两边形态不一致就会撞上这个错。
183
+
184
+ **修复(不影响任何配置与数据)**:
185
+
186
+ 1. **关闭 DSH 桌面端**(避免文件占用);
187
+ 2. 删除或改名这个目录(注意真实路径里有 `\@zmainer\`,报错信息里可能少个反斜杠):
188
+ `<harness home>\profiles\<profile>
189
+ ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳妥;
190
+ 3. **重启桌面端**,在插件管理里**重新安装**(不是"更新");装好后可删掉 `.bak`;
191
+ 4. 想用命令行装:`dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge@latest`(pnpm 会建立链接式安装),
192
+ 然后重启宿主;
193
+ 5. 配置与数据**不在 profile 里**,所以重装/换安装方式都不会丢:
194
+ 配置在 `<DSH_HOME>/wxbridge/config.json`、数据在配置里的 `dataDir`;插件的 id、路由前缀、
195
+ 配置目录名都没变过。
196
+
197
+ **避免复发**:在一个 profile 上**只用一个安装通道**(要么一直用市场,要么一直用 CLI),
198
+ 不要手工复制文件进 `node_modules`,也不要在两个通道之间来回切。
199
+
163
200
  ## 最近变更
164
201
 
202
+ - **1.0.15**:**准入默认改回 `strict`(与 README 一致)**,并把选择权交给用户——
203
+ 起因是 awesome-dsh-plugin 的收录评审指出的一个真实矛盾:代码默认 `ALLOWLIST=auto`(**任何能给机器人发消息的人
204
+ 首次发言即自动登记**)叠加手机通道的 `danger-full-access`(**不经审批**),等价于"任何能给它发消息的人
205
+ 都能免审批完全控制这台机器";而 README「安全基线」写的是"未登记静默丢弃"(那是 `strict` 的行为)。
206
+ 现在:① 默认 `strict`(`--allowlist` / `WXBRIDGE_ALLOWLIST` / `config.json` 的 `allowlist` 均可覆盖);
207
+ ② 未登记**不执行但不再静默**——回一次可操作提示(每人 10 分钟一次),告诉对方怎么登记;
208
+ ③ 面板新增「**准入模式**」开关(`strict`/`auto`/`open`,一键写入配置并重启桥),换号/多设备的用户
209
+ 自己决定放开;④ README 的「安全基线」「配对」与配置表同步对齐。
210
+ 权限预设(`danger-full-access`)与准入是两件事:前者是本通道"无法应答审批"的取舍,后者决定**谁能**驱动它。
165
211
  - **1.0.14**:**文件/图片消息:先收下 → 反向提问 → 第二句话触发**(用户设计拍板)——
166
212
  微信的文件消息带不了附言,所以插件把文件**取件**(CDN 下载 + AES-128-ECB 解密 + **按原名**落盘)后
167
213
  先反问"你想让我做什么",等你下一句话再连同附件清单交给会话。
package/lib/client.js CHANGED
@@ -121,6 +121,7 @@ window.__ModuleLoader__.load({
121
121
  const skin = controlSkin()
122
122
  const [presets, setPresets] = R.useState([])
123
123
  const [presetSel, setPresetSel] = R.useState('')
124
+ const [allowlist, setAllowlist] = R.useState('strict')
124
125
  const [presetState, setPresetState] = R.useState(null)
125
126
  useWxStyle()
126
127
 
@@ -133,6 +134,7 @@ window.__ModuleLoader__.load({
133
134
  fetch('/wxbridge/log', { cache: 'no-store' }),
134
135
  ])
135
136
  const d = await sr.json()
137
+ if (d && typeof d.allowlist === 'string') setAllowlist(d.allowlist)
136
138
  const lg = await lr.json()
137
139
  if (!alive) return
138
140
  setJ(d); setErr(''); setLog(lg.actions || [])
@@ -159,6 +161,21 @@ window.__ModuleLoader__.load({
159
161
 
160
162
  R.useEffect(() => { let alive = true; loadPresets().then(() => {}); return () => { alive = false } }, [])
161
163
 
164
+ const saveAllowlist = async (mode) => {
165
+ setBusy('allowlist')
166
+ try {
167
+ const r = await fetch('/wxbridge/allowlist', {
168
+ method: 'POST', headers: { 'content-type': 'application/json' },
169
+ body: JSON.stringify({ mode, restart: true }),
170
+ })
171
+ const d = await r.json()
172
+ if (typeof d.allowlist === 'string') setAllowlist(d.allowlist)
173
+ setNote(d.ok
174
+ ? ('准入模式已切到 ' + mode + ':' + (d.note || ''))
175
+ : ('切换失败:' + (d.error || '未知错误')))
176
+ } catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
177
+ }
178
+
162
179
  const savePreset = async (want) => {
163
180
  setBusy('preset')
164
181
  try {
@@ -410,6 +427,30 @@ window.__ModuleLoader__.load({
410
427
  )
411
428
  ),
412
429
 
430
+ R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
431
+ R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
432
+ wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
433
+ R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '准入模式'),
434
+ R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(决定谁能驱动这台机器)')),
435
+ R.createElement('select', {
436
+ value: allowlist, onChange: (e) => setAllowlist(e.target.value),
437
+ style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
438
+ }, [
439
+ R.createElement('option', { key: 'strict', value: 'strict', style: skin.option }, 'strict(默认)· 只服务已登记的设备;未登记不执行,只回一次提示'),
440
+ R.createElement('option', { key: 'auto', value: 'auto', style: skin.option }, 'auto · 任何能给机器人发消息的人首次发言即登记(机器人别被拉进群/加好友)'),
441
+ R.createElement('option', { key: 'open', value: 'open', style: skin.option }, 'open · 不做任何检查(强烈不建议)'),
442
+ ]),
443
+ R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
444
+ R.createElement('button', {
445
+ className: 'wxbridge-btn', onClick: () => saveAllowlist(allowlist), disabled: !!busy,
446
+ style: {
447
+ marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
448
+ cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
449
+ },
450
+ }, busy === 'allowlist' ? '处理中…' : '保存并重启桥'),
451
+ R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
452
+ ),
453
+
413
454
  note ? R.createElement('pre', { style: { margin: '10px 0 0 0', padding: '9px 11px', borderRadius: '10px', background: mutedBg, border: subtle, fontSize: '11px', overflow: 'auto', maxHeight: '160px' } }, note) : null,
414
455
 
415
456
  // ── 操作日志 ───────────────────────────────────────────────────
package/lib/index.js CHANGED
@@ -313,6 +313,7 @@ export function apply(ctx, config) {
313
313
  peers, tasks, allowedUsers: allowed, stateOk, manualStop,
314
314
  dataDir: st.dataDir, cwd: st.cwd, intervalMs: st.intervalMs, staleMs: st.staleMs,
315
315
  runtimeBin: rtBin, runtimeBinOk: !!rtBin && existsSync(rtBin), runtimeSource: runtimeInfo.source,
316
+ allowlist: String((() => { try { return readConfigFile().allowlist || 'strict' } catch { return 'strict' } })()),
316
317
  }
317
318
  }
318
319
 
@@ -693,12 +694,32 @@ export function apply(ctx, config) {
693
694
  const body = await readBody(req)
694
695
  const f = configPath()
695
696
  const keep = readJsonFile(f)
696
- for (const k of ['dataDir', 'cwd', 'vault', 'dshBin', 'intervalMs', 'staleMs']) if (body && body[k]) keep[k] = body[k]
697
+ for (const k of ['dataDir', 'cwd', 'vault', 'dshBin', 'intervalMs', 'staleMs', 'allowlist']) if (body && body[k]) keep[k] = body[k]
697
698
  if (body && typeof body.prompt === 'string') keep.prompt = body.prompt
698
699
  mkdirSync(dirname(f), { recursive: true })
699
700
  writeFileSync(f, JSON.stringify(keep, null, 2))
700
701
  return send(res, { ok: true, written: f, applied: keep, note: '提示词下次任务即生效;其余项重启宿主后生效' })
701
702
  }
703
+ if (p === '/allowlist' && req.method === 'POST') {
704
+ // 准入模式:strict(默认,只认已登记)/ auto(首次发言即登记)/ open(不检查)
705
+ const body = await readBody(req)
706
+ const mode = String((body && body.mode) || '').toLowerCase()
707
+ if (!['strict', 'auto', 'open'].includes(mode)) {
708
+ return send(res, { ok: false, error: 'mode must be strict | auto | open' }, 400)
709
+ }
710
+ try {
711
+ const f = configPath()
712
+ const keep = readJsonFile(f)
713
+ keep.allowlist = mode
714
+ mkdirSync(dirname(f), { recursive: true })
715
+ writeFileSync(f, JSON.stringify(keep, null, 2))
716
+ pushAction({ action: 'allowlist', ok: true, detail: mode })
717
+ let restarted = 0
718
+ if (body && body.restart && keeper) { try { restarted = keeper.startBridge('allowlist-change') || 0 } catch (e) { ctx.logger?.warn?.('wxbridge: 重启失败 ' + String(e?.message ?? e)) } }
719
+ return send(res, { ok: true, allowlist: mode, configFile: f, restarted,
720
+ note: restarted ? '已写入配置并重启桥,立即生效' : '已写入配置;重启桥后生效(面板「重启」或等下次宿主启动)' })
721
+ } catch (e) { return send(res, { ok: false, error: String(e?.message ?? e) }, 500) }
722
+ }
702
723
  if (p === '/prompt') {
703
724
  if (req.method === 'POST') {
704
725
  const body = await readBody(req)
@@ -190,15 +190,6 @@ const HEARTBEAT_MS = 30000
190
190
  const TASK_TIMEOUT_MS = Number(process.env.BRIDGE_TASK_TIMEOUT_MS || 300000)
191
191
  const MAX_CONCURRENT_TASKS = Number(process.env.BRIDGE_MAX_TASKS || 2)
192
192
  const DEDUP_WINDOW_MS = 24 * 3600 * 1000
193
- /**
194
- * 授权策略:
195
- * auto (默认)—— 任何给本 bot 发消息的联系人自动登记 + 记审计日志。
196
- * 准入边界前移到「扫码拿到 bot 控制权」那一步:能扫码 = 本来就有权。
197
- * 换号/多台手机都无需干预(2026-09-21 用户反馈后定为默认)。
198
- * strict —— 只认白名单;未登记的一律丢弃(旧行为)。
199
- * open —— 不做任何检查(不建议:任何能找到该 bot 的号都能驱动本机)。
200
- */
201
- const ALLOWLIST = (argOf('--allowlist') || process.env.WXBRIDGE_ALLOWLIST || 'auto').toLowerCase()
202
193
  /**
203
194
  * 执行模式:
204
195
  * host (默认,若宿主可达)—— 任务交给宿主进程执行:原生会话 + 原生上下文,
@@ -218,6 +209,18 @@ function loadBridgeConfig() {
218
209
  }
219
210
  const BRIDGE_CONFIG = loadBridgeConfig()
220
211
  const ACP_CFG = (BRIDGE_CONFIG && typeof BRIDGE_CONFIG.acp === 'object') ? BRIDGE_CONFIG.acp : {}
212
+ /**
213
+ * 授权策略(2026-09-23 起默认 `strict`,与 README 的「安全基线」一致):
214
+ * strict (默认)—— 只认已登记的发送者;**未登记不执行**,但只回一次可操作提示
215
+ * (10 分钟/人节流),不静默——否则"换号/换手机"的用户只会看到"发了没反应"。
216
+ * auto —— 任何能给本 bot 发消息的联系人首次发言即自动登记。
217
+ * 准入边界前移到「谁拿到了这个机器人」:**机器人被拉进群/被加好友时会连带放权**,
218
+ * 所以它不再是默认,只在你确认机器人只有自己用、或想省掉换号登记时,在面板里显式切过去。
219
+ * open —— 不做任何检查(不建议:任何能找到该 bot 的号都能驱动本机)。
220
+ * 可用参数/环境变量/配置覆盖:--allowlist / WXBRIDGE_ALLOWLIST / config.json 的 allowlist。
221
+ */
222
+ const ALLOWLIST = (argOf('--allowlist') || process.env.WXBRIDGE_ALLOWLIST || BRIDGE_CONFIG.allowlist || 'strict').toLowerCase()
223
+
221
224
  /**
222
225
  * ACP 路径(首选执行方式):桥起一个常驻 `dsh --profile acp`,用 Agent Client Protocol
223
226
  * 驱动 **DSH 原生会话** —— 上下文由 DSH 持久化持有(可 resume),桥不再把历史拼进提示词。
@@ -659,6 +662,8 @@ function attachmentBlock(atts) {
659
662
  }
660
663
 
661
664
  const NOTEXT_NOTICE_AT = new Map()
665
+ /** 未登记提示的节流(每人 10 分钟一次)。 */
666
+ const UNREG_NOTICE_AT = new Map()
662
667
  const NOTEXT_NOTICE = '这条消息里没有我能处理的内容(表情、链接卡片、位置这类条目暂时不支持)。'
663
668
  + NL + '发文字、或者直接发图片 / 文件(Excel、Word、PDF 都行)都可以。'
664
669
  /** 该不该给他回「只认文字」:同一个人 60 秒内只回一次。返回要发的文本,'' = 这次不回。 */
@@ -1371,8 +1376,18 @@ async function handleMessage(token, msg) {
1371
1376
  isAllowed = true
1372
1377
  try { await reply(token, from, '✅ 已登记本机授权(多台手机/换号都无需再设置)。发 /help 看指令。', ctx) } catch {}
1373
1378
  } else {
1374
- // strict:记录完整 from(本地日志,仅本人可读)
1379
+ // strict(默认):未登记**不执行**,但别静默——回一次可操作的提示(每人 10 分钟一次)。
1380
+ // 静默丢弃对"换手机/换号"的本人来说等于"坏了",而提示对陌生人也无害。
1375
1381
  log('rejected-unauthenticated', { from, fromShort: from.slice(0, 12) })
1382
+ if (Date.now() - (UNREG_NOTICE_AT.get(from) || 0) > 600000) {
1383
+ UNREG_NOTICE_AT.set(from, Date.now())
1384
+ try {
1385
+ await reply(token, from, '未登记:这条通道默认只服务已登记的设备,所以刚才那条我没有执行。' + NL
1386
+ + '登记方式:把设置页「微信连接」里显示的**登记 token** 发我一次(形如 `abc123… /help`)。' + NL
1387
+ + '(也可以在面板里把「准入模式」切成 auto —— 那样任何能给机器人发消息的人首次发言即登记,'
1388
+ + '请先确认这个机器人没有被拉进群、也没被别人加好友。)', ctx)
1389
+ } catch (e) { log('send-failed', { error: String(e?.message ?? e) }) }
1390
+ }
1376
1391
  return
1377
1392
  }
1378
1393
  }
@@ -1726,6 +1741,8 @@ async function main() {
1726
1741
  voice: { text: voiceTranscript(voiceMsg), types: inboundItemTypes(voiceMsg) },
1727
1742
  cooldown: { at0: !!first, at30s: !!second, at61s: !!third },
1728
1743
  noticeText: NOTEXT_NOTICE,
1744
+ allowlist: ALLOWLIST,
1745
+ allowlistSource: argOf('--allowlist') ? 'arg' : (process.env.WXBRIDGE_ALLOWLIST ? 'env' : (BRIDGE_CONFIG.allowlist ? 'config' : 'default')),
1729
1746
  }, null, 1))
1730
1747
  process.exit(0)
1731
1748
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmainer/dsh-wx-bridge",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "dsh": {
package/src/client.js CHANGED
@@ -115,6 +115,7 @@ function Panel() {
115
115
  const skin = controlSkin()
116
116
  const [presets, setPresets] = R.useState([])
117
117
  const [presetSel, setPresetSel] = R.useState('')
118
+ const [allowlist, setAllowlist] = R.useState('strict')
118
119
  const [presetState, setPresetState] = R.useState(null)
119
120
  useWxStyle()
120
121
 
@@ -127,6 +128,7 @@ function Panel() {
127
128
  fetch('/wxbridge/log', { cache: 'no-store' }),
128
129
  ])
129
130
  const d = await sr.json()
131
+ if (d && typeof d.allowlist === 'string') setAllowlist(d.allowlist)
130
132
  const lg = await lr.json()
131
133
  if (!alive) return
132
134
  setJ(d); setErr(''); setLog(lg.actions || [])
@@ -153,6 +155,21 @@ function Panel() {
153
155
 
154
156
  R.useEffect(() => { let alive = true; loadPresets().then(() => {}); return () => { alive = false } }, [])
155
157
 
158
+ const saveAllowlist = async (mode) => {
159
+ setBusy('allowlist')
160
+ try {
161
+ const r = await fetch('/wxbridge/allowlist', {
162
+ method: 'POST', headers: { 'content-type': 'application/json' },
163
+ body: JSON.stringify({ mode, restart: true }),
164
+ })
165
+ const d = await r.json()
166
+ if (typeof d.allowlist === 'string') setAllowlist(d.allowlist)
167
+ setNote(d.ok
168
+ ? ('准入模式已切到 ' + mode + ':' + (d.note || ''))
169
+ : ('切换失败:' + (d.error || '未知错误')))
170
+ } catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
171
+ }
172
+
156
173
  const savePreset = async (want) => {
157
174
  setBusy('preset')
158
175
  try {
@@ -404,6 +421,30 @@ function Panel() {
404
421
  )
405
422
  ),
406
423
 
424
+ R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
425
+ R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
426
+ wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
427
+ R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '准入模式'),
428
+ R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(决定谁能驱动这台机器)')),
429
+ R.createElement('select', {
430
+ value: allowlist, onChange: (e) => setAllowlist(e.target.value),
431
+ style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
432
+ }, [
433
+ R.createElement('option', { key: 'strict', value: 'strict', style: skin.option }, 'strict(默认)· 只服务已登记的设备;未登记不执行,只回一次提示'),
434
+ R.createElement('option', { key: 'auto', value: 'auto', style: skin.option }, 'auto · 任何能给机器人发消息的人首次发言即登记(机器人别被拉进群/加好友)'),
435
+ R.createElement('option', { key: 'open', value: 'open', style: skin.option }, 'open · 不做任何检查(强烈不建议)'),
436
+ ]),
437
+ R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
438
+ R.createElement('button', {
439
+ className: 'wxbridge-btn', onClick: () => saveAllowlist(allowlist), disabled: !!busy,
440
+ style: {
441
+ marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
442
+ cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
443
+ },
444
+ }, busy === 'allowlist' ? '处理中…' : '保存并重启桥'),
445
+ R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
446
+ ),
447
+
407
448
  note ? R.createElement('pre', { style: { margin: '10px 0 0 0', padding: '9px 11px', borderRadius: '10px', background: mutedBg, border: subtle, fontSize: '11px', overflow: 'auto', maxHeight: '160px' } }, note) : null,
408
449
 
409
450
  // ── 操作日志 ───────────────────────────────────────────────────