@wenbin_wb/dsh-bridge 2.8.1 → 2.8.4

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,41 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.8.4] - 2026-08-26
8
+
9
+ ### 🗂️ 会话列表 Web 端 1:1 深度对齐与归档过滤修复
10
+ - **🗂️ 严格按工作区账本对齐会话列表**:全面采用 DSH 官方工作区账本排序规则,自动过滤子代理派生会话、空白草稿与已归档会话,彻底解决磁盘历史孤立会话冗余列出的问题,保持 IM 端与 Web 端侧边栏 1:1 结构一致与编号精准对应。
11
+
12
+ ---
13
+
14
+ ## [v2.8.3] - 2026-08-26
15
+
16
+ ### 📱 微信与全平台 IM 文件收发链路全面修复与深度兼容
17
+ - **📱 微信 iLink 官方媒体传输协议完全对齐**:
18
+ - 修复 `getuploadurl` 媒体类型映射,确保文件上传请求采用标准 `UploadMediaType.FILE = 3`,微信服务端正确识别文件类型与大小;
19
+ - 严格采用官方规范对 `CDNMedia.aes_key` 进行 `base64(hex_string)` 编码,彻底解决微信移动端/电脑端解密失败报「文件已过期/下载失败/0B」的深层问题;
20
+ - 精简规范 `file_item` 结构并对齐 `encrypt_type: 1`。
21
+ - **🤖 QQ 机器人富媒体文件直传与显示修复**:
22
+ - 支持 `file_type: 4` 通用文件传输,适配 QQ OpenAPI v2 文件格式校验(解决此前类型不符导致的 850019 拦截);
23
+ - 显式传递 `file_name`,彻底消除 QQ 聊天界面将文档显示为「未命名」的问题;
24
+ - 自动识别群聊与单聊端点路由(`/v2/users/{openid}/files` 与 `/v2/groups/{group_openid}/files`)。
25
+ - **🌐 微信会话凭证(context_token)持久化机制**:
26
+ - 捕获微信入站消息时自动将实时安全会话凭证持久化落盘(`wechat-context-tokens.json`),并在服务启动与重启时自动载入,彻底避免凭证在重启后丢失;
27
+ - 完善 `ret: -2` / `prepare failed` 过期感知,当用户重新发消息时自动刷新续期。
28
+ - **📢 多平台多端共享会话广播**:
29
+ - 优化同一工作区多端共享会话的广播逻辑,当任一客户端(飞书/QQ/Telegram/Web)触发生成文件时,各平台桥接器自动回退至已授权用户,实现产物文件多端同步直传。
30
+
31
+ ---
32
+
33
+ ## [v2.8.2] - 2026-08-26
34
+
35
+ ### 🚀 微信/IM 本地生成文件全域探测与直接推送
36
+ - **📁 全域文件智能探测与提取**:全面支持从 PowerShell/Bash 终端脚本(`New-Item`、`Set-Content`、`Out-File`、`>`)、工具调用参数及回复文本中自动探测提取生成的文件,并结合会话工作区目录自动解析为绝对路径;
37
+ - **🌐 微信官方 CDN 媒体上传链路修复**:严格对齐腾讯 iLink 官方 CDN `/upload` 路由子路径与 `encrypted_query_param` / `filekey` 鉴权参数,彻底解决此前上传失败的问题;
38
+ - **💬 微信与各 IM 原生文件气泡推送**:任务结束自动将 txt/pdf/docx/xlsx/zip/png 等产物推送到聊天界面,支持直接下载查看与转发。
39
+
40
+ ---
41
+
7
42
  ## [v2.8.1] - 2026-08-26
8
43
 
9
44
  ### 🛡️ 微信/IM 消息分块保护与流式体验修复
@@ -19,9 +19,10 @@
19
19
 
20
20
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
21
  import { randomUUID } from 'node:crypto'
22
- import { statSync } from 'node:fs'
22
+ import { statSync, existsSync, readFileSync } from 'node:fs'
23
23
  import { stat } from 'node:fs/promises'
24
- import { resolve, normalize, basename } from 'node:path'
24
+ import { join, resolve, normalize, basename, isAbsolute } from 'node:path'
25
+ import { homedir } from 'node:os'
25
26
  import { isSafeWorkspacePath } from '../security/path-validator.js'
26
27
 
27
28
  // 纯文本标记(用户偏好不用 emoji)
@@ -155,6 +156,73 @@ export function textOfAssistantMessage(message) {
155
156
  .join('\n')
156
157
  }
157
158
 
159
+ /**
160
+ * 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
161
+ */
162
+ export function resolveFilePath(rawPath, cwd = process.cwd()) {
163
+ if (typeof rawPath !== 'string') return null
164
+ let p = rawPath.trim()
165
+ .replace(/^["'`]|["'`]$/g, '')
166
+ .replace(/^file:\/\/\/?/, '')
167
+ .replace(/^[📁📄📦\s]+/, '')
168
+ if (!p) return null
169
+ // 排除 HTTP/HTTPS 网址
170
+ if (/^https?:\/\//i.test(p)) return null
171
+ const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
172
+ try {
173
+ if (statSync(resolved).isFile()) {
174
+ return resolved
175
+ }
176
+ } catch {}
177
+ return null
178
+ }
179
+
180
+ /**
181
+ * 从模型助手回复正文或工具命令中提取所有真实存在于本地磁盘的文件绝对路径
182
+ */
183
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
184
+ if (typeof text !== 'string' || !text.trim()) return []
185
+ const found = new Set()
186
+
187
+ // 1. Windows 绝对路径:C:\Users\...\file.ext 或 C:/Users/.../file.ext(支持中文、空格、特殊符号)
188
+ const winAbsRegex = /[A-Za-z]:[\\/][^\s"'`<>|*?()]+?\.[A-Za-z0-9_.-]+/g
189
+ let m
190
+ while ((m = winAbsRegex.exec(text)) !== null) {
191
+ const r = resolveFilePath(m[0], cwd)
192
+ if (r) found.add(r)
193
+ }
194
+
195
+ // 2. POSIX 绝对路径:/home/.../file.ext 或 /tmp/.../file.ext
196
+ const posixAbsRegex = /\/(?:[^\s"'`<>|*?()\/]+\/)+[^\s"'`<>|*?()\/]+\.[A-Za-z0-9_.-]+/g
197
+ while ((m = posixAbsRegex.exec(text)) !== null) {
198
+ const r = resolveFilePath(m[0], cwd)
199
+ if (r) found.add(r)
200
+ }
201
+
202
+ // 3. Markdown 文件链接:[name](file:///path/to/file) 或 [name](path/to/file)
203
+ const mdLinkRegex = /\[(?:[^\]]*)\]\((?:file:\/\/\/?)?([^)]+)\)/g
204
+ while ((m = mdLinkRegex.exec(text)) !== null) {
205
+ const r = resolveFilePath(m[1], cwd)
206
+ if (r) found.add(r)
207
+ }
208
+
209
+ // 4. 关键词或 Emoji 引用的文件路径:📁 file.txt, 保存到:file.txt, 产物文件: ...
210
+ const keywordRegex = /(?:📁|📄|📦|保存到[::\s]*|生成文件[::\s]*|文件路径[::\s]*|产物[::\s]*|输出文件[::\s]*|写入文件[::\s]*)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/g
211
+ while ((m = keywordRegex.exec(text)) !== null) {
212
+ const r = resolveFilePath(m[1], cwd)
213
+ if (r) found.add(r)
214
+ }
215
+
216
+ // 5. Shell 终端常用输出命令参数:-Path "...", > "...", Out-File "...", Set-Content "..."
217
+ const cmdRegex = /(?:-Path\s+|>\s*|Out-File\s+|Set-Content\s+|TargetFile["':\s]+|targetFile["':\s]+)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/gi
218
+ while ((m = cmdRegex.exec(text)) !== null) {
219
+ const r = resolveFilePath(m[1], cwd)
220
+ if (r) found.add(r)
221
+ }
222
+
223
+ return Array.from(found)
224
+ }
225
+
158
226
  // ---------------------------------------------------------------------------
159
227
  // digest 摘要
160
228
  // ---------------------------------------------------------------------------
@@ -559,8 +627,11 @@ export class ConversationBridge {
559
627
  return 'routed'
560
628
  }
561
629
 
630
+ // 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
631
+ const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
632
+
562
633
  const messageValue = createUserMessage({
563
- content: [{ type: 'text', text: fullText }],
634
+ content: [{ type: 'text', text: promptWithContext }],
564
635
  source: { kind: 'user' },
565
636
  })
566
637
  agent.followup(messageValue)
@@ -572,7 +643,7 @@ export class ConversationBridge {
572
643
 
573
644
  /** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
574
645
  async sendText(text) {
575
- const peer = this.peerId
646
+ const peer = this.peerId || this.config.allowFrom?.[0]
576
647
  if (!peer) return
577
648
  const chunks = splitForIM(text, this.config.maxMessageChars)
578
649
  if (chunks.length === 0) return
@@ -595,8 +666,9 @@ export class ConversationBridge {
595
666
 
596
667
  /** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
597
668
  async sendTyping(state) {
598
- if (!this.platform?.sendTyping || this.peerId == null) return
599
- return this.platform.sendTyping(this.peerId, state)
669
+ const peer = this.peerId || this.config.allowFrom?.[0]
670
+ if (!this.platform?.sendTyping || peer == null) return
671
+ return this.platform.sendTyping(peer, state)
600
672
  }
601
673
 
602
674
  // ---- 出站事件绑定 ----
@@ -630,7 +702,7 @@ export class ConversationBridge {
630
702
  }, this.config.digestIntervalSec * 1000)
631
703
  if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
632
704
  }
633
- const onEvent = (session, event) => {
705
+ const onEvent = async (session, event) => {
634
706
  const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
635
707
  this._digestState.set(session.id, state)
636
708
 
@@ -651,17 +723,40 @@ export class ConversationBridge {
651
723
  startHeartbeat(session, state)
652
724
  return
653
725
  }
726
+ const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
727
+
654
728
  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)
729
+ const cwd = getSessionCwd(session)
730
+ let args = event.data?.parameters || event.data?.args || event.data?.arguments || {}
731
+ if (typeof args === 'string') {
732
+ try { args = JSON.parse(args) } catch {}
659
733
  }
734
+ if (typeof args === 'object' && args !== null) {
735
+ const possibleKeys = [
736
+ 'TargetFile', 'targetFile', 'target_file', 'path', 'filePath', 'file',
737
+ 'destination', 'out_file', 'output', 'ImageName', 'fileName', 'filename'
738
+ ]
739
+ for (const k of possibleKeys) {
740
+ const val = args[k]
741
+ if (val && typeof val === 'string') {
742
+ const clean = val.trim().replace(/^["'`]|["'`]$/g, '').replace(/^file:\/\/\/?/, '')
743
+ if (clean) state.createdFiles.add(clean)
744
+ }
745
+ }
746
+ }
747
+ const rawStr = JSON.stringify(event.data || {})
748
+ const fromRaw = extractFilePathsFromText(rawStr, cwd)
749
+ for (const f of fromRaw) state.createdFiles.add(f)
660
750
  return
661
751
  }
662
752
  if (event.type === 'assistant/message') {
663
753
  const text = textOfAssistantMessage(event.data.message)
664
- if (text.trim()) void this.sendText(text)
754
+ if (text.trim()) {
755
+ const cwd = getSessionCwd(session)
756
+ const fromText = extractFilePathsFromText(text, cwd)
757
+ for (const f of fromText) state.createdFiles.add(f)
758
+ void this.sendText(text)
759
+ }
665
760
  return
666
761
  }
667
762
  if (event.type === 'turn/end') {
@@ -675,29 +770,40 @@ export class ConversationBridge {
675
770
  } else if (reason.kind === 'max-tokens') {
676
771
  void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
677
772
  }
678
- // 如果本轮生成/修改了产物文件,下发产物清单通知并尝试直接上传文件至聊天窗口
773
+
774
+ // 如果本轮生成/记录了产物文件,下发清单通知并尝试直接上传文件至聊天窗口
679
775
  if (state.createdFiles && state.createdFiles.size > 0) {
680
- const files = Array.from(state.createdFiles)
681
- const fileLines = files.map((f) => `- \`${f}\``).join('\n')
776
+ const rawFiles = Array.from(state.createdFiles)
777
+ const cwd = getSessionCwd(session)
778
+ const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
682
779
  void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
683
780
 
684
- // 如果平台支持 sendMediaFile,自动尝试直接发送文件/图片到聊天窗口
685
- if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
686
- for (const f of files) {
781
+ // 如果平台支持 sendMediaFile,自动尝试直接发送真实存在的文件/图片到聊天窗口(严格按绝对路径去重)
782
+ const targetPeer = this.peerId || this.config.allowFrom?.[0]
783
+ if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
784
+ const uniqueFilesToSend = new Set()
785
+ for (const f of rawFiles) {
786
+ const resolved = resolveFilePath(f, cwd)
787
+ if (resolved) uniqueFilesToSend.add(resolved)
788
+ }
789
+ for (const resolved of uniqueFilesToSend) {
687
790
  try {
688
- if (statSync(f).isFile()) {
689
- void this.platform.sendMediaFile(this.peerId, f)
791
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
792
+ const res = await this.platform.sendMediaFile(targetPeer, resolved)
793
+ if (res && res.success === false) {
794
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
690
795
  }
691
- } catch {}
796
+ } catch (err) {
797
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
798
+ }
692
799
  }
693
800
  }
694
-
695
801
  state.createdFiles.clear()
696
802
  }
697
803
  return
698
804
  }
699
805
  }
700
- const listener = (session, event) => onEvent(session, event)
806
+ const listener = (session, event) => { void onEvent(session, event) }
701
807
  const disposer = this.ctx.on('session/event', listener)
702
808
  this.disposers.push(() => {
703
809
  for (const state of digestState.values()) stopHeartbeat(state)
@@ -837,45 +943,261 @@ function foldTitle(events) {
837
943
  return null
838
944
  }
839
945
 
840
- // 列出会话:使用 DSH 官方 API(ctx.sessions + sessionPersistence),与 web 端一致。
841
- // 屏蔽已归档会话。返回 [{ id, createdAt, events?, seq?, cwd?, title? }],按时间倒序。
842
- async function listSessions(node) {
843
- // 归档会话 ID 集合
844
- let archived = new Set()
845
- try { archived = new Set(node.ctx.workspaceRegistry?.archivedSessionIds ?? []) } catch { /* ignore */ }
846
- const live = [...(node.ctx.sessions?.list() ?? [])].filter((s) => !archived.has(s.id))
847
- const liveIds = new Set(live.map((s) => s.id))
848
- // 内存活跃会话(带完整 events/title)
849
- const liveMapped = live.map((s) => {
946
+ /** 获取所有已归档会话 ID 集合(支持 ctx.workspaceRegistry 内存服务 + workspace.json 文件存储双重兜底) */
947
+ function getArchivedSessionIds(ctx) {
948
+ const archived = new Set()
949
+ // 1. 尝试从 ctx.workspaceRegistry 内存服务读取
950
+ try {
951
+ const list = ctx?.workspaceRegistry?.archivedSessionIds
952
+ if (Array.isArray(list)) {
953
+ for (const id of list) {
954
+ if (id) archived.add(String(id))
955
+ }
956
+ return archived
957
+ }
958
+ } catch { /* ignore */ }
959
+
960
+ // 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
961
+ if (!ctx?._mock) {
850
962
  try {
851
- const title = foldTitle(s.events ?? [])
852
- if (title) return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd, title }
963
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
964
+ const wsFile = join(home, 'storages', 'workspace.json')
965
+ if (existsSync(wsFile)) {
966
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
967
+ const fileArchived = data?.global?.archivedSessionIds
968
+ if (Array.isArray(fileArchived)) {
969
+ for (const id of fileArchived) {
970
+ if (id) archived.add(String(id))
971
+ }
972
+ }
973
+ }
853
974
  } catch { /* ignore */ }
854
- return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd }
855
- })
856
- // 持久化会话(含 cwd,与 web 端过滤一致)
857
- let cold = []
975
+ }
976
+
977
+ return archived
978
+ }
979
+
980
+ /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
981
+ function getSessionProjCache(ctx) {
982
+ if (ctx?._mock) return {}
858
983
  try {
859
- const headers = await node.ctx.sessionPersistence?.list?.()
860
- if (Array.isArray(headers)) {
861
- const coldHeaders = headers.filter((h) => h && h.id && !liveIds.has(h.id) && h.cwd !== undefined && !archived.has(h.id))
862
- // 分批加载以避免并发过载(每批 10 个)
863
- const BATCH_SIZE = 10
864
- for (let i = 0; i < coldHeaders.length; i += BATCH_SIZE) {
865
- const batch = coldHeaders.slice(i, i + BATCH_SIZE)
866
- const batchResults = await Promise.all(batch.map(async (h) => {
867
- let title
984
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
985
+ const cacheFile = join(home, 'storages', 'session_projcache.json')
986
+ if (existsSync(cacheFile)) {
987
+ const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
988
+ return data?.tables?.sessions || {}
989
+ }
990
+ } catch { /* ignore */ }
991
+ return {}
992
+ }
993
+
994
+ /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
995
+ async function getRegisteredWorkspaces(ctx) {
996
+ const workspaces = []
997
+
998
+ // 优先从内存服务获取
999
+ if (ctx?.workspaceRegistry) {
1000
+ try {
1001
+ const list = await ctx.workspaceRegistry.list?.()
1002
+ if (Array.isArray(list)) {
1003
+ for (const w of list) {
1004
+ if (w && w.path) {
1005
+ workspaces.push({
1006
+ id: w.id || w.path,
1007
+ path: w.path,
1008
+ title: w.title || basename(w.path),
1009
+ sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
1010
+ })
1011
+ }
1012
+ }
1013
+ return workspaces
1014
+ }
1015
+ } catch { /* ignore */ }
1016
+ }
1017
+
1018
+ // 兜底从 workspace.json 存储文件读取
1019
+ if (!ctx?._mock) {
1020
+ try {
1021
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1022
+ const wsFile = join(home, 'storages', 'workspace.json')
1023
+ if (existsSync(wsFile)) {
1024
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
1025
+ const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
1026
+ const table = data?.tables?.workspaces || {}
1027
+ for (const wId of wsIds) {
1028
+ const ws = table[wId]
1029
+ if (ws && ws.path) {
1030
+ workspaces.push({
1031
+ id: wId,
1032
+ path: ws.path,
1033
+ title: ws.title || basename(ws.path),
1034
+ sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
1035
+ })
1036
+ }
1037
+ }
1038
+ }
1039
+ } catch { /* ignore */ }
1040
+ }
1041
+
1042
+ return workspaces
1043
+ }
1044
+
1045
+ function isSubagentSession(cacheRow, liveSession) {
1046
+ if (liveSession?.origin === 'subagent' || liveSession?.header?.origin === 'subagent') return true
1047
+ const subVal = cacheRow?.rows?.subagent?.val
1048
+ if (subVal && typeof subVal === 'object' && Object.keys(subVal).length > 0) return true
1049
+ return false
1050
+ }
1051
+
1052
+ // 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
1053
+ // 1. 过滤已归档会话 (archivedSessionIds)
1054
+ // 2. 过滤未发起提问的空白会话 (blank: true)
1055
+ // 3. 过滤子代理内部会话 (subagent origin)
1056
+ // 4. 严格按工作区账本 (workspace.sessionIds) 组织
1057
+ async function listSessions(node) {
1058
+ const archived = getArchivedSessionIds(node.ctx)
1059
+ const projCache = getSessionProjCache(node.ctx)
1060
+ const workspaces = await getRegisteredWorkspaces(node.ctx)
1061
+
1062
+ // 内存活跃会话(按 id 索引)
1063
+ const liveList = [...(node.ctx.sessions?.list?.() ?? [])].filter(
1064
+ (s) => s && s.id && !archived.has(s.id) && !s.archived && !s.header?.archived
1065
+ )
1066
+ const liveById = new Map(liveList.map((s) => [s.id, s]))
1067
+
1068
+ const accounted = new Set()
1069
+ const result = []
1070
+
1071
+ // 1. 如果存在已注册的工作区,严格按工作区及其 sessionIds 账本组织(与 Web 端完全一致)
1072
+ if (workspaces.length > 0) {
1073
+ for (const ws of workspaces) {
1074
+ const normWsPath = ws.path ? normalize(ws.path).toLowerCase() : ''
1075
+
1076
+ // 优先将当前工作区下新创建但在内存里的 live 会话追加到头部
1077
+ for (const s of liveList) {
1078
+ const sCwd = s.header?.cwd || s.cwd
1079
+ if (sCwd && normalize(sCwd).toLowerCase() === normWsPath && !accounted.has(s.id)) {
1080
+ if (isSubagentSession(projCache[s.id], s)) continue
1081
+ accounted.add(s.id)
1082
+ let title = s.title || (s.events ? foldTitle(s.events) : '')
1083
+ if (!title) {
1084
+ const cache = projCache[s.id]
1085
+ title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1086
+ }
1087
+ result.push({
1088
+ id: s.id,
1089
+ createdAt: s.header?.createdAt || Date.now(),
1090
+ cwd: ws.path,
1091
+ workspaceTitle: ws.title,
1092
+ title: title || '新会话',
1093
+ events: s.events,
1094
+ seq: s.seq ?? 0,
1095
+ })
1096
+ }
1097
+ }
1098
+
1099
+ // 按工作区账本存储的 sessionIds 顺序追加已记录会话
1100
+ for (const sId of ws.sessionIds) {
1101
+ if (archived.has(sId) || accounted.has(sId)) continue
1102
+ accounted.add(sId)
1103
+
1104
+ const cache = projCache[sId]
1105
+ const live = liveById.get(sId)
1106
+ // 过滤空白草稿会话(非当前活动会话)
1107
+ if (cache?.rows?.sessionListMetadata?.val?.blank === true && sId !== node.activeSessionId) {
1108
+ continue
1109
+ }
1110
+ // 过滤子代理内部会话
1111
+ if (isSubagentSession(cache, live)) {
1112
+ continue
1113
+ }
1114
+
1115
+ let title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1116
+ let createdAt = cache?.identity?.createdAt || 0
1117
+ let cwd = ws.path
1118
+
1119
+ // 如果内存有该会话,提取最新数据
1120
+ if (live) {
1121
+ title = live.title || (live.events ? foldTitle(live.events) : '') || title
1122
+ createdAt = live.header?.createdAt || createdAt
1123
+ } else if (!title && node.ctx.sessionPersistence?.load) {
868
1124
  try {
869
- const insp = await node.ctx.sessionPersistence.load(h.id)
1125
+ const insp = await node.ctx.sessionPersistence.load(sId)
870
1126
  title = foldTitle(insp.events ?? []) ?? undefined
871
- } catch { /* 标题提取失败则只用 id */ }
872
- return { id: h.id, createdAt: h.createdAt ?? 0, events: undefined, seq: 0, cwd: h.cwd, title }
873
- }))
874
- cold.push(...batchResults)
1127
+ } catch {}
1128
+ }
1129
+
1130
+ result.push({
1131
+ id: sId,
1132
+ createdAt,
1133
+ cwd,
1134
+ workspaceTitle: ws.title,
1135
+ title: title || '新会话',
1136
+ events: live?.events,
1137
+ seq: live?.seq ?? 0,
1138
+ })
875
1139
  }
876
1140
  }
877
- } catch { /* 持久化服务不可用时仅返回内存会话 */ }
878
- return [...liveMapped, ...cold].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq)
1141
+
1142
+ // 处理当前内存中处于活动状态但未绑定任何工作区的 live 会话
1143
+ for (const s of liveList) {
1144
+ if (accounted.has(s.id)) continue
1145
+ if (isSubagentSession(projCache[s.id], s)) continue
1146
+ accounted.add(s.id)
1147
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '未分组会话'
1148
+ result.push({
1149
+ id: s.id,
1150
+ createdAt: s.header?.createdAt || Date.now(),
1151
+ cwd: s.header?.cwd || '(未指定)',
1152
+ workspaceTitle: '未指定工作区',
1153
+ title,
1154
+ events: s.events,
1155
+ seq: s.seq ?? 0,
1156
+ })
1157
+ }
1158
+ } else {
1159
+ // 2. 如果系统未注册任何工作区(如单目录/无工作区模式),降级读取内存及持久化会话
1160
+ for (const s of liveList) {
1161
+ accounted.add(s.id)
1162
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '活跃会话'
1163
+ result.push({
1164
+ id: s.id,
1165
+ createdAt: s.header?.createdAt || Date.now(),
1166
+ cwd: s.header?.cwd || '(未指定)',
1167
+ workspaceTitle: '未指定工作区',
1168
+ title,
1169
+ events: s.events,
1170
+ seq: s.seq ?? 0,
1171
+ })
1172
+ }
1173
+ if (node.ctx.sessionPersistence?.list) {
1174
+ try {
1175
+ const headers = await node.ctx.sessionPersistence.list()
1176
+ if (Array.isArray(headers)) {
1177
+ const coldHeaders = headers.filter((h) => h && h.id && !accounted.has(h.id) && !archived.has(h.id) && !h.archived)
1178
+ for (const h of coldHeaders) {
1179
+ accounted.add(h.id)
1180
+ let title
1181
+ try {
1182
+ const insp = await node.ctx.sessionPersistence.load(h.id)
1183
+ title = foldTitle(insp.events ?? []) ?? undefined
1184
+ } catch {}
1185
+ result.push({
1186
+ id: h.id,
1187
+ createdAt: h.createdAt ?? 0,
1188
+ events: undefined,
1189
+ seq: 0,
1190
+ cwd: h.cwd || '(未指定)',
1191
+ workspaceTitle: '未指定工作区',
1192
+ title: title || '新会话',
1193
+ })
1194
+ }
1195
+ }
1196
+ } catch {}
1197
+ }
1198
+ }
1199
+
1200
+ return result
879
1201
  }
880
1202
 
881
1203
  // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
@@ -1127,37 +1449,38 @@ async function renderSessions(node) {
1127
1449
  if (all.length === 0) {
1128
1450
  return `## 📋 会话列表\n\n> 暂无历史会话。发送 \`/new <提示词>\` 开始新会话。`
1129
1451
  }
1130
- // 按工作区(真实 cwd)分组;无 cwd 的归入 '(未指定)'
1452
+ // 按工作区分组(保持 listSessions 中的工作区账本顺序)
1131
1453
  const groups = new Map()
1132
1454
  for (const s of all) {
1133
1455
  const key = s.cwd || '(未指定)'
1134
- if (!groups.has(key)) groups.set(key, [])
1135
- groups.get(key).push(s)
1456
+ if (!groups.has(key)) {
1457
+ groups.set(key, { title: s.workspaceTitle || getWorkspaceBasename(key), sessions: [] })
1458
+ }
1459
+ groups.get(key).sessions.push(s)
1136
1460
  }
1137
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1138
1461
  const parts = [
1139
1462
  `## 📋 会话列表 (共 ${all.length} 个)`,
1140
1463
  `> 切换会话:发送 \`/use 编号\` 或 \`/resume 编号\``,
1141
1464
  '',
1142
1465
  ]
1143
1466
  let idx = 0
1144
- for (const [cwd, sessions] of sortedGroups) {
1145
- const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${getWorkspaceBasename(cwd)}**`
1467
+ for (const [cwd, group] of groups) {
1468
+ const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${group.title || getWorkspaceBasename(cwd)}**`
1146
1469
  parts.push(groupName)
1147
1470
  parts.push('')
1148
1471
  parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
1149
1472
  parts.push('| :--- | :--- | :--- | :--- |')
1150
- for (const session of sessions.slice(0, 20)) {
1473
+ for (const session of group.sessions.slice(0, 20)) {
1151
1474
  idx += 1
1152
1475
  const isActive = session.id === node.activeSessionId
1153
1476
  const statusTag = isActive ? '`[当前]`' : '-'
1154
1477
  const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
1155
- const safeTitle = (rawTitle || '新会话 (待输入)').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1478
+ const safeTitle = (rawTitle || '新会话').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1156
1479
  const when = session.createdAt ? fmtTime(session.createdAt) : '-'
1157
1480
  parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
1158
1481
  }
1159
- if (sessions.length > 20) {
1160
- parts.push(`*…该工作区共 ${sessions.length} 个会话,仅显示前 20 个*`)
1482
+ if (group.sessions.length > 20) {
1483
+ parts.push(`*…该工作区共 ${group.sessions.length} 个会话,仅显示前 20 个*`)
1161
1484
  }
1162
1485
  parts.push('')
1163
1486
  }
@@ -1165,7 +1488,7 @@ async function renderSessions(node) {
1165
1488
  return parts.join('\n').trim()
1166
1489
  }
1167
1490
 
1168
- // 与 renderSessions 完全一致的显示顺序:按工作区字母序分组、组内保持 listSessions 顺序。
1491
+ // 与 renderSessions 完全一致的显示顺序:保持 listSessions 中的分组和顺序。
1169
1492
  // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
1170
1493
  function sessionsInDisplayOrder(all) {
1171
1494
  const groups = new Map()
@@ -1174,8 +1497,7 @@ function sessionsInDisplayOrder(all) {
1174
1497
  if (!groups.has(key)) groups.set(key, [])
1175
1498
  groups.get(key).push(s)
1176
1499
  }
1177
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1178
- return sortedGroups.flatMap(([, sessions]) => sessions)
1500
+ return [...groups.values()].flatMap((sessions) => sessions)
1179
1501
  }
1180
1502
 
1181
1503
  // 时间戳 → 简洁可读时间 (MM-DD HH:mm 或 YYYY-MM-DD HH:mm)
@@ -1234,8 +1556,11 @@ export const conversationBridgeHelpers = {
1234
1556
  splitForIM,
1235
1557
  digestLine,
1236
1558
  textOfAssistantMessage,
1559
+ resolveFilePath,
1560
+ extractFilePathsFromText,
1237
1561
  sessionsInDisplayOrder,
1238
1562
  listSessions,
1563
+ renderSessions,
1239
1564
  listWorkspaces,
1240
1565
  BRIDGE_MARK,
1241
1566
  }
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.4",
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.4 会话列表 Web 1:1 深度对齐与归档过滤修复】\n• 🗂️ 严格按工作区账本对齐会话列表:修复会话列表展示冗余,严格以 DSH 官方工作区账本为准,自动过滤子代理派生会话、空白草稿与已归档会话,与 Web 端侧边栏 1:1 保持一致",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "build:lark": "node scripts/bundle-lark.mjs",
28
28
  "build:banner": "node scripts/generate-banner.mjs",
29
29
  "prepack": "npm run build:lark && npm run build:client",
30
+ "release:github": "node scripts/create-github-release.mjs",
30
31
  "test": "node --test test/*.test.mjs"
31
32
  },
32
33
  "dependencies": {