@wenbin_wb/dsh-bridge 2.3.3 → 2.5.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
@@ -21,6 +21,9 @@ import { PlatformManager } from './platform/manager.js';
21
21
  import { WechatService } from './wechat/index.js';
22
22
  import { QqService } from './qq/index.js';
23
23
  import { FeishuService } from './feishu/index.js';
24
+ import { TelegramService } from './telegram/index.js';
25
+ import { AuthManager } from './auth/manager.js';
26
+ import { renderLoginPage } from './auth/login-template.js';
24
27
 
25
28
  const name = 'dsh-bridge';
26
29
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -126,14 +129,15 @@ function loopbackHeaders(headers, targetPort) {
126
129
  }
127
130
 
128
131
  /**
129
- * HTTP + WebSocket 代理服务器
132
+ * HTTP + WebSocket 代理服务器(带安全认证守门)
130
133
  * 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
131
- * 否则手机通过局域网访问时 DSH 会把它当未登录的外部请求处理
134
+ * 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
132
135
  */
133
136
  class ProxyServer {
134
- constructor({ localPort, targetPort, logger }) {
137
+ constructor({ localPort, targetPort, authManager, logger }) {
135
138
  this.localPort = localPort;
136
139
  this.targetPort = targetPort;
140
+ this.authManager = authManager;
137
141
  this.logger = logger;
138
142
  this.server = null;
139
143
  this.clientSockets = new Set();
@@ -144,6 +148,101 @@ class ProxyServer {
144
148
  if (this.server) return;
145
149
 
146
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
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?.getStatus() ?? { 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. 认证通过:正常执行反向代理转发
147
246
  const headers = loopbackHeaders(req.headers, this.targetPort);
148
247
  const proxyReq = httpRequest(
149
248
  { host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
@@ -184,8 +283,15 @@ class ProxyServer {
184
283
  req.pipe(proxyReq);
185
284
  });
186
285
 
187
- // WebSocket upgrade(DSH 的 /api/events.mux 等流式通道)
286
+ // WebSocket upgrade 鉴权与代理
188
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
+
189
295
  const headers = loopbackHeaders(req.headers, this.targetPort);
190
296
  const proxyReq = httpRequest({
191
297
  host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
@@ -258,11 +364,12 @@ class ProxyServer {
258
364
  * Bridge Service
259
365
  */
260
366
  class BridgeService {
261
- constructor({ dshPort, proxyPort, home, customTunnelConfig, logger }) {
367
+ constructor({ dshPort, proxyPort, home, customTunnelConfig, authManager, logger }) {
262
368
  this.dshPort = dshPort;
263
369
  this.proxyPort = proxyPort;
264
370
  this.home = home;
265
371
  this.customTunnelConfig = customTunnelConfig ?? null;
372
+ this.authManager = authManager ?? null;
266
373
  this.logger = logger;
267
374
 
268
375
  this.qrCache = new QrCache();
@@ -281,6 +388,7 @@ class BridgeService {
281
388
  this.proxy = new ProxyServer({
282
389
  localPort: this.proxyPort,
283
390
  targetPort: this.dshPort,
391
+ authManager: this.authManager,
284
392
  logger: this.logger,
285
393
  });
286
394
 
@@ -290,11 +398,38 @@ class BridgeService {
290
398
 
291
399
  async getStatus() {
292
400
  const lanIp = selectLanIPv4();
293
- const lanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
401
+ const token = this.authManager?.secretToken;
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);
294
427
 
295
428
  return {
296
429
  version: VERSION,
297
430
 
431
+ auth: this.authManager?.getStatus() ?? { enabled: false },
432
+
298
433
  proxy: {
299
434
  running: !!this.proxy,
300
435
  port: this.proxyPort,
@@ -304,14 +439,16 @@ class BridgeService {
304
439
  lan: {
305
440
  ip: lanIp,
306
441
  url: lanUrl,
442
+ rawUrl: baseLanUrl,
307
443
  qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
308
444
  },
309
445
 
310
446
  cloudflared: {
311
447
  running: !!this.cloudflared,
312
- url: this.cloudflared?.url || null,
313
- qr: this.cloudflared?.url
314
- ? await this.qrCache.get(this.cloudflared.url)
448
+ url: cloudflaredUrl,
449
+ rawUrl: baseCloudflaredUrl,
450
+ qr: cloudflaredUrl
451
+ ? await this.qrCache.get(cloudflaredUrl)
315
452
  : null,
316
453
  state: this.cloudflaredState,
317
454
  },
@@ -320,9 +457,10 @@ class BridgeService {
320
457
  configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
321
458
  serverUrl: this.customTunnelConfig?.serverUrl ?? '',
322
459
  running: !!this.customTunnel?.connected,
323
- url: this.customTunnel?.publicUrl || null,
324
- qr: this.customTunnel?.publicUrl
325
- ? await this.qrCache.get(this.customTunnel.publicUrl)
460
+ url: customUrl,
461
+ rawUrl: baseCustomUrl,
462
+ qr: customUrl
463
+ ? await this.qrCache.get(customUrl)
326
464
  : null,
327
465
  state: this.customTunnelState,
328
466
  },
@@ -494,6 +632,7 @@ function apply(ctx, config = {}) {
494
632
  const proxyPort = config.port ?? 3082;
495
633
  const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
496
634
  const configFile = join(dshHome, 'dsh-bridge', 'config.json');
635
+ const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
497
636
 
498
637
  // 从 JSON 文件读取持久化配置
499
638
  async function loadConfig() {
@@ -511,11 +650,60 @@ function apply(ctx, config = {}) {
511
650
  await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
512
651
  }
513
652
 
653
+ // 访问安全认证管理器
654
+ const authManager = new AuthManager({
655
+ config: config.auth ?? {},
656
+ logger,
657
+ onPersist: async (patch) => {
658
+ const stored = await loadConfig();
659
+ stored.auth = { ...(stored.auth ?? {}), ...patch };
660
+ await saveConfig(stored);
661
+ },
662
+ });
663
+
664
+ // 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
665
+ async function checkEmergencyReset() {
666
+ try {
667
+ await unlink(emergencyResetFile);
668
+ authManager.enabled = false;
669
+ authManager.passwordHash = '';
670
+ authManager.passwordSalt = '';
671
+ authManager.adminPasswordHash = '';
672
+ authManager.adminPasswordSalt = '';
673
+ authManager.adminPolicy = 'password_unlock';
674
+ authManager.mode = 'token_and_password';
675
+ authManager.sessions.clear();
676
+ authManager.adminSessions.clear();
677
+ const stored = await loadConfig();
678
+ delete stored.auth;
679
+ await saveConfig(stored);
680
+ logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
681
+ } catch {}
682
+ }
683
+
684
+ // 启动时读取已保存的 auth 配置并执行保命标记检查
685
+ checkEmergencyReset().then(() => loadConfig()).then((stored) => {
686
+ if (stored?.auth) {
687
+ if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
688
+ if (stored.auth.mode) authManager.mode = stored.auth.mode;
689
+ if (stored.auth.scope) authManager.scope = stored.auth.scope;
690
+ if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
691
+ if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
692
+ if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
693
+ if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
694
+ if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
695
+ if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
696
+ if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
697
+ logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
698
+ }
699
+ }).catch(() => {});
700
+
514
701
  const service = new BridgeService({
515
702
  dshPort,
516
703
  proxyPort,
517
704
  home: config.home,
518
705
  customTunnelConfig: config.customTunnel ?? null,
706
+ authManager,
519
707
  logger,
520
708
  });
521
709
 
@@ -569,6 +757,19 @@ function apply(ctx, config = {}) {
569
757
  });
570
758
  platformManager.register(feishu);
571
759
 
760
+ // Telegram Bot(官方 Long Polling + 代理支持)—— 作为 Platform 子类注册进平台管理器
761
+ const telegram = new TelegramService({
762
+ ctx,
763
+ logger,
764
+ config: config.telegram ?? {},
765
+ onPersist: async (patch) => {
766
+ const stored = await loadConfig();
767
+ stored.telegram = { ...(stored.telegram ?? {}), ...patch };
768
+ await saveConfig(stored);
769
+ },
770
+ });
771
+ platformManager.register(telegram);
772
+
572
773
  // 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
573
774
  loadConfig().then(async (stored) => {
574
775
  if (stored?.wechat) {
@@ -681,11 +882,51 @@ function apply(ctx, config = {}) {
681
882
  }
682
883
  }).catch(() => {});
683
884
 
885
+ // 启动时读取已保存的 Telegram Bot 配置(凭证 + 代理 + 白名单 + 活动会话)
886
+ loadConfig().then(async (stored) => {
887
+ if (stored?.telegram) {
888
+ const cfg = stored.telegram;
889
+ telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
890
+ if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
891
+ if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
892
+ if (cfg.maxMessageChars != null) telegram.node.config.maxMessageChars = Number(cfg.maxMessageChars);
893
+ if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
894
+
895
+ telegram.node._restoringConfig = (async () => {
896
+ try {
897
+ if (cfg.activeSessionId) {
898
+ telegram.node.activeSessionId = cfg.activeSessionId;
899
+ logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
900
+ } else {
901
+ await telegram.node._pickDefaultSession().catch(() => {});
902
+ }
903
+ } finally {
904
+ telegram.node._configRestored = true;
905
+ }
906
+ })();
907
+
908
+ await telegram.node._restoringConfig;
909
+
910
+ if (cfg.botToken) {
911
+ telegram.gateway.setCredentials({
912
+ botToken: cfg.botToken,
913
+ proxy: cfg.proxy || '',
914
+ });
915
+ logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
916
+ await telegram.start().catch((err) => {
917
+ logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
918
+ });
919
+ }
920
+ }
921
+ }).catch(() => {});
922
+
684
923
  const disposeRpc = installBridgeRpc(ctx, {
685
924
  service,
925
+ authManager,
686
926
  wechat,
687
927
  qq,
688
928
  feishu,
929
+ telegram,
689
930
  platformManager,
690
931
  logger,
691
932
  saveCustomTunnelConfig: async (serverUrl, accessToken) => {
@@ -705,9 +946,12 @@ function apply(ctx, config = {}) {
705
946
  try { disposeRpc(); } catch {}
706
947
  await wechat.destroy();
707
948
  await qq.destroy();
949
+ await feishu.destroy();
950
+ await telegram.destroy();
708
951
  platformManager.dispose();
952
+ authManager.dispose();
709
953
  await service.dispose();
710
- }, 'dsh-bridge: stop wechat, qq, proxy and tunnels');
954
+ }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
711
955
  }
712
956
 
713
- export { name, inject, apply };
957
+ export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4 };
@@ -140,4 +140,17 @@ export class Platform {
140
140
  this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
141
141
  }
142
142
  }
143
+
144
+ dispose() {
145
+ for (const d of this.disposers) {
146
+ try { d() } catch {}
147
+ }
148
+ this.disposers = []
149
+ this.bridge?.dispose?.()
150
+ this.bridge = null
151
+ }
152
+
153
+ async destroy() {
154
+ this.dispose()
155
+ }
143
156
  }
@@ -582,12 +582,13 @@ export class ConversationBridge {
582
582
  }
583
583
  const onEvent = (session, event) => {
584
584
  if (session.id !== this.activeSessionId) return
585
- const state = digestState.get(session.id) ?? { startedTurns: new Set() }
585
+ const state = digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
586
586
  digestState.set(session.id, state)
587
587
 
588
588
  if (event.type === 'turn/start') {
589
- const turn = event.data.turn
590
- if (!state.startedTurns.has(turn)) {
589
+ const turn = event.data?.turn
590
+ state.createdFiles = new Set()
591
+ if (turn != null && !state.startedTurns.has(turn)) {
591
592
  state.startedTurns.add(turn)
592
593
  // 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
593
594
  if (this.peerId) this.sendTyping(1).catch(() => {})
@@ -595,6 +596,14 @@ export class ConversationBridge {
595
596
  startHeartbeat(session, state)
596
597
  return
597
598
  }
599
+ if (event.type === 'tool/call') {
600
+ const args = event.data?.parameters || event.data?.args || {}
601
+ const target = args.TargetFile || args.targetFile || args.target_file || args.path || args.filePath
602
+ if (target && typeof target === 'string') {
603
+ state.createdFiles.add(target)
604
+ }
605
+ return
606
+ }
598
607
  if (event.type === 'assistant/message') {
599
608
  const text = textOfAssistantMessage(event.data.message)
600
609
  if (text.trim()) void this.sendText(text)
@@ -603,7 +612,7 @@ export class ConversationBridge {
603
612
  if (event.type === 'turn/end') {
604
613
  stopHeartbeat(state)
605
614
  if (this.peerId) this.sendTyping(2).catch(() => {})
606
- const reason = event.data.reason
615
+ const reason = event.data?.reason || {}
607
616
  if (reason.kind === 'error') {
608
617
  void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`)
609
618
  } else if (reason.kind === 'aborted') {
@@ -611,6 +620,25 @@ export class ConversationBridge {
611
620
  } else if (reason.kind === 'max-tokens') {
612
621
  void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
613
622
  }
623
+ // 如果本轮生成/修改了产物文件,下发产物清单通知并尝试直接上传文件至聊天窗口
624
+ if (state.createdFiles && state.createdFiles.size > 0) {
625
+ const files = Array.from(state.createdFiles)
626
+ const fileLines = files.map((f) => `- \`${f}\``).join('\n')
627
+ void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
628
+
629
+ // 如果平台支持 sendMediaFile,自动尝试直接发送文件/图片到聊天窗口
630
+ if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
631
+ for (const f of files) {
632
+ try {
633
+ if (statSync(f).isFile()) {
634
+ void this.platform.sendMediaFile(this.peerId, f)
635
+ }
636
+ } catch {}
637
+ }
638
+ }
639
+
640
+ state.createdFiles.clear()
641
+ }
614
642
  return
615
643
  }
616
644
  }
package/lib/qq/gateway.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // QQ Bot OpenAPI v2 gateway
2
2
  // Official API: https://bot.q.qq.com/wiki/develop/api-v2/
3
3
 
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
4
6
  import { Service } from '@deepseek-ai/cordis'
5
7
  import WebSocket from 'ws'
6
8
 
@@ -66,6 +68,7 @@ function normalizeEvent(payload) {
66
68
  senderId: data.author?.user_openid || data.user_openid,
67
69
  peerId: data.author?.user_openid || data.user_openid,
68
70
  text: stringValue(data.content).trim(),
71
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
69
72
  message: data,
70
73
  messageReference: data.message_reference,
71
74
  msgSeq: data.msg_seq, // 用于避免去重
@@ -81,6 +84,7 @@ function normalizeEvent(payload) {
81
84
  peerId: data.group_openid || data.group_id,
82
85
  groupId: data.group_openid || data.group_id,
83
86
  text: stringValue(data.content).trim(),
87
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
84
88
  message: data,
85
89
  messageReference: data.message_reference,
86
90
  msgSeq: data.msg_seq, // 用于避免去重
@@ -137,6 +141,8 @@ export class QqGateway extends Service {
137
141
  this.heartbeatTimer = null
138
142
  this.heartbeatInterval = 30_000
139
143
  this.sequence = null
144
+ this.sessionId = null
145
+ this.unackedHeartbeats = 0
140
146
  this.tokenPromise = null
141
147
  this.dedup = new Map()
142
148
  }
@@ -213,6 +219,7 @@ export class QqGateway extends Service {
213
219
  }
214
220
 
215
221
  async runLoop() {
222
+ let backoffMs = this.config.reconnectDelayMs
216
223
  while (!this.stopRequested) {
217
224
  try {
218
225
  this.setStatus('starting')
@@ -223,11 +230,13 @@ export class QqGateway extends Service {
223
230
  || ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
224
231
  || DEFAULT_GATEWAY
225
232
  await this.connect(gateway, token)
233
+ backoffMs = this.config.reconnectDelayMs
226
234
  } catch (error) {
227
235
  if (this.stopRequested) break
228
236
  this.setStatus('reconnecting')
229
- this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
230
- await sleep(this.config.reconnectDelayMs)
237
+ this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s, retrying in %dms...', error?.message ?? error, backoffMs)
238
+ await sleep(backoffMs)
239
+ backoffMs = Math.min(Math.round(backoffMs * 1.5), 30_000)
231
240
  }
232
241
  }
233
242
  this.setStatus('idle')
@@ -262,22 +271,48 @@ export class QqGateway extends Service {
262
271
  if (op === 10) {
263
272
  this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
264
273
  this.startHeartbeat(ws)
265
- ws.send(JSON.stringify({
266
- op: 2,
267
- d: {
268
- token: `QQBot ${token}`,
269
- intents: this.config.intents,
270
- shard: [0, 1],
271
- properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
272
- },
273
- }))
274
+ if (this.sessionId && this.sequence != null) {
275
+ // 快速恢复模式(Resume):携带 sessionId 与 sequence 避免重新全量鉴权与丢失消息
276
+ this.logger?.info?.('[dsh-bridge qq] attempting session resume (sessionId=%s, seq=%s)', this.sessionId, this.sequence)
277
+ ws.send(JSON.stringify({
278
+ op: 6,
279
+ d: {
280
+ token: `QQBot ${token}`,
281
+ session_id: this.sessionId,
282
+ seq: this.sequence,
283
+ },
284
+ }))
285
+ } else {
286
+ // 全量鉴权模式(Identify)
287
+ ws.send(JSON.stringify({
288
+ op: 2,
289
+ d: {
290
+ token: `QQBot ${token}`,
291
+ intents: this.config.intents,
292
+ shard: [0, 1],
293
+ properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
294
+ },
295
+ }))
296
+ }
297
+ return
298
+ }
299
+ if (op === 11) {
300
+ // 心跳确认(Heartbeat ACK):重置未确认计数器
301
+ this.unackedHeartbeats = 0
274
302
  return
275
303
  }
276
304
  if (op === 0) {
277
305
  if (payload.t === 'READY') {
306
+ this.sessionId = payload.d?.session_id || this.sessionId
278
307
  this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
308
+ this.unackedHeartbeats = 0
279
309
  await this.persist({ accountId: this.accountId })
280
310
  this.setStatus('connected')
311
+ this.logger?.info?.('[dsh-bridge qq] gateway READY (session_id=%s)', this.sessionId)
312
+ } else if (payload.t === 'RESUMED') {
313
+ this.unackedHeartbeats = 0
314
+ this.setStatus('connected')
315
+ this.logger?.info?.('[dsh-bridge qq] gateway RESUMED successfully')
281
316
  }
282
317
  const event = normalizeEvent(payload)
283
318
  if (event.type === 'message' && event.id && !this.seen(event.id)) {
@@ -288,14 +323,31 @@ export class QqGateway extends Service {
288
323
  }
289
324
  return
290
325
  }
291
- if (op === 7) { try { ws.close() } catch {}; return }
292
- if (op === 9) throw new Error('QQ gateway invalid session')
326
+ if (op === 7) {
327
+ this.logger?.info?.('[dsh-bridge qq] gateway requested reconnect (OpCode 7)')
328
+ try { ws.close() } catch {}
329
+ return
330
+ }
331
+ if (op === 9) {
332
+ this.sessionId = null
333
+ this.sequence = null
334
+ throw new Error('QQ gateway invalid session (OpCode 9)')
335
+ }
293
336
  }
294
337
 
295
338
  startHeartbeat(ws) {
296
339
  this.clearHeartbeat()
340
+ this.unackedHeartbeats = 0
297
341
  const beat = () => {
298
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 1, d: this.sequence }))
342
+ if (ws.readyState === WebSocket.OPEN) {
343
+ if (this.unackedHeartbeats >= 2) {
344
+ this.logger?.warn?.('[dsh-bridge qq] heartbeat ACK missed (count=%d), dead link detected, reconnecting...', this.unackedHeartbeats)
345
+ try { ws.terminate() } catch {}
346
+ return
347
+ }
348
+ this.unackedHeartbeats += 1
349
+ ws.send(JSON.stringify({ op: 1, d: this.sequence }))
350
+ }
299
351
  }
300
352
  beat()
301
353
  this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
@@ -304,6 +356,7 @@ export class QqGateway extends Service {
304
356
  clearHeartbeat() {
305
357
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
306
358
  this.heartbeatTimer = null
359
+ this.unackedHeartbeats = 0
307
360
  }
308
361
 
309
362
  seen(id) {
@@ -485,6 +538,33 @@ export class QqGateway extends Service {
485
538
  return this.api(`/v2/panels/${encodeURIComponent(panelId)}/target`, { method: 'PUT', body })
486
539
  }
487
540
 
541
+ /**
542
+ * 上传并发送本地媒体文件(图片等)
543
+ */
544
+ async sendMediaFile(peerId, filePath, opts = {}) {
545
+ if (!fs.existsSync(filePath)) return null
546
+ const ext = path.extname(filePath).toLowerCase()
547
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
548
+ const fileType = isImage ? 1 : 1
549
+ try {
550
+ const buf = await fs.promises.readFile(filePath)
551
+ const base64Data = buf.toString('base64')
552
+ const ep = this.endpoint(peerId, opts.scope, 'files')
553
+ const res = await this.api(ep, {
554
+ method: 'POST',
555
+ body: {
556
+ file_type: fileType,
557
+ file_data: base64Data,
558
+ srv_send_msg: true,
559
+ },
560
+ })
561
+ return res
562
+ } catch (err) {
563
+ this.logger?.warn?.('[dsh-bridge qq] sendMediaFile error: %s', err?.message ?? err)
564
+ return null
565
+ }
566
+ }
567
+
488
568
  setCredentials(values = {}) {
489
569
  for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
490
570
  if (values[key] !== undefined) this.config[key] = values[key]