@wenbin_wb/dsh-bridge 2.8.7 → 2.10.0

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/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { TelegramService } from './telegram/index.js';
26
26
  import { AuthManager } from './auth/manager.js';
27
27
  import { renderLoginPage } from './auth/login-template.js';
28
28
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
+ import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
29
30
 
30
31
  const name = 'dsh-bridge';
31
32
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -182,7 +183,8 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
182
183
  <link rel="manifest" href="/manifest.webmanifest">
183
184
  <link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
184
185
  <link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
185
- <script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>`;
186
+ <script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
187
+ ${BROWSER_ABORT_SIGNAL_POLYFILL}`;
186
188
  const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
187
189
 
188
190
  function isCompressed(headers) {
@@ -249,14 +251,15 @@ function loopbackHeaders(headers, targetPort) {
249
251
  * 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
250
252
  */
251
253
  class ProxyServer {
252
- constructor({ localPort, targetPort, authManager, logger }) {
254
+ constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
253
255
  this.localPort = localPort;
254
256
  this.targetPort = targetPort;
255
257
  this.authManager = authManager;
256
258
  this.logger = logger;
259
+ // 返回 loopback-token 端点允许跨域读取的 Origin 列表(本插件自身生成的面板地址)
260
+ this.allowedOrigins = allowedOrigins ?? (() => []);
257
261
  this.server = null;
258
262
  this.clientSockets = new Set();
259
- this.activeConnections = 0;
260
263
  }
261
264
 
262
265
  async start() {
@@ -281,11 +284,11 @@ class ProxyServer {
281
284
  if (pathname === '/__dsh_bridge__/login' && req.method === 'POST') {
282
285
  const chunks = [];
283
286
  req.on('data', (c) => chunks.push(c));
284
- req.on('end', () => {
287
+ req.on('end', async () => {
285
288
  try {
286
289
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
287
290
  const clientIp = req.socket?.remoteAddress || '';
288
- const verify = this.authManager?.verifyPassword(body.password, clientIp);
291
+ const verify = await this.authManager?.verifyPassword(body.password, clientIp);
289
292
  if (verify?.success) {
290
293
  const sessionToken = this.authManager.createSession();
291
294
  res.writeHead(200, {
@@ -324,8 +327,21 @@ class ProxyServer {
324
327
 
325
328
  // 3.1 本机特权 Token 签发:仅限真正物理回环连接(127.0.0.1 / ::1,严禁隧道转发流量伪造)
326
329
  if (pathname === '/__dsh_bridge__/loopback-token') {
330
+ // CORS 收敛(T2.10):不再使用 *。仅允许本插件自己生成的面板来源(回环/局域网 IP/隧道地址)
331
+ // 跨域读取响应,防止任意网页在 Firefox/Safari 下借访客浏览器回环领取 adminToken。
332
+ // 无 Origin 头的请求(curl 等非浏览器客户端)不受影响。
333
+ let corsOrigin;
334
+ {
335
+ const origin = req.headers?.origin;
336
+ if (origin) {
337
+ try {
338
+ const allowed = new Set(this.allowedOrigins());
339
+ if (allowed.has(origin)) corsOrigin = origin;
340
+ } catch { /* 来源计算失败则不放开跨域 */ }
341
+ }
342
+ }
327
343
  const corsHeaders = {
328
- 'Access-Control-Allow-Origin': '*',
344
+ ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin, Vary: 'Origin' } : { Vary: 'Origin' }),
329
345
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
330
346
  'Access-Control-Allow-Headers': 'Content-Type',
331
347
  };
@@ -355,7 +371,13 @@ class ProxyServer {
355
371
  }
356
372
 
357
373
  // 4. 核心鉴权拦截
358
- const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
374
+ // 豁免:authAdminUnlock(管理密码解锁)不要求访问会话——锁屏状态下访问会话可能已失效,
375
+ // 但用户应能凭管理密码解锁(否则访问会话失效后锁屏永远解不开,死锁)
376
+ const isAdminUnlockRpc = pathname === '/dsh-bridge/authAdminUnlock'
377
+ || pathname.endsWith('/dsh-bridge/authAdminUnlock');
378
+ const auth = isAdminUnlockRpc
379
+ ? { authenticated: true }
380
+ : (this.authManager?.verifyRequest(req) ?? { authenticated: true });
359
381
 
360
382
  // 4.1 若从 URL Token 认证通过:下发 Cookie 并 302 重定向到干净 URL (去掉 ?auth=)
361
383
  if (auth.fromToken) {
@@ -513,7 +535,10 @@ class ProxyServer {
513
535
  await new Promise((resolve) => this.server.close(() => resolve()));
514
536
  this.server = null;
515
537
  this.clientSockets.clear();
516
- this.activeConnections = 0;
538
+ }
539
+
540
+ get activeConnections() {
541
+ return this.clientSockets.size;
517
542
  }
518
543
 
519
544
  get port() {
@@ -544,6 +569,62 @@ class BridgeService {
544
569
 
545
570
  this.cloudflared = null;
546
571
  this.cloudflaredState = { phase: 'idle', detail: '' };
572
+
573
+ // DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
574
+ this._dshVersion = null;
575
+ this._dshVersionLoaded = false;
576
+ }
577
+
578
+ /**
579
+ * 探测 DSH 宿主版本(`dsh --version`,与升级功能同一套增强 PATH 的 spawn 模式)。
580
+ * 惰性执行 + 结果缓存;失败静默返回 null(不阻塞面板)。
581
+ * @returns {Promise<string|null>}
582
+ */
583
+ async getDshVersion() {
584
+ if (this._dshVersionLoaded) return this._dshVersion;
585
+ this._dshVersionLoaded = true; // 只尝试一次,失败也不重试(避免每次 getStatus 都 spawn)
586
+
587
+ const isWin = process.platform === 'win32';
588
+ const nodeDir = dirname(process.execPath);
589
+ const existingPath = process.env.PATH || process.env.Path || '';
590
+ const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin'];
591
+ const separator = isWin ? ';' : ':';
592
+ const augmentedEnv = {
593
+ ...process.env,
594
+ PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
595
+ };
596
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
597
+
598
+ try {
599
+ const version = await new Promise((resolve, reject) => {
600
+ let cp;
601
+ try {
602
+ // 命令为固定字面量(无用户输入),shell 拼接安全;带 args 的 shell:true 在 Node 24 有弃用警告
603
+ cp = spawn('dsh --version', {
604
+ windowsHide: true,
605
+ shell: true,
606
+ env: augmentedEnv,
607
+ timeout: 5000,
608
+ });
609
+ } catch (e) {
610
+ return reject(e);
611
+ }
612
+ let stdout = '';
613
+ let stderr = '';
614
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
615
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
616
+ cp.on('error', reject);
617
+ cp.on('close', (code) => {
618
+ if (code === 0 && stdout.trim()) resolve(stdout.trim());
619
+ else reject(new Error(stderr || stdout || `退出码 ${code}`));
620
+ });
621
+ });
622
+ this._dshVersion = version;
623
+ } catch (e) {
624
+ this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
625
+ this._dshVersion = null;
626
+ }
627
+ return this._dshVersion;
547
628
  }
548
629
 
549
630
  async setLanIp({ ip } = {}) {
@@ -562,6 +643,17 @@ class BridgeService {
562
643
  targetPort: this.dshPort,
563
644
  authManager: this.authManager,
564
645
  logger: this.logger,
646
+ // loopback-token 允许跨域的面板来源:回环、当前局域网 IP、隧道公网地址
647
+ allowedOrigins: () => {
648
+ const origins = [`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`];
649
+ try {
650
+ for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
651
+ if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
652
+ if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
653
+ if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
654
+ } catch { /* 单项来源解析失败不影响其余 */ }
655
+ return origins;
656
+ },
565
657
  });
566
658
 
567
659
  await this.proxy.start();
@@ -601,6 +693,7 @@ class BridgeService {
601
693
 
602
694
  return {
603
695
  version: VERSION,
696
+ dshVersion: await this.getDshVersion(),
604
697
 
605
698
  auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
606
699
 
@@ -646,20 +739,19 @@ class BridgeService {
646
739
  autoStart: Boolean(this.customTunnelConfig?.autoStart),
647
740
  },
648
741
 
649
- // 轻量摘要,供 UI Tab 状态点使用(完整状态由 wechatGetStatus 提供)
650
- wechat: this.wechat ? { status: this.wechat.gateway?.status ?? 'idle' } : null,
651
-
652
742
  // 宿主系统运行监控指标
653
743
  system: this.getSystemMetrics(),
654
744
  };
655
745
  }
656
746
 
657
- async saveCloudflaredConfig({ token, hostname }) {
658
- this.cloudflaredConfig = {
659
- ...(this.cloudflaredConfig ?? {}),
660
- token: token ? String(token).trim() : '',
661
- hostname: hostname ? String(hostname).trim() : '',
662
- };
747
+ async saveCloudflaredConfig({ token, hostname } = {}) {
748
+ const prev = this.cloudflaredConfig ?? {};
749
+ const next = { ...prev };
750
+ // undefined = 客户端未修改不上传;'******' = 非管理员视图的掩码回显。
751
+ // 两者都保留现值,防止真实 Token 被掩码覆盖;仅显式字符串(含空串=清除)才变更。
752
+ if (token !== undefined) next.token = token === '******' ? (prev.token ?? '') : String(token).trim();
753
+ if (hostname !== undefined) next.hostname = String(hostname).trim();
754
+ this.cloudflaredConfig = next;
663
755
  await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
664
756
  }
665
757
 
@@ -709,7 +801,15 @@ class BridgeService {
709
801
  logger: this.logger,
710
802
  });
711
803
 
712
- await this.customTunnel.connect();
804
+ try {
805
+ await this.customTunnel.connect();
806
+ } catch (err) {
807
+ // 启动失败不留僵尸:断开(阻止其后台重连计时器)并清空引用,用户可立即重试
808
+ this.customTunnel.disconnect();
809
+ this.customTunnel = null;
810
+ this.customTunnelState = { phase: 'error', detail: err.message };
811
+ throw err;
812
+ }
713
813
  }
714
814
 
715
815
  async stopCustomTunnel() {
@@ -811,9 +911,10 @@ class BridgeService {
811
911
  current: VERSION,
812
912
  latest: latestData?.version ?? null,
813
913
  releaseNotes: latestData?.releaseNotes ?? null,
914
+ dshVersion: await this.getDshVersion(),
814
915
  };
815
916
  } catch (e) {
816
- return { current: VERSION, latest: null, error: e.message ?? '检查失败' };
917
+ return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
817
918
  }
818
919
  }
819
920
 
@@ -1294,7 +1395,7 @@ class BridgeService {
1294
1395
  // 5. 自建隧道部署服务器连通性检测
1295
1396
  const customServerUrl = this.customTunnelConfig?.serverUrl?.trim();
1296
1397
  if (customServerUrl) {
1297
- const isRunning = Boolean(this.customTunnelClient?.running);
1398
+ const isRunning = Boolean(this.customTunnel?.connected);
1298
1399
  const ctStart = Date.now();
1299
1400
  try {
1300
1401
  const parsedUrl = new URL(customServerUrl);
@@ -1372,6 +1473,13 @@ function apply(ctx, config = {}) {
1372
1473
  const logger = ctx.logger(name);
1373
1474
  const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
1374
1475
 
1476
+ // 低版本 Node(<20.3)缺少 AbortSignal.any,DSH 核心链路(dsh-timeout ← dsh-llm)
1477
+ // 每次 agent 请求都会调用它——缺失时通过桥接发送消息直接报 "(internal)"。
1478
+ // 插件加载即安装兼容垫片,并在缺失时告警引导升级。
1479
+ if (installAbortSignalCompat()) {
1480
+ logger.warn('dsh-bridge: 当前 Node %s 缺少 AbortSignal.any/timeout,已安装兼容垫片。建议升级 Node 至 22.19+ 或 24+(见 package.json engines)。', process.version);
1481
+ }
1482
+
1375
1483
  if (!dshPort) {
1376
1484
  logger.error('webServer port unavailable');
1377
1485
  return;
@@ -1382,10 +1490,11 @@ function apply(ctx, config = {}) {
1382
1490
  const configFile = join(dshHome, 'dsh-bridge', 'config.json');
1383
1491
  const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
1384
1492
 
1385
- // 配置写入互斥锁队列,杜绝多平台并发写入造成文件覆盖损坏
1386
- let configWriteQueue = Promise.resolve();
1493
+ // 配置持久化互斥队列:读-改-写事务整体入队,杜绝多平台并发持久化时
1494
+ // "读到同一份旧配置 各自合并 → 后写覆盖先写"的丢失更新问题
1495
+ let configQueue = Promise.resolve();
1387
1496
 
1388
- // 从 JSON 文件读取持久化配置
1497
+ // 从 JSON 文件读取持久化配置(只读快照;启动恢复等场景使用)
1389
1498
  async function loadConfig() {
1390
1499
  try {
1391
1500
  const raw = await readFile(configFile, 'utf8');
@@ -1395,26 +1504,42 @@ function apply(ctx, config = {}) {
1395
1504
  }
1396
1505
  }
1397
1506
 
1398
- // 持久化配置到 JSON 文件(排队原子写入)
1507
+ async function writeConfig(data) {
1508
+ await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
1509
+ await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
1510
+ }
1511
+
1512
+ // 整对象写入(同样入队,避免与进行中的事务交错)
1399
1513
  async function saveConfig(data) {
1400
- configWriteQueue = configWriteQueue.then(async () => {
1401
- await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
1402
- await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
1403
- }).catch((err) => {
1514
+ const task = configQueue.then(() => writeConfig(data));
1515
+ configQueue = task.catch((err) => {
1404
1516
  logger.error('saveConfig failed: %s', err.message);
1405
1517
  });
1406
- return configWriteQueue;
1518
+ return task;
1519
+ }
1520
+
1521
+ // 读-改-写事务:mutate(current) 在队列内执行并返回新配置对象,与其他持久化调用严格串行
1522
+ async function updateConfig(mutate) {
1523
+ const task = configQueue.then(async () => {
1524
+ const current = await loadConfig();
1525
+ const next = (await mutate(current)) ?? current;
1526
+ await writeConfig(next);
1527
+ return next;
1528
+ });
1529
+ configQueue = task.catch((err) => {
1530
+ logger.error('updateConfig failed: %s', err.message);
1531
+ });
1532
+ return task;
1407
1533
  }
1408
1534
 
1409
1535
  // 访问安全认证管理器
1410
1536
  const authManager = new AuthManager({
1411
1537
  config: config.auth ?? {},
1412
1538
  logger,
1413
- onPersist: async (patch) => {
1414
- const stored = await loadConfig();
1539
+ onPersist: (patch) => updateConfig((stored) => {
1415
1540
  stored.auth = { ...(stored.auth ?? {}), ...patch };
1416
- await saveConfig(stored);
1417
- },
1541
+ return stored;
1542
+ }),
1418
1543
  });
1419
1544
 
1420
1545
  // 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
@@ -1430,9 +1555,10 @@ function apply(ctx, config = {}) {
1430
1555
  authManager.mode = 'token_and_password';
1431
1556
  authManager.sessions.clear();
1432
1557
  authManager.adminSessions.clear();
1433
- const stored = await loadConfig();
1434
- delete stored.auth;
1435
- await saveConfig(stored);
1558
+ await updateConfig((stored) => {
1559
+ delete stored.auth;
1560
+ return stored;
1561
+ });
1436
1562
  logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
1437
1563
  } catch {}
1438
1564
  }
@@ -1444,13 +1570,14 @@ function apply(ctx, config = {}) {
1444
1570
  if (stored.auth.mode) authManager.mode = stored.auth.mode;
1445
1571
  if (stored.auth.scope) authManager.scope = stored.auth.scope;
1446
1572
  if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
1573
+ if (stored.auth.adminProtection != null) authManager.adminProtection = stored.auth.adminProtection !== false;
1447
1574
  if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
1448
1575
  if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
1449
1576
  if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
1450
1577
  if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
1451
1578
  if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
1452
1579
  if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
1453
- logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
1580
+ logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s, adminProtection=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy, authManager.adminProtection);
1454
1581
  }
1455
1582
  }).catch(() => {});
1456
1583
 
@@ -1462,11 +1589,7 @@ function apply(ctx, config = {}) {
1462
1589
  cloudflaredConfig: config.cloudflared ?? null,
1463
1590
  lanConfig: config.lan ?? null,
1464
1591
  authManager,
1465
- onPersist: async (patch) => {
1466
- const stored = await loadConfig();
1467
- Object.assign(stored, patch);
1468
- await saveConfig(stored);
1469
- },
1592
+ onPersist: (patch) => updateConfig((stored) => Object.assign(stored, patch)),
1470
1593
  logger,
1471
1594
  });
1472
1595
 
@@ -1502,228 +1625,132 @@ function apply(ctx, config = {}) {
1502
1625
  // 平台管理器:注册/协调所有 IM 平台适配器
1503
1626
  const platformManager = new PlatformManager({ logger });
1504
1627
 
1505
- // 微信 Bot(ClawBot/iLink)—— 作为 Platform 子类注册进平台管理器
1506
- const wechat = new WechatService({
1507
- ctx,
1508
- logger,
1509
- config: config.wechat ?? {},
1510
- onPersist: async (patch) => {
1511
- const stored = await loadConfig();
1512
- stored.wechat = { ...(stored.wechat ?? {}), ...patch };
1513
- await saveConfig(stored);
1514
- },
1515
- });
1516
- platformManager.register(wechat);
1517
-
1518
- // QQ Bot(OpenAPI v2)—— 作为 Platform 子类注册进平台管理器
1519
- const qq = new QqService({
1520
- ctx,
1521
- logger,
1522
- config: config.qq ?? {},
1523
- onPersist: async (patch) => {
1524
- const stored = await loadConfig();
1525
- stored.qq = { ...(stored.qq ?? {}), ...patch };
1526
- await saveConfig(stored);
1527
- },
1528
- });
1529
- platformManager.register(qq);
1530
-
1531
- // 飞书 Bot(官方 OpenAPI / WebSocket 长连接)—— 作为 Platform 子类注册进平台管理器
1532
- const feishu = new FeishuService({
1533
- ctx,
1534
- logger,
1535
- config: config.feishu ?? {},
1536
- onPersist: async (patch) => {
1537
- const stored = await loadConfig();
1538
- stored.feishu = { ...(stored.feishu ?? {}), ...patch };
1539
- await saveConfig(stored);
1540
- },
1541
- });
1542
- platformManager.register(feishu);
1543
-
1544
- // Telegram Bot(官方 Long Polling + 代理支持)—— 作为 Platform 子类注册进平台管理器
1545
- const telegram = new TelegramService({
1546
- ctx,
1547
- logger,
1548
- config: config.telegram ?? {},
1549
- onPersist: async (patch) => {
1550
- const stored = await loadConfig();
1551
- stored.telegram = { ...(stored.telegram ?? {}), ...patch };
1552
- await saveConfig(stored);
1553
- },
1554
- });
1555
- platformManager.register(telegram);
1556
-
1557
- // 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
1558
- loadConfig().then(async (stored) => {
1559
- if (stored?.wechat) {
1560
- const cfg = stored.wechat;
1561
- wechat.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1562
- if (cfg.digestIntervalSec != null) wechat.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
1563
- if (cfg.approvalTimeoutSec != null) wechat.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
1628
+ // ---- 平台装配(T3.4:注册 + 统一持久化回调,替代四段逐平台复制的构造块)----
1629
+ // 新增平台只需在此表加一行;持久化、注册、恢复编排与销毁全部自动接入
1630
+ const platformCtors = [
1631
+ ['wechat', WechatService], // 微信 Bot(ClawBot/iLink)
1632
+ ['qq', QqService], // QQ Bot(OpenAPI v2)
1633
+ ['feishu', FeishuService], // 飞书 Bot(官方 OpenAPI / WebSocket 长连接)
1634
+ ['telegram', TelegramService], // Telegram Bot(Long Polling + 代理)
1635
+ ];
1636
+ const platforms = {};
1637
+ for (const [key, Ctor] of platformCtors) {
1638
+ const service = new Ctor({
1639
+ ctx,
1640
+ logger,
1641
+ config: config[key] ?? {},
1642
+ onPersist: (patch) => updateConfig((stored) => {
1643
+ stored[key] = { ...(stored[key] ?? {}), ...patch };
1644
+ return stored;
1645
+ }),
1646
+ });
1647
+ platformManager.register(service);
1648
+ platforms[key] = service;
1649
+ }
1650
+ const { wechat, qq, feishu, telegram } = platforms;
1651
+
1652
+ // ---- 平台配置恢复编排(统一工厂,替代四段逐平台复制的 loadConfig 恢复块)----
1653
+ // 顺序:白名单/数值字段 → 活动会话恢复(_restoringConfig 屏障)→ 凭证注入 → 网关自启
1654
+ function restorePlatform(service, { platformKey, numericFields = [], defaultMaxMessageChars = 2000, hasCredentials, applyCredentials }) {
1655
+ return loadConfig().then(async (stored) => {
1656
+ const cfg = stored?.[platformKey];
1657
+ if (!cfg) return;
1658
+ const node = service.node;
1659
+ node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1660
+ for (const field of numericFields) {
1661
+ if (cfg[field] != null) node.config[field] = Number(cfg[field]);
1662
+ }
1564
1663
  if (cfg.maxMessageChars != null) {
1565
1664
  const val = Number(cfg.maxMessageChars);
1566
- wechat.node.config.maxMessageChars = (val >= 200) ? val : 2000;
1567
- }
1568
- if (cfg.sendChunkDelayMs != null) wechat.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
1569
-
1570
- wechat.node._restoringConfig = (async () => {
1571
- try {
1572
- if (cfg.activeSessionId) {
1573
- wechat.node.activeSessionId = cfg.activeSessionId;
1574
- logger.info('dsh-bridge: restored wechat active session: %s', cfg.activeSessionId);
1575
- } else {
1576
- await wechat.node._pickDefaultSession().catch(() => {});
1577
- }
1578
- } finally {
1579
- wechat.node._configRestored = true;
1580
- }
1581
- })();
1582
-
1583
- await wechat.node._restoringConfig;
1584
-
1585
- if (cfg.token && cfg.accountId) {
1586
- wechat.gateway.setCredentials({
1587
- token: cfg.token,
1588
- accountId: cfg.accountId,
1589
- baseUrl: cfg.baseUrl,
1590
- });
1591
- logger.info('dsh-bridge: loaded saved wechat bot config, starting gateway');
1592
- await wechat.start().catch((err) => {
1593
- logger.error('dsh-bridge: wechat auto-start failed: %s', err?.message ?? err);
1594
- });
1595
- }
1596
- }
1597
- }).catch(() => {});
1598
-
1599
- // 启动时读取已保存的 QQ Bot 配置(凭证 + 白名单 + 活动会话)
1600
- loadConfig().then(async (stored) => {
1601
- if (stored?.qq) {
1602
- const cfg = stored.qq;
1603
- qq.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1604
-
1605
- qq.node._restoringConfig = (async () => {
1606
- try {
1607
- if (cfg.activeSessionId) {
1608
- qq.node.activeSessionId = cfg.activeSessionId;
1609
- logger.info('dsh-bridge: restored qq active session: %s', cfg.activeSessionId);
1610
- } else {
1611
- await qq.node._pickDefaultSession().catch(() => {});
1612
- }
1613
- } finally {
1614
- qq.node._configRestored = true;
1615
- }
1616
- })();
1617
-
1618
- await qq.node._restoringConfig;
1619
-
1620
- if (cfg.appId && cfg.clientSecret) {
1621
- qq.gateway.setCredentials({
1622
- appId: cfg.appId,
1623
- clientSecret: cfg.clientSecret,
1624
- accessToken: cfg.accessToken,
1625
- accessTokenExpiresAt: cfg.accessTokenExpiresAt,
1626
- gatewayUrl: cfg.gatewayUrl,
1627
- accountId: cfg.accountId,
1628
- });
1629
- logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
1630
- await qq.start().catch((err) => {
1631
- logger.error('dsh-bridge: qq auto-start failed: %s', err?.message ?? err);
1632
- });
1665
+ node.config.maxMessageChars = (val >= 200) ? val : defaultMaxMessageChars;
1633
1666
  }
1634
- }
1635
- }).catch(() => {});
1636
-
1637
- // 启动时读取已保存的飞书 Bot 配置(凭证 + 白名单 + 活动会话)
1638
- loadConfig().then(async (stored) => {
1639
- if (stored?.feishu) {
1640
- const cfg = stored.feishu;
1641
- feishu.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1667
+ if (cfg.groupAutoApprove != null) node.config.groupAutoApprove = cfg.groupAutoApprove === true;
1642
1668
 
1643
- feishu.node._restoringConfig = (async () => {
1644
- try {
1645
- if (cfg.activeSessionId) {
1646
- feishu.node.activeSessionId = cfg.activeSessionId;
1647
- logger.info('dsh-bridge: restored feishu active session: %s', cfg.activeSessionId);
1648
- } else {
1649
- await feishu.node._pickDefaultSession().catch(() => {});
1650
- }
1651
- } finally {
1652
- feishu.node._configRestored = true;
1669
+ node._restoringConfig = (async () => {
1670
+ if (cfg.activeSessionId) {
1671
+ node.activeSessionId = cfg.activeSessionId;
1672
+ logger.info('dsh-bridge: restored %s active session: %s', platformKey, cfg.activeSessionId);
1673
+ } else {
1674
+ await node._pickDefaultSession().catch(() => {});
1653
1675
  }
1654
1676
  })();
1655
1677
 
1656
- await feishu.node._restoringConfig;
1678
+ await node._restoringConfig;
1657
1679
 
1658
- if (cfg.appId && cfg.appSecret) {
1659
- feishu.gateway.updateConfig({
1660
- appId: cfg.appId,
1661
- appSecret: cfg.appSecret,
1662
- domain: cfg.domain || 'feishu',
1663
- });
1664
- logger.info('dsh-bridge: loaded saved feishu bot config, starting gateway');
1665
- await feishu.start().catch((err) => {
1666
- logger.error('dsh-bridge: feishu auto-start failed: %s', err?.message ?? err);
1680
+ if (hasCredentials(cfg)) {
1681
+ applyCredentials(cfg);
1682
+ logger.info('dsh-bridge: loaded saved %s bot config, starting gateway', platformKey);
1683
+ await service.start().catch((err) => {
1684
+ logger.error('dsh-bridge: %s auto-start failed: %s', platformKey, err?.message ?? err);
1667
1685
  });
1668
1686
  }
1669
- }
1670
- }).catch(() => {});
1687
+ }).catch(() => {});
1688
+ }
1671
1689
 
1672
- // 启动时读取已保存的 Telegram Bot 配置(凭证 + 代理 + 白名单 + 活动会话)
1673
- loadConfig().then(async (stored) => {
1674
- if (stored?.telegram) {
1675
- const cfg = stored.telegram;
1676
- telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1677
- if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
1678
- if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
1679
- if (cfg.maxMessageChars != null) {
1680
- const val = Number(cfg.maxMessageChars);
1681
- telegram.node.config.maxMessageChars = (val >= 200) ? val : 4096;
1682
- }
1683
- if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
1690
+ restorePlatform(wechat, {
1691
+ platformKey: 'wechat',
1692
+ numericFields: ['digestIntervalSec', 'approvalTimeoutSec', 'sendChunkDelayMs'],
1693
+ hasCredentials: (cfg) => Boolean(cfg.token && cfg.accountId),
1694
+ applyCredentials: (cfg) => wechat.gateway.setCredentials({
1695
+ token: cfg.token,
1696
+ accountId: cfg.accountId,
1697
+ baseUrl: cfg.baseUrl,
1698
+ }),
1699
+ });
1684
1700
 
1685
- telegram.node._restoringConfig = (async () => {
1686
- try {
1687
- if (cfg.activeSessionId) {
1688
- telegram.node.activeSessionId = cfg.activeSessionId;
1689
- logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
1690
- } else {
1691
- await telegram.node._pickDefaultSession().catch(() => {});
1692
- }
1693
- } finally {
1694
- telegram.node._configRestored = true;
1695
- }
1696
- })();
1701
+ restorePlatform(qq, {
1702
+ platformKey: 'qq',
1703
+ hasCredentials: (cfg) => Boolean(cfg.appId && cfg.clientSecret),
1704
+ applyCredentials: (cfg) => qq.gateway.setCredentials({
1705
+ appId: cfg.appId,
1706
+ clientSecret: cfg.clientSecret,
1707
+ accessToken: cfg.accessToken,
1708
+ accessTokenExpiresAt: cfg.accessTokenExpiresAt,
1709
+ gatewayUrl: cfg.gatewayUrl,
1710
+ accountId: cfg.accountId,
1711
+ }),
1712
+ });
1697
1713
 
1698
- await telegram.node._restoringConfig;
1714
+ restorePlatform(feishu, {
1715
+ platformKey: 'feishu',
1716
+ hasCredentials: (cfg) => Boolean(cfg.appId && cfg.appSecret),
1717
+ applyCredentials: (cfg) => feishu.gateway.updateConfig({
1718
+ appId: cfg.appId,
1719
+ appSecret: cfg.appSecret,
1720
+ domain: cfg.domain || 'feishu',
1721
+ }),
1722
+ });
1699
1723
 
1700
- if (cfg.botToken) {
1701
- telegram.gateway.setCredentials({
1702
- botToken: cfg.botToken,
1703
- proxy: cfg.proxy || '',
1704
- });
1705
- logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
1706
- await telegram.start().catch((err) => {
1707
- logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
1708
- });
1709
- }
1710
- }
1711
- }).catch(() => {});
1724
+ restorePlatform(telegram, {
1725
+ platformKey: 'telegram',
1726
+ numericFields: ['digestIntervalSec', 'approvalTimeoutSec', 'sendChunkDelayMs'],
1727
+ defaultMaxMessageChars: 4096,
1728
+ hasCredentials: (cfg) => Boolean(cfg.botToken),
1729
+ applyCredentials: (cfg) => telegram.gateway.setCredentials({
1730
+ botToken: cfg.botToken,
1731
+ proxy: cfg.proxy || '',
1732
+ }),
1733
+ });
1712
1734
 
1713
1735
  const disposeRpc = installBridgeRpc(ctx, {
1714
1736
  service,
1715
1737
  authManager,
1716
- wechat,
1717
1738
  qq,
1718
1739
  feishu,
1719
1740
  telegram,
1720
1741
  platformManager,
1721
1742
  logger,
1722
1743
  saveCustomTunnelConfig: async (serverUrl, accessToken) => {
1723
- const stored = await loadConfig();
1724
- stored.customTunnel = { serverUrl, accessToken };
1725
- await saveConfig(stored);
1726
- service.customTunnelConfig = { serverUrl, accessToken };
1744
+ const stored = await updateConfig((current) => {
1745
+ const prev = service.customTunnelConfig ?? {};
1746
+ const next = { ...prev };
1747
+ // saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
1748
+ if (serverUrl !== undefined) next.serverUrl = String(serverUrl).trim();
1749
+ if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
1750
+ current.customTunnel = next;
1751
+ return current;
1752
+ });
1753
+ service.customTunnelConfig = stored.customTunnel;
1727
1754
  },
1728
1755
  exportBackup: async () => {
1729
1756
  const stored = await loadConfig();
@@ -1746,6 +1773,7 @@ function apply(ctx, config = {}) {
1746
1773
  if (incoming.auth.mode) authManager.mode = incoming.auth.mode;
1747
1774
  if (incoming.auth.scope) authManager.scope = incoming.auth.scope;
1748
1775
  if (incoming.auth.adminPolicy) authManager.adminPolicy = incoming.auth.adminPolicy;
1776
+ if (incoming.auth.adminProtection != null) authManager.adminProtection = incoming.auth.adminProtection !== false;
1749
1777
  if (incoming.auth.passwordHash) authManager.passwordHash = incoming.auth.passwordHash;
1750
1778
  if (incoming.auth.passwordSalt) authManager.passwordSalt = incoming.auth.passwordSalt;
1751
1779
  if (incoming.auth.adminPasswordHash) authManager.adminPasswordHash = incoming.auth.adminPasswordHash;
@@ -1791,10 +1819,9 @@ function apply(ctx, config = {}) {
1791
1819
 
1792
1820
  ctx.effect(() => async () => {
1793
1821
  try { disposeRpc(); } catch {}
1794
- await wechat.destroy();
1795
- await qq.destroy();
1796
- await feishu.destroy();
1797
- await telegram.destroy();
1822
+ for (const service of Object.values(platforms)) {
1823
+ await service.destroy();
1824
+ }
1798
1825
  platformManager.dispose();
1799
1826
  authManager.dispose();
1800
1827
  await service.dispose();