@wenbin_wb/dsh-bridge 2.10.2 → 2.10.4
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 +45 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +86 -14
- package/client/index.js +58 -14
- package/client/mobile-styles.js +834 -802
- package/lib/auth/login-template.js +417 -382
- package/lib/bridge-rpc.js +2 -2
- package/lib/feishu/node.js +10 -4
- package/lib/index.js +109 -15
- package/lib/platform/conversation-bridge.js +8 -3
- package/lib/platform/message-split.js +39 -1
- package/lib/qq/node.js +539 -532
- package/lib/session-strip.js +67 -0
- package/lib/telegram/gateway.js +28 -26
- package/lib/telegram/node.js +0 -27
- package/lib/tunnel-client.mjs +130 -35
- package/lib/wechat/gateway.js +17 -4
- package/package.json +2 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// DSH Bridge - 会话响应投影剥离(共享逻辑)
|
|
2
|
+
//
|
|
3
|
+
// contextHeaders / contextTimeline 是 DSH 会话投影里的两个大字段:每轮完整系统提示
|
|
4
|
+
// + 工具定义(单会话 4.8MB+,session.list 数百会话可达 60MB+),但没有客户端 UI
|
|
5
|
+
// 读取它们(会话打开时通过 WebSocket 实时获取,见 dsh-session-projection-cache 注释)。
|
|
6
|
+
// 剥离后可大幅降低传输体积(session.history gzip 1.2MB -> ~150KB,约 8 倍)。
|
|
7
|
+
//
|
|
8
|
+
// 本模块被两个转发层复用:
|
|
9
|
+
// - tunnel-client.mjs(自建隧道)
|
|
10
|
+
// - index.js ProxyServer(局域网 / Cloudflare 隧道 / 外部隧道登记 —— 所有公网入口)
|
|
11
|
+
// 命中失败(非 200 / 解析失败 / 结构不符)时原样返回,绝不破坏响应。
|
|
12
|
+
//
|
|
13
|
+
// DSH web 服务器默认开启 gzip 压缩(compression: gzip, level 1, threshold 1KB),
|
|
14
|
+
// 所以 API 响应可能是 gzip 编码的。必须先解压才能 JSON.parse,否则解析失败,
|
|
15
|
+
// catch {} 静默跳过剥离 → 完整 58MB+ 响应原样传输 → 隧道慢。
|
|
16
|
+
|
|
17
|
+
import { gunzipSync } from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 剥离 session.list / session.history 响应中的 contextHeaders 与 contextTimeline。
|
|
23
|
+
* @param {string} pathname 请求路径(不含 query)
|
|
24
|
+
* @param {Buffer} bodyBuf 原始响应体
|
|
25
|
+
* @param {string} [contentEncoding] 响应的 content-encoding 头值(用于判断是否 gzip)
|
|
26
|
+
* @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length 和 content-encoding)
|
|
27
|
+
*/
|
|
28
|
+
export function stripSessionProjections(pathname, bodyBuf, contentEncoding) {
|
|
29
|
+
const cleanPath = String(pathname || '').split('?')[0];
|
|
30
|
+
if (!STRIP_PATHS.has(cleanPath) || !Buffer.isBuffer(bodyBuf)) {
|
|
31
|
+
return { body: bodyBuf, stripped: false };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
// 如果 DSH 服务器 gzip 压缩了响应,先解压再解析
|
|
35
|
+
const isGzipped = String(contentEncoding ?? '').toLowerCase() === 'gzip';
|
|
36
|
+
const parseBuf = isGzipped ? gunzipSync(bodyBuf) : bodyBuf;
|
|
37
|
+
const json = JSON.parse(parseBuf.toString('utf8'));
|
|
38
|
+
if (json?.result?.ok) {
|
|
39
|
+
const v = json.result.value;
|
|
40
|
+
let changed = false;
|
|
41
|
+
// session.list: items[].projections.values
|
|
42
|
+
if (v?.items) {
|
|
43
|
+
for (const item of v.items) {
|
|
44
|
+
const proj = item?.projections?.values;
|
|
45
|
+
if (proj && (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined)) {
|
|
46
|
+
delete proj.contextHeaders;
|
|
47
|
+
delete proj.contextTimeline;
|
|
48
|
+
changed = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// session.history: projections.values
|
|
53
|
+
if (v?.projections?.values) {
|
|
54
|
+
const proj = v.projections.values;
|
|
55
|
+
if (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined) {
|
|
56
|
+
delete proj.contextHeaders;
|
|
57
|
+
delete proj.contextTimeline;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (changed) {
|
|
62
|
+
return { body: Buffer.from(JSON.stringify(json), 'utf8'), stripped: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch { /* 解析/解压失败则原样发送 */ }
|
|
66
|
+
return { body: bodyBuf, stripped: false };
|
|
67
|
+
}
|
package/lib/telegram/gateway.js
CHANGED
|
@@ -37,34 +37,36 @@ export function createConnectProxyAgent(proxyUrl) {
|
|
|
37
37
|
? 'Basic ' + Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')
|
|
38
38
|
: undefined
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
40
|
+
// Node >=24 下自定义 createConnection 作为 https.Agent 构造参数会被静默忽略
|
|
41
|
+
// (https.Agent 不拷贝构造入参中的 createConnection,回退到原型默认直连方法)。
|
|
42
|
+
// 改为构造后赋值实例属性:实例自有属性会正确覆盖原型方法,Node 22/24 均生效。
|
|
43
|
+
const agent = new https.Agent({ keepAlive: true })
|
|
44
|
+
agent.createConnection = function createTunnelConnection(opts, callback) {
|
|
45
|
+
const connectReq = http.request({
|
|
46
|
+
host: proxyHost,
|
|
47
|
+
port: proxyPort,
|
|
48
|
+
method: 'CONNECT',
|
|
49
|
+
path: `${opts.host}:${opts.port || 443}`,
|
|
50
|
+
headers: authHeader ? { 'Proxy-Authorization': authHeader } : {},
|
|
51
|
+
})
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
callback(null, tlsSocket)
|
|
53
|
+
connectReq.on('connect', (res, socket) => {
|
|
54
|
+
if (res.statusCode !== 200) {
|
|
55
|
+
socket.destroy()
|
|
56
|
+
return callback(new Error(`Proxy CONNECT failed with HTTP ${res.statusCode}`))
|
|
57
|
+
}
|
|
58
|
+
const tlsSocket = tls.connect({
|
|
59
|
+
host: opts.host,
|
|
60
|
+
socket,
|
|
61
|
+
servername: opts.servername || opts.host,
|
|
62
62
|
})
|
|
63
|
+
callback(null, tlsSocket)
|
|
64
|
+
})
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
connectReq.on('error', (err) => callback(err))
|
|
67
|
+
connectReq.end()
|
|
68
|
+
}
|
|
69
|
+
return agent
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -627,4 +629,4 @@ export class TelegramGateway extends Service {
|
|
|
627
629
|
dispose() {
|
|
628
630
|
void this.stop()
|
|
629
631
|
}
|
|
630
|
-
}
|
|
632
|
+
}
|
package/lib/telegram/node.js
CHANGED
|
@@ -206,33 +206,6 @@ export class TelegramConversationNode extends ConversationBridge {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
async sendApprovalCard(approvalId, request) {
|
|
210
|
-
const peerId = this._lastPeer?.chatId || this.peerId
|
|
211
|
-
if (!peerId || !this.gateway) return
|
|
212
|
-
|
|
213
|
-
const toolName = request?.name || request?.tool || '系统操作'
|
|
214
|
-
const desc = request?.description || request?.summary || ''
|
|
215
|
-
const command = request?.command || request?.cmd || ''
|
|
216
|
-
|
|
217
|
-
const lines = [
|
|
218
|
-
`⚠️ **操作权限确认** (ID: <code>${approvalId}</code>)`,
|
|
219
|
-
'',
|
|
220
|
-
`• **工具**:<code>${toolName}</code>`,
|
|
221
|
-
]
|
|
222
|
-
if (desc) lines.push(`• **说明**:${desc}`)
|
|
223
|
-
if (command) lines.push(`• **命令**:<code>${command}</code>`)
|
|
224
|
-
lines.push('', '请选择审批决议(亦可直接输入 <code>1</code> 批准,<code>2</code> 拒绝):')
|
|
225
|
-
|
|
226
|
-
const buttons = [
|
|
227
|
-
[
|
|
228
|
-
{ text: '✓ 批准执行', callback_data: `approve:${approvalId}` },
|
|
229
|
-
{ text: '✕ 拒绝执行', callback_data: `reject:${approvalId}` },
|
|
230
|
-
],
|
|
231
|
-
]
|
|
232
|
-
|
|
233
|
-
return this.gateway.sendKeyboard(peerId, lines.join('\n'), buttons)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
209
|
async _sendTextNow(text, opts = {}) {
|
|
237
210
|
// T2.3:轮次绑定的 outboundPeer(chat 级目标)优先生效
|
|
238
211
|
const peerId = opts?.outboundPeer?.peerId || this._lastPeer?.chatId || this.peerId
|
package/lib/tunnel-client.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { request as httpRequest } from 'node:http';
|
|
|
4
4
|
import { connect as netConnect } from 'node:net';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { gzip as gzipCallback } from 'node:zlib';
|
|
7
|
+
import { randomBytes as cryptoRandomBytes } from 'node:crypto';
|
|
8
|
+
import { stripSessionProjections } from './session-strip.js';
|
|
7
9
|
|
|
8
10
|
const gzipAsync = promisify(gzipCallback);
|
|
9
11
|
|
|
@@ -21,7 +23,7 @@ const GZIP_THRESHOLD = 102400; // 100KB
|
|
|
21
23
|
const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
|
|
22
24
|
|
|
23
25
|
export class CustomTunnelClient {
|
|
24
|
-
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
|
|
26
|
+
constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger, sseStreaming = false }) {
|
|
25
27
|
this.serverUrl = serverUrl;
|
|
26
28
|
this.accessToken = accessToken;
|
|
27
29
|
this.localPort = localPort;
|
|
@@ -29,6 +31,7 @@ export class CustomTunnelClient {
|
|
|
29
31
|
this.signal = signal;
|
|
30
32
|
this.onStateChange = onStateChange;
|
|
31
33
|
this.logger = logger;
|
|
34
|
+
this.sseStreaming = sseStreaming;
|
|
32
35
|
this.ws = null;
|
|
33
36
|
this.publicUrl = null;
|
|
34
37
|
this.connected = false;
|
|
@@ -160,8 +163,33 @@ export class CustomTunnelClient {
|
|
|
160
163
|
const chunks = [];
|
|
161
164
|
|
|
162
165
|
if (isSSE) {
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
if (this.sseStreaming) {
|
|
167
|
+
// ── 流式模式(需服务端支持 response-start/chunk/end 协议)──
|
|
168
|
+
// 逐 chunk 转发,SSE 长连接持续保持,浏览器端不会断连重连
|
|
169
|
+
const respHeaders = Object.fromEntries(
|
|
170
|
+
Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
|
|
171
|
+
);
|
|
172
|
+
this._sendMessage({
|
|
173
|
+
type: 'response-start', requestId,
|
|
174
|
+
statusCode: res.statusCode, headers: respHeaders,
|
|
175
|
+
});
|
|
176
|
+
res.on('data', (c) => {
|
|
177
|
+
this._sendMessage({
|
|
178
|
+
type: 'response-chunk', requestId,
|
|
179
|
+
body: c.toString('base64'),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
res.on('error', () => {
|
|
183
|
+
this._sendMessage({ type: 'response-end', requestId });
|
|
184
|
+
});
|
|
185
|
+
res.on('end', () => {
|
|
186
|
+
this._sendMessage({ type: 'response-end', requestId });
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 截断模式(默认,兼容老服务端)──
|
|
192
|
+
// 收集初始数据后立即返回,避免 SSE 永不 end 导致隧道服务器超时 504
|
|
165
193
|
let sseSent = false;
|
|
166
194
|
const sseTimer = setTimeout(() => {
|
|
167
195
|
if (sseSent) return;
|
|
@@ -255,40 +283,23 @@ export class CustomTunnelClient {
|
|
|
255
283
|
);
|
|
256
284
|
let bodyBuf = Buffer.concat(chunks);
|
|
257
285
|
|
|
258
|
-
// 剥离 contextHeaders
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// session.list: items[].projections.values
|
|
269
|
-
if (v?.items) {
|
|
270
|
-
for (const item of v.items) {
|
|
271
|
-
const proj = item?.projections?.values;
|
|
272
|
-
if (proj) {
|
|
273
|
-
delete proj.contextHeaders;
|
|
274
|
-
delete proj.contextTimeline;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// session.history: projections.values
|
|
279
|
-
if (v?.projections?.values) {
|
|
280
|
-
delete v.projections.values.contextHeaders;
|
|
281
|
-
delete v.projections.values.contextTimeline;
|
|
282
|
-
}
|
|
283
|
-
bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
|
284
|
-
respHeaders['content-length'] = String(bodyBuf.length);
|
|
285
|
-
}
|
|
286
|
-
} catch {} // 解析失败则原样发送
|
|
286
|
+
// 剥离 session.list / session.history 的 contextHeaders 投影(无 UI 读取的大字段)
|
|
287
|
+
// 传入 content-encoding 以便 stripSessionProjections 先 gunzip 再解析
|
|
288
|
+
if (res.statusCode === 200) {
|
|
289
|
+
const { body, stripped } = stripSessionProjections(path, bodyBuf, res.headers['content-encoding']);
|
|
290
|
+
if (stripped) {
|
|
291
|
+
bodyBuf = body;
|
|
292
|
+
// 已修改 body,清除原始压缩编码标记,让下方 gzip 逻辑重新压缩
|
|
293
|
+
delete respHeaders['content-encoding'];
|
|
294
|
+
respHeaders['content-length'] = String(bodyBuf.length);
|
|
295
|
+
}
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
// 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
|
|
290
299
|
const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
|
|
291
|
-
|
|
300
|
+
// 注意:alreadyEncoded 从 respHeaders 读取(而非 res.headers),
|
|
301
|
+
// 因为上面的剥离逻辑可能已经删除了 respHeaders['content-encoding']
|
|
302
|
+
const alreadyEncoded = String(respHeaders['content-encoding'] ?? res.headers['content-encoding'] ?? '').toLowerCase();
|
|
292
303
|
const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
|
|
293
304
|
if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
|
|
294
305
|
gzipAsync(bodyBuf).then((zipped) => {
|
|
@@ -337,9 +348,15 @@ export class CustomTunnelClient {
|
|
|
337
348
|
let headerBuf = '';
|
|
338
349
|
let upgraded = false;
|
|
339
350
|
|
|
351
|
+
// WebSocket 帧解析缓冲区 — 拦截 Ping (0x9) 自动回复 Pong (0xA)。
|
|
352
|
+
// DSH API Gateway 的 /api/remote.mux WebSocket 每 2s 发 Ping,连续 2 次没收到
|
|
353
|
+
// Pong 就 terminate。隧道用裸 TCP 转发,Pong 往返延迟可能超时,本地直接回复最可靠。
|
|
354
|
+
let wsFrameBuf = Buffer.alloc(0);
|
|
355
|
+
|
|
340
356
|
sock.on('data', (chunk) => {
|
|
341
357
|
if (upgraded) {
|
|
342
|
-
|
|
358
|
+
wsFrameBuf = Buffer.concat([wsFrameBuf, chunk]);
|
|
359
|
+
wsFrameBuf = this._processWsFrames(wsId, wsFrameBuf, sock);
|
|
343
360
|
return;
|
|
344
361
|
}
|
|
345
362
|
headerBuf += chunk.toString('binary');
|
|
@@ -347,6 +364,7 @@ export class CustomTunnelClient {
|
|
|
347
364
|
if (sep === -1) return;
|
|
348
365
|
|
|
349
366
|
upgraded = true;
|
|
367
|
+
const statusLine = headerBuf.slice(0, headerBuf.indexOf('\r\n'));
|
|
350
368
|
const replyHeaders = {};
|
|
351
369
|
const headerLines = headerBuf.slice(0, sep).split('\r\n');
|
|
352
370
|
for (let i = 1; i < headerLines.length; i++) {
|
|
@@ -356,7 +374,20 @@ export class CustomTunnelClient {
|
|
|
356
374
|
headerLines[i].slice(ci + 1).trim();
|
|
357
375
|
}
|
|
358
376
|
}
|
|
359
|
-
|
|
377
|
+
|
|
378
|
+
// 解析状态码,传递给服务端以便正确转发(非 101 时浏览器能看到真实错误)
|
|
379
|
+
const statusMatch = /^HTTP\/1\.\d\s+(\d+)\s*(.*)/.exec(statusLine);
|
|
380
|
+
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 101;
|
|
381
|
+
const statusMessage = statusMatch ? statusMatch[2] : '';
|
|
382
|
+
|
|
383
|
+
if (statusCode === 101) {
|
|
384
|
+
this._sendMessage({ type: 'ws-accept', wsId, statusCode, replyHeaders });
|
|
385
|
+
} else {
|
|
386
|
+
// 非 101 响应:转发状态码和头,让浏览器看到真实错误(如 401)
|
|
387
|
+
this._sendMessage({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
|
|
388
|
+
sock.destroy();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
360
391
|
|
|
361
392
|
// 握手后紧跟的帧数据
|
|
362
393
|
const rest = headerBuf.slice(sep + 4);
|
|
@@ -376,6 +407,70 @@ export class CustomTunnelClient {
|
|
|
376
407
|
});
|
|
377
408
|
}
|
|
378
409
|
|
|
410
|
+
// ── WebSocket 帧解析 ─────────────────────────────────────────────────────
|
|
411
|
+
// 在裸 TCP 层面解析 WebSocket 帧,拦截 Ping (0x9) 自动回复 Pong (0xA),
|
|
412
|
+
// 其余帧原样转发。DSH API Gateway 每 2s 发 Ping,2 次没 Pong 就 terminate。
|
|
413
|
+
_processWsFrames(wsId, buf, sock) {
|
|
414
|
+
while (buf.length >= 2) {
|
|
415
|
+
const b0 = buf[0];
|
|
416
|
+
const b1 = buf[1];
|
|
417
|
+
const opcode = b0 & 0x0f;
|
|
418
|
+
const masked = b1 & 0x80;
|
|
419
|
+
let payloadLen = b1 & 0x7f;
|
|
420
|
+
let offset = 2;
|
|
421
|
+
|
|
422
|
+
if (payloadLen === 126) {
|
|
423
|
+
if (buf.length < 4) break;
|
|
424
|
+
payloadLen = buf.readUInt16BE(2);
|
|
425
|
+
offset = 4;
|
|
426
|
+
} else if (payloadLen === 127) {
|
|
427
|
+
if (buf.length < 10) break;
|
|
428
|
+
payloadLen = Number(buf.readBigUInt64BE(2));
|
|
429
|
+
offset = 10;
|
|
430
|
+
}
|
|
431
|
+
if (masked) offset += 4;
|
|
432
|
+
if (buf.length < offset + payloadLen) break;
|
|
433
|
+
|
|
434
|
+
const frameEnd = offset + payloadLen;
|
|
435
|
+
const frameBytes = buf.subarray(0, frameEnd);
|
|
436
|
+
|
|
437
|
+
if (opcode === 0x9) {
|
|
438
|
+
// Ping → 自动回复 Pong(同 payload,必须 mask,因为这是 client→server 方向)
|
|
439
|
+
// WebSocket 协议规定客户端→服务端的帧必须加 mask,否则服务端 ws 库会判定
|
|
440
|
+
// 协议错误(1002)并关闭连接。
|
|
441
|
+
const payload = buf.subarray(offset, frameEnd);
|
|
442
|
+
const mask = cryptoRandomBytes(4);
|
|
443
|
+
const maskedPayload = Buffer.alloc(payload.length);
|
|
444
|
+
for (let i = 0; i < payload.length; i++) {
|
|
445
|
+
maskedPayload[i] = payload[i] ^ mask[i % 4];
|
|
446
|
+
}
|
|
447
|
+
if (payload.length < 126) {
|
|
448
|
+
const hdr = Buffer.alloc(6);
|
|
449
|
+
hdr[0] = 0x8a; // fin + opcode 0xA (pong)
|
|
450
|
+
hdr[1] = 0x80 | payload.length; // masked + length
|
|
451
|
+
mask.copy(hdr, 2);
|
|
452
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
453
|
+
} else if (payload.length < 65536) {
|
|
454
|
+
const hdr = Buffer.alloc(8);
|
|
455
|
+
hdr[0] = 0x8a; hdr[1] = 0x80 | 126;
|
|
456
|
+
hdr.writeUInt16BE(payload.length, 2);
|
|
457
|
+
mask.copy(hdr, 4);
|
|
458
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
459
|
+
} else {
|
|
460
|
+
const hdr = Buffer.alloc(14);
|
|
461
|
+
hdr[0] = 0x8a; hdr[1] = 0x80 | 127;
|
|
462
|
+
hdr.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
463
|
+
mask.copy(hdr, 10);
|
|
464
|
+
sock.write(Buffer.concat([hdr, maskedPayload]));
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
this._sendMessage({ type: 'ws-frame', wsId, data: frameBytes.toString('base64') });
|
|
468
|
+
}
|
|
469
|
+
buf = buf.subarray(frameEnd);
|
|
470
|
+
}
|
|
471
|
+
return buf;
|
|
472
|
+
}
|
|
473
|
+
|
|
379
474
|
_handleWsFrame(msg) {
|
|
380
475
|
const sock = this.localWsSockets.get(msg.wsId);
|
|
381
476
|
if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
|
package/lib/wechat/gateway.js
CHANGED
|
@@ -84,17 +84,19 @@ function baseInfo() {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** 带超时与 abort 的 POST JSON。非 2xx 抛出带 HTTP 状态的错误。 */
|
|
87
|
-
async function postJson({ baseUrl = ILINK_BASE_URL, endpoint, payload, token, timeoutMs = API_TIMEOUT_MS }) {
|
|
87
|
+
async function postJson({ baseUrl = ILINK_BASE_URL, endpoint, payload, token, timeoutMs = API_TIMEOUT_MS, signal: externalSignal }) {
|
|
88
88
|
const body = JSON.stringify({ ...payload, base_info: baseInfo() })
|
|
89
89
|
const url = `${baseUrl.replace(/\/+$/, '')}/${endpoint}`
|
|
90
90
|
const controller = new AbortController()
|
|
91
91
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
92
|
+
// 外部 signal(如轮询停止)与超时合并:任一触发即中止请求
|
|
93
|
+
const signal = externalSignal ? AbortSignal.any([externalSignal, controller.signal]) : controller.signal
|
|
92
94
|
try {
|
|
93
95
|
const response = await fetch(url, {
|
|
94
96
|
method: 'POST',
|
|
95
97
|
headers: requestHeaders(token, body),
|
|
96
98
|
body,
|
|
97
|
-
signal
|
|
99
|
+
signal,
|
|
98
100
|
})
|
|
99
101
|
const raw = await response.text()
|
|
100
102
|
if (!response.ok) {
|
|
@@ -136,7 +138,7 @@ async function getJson({ baseUrl = ILINK_BASE_URL, endpoint, timeoutMs = QR_TIME
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/** 长轮询收消息;超时返回空批次(不算错误)。 */
|
|
139
|
-
async function getUpdates({ baseUrl, token, syncBuf, timeoutMs = LONG_POLL_TIMEOUT_MS }) {
|
|
141
|
+
async function getUpdates({ baseUrl, token, syncBuf, timeoutMs = LONG_POLL_TIMEOUT_MS, signal } = {}) {
|
|
140
142
|
try {
|
|
141
143
|
const raw = await postJson({
|
|
142
144
|
baseUrl,
|
|
@@ -144,6 +146,7 @@ async function getUpdates({ baseUrl, token, syncBuf, timeoutMs = LONG_POLL_TIMEO
|
|
|
144
146
|
payload: { get_updates_buf: syncBuf },
|
|
145
147
|
token,
|
|
146
148
|
timeoutMs,
|
|
149
|
+
signal,
|
|
147
150
|
})
|
|
148
151
|
return {
|
|
149
152
|
messages: Array.isArray(raw.msgs) ? raw.msgs : [],
|
|
@@ -315,6 +318,8 @@ export class WechatGateway extends Service {
|
|
|
315
318
|
this.syncBuf = ''
|
|
316
319
|
this.pollTask = null
|
|
317
320
|
this.stopPollingLocal = false
|
|
321
|
+
// 当前轮询循环的中止信号:stop/restart 时 abort 以立即中断 in-flight 长轮询
|
|
322
|
+
this._pollAbort = null
|
|
318
323
|
this.statusValue = 'idle'
|
|
319
324
|
this.contextTokens = new Map()
|
|
320
325
|
try {
|
|
@@ -388,6 +393,7 @@ export class WechatGateway extends Service {
|
|
|
388
393
|
|
|
389
394
|
async stop() {
|
|
390
395
|
this.stopPollingLocal = true
|
|
396
|
+
this._pollAbort?.abort()
|
|
391
397
|
const task = this.pollTask
|
|
392
398
|
this.pollTask = null
|
|
393
399
|
if (task) {
|
|
@@ -770,6 +776,8 @@ export class WechatGateway extends Service {
|
|
|
770
776
|
if (this._restartingPromise) return this._restartingPromise
|
|
771
777
|
this._restartingPromise = (async () => {
|
|
772
778
|
this.stopPollingLocal = true
|
|
779
|
+
// 立即中断旧循环的 in-flight 长轮询,避免等待最长 35s 才切换
|
|
780
|
+
this._pollAbort?.abort()
|
|
773
781
|
const previous = this.pollTask
|
|
774
782
|
this.pollTask = null
|
|
775
783
|
if (previous) {
|
|
@@ -799,6 +807,9 @@ export class WechatGateway extends Service {
|
|
|
799
807
|
}
|
|
800
808
|
|
|
801
809
|
async runPollLoop() {
|
|
810
|
+
// 每次轮询循环持有一个独立 abort:stop/restart 时中断 in-flight getUpdates(最长 35s)
|
|
811
|
+
const pollAbort = new AbortController()
|
|
812
|
+
this._pollAbort = pollAbort
|
|
802
813
|
let consecutiveFailures = 0
|
|
803
814
|
let timeoutMs = this.c.longPollTimeoutMs
|
|
804
815
|
let fatal = false
|
|
@@ -809,6 +820,7 @@ export class WechatGateway extends Service {
|
|
|
809
820
|
token: this.c.token,
|
|
810
821
|
syncBuf: this.syncBuf,
|
|
811
822
|
timeoutMs,
|
|
823
|
+
signal: pollAbort.signal,
|
|
812
824
|
})
|
|
813
825
|
if (this.stopPollingLocal) break
|
|
814
826
|
|
|
@@ -874,7 +886,8 @@ export class WechatGateway extends Service {
|
|
|
874
886
|
await sleep(backoff)
|
|
875
887
|
}
|
|
876
888
|
}
|
|
877
|
-
//
|
|
889
|
+
// 清理当前轮询 abort(避免悬挂引用);致命错误保持终态,普通停止回到 idle
|
|
890
|
+
if (this._pollAbort === pollAbort) this._pollAbort = null
|
|
878
891
|
if (!fatal) this.setStatus('idle')
|
|
879
892
|
}
|
|
880
893
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.4",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
5
|
+
"releaseNotes": "【v2.10.4】\n• 修复:全新安装(未设置任何密码)时远程访问被误锁,需输入『管理密码』(任意输入皆可通过)才可进入——现无密码时远程免锁直进,并明确提示先设置访问密码\n• 修复:Telegram 代理在 Node ≥ 24 下不生效(createConnection 构造参数被忽略,改为构造后赋值)\n• 修复:移动端点击 workspace 分组名误关抽屉、自建隧道 gzip 会话剥离失效、目录弹窗深色模式适配\n• 新功能:自建隧道 SSE 流式传输 + WebSocket Ping/Pong 保活;远程/移动端禁用下拉刷新防误触",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|