@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 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 ?? (platformId === 'telegram' ? 4096 : 2000)),
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 不由后端回传;空值表示沿用已保存密钥
@@ -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) this.node.config.maxMessageChars = Number(maxMessageChars)
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) wechat.node.config.maxMessageChars = Number(cfg.maxMessageChars);
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) telegram.node.config.maxMessageChars = Number(cfg.maxMessageChars);
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: config.maxMessageChars ?? maxChars,
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: fullText }],
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
- if (!this.platform?.sendTyping || this.peerId == null) return
597
- 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)
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 args = event.data?.parameters || event.data?.args || {}
654
- const target = args.TargetFile || args.targetFile || args.target_file || args.path || args.filePath
655
- if (target && typeof target === 'string') {
656
- 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 {}
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()) 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
+ }
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 files = Array.from(state.createdFiles)
679
- 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')
680
778
  void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
681
779
 
682
- // 如果平台支持 sendMediaFile,自动尝试直接发送文件/图片到聊天窗口
683
- if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
684
- 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) {
685
789
  try {
686
- if (statSync(f).isFile()) {
687
- 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)
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 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) {
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) this.node.config.maxMessageChars = Number(maxMessageChars)
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({
@@ -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) this.node.config.maxMessageChars = Number(maxMessageChars)
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) {
@@ -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 ?? [])]
@@ -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) this.node.config.maxMessageChars = Number(maxMessageChars)
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,
@@ -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.0",
3
+ "version": "2.8.3",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.8.0 移动端体验革新、远程工作区管理与全面安全加固】\n• 🗂️ 远程工作区网页选择器:移动端/远程点击添加工作区自动呼出网页端目录浏览器,本机电脑智能无感分流原生系统选择器\n• 📱 移动端视觉与布局体验深度优化:顶部导航栏居中动态标题、第二行徽标与极简下载图标两端排布、底部工具栏防重叠自适应\n• 🔐 全方位安全加固:RPC 端点鉴权、路径穿越与系统核心目录防御、滑动窗口限流与 IM 权限拦截",
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": {