@wenbin_wb/dsh-bridge 2.8.7 → 2.9.0

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.
@@ -1,350 +1,352 @@
1
- // dsh-bridge WeChat conversation node
2
- //
3
- // 微信 ⇄ DSH 会话桥:把 iLink 入站消息解析后交给平台无关的 ConversationBridge 处理,
4
- // 出站通过 ctx.wechat 发送。负责微信协议相关的部分:
5
- // - 消息解析(extractText / isGroupMessage)
6
- // - 媒体处理(图片/文件/语音/视频:下载、AES 解密、保存到 .wechat-media/)
7
- // - 白名单/会话/审批/命令路由等平台无关逻辑继承自 ConversationBridge
8
- //
9
- // 由 Jesse-njx/dsh-chatnode-wechat 移植精简而来。消费的 DSH 服务:
10
- // ctx.wechat (本插件 gateway 提供)sendText/sendTyping/accountId
11
- // ctx.sessions (DSH 宿主提供)list/get
12
- // ctx.agents (DSH 宿主提供)create/get/resume
13
- // ctx.approval (DSH 宿主提供)approval/request 事件
14
- //
15
- // 安全边界:强制白名单(allowFrom),非白名单发件人绝不喂给模型。
16
-
17
- import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
18
- import { gatewayConstants } from './gateway.js'
19
-
20
- const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
21
-
22
- // 将 ctx.wechat 网关适配为 ConversationBridge 需要的 Platform 消息接口
23
- function makePlatform(ctx) {
24
- return {
25
- id: 'wechat',
26
- name: '微信',
27
- get accountId() { return ctx.wechat?.accountId ?? '' },
28
- get capabilities() {
29
- return {
30
- supportsGroup: false, // v0.1 不处理群消息
31
- supportsMedia: true,
32
- supportsVoice: true,
33
- supportsTyping: true,
34
- maxMessageChars: MAX_MESSAGE_CHARS,
35
- }
36
- },
37
- sendText: (peer, text) => ctx.wechat.sendText(peer, text),
38
- sendMediaFile: (peer, filePath) => ctx.wechat.sendMediaFile(peer, filePath),
39
- sendTyping: (peer, state) => ctx.wechat.sendTyping(peer, state),
40
- }
41
- }
42
-
43
- export class WechatConversationNode extends ConversationBridge {
44
- /**
45
- * @param {object} ctx Cordis 上下文(含 ctx.wechat 网关服务)
46
- * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
47
- * @param {object} logger 日志器
48
- * @param {object} [opts]
49
- * @param {(senderId: string) => void} [opts.onFirstSender]
50
- * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
51
- */
52
- constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
53
- super({
54
- ctx,
55
- logger,
56
- config,
57
- platform: makePlatform(ctx),
58
- onFirstSender,
59
- onActiveSessionChange,
60
- })
61
-
62
- this.disposers.push(this.ctx.on('wechat/message', (message) => {
63
- void this._handleInbound(message)
64
- }))
65
- }
66
-
67
- // ---- 入站 ----
68
-
69
- async _handleInbound(message) {
70
- if (this.ctx?.wechat?.stopPollingLocal) return
71
- const sender = String(message.from_user_id ?? '').trim()
72
- if (!sender) return
73
-
74
- // 调试:记录收到的消息类型
75
- const items = message.item_list ?? []
76
- const itemTypes = items.map((it) => it.type).join(',')
77
- this.logger?.info?.(`[dsh-bridge wechat] received message from ${sender}, item_types=[${itemTypes}]`)
78
-
79
- // 快速预检查:白名单非空时,未授权发件人直接忽略(不下载媒体,防资源滥用)。
80
- // 白名单为空的"首条自动授权"场景交给 handleInbound 处理。
81
- if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
82
- this.logger?.info?.(`[dsh-bridge wechat] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
83
- return
84
- }
85
-
86
- // v0.2: 处理媒体项(图片/文件/语音/视频)
87
- this.logger?.info?.(`[dsh-bridge wechat] processing media items for ${sender}...`)
88
- let mediaFiles = []
89
- let hadMediaItems = false
90
- let mediaError = null
91
- let debugInfo = [] // 调试信息
92
- try {
93
- hadMediaItems = items.some((it) => it.type === 2 || it.type === 4 || it.type === 5) // 图片/文件/视频
94
- const result = await this._processMediaItems(message, sender)
95
- mediaFiles = result.files
96
- debugInfo = result.debug
97
- this.logger?.info?.(`[dsh-bridge wechat] media processing done, got ${mediaFiles.length} files`)
98
- } catch (error) {
99
- // 尽可能捕获完整错误信息
100
- mediaError = error?.message || error?.toString?.() || JSON.stringify(error) || '未知错误'
101
- this.logger?.error?.(`[dsh-bridge wechat] media processing failed: ${mediaError}`, error)
102
- // 媒体处理失败不阻断文本消息处理
103
- }
104
-
105
- const text = extractText(message)
106
- if (!text.trim() && mediaFiles.length === 0) {
107
- // 如果原消息有媒体项但下载失败,给用户提示(包含错误详情+调试信息)
108
- if (hadMediaItems) {
109
- let errMsg = mediaError ? `媒体处理失败: ${mediaError.substring(0, 150)}` : '媒体消息处理失败'
110
- if (debugInfo.length > 0) {
111
- errMsg += `\n调试信息:\n${debugInfo.join('\n')}`
112
- }
113
- await this.sendText(`❌ **${errMsg}**`)
114
- return
115
- }
116
- this.logger?.info?.(`[dsh-bridge wechat] ignore empty message from ${sender}`)
117
- return
118
- }
119
-
120
- // 构建完整消息内容(文本 + 媒体文件路径)
121
- let fullText = text
122
- if (mediaFiles.length > 0) {
123
- const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
124
- fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
125
- }
126
-
127
- // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
128
- const isGroup = isGroupMessage(message, this.gatewayAccountId)
129
- await this.handleInbound({ senderId: sender, text: fullText, isGroup })
130
- }
131
-
132
- /**
133
- * 处理消息中的媒体项(v0.2):下载、解密、保存到工作目录。
134
- * @returns {Promise<{files: Array<{type: string, path: string, size: number}>, debug: string[]}>}
135
- */
136
- async _processMediaItems(message, sender) {
137
- const items = message.item_list ?? []
138
- const mediaFiles = []
139
- const debugInfo = []
140
-
141
- for (const item of items) {
142
- try {
143
- if (item.type === 2) { // 图片:image_item.media 是 {encrypt_query_param, aes_key, encrypt_type} 对象
144
- const img = item.image_item
145
- if (!img) {
146
- const msg = `图片项缺少 image_item`
147
- this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
148
- debugInfo.push(msg)
149
- continue
150
- }
151
-
152
- const aesKey = img.aeskey || img.aes_key || img.media?.aes_key || img.media?.aeskey
153
- if (!aesKey) {
154
- const fields = Object.keys(img).join(', ')
155
- const msg = `图片缺 aes_key,字段: ${fields}`
156
- this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
157
- debugInfo.push(msg)
158
- continue
159
- }
160
-
161
- // 从 image_item 或 image_item.media 对象提取 CDN 下载参数
162
- const mediaObj = img.media
163
- const encryptedParam = img.encrypt_query_param
164
- || img.encrypted_query_param
165
- || mediaObj?.encrypt_query_param
166
- || mediaObj?.encrypted_query_param
167
- || mediaObj?.encrypt_query_param_full
168
- || (typeof mediaObj === 'string' ? mediaObj : undefined)
169
- const fullUrl = img.full_url || img.url || mediaObj?.full_url || mediaObj?.url
170
- debugInfo.push(`media 类型: ${typeof mediaObj}, param=${!!encryptedParam}`)
171
-
172
- const file = await this._downloadMediaItem({
173
- encryptedQueryParam: encryptedParam,
174
- fullUrl,
175
- aesKeyBase64: aesKey,
176
- sender,
177
- mediaType: 'image',
178
- filename: `image_${Date.now()}.jpg`,
179
- })
180
- if (file) {
181
- mediaFiles.push(file)
182
- debugInfo.push(`成功: ${file.size} 字节`)
183
- } else {
184
- debugInfo.push(`下载返回 null`)
185
- }
186
- } else if (item.type === 4) { // 文件:file_item.media 是 {encrypt_query_param, aes_key,...}
187
- const fileItem = item.file_item
188
- if (!fileItem) {
189
- debugInfo.push(`文件项缺少 file_item`)
190
- continue
191
- }
192
- const aesKey = fileItem.aes_key || fileItem.aeskey || fileItem.media?.aes_key || fileItem.media?.aeskey
193
- const encryptedParam = fileItem.encrypt_query_param
194
- || fileItem.encrypted_query_param
195
- || fileItem.media?.encrypt_query_param
196
- || fileItem.media?.encrypted_query_param
197
- || (typeof fileItem.media === 'string' ? fileItem.media : undefined)
198
- const fullUrl = fileItem.full_url || fileItem.url || fileItem.media?.full_url || fileItem.media?.url
199
-
200
- if (!encryptedParam && !fullUrl) {
201
- debugInfo.push(`文件缺 media/encrypt_query_param,字段: ${Object.keys(fileItem).join(', ')}`)
202
- continue
203
- }
204
- if (!aesKey) {
205
- debugInfo.push(`文件缺 aes_key,字段: ${Object.keys(fileItem).join(', ')}`)
206
- continue
207
- }
208
- const file = await this._downloadMediaItem({
209
- encryptedQueryParam: encryptedParam,
210
- fullUrl,
211
- aesKeyBase64: aesKey,
212
- sender,
213
- mediaType: 'file',
214
- filename: fileItem.file_name || fileItem.filename || `file_${Date.now()}`,
215
- })
216
- if (file) {
217
- mediaFiles.push(file)
218
- debugInfo.push(`文件成功: ${file.size} 字节`)
219
- } else {
220
- debugInfo.push(`文件下载返回 null`)
221
- }
222
- } else if (item.type === 3) { // 语音(iLink 自动转文字,原始音频暂不下载)
223
- // 语音转文字已在 extractText 中作为文本提取;原始音频下载留待后续
224
- continue
225
- } else if (item.type === 5) { // 视频:video_item.media
226
- const video = item.video_item
227
- if (!video) {
228
- debugInfo.push(`视频项缺少 video_item`)
229
- continue
230
- }
231
- const aesKey = video.aes_key || video.aeskey || video.media?.aes_key || video.media?.aeskey
232
- const encryptedParam = video.encrypt_query_param
233
- || video.encrypted_query_param
234
- || video.media?.encrypt_query_param
235
- || video.media?.encrypted_query_param
236
- || (typeof video.media === 'string' ? video.media : undefined)
237
- const fullUrl = video.full_url || video.url || video.media?.full_url || video.media?.url
238
-
239
- if (!encryptedParam && !fullUrl) {
240
- debugInfo.push(`视频缺 media/encrypt_query_param,字段: ${Object.keys(video).join(', ')}`)
241
- continue
242
- }
243
- if (!aesKey) {
244
- debugInfo.push(`视频缺 aes_key,字段: ${Object.keys(video).join(', ')}`)
245
- continue
246
- }
247
- const file = await this._downloadMediaItem({
248
- encryptedQueryParam: encryptedParam,
249
- fullUrl,
250
- aesKeyBase64: aesKey,
251
- sender,
252
- mediaType: 'video',
253
- filename: `video_${Date.now()}.mp4`,
254
- })
255
- if (file) {
256
- mediaFiles.push(file)
257
- debugInfo.push(`视频成功: ${file.size} 字节`)
258
- } else {
259
- debugInfo.push(`视频下载返回 null`)
260
- }
261
- }
262
- } catch (error) {
263
- const msg = `处理媒体项 type=${item.type} 失败: ${error?.message ?? error}`
264
- this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
265
- debugInfo.push(msg)
266
- }
267
- }
268
-
269
- return { files: mediaFiles, debug: debugInfo }
270
- }
271
-
272
- /**
273
- * 下载并解密一个媒体项,保存到工作目录的 .wechat-media/ 子目录。
274
- * aesKeyBase64 可能为:base64(raw16) / base64(hex32) / 裸 hex32 字符串 —— 统一在 parseAesKey 归一化。
275
- */
276
- async _downloadMediaItem({ encryptedQueryParam, fullUrl, aesKeyBase64, sender, mediaType, filename }) {
277
- if (!encryptedQueryParam && !fullUrl) return null
278
- if (!aesKeyBase64) {
279
- this.logger?.warn?.(`[dsh-bridge wechat] media item missing aes_key, cannot decrypt`)
280
- return null
281
- }
282
-
283
- const { downloadMedia, normalizeAesKey } = await import('./media.js')
284
- // 图片的 image_item.aeskey 可能是裸 hex(32 字符),需归一化
285
- const normalizedKey = normalizeAesKey(aesKeyBase64)
286
- if (!normalizedKey) {
287
- this.logger?.warn?.(`[dsh-bridge wechat] media item aes_key 无法解析: ${aesKeyBase64.slice(0, 16)}...`)
288
- return null
289
- }
290
- const plaintext = await downloadMedia({
291
- encryptedQueryParam,
292
- fullUrl,
293
- aesKeyBase64: normalizedKey,
294
- timeoutMs: 60000,
295
- })
296
-
297
- // 保存到工作目录的 .wechat-media/ 子目录
298
- const { mkdir, writeFile } = await import('node:fs/promises')
299
- const { join } = await import('node:path')
300
- const cwd = this.config.cwd || process.cwd()
301
- const mediaDir = join(cwd, '.wechat-media')
302
- await mkdir(mediaDir, { recursive: true })
303
-
304
- const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_')
305
- const filePath = join(mediaDir, `${Date.now()}_${safeName}`)
306
- await writeFile(filePath, plaintext)
307
-
308
- this.logger?.info?.(`[dsh-bridge wechat] downloaded ${mediaType} from ${sender}: ${filePath} (${plaintext.length} bytes)`)
309
- return { type: mediaType, path: filePath, size: plaintext.length }
310
- }
311
- }
312
-
313
- // ---------------------------------------------------------------------------
314
- // 微信消息解析辅助(协议特定)
315
- // ---------------------------------------------------------------------------
316
-
317
- function isGroupMessage(message, accountId) {
318
- const roomId = String(message.room_id ?? message.chat_room_id ?? '').trim()
319
- if (roomId) return true
320
- const toUserId = String(message.to_user_id ?? '').trim()
321
- const sender = String(message.from_user_id ?? '').trim()
322
- return Boolean(toUserId && accountId && toUserId !== accountId && message.msg_type === 1)
323
- }
324
-
325
- function extractText(message) {
326
- const items = Array.isArray(message.item_list) ? message.item_list : []
327
- const texts = []
328
- for (const item of items) {
329
- if (item?.type === 1) {
330
- const text = String(item.text_item?.text ?? '')
331
- if (text.trim()) texts.push(text)
332
- } else if (item?.type === 3) {
333
- // 语音:iLink 自动转文字,在 voice_item.text
334
- const voiceText = String(item.voice_item?.text ?? '')
335
- if (voiceText.trim()) texts.push(voiceText)
336
- }
337
- }
338
- return texts.join('\n')
339
- }
340
-
341
- // 导出,便于测试与复用
342
- export const wechatNodeHelpers = {
343
- splitForWechat: conversationBridgeHelpers.splitForIM,
344
- digestLine: conversationBridgeHelpers.digestLine,
345
- textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
346
- extractText,
347
- isGroupMessage,
348
- listSessions: conversationBridgeHelpers.listSessions,
349
- sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
350
- }
1
+ // dsh-bridge WeChat conversation node
2
+ //
3
+ // 微信 ⇄ DSH 会话桥:把 iLink 入站消息解析后交给平台无关的 ConversationBridge 处理,
4
+ // 出站通过 ctx.wechat 发送。负责微信协议相关的部分:
5
+ // - 消息解析(extractText / isGroupMessage)
6
+ // - 媒体处理(图片/文件/语音/视频:下载、AES 解密、保存到 .wechat-media/)
7
+ // - 白名单/会话/审批/命令路由等平台无关逻辑继承自 ConversationBridge
8
+ //
9
+ // 由 Jesse-njx/dsh-chatnode-wechat 移植精简而来。消费的 DSH 服务:
10
+ // ctx.wechat (本插件 gateway 提供)sendText/sendTyping/accountId
11
+ // ctx.sessions (DSH 宿主提供)list/get
12
+ // ctx.agents (DSH 宿主提供)create/get/resume
13
+ // ctx.approval (DSH 宿主提供)approval/request 事件
14
+ //
15
+ // 安全边界:强制白名单(allowFrom),非白名单发件人绝不喂给模型。
16
+
17
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
18
+ import { gatewayConstants } from './gateway.js'
19
+
20
+ const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
21
+
22
+ // 将 ctx.wechat 网关适配为 ConversationBridge 需要的 Platform 消息接口
23
+ function makePlatform(ctx) {
24
+ return {
25
+ id: 'wechat',
26
+ name: '微信',
27
+ get accountId() { return ctx.wechat?.accountId ?? '' },
28
+ get status() { return ctx.wechat?.status ?? 'idle' },
29
+ get capabilities() {
30
+ return {
31
+ supportsGroup: false, // v0.1 不处理群消息
32
+ supportsMedia: true,
33
+ supportsVoice: true,
34
+ supportsTyping: true,
35
+ maxMessageChars: MAX_MESSAGE_CHARS,
36
+ }
37
+ },
38
+ sendText: (peer, text) => ctx.wechat.sendText(peer, text),
39
+ sendMediaFile: (peer, filePath) => ctx.wechat.sendMediaFile(peer, filePath),
40
+ sendTyping: (peer, state) => ctx.wechat.sendTyping(peer, state),
41
+ }
42
+ }
43
+
44
+ export class WechatConversationNode extends ConversationBridge {
45
+ /**
46
+ * @param {object} ctx Cordis 上下文(含 ctx.wechat 网关服务)
47
+ * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
48
+ * @param {object} logger 日志器
49
+ * @param {object} [opts]
50
+ * @param {(senderId: string) => void} [opts.onFirstSender]
51
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
52
+ */
53
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
54
+ super({
55
+ ctx,
56
+ logger,
57
+ config,
58
+ platform: makePlatform(ctx),
59
+ onFirstSender,
60
+ onActiveSessionChange,
61
+ })
62
+
63
+ this.disposers.push(this.ctx.on('wechat/message', (message) => {
64
+ void this._handleInbound(message)
65
+ }))
66
+ }
67
+
68
+ // ---- 入站 ----
69
+
70
+ async _handleInbound(message) {
71
+ if (this.ctx?.wechat?.stopPollingLocal) return
72
+ const sender = String(message.from_user_id ?? '').trim()
73
+ if (!sender) return
74
+
75
+ // 调试:记录收到的消息类型
76
+ const items = message.item_list ?? []
77
+ const itemTypes = items.map((it) => it.type).join(',')
78
+ this.logger?.info?.(`[dsh-bridge wechat] received message from ${sender}, item_types=[${itemTypes}]`)
79
+
80
+ // 快速预检查:白名单非空时,未授权发件人直接忽略(不下载媒体,防资源滥用)。
81
+ // 白名单为空的"首条自动授权"场景交给 handleInbound 处理。
82
+ if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
83
+ this.logger?.info?.(`[dsh-bridge wechat] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
84
+ return
85
+ }
86
+
87
+ // v0.2: 处理媒体项(图片/文件/语音/视频)
88
+ this.logger?.info?.(`[dsh-bridge wechat] processing media items for ${sender}...`)
89
+ let mediaFiles = []
90
+ let hadMediaItems = false
91
+ let mediaError = null
92
+ let debugInfo = [] // 调试信息
93
+ try {
94
+ hadMediaItems = items.some((it) => it.type === 2 || it.type === 4 || it.type === 5) // 图片/文件/视频
95
+ const result = await this._processMediaItems(message, sender)
96
+ mediaFiles = result.files
97
+ debugInfo = result.debug
98
+ this.logger?.info?.(`[dsh-bridge wechat] media processing done, got ${mediaFiles.length} files`)
99
+ } catch (error) {
100
+ // 尽可能捕获完整错误信息
101
+ mediaError = error?.message || error?.toString?.() || JSON.stringify(error) || '未知错误'
102
+ this.logger?.error?.(`[dsh-bridge wechat] media processing failed: ${mediaError}`, error)
103
+ // 媒体处理失败不阻断文本消息处理
104
+ }
105
+
106
+ const text = extractText(message)
107
+ if (!text.trim() && mediaFiles.length === 0) {
108
+ // 如果原消息有媒体项但下载失败,给用户提示(包含错误详情+调试信息)
109
+ if (hadMediaItems) {
110
+ let errMsg = mediaError ? `媒体处理失败: ${mediaError.substring(0, 150)}` : '媒体消息处理失败'
111
+ if (debugInfo.length > 0) {
112
+ errMsg += `\n调试信息:\n${debugInfo.join('\n')}`
113
+ }
114
+ await this.sendText(`❌ **${errMsg}**`)
115
+ return
116
+ }
117
+ this.logger?.info?.(`[dsh-bridge wechat] ignore empty message from ${sender}`)
118
+ return
119
+ }
120
+
121
+ // 构建完整消息内容(文本 + 媒体文件路径)
122
+ let fullText = text
123
+ if (mediaFiles.length > 0) {
124
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
125
+ fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
126
+ }
127
+
128
+ // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
129
+ // outboundPeer:微信单聊的会话级发送目标就是 sender 本身(T2.3 轮次绑定)
130
+ const isGroup = isGroupMessage(message, this.gatewayAccountId)
131
+ await this.handleInbound({ senderId: sender, text: fullText, isGroup, outboundPeer: { peerId: sender } })
132
+ }
133
+
134
+ /**
135
+ * 处理消息中的媒体项(v0.2):下载、解密、保存到工作目录。
136
+ * @returns {Promise<{files: Array<{type: string, path: string, size: number}>, debug: string[]}>}
137
+ */
138
+ async _processMediaItems(message, sender) {
139
+ const items = message.item_list ?? []
140
+ const mediaFiles = []
141
+ const debugInfo = []
142
+
143
+ for (const item of items) {
144
+ try {
145
+ if (item.type === 2) { // 图片:image_item.media 是 {encrypt_query_param, aes_key, encrypt_type} 对象
146
+ const img = item.image_item
147
+ if (!img) {
148
+ const msg = `图片项缺少 image_item`
149
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
150
+ debugInfo.push(msg)
151
+ continue
152
+ }
153
+
154
+ const aesKey = img.aeskey || img.aes_key || img.media?.aes_key || img.media?.aeskey
155
+ if (!aesKey) {
156
+ const fields = Object.keys(img).join(', ')
157
+ const msg = `图片缺 aes_key,字段: ${fields}`
158
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
159
+ debugInfo.push(msg)
160
+ continue
161
+ }
162
+
163
+ // image_item 或 image_item.media 对象提取 CDN 下载参数
164
+ const mediaObj = img.media
165
+ const encryptedParam = img.encrypt_query_param
166
+ || img.encrypted_query_param
167
+ || mediaObj?.encrypt_query_param
168
+ || mediaObj?.encrypted_query_param
169
+ || mediaObj?.encrypt_query_param_full
170
+ || (typeof mediaObj === 'string' ? mediaObj : undefined)
171
+ const fullUrl = img.full_url || img.url || mediaObj?.full_url || mediaObj?.url
172
+ debugInfo.push(`media 类型: ${typeof mediaObj}, param=${!!encryptedParam}`)
173
+
174
+ const file = await this._downloadMediaItem({
175
+ encryptedQueryParam: encryptedParam,
176
+ fullUrl,
177
+ aesKeyBase64: aesKey,
178
+ sender,
179
+ mediaType: 'image',
180
+ filename: `image_${Date.now()}.jpg`,
181
+ })
182
+ if (file) {
183
+ mediaFiles.push(file)
184
+ debugInfo.push(`成功: ${file.size} 字节`)
185
+ } else {
186
+ debugInfo.push(`下载返回 null`)
187
+ }
188
+ } else if (item.type === 4) { // 文件:file_item.media 是 {encrypt_query_param, aes_key,...}
189
+ const fileItem = item.file_item
190
+ if (!fileItem) {
191
+ debugInfo.push(`文件项缺少 file_item`)
192
+ continue
193
+ }
194
+ const aesKey = fileItem.aes_key || fileItem.aeskey || fileItem.media?.aes_key || fileItem.media?.aeskey
195
+ const encryptedParam = fileItem.encrypt_query_param
196
+ || fileItem.encrypted_query_param
197
+ || fileItem.media?.encrypt_query_param
198
+ || fileItem.media?.encrypted_query_param
199
+ || (typeof fileItem.media === 'string' ? fileItem.media : undefined)
200
+ const fullUrl = fileItem.full_url || fileItem.url || fileItem.media?.full_url || fileItem.media?.url
201
+
202
+ if (!encryptedParam && !fullUrl) {
203
+ debugInfo.push(`文件缺 media/encrypt_query_param,字段: ${Object.keys(fileItem).join(', ')}`)
204
+ continue
205
+ }
206
+ if (!aesKey) {
207
+ debugInfo.push(`文件缺 aes_key,字段: ${Object.keys(fileItem).join(', ')}`)
208
+ continue
209
+ }
210
+ const file = await this._downloadMediaItem({
211
+ encryptedQueryParam: encryptedParam,
212
+ fullUrl,
213
+ aesKeyBase64: aesKey,
214
+ sender,
215
+ mediaType: 'file',
216
+ filename: fileItem.file_name || fileItem.filename || `file_${Date.now()}`,
217
+ })
218
+ if (file) {
219
+ mediaFiles.push(file)
220
+ debugInfo.push(`文件成功: ${file.size} 字节`)
221
+ } else {
222
+ debugInfo.push(`文件下载返回 null`)
223
+ }
224
+ } else if (item.type === 3) { // 语音(iLink 自动转文字,原始音频暂不下载)
225
+ // 语音转文字已在 extractText 中作为文本提取;原始音频下载留待后续
226
+ continue
227
+ } else if (item.type === 5) { // 视频:video_item.media
228
+ const video = item.video_item
229
+ if (!video) {
230
+ debugInfo.push(`视频项缺少 video_item`)
231
+ continue
232
+ }
233
+ const aesKey = video.aes_key || video.aeskey || video.media?.aes_key || video.media?.aeskey
234
+ const encryptedParam = video.encrypt_query_param
235
+ || video.encrypted_query_param
236
+ || video.media?.encrypt_query_param
237
+ || video.media?.encrypted_query_param
238
+ || (typeof video.media === 'string' ? video.media : undefined)
239
+ const fullUrl = video.full_url || video.url || video.media?.full_url || video.media?.url
240
+
241
+ if (!encryptedParam && !fullUrl) {
242
+ debugInfo.push(`视频缺 media/encrypt_query_param,字段: ${Object.keys(video).join(', ')}`)
243
+ continue
244
+ }
245
+ if (!aesKey) {
246
+ debugInfo.push(`视频缺 aes_key,字段: ${Object.keys(video).join(', ')}`)
247
+ continue
248
+ }
249
+ const file = await this._downloadMediaItem({
250
+ encryptedQueryParam: encryptedParam,
251
+ fullUrl,
252
+ aesKeyBase64: aesKey,
253
+ sender,
254
+ mediaType: 'video',
255
+ filename: `video_${Date.now()}.mp4`,
256
+ })
257
+ if (file) {
258
+ mediaFiles.push(file)
259
+ debugInfo.push(`视频成功: ${file.size} 字节`)
260
+ } else {
261
+ debugInfo.push(`视频下载返回 null`)
262
+ }
263
+ }
264
+ } catch (error) {
265
+ const msg = `处理媒体项 type=${item.type} 失败: ${error?.message ?? error}`
266
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
267
+ debugInfo.push(msg)
268
+ }
269
+ }
270
+
271
+ return { files: mediaFiles, debug: debugInfo }
272
+ }
273
+
274
+ /**
275
+ * 下载并解密一个媒体项,保存到工作目录的 .wechat-media/ 子目录。
276
+ * aesKeyBase64 可能为:base64(raw16) / base64(hex32) / hex32 字符串 —— 统一在 parseAesKey 归一化。
277
+ */
278
+ async _downloadMediaItem({ encryptedQueryParam, fullUrl, aesKeyBase64, sender, mediaType, filename }) {
279
+ if (!encryptedQueryParam && !fullUrl) return null
280
+ if (!aesKeyBase64) {
281
+ this.logger?.warn?.(`[dsh-bridge wechat] media item missing aes_key, cannot decrypt`)
282
+ return null
283
+ }
284
+
285
+ const { downloadMedia, normalizeAesKey } = await import('./media.js')
286
+ // 图片的 image_item.aeskey 可能是裸 hex(32 字符),需归一化
287
+ const normalizedKey = normalizeAesKey(aesKeyBase64)
288
+ if (!normalizedKey) {
289
+ this.logger?.warn?.(`[dsh-bridge wechat] media item aes_key 无法解析: ${aesKeyBase64.slice(0, 16)}...`)
290
+ return null
291
+ }
292
+ const plaintext = await downloadMedia({
293
+ encryptedQueryParam,
294
+ fullUrl,
295
+ aesKeyBase64: normalizedKey,
296
+ timeoutMs: 60000,
297
+ })
298
+
299
+ // 保存到工作目录的 .wechat-media/ 子目录
300
+ const { mkdir, writeFile } = await import('node:fs/promises')
301
+ const { join } = await import('node:path')
302
+ const cwd = this.config.cwd || process.cwd()
303
+ const mediaDir = join(cwd, '.wechat-media')
304
+ await mkdir(mediaDir, { recursive: true })
305
+
306
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_')
307
+ const filePath = join(mediaDir, `${Date.now()}_${safeName}`)
308
+ await writeFile(filePath, plaintext)
309
+
310
+ this.logger?.info?.(`[dsh-bridge wechat] downloaded ${mediaType} from ${sender}: ${filePath} (${plaintext.length} bytes)`)
311
+ return { type: mediaType, path: filePath, size: plaintext.length }
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // 微信消息解析辅助(协议特定)
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function isGroupMessage(message, accountId) {
320
+ const roomId = String(message.room_id ?? message.chat_room_id ?? '').trim()
321
+ if (roomId) return true
322
+ const toUserId = String(message.to_user_id ?? '').trim()
323
+ const sender = String(message.from_user_id ?? '').trim()
324
+ return Boolean(toUserId && accountId && toUserId !== accountId && message.msg_type === 1)
325
+ }
326
+
327
+ function extractText(message) {
328
+ const items = Array.isArray(message.item_list) ? message.item_list : []
329
+ const texts = []
330
+ for (const item of items) {
331
+ if (item?.type === 1) {
332
+ const text = String(item.text_item?.text ?? '')
333
+ if (text.trim()) texts.push(text)
334
+ } else if (item?.type === 3) {
335
+ // 语音:iLink 自动转文字,在 voice_item.text
336
+ const voiceText = String(item.voice_item?.text ?? '')
337
+ if (voiceText.trim()) texts.push(voiceText)
338
+ }
339
+ }
340
+ return texts.join('\n')
341
+ }
342
+
343
+ // 导出,便于测试与复用
344
+ export const wechatNodeHelpers = {
345
+ splitForWechat: conversationBridgeHelpers.splitForIM,
346
+ digestLine: conversationBridgeHelpers.digestLine,
347
+ textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
348
+ extractText,
349
+ isGroupMessage,
350
+ listSessions: conversationBridgeHelpers.listSessions,
351
+ sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
352
+ }