@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 +9 -0
- package/lib/feishu/gateway.js +1 -0
- package/lib/feishu/node.js +1 -0
- package/lib/platform/conversation-bridge.js +50 -71
- package/lib/qq/gateway.js +26 -2
- package/lib/qq/node.js +2 -0
- package/lib/telegram/gateway.js +3 -0
- package/lib/telegram/node.js +1 -0
- package/lib/wechat/gateway.js +2 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +2 -2
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 深度对齐与归档过滤修复
|
package/lib/feishu/gateway.js
CHANGED
|
@@ -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
|
package/lib/feishu/node.js
CHANGED
|
@@ -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
|
|
184
|
-
if (typeof text !== 'string' || !text.trim())
|
|
185
|
-
|
|
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
|
-
|
|
196
|
-
const
|
|
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
|
-
|
|
203
|
-
const
|
|
204
|
-
while ((m =
|
|
205
|
-
const
|
|
206
|
-
|
|
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
|
-
//
|
|
210
|
-
const
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
631
|
-
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${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
|
-
|
|
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
|
|
754
|
-
if (
|
|
727
|
+
const rawText = textOfAssistantMessage(event.data.message)
|
|
728
|
+
if (rawText.trim()) {
|
|
755
729
|
const cwd = getSessionCwd(session)
|
|
756
|
-
const
|
|
757
|
-
for (const f of
|
|
758
|
-
|
|
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 =
|
|
760
|
+
const uniqueFilesToSend = []
|
|
785
761
|
for (const f of rawFiles) {
|
|
786
762
|
const resolved = resolveFilePath(f, cwd)
|
|
787
|
-
if (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) {
|
|
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
|
-
|
|
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
|
|
package/lib/telegram/gateway.js
CHANGED
|
@@ -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) {
|
package/lib/telegram/node.js
CHANGED
|
@@ -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
|
package/lib/wechat/gateway.js
CHANGED
|
@@ -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)
|
package/lib/wechat/node.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.5",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.8.
|
|
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": {
|