@wenbin_wb/dsh-bridge 1.0.8 → 1.2.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.
@@ -0,0 +1,1040 @@
1
+ // dsh-bridge WeChat conversation node
2
+ //
3
+ // 微信 ⇄ DSH 会话桥:把 iLink 入站消息路由到 DSH agent 会话,并把会话事件以 digest
4
+ // 摘要形式回传到微信。负责:白名单、会话切换/新建/停止、审批问答、出站分块限流。
5
+ //
6
+ // 由 Jesse-njx/dsh-chatnode-wechat 移植精简而来。消费的 DSH 服务:
7
+ // ctx.wechat (本插件 gateway 提供)sendText/sendTyping/accountId
8
+ // ctx.sessions (DSH 宿主提供)list/get
9
+ // ctx.agents (DSH 宿主提供)create/get
10
+ // ctx.approval (DSH 宿主提供)approval/request 事件
11
+ //
12
+ // 安全边界:强制白名单(allowFrom),非白名单发件人绝不喂给模型。
13
+
14
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
15
+ import { randomUUID } from 'node:crypto'
16
+ import { statSync } from 'node:fs'
17
+ import { gatewayConstants } from './gateway.js'
18
+
19
+ const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // 出站分块(移植 hermes-agent _split_text_for_weixin_delivery)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const FENCE_RE = /^```([^\n`]*)\s*$/
26
+
27
+ function normalizeMarkdownBlocks(content) {
28
+ const lines = content.split('\n')
29
+ const out = []
30
+ let blankRun = 0
31
+ let inCode = false
32
+ for (const raw of lines) {
33
+ const line = raw.replace(/\s+$/, '')
34
+ if (FENCE_RE.test(line.trim())) {
35
+ inCode = !inCode
36
+ out.push(line)
37
+ blankRun = 0
38
+ continue
39
+ }
40
+ if (inCode) {
41
+ out.push(line)
42
+ continue
43
+ }
44
+ if (!line.trim()) {
45
+ blankRun += 1
46
+ if (blankRun <= 1) out.push('')
47
+ continue
48
+ }
49
+ blankRun = 0
50
+ out.push(line)
51
+ }
52
+ return out.join('\n').trim()
53
+ }
54
+
55
+ function splitMarkdownBlocks(content) {
56
+ const blocks = []
57
+ let current = []
58
+ let inCode = false
59
+ const flush = () => {
60
+ const block = current.join('\n').trim()
61
+ if (block) blocks.push(block)
62
+ current = []
63
+ }
64
+ for (const raw of content.split('\n')) {
65
+ const line = raw.replace(/\s+$/, '')
66
+ if (FENCE_RE.test(line.trim())) {
67
+ if (!inCode && current.length) flush()
68
+ current.push(line)
69
+ inCode = !inCode
70
+ if (!inCode) flush()
71
+ continue
72
+ }
73
+ if (inCode) {
74
+ current.push(line)
75
+ continue
76
+ }
77
+ if (!line.trim()) {
78
+ flush()
79
+ continue
80
+ }
81
+ current.push(line)
82
+ }
83
+ flush()
84
+ return blocks
85
+ }
86
+
87
+ function hardSplit(text, max) {
88
+ const chunks = []
89
+ let rest = text
90
+ while (rest.length > max) {
91
+ chunks.push(rest.slice(0, max))
92
+ rest = rest.slice(max)
93
+ }
94
+ if (rest) chunks.push(rest)
95
+ return chunks
96
+ }
97
+
98
+ function packBlocks(blocks, max) {
99
+ const units = []
100
+ let current = ''
101
+ for (const block of blocks) {
102
+ const candidate = current ? `${current}\n\n${block}` : block
103
+ if (candidate.length <= max) {
104
+ current = candidate
105
+ continue
106
+ }
107
+ if (current) units.push(current)
108
+ if (block.length <= max) {
109
+ current = block
110
+ } else {
111
+ units.push(...hardSplit(block, max))
112
+ current = ''
113
+ }
114
+ }
115
+ if (current) units.push(current)
116
+ return units
117
+ }
118
+
119
+ function splitForWechat(content, max = MAX_MESSAGE_CHARS) {
120
+ const normalized = normalizeMarkdownBlocks(content)
121
+ if (!normalized) return []
122
+ if (normalized.length <= max) return [normalized]
123
+ return packBlocks(splitMarkdownBlocks(normalized), max)
124
+ }
125
+
126
+ function textOfAssistantMessage(message) {
127
+ return (message.content ?? [])
128
+ .filter((block) => block?.type === 'text')
129
+ .map((block) => block.text)
130
+ .join('\n')
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // digest 摘要
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function digestLine(session) {
138
+ let turn = 0
139
+ let tools = 0
140
+ let lastTool = undefined
141
+ let inTurn = false
142
+ for (const event of session.events ?? []) {
143
+ if (event.type === 'turn/start') {
144
+ turn = event.data.turn
145
+ inTurn = true
146
+ tools = 0
147
+ lastTool = undefined
148
+ } else if (event.type === 'turn/end') {
149
+ inTurn = false
150
+ } else if (event.type === 'tool/call' && inTurn) {
151
+ tools += 1
152
+ lastTool = event.data.name
153
+ }
154
+ }
155
+ const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
156
+ const last = lastTool ? ` | 最近: ${lastTool}` : ''
157
+ return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // 出站
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function sendTextToPeer(node, text) {
165
+ const peer = node.peerId
166
+ if (!peer) return
167
+ const chunks = splitForWechat(text, node.config.maxMessageChars)
168
+ if (chunks.length === 0) return
169
+ await node.ctx.wechat.sendTyping(peer, 1).catch(() => {})
170
+ try {
171
+ for (let i = 0; i < chunks.length; i++) {
172
+ const result = await node.ctx.wechat.sendText(peer, chunks[i])
173
+ if (!result.success) {
174
+ node.logger?.warn?.(`[dsh-bridge wechat] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
175
+ break
176
+ }
177
+ if (i < chunks.length - 1 && node.config.sendChunkDelayMs > 0) {
178
+ await sleep(node.config.sendChunkDelayMs)
179
+ }
180
+ }
181
+ } finally {
182
+ await node.ctx.wechat.sendTyping(peer, 2).catch(() => {})
183
+ }
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // 会话桥编排
188
+ // ---------------------------------------------------------------------------
189
+
190
+ // 纯文本标记(用户偏好不用 emoji)
191
+ const MARK = {
192
+ ok: '[OK]',
193
+ err: '[错误]',
194
+ stop: '[已停止]',
195
+ idle: '[空闲]',
196
+ turn: '[新会话]',
197
+ ask: '[待确认]',
198
+ welcome: '[微信 Bot]',
199
+ list: '[会话列表]',
200
+ status: '[状态]',
201
+ warn: '[注意]',
202
+ }
203
+
204
+ export class WechatConversationNode {
205
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
206
+ this.ctx = ctx
207
+ this.logger = logger
208
+ this.onFirstSender = onFirstSender
209
+ this.onActiveSessionChange = onActiveSessionChange
210
+ this.config = {
211
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
212
+ digestIntervalSec: config.digestIntervalSec ?? 300,
213
+ approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
214
+ maxMessageChars: config.maxMessageChars ?? MAX_MESSAGE_CHARS,
215
+ sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
216
+ cwd: config.cwd,
217
+ agentPreset: config.agentPreset,
218
+ agentProvider: config.agentProvider,
219
+ agentModel: config.agentModel,
220
+ }
221
+ // 从配置恢复活动会话(v0.2.1:重启后保持会话)
222
+ // 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
223
+ this.activeSessionId = config.activeSessionId ?? null
224
+ this.peerId = null
225
+ this.pending = new Map() // number -> PendingApproval
226
+ this.approvalCounter = 0
227
+ this.disposers = []
228
+
229
+ this._attachOutbound()
230
+ this._attachApprovalBridge()
231
+ this.ctx.on('wechat/message', (message) => {
232
+ void this._handleInbound(message)
233
+ })
234
+ }
235
+
236
+ get gatewayAccountId() {
237
+ return this.ctx.wechat?.accountId ?? ''
238
+ }
239
+
240
+ activeSession() {
241
+ if (!this.activeSessionId) return undefined
242
+ return this.ctx.sessions?.get(this.activeSessionId)
243
+ }
244
+
245
+ activeAgent() {
246
+ if (!this.activeSessionId) return undefined
247
+ return this.ctx.agents?.get(this.activeSessionId)
248
+ }
249
+
250
+ ownsAgent(agent) {
251
+ return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
252
+ }
253
+
254
+ isAllowed(senderId) {
255
+ return Array.isArray(this.config.allowFrom) && this.config.allowFrom.includes(senderId)
256
+ }
257
+
258
+ setActiveSession(session) {
259
+ this.activeSessionId = session.id
260
+ // 持久化活动会话 ID
261
+ try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
262
+ }
263
+
264
+ // 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
265
+ // 发消息时通过 re-attach 逻辑拉起 agent。
266
+ setActiveSessionById(id) {
267
+ if (!id) return
268
+ this.activeSessionId = id
269
+ try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
270
+ }
271
+
272
+ async _pickDefaultSession() {
273
+ const sessions = await listSessions(this)
274
+ if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
275
+ }
276
+
277
+ async createSession(prompt, cwdOverride) {
278
+ // 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
279
+ const sessionId = `session-${randomUUID()}`
280
+ try {
281
+ const cwd = cwdOverride || this.config.cwd || process.cwd()
282
+ // 校验指定目录存在且是目录
283
+ if (cwdOverride) {
284
+ let ok = false
285
+ try { ok = statSync(cwd).isDirectory() } catch { ok = false }
286
+ if (!ok) {
287
+ await sendTextToPeer(this, `${MARK.err} 工作区目录不存在: ${cwd}`)
288
+ return
289
+ }
290
+ }
291
+ const meta = {
292
+ cwd,
293
+ agentPreset: this.config.agentPreset || 'routing-suite',
294
+ }
295
+ const agentOptions = {}
296
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
297
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
298
+ if (!agentOptions.provider || !agentOptions.model) {
299
+ try {
300
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
301
+ if (def) {
302
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
303
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
304
+ }
305
+ } catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
306
+ }
307
+ // 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
308
+ const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
309
+ this.setActiveSession(handle.agent.session)
310
+ if (prompt) {
311
+ handle.agent.followup(createUserMessage({
312
+ content: [{ type: 'text', text: prompt }],
313
+ source: { kind: 'user' },
314
+ }))
315
+ }
316
+ await sendTextToPeer(this, `${MARK.turn} 已创建会话 ${handle.agent.session.id}${cwdOverride ? `(工作区: ${cwdOverride})` : ''}${prompt ? '' : '(发消息即可开始)'}`)
317
+ } catch (error) {
318
+ await sendTextToPeer(this, `${MARK.err} 创建会话失败: ${error instanceof Error ? error.message : String(error)}`)
319
+ }
320
+ }
321
+
322
+ // ---- 审批 ----
323
+
324
+ nextApprovalNumber() {
325
+ this.approvalCounter += 1
326
+ return this.approvalCounter
327
+ }
328
+
329
+ registerApproval(number, approval) {
330
+ this.pending.set(number, approval)
331
+ }
332
+
333
+ clearApproval(number) {
334
+ const entry = this.pending.get(number)
335
+ if (entry) {
336
+ clearTimeout(entry.timer)
337
+ this.pending.delete(number)
338
+ }
339
+ }
340
+
341
+ resolveApproval(text) {
342
+ const entries = [...this.pending.entries()]
343
+ if (entries.length === 0) return false
344
+ let outcome
345
+ if (text === '/yes') outcome = 'allowed-once'
346
+ else if (text === '/no') outcome = 'rejected'
347
+ if (outcome) {
348
+ const [number, entry] = entries[entries.length - 1]
349
+ this.clearApproval(number)
350
+ entry.resolve(outcome)
351
+ return true
352
+ }
353
+ if ((text === '1' || text === '2') && entries.length === 1) {
354
+ const [number, entry] = entries[0]
355
+ this.clearApproval(number)
356
+ entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
357
+ return true
358
+ }
359
+ return false
360
+ }
361
+
362
+ dispose() {
363
+ for (const disposer of this.disposers) {
364
+ try { disposer() } catch { /* 忽略 */ }
365
+ }
366
+ this.disposers = []
367
+ for (const number of [...this.pending.keys()]) this.clearApproval(number)
368
+ }
369
+
370
+ // ---- 入站 ----
371
+
372
+ async _handleInbound(message) {
373
+ const sender = String(message.from_user_id ?? '').trim()
374
+ if (!sender) return
375
+
376
+ // 调试:记录收到的消息类型
377
+ const items = message.item_list ?? []
378
+ const itemTypes = items.map((it) => it.type).join(',')
379
+ this.logger?.info?.(`[dsh-bridge wechat] received message from ${sender}, item_types=[${itemTypes}]`)
380
+
381
+ if (!this.isAllowed(sender)) {
382
+ // 扫码即自动加入:白名单为空时,首个给 Bot 发消息的真实用户("扫码验收人")
383
+ // 自动纳入白名单。这是"扫码登录后第一条消息即完成授权"的一步到位体验。
384
+ if (this.config.allowFrom.length === 0 && !isGroupMessage(message, this.gatewayAccountId)) {
385
+ const text0 = extractText(message)
386
+ if (text0.trim()) {
387
+ this.config.allowFrom = [sender]
388
+ this.logger?.info?.(`[dsh-bridge wechat] auto-approved first sender ${sender} into allowlist (scan onboarding)`)
389
+ try { await this.onFirstSender?.(sender) } catch { /* 持久化失败不致命 */ }
390
+ } else {
391
+ this.logger?.info?.(`[dsh-bridge wechat] media-only first message from ${sender} not auto-approved (waiting for text)`)
392
+ }
393
+ } else {
394
+ this.logger?.info?.(`[dsh-bridge wechat] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
395
+ return
396
+ }
397
+ }
398
+ if (isGroupMessage(message, this.gatewayAccountId)) {
399
+ this.logger?.info?.(`[dsh-bridge wechat] ignore group message from ${sender} (v0.1: no group support)`)
400
+ return
401
+ }
402
+
403
+ // v0.2: 处理媒体项(图片/文件/语音/视频)
404
+ this.logger?.info?.(`[dsh-bridge wechat] processing media items for ${sender}...`)
405
+ let mediaFiles = []
406
+ let hadMediaItems = false
407
+ let mediaError = null
408
+ let debugInfo = [] // 调试信息
409
+ try {
410
+ const items = message.item_list ?? []
411
+ hadMediaItems = items.some((it) => it.type === 2 || it.type === 4 || it.type === 5) // 图片/文件/视频
412
+ const result = await this._processMediaItems(message, sender)
413
+ mediaFiles = result.files
414
+ debugInfo = result.debug
415
+ this.logger?.info?.(`[dsh-bridge wechat] media processing done, got ${mediaFiles.length} files`)
416
+ } catch (error) {
417
+ // 尽可能捕获完整错误信息
418
+ mediaError = error?.message || error?.toString?.() || JSON.stringify(error) || '未知错误'
419
+ this.logger?.error?.(`[dsh-bridge wechat] media processing failed: ${mediaError}`, error)
420
+ // 媒体处理失败不阻断文本消息处理
421
+ }
422
+
423
+ const text = extractText(message)
424
+ if (!text.trim() && mediaFiles.length === 0) {
425
+ // 如果原消息有媒体项但下载失败,给用户提示(包含错误详情+调试信息)
426
+ if (hadMediaItems) {
427
+ let errMsg = mediaError ? `媒体处理失败: ${mediaError.substring(0, 150)}` : '媒体消息处理失败'
428
+ if (debugInfo.length > 0) {
429
+ errMsg += `\n调试信息:\n${debugInfo.join('\n')}`
430
+ }
431
+ await sendTextToPeer(this, `${MARK.err} ${errMsg}`)
432
+ return
433
+ }
434
+ this.logger?.info?.(`[dsh-bridge wechat] ignore empty message from ${sender}`)
435
+ return
436
+ }
437
+
438
+ this.peerId = sender
439
+
440
+ // 构建完整消息内容(文本 + 媒体文件路径)
441
+ let fullText = text
442
+ if (mediaFiles.length > 0) {
443
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
444
+ fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
445
+ }
446
+
447
+ if (await routeCommand(this, fullText)) return
448
+
449
+ let agent = this.activeAgent()
450
+ if (!agent && this.activeSessionId) {
451
+ // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
452
+ // agents.create(sessionId) 对已持久化的 session 会自动采纳其历史,对不存在的则新建。
453
+ try {
454
+ const meta = {
455
+ cwd: this.config.cwd || process.cwd(),
456
+ agentPreset: this.config.agentPreset || 'routing-suite',
457
+ }
458
+ const agentOptions = {}
459
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
460
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
461
+ if (!agentOptions.provider || !agentOptions.model) {
462
+ try {
463
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
464
+ if (def) {
465
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
466
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
467
+ }
468
+ } catch { /* ignore */ }
469
+ }
470
+ const handle = await this.ctx.agents.create({
471
+ sessionId: this.activeSessionId,
472
+ meta,
473
+ agentOptions,
474
+ })
475
+ agent = handle.agent
476
+ this.logger?.info?.(`[dsh-bridge wechat] re-attached agent to session ${this.activeSessionId}`)
477
+ } catch (err) {
478
+ this.logger?.warn?.(`[dsh-bridge wechat] failed to re-attach agent: ${err?.message ?? err}`)
479
+ }
480
+ }
481
+ if (!agent) {
482
+ await sendTextToPeer(this, `${MARK.idle} 没有活动会话。发送 /new <提示词> 开始一个新会话,或 /sessions 查看已有会话。`)
483
+ return
484
+ }
485
+
486
+ const messageValue = createUserMessage({
487
+ content: [{ type: 'text', text: fullText }],
488
+ source: { kind: 'user' },
489
+ })
490
+ agent.followup(messageValue)
491
+ await this.ctx.wechat.sendTyping(sender, 1).catch(() => {})
492
+ }
493
+
494
+ /**
495
+ * 处理消息中的媒体项(v0.2):下载、解密、保存到工作目录。
496
+ * @returns {Promise<{files: Array<{type: string, path: string, size: number}>, debug: string[]}>}
497
+ */
498
+ async _processMediaItems(message, sender) {
499
+ const items = message.item_list ?? []
500
+ const mediaFiles = []
501
+ const debugInfo = []
502
+
503
+ for (const item of items) {
504
+ try {
505
+ if (item.type === 2) { // 图片:image_item.media 是 {encrypt_query_param, aes_key, encrypt_type} 对象
506
+ const img = item.image_item
507
+ if (!img) {
508
+ const msg = `图片项缺少 image_item`
509
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
510
+ debugInfo.push(msg)
511
+ continue
512
+ }
513
+
514
+ const aesKey = img.aeskey || img.aes_key || img.media?.aes_key
515
+ if (!aesKey) {
516
+ const fields = Object.keys(img).join(', ')
517
+ const msg = `图片缺 aes_key,字段: ${fields}`
518
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
519
+ debugInfo.push(msg)
520
+ continue
521
+ }
522
+
523
+ // 从 image_item.media 对象提取 CDN 下载参数
524
+ const mediaObj = img.media
525
+ const encryptedParam = mediaObj?.encrypt_query_param || mediaObj?.encrypted_query_param || mediaObj?.encrypt_query_param_full || mediaObj
526
+ debugInfo.push(`media 类型: ${typeof mediaObj}, param=${!!encryptedParam && typeof mediaObj === 'object'}${typeof mediaObj === 'string' ? ' (字符串)' : ''}`)
527
+
528
+ const file = await this._downloadMediaItem({
529
+ encryptedQueryParam: mediaObj?.encrypt_query_param || (typeof mediaObj === 'string' ? mediaObj : undefined),
530
+ fullUrl: mediaObj?.full_url || mediaObj?.url,
531
+ aesKeyBase64: aesKey,
532
+ sender,
533
+ mediaType: 'image',
534
+ filename: `image_${Date.now()}.jpg`,
535
+ })
536
+ if (file) {
537
+ mediaFiles.push(file)
538
+ debugInfo.push(`成功: ${file.size} 字节`)
539
+ } else {
540
+ debugInfo.push(`下载返回 null`)
541
+ }
542
+ } else if (item.type === 4) { // 文件:file_item.media 是 {encrypt_query_param, aes_key,...}
543
+ const fileItem = item.file_item
544
+ if (!fileItem) {
545
+ debugInfo.push(`文件项缺少 file_item`)
546
+ continue
547
+ }
548
+ const aesKey = fileItem.aes_key || fileItem.media?.aes_key
549
+ const encryptedParam = fileItem.media?.encrypt_query_param || (typeof fileItem.media === 'string' ? fileItem.media : undefined)
550
+ if (!encryptedParam || !aesKey) {
551
+ debugInfo.push(`文件缺 media/aes_key,字段: ${Object.keys(fileItem).join(', ')}`)
552
+ continue
553
+ }
554
+ const file = await this._downloadMediaItem({
555
+ encryptedQueryParam: encryptedParam,
556
+ fullUrl: fileItem.media?.full_url || fileItem.full_url,
557
+ aesKeyBase64: aesKey,
558
+ sender,
559
+ mediaType: 'file',
560
+ filename: fileItem.file_name || fileItem.filename || `file_${Date.now()}`,
561
+ })
562
+ if (file) {
563
+ mediaFiles.push(file)
564
+ debugInfo.push(`文件成功: ${file.size} 字节`)
565
+ } else {
566
+ debugInfo.push(`文件下载返回 null`)
567
+ }
568
+ } else if (item.type === 3) { // 语音(iLink 自动转文字,原始音频暂不下载)
569
+ // 语音转文字已在 extractText 中作为文本提取;原始音频下载留待后续
570
+ continue
571
+ } else if (item.type === 5) { // 视频:video_item.media
572
+ const video = item.video_item
573
+ if (!video) {
574
+ debugInfo.push(`视频项缺少 video_item`)
575
+ continue
576
+ }
577
+ const aesKey = video.aes_key || video.media?.aes_key
578
+ const encryptedParam = video.media?.encrypt_query_param || (typeof video.media === 'string' ? video.media : undefined)
579
+ if (!encryptedParam || !aesKey) {
580
+ debugInfo.push(`视频缺 media/aes_key,字段: ${Object.keys(video).join(', ')}`)
581
+ continue
582
+ }
583
+ const file = await this._downloadMediaItem({
584
+ encryptedQueryParam: encryptedParam,
585
+ fullUrl: video.media?.full_url || video.full_url,
586
+ aesKeyBase64: aesKey,
587
+ sender,
588
+ mediaType: 'video',
589
+ filename: `video_${Date.now()}.mp4`,
590
+ })
591
+ if (file) {
592
+ mediaFiles.push(file)
593
+ debugInfo.push(`视频成功: ${file.size} 字节`)
594
+ } else {
595
+ debugInfo.push(`视频下载返回 null`)
596
+ }
597
+ }
598
+ } catch (error) {
599
+ const msg = `处理媒体项 type=${item.type} 失败: ${error?.message ?? error}`
600
+ this.logger?.warn?.(`[dsh-bridge wechat] ${msg}`)
601
+ debugInfo.push(msg)
602
+ }
603
+ }
604
+
605
+ return { files: mediaFiles, debug: debugInfo }
606
+ }
607
+
608
+ /**
609
+ * 下载并解密一个媒体项,保存到工作目录的 .wechat-media/ 子目录。
610
+ * aesKeyBase64 可能为:base64(raw16) / base64(hex32) / 裸 hex32 字符串 —— 统一在 parseAesKey 归一化。
611
+ */
612
+ async _downloadMediaItem({ encryptedQueryParam, fullUrl, aesKeyBase64, sender, mediaType, filename }) {
613
+ if (!encryptedQueryParam && !fullUrl) return null
614
+ if (!aesKeyBase64) {
615
+ this.logger?.warn?.(`[dsh-bridge wechat] media item missing aes_key, cannot decrypt`)
616
+ return null
617
+ }
618
+
619
+ const { downloadMedia, normalizeAesKey } = await import('./media.js')
620
+ // 图片的 image_item.aeskey 可能是裸 hex(32 字符),需归一化
621
+ const normalizedKey = normalizeAesKey(aesKeyBase64)
622
+ if (!normalizedKey) {
623
+ this.logger?.warn?.(`[dsh-bridge wechat] media item aes_key 无法解析: ${aesKeyBase64.slice(0, 16)}...`)
624
+ return null
625
+ }
626
+ const plaintext = await downloadMedia({
627
+ encryptedQueryParam,
628
+ fullUrl,
629
+ aesKeyBase64: normalizedKey,
630
+ timeoutMs: 60000,
631
+ })
632
+
633
+ // 保存到工作目录的 .wechat-media/ 子目录
634
+ const { mkdir, writeFile } = await import('node:fs/promises')
635
+ const { join } = await import('node:path')
636
+ const cwd = this.config.cwd || process.cwd()
637
+ const mediaDir = join(cwd, '.wechat-media')
638
+ await mkdir(mediaDir, { recursive: true })
639
+
640
+ const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_')
641
+ const filePath = join(mediaDir, `${Date.now()}_${safeName}`)
642
+ await writeFile(filePath, plaintext)
643
+
644
+ this.logger?.info?.(`[dsh-bridge wechat] downloaded ${mediaType} from ${sender}: ${filePath} (${plaintext.length} bytes)`)
645
+ return { type: mediaType, path: filePath, size: plaintext.length }
646
+ }
647
+
648
+ // ---- 出站事件绑定 ----
649
+
650
+ _attachOutbound() {
651
+ const digestState = new Map()
652
+ const stopHeartbeat = (state) => {
653
+ if (state.heartbeat) {
654
+ clearInterval(state.heartbeat)
655
+ state.heartbeat = undefined
656
+ }
657
+ }
658
+ const startHeartbeat = (session, state) => {
659
+ stopHeartbeat(state)
660
+ if (this.config.digestIntervalSec <= 0) return
661
+ state.heartbeat = setInterval(() => {
662
+ void sendTextToPeer(this, digestLine(session))
663
+ }, this.config.digestIntervalSec * 1000)
664
+ if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
665
+ }
666
+ const onEvent = (session, event) => {
667
+ if (session.id !== this.activeSessionId) return
668
+ const state = digestState.get(session.id) ?? { startedTurns: new Set() }
669
+ digestState.set(session.id, state)
670
+
671
+ if (event.type === 'turn/start') {
672
+ const turn = event.data.turn
673
+ if (!state.startedTurns.has(turn)) {
674
+ state.startedTurns.add(turn)
675
+ // 不发送"[OK] 收到,开始处理…",改用微信"正在输入…"指示 + 心跳进度。
676
+ // 心跳(digestIntervalSec)会周期性报告"[处理中] 第 N 轮…",避免刷屏。
677
+ if (this.peerId) this.ctx.wechat.sendTyping(this.peerId, 1).catch(() => {})
678
+ }
679
+ startHeartbeat(session, state)
680
+ return
681
+ }
682
+ if (event.type === 'assistant/message') {
683
+ const text = textOfAssistantMessage(event.data.message)
684
+ if (text.trim()) void sendTextToPeer(this, text)
685
+ return
686
+ }
687
+ if (event.type === 'turn/end') {
688
+ stopHeartbeat(state)
689
+ // 停止"正在输入…"指示
690
+ if (this.peerId) this.ctx.wechat.sendTyping(this.peerId, 2).catch(() => {})
691
+ const reason = event.data.reason
692
+ if (reason.kind === 'error') {
693
+ void sendTextToPeer(this, `${MARK.err} 处理出错: ${summarizeError(reason.error)}`)
694
+ } else if (reason.kind === 'aborted') {
695
+ void sendTextToPeer(this, `${MARK.stop} 已停止`)
696
+ } else if (reason.kind === 'max-tokens') {
697
+ void sendTextToPeer(this, `${MARK.warn} 达到输出上限,本轮已截断`)
698
+ }
699
+ return
700
+ }
701
+ }
702
+ const listener = (session, event) => onEvent(session, event)
703
+ const disposer = this.ctx.on('session/event', listener)
704
+ this.disposers.push(() => {
705
+ for (const state of digestState.values()) stopHeartbeat(state)
706
+ disposer()
707
+ })
708
+ }
709
+
710
+ // ---- 审批桥 ----
711
+
712
+ _attachApprovalBridge() {
713
+ const listener = async (req, next) => {
714
+ if (!this.ownsAgent(req.agent)) return next()
715
+ const peer = this.peerId
716
+ if (!peer) return next()
717
+
718
+ const number = this.nextApprovalNumber()
719
+ const timeoutSec = this.config.approvalTimeoutSec
720
+ const prompt = [
721
+ `${MARK.ask} #${number} 需要你的确认`,
722
+ `工具: ${req.toolName}`,
723
+ ...(req.reason ? [`原因: ${req.reason}`] : []),
724
+ `回复 /yes 同意,/no 拒绝(仅一条待确认时也可回复 1/2)`,
725
+ `${Math.max(1, Math.round(timeoutSec / 60))} 分钟内未回复将自动拒绝。`,
726
+ ].join('\n')
727
+
728
+ void sendTextToPeer(this, prompt)
729
+
730
+ const outcome = await new Promise((resolve) => {
731
+ const timer = setTimeout(() => {
732
+ this.clearApproval(number)
733
+ resolve('rejected')
734
+ }, timeoutSec * 1000)
735
+ if (typeof timer.unref === 'function') timer.unref()
736
+ this.registerApproval(number, { number, request: req, resolve, timer })
737
+ })
738
+
739
+ const label = outcome === 'allowed-once' ? `${MARK.ok} 已同意` : outcome === 'rejected' ? `${MARK.err} 已拒绝` : `[${outcome}]`
740
+ void sendTextToPeer(this, `${label}(#${number})`)
741
+ return outcome
742
+ }
743
+ const disposer = this.ctx.on('approval/request', listener)
744
+ this.disposers.push(disposer)
745
+ }
746
+ }
747
+
748
+ // ---------------------------------------------------------------------------
749
+ // 辅助
750
+ // ---------------------------------------------------------------------------
751
+
752
+ function isGroupMessage(message, accountId) {
753
+ const roomId = String(message.room_id ?? message.chat_room_id ?? '').trim()
754
+ if (roomId) return true
755
+ const toUserId = String(message.to_user_id ?? '').trim()
756
+ const sender = String(message.from_user_id ?? '').trim()
757
+ return Boolean(toUserId && accountId && toUserId !== accountId && message.msg_type === 1)
758
+ }
759
+
760
+ function extractText(message) {
761
+ const items = Array.isArray(message.item_list) ? message.item_list : []
762
+ const texts = []
763
+ for (const item of items) {
764
+ if (item?.type === 1) {
765
+ const text = String(item.text_item?.text ?? '')
766
+ if (text.trim()) texts.push(text)
767
+ } else if (item?.type === 3) {
768
+ // 语音:iLink 自动转文字,在 voice_item.text 里
769
+ const voiceText = String(item.voice_item?.text ?? '')
770
+ if (voiceText.trim()) texts.push(voiceText)
771
+ }
772
+ }
773
+ return texts.join('\n')
774
+ }
775
+
776
+ function summarizeError(error) {
777
+ if (error && typeof error === 'object' && 'message' in error) {
778
+ return String(error.message).slice(0, 200)
779
+ }
780
+ return String(error).slice(0, 200)
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // 命令
785
+ // ---------------------------------------------------------------------------
786
+
787
+ function sessionLabel(session) {
788
+ for (const event of session.events ?? []) {
789
+ if (event.type === 'user/message') {
790
+ const blocks = event.data.content ?? []
791
+ const text = blocks
792
+ .filter((block) => block.type === 'text')
793
+ .map((block) => block.text ?? '')
794
+ .join(' ')
795
+ .trim()
796
+ if (text) return text.length > 24 ? `${text.slice(0, 24)}…` : text
797
+ }
798
+ }
799
+ return '(空会话)'
800
+ }
801
+
802
+ // 从事件日志折叠会话标题(本地实现,等价 DSH foldSessionTitle):优先取最后的
803
+ // session/title 事件(DSH 生成的会话名),否则回退到第一条用户消息文本。
804
+ function foldTitle(events) {
805
+ const evts = events ?? []
806
+ for (let i = evts.length - 1; i >= 0; i--) {
807
+ const e = evts[i]
808
+ if (e && e.type === 'session/title' && e.data?.title) return String(e.data.title)
809
+ }
810
+ return null
811
+ }
812
+
813
+ // 列出会话:使用 DSH 官方 API(ctx.sessions + sessionPersistence),与 web 端一致。
814
+ // 返回 [{ id, createdAt, events?, seq?, cwd?, title? }],按时间倒序。
815
+ async function listSessions(node) {
816
+ const live = [...(node.ctx.sessions?.list() ?? [])]
817
+ const liveIds = new Set(live.map((s) => s.id))
818
+ // 内存活跃会话(带完整 events/title)
819
+ const liveMapped = live.map((s) => {
820
+ try {
821
+ const title = foldTitle(s.events ?? [])
822
+ if (title) return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd, title }
823
+ } catch { /* ignore */ }
824
+ return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd }
825
+ })
826
+ // 持久化会话(含 cwd,与 web 端过滤一致)
827
+ let cold = []
828
+ try {
829
+ const headers = await node.ctx.sessionPersistence?.list?.()
830
+ if (Array.isArray(headers)) {
831
+ const coldHeaders = headers.filter((h) => h && h.id && !liveIds.has(h.id) && h.cwd !== undefined)
832
+ // 并行加载每个冷会话的 events 以提取标题
833
+ cold = await Promise.all(coldHeaders.map(async (h) => {
834
+ let title
835
+ try {
836
+ const insp = await node.ctx.sessionPersistence.load(h.id)
837
+ title = foldTitle(insp.events ?? []) ?? undefined
838
+ } catch { /* 标题提取失败则只用 id */ }
839
+ return { id: h.id, createdAt: h.createdAt ?? 0, events: undefined, seq: 0, cwd: h.cwd, title }
840
+ }))
841
+ }
842
+ } catch { /* 持久化服务不可用时仅返回内存会话 */ }
843
+ return [...liveMapped, ...cold].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq)
844
+ }
845
+
846
+ // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
847
+ function listWorkspaces(node) {
848
+ try {
849
+ const list = node.ctx.workspaceRegistry?.list?.() ?? []
850
+ const out = []
851
+ for (const ws of list) {
852
+ if (ws && ws.path) out.push({ title: ws.title ?? ws.path, path: ws.path })
853
+ }
854
+ return out.sort((a, b) => String(a.path).localeCompare(String(b.path)))
855
+ } catch {
856
+ return []
857
+ }
858
+ }
859
+
860
+ async function routeCommand(node, text) {
861
+ const trimmed = text.trim()
862
+ if (!trimmed.startsWith('/')) return false
863
+
864
+ if (trimmed === '/yes' || trimmed === '/no' || /^[12]$/.test(trimmed)) {
865
+ if (node.resolveApproval(trimmed)) return true
866
+ }
867
+
868
+ const [command, ...rest] = trimmed.slice(1).split(/\s+/)
869
+ switch (command) {
870
+ case 'help':
871
+ await sendTextToPeer(node, helpText())
872
+ return true
873
+ case 'sessions':
874
+ await sendTextToPeer(node, await renderSessions(node))
875
+ return true
876
+ case 'use': {
877
+ const index = Number(rest[0])
878
+ const sessions = await listSessions(node)
879
+ if (!Number.isInteger(index) || index < 1 || index > sessions.length) {
880
+ await sendTextToPeer(node, `${MARK.err} 无效编号。可用: 1–${sessions.length}(/sessions 查看列表)`)
881
+ return true
882
+ }
883
+ const session = sessions[index - 1]
884
+ node.setActiveSessionById(session.id)
885
+ await sendTextToPeer(node, `${MARK.ok} 已切换到会话 #${index}(${session.id})`)
886
+ return true
887
+ }
888
+ case 'workspaces': {
889
+ const workspaces = listWorkspaces(node)
890
+ if (workspaces.length === 0) {
891
+ await sendTextToPeer(node, `${MARK.list} 没有可用的工作区。使用 /new <提示词> @<路径> 指定一个目录。`)
892
+ return true
893
+ }
894
+ const lines = workspaces.map((w, i) => {
895
+ const name = w.title && w.title !== w.path ? `**${w.title}** · \`${w.path}\`` : `\`${w.path}\``
896
+ return `${i + 1}. ${name}`
897
+ })
898
+ await sendTextToPeer(node, `${MARK.list}\n**可用工作区**(/new <提示词> @N 选择)\n\n${lines.join('\n')}`)
899
+ return true
900
+ }
901
+ case 'new': {
902
+ // 解析尾部 @N 或 @路径 作为工作区 cwd
903
+ const args = rest.join(' ').trim()
904
+ let cwd
905
+ let prompt = args
906
+ const atMatch = args.match(/\s+@(\S+)$/)
907
+ if (atMatch) {
908
+ prompt = args.slice(0, atMatch.index).trim()
909
+ const sel = atMatch[1]
910
+ const workspaces = listWorkspaces(node)
911
+ if (/^\d+$/.test(sel)) {
912
+ const idx = Number(sel)
913
+ const ws = workspaces[idx - 1]
914
+ if (ws) cwd = ws.path
915
+ else { await sendTextToPeer(node, `${MARK.err} 无效工作区编号 ${sel}。用 /workspaces 查看。`); return true }
916
+ } else {
917
+ cwd = sel
918
+ }
919
+ }
920
+ await node.createSession(prompt, cwd)
921
+ return true
922
+ }
923
+ case 'stop': {
924
+ const agent = node.activeAgent()
925
+ if (!agent) {
926
+ await sendTextToPeer(node, `${MARK.err} 没有活动的 agent`)
927
+ } else {
928
+ agent.cancel({ kind: 'user' })
929
+ await sendTextToPeer(node, `${MARK.stop} 已请求停止`)
930
+ }
931
+ return true
932
+ }
933
+ case 'status': {
934
+ const agent = node.activeAgent()
935
+ const session = node.activeSession()
936
+ if (!session) {
937
+ await sendTextToPeer(node, `${MARK.idle} 没有活动会话。发送 /new <提示词> 开始,或 /sessions 查看已有会话。`)
938
+ return true
939
+ }
940
+ const status = agent?.status ?? 'idle'
941
+ const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
942
+ const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
943
+ await sendTextToPeer(node, `${MARK.status}\n会话: ${session.id}\nagent: ${status}\n事件: ${session.seq} 条\n最近: ${reason}`)
944
+ return true
945
+ }
946
+ case 'start': // 别名:首次扫码自动开始一个会话
947
+ await node.createSession('')
948
+ return true
949
+ default:
950
+ await sendTextToPeer(node, `${MARK.err} 未知命令 /${command}\n${helpText()}`)
951
+ return true
952
+ }
953
+ }
954
+
955
+ function describeTurnEnd(reason) {
956
+ switch (reason.kind) {
957
+ case 'completed': return '[完成]'
958
+ case 'error': return '[出错]'
959
+ case 'aborted': return '[已停止]'
960
+ case 'blocked': return '[已阻塞]'
961
+ case 'max-tokens': return '[输出截断]'
962
+ case 'interrupted': return '[中断]'
963
+ default: return reason.kind
964
+ }
965
+ }
966
+
967
+ async function renderSessions(node) {
968
+ const all = await listSessions(node)
969
+ if (all.length === 0) return `${MARK.list} 没有会话。发送 /new <提示词> 开始。`
970
+ // 按工作区(真实 cwd)分组;无 cwd 的归入 '(未指定)'
971
+ const groups = new Map()
972
+ for (const s of all) {
973
+ const key = s.cwd || '(未指定)'
974
+ if (!groups.has(key)) groups.set(key, [])
975
+ groups.get(key).push(s)
976
+ }
977
+ const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
978
+ const parts = [`**会话列表** · 共 ${all.length} 个(/use N 切换)`, '']
979
+ let idx = 0
980
+ for (const [cwd, sessions] of sortedGroups) {
981
+ parts.push(`## ${cwd}`)
982
+ for (const session of sessions.slice(0, 20)) {
983
+ idx += 1
984
+ const active = session.id === node.activeSessionId ? ' **← 当前**' : ''
985
+ const title = session.title || (session.events ? sessionLabel(session) : '')
986
+ const label = title || fmtSessionId(session.id)
987
+ const when = session.createdAt ? fmtTime(session.createdAt) : ''
988
+ parts.push(`${idx}. \`${label}\``)
989
+ parts.push(` ${session.id} · ${when}${active}`)
990
+ }
991
+ if (sessions.length > 20) parts.push(` …该工作区共 ${sessions.length} 个`)
992
+ }
993
+ if (all.length > 50) parts.push('', `…共 ${all.length} 个会话,仅显示前若干`)
994
+ return `${MARK.list}\n${parts.join('\n')}`
995
+ }
996
+
997
+ // 时间戳 → 可读时间
998
+ function fmtTime(ms) {
999
+ try {
1000
+ const d = new Date(ms)
1001
+ const p = (n) => String(n).padStart(2, '0')
1002
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
1003
+ } catch { return '' }
1004
+ }
1005
+
1006
+ // 持久化会话没有加载 events,用时间戳作为可读标识
1007
+ function fmtSessionId(id) {
1008
+ const m = /^session-([0-9a-f]{8})/.exec(id ?? '')
1009
+ return m ? `session-${m[1]}…` : (id ?? '')
1010
+ }
1011
+
1012
+ function helpText() {
1013
+ return [
1014
+ `${MARK.welcome} 命令`,
1015
+ '/sessions — 列出会话(按工作区分组)',
1016
+ '/use N — 切换到会话 N',
1017
+ '/workspaces — 列出可用工作区',
1018
+ '/new <提示词> — 新建会话并开始(当前工作区)',
1019
+ '/new <提示词> @路径 — 在指定目录新建会话',
1020
+ '/new <提示词> @N — 用编号选择工作区(/workspaces)',
1021
+ '/stop — 停止当前任务',
1022
+ '/status — 查看状态',
1023
+ '/yes /no 或 1/2 — 回应权限请求',
1024
+ '/help — 本帮助',
1025
+ ].join('\n')
1026
+ }
1027
+
1028
+ // 导出,便于测试与复用
1029
+ export const wechatNodeHelpers = {
1030
+ splitForWechat,
1031
+ digestLine,
1032
+ textOfAssistantMessage,
1033
+ extractText,
1034
+ isGroupMessage,
1035
+ listSessions,
1036
+ }
1037
+
1038
+ function sleep(ms) {
1039
+ return new Promise((resolve) => setTimeout(resolve, ms))
1040
+ }