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