@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 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: fullText }],
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
- if (!this.platform?.sendTyping || this.peerId == null) return
599
- return this.platform.sendTyping(this.peerId, state)
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 args = event.data?.parameters || event.data?.args || {}
656
- const target = args.TargetFile || args.targetFile || args.target_file || args.path || args.filePath
657
- if (target && typeof target === 'string') {
658
- state.createdFiles.add(target)
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()) void this.sendText(text)
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 files = Array.from(state.createdFiles)
681
- const fileLines = files.map((f) => `- \`${f}\``).join('\n')
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
- if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
686
- for (const f of files) {
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
- if (statSync(f).isFile()) {
689
- void this.platform.sendMediaFile(this.peerId, f)
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 fileType = isImage ? 1 : 1
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, opts.scope, 'files')
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) {
@@ -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
- return String(errmsg ?? '').toLowerCase() === 'unknown error'
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: mediaType,
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
- encrypt_query_param: encryptedQueryParam,
568
- aeskey: aesKeyBase64,
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
- encrypt_query_param: encryptedQueryParam,
579
- aes_key: aesKeyBase64,
580
- filesize: ciphertextSize,
581
- rawsize: plaintextSize,
582
- filename,
583
- rawfilemd5: rawFileMd5,
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
- encrypt_query_param: encryptedQueryParam,
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
- encrypt_query_param: encryptedQueryParam,
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: uploadInfo.uploadFullUrl || `${WEIXIN_CDN_BASE_URL}?upload_param=${encodeURIComponent(uploadInfo.uploadParam)}`,
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) this.contextTokens.set(sender, 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)
@@ -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 ?? [])]
@@ -114,11 +114,11 @@ export function normalizeAesKey(input) {
114
114
  }
115
115
 
116
116
  /**
117
- * 生成用于 iLink API 的 aes_key 字段(上传时用)。
118
- * iLink 期望 base64(hex_string),不是 base64(raw_bytes)
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.1",
3
+ "version": "2.8.3",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.8.1 微信/IM 消息分块保护与流式体验修复】\n• 🛡️ 修复微信/IM 平台单条消息分块字符数因异常配置被切成 20 字符碎片的问题\n• ⚙️ 增加消息分块字符数全局安全下限防御(自动纠偏并保底为平台标准容量)\n• 💬 恢复微信/各平台完整段落与 Markdown 代码块的原生一次性下发",
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": {