@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/README.en.md +85 -8
- package/README.md +85 -8
- package/client/client.js +832 -57
- package/client/index.js +718 -61
- package/docs/telegram-usage.md +89 -0
- package/lib/auth/login-template.js +378 -0
- package/lib/auth/manager.js +425 -0
- package/lib/bridge-rpc-constants.js +6 -0
- package/lib/bridge-rpc.js +34 -1
- package/lib/feishu/gateway.js +166 -7
- package/lib/feishu/node.js +55 -10
- package/lib/index.js +258 -14
- package/lib/platform/base.js +13 -0
- package/lib/platform/conversation-bridge.js +32 -4
- package/lib/qq/gateway.js +94 -14
- package/lib/qq/node.js +49 -9
- package/lib/telegram/gateway.js +619 -0
- package/lib/telegram/index.js +210 -0
- package/lib/telegram/node.js +342 -0
- package/lib/wechat/gateway.js +54 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +4 -3
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
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
313
|
-
|
|
314
|
-
|
|
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:
|
|
324
|
-
|
|
325
|
-
|
|
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 };
|
package/lib/platform/base.js
CHANGED
|
@@ -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
|
|
590
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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) {
|
|
292
|
-
|
|
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)
|
|
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]
|