@wenbin_wb/dsh-bridge 2.10.2 → 2.10.3

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.
@@ -0,0 +1,57 @@
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
+ const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
14
+
15
+ /**
16
+ * 剥离 session.list / session.history 响应中的 contextHeaders 与 contextTimeline。
17
+ * @param {string} pathname 请求路径(不含 query)
18
+ * @param {Buffer} bodyBuf 原始响应体
19
+ * @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length)
20
+ */
21
+ export function stripSessionProjections(pathname, bodyBuf) {
22
+ const cleanPath = String(pathname || '').split('?')[0];
23
+ if (!STRIP_PATHS.has(cleanPath) || !Buffer.isBuffer(bodyBuf)) {
24
+ return { body: bodyBuf, stripped: false };
25
+ }
26
+ try {
27
+ const json = JSON.parse(bodyBuf.toString('utf8'));
28
+ if (json?.result?.ok) {
29
+ const v = json.result.value;
30
+ let changed = false;
31
+ // session.list: items[].projections.values
32
+ if (v?.items) {
33
+ for (const item of v.items) {
34
+ const proj = item?.projections?.values;
35
+ if (proj && (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined)) {
36
+ delete proj.contextHeaders;
37
+ delete proj.contextTimeline;
38
+ changed = true;
39
+ }
40
+ }
41
+ }
42
+ // session.history: projections.values
43
+ if (v?.projections?.values) {
44
+ const proj = v.projections.values;
45
+ if (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined) {
46
+ delete proj.contextHeaders;
47
+ delete proj.contextTimeline;
48
+ changed = true;
49
+ }
50
+ }
51
+ if (changed) {
52
+ return { body: Buffer.from(JSON.stringify(json), 'utf8'), stripped: true };
53
+ }
54
+ }
55
+ } catch { /* 解析失败则原样发送 */ }
56
+ return { body: bodyBuf, stripped: false };
57
+ }
@@ -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
@@ -4,6 +4,7 @@ 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 { stripSessionProjections } from './session-strip.js';
7
8
 
8
9
  const gzipAsync = promisify(gzipCallback);
9
10
 
@@ -255,35 +256,13 @@ export class CustomTunnelClient {
255
256
  );
256
257
  let bodyBuf = Buffer.concat(chunks);
257
258
 
258
- // 剥离 contextHeaders contextTimeline — 这两个投影字段包含每轮完整
259
- // 系统提示+工具定义(4.8MB+),但没有任何客户端 UI 读取它们。
260
- // session.list: 数百会话 × contextHeaders = 60MB+
261
- // session.history: 单会话 contextHeaders = 4.8MB,占响应 80%+
262
- // 剥离后 session.history 从 1.2MB gzip 降到 ~150KB(8x 减少)
263
- if ((cleanPath === '/api/session.list' || cleanPath === '/api/session.history') && res.statusCode === 200) {
264
- try {
265
- const json = JSON.parse(bodyBuf.toString('utf8'));
266
- if (json?.result?.ok) {
267
- const v = json.result.value;
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 {} // 解析失败则原样发送
259
+ // 剥离 session.list / session.history 的 contextHeaders 投影(无 UI 读取的大字段)
260
+ if (res.statusCode === 200) {
261
+ const { body, stripped } = stripSessionProjections(path, bodyBuf);
262
+ if (stripped) {
263
+ bodyBuf = body;
264
+ respHeaders['content-length'] = String(bodyBuf.length);
265
+ }
287
266
  }
288
267
 
289
268
  // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
@@ -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: controller.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
- // 致命错误保持终态;普通停止回到 idle
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.2",
3
+ "version": "2.10.3",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.2】\n• 🆕 外部已部署隧道登记(issue #25):自行部署 cloudflared(Docker 等)可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理\n• 🐞 修复会话列表/历史加载问题:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)",
5
+ "releaseNotes": "【v2.10.3】\n• 兼容 DSH 原生端口 3080 直连(issue #28):本机直连不再被误判为远程而锁定,走代理与直连行为一致\n• 安全:AI [SEND_FILE] 仅允许发送工作目录内文件,拦截 .ssh/.credentials/.env 等敏感路径\n• 修复:代理响应头透传、QQ 流式重复发送、微信轮询竞态(iLink 403)、飞书群聊审批按钮误拦\n• 会话列表/历史大字段剥离覆盖局域网与 Cloudflare 等全部公网入口",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {