@wenbin_wb/dsh-bridge 2.8.0 → 2.8.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.
- package/CHANGELOG.md +37 -0
- package/client/client.js +1 -1
- package/client/index.js +1 -1
- package/lib/feishu/index.js +4 -1
- package/lib/index.js +8 -2
- package/lib/platform/conversation-bridge.js +132 -23
- package/lib/qq/gateway.js +20 -7
- package/lib/qq/index.js +4 -1
- package/lib/telegram/index.js +4 -1
- package/lib/wechat/gateway.js +67 -20
- package/lib/wechat/index.js +8 -1
- package/lib/wechat/media.js +3 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [v2.8.3] - 2026-08-26
|
|
8
|
+
|
|
9
|
+
### 📱 微信与全平台 IM 文件收发链路全面修复与深度兼容
|
|
10
|
+
- **📱 微信 iLink 官方媒体传输协议完全对齐**:
|
|
11
|
+
- 修复 `getuploadurl` 媒体类型映射,确保文件上传请求采用标准 `UploadMediaType.FILE = 3`,微信服务端正确识别文件类型与大小;
|
|
12
|
+
- 严格采用官方规范对 `CDNMedia.aes_key` 进行 `base64(hex_string)` 编码,彻底解决微信移动端/电脑端解密失败报「文件已过期/下载失败/0B」的深层问题;
|
|
13
|
+
- 精简规范 `file_item` 结构并对齐 `encrypt_type: 1`。
|
|
14
|
+
- **🤖 QQ 机器人富媒体文件直传与显示修复**:
|
|
15
|
+
- 支持 `file_type: 4` 通用文件传输,适配 QQ OpenAPI v2 文件格式校验(解决此前类型不符导致的 850019 拦截);
|
|
16
|
+
- 显式传递 `file_name`,彻底消除 QQ 聊天界面将文档显示为「未命名」的问题;
|
|
17
|
+
- 自动识别群聊与单聊端点路由(`/v2/users/{openid}/files` 与 `/v2/groups/{group_openid}/files`)。
|
|
18
|
+
- **🌐 微信会话凭证(context_token)持久化机制**:
|
|
19
|
+
- 捕获微信入站消息时自动将实时安全会话凭证持久化落盘(`wechat-context-tokens.json`),并在服务启动与重启时自动载入,彻底避免凭证在重启后丢失;
|
|
20
|
+
- 完善 `ret: -2` / `prepare failed` 过期感知,当用户重新发消息时自动刷新续期。
|
|
21
|
+
- **📢 多平台多端共享会话广播**:
|
|
22
|
+
- 优化同一工作区多端共享会话的广播逻辑,当任一客户端(飞书/QQ/Telegram/Web)触发生成文件时,各平台桥接器自动回退至已授权用户,实现产物文件多端同步直传。
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## [v2.8.2] - 2026-08-26
|
|
27
|
+
|
|
28
|
+
### 🚀 微信/IM 本地生成文件全域探测与直接推送
|
|
29
|
+
- **📁 全域文件智能探测与提取**:全面支持从 PowerShell/Bash 终端脚本(`New-Item`、`Set-Content`、`Out-File`、`>`)、工具调用参数及回复文本中自动探测提取生成的文件,并结合会话工作区目录自动解析为绝对路径;
|
|
30
|
+
- **🌐 微信官方 CDN 媒体上传链路修复**:严格对齐腾讯 iLink 官方 CDN `/upload` 路由子路径与 `encrypted_query_param` / `filekey` 鉴权参数,彻底解决此前上传失败的问题;
|
|
31
|
+
- **💬 微信与各 IM 原生文件气泡推送**:任务结束自动将 txt/pdf/docx/xlsx/zip/png 等产物推送到聊天界面,支持直接下载查看与转发。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [v2.8.1] - 2026-08-26
|
|
36
|
+
|
|
37
|
+
### 🛡️ 微信/IM 消息分块保护与流式体验修复
|
|
38
|
+
- **🛡️ 修复消息被切成 20 字符碎片的问题**:排查并修复历史配置中 `maxMessageChars` 异常写入 `20` 导致的聊天框逐句高频切块分发问题;
|
|
39
|
+
- **⚙️ 全链路字符数下限安全防御**:在会话桥基类、各平台 Service 配置接口及启动加载链路中增加强制安全下限(`< 200` 自动回退为平台标准默认容量),彻底杜绝异常分块;
|
|
40
|
+
- **💬 微信与各 IM 平台体验恢复**:恢复完整 Markdown 回复、段落与代码块的原生一次性聚合推送。
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
7
44
|
## [v2.8.0] - 2026-08-25
|
|
8
45
|
|
|
9
46
|
### 📱 移动端体验革新、远程工作区管理与全面安全加固
|
package/client/client.js
CHANGED
|
@@ -1173,7 +1173,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
1173
1173
|
setCfgDraft({
|
|
1174
1174
|
digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
|
|
1175
1175
|
approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
|
|
1176
|
-
maxMessageChars: String(platform.config.maxMessageChars ?? (platformId === "telegram" ? 4096 : 2e3)),
|
|
1176
|
+
maxMessageChars: String((platform.config.maxMessageChars >= 500 ? platform.config.maxMessageChars : null) ?? (platformId === "telegram" ? 4096 : 2e3)),
|
|
1177
1177
|
sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
|
|
1178
1178
|
appId: platform.config.appId ?? "",
|
|
1179
1179
|
// Secret 不由后端回传;空值表示沿用已保存密钥
|
package/client/index.js
CHANGED
|
@@ -1001,7 +1001,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
1001
1001
|
setCfgDraft({
|
|
1002
1002
|
digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
|
|
1003
1003
|
approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
|
|
1004
|
-
maxMessageChars: String(platform.config.maxMessageChars
|
|
1004
|
+
maxMessageChars: String((platform.config.maxMessageChars >= 500 ? platform.config.maxMessageChars : null) ?? (platformId === 'telegram' ? 4096 : 2000)),
|
|
1005
1005
|
sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
|
|
1006
1006
|
appId: platform.config.appId ?? '',
|
|
1007
1007
|
// Secret 不由后端回传;空值表示沿用已保存密钥
|
package/lib/feishu/index.js
CHANGED
|
@@ -181,7 +181,10 @@ export class FeishuService extends Platform {
|
|
|
181
181
|
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, appSecret, domain } = {}) {
|
|
182
182
|
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
183
183
|
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
184
|
-
if (maxMessageChars != null)
|
|
184
|
+
if (maxMessageChars != null) {
|
|
185
|
+
const val = Number(maxMessageChars)
|
|
186
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : 2000
|
|
187
|
+
}
|
|
185
188
|
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
186
189
|
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
187
190
|
this.gateway.updateConfig({
|
package/lib/index.js
CHANGED
|
@@ -1440,7 +1440,10 @@ function apply(ctx, config = {}) {
|
|
|
1440
1440
|
wechat.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1441
1441
|
if (cfg.digestIntervalSec != null) wechat.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
|
|
1442
1442
|
if (cfg.approvalTimeoutSec != null) wechat.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
|
|
1443
|
-
if (cfg.maxMessageChars != null)
|
|
1443
|
+
if (cfg.maxMessageChars != null) {
|
|
1444
|
+
const val = Number(cfg.maxMessageChars);
|
|
1445
|
+
wechat.node.config.maxMessageChars = (val >= 200) ? val : 2000;
|
|
1446
|
+
}
|
|
1444
1447
|
if (cfg.sendChunkDelayMs != null) wechat.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
|
|
1445
1448
|
|
|
1446
1449
|
wechat.node._restoringConfig = (async () => {
|
|
@@ -1552,7 +1555,10 @@ function apply(ctx, config = {}) {
|
|
|
1552
1555
|
telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
1553
1556
|
if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
|
|
1554
1557
|
if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
|
|
1555
|
-
if (cfg.maxMessageChars != null)
|
|
1558
|
+
if (cfg.maxMessageChars != null) {
|
|
1559
|
+
const val = Number(cfg.maxMessageChars);
|
|
1560
|
+
telegram.node.config.maxMessageChars = (val >= 200) ? val : 4096;
|
|
1561
|
+
}
|
|
1556
1562
|
if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
|
|
1557
1563
|
|
|
1558
1564
|
telegram.node._restoringConfig = (async () => {
|
|
@@ -21,7 +21,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
22
22
|
import { statSync } from 'node:fs'
|
|
23
23
|
import { stat } from 'node:fs/promises'
|
|
24
|
-
import { resolve, normalize, basename } from 'node:path'
|
|
24
|
+
import { resolve, normalize, basename, isAbsolute } from 'node:path'
|
|
25
25
|
import { isSafeWorkspacePath } from '../security/path-validator.js'
|
|
26
26
|
|
|
27
27
|
// 纯文本标记(用户偏好不用 emoji)
|
|
@@ -155,6 +155,73 @@ export function textOfAssistantMessage(message) {
|
|
|
155
155
|
.join('\n')
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
|
|
160
|
+
*/
|
|
161
|
+
export function resolveFilePath(rawPath, cwd = process.cwd()) {
|
|
162
|
+
if (typeof rawPath !== 'string') return null
|
|
163
|
+
let p = rawPath.trim()
|
|
164
|
+
.replace(/^["'`]|["'`]$/g, '')
|
|
165
|
+
.replace(/^file:\/\/\/?/, '')
|
|
166
|
+
.replace(/^[📁📄📦\s]+/, '')
|
|
167
|
+
if (!p) return null
|
|
168
|
+
// 排除 HTTP/HTTPS 网址
|
|
169
|
+
if (/^https?:\/\//i.test(p)) return null
|
|
170
|
+
const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
|
|
171
|
+
try {
|
|
172
|
+
if (statSync(resolved).isFile()) {
|
|
173
|
+
return resolved
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 从模型助手回复正文或工具命令中提取所有真实存在于本地磁盘的文件绝对路径
|
|
181
|
+
*/
|
|
182
|
+
export function extractFilePathsFromText(text, cwd = process.cwd()) {
|
|
183
|
+
if (typeof text !== 'string' || !text.trim()) return []
|
|
184
|
+
const found = new Set()
|
|
185
|
+
|
|
186
|
+
// 1. Windows 绝对路径:C:\Users\...\file.ext 或 C:/Users/.../file.ext(支持中文、空格、特殊符号)
|
|
187
|
+
const winAbsRegex = /[A-Za-z]:[\\/][^\s"'`<>|*?()]+?\.[A-Za-z0-9_.-]+/g
|
|
188
|
+
let m
|
|
189
|
+
while ((m = winAbsRegex.exec(text)) !== null) {
|
|
190
|
+
const r = resolveFilePath(m[0], cwd)
|
|
191
|
+
if (r) found.add(r)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. POSIX 绝对路径:/home/.../file.ext 或 /tmp/.../file.ext
|
|
195
|
+
const posixAbsRegex = /\/(?:[^\s"'`<>|*?()\/]+\/)+[^\s"'`<>|*?()\/]+\.[A-Za-z0-9_.-]+/g
|
|
196
|
+
while ((m = posixAbsRegex.exec(text)) !== null) {
|
|
197
|
+
const r = resolveFilePath(m[0], cwd)
|
|
198
|
+
if (r) found.add(r)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Markdown 文件链接:[name](file:///path/to/file) 或 [name](path/to/file)
|
|
202
|
+
const mdLinkRegex = /\[(?:[^\]]*)\]\((?:file:\/\/\/?)?([^)]+)\)/g
|
|
203
|
+
while ((m = mdLinkRegex.exec(text)) !== null) {
|
|
204
|
+
const r = resolveFilePath(m[1], cwd)
|
|
205
|
+
if (r) found.add(r)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. 关键词或 Emoji 引用的文件路径:📁 file.txt, 保存到:file.txt, 产物文件: ...
|
|
209
|
+
const keywordRegex = /(?:📁|📄|📦|保存到[::\s]*|生成文件[::\s]*|文件路径[::\s]*|产物[::\s]*|输出文件[::\s]*|写入文件[::\s]*)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/g
|
|
210
|
+
while ((m = keywordRegex.exec(text)) !== null) {
|
|
211
|
+
const r = resolveFilePath(m[1], cwd)
|
|
212
|
+
if (r) found.add(r)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 5. Shell 终端常用输出命令参数:-Path "...", > "...", Out-File "...", Set-Content "..."
|
|
216
|
+
const cmdRegex = /(?:-Path\s+|>\s*|Out-File\s+|Set-Content\s+|TargetFile["':\s]+|targetFile["':\s]+)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/gi
|
|
217
|
+
while ((m = cmdRegex.exec(text)) !== null) {
|
|
218
|
+
const r = resolveFilePath(m[1], cwd)
|
|
219
|
+
if (r) found.add(r)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return Array.from(found)
|
|
223
|
+
}
|
|
224
|
+
|
|
158
225
|
// ---------------------------------------------------------------------------
|
|
159
226
|
// digest 摘要
|
|
160
227
|
// ---------------------------------------------------------------------------
|
|
@@ -220,11 +287,13 @@ export class ConversationBridge {
|
|
|
220
287
|
this.onActiveSessionChange = onActiveSessionChange
|
|
221
288
|
|
|
222
289
|
const maxChars = platform.capabilities?.maxMessageChars ?? 2000
|
|
290
|
+
const rawMax = Number(config.maxMessageChars)
|
|
291
|
+
const safeMaxChars = (Number.isFinite(rawMax) && rawMax > 0) ? rawMax : maxChars
|
|
223
292
|
this.config = {
|
|
224
293
|
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
225
294
|
digestIntervalSec: config.digestIntervalSec ?? 300,
|
|
226
295
|
approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
|
|
227
|
-
maxMessageChars:
|
|
296
|
+
maxMessageChars: safeMaxChars,
|
|
228
297
|
sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
|
|
229
298
|
cwd: config.cwd,
|
|
230
299
|
agentPreset: config.agentPreset,
|
|
@@ -557,8 +626,11 @@ export class ConversationBridge {
|
|
|
557
626
|
return 'routed'
|
|
558
627
|
}
|
|
559
628
|
|
|
629
|
+
// 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
|
|
630
|
+
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
|
|
631
|
+
|
|
560
632
|
const messageValue = createUserMessage({
|
|
561
|
-
content: [{ type: 'text', text:
|
|
633
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
562
634
|
source: { kind: 'user' },
|
|
563
635
|
})
|
|
564
636
|
agent.followup(messageValue)
|
|
@@ -570,7 +642,7 @@ export class ConversationBridge {
|
|
|
570
642
|
|
|
571
643
|
/** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
|
|
572
644
|
async sendText(text) {
|
|
573
|
-
const peer = this.peerId
|
|
645
|
+
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
574
646
|
if (!peer) return
|
|
575
647
|
const chunks = splitForIM(text, this.config.maxMessageChars)
|
|
576
648
|
if (chunks.length === 0) return
|
|
@@ -593,8 +665,9 @@ export class ConversationBridge {
|
|
|
593
665
|
|
|
594
666
|
/** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
|
|
595
667
|
async sendTyping(state) {
|
|
596
|
-
|
|
597
|
-
|
|
668
|
+
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
669
|
+
if (!this.platform?.sendTyping || peer == null) return
|
|
670
|
+
return this.platform.sendTyping(peer, state)
|
|
598
671
|
}
|
|
599
672
|
|
|
600
673
|
// ---- 出站事件绑定 ----
|
|
@@ -628,7 +701,7 @@ export class ConversationBridge {
|
|
|
628
701
|
}, this.config.digestIntervalSec * 1000)
|
|
629
702
|
if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
|
|
630
703
|
}
|
|
631
|
-
const onEvent = (session, event) => {
|
|
704
|
+
const onEvent = async (session, event) => {
|
|
632
705
|
const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
|
|
633
706
|
this._digestState.set(session.id, state)
|
|
634
707
|
|
|
@@ -649,17 +722,40 @@ export class ConversationBridge {
|
|
|
649
722
|
startHeartbeat(session, state)
|
|
650
723
|
return
|
|
651
724
|
}
|
|
725
|
+
const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
|
|
726
|
+
|
|
652
727
|
if (event.type === 'tool/call') {
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
if (
|
|
656
|
-
|
|
728
|
+
const cwd = getSessionCwd(session)
|
|
729
|
+
let args = event.data?.parameters || event.data?.args || event.data?.arguments || {}
|
|
730
|
+
if (typeof args === 'string') {
|
|
731
|
+
try { args = JSON.parse(args) } catch {}
|
|
657
732
|
}
|
|
733
|
+
if (typeof args === 'object' && args !== null) {
|
|
734
|
+
const possibleKeys = [
|
|
735
|
+
'TargetFile', 'targetFile', 'target_file', 'path', 'filePath', 'file',
|
|
736
|
+
'destination', 'out_file', 'output', 'ImageName', 'fileName', 'filename'
|
|
737
|
+
]
|
|
738
|
+
for (const k of possibleKeys) {
|
|
739
|
+
const val = args[k]
|
|
740
|
+
if (val && typeof val === 'string') {
|
|
741
|
+
const clean = val.trim().replace(/^["'`]|["'`]$/g, '').replace(/^file:\/\/\/?/, '')
|
|
742
|
+
if (clean) state.createdFiles.add(clean)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const rawStr = JSON.stringify(event.data || {})
|
|
747
|
+
const fromRaw = extractFilePathsFromText(rawStr, cwd)
|
|
748
|
+
for (const f of fromRaw) state.createdFiles.add(f)
|
|
658
749
|
return
|
|
659
750
|
}
|
|
660
751
|
if (event.type === 'assistant/message') {
|
|
661
752
|
const text = textOfAssistantMessage(event.data.message)
|
|
662
|
-
if (text.trim())
|
|
753
|
+
if (text.trim()) {
|
|
754
|
+
const cwd = getSessionCwd(session)
|
|
755
|
+
const fromText = extractFilePathsFromText(text, cwd)
|
|
756
|
+
for (const f of fromText) state.createdFiles.add(f)
|
|
757
|
+
void this.sendText(text)
|
|
758
|
+
}
|
|
663
759
|
return
|
|
664
760
|
}
|
|
665
761
|
if (event.type === 'turn/end') {
|
|
@@ -673,29 +769,40 @@ export class ConversationBridge {
|
|
|
673
769
|
} else if (reason.kind === 'max-tokens') {
|
|
674
770
|
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
|
|
675
771
|
}
|
|
676
|
-
|
|
772
|
+
|
|
773
|
+
// 如果本轮生成/记录了产物文件,下发清单通知并尝试直接上传文件至聊天窗口
|
|
677
774
|
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
678
|
-
const
|
|
679
|
-
const
|
|
775
|
+
const rawFiles = Array.from(state.createdFiles)
|
|
776
|
+
const cwd = getSessionCwd(session)
|
|
777
|
+
const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
|
|
680
778
|
void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
|
|
681
779
|
|
|
682
|
-
// 如果平台支持 sendMediaFile
|
|
683
|
-
|
|
684
|
-
|
|
780
|
+
// 如果平台支持 sendMediaFile,自动尝试直接发送真实存在的文件/图片到聊天窗口(严格按绝对路径去重)
|
|
781
|
+
const targetPeer = this.peerId || this.config.allowFrom?.[0]
|
|
782
|
+
if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
|
|
783
|
+
const uniqueFilesToSend = new Set()
|
|
784
|
+
for (const f of rawFiles) {
|
|
785
|
+
const resolved = resolveFilePath(f, cwd)
|
|
786
|
+
if (resolved) uniqueFilesToSend.add(resolved)
|
|
787
|
+
}
|
|
788
|
+
for (const resolved of uniqueFilesToSend) {
|
|
685
789
|
try {
|
|
686
|
-
|
|
687
|
-
|
|
790
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
|
|
791
|
+
const res = await this.platform.sendMediaFile(targetPeer, resolved)
|
|
792
|
+
if (res && res.success === false) {
|
|
793
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
|
|
688
794
|
}
|
|
689
|
-
} catch {
|
|
795
|
+
} catch (err) {
|
|
796
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
|
|
797
|
+
}
|
|
690
798
|
}
|
|
691
799
|
}
|
|
692
|
-
|
|
693
800
|
state.createdFiles.clear()
|
|
694
801
|
}
|
|
695
802
|
return
|
|
696
803
|
}
|
|
697
804
|
}
|
|
698
|
-
const listener = (session, event) => onEvent(session, event)
|
|
805
|
+
const listener = (session, event) => { void onEvent(session, event) }
|
|
699
806
|
const disposer = this.ctx.on('session/event', listener)
|
|
700
807
|
this.disposers.push(() => {
|
|
701
808
|
for (const state of digestState.values()) stopHeartbeat(state)
|
|
@@ -1232,6 +1339,8 @@ export const conversationBridgeHelpers = {
|
|
|
1232
1339
|
splitForIM,
|
|
1233
1340
|
digestLine,
|
|
1234
1341
|
textOfAssistantMessage,
|
|
1342
|
+
resolveFilePath,
|
|
1343
|
+
extractFilePathsFromText,
|
|
1235
1344
|
sessionsInDisplayOrder,
|
|
1236
1345
|
listSessions,
|
|
1237
1346
|
listWorkspaces,
|
package/lib/qq/gateway.js
CHANGED
|
@@ -548,18 +548,31 @@ export class QqGateway extends Service {
|
|
|
548
548
|
if (!fs.existsSync(filePath)) return null
|
|
549
549
|
const ext = path.extname(filePath).toLowerCase()
|
|
550
550
|
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
|
|
551
|
-
const
|
|
551
|
+
const isVideo = ['.mp4', '.mov', '.webm', '.avi'].includes(ext)
|
|
552
|
+
const isVoice = ['.silk', '.amr', '.wav', '.mp3'].includes(ext)
|
|
553
|
+
let fileType = 4 // 默认文件类型(txt, pdf, zip, docx, etc.)
|
|
554
|
+
if (isImage) fileType = 1
|
|
555
|
+
else if (isVideo) fileType = 2
|
|
556
|
+
else if (isVoice) fileType = 3
|
|
557
|
+
|
|
558
|
+
const scope = opts.scope || (peerId.startsWith('g_') || peerId.startsWith('group_') ? 'group' : 'user')
|
|
552
559
|
try {
|
|
560
|
+
const fileName = path.basename(filePath)
|
|
553
561
|
const buf = await fs.promises.readFile(filePath)
|
|
554
562
|
const base64Data = buf.toString('base64')
|
|
555
|
-
const ep = this.endpoint(peerId,
|
|
563
|
+
const ep = this.endpoint(peerId, scope, 'files')
|
|
564
|
+
const body = {
|
|
565
|
+
file_type: fileType,
|
|
566
|
+
file_data: base64Data,
|
|
567
|
+
file_name: fileName,
|
|
568
|
+
srv_send_msg: true,
|
|
569
|
+
}
|
|
570
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
571
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
572
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
556
573
|
const res = await this.api(ep, {
|
|
557
574
|
method: 'POST',
|
|
558
|
-
body
|
|
559
|
-
file_type: fileType,
|
|
560
|
-
file_data: base64Data,
|
|
561
|
-
srv_send_msg: true,
|
|
562
|
-
},
|
|
575
|
+
body,
|
|
563
576
|
})
|
|
564
577
|
return res
|
|
565
578
|
} catch (err) {
|
package/lib/qq/index.js
CHANGED
|
@@ -268,7 +268,10 @@ export class QqService extends Platform {
|
|
|
268
268
|
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, clientSecret } = {}) {
|
|
269
269
|
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
270
270
|
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
271
|
-
if (maxMessageChars != null)
|
|
271
|
+
if (maxMessageChars != null) {
|
|
272
|
+
const val = Number(maxMessageChars)
|
|
273
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : 2000
|
|
274
|
+
}
|
|
272
275
|
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
273
276
|
if (appId !== undefined || clientSecret !== undefined) {
|
|
274
277
|
this.gateway.setCredentials({
|
package/lib/telegram/index.js
CHANGED
|
@@ -175,7 +175,10 @@ export class TelegramService extends Platform {
|
|
|
175
175
|
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, botToken, proxy } = {}) {
|
|
176
176
|
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
177
177
|
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
178
|
-
if (maxMessageChars != null)
|
|
178
|
+
if (maxMessageChars != null) {
|
|
179
|
+
const val = Number(maxMessageChars)
|
|
180
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : 4096
|
|
181
|
+
}
|
|
179
182
|
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
180
183
|
|
|
181
184
|
if (botToken !== undefined || proxy !== undefined) {
|
package/lib/wechat/gateway.js
CHANGED
|
@@ -57,10 +57,11 @@ const MESSAGE_DEDUP_TTL_SECONDS = 300
|
|
|
57
57
|
/** 默认 CDN 白名单(SSRF 防护)。v0.2 媒体用到,先保留常量。 */
|
|
58
58
|
const DEFAULT_CDN_ALLOWLIST = ['novac2c.cdn.weixin.qq.com']
|
|
59
59
|
|
|
60
|
-
/** ret/errcode=-2 + "unknown error"
|
|
60
|
+
/** ret/errcode=-2 + "unknown error" 或 "prepare failed" 表示会话/凭证过期(而非限流)。 */
|
|
61
61
|
function isStaleSessionRet(ret, errcode, errmsg) {
|
|
62
62
|
if (ret !== RATE_LIMIT_ERRCODE && errcode !== RATE_LIMIT_ERRCODE) return false
|
|
63
|
-
|
|
63
|
+
const msg = String(errmsg ?? '').toLowerCase()
|
|
64
|
+
return msg === 'unknown error' || msg === 'prepare failed' || msg.includes('expired') || msg.includes('token')
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// ---------------------------------------------------------------------------
|
|
@@ -323,6 +324,15 @@ export class WechatGateway extends Service {
|
|
|
323
324
|
this.stopPollingLocal = false
|
|
324
325
|
this.statusValue = 'idle'
|
|
325
326
|
this.contextTokens = new Map()
|
|
327
|
+
try {
|
|
328
|
+
const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
|
|
329
|
+
if (fs.existsSync(tokenFile)) {
|
|
330
|
+
const data = JSON.parse(fs.readFileSync(tokenFile, 'utf8'))
|
|
331
|
+
for (const [k, v] of Object.entries(data)) {
|
|
332
|
+
if (v) this.contextTokens.set(k, String(v))
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch {}
|
|
326
336
|
this.dedup = new Map()
|
|
327
337
|
this.typingTickets = new Map()
|
|
328
338
|
this.rateLimitHits = []
|
|
@@ -507,13 +517,20 @@ export class WechatGateway extends Service {
|
|
|
507
517
|
*/
|
|
508
518
|
async getUploadUrl({ to, mediaType, filekey, rawSize, rawFileMd5, fileSize, aesKeyHex }) {
|
|
509
519
|
if (!this.configured) throw new Error('not configured')
|
|
520
|
+
// 映射 MessageItemType 到 UploadMediaType (IMAGE:1, VIDEO:2, FILE:3, VOICE:4)
|
|
521
|
+
let uploadMediaType = mediaType
|
|
522
|
+
if (mediaType === 2) uploadMediaType = 1 // IMAGE
|
|
523
|
+
else if (mediaType === 4) uploadMediaType = 3 // FILE
|
|
524
|
+
else if (mediaType === 3) uploadMediaType = 4 // VOICE
|
|
525
|
+
else if (mediaType === 5) uploadMediaType = 2 // VIDEO
|
|
526
|
+
|
|
510
527
|
const resp = await postJson({
|
|
511
528
|
baseUrl: this.c.baseUrl,
|
|
512
529
|
endpoint: 'ilink/bot/getuploadurl',
|
|
513
530
|
token: this.c.token,
|
|
514
531
|
payload: {
|
|
515
532
|
filekey,
|
|
516
|
-
media_type:
|
|
533
|
+
media_type: uploadMediaType,
|
|
517
534
|
to_user_id: to,
|
|
518
535
|
rawsize: rawSize,
|
|
519
536
|
rawfilemd5: rawFileMd5,
|
|
@@ -548,6 +565,7 @@ export class WechatGateway extends Service {
|
|
|
548
565
|
mediaType,
|
|
549
566
|
encryptedQueryParam,
|
|
550
567
|
aesKeyBase64,
|
|
568
|
+
aesKeyHex,
|
|
551
569
|
ciphertextSize,
|
|
552
570
|
plaintextSize,
|
|
553
571
|
filename,
|
|
@@ -557,15 +575,22 @@ export class WechatGateway extends Service {
|
|
|
557
575
|
if (!this.configured) return { success: false, error: 'not configured' }
|
|
558
576
|
const contextToken = this.contextTokens.get(to)
|
|
559
577
|
const id = clientId ?? `dsh-bridge-wechat-${randomId()}`
|
|
560
|
-
|
|
561
|
-
|
|
578
|
+
const hexKey = aesKeyHex || (aesKeyBase64 ? Buffer.from(aesKeyBase64, 'base64').toString('hex') : '')
|
|
579
|
+
|
|
580
|
+
// 构建媒体项(全字段兼容各端微信客户端解析)
|
|
562
581
|
let item
|
|
563
582
|
if (mediaType === 2) { // 图片
|
|
564
583
|
item = {
|
|
565
584
|
type: 2,
|
|
566
585
|
image_item: {
|
|
567
|
-
|
|
568
|
-
|
|
586
|
+
media: {
|
|
587
|
+
encrypt_query_param: encryptedQueryParam,
|
|
588
|
+
aes_key: aesKeyBase64,
|
|
589
|
+
aeskey: hexKey,
|
|
590
|
+
encrypt_type: 1,
|
|
591
|
+
},
|
|
592
|
+
aeskey: hexKey,
|
|
593
|
+
aes_key: aesKeyBase64,
|
|
569
594
|
filesize: ciphertextSize,
|
|
570
595
|
rawsize: plaintextSize,
|
|
571
596
|
rawfilemd5: rawFileMd5,
|
|
@@ -575,23 +600,27 @@ export class WechatGateway extends Service {
|
|
|
575
600
|
item = {
|
|
576
601
|
type: 4,
|
|
577
602
|
file_item: {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
603
|
+
file_name: filename,
|
|
604
|
+
len: String(plaintextSize),
|
|
605
|
+
media: {
|
|
606
|
+
encrypt_query_param: encryptedQueryParam,
|
|
607
|
+
aes_key: aesKeyBase64,
|
|
608
|
+
encrypt_type: 1,
|
|
609
|
+
},
|
|
584
610
|
},
|
|
585
611
|
}
|
|
586
612
|
} else if (mediaType === 3) { // 语音
|
|
587
613
|
item = {
|
|
588
614
|
type: 3,
|
|
589
615
|
voice_item: {
|
|
590
|
-
|
|
616
|
+
media: {
|
|
617
|
+
encrypt_query_param: encryptedQueryParam,
|
|
618
|
+
aes_key: aesKeyBase64,
|
|
619
|
+
aeskey: hexKey,
|
|
620
|
+
encrypt_type: 0,
|
|
621
|
+
},
|
|
622
|
+
aeskey: hexKey,
|
|
591
623
|
aes_key: aesKeyBase64,
|
|
592
|
-
filesize: ciphertextSize,
|
|
593
|
-
rawsize: plaintextSize,
|
|
594
|
-
rawfilemd5: rawFileMd5,
|
|
595
624
|
encode_type: 6, // silk
|
|
596
625
|
sample_rate: 24000,
|
|
597
626
|
bits_per_sample: 16,
|
|
@@ -601,7 +630,13 @@ export class WechatGateway extends Service {
|
|
|
601
630
|
item = {
|
|
602
631
|
type: 5,
|
|
603
632
|
video_item: {
|
|
604
|
-
|
|
633
|
+
media: {
|
|
634
|
+
encrypt_query_param: encryptedQueryParam,
|
|
635
|
+
aes_key: aesKeyBase64,
|
|
636
|
+
aeskey: hexKey,
|
|
637
|
+
encrypt_type: 1,
|
|
638
|
+
},
|
|
639
|
+
aeskey: hexKey,
|
|
605
640
|
aes_key: aesKeyBase64,
|
|
606
641
|
filesize: ciphertextSize,
|
|
607
642
|
rawsize: plaintextSize,
|
|
@@ -668,9 +703,11 @@ export class WechatGateway extends Service {
|
|
|
668
703
|
aesKeyHex,
|
|
669
704
|
})
|
|
670
705
|
|
|
706
|
+
const uploadUrl = uploadInfo.uploadFullUrl || `${this.c.cdnBaseUrl.replace(/\/+$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadInfo.uploadParam)}&filekey=${encodeURIComponent(filekey)}`
|
|
707
|
+
|
|
671
708
|
const encryptedParam = await uploadMedia({
|
|
672
709
|
plaintext: buf,
|
|
673
|
-
uploadUrl
|
|
710
|
+
uploadUrl,
|
|
674
711
|
aesKey,
|
|
675
712
|
})
|
|
676
713
|
|
|
@@ -679,6 +716,7 @@ export class WechatGateway extends Service {
|
|
|
679
716
|
mediaType,
|
|
680
717
|
encryptedQueryParam: encryptedParam,
|
|
681
718
|
aesKeyBase64,
|
|
719
|
+
aesKeyHex,
|
|
682
720
|
ciphertextSize: fileSize,
|
|
683
721
|
plaintextSize: rawSize,
|
|
684
722
|
filename,
|
|
@@ -848,7 +886,16 @@ export class WechatGateway extends Service {
|
|
|
848
886
|
if (messageId) this.remember(messageId)
|
|
849
887
|
|
|
850
888
|
const contextToken = String(message.context_token ?? '')
|
|
851
|
-
if (contextToken)
|
|
889
|
+
if (contextToken) {
|
|
890
|
+
this.contextTokens.set(sender, contextToken)
|
|
891
|
+
try {
|
|
892
|
+
const tokenFile = path.join(process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh'), 'dsh-bridge', 'wechat-context-tokens.json')
|
|
893
|
+
let data = {}
|
|
894
|
+
try { if (fs.existsSync(tokenFile)) data = JSON.parse(fs.readFileSync(tokenFile, 'utf8')) } catch {}
|
|
895
|
+
data[sender] = contextToken
|
|
896
|
+
fs.writeFileSync(tokenFile, JSON.stringify(data, null, 2), 'utf8')
|
|
897
|
+
} catch {}
|
|
898
|
+
}
|
|
852
899
|
|
|
853
900
|
try {
|
|
854
901
|
this.ctx.emit('wechat/message', message)
|
package/lib/wechat/index.js
CHANGED
|
@@ -85,6 +85,10 @@ export class WechatService extends Platform {
|
|
|
85
85
|
return this.gateway.sendMedia({ ...media, to: peerId, ...opts })
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
async sendMediaFile(peerId, filePath, opts = {}) {
|
|
89
|
+
return this.gateway.sendMediaFile(peerId, filePath)
|
|
90
|
+
}
|
|
91
|
+
|
|
88
92
|
/** 合并展示状态给浏览器 UI(保持 v1.x 字段结构,新增 id/name/capabilities)。 */
|
|
89
93
|
getStatus() {
|
|
90
94
|
const allowFrom = [...(this.node.config.allowFrom ?? [])]
|
|
@@ -222,7 +226,10 @@ export class WechatService extends Platform {
|
|
|
222
226
|
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs } = {}) {
|
|
223
227
|
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
224
228
|
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
225
|
-
if (maxMessageChars != null)
|
|
229
|
+
if (maxMessageChars != null) {
|
|
230
|
+
const val = Number(maxMessageChars)
|
|
231
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : gatewayConstants.MAX_MESSAGE_CHARS
|
|
232
|
+
}
|
|
226
233
|
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
227
234
|
await this.persist({
|
|
228
235
|
digestIntervalSec: this.node.config.digestIntervalSec,
|
package/lib/wechat/media.js
CHANGED
|
@@ -114,11 +114,11 @@ export function normalizeAesKey(input) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* 生成用于 iLink API 的 aes_key 字段(上传时用)。
|
|
118
|
-
* iLink
|
|
117
|
+
* 生成用于 iLink API CDNMedia 的 aes_key 字段(上传时用)。
|
|
118
|
+
* iLink 协议规范:aes_key 必须先转 hex 字符串,再 base64 编码(base64(hex_string))。
|
|
119
119
|
*/
|
|
120
120
|
export function encodeAesKeyForApi(keyBytes) {
|
|
121
|
-
const hexStr = keyBytes.toString('hex')
|
|
121
|
+
const hexStr = Buffer.isBuffer(keyBytes) ? keyBytes.toString('hex') : Buffer.from(keyBytes).toString('hex')
|
|
122
122
|
return Buffer.from(hexStr, 'ascii').toString('base64')
|
|
123
123
|
}
|
|
124
124
|
|
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.3",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.8.
|
|
5
|
+
"releaseNotes": "【v2.8.3 微信与全平台 IM 文件收发链路全面修复与深度兼容】\n• 📱 微信 iLink 官方媒体传输协议完全对齐:修复 getuploadurl 文件媒体类型枚举(media_type=3)与 AES 密钥规范编码(base64(hex)),彻底解决手机端「文件已过期/下载失败/0B」问题\n• 🤖 QQ 机器人富媒体文件直传修复:支持 file_type=4 通用文件传输并显式传递 file_name,彻底消除「未命名」卡片显示\n• 🌐 微信会话凭证(context_token)持久化机制:自动捕获并持久化落盘实时安全凭证,重启服务永不丢失会话上下文\n• 📢 多平台多端共享会话广播:同一会话在任一端(飞书/QQ/TG/Web)生成产物文件时,自动同步广播推送到所有已绑定的 IM 客户端",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|