@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/CHANGELOG.md +70 -0
- package/README.en.md +408 -572
- package/README.md +430 -570
- package/client/client.js +3983 -3657
- package/client/index.js +4244 -4809
- package/client/mobile-styles.js +802 -0
- package/client/unlock-manager.js +142 -0
- package/docs/fix-plan-202608.md +108 -0
- package/lib/auth/login-template.js +381 -381
- package/lib/auth/manager.js +97 -22
- package/lib/bridge-rpc.js +48 -104
- package/lib/cloudflared-manager.mjs +361 -345
- package/lib/compat.js +129 -0
- package/lib/feishu/index.js +225 -222
- package/lib/feishu/node.js +433 -409
- package/lib/index.js +271 -244
- package/lib/platform/base.js +147 -156
- package/lib/platform/commands.js +221 -0
- package/lib/platform/conversation-bridge.js +816 -1570
- package/lib/platform/dsh-storage.js +117 -0
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +191 -0
- package/lib/platform/session-catalog.js +372 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/index.js +312 -309
- package/lib/qq/node.js +532 -533
- package/lib/telegram/index.js +215 -212
- package/lib/telegram/node.js +348 -350
- package/lib/tunnel-client.mjs +39 -15
- package/lib/wechat/gateway.js +973 -960
- package/lib/wechat/index.js +244 -241
- package/lib/wechat/media.js +285 -281
- package/lib/wechat/node.js +352 -350
- package/package.json +6 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
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
|
|
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:
|
|
1414
|
-
const stored = await loadConfig();
|
|
1539
|
+
onPersist: (patch) => updateConfig((stored) => {
|
|
1415
1540
|
stored.auth = { ...(stored.auth ?? {}), ...patch };
|
|
1416
|
-
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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:
|
|
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
|
-
//
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
|
1678
|
+
await node._restoringConfig;
|
|
1657
1679
|
|
|
1658
|
-
if (cfg
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
}
|
|
1687
|
+
}).catch(() => {});
|
|
1688
|
+
}
|
|
1671
1689
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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();
|