@wenbin_wb/dsh-bridge 2.8.4 → 2.8.5

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 CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.8.5] - 2026-08-27
8
+
9
+ ### 🎯 产物文件推送改为 AI 指令驱动 & 修复 IM 平台断开连接问题
10
+ - **🎯 显式文件发送协议**:由 AI 通过 `[SEND_FILE: <路径>]` 显式驱动文件直传,杜绝修改代码时的文件误发风暴;
11
+ - **🧹 聊天正文自动净化**:自动剔除聊天窗口中的文件发送指令,保持聊天气泡清爽;
12
+ - **🔌 修复 IM 断开连接失效**:修复 QQ 等网关断开连接时 WebSocket 事件悬挂问题,断开后彻底阻断入站消息与出站回复。
13
+
14
+ ---
15
+
7
16
  ## [v2.8.4] - 2026-08-26
8
17
 
9
18
  ### 🗂️ 会话列表 Web 端 1:1 深度对齐与归档过滤修复
@@ -205,6 +205,7 @@ export class FeishuGateway extends Service {
205
205
  // ---- 消息收发 ----
206
206
 
207
207
  async _handleMessageReceive(data) {
208
+ if (this._closing || this.status === 'offline') return
208
209
  if (!data?.message) return
209
210
  const { message, sender } = data
210
211
  const messageId = message.message_id
@@ -107,6 +107,7 @@ export class FeishuConversationNode extends ConversationBridge {
107
107
  }
108
108
 
109
109
  async _handleInbound(event) {
110
+ if (this.gateway?._closing || this.gateway?.status === 'offline') return
110
111
  const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
111
112
  this._lastPeer = { peerId, senderId, isGroup }
112
113
 
@@ -21,7 +21,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
21
  import { randomUUID } from 'node:crypto'
22
22
  import { statSync, existsSync, readFileSync } from 'node:fs'
23
23
  import { stat } from 'node:fs/promises'
24
- import { join, resolve, normalize, basename, isAbsolute } from 'node:path'
24
+ import { join, resolve, normalize, basename, extname, isAbsolute } from 'node:path'
25
25
  import { homedir } from 'node:os'
26
26
  import { isSafeWorkspacePath } from '../security/path-validator.js'
27
27
 
@@ -178,49 +178,41 @@ export function resolveFilePath(rawPath, cwd = process.cwd()) {
178
178
  }
179
179
 
180
180
  /**
181
- * 从模型助手回复正文或工具命令中提取所有真实存在于本地磁盘的文件绝对路径
181
+ * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
182
+ * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
183
+ * @param {string} text - 原始助手回复文本
184
+ * @param {string} cwd - 会话当前工作目录
185
+ * @returns {{ cleanText: string, files: string[] }}
182
186
  */
183
- export function extractFilePathsFromText(text, cwd = process.cwd()) {
184
- if (typeof text !== 'string' || !text.trim()) return []
185
- const found = new Set()
186
-
187
- // 1. Windows 绝对路径:C:\Users\...\file.ext 或 C:/Users/.../file.ext(支持中文、空格、特殊符号)
188
- const winAbsRegex = /[A-Za-z]:[\\/][^\s"'`<>|*?()]+?\.[A-Za-z0-9_.-]+/g
189
- let m
190
- while ((m = winAbsRegex.exec(text)) !== null) {
191
- const r = resolveFilePath(m[0], cwd)
192
- if (r) found.add(r)
187
+ export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
188
+ if (typeof text !== 'string' || !text.trim()) {
189
+ return { cleanText: text || '', files: [] }
193
190
  }
194
191
 
195
- // 2. POSIX 绝对路径:/home/.../file.ext 或 /tmp/.../file.ext
196
- const posixAbsRegex = /\/(?:[^\s"'`<>|*?()\/]+\/)+[^\s"'`<>|*?()\/]+\.[A-Za-z0-9_.-]+/g
197
- while ((m = posixAbsRegex.exec(text)) !== null) {
198
- const r = resolveFilePath(m[0], cwd)
199
- if (r) found.add(r)
200
- }
192
+ const files = []
193
+ const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
201
194
 
202
- // 3. Markdown 文件链接:[name](file:///path/to/file) 或 [name](path/to/file)
203
- const mdLinkRegex = /\[(?:[^\]]*)\]\((?:file:\/\/\/?)?([^)]+)\)/g
204
- while ((m = mdLinkRegex.exec(text)) !== null) {
205
- const r = resolveFilePath(m[1], cwd)
206
- if (r) found.add(r)
195
+ let m
196
+ const re = new RegExp(directiveRegex)
197
+ while ((m = re.exec(text)) !== null) {
198
+ const rawPath = m[1].trim()
199
+ const resolved = resolveFilePath(rawPath, cwd)
200
+ if (resolved && !files.includes(resolved)) {
201
+ files.push(resolved)
202
+ }
207
203
  }
208
204
 
209
- // 4. 关键词或 Emoji 引用的文件路径:📁 file.txt, 保存到:file.txt, 产物文件: ...
210
- const keywordRegex = /(?:📁|📄|📦|保存到[::\s]*|生成文件[::\s]*|文件路径[::\s]*|产物[::\s]*|输出文件[::\s]*|写入文件[::\s]*)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/g
211
- while ((m = keywordRegex.exec(text)) !== null) {
212
- const r = resolveFilePath(m[1], cwd)
213
- if (r) found.add(r)
214
- }
205
+ // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
206
+ const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
215
207
 
216
- // 5. Shell 终端常用输出命令参数:-Path "...", > "...", Out-File "...", Set-Content "..."
217
- const cmdRegex = /(?:-Path\s+|>\s*|Out-File\s+|Set-Content\s+|TargetFile["':\s]+|targetFile["':\s]+)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/gi
218
- while ((m = cmdRegex.exec(text)) !== null) {
219
- const r = resolveFilePath(m[1], cwd)
220
- if (r) found.add(r)
221
- }
208
+ return { cleanText, files }
209
+ }
222
210
 
223
- return Array.from(found)
211
+ /**
212
+ * 提取文本中的产物文件路径(基于显式指令)
213
+ */
214
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
215
+ return extractAndStripSendFileDirectives(text, cwd).files
224
216
  }
225
217
 
226
218
  // ---------------------------------------------------------------------------
@@ -627,8 +619,8 @@ export class ConversationBridge {
627
619
  return 'routed'
628
620
  }
629
621
 
630
- // 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
631
- const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
622
+ // 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
623
+ const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
632
624
 
633
625
  const messageValue = createUserMessage({
634
626
  content: [{ type: 'text', text: promptWithContext }],
@@ -643,6 +635,7 @@ export class ConversationBridge {
643
635
 
644
636
  /** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
645
637
  async sendText(text) {
638
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
646
639
  const peer = this.peerId || this.config.allowFrom?.[0]
647
640
  if (!peer) return
648
641
  const chunks = splitForIM(text, this.config.maxMessageChars)
@@ -711,6 +704,7 @@ export class ConversationBridge {
711
704
  stopHeartbeat(state)
712
705
  }
713
706
  if (session.id !== this.activeSessionId) return
707
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
714
708
 
715
709
  if (event.type === 'turn/start') {
716
710
  const turn = event.data?.turn
@@ -726,36 +720,21 @@ export class ConversationBridge {
726
720
  const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
727
721
 
728
722
  if (event.type === 'tool/call') {
729
- const cwd = getSessionCwd(session)
730
- let args = event.data?.parameters || event.data?.args || event.data?.arguments || {}
731
- if (typeof args === 'string') {
732
- try { args = JSON.parse(args) } catch {}
733
- }
734
- if (typeof args === 'object' && args !== null) {
735
- const possibleKeys = [
736
- 'TargetFile', 'targetFile', 'target_file', 'path', 'filePath', 'file',
737
- 'destination', 'out_file', 'output', 'ImageName', 'fileName', 'filename'
738
- ]
739
- for (const k of possibleKeys) {
740
- const val = args[k]
741
- if (val && typeof val === 'string') {
742
- const clean = val.trim().replace(/^["'`]|["'`]$/g, '').replace(/^file:\/\/\/?/, '')
743
- if (clean) state.createdFiles.add(clean)
744
- }
745
- }
746
- }
747
- const rawStr = JSON.stringify(event.data || {})
748
- const fromRaw = extractFilePathsFromText(rawStr, cwd)
749
- for (const f of fromRaw) state.createdFiles.add(f)
723
+ // 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
750
724
  return
751
725
  }
752
726
  if (event.type === 'assistant/message') {
753
- const text = textOfAssistantMessage(event.data.message)
754
- if (text.trim()) {
727
+ const rawText = textOfAssistantMessage(event.data.message)
728
+ if (rawText.trim()) {
755
729
  const cwd = getSessionCwd(session)
756
- const fromText = extractFilePathsFromText(text, cwd)
757
- for (const f of fromText) state.createdFiles.add(f)
758
- void this.sendText(text)
730
+ const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
731
+ for (const f of files) {
732
+ state.createdFiles.add(f)
733
+ }
734
+ // 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
735
+ if (cleanText) {
736
+ void this.sendText(cleanText)
737
+ }
759
738
  }
760
739
  return
761
740
  }
@@ -771,20 +750,19 @@ export class ConversationBridge {
771
750
  void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
772
751
  }
773
752
 
774
- // 如果本轮生成/记录了产物文件,下发清单通知并尝试直接上传文件至聊天窗口
753
+ // 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
775
754
  if (state.createdFiles && state.createdFiles.size > 0) {
776
755
  const rawFiles = Array.from(state.createdFiles)
777
756
  const cwd = getSessionCwd(session)
778
- const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
779
- void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
780
-
781
- // 如果平台支持 sendMediaFile,自动尝试直接发送真实存在的文件/图片到聊天窗口(严格按绝对路径去重)
782
757
  const targetPeer = this.peerId || this.config.allowFrom?.[0]
758
+
783
759
  if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
784
- const uniqueFilesToSend = new Set()
760
+ const uniqueFilesToSend = []
785
761
  for (const f of rawFiles) {
786
762
  const resolved = resolveFilePath(f, cwd)
787
- if (resolved) uniqueFilesToSend.add(resolved)
763
+ if (resolved && !uniqueFilesToSend.includes(resolved)) {
764
+ uniqueFilesToSend.push(resolved)
765
+ }
788
766
  }
789
767
  for (const resolved of uniqueFilesToSend) {
790
768
  try {
@@ -1558,6 +1536,7 @@ export const conversationBridgeHelpers = {
1558
1536
  textOfAssistantMessage,
1559
1537
  resolveFilePath,
1560
1538
  extractFilePathsFromText,
1539
+ extractAndStripSendFileDirectives,
1561
1540
  sessionsInDisplayOrder,
1562
1541
  listSessions,
1563
1542
  renderSessions,
package/lib/qq/gateway.js CHANGED
@@ -164,9 +164,16 @@ export class QqGateway extends Service {
164
164
  async stop() {
165
165
  this.stopRequested = true
166
166
  this.clearHeartbeat()
167
+ const finish = this._finishConnect
168
+ this._finishConnect = null
169
+ if (finish) {
170
+ try { finish(new Error('Stopped by user')) } catch {}
171
+ }
167
172
  const ws = this.ws
168
173
  this.ws = null
169
- if (ws) { try { ws.removeAllListeners(); ws.close(); ws.terminate() } catch {} }
174
+ if (ws) {
175
+ try { ws.close(); ws.terminate() } catch {}
176
+ }
170
177
  const task = this.loopTask
171
178
  this.loopTask = null
172
179
  if (task) await task.catch(() => {})
@@ -227,11 +234,13 @@ export class QqGateway extends Service {
227
234
  try {
228
235
  this.setStatus('starting')
229
236
  const token = await this.refreshAccessToken()
237
+ if (this.stopRequested) break
230
238
  // 官方「获取带分片 WSS 接入点」接口,返回网关地址与建议分片数
231
239
  // 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
232
240
  const gateway = this.config.gatewayUrl
233
241
  || ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
234
242
  || DEFAULT_GATEWAY
243
+ if (this.stopRequested) break
235
244
  await this.connect(gateway, token)
236
245
  backoffMs = this.config.reconnectDelayMs
237
246
  } catch (error) {
@@ -247,18 +256,32 @@ export class QqGateway extends Service {
247
256
 
248
257
  connect(url, token) {
249
258
  return new Promise((resolve, reject) => {
259
+ if (this.stopRequested) {
260
+ resolve()
261
+ return
262
+ }
250
263
  const ws = new WebSocket(url)
251
264
  this.ws = ws
252
265
  let settled = false
253
266
  const finish = (error) => {
254
267
  if (settled) return
255
268
  settled = true
269
+ this._finishConnect = null
256
270
  this.clearHeartbeat()
257
271
  if (this.ws === ws) this.ws = null
258
272
  error ? reject(error) : resolve()
259
273
  }
260
- ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform'))
274
+ this._finishConnect = finish
275
+
276
+ ws.on('open', () => {
277
+ if (this.stopRequested) {
278
+ finish()
279
+ return
280
+ }
281
+ this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform')
282
+ })
261
283
  ws.on('message', (raw) => {
284
+ if (this.stopRequested) return
262
285
  let payload
263
286
  try { payload = JSON.parse(String(raw)) } catch { return }
264
287
  void this.handlePayload(payload, token, ws).catch(finish)
@@ -269,6 +292,7 @@ export class QqGateway extends Service {
269
292
  }
270
293
 
271
294
  async handlePayload(payload, token, ws) {
295
+ if (this.stopRequested) return
272
296
  const op = Number(payload?.op)
273
297
  if (payload?.s != null) this.sequence = payload.s
274
298
  if (op === 10) {
package/lib/qq/node.js CHANGED
@@ -360,6 +360,7 @@ export class QqConversationNode extends ConversationBridge {
360
360
  // ---- 入站 ----
361
361
 
362
362
  async _handleInbound(event) {
363
+ if (this.gateway?.stopRequested) return
363
364
  const sender = String(event.senderId ?? '').trim()
364
365
  if (!sender) return
365
366
 
@@ -449,6 +450,7 @@ export class QqConversationNode extends ConversationBridge {
449
450
  // ---- 互动事件 ----
450
451
 
451
452
  async _handleInteraction(event) {
453
+ if (this.gateway?.stopRequested) return
452
454
  const sender = String(event.senderId ?? '').trim()
453
455
  if (!sender) return
454
456
 
@@ -365,8 +365,11 @@ export class TelegramGateway extends Service {
365
365
  consecutiveErrors = 0
366
366
  if (this.status !== 'online') this.setStatus('online')
367
367
 
368
+ if (this._stopPolling) break
369
+
368
370
  if (Array.isArray(updates) && updates.length > 0) {
369
371
  for (const update of updates) {
372
+ if (this._stopPolling) break
370
373
  if (this._seenUpdates.has(update.update_id)) continue
371
374
  this._seenUpdates.add(update.update_id)
372
375
  if (this._seenUpdates.size > 2000) {
@@ -95,6 +95,7 @@ export class TelegramConversationNode extends ConversationBridge {
95
95
  }
96
96
 
97
97
  async _handleInbound(event) {
98
+ if (this.gateway?._stopPolling) return
98
99
  const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
99
100
  this._lastPeer = { chatId, senderId, senderUsername, isGroup }
100
101
  const authId = isGroup ? chatId : senderId
@@ -845,8 +845,10 @@ export class WechatGateway extends Service {
845
845
  if (this.statusValue !== 'connected') {
846
846
  this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
847
847
  }
848
+ if (this.stopPollingLocal) break
848
849
  this.setStatus('connected')
849
850
  for (const message of batch.messages) {
851
+ if (this.stopPollingLocal) break
850
852
  this.dispatchInbound(message)
851
853
  }
852
854
  if (this.c.pollIdleDelayMs > 0) await sleep(this.c.pollIdleDelayMs)
@@ -67,6 +67,7 @@ export class WechatConversationNode extends ConversationBridge {
67
67
  // ---- 入站 ----
68
68
 
69
69
  async _handleInbound(message) {
70
+ if (this.ctx?.wechat?.stopPollingLocal) return
70
71
  const sender = String(message.from_user_id ?? '').trim()
71
72
  if (!sender) return
72
73
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.8.4",
3
+ "version": "2.8.5",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.8.4 会话列表 Web 1:1 深度对齐与归档过滤修复】\n• 🗂️ 严格按工作区账本对齐会话列表:修复会话列表展示冗余,严格以 DSH 官方工作区账本为准,自动过滤子代理派生会话、空白草稿与已归档会话,与 Web 端侧边栏 1:1 保持一致",
5
+ "releaseNotes": "【v2.8.5 产物文件推送改为 AI 指令驱动 & 修复 IM 平台断开连接问题】\n• 🎯 显式文件发送协议:由 AI 通过 [SEND_FILE: <路径>] 显式驱动文件直传,杜绝修改代码时的文件误发风暴\n• 🧹 聊天正文自动净化:自动剔除聊天窗口中的文件发送指令,保持聊天气泡清爽\n• 🔌 修复 IM 断开连接失效:修复 QQ 等网关断开连接时 WebSocket 事件悬挂问题,断开后彻底阻断入站消息与出站回复",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {