@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.
- package/CHANGELOG.md +295 -250
- package/README.en.md +408 -572
- package/README.md +430 -570
- package/client/client.js +3844 -3764
- package/client/index.js +140 -876
- package/client/mobile-styles.js +802 -0
- package/docs/fix-plan-202608.md +108 -0
- package/lib/auth/login-template.js +381 -381
- package/lib/auth/manager.js +531 -469
- package/lib/bridge-rpc.js +436 -511
- package/lib/cloudflared-manager.mjs +361 -345
- package/lib/compat.js +129 -0
- package/lib/feishu/index.js +225 -222
- package/lib/feishu/node.js +433 -409
- package/lib/index.js +1765 -1804
- package/lib/platform/base.js +147 -156
- package/lib/platform/commands.js +221 -0
- package/lib/platform/conversation-bridge.js +816 -1570
- package/lib/platform/dsh-storage.js +117 -0
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +191 -0
- package/lib/platform/session-catalog.js +372 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/index.js +312 -309
- package/lib/qq/node.js +532 -533
- package/lib/telegram/index.js +215 -212
- package/lib/telegram/node.js +348 -350
- package/lib/tunnel-client.mjs +39 -15
- package/lib/wechat/gateway.js +973 -960
- package/lib/wechat/index.js +244 -241
- package/lib/wechat/media.js +285 -281
- package/lib/wechat/node.js +352 -350
- package/package.json +106 -102
package/lib/wechat/node.js
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* @param {object}
|
|
47
|
-
* @param {object}
|
|
48
|
-
* @param {object}
|
|
49
|
-
* @param {
|
|
50
|
-
* @param {(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let
|
|
90
|
-
let
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
||
|
|
167
|
-
|| mediaObj?.
|
|
168
|
-
||
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
debugInfo.push(
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|| fileItem.
|
|
197
|
-
||
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
debugInfo.push(
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|| video.
|
|
236
|
-
||
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
debugInfo.push(
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
}
|