@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 +35 -0
- package/lib/platform/conversation-bridge.js +393 -68
- 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 +3 -2
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:
|
|
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
|
-
|
|
599
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
|
|
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())
|
|
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
|
|
681
|
-
const
|
|
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
|
-
|
|
686
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
|
852
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return archived
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
|
|
981
|
+
function getSessionProjCache(ctx) {
|
|
982
|
+
if (ctx?._mock) return {}
|
|
858
983
|
try {
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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(
|
|
1125
|
+
const insp = await node.ctx.sessionPersistence.load(sId)
|
|
870
1126
|
title = foldTitle(insp.events ?? []) ?? undefined
|
|
871
|
-
} catch {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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
|
-
//
|
|
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))
|
|
1135
|
-
|
|
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,
|
|
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 || '新会话
|
|
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
|
|
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
|
-
|
|
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
|
|
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.4",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.8.
|
|
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": {
|