@wenbin_wb/dsh-bridge 2.4.0 → 2.5.1

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
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
15
- import { exec } from 'node:child_process';
15
+ import { spawn } from 'node:child_process';
16
16
  import QRCode from 'qrcode';
17
17
  import { installBridgeRpc } from './bridge-rpc.js';
18
18
  import { CustomTunnelClient } from './tunnel-client.mjs';
@@ -22,6 +22,8 @@ import { WechatService } from './wechat/index.js';
22
22
  import { QqService } from './qq/index.js';
23
23
  import { FeishuService } from './feishu/index.js';
24
24
  import { TelegramService } from './telegram/index.js';
25
+ import { AuthManager } from './auth/manager.js';
26
+ import { renderLoginPage } from './auth/login-template.js';
25
27
 
26
28
  const name = 'dsh-bridge';
27
29
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -127,14 +129,15 @@ function loopbackHeaders(headers, targetPort) {
127
129
  }
128
130
 
129
131
  /**
130
- * HTTP + WebSocket 代理服务器
132
+ * HTTP + WebSocket 代理服务器(带安全认证守门)
131
133
  * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
132
- * 否则手机通过局域网访问时 DSH 会把它当未登录的外部请求处理
134
+ * 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
133
135
  */
134
136
  class ProxyServer {
135
- constructor({ localPort, targetPort, logger }) {
137
+ constructor({ localPort, targetPort, authManager, logger }) {
136
138
  this.localPort = localPort;
137
139
  this.targetPort = targetPort;
140
+ this.authManager = authManager;
138
141
  this.logger = logger;
139
142
  this.server = null;
140
143
  this.clientSockets = new Set();
@@ -145,6 +148,101 @@ class ProxyServer {
145
148
  if (this.server) return;
146
149
 
147
150
  this.server = createServer((req, res) => {
151
+ // 1. 处理登录 API: POST /__dsh_bridge__/login
152
+ if (req.url === '/__dsh_bridge__/login' && req.method === 'POST') {
153
+ const chunks = [];
154
+ req.on('data', (c) => chunks.push(c));
155
+ req.on('end', () => {
156
+ try {
157
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
158
+ const clientIp = req.socket?.remoteAddress || '';
159
+ const verify = this.authManager?.verifyPassword(body.password, clientIp);
160
+ if (verify?.success) {
161
+ const sessionToken = this.authManager.createSession();
162
+ res.writeHead(200, {
163
+ 'Content-Type': 'application/json; charset=utf-8',
164
+ 'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
165
+ });
166
+ res.end(JSON.stringify({ ok: true }));
167
+ } else {
168
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
169
+ res.end(JSON.stringify({ ok: false, error: verify?.error || '访问密码错误' }));
170
+ }
171
+ } catch (e) {
172
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
173
+ res.end(JSON.stringify({ ok: false, error: '无效请求' }));
174
+ }
175
+ });
176
+ return;
177
+ }
178
+
179
+ // 2. 处理登出 API: POST /__dsh_bridge__/logout
180
+ if (req.url === '/__dsh_bridge__/logout' && req.method === 'POST') {
181
+ res.writeHead(200, {
182
+ 'Content-Type': 'application/json; charset=utf-8',
183
+ 'Set-Cookie': 'dsh_bridge_auth=; Path=/; HttpOnly; Max-Age=0',
184
+ });
185
+ res.end(JSON.stringify({ ok: true }));
186
+ return;
187
+ }
188
+
189
+ // 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status (严格脱敏,不暴露 secretToken)
190
+ if (req.url === '/__dsh_bridge__/auth-status' && req.method === 'GET') {
191
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
192
+ res.end(JSON.stringify(this.authManager?.getPublicStatus() ?? { enabled: false }));
193
+ return;
194
+ }
195
+
196
+ // 4. 核心鉴权拦截
197
+ const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
198
+
199
+ // 4.1 若从 URL Token 认证通过:下发 Cookie 并 302 重定向到干净 URL (去掉 ?auth=)
200
+ if (auth.fromToken) {
201
+ const sessionToken = this.authManager.createSession();
202
+ try {
203
+ const urlObj = new URL(req.url, 'http://localhost');
204
+ urlObj.searchParams.delete('auth');
205
+ urlObj.searchParams.delete('token');
206
+ const cleanPath = (urlObj.pathname || '/') + (urlObj.search || '');
207
+ res.writeHead(302, {
208
+ 'Location': cleanPath,
209
+ 'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
210
+ });
211
+ res.end();
212
+ return;
213
+ } catch {
214
+ res.writeHead(302, {
215
+ 'Location': '/',
216
+ 'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
217
+ });
218
+ res.end();
219
+ return;
220
+ }
221
+ }
222
+
223
+ // 4.2 若未通过认证:根据请求类型渲染 DSH 登录页或返回 401 JSON
224
+ if (!auth.authenticated) {
225
+ const accept = String(req.headers['accept'] || '');
226
+ const isHtml = accept.includes('text/html') || (!req.url.startsWith('/api/') && !req.url.includes('.'));
227
+ if (isHtml) {
228
+ const clientIp = req.socket?.remoteAddress || '';
229
+ const isLocked = this.authManager?.isIpBlocked(clientIp);
230
+ const html = renderLoginPage({
231
+ hasPassword: this.authManager?.hasPassword,
232
+ locked: isLocked,
233
+ error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
234
+ });
235
+ res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
236
+ res.end(html);
237
+ return;
238
+ } else {
239
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
240
+ res.end(JSON.stringify({ error: 'unauthorized', message: '需要访问认证,请先登录' }));
241
+ return;
242
+ }
243
+ }
244
+
245
+ // 5. 认证通过:正常执行反向代理转发
148
246
  const headers = loopbackHeaders(req.headers, this.targetPort);
149
247
  const proxyReq = httpRequest(
150
248
  { host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
@@ -185,8 +283,15 @@ class ProxyServer {
185
283
  req.pipe(proxyReq);
186
284
  });
187
285
 
188
- // WebSocket upgrade(DSH 的 /api/events.mux 等流式通道)
286
+ // WebSocket upgrade 鉴权与代理
189
287
  this.server.on('upgrade', (req, socket, head) => {
288
+ const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
289
+ if (!auth.authenticated) {
290
+ socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nUnauthorized\r\n');
291
+ socket.destroy();
292
+ return;
293
+ }
294
+
190
295
  const headers = loopbackHeaders(req.headers, this.targetPort);
191
296
  const proxyReq = httpRequest({
192
297
  host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
@@ -259,11 +364,12 @@ class ProxyServer {
259
364
  * Bridge Service
260
365
  */
261
366
  class BridgeService {
262
- constructor({ dshPort, proxyPort, home, customTunnelConfig, logger }) {
367
+ constructor({ dshPort, proxyPort, home, customTunnelConfig, authManager, logger }) {
263
368
  this.dshPort = dshPort;
264
369
  this.proxyPort = proxyPort;
265
370
  this.home = home;
266
371
  this.customTunnelConfig = customTunnelConfig ?? null;
372
+ this.authManager = authManager ?? null;
267
373
  this.logger = logger;
268
374
 
269
375
  this.qrCache = new QrCache();
@@ -282,6 +388,7 @@ class BridgeService {
282
388
  this.proxy = new ProxyServer({
283
389
  localPort: this.proxyPort,
284
390
  targetPort: this.dshPort,
391
+ authManager: this.authManager,
285
392
  logger: this.logger,
286
393
  });
287
394
 
@@ -289,13 +396,40 @@ class BridgeService {
289
396
  return this.proxy;
290
397
  }
291
398
 
292
- async getStatus() {
399
+ async getStatus({ adminAuthValid = false } = {}) {
293
400
  const lanIp = selectLanIPv4();
294
- const lanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
401
+ const token = adminAuthValid ? this.authManager?.secretToken : null;
402
+ const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
403
+
404
+ const isLanProtected = isAuthEnabled && this.authManager?.scope !== 'public_only';
405
+ const isPublicProtected = isAuthEnabled && this.authManager?.scope !== 'lan_only';
406
+
407
+ const appendToken = (url, shouldAppend) => {
408
+ if (!url || !shouldAppend || !token) return url;
409
+ try {
410
+ const u = new URL(url);
411
+ u.searchParams.set('auth', token);
412
+ return u.toString();
413
+ } catch {
414
+ const sep = url.includes('?') ? '&' : '?';
415
+ return `${url}${sep}auth=${encodeURIComponent(token)}`;
416
+ }
417
+ };
418
+
419
+ const baseLanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
420
+ const lanUrl = appendToken(baseLanUrl, isLanProtected);
421
+
422
+ const baseCloudflaredUrl = this.cloudflared?.url || null;
423
+ const cloudflaredUrl = appendToken(baseCloudflaredUrl, isPublicProtected);
424
+
425
+ const baseCustomUrl = this.customTunnel?.publicUrl || null;
426
+ const customUrl = appendToken(baseCustomUrl, isPublicProtected);
295
427
 
296
428
  return {
297
429
  version: VERSION,
298
430
 
431
+ auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
432
+
299
433
  proxy: {
300
434
  running: !!this.proxy,
301
435
  port: this.proxyPort,
@@ -305,14 +439,16 @@ class BridgeService {
305
439
  lan: {
306
440
  ip: lanIp,
307
441
  url: lanUrl,
442
+ rawUrl: baseLanUrl,
308
443
  qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
309
444
  },
310
445
 
311
446
  cloudflared: {
312
447
  running: !!this.cloudflared,
313
- url: this.cloudflared?.url || null,
314
- qr: this.cloudflared?.url
315
- ? await this.qrCache.get(this.cloudflared.url)
448
+ url: cloudflaredUrl,
449
+ rawUrl: baseCloudflaredUrl,
450
+ qr: cloudflaredUrl
451
+ ? await this.qrCache.get(cloudflaredUrl)
316
452
  : null,
317
453
  state: this.cloudflaredState,
318
454
  },
@@ -321,9 +457,10 @@ class BridgeService {
321
457
  configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
322
458
  serverUrl: this.customTunnelConfig?.serverUrl ?? '',
323
459
  running: !!this.customTunnel?.connected,
324
- url: this.customTunnel?.publicUrl || null,
325
- qr: this.customTunnel?.publicUrl
326
- ? await this.qrCache.get(this.customTunnel.publicUrl)
460
+ url: customUrl,
461
+ rawUrl: baseCustomUrl,
462
+ qr: customUrl
463
+ ? await this.qrCache.get(customUrl)
327
464
  : null,
328
465
  state: this.customTunnelState,
329
466
  },
@@ -349,6 +486,7 @@ class BridgeService {
349
486
  serverUrl,
350
487
  accessToken,
351
488
  localPort: this.proxyPort,
489
+ internalTunnelSecret: this.authManager?.internalTunnelSecret,
352
490
  onStateChange: (state) => {
353
491
  this.customTunnelState = state;
354
492
  },
@@ -438,29 +576,57 @@ class BridgeService {
438
576
  }
439
577
  }
440
578
 
441
- // 一键直接升级插件(执行 dsh plugin / pnpm / npm 自动升级)
579
+ // 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
442
580
  async upgradePlugin({ version } = {}) {
443
581
  const targetVersion = version ? String(version).trim() : 'latest';
582
+ // 严格 SemVer 白名单正则校验
583
+ if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
584
+ return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
585
+ }
444
586
  const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
445
- const commands = [
446
- `dsh plugin --profile web add ${pkgSpec}`,
447
- `npx --yes @deepseek-ai/dsh plugin --profile web add ${pkgSpec}`,
448
- `npm install ${pkgSpec}`,
587
+ const isWin = process.platform === 'win32';
588
+
589
+ const tasks = [
590
+ { cmd: isWin ? 'dsh.cmd' : 'dsh', fallbackCmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
591
+ { cmd: isWin ? 'npx.cmd' : 'npx', fallbackCmd: 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
592
+ { cmd: isWin ? 'npm.cmd' : 'npm', fallbackCmd: 'npm', args: ['install', pkgSpec] },
449
593
  ];
450
594
 
451
595
  let lastError = null;
452
- let output = '';
453
596
 
454
- for (const cmd of commands) {
597
+ for (const task of tasks) {
455
598
  try {
456
599
  const res = await new Promise((resolve, reject) => {
457
- exec(cmd, { timeout: 90000, windowsHide: true }, (err, stdout, stderr) => {
458
- if (err) return reject(new Error(stderr || err.message));
459
- resolve({ stdout, stderr });
460
- });
600
+ const runExecutable = (executable) => {
601
+ const cp = spawn(executable, task.args, {
602
+ windowsHide: true,
603
+ shell: false,
604
+ timeout: 90000,
605
+ });
606
+ let stdout = '';
607
+ let stderr = '';
608
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
609
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
610
+ cp.on('error', (err) => {
611
+ if (executable !== task.fallbackCmd) {
612
+ runExecutable(task.fallbackCmd);
613
+ } else {
614
+ reject(err);
615
+ }
616
+ });
617
+ cp.on('close', (code) => {
618
+ if (code === 0) {
619
+ resolve({ stdout, stderr });
620
+ } else {
621
+ reject(new Error(stderr || stdout || `进程退出码 ${code}`));
622
+ }
623
+ });
624
+ };
625
+ runExecutable(task.cmd);
461
626
  });
462
- output = res.stdout || res.stderr || '升级成功';
463
- return { ok: true, command: cmd, output, version: targetVersion };
627
+
628
+ const output = res.stdout || res.stderr || '升级成功';
629
+ return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
464
630
  } catch (err) {
465
631
  lastError = err;
466
632
  }
@@ -495,6 +661,10 @@ function apply(ctx, config = {}) {
495
661
  const proxyPort = config.port ?? 3082;
496
662
  const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
497
663
  const configFile = join(dshHome, 'dsh-bridge', 'config.json');
664
+ const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
665
+
666
+ // 配置写入互斥锁队列,杜绝多平台并发写入造成文件覆盖损坏
667
+ let configWriteQueue = Promise.resolve();
498
668
 
499
669
  // 从 JSON 文件读取持久化配置
500
670
  async function loadConfig() {
@@ -506,17 +676,71 @@ function apply(ctx, config = {}) {
506
676
  }
507
677
  }
508
678
 
509
- // 持久化配置到 JSON 文件
679
+ // 持久化配置到 JSON 文件(排队原子写入)
510
680
  async function saveConfig(data) {
511
- await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
512
- await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
681
+ configWriteQueue = configWriteQueue.then(async () => {
682
+ await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
683
+ await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
684
+ }).catch((err) => {
685
+ logger.error('saveConfig failed: %s', err.message);
686
+ });
687
+ return configWriteQueue;
513
688
  }
514
689
 
690
+ // 访问安全认证管理器
691
+ const authManager = new AuthManager({
692
+ config: config.auth ?? {},
693
+ logger,
694
+ onPersist: async (patch) => {
695
+ const stored = await loadConfig();
696
+ stored.auth = { ...(stored.auth ?? {}), ...patch };
697
+ await saveConfig(stored);
698
+ },
699
+ });
700
+
701
+ // 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
702
+ async function checkEmergencyReset() {
703
+ try {
704
+ await unlink(emergencyResetFile);
705
+ authManager.enabled = false;
706
+ authManager.passwordHash = '';
707
+ authManager.passwordSalt = '';
708
+ authManager.adminPasswordHash = '';
709
+ authManager.adminPasswordSalt = '';
710
+ authManager.adminPolicy = 'password_unlock';
711
+ authManager.mode = 'token_and_password';
712
+ authManager.sessions.clear();
713
+ authManager.adminSessions.clear();
714
+ const stored = await loadConfig();
715
+ delete stored.auth;
716
+ await saveConfig(stored);
717
+ logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
718
+ } catch {}
719
+ }
720
+
721
+ // 启动时读取已保存的 auth 配置并执行保命标记检查
722
+ checkEmergencyReset().then(() => loadConfig()).then((stored) => {
723
+ if (stored?.auth) {
724
+ if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
725
+ if (stored.auth.mode) authManager.mode = stored.auth.mode;
726
+ if (stored.auth.scope) authManager.scope = stored.auth.scope;
727
+ if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
728
+ if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
729
+ if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
730
+ if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
731
+ if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
732
+ if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
733
+ if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
734
+ logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
735
+ }
736
+ }).catch(() => {});
737
+
515
738
  const service = new BridgeService({
516
739
  dshPort,
517
740
  proxyPort,
518
741
  home: config.home,
519
742
  customTunnelConfig: config.customTunnel ?? null,
743
+ authManager,
520
744
  logger,
521
745
  });
522
746
 
@@ -735,6 +959,7 @@ function apply(ctx, config = {}) {
735
959
 
736
960
  const disposeRpc = installBridgeRpc(ctx, {
737
961
  service,
962
+ authManager,
738
963
  wechat,
739
964
  qq,
740
965
  feishu,
@@ -761,8 +986,9 @@ function apply(ctx, config = {}) {
761
986
  await feishu.destroy();
762
987
  await telegram.destroy();
763
988
  platformManager.dispose();
989
+ authManager.dispose();
764
990
  await service.dispose();
765
- }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy and tunnels');
991
+ }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
766
992
  }
767
993
 
768
- export { name, inject, apply };
994
+ export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4 };
package/lib/qq/node.js CHANGED
@@ -147,8 +147,8 @@ export class QqConversationNode extends ConversationBridge {
147
147
  this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
148
148
 
149
149
  // 订阅网关入站事件
150
- this.ctx.on('qq/message', (event) => this._handleInbound(event))
151
- this.ctx.on('qq/interaction', (event) => this._handleInteraction(event))
150
+ this.disposers.push(this.ctx.on('qq/message', (event) => this._handleInbound(event)))
151
+ this.disposers.push(this.ctx.on('qq/interaction', (event) => this._handleInteraction(event)))
152
152
  }
153
153
 
154
154
  // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
@@ -350,6 +350,13 @@ export class QqConversationNode extends ConversationBridge {
350
350
  })
351
351
  }
352
352
 
353
+ /** 发送多媒体附件,正确传递 c2c 或 group 范围 */
354
+ async sendMediaFile(filePath, opts = {}) {
355
+ const peerInfo = this._currentPeer()
356
+ if (!peerInfo) return
357
+ return this.gateway.sendMediaFile(peerInfo.peerId, filePath, { scope: peerInfo.scope, ...opts })
358
+ }
359
+
353
360
  // ---- 入站 ----
354
361
 
355
362
  async _handleInbound(event) {
@@ -68,13 +68,13 @@ export class TelegramConversationNode extends ConversationBridge {
68
68
  this._inTurn = false
69
69
 
70
70
  // 订阅网关入站消息事件
71
- this.ctx.on('telegram/message', (event) => this._handleInbound(event))
71
+ this.disposers.push(this.ctx.on('telegram/message', (event) => this._handleInbound(event)))
72
72
 
73
73
  // 订阅 Inline 按钮点击交互事件(审批确认)
74
- this.ctx.on('telegram/action', (event) => this._handleAction(event))
74
+ this.disposers.push(this.ctx.on('telegram/action', (event) => this._handleAction(event)))
75
75
 
76
76
  // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
77
- this.ctx.on('session/event', (session, event) => {
77
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
78
78
  if (session.id !== this.activeSessionId) return
79
79
  if (event.type === 'turn/start') {
80
80
  this._inTurn = true
@@ -91,7 +91,7 @@ export class TelegramConversationNode extends ConversationBridge {
91
91
  this._streamMsgId = null
92
92
  this._streamContent = ''
93
93
  }
94
- })
94
+ }))
95
95
  }
96
96
 
97
97
  async _handleInbound(event) {
@@ -8,10 +8,11 @@ const RECONNECT_DELAY = 5000;
8
8
  const MAX_RECONNECT_ATTEMPTS = 5;
9
9
 
10
10
  export class CustomTunnelClient {
11
- constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
11
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
12
12
  this.serverUrl = serverUrl;
13
13
  this.accessToken = accessToken;
14
14
  this.localPort = localPort;
15
+ this.internalTunnelSecret = internalTunnelSecret;
15
16
  this.signal = signal;
16
17
  this.onStateChange = onStateChange;
17
18
  this.logger = logger;
@@ -126,10 +127,15 @@ export class CustomTunnelClient {
126
127
  Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
127
128
  );
128
129
 
130
+ const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
131
+ if (this.internalTunnelSecret) {
132
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
133
+ }
134
+
129
135
  const req = httpRequest({
130
136
  host: '127.0.0.1', port: this.localPort,
131
137
  method, path: path || '/',
132
- headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
138
+ headers: reqHeaders,
133
139
  }, (res) => {
134
140
  const chunks = [];
135
141
  res.on('data', c => chunks.push(c));
@@ -168,6 +174,9 @@ export class CustomTunnelClient {
168
174
  const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
169
175
  delete reqHeaders['proxy-connection'];
170
176
  delete reqHeaders['proxy-authorization'];
177
+ if (this.internalTunnelSecret) {
178
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
179
+ }
171
180
 
172
181
  const lines = [`GET ${path || '/'} HTTP/1.1`];
173
182
  for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
@@ -187,9 +187,15 @@ export async function downloadMedia({
187
187
  const controller = new AbortController()
188
188
  const timer = setTimeout(() => controller.abort(), timeoutMs)
189
189
  try {
190
- const response = await fetch(url, { signal: controller.signal })
191
- if (!response.ok) throw new Error(`media download HTTP ${response.status}`)
190
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
191
+ const contentLength = Number(response.headers.get('content-length') || 0);
192
+ if (contentLength > MAX_DOWNLOAD_BYTES) {
193
+ throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
194
+ }
192
195
  const raw = Buffer.from(await response.arrayBuffer())
196
+ if (raw.length > MAX_DOWNLOAD_BYTES) {
197
+ throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
198
+ }
193
199
  if (aesKeyBase64) {
194
200
  const key = parseAesKey(aesKeyBase64)
195
201
  return aes128EcbDecrypt(raw, key)
@@ -59,9 +59,9 @@ export class WechatConversationNode extends ConversationBridge {
59
59
  onActiveSessionChange,
60
60
  })
61
61
 
62
- this.ctx.on('wechat/message', (message) => {
62
+ this.disposers.push(this.ctx.on('wechat/message', (message) => {
63
63
  void this._handleInbound(message)
64
- })
64
+ }))
65
65
  }
66
66
 
67
67
  // ---- 入站 ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,15 +18,11 @@
18
18
  "README.md",
19
19
  "README.en.md",
20
20
  "LICENSE",
21
- "docs/banner.jpg",
22
- "docs/custom-tunnel.md",
23
- "docs/wechat-usage.md",
24
- "docs/qq-usage.md",
25
- "docs/feishu-usage.md",
26
- "docs/telegram-usage.md"
21
+ "docs"
27
22
  ],
28
23
  "scripts": {
29
24
  "build:client": "node client/build.mjs",
25
+ "prepack": "npm run build:client",
30
26
  "test": "node --test test/*.test.mjs"
31
27
  },
32
28
  "dependencies": {
@@ -42,7 +38,7 @@
42
38
  "@deepseek-ai/cordis": "^4.0.1"
43
39
  },
44
40
  "engines": {
45
- "node": ">=22"
41
+ "node": "^22.19.0 || >=24.0.0"
46
42
  },
47
43
  "license": "MIT",
48
44
  "keywords": [
@@ -62,7 +58,8 @@
62
58
  "bot",
63
59
  "wechat",
64
60
  "qq",
65
- "feishu"
61
+ "feishu",
62
+ "telegram"
66
63
  ],
67
64
  "dsh": {
68
65
  "bundle": {