@wenbin_wb/dsh-bridge 2.8.1 → 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 +28 -0
- package/lib/platform/conversation-bridge.js +129 -22
- package/lib/qq/gateway.js +20 -7
- package/lib/wechat/gateway.js +67 -20
- package/lib/wechat/index.js +4 -0
- package/lib/wechat/media.js +3 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@
|
|
|
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
|
+
|
|
7
35
|
## [v2.8.1] - 2026-08-26
|
|
8
36
|
|
|
9
37
|
### 🛡️ 微信/IM 消息分块保护与流式体验修复
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -559,8 +626,11 @@ export class ConversationBridge {
|
|
|
559
626
|
return 'routed'
|
|
560
627
|
}
|
|
561
628
|
|
|
629
|
+
// 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
|
|
630
|
+
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
|
|
631
|
+
|
|
562
632
|
const messageValue = createUserMessage({
|
|
563
|
-
content: [{ type: 'text', text:
|
|
633
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
564
634
|
source: { kind: 'user' },
|
|
565
635
|
})
|
|
566
636
|
agent.followup(messageValue)
|
|
@@ -572,7 +642,7 @@ export class ConversationBridge {
|
|
|
572
642
|
|
|
573
643
|
/** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
|
|
574
644
|
async sendText(text) {
|
|
575
|
-
const peer = this.peerId
|
|
645
|
+
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
576
646
|
if (!peer) return
|
|
577
647
|
const chunks = splitForIM(text, this.config.maxMessageChars)
|
|
578
648
|
if (chunks.length === 0) return
|
|
@@ -595,8 +665,9 @@ export class ConversationBridge {
|
|
|
595
665
|
|
|
596
666
|
/** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
|
|
597
667
|
async sendTyping(state) {
|
|
598
|
-
|
|
599
|
-
|
|
668
|
+
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
669
|
+
if (!this.platform?.sendTyping || peer == null) return
|
|
670
|
+
return this.platform.sendTyping(peer, state)
|
|
600
671
|
}
|
|
601
672
|
|
|
602
673
|
// ---- 出站事件绑定 ----
|
|
@@ -630,7 +701,7 @@ export class ConversationBridge {
|
|
|
630
701
|
}, this.config.digestIntervalSec * 1000)
|
|
631
702
|
if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
|
|
632
703
|
}
|
|
633
|
-
const onEvent = (session, event) => {
|
|
704
|
+
const onEvent = async (session, event) => {
|
|
634
705
|
const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
|
|
635
706
|
this._digestState.set(session.id, state)
|
|
636
707
|
|
|
@@ -651,17 +722,40 @@ export class ConversationBridge {
|
|
|
651
722
|
startHeartbeat(session, state)
|
|
652
723
|
return
|
|
653
724
|
}
|
|
725
|
+
const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
|
|
726
|
+
|
|
654
727
|
if (event.type === 'tool/call') {
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
|
|
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 {}
|
|
659
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)
|
|
660
749
|
return
|
|
661
750
|
}
|
|
662
751
|
if (event.type === 'assistant/message') {
|
|
663
752
|
const text = textOfAssistantMessage(event.data.message)
|
|
664
|
-
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
|
+
}
|
|
665
759
|
return
|
|
666
760
|
}
|
|
667
761
|
if (event.type === 'turn/end') {
|
|
@@ -675,29 +769,40 @@ export class ConversationBridge {
|
|
|
675
769
|
} else if (reason.kind === 'max-tokens') {
|
|
676
770
|
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
|
|
677
771
|
}
|
|
678
|
-
|
|
772
|
+
|
|
773
|
+
// 如果本轮生成/记录了产物文件,下发清单通知并尝试直接上传文件至聊天窗口
|
|
679
774
|
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
680
|
-
const
|
|
681
|
-
const
|
|
775
|
+
const rawFiles = Array.from(state.createdFiles)
|
|
776
|
+
const cwd = getSessionCwd(session)
|
|
777
|
+
const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
|
|
682
778
|
void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
|
|
683
779
|
|
|
684
|
-
// 如果平台支持 sendMediaFile
|
|
685
|
-
|
|
686
|
-
|
|
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) {
|
|
687
789
|
try {
|
|
688
|
-
|
|
689
|
-
|
|
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)
|
|
690
794
|
}
|
|
691
|
-
} catch {
|
|
795
|
+
} catch (err) {
|
|
796
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
|
|
797
|
+
}
|
|
692
798
|
}
|
|
693
799
|
}
|
|
694
|
-
|
|
695
800
|
state.createdFiles.clear()
|
|
696
801
|
}
|
|
697
802
|
return
|
|
698
803
|
}
|
|
699
804
|
}
|
|
700
|
-
const listener = (session, event) => onEvent(session, event)
|
|
805
|
+
const listener = (session, event) => { void onEvent(session, event) }
|
|
701
806
|
const disposer = this.ctx.on('session/event', listener)
|
|
702
807
|
this.disposers.push(() => {
|
|
703
808
|
for (const state of digestState.values()) stopHeartbeat(state)
|
|
@@ -1234,6 +1339,8 @@ export const conversationBridgeHelpers = {
|
|
|
1234
1339
|
splitForIM,
|
|
1235
1340
|
digestLine,
|
|
1236
1341
|
textOfAssistantMessage,
|
|
1342
|
+
resolveFilePath,
|
|
1343
|
+
extractFilePathsFromText,
|
|
1237
1344
|
sessionsInDisplayOrder,
|
|
1238
1345
|
listSessions,
|
|
1239
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/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 ?? [])]
|
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": {
|