@wenbin_wb/dsh-bridge 2.10.7 → 2.10.9
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 +26 -0
- package/README.en.md +27 -0
- package/README.md +27 -0
- package/client/client.js +388 -5
- package/client/index.js +327 -3
- package/client/mobile-styles.js +68 -0
- package/docs/telegram-usage.md +1 -1
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/auth/manager.js +54 -4
- package/lib/bridge-rpc-constants.js +1 -0
- package/lib/bridge-rpc.js +17 -2
- package/lib/compat.js +129 -129
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/index.js +225 -225
- package/lib/feishu/node.js +439 -439
- package/lib/index.js +163 -28
- package/lib/platform/base.js +147 -147
- package/lib/platform/commands.js +221 -221
- package/lib/platform/conversation-bridge.js +821 -821
- package/lib/platform/dsh-storage.js +117 -117
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +229 -229
- package/lib/platform/session-catalog.js +372 -372
- package/lib/platform/stream-slices.js +21 -21
- package/lib/qq/index.js +312 -312
- package/lib/telegram/index.js +216 -216
- package/lib/telegram/node.js +322 -322
- package/lib/wechat/gateway.js +986 -986
- package/lib/wechat/index.js +244 -244
- package/lib/wechat/media.js +285 -285
- package/lib/wechat/node.js +352 -352
- package/package.json +2 -2
|
@@ -1,821 +1,821 @@
|
|
|
1
|
-
// dsh-bridge 平台无关的会话桥(核心类)
|
|
2
|
-
//
|
|
3
|
-
// 本模块只保留 ConversationBridge 类本身(白名单、会话生命周期、审批桥、
|
|
4
|
-
// 出站 digest/心跳、入站路由入口)。拆分出去的模块:
|
|
5
|
-
// - message-split.js 出站分块 + [SEND_FILE] 指令解析(纯函数)
|
|
6
|
-
// - dsh-storage.js DSH 私有存储读取兜底(workspace.json / projcache)
|
|
7
|
-
// - session-catalog.js 会话目录组织 / 渲染 / 格式化
|
|
8
|
-
// - commands.js 斜杠命令解释器(routeCommand)
|
|
9
|
-
//
|
|
10
|
-
// 平台相关的部分由子类(或组合)提供:
|
|
11
|
-
// - sendText(text, opts) / sendTyping(state):向当前 peer 发送
|
|
12
|
-
// - extractTextFrom(message):从平台消息提取文本
|
|
13
|
-
// - isGroupMessage(message):判断群消息
|
|
14
|
-
// - handlePlatformInbound(message):消息解析(返回 { senderId, text, isGroup } 或 null)
|
|
15
|
-
//
|
|
16
|
-
// 本类不直接依赖任何 IM 协议,只消费 DSH 官方服务:
|
|
17
|
-
// ctx.sessions / ctx.agents / ctx.approval / ctx.sessionPersistence / ctx.workspaceRegistry
|
|
18
|
-
|
|
19
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
20
|
-
import { randomUUID } from 'node:crypto'
|
|
21
|
-
import { stat } from 'node:fs/promises'
|
|
22
|
-
import { normalize } from 'node:path'
|
|
23
|
-
import { resolveFilePath } from './message-split.js'
|
|
24
|
-
import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText, isPathAllowedForSend } from './message-split.js'
|
|
25
|
-
import { routeCommand } from './commands.js'
|
|
26
|
-
import { listSessions, listWorkspaces, validateWorkspacePath, renderSessions, sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId, sessionLabel } from './session-catalog.js'
|
|
27
|
-
|
|
28
|
-
export { textOfAssistantMessage } from './message-split.js'
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// 出站分块(通用:按平台 maxMessageChars 硬分块 + 保留 fenced code block)
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
function digestLine(session) {
|
|
36
|
-
let turn = 0
|
|
37
|
-
let tools = 0
|
|
38
|
-
let lastTool = undefined
|
|
39
|
-
let inTurn = false
|
|
40
|
-
for (const event of session.events ?? []) {
|
|
41
|
-
if (event.type === 'turn/start') {
|
|
42
|
-
turn = event.data.turn
|
|
43
|
-
inTurn = true
|
|
44
|
-
tools = 0
|
|
45
|
-
lastTool = undefined
|
|
46
|
-
} else if (event.type === 'turn/end') {
|
|
47
|
-
inTurn = false
|
|
48
|
-
} else if (event.type === 'tool/call' && inTurn) {
|
|
49
|
-
tools += 1
|
|
50
|
-
lastTool = event.data.name
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (!inTurn || turn === 0) return null
|
|
54
|
-
const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
|
|
55
|
-
const last = lastTool ? ` | 最近: ${lastTool}` : ''
|
|
56
|
-
return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function summarizeError(error) {
|
|
60
|
-
if (error && typeof error === 'object' && 'message' in error) {
|
|
61
|
-
return String(error.message).slice(0, 200)
|
|
62
|
-
}
|
|
63
|
-
return String(error).slice(0, 200)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function sleep(ms) {
|
|
67
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// 会话桥基类
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
export class ConversationBridge {
|
|
75
|
-
/**
|
|
76
|
-
* @param {object} opts
|
|
77
|
-
* @param {object} opts.ctx Cordis 上下文
|
|
78
|
-
* @param {object} opts.logger 日志器
|
|
79
|
-
* @param {object} [opts.config] 已持久化配置(allowFrom/间隔/活动会话等)
|
|
80
|
-
* @param {object} opts.platform 所属 Platform 实例(提供 accountId/capabilities)—— 必需
|
|
81
|
-
* @param {(senderId: string) => void} [opts.onFirstSender]
|
|
82
|
-
* @param {(sessionId: string) => void} [opts.onActiveSessionChange]
|
|
83
|
-
*/
|
|
84
|
-
constructor({ ctx, logger, config = {}, platform, onFirstSender, onActiveSessionChange } = {}) {
|
|
85
|
-
if (!platform) {
|
|
86
|
-
throw new Error('ConversationBridge requires a platform instance')
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
this.ctx = ctx
|
|
90
|
-
this.logger = logger
|
|
91
|
-
this.platform = platform
|
|
92
|
-
this.onFirstSender = onFirstSender
|
|
93
|
-
this.onActiveSessionChange = onActiveSessionChange
|
|
94
|
-
|
|
95
|
-
const maxChars = platform.capabilities?.maxMessageChars ?? 2000
|
|
96
|
-
const rawMax = Number(config.maxMessageChars)
|
|
97
|
-
const safeMaxChars = (Number.isFinite(rawMax) && rawMax > 0) ? rawMax : maxChars
|
|
98
|
-
this.config = {
|
|
99
|
-
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
100
|
-
digestIntervalSec: config.digestIntervalSec ?? 300,
|
|
101
|
-
approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
|
|
102
|
-
maxMessageChars: safeMaxChars,
|
|
103
|
-
sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
|
|
104
|
-
cwd: config.cwd,
|
|
105
|
-
agentPreset: config.agentPreset,
|
|
106
|
-
agentProvider: config.agentProvider,
|
|
107
|
-
agentModel: config.agentModel,
|
|
108
|
-
// 群聊自动授权开关:默认关闭,防止任意陌生群 @机器人 即获得访问权(T2.5)
|
|
109
|
-
groupAutoApprove: config.groupAutoApprove === true,
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 从配置恢复活动会话(v0.2.1:重启后保持会话)
|
|
113
|
-
// 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
|
|
114
|
-
this.activeSessionId = config.activeSessionId ?? null
|
|
115
|
-
this.peerId = null
|
|
116
|
-
this.pending = new Map() // number -> PendingApproval
|
|
117
|
-
this.approvalCounter = 0
|
|
118
|
-
this.disposers = []
|
|
119
|
-
|
|
120
|
-
// 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
|
|
121
|
-
this._restoringConfig = null
|
|
122
|
-
this._restoringSessionMap = new Map() // sessionId -> Promise<Agent|null>
|
|
123
|
-
|
|
124
|
-
// T2.3:sessionId -> { outboundPeer, senderId }
|
|
125
|
-
// 记录每轮对话的发起者:出站事件流(assistant/turn/end/心跳/SEND_FILE/审批)绑定到
|
|
126
|
-
// 发起轮次的 peer,而不是"最近一条入站的 peer",防止 A 任务进行中 B 来消息把回复串到 B 的窗口
|
|
127
|
-
this._turnPeers = new Map()
|
|
128
|
-
// 入站串行队列:handleInbound 内部读写 this.peerId 等共享状态,并发入站会造成串扰
|
|
129
|
-
this._inboundQueue = Promise.resolve()
|
|
130
|
-
|
|
131
|
-
this._attachOutbound()
|
|
132
|
-
this._attachApprovalBridge()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
get gatewayAccountId() {
|
|
136
|
-
return this.platform?.accountId ?? ''
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
activeSession() {
|
|
140
|
-
if (!this.activeSessionId) return undefined
|
|
141
|
-
return this.ctx.sessions?.get(this.activeSessionId)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
activeAgent() {
|
|
145
|
-
if (!this.activeSessionId) return undefined
|
|
146
|
-
return this.ctx.agents?.get(this.activeSessionId)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
ownsAgent(agent) {
|
|
150
|
-
return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
isAllowed(senderId) {
|
|
154
|
-
if (!Array.isArray(this.config.allowFrom)) return false
|
|
155
|
-
if (this.config.allowFrom.length === 0) return false
|
|
156
|
-
return this.config.allowFrom.includes(senderId)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
setActiveSession(session) {
|
|
160
|
-
this.stopAllHeartbeats()
|
|
161
|
-
this._dropDigestState(this.activeSessionId)
|
|
162
|
-
this.activeSessionId = session.id
|
|
163
|
-
try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
|
|
167
|
-
// 发消息时通过 re-attach 逻辑拉起 agent。
|
|
168
|
-
setActiveSessionById(id) {
|
|
169
|
-
if (!id) return
|
|
170
|
-
this.stopAllHeartbeats()
|
|
171
|
-
this._dropDigestState(this.activeSessionId)
|
|
172
|
-
this.activeSessionId = id
|
|
173
|
-
try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// 释放指定会话的 digest 状态(活动会话切换后旧状态不再使用)
|
|
177
|
-
_dropDigestState(sessionId) {
|
|
178
|
-
if (sessionId && this._digestState) this._digestState.delete(sessionId)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async _pickDefaultSession() {
|
|
182
|
-
const sessions = await listSessions(this)
|
|
183
|
-
if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async createSession(prompt, cwdOverride) {
|
|
187
|
-
// 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
|
|
188
|
-
const sessionId = `session-${randomUUID()}`
|
|
189
|
-
try {
|
|
190
|
-
const workspaces = await listWorkspaces(this)
|
|
191
|
-
const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
|
|
192
|
-
const cwd = cwdOverride || this.config.cwd || defaultWsPath
|
|
193
|
-
// 校验指定目录存在且是目录,防止路径遍历
|
|
194
|
-
if (cwdOverride) {
|
|
195
|
-
const validation = await validateWorkspacePath(this, cwdOverride)
|
|
196
|
-
if (!validation.valid) {
|
|
197
|
-
await this.sendText(validation.error)
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const meta = {
|
|
202
|
-
cwd,
|
|
203
|
-
agentPreset: this.config.agentPreset || 'routing-suite',
|
|
204
|
-
}
|
|
205
|
-
const agentOptions = {}
|
|
206
|
-
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
207
|
-
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
208
|
-
if (!agentOptions.provider || !agentOptions.model) {
|
|
209
|
-
try {
|
|
210
|
-
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
211
|
-
if (def) {
|
|
212
|
-
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
213
|
-
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
214
|
-
}
|
|
215
|
-
} catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
|
|
216
|
-
}
|
|
217
|
-
// 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
|
|
218
|
-
const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
|
|
219
|
-
this.setActiveSession(handle.agent.session)
|
|
220
|
-
|
|
221
|
-
// 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
|
|
222
|
-
try {
|
|
223
|
-
const reg = this.ctx.workspaceRegistry
|
|
224
|
-
const entities = reg?.list ? (await reg.list()) : []
|
|
225
|
-
const normCwd = normalize(cwd).toLowerCase()
|
|
226
|
-
const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
|
|
227
|
-
if (match?.attachSession) await match.attachSession(sessionId)
|
|
228
|
-
} catch { /* 账本写入失败不影响会话创建 */ }
|
|
229
|
-
|
|
230
|
-
if (prompt) {
|
|
231
|
-
const platformName = this.platform?.name || 'IM客户端'
|
|
232
|
-
const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
233
|
-
handle.agent.followup(createUserMessage({
|
|
234
|
-
content: [{ type: 'text', text: promptWithContext }],
|
|
235
|
-
source: { kind: 'user' },
|
|
236
|
-
}))
|
|
237
|
-
}
|
|
238
|
-
const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`` : ''
|
|
239
|
-
const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
|
|
240
|
-
await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${hint}`)
|
|
241
|
-
} catch (error) {
|
|
242
|
-
await this.sendText(`❌ **创建会话失败**:${error instanceof Error ? error.message : String(error)}`)
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ---- 审批 ----
|
|
247
|
-
|
|
248
|
-
nextApprovalNumber() {
|
|
249
|
-
this.approvalCounter += 1
|
|
250
|
-
return this.approvalCounter
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
registerApproval(number, approval) {
|
|
254
|
-
this.pending.set(number, approval)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
clearApproval(number) {
|
|
258
|
-
const entry = this.pending.get(number)
|
|
259
|
-
if (entry) {
|
|
260
|
-
clearTimeout(entry.timer)
|
|
261
|
-
this.pending.delete(number)
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// 取消并拒绝审批(用于 dispose 清理)
|
|
266
|
-
cancelApproval(number) {
|
|
267
|
-
const entry = this.pending.get(number)
|
|
268
|
-
if (entry) {
|
|
269
|
-
clearTimeout(entry.timer)
|
|
270
|
-
this.pending.delete(number)
|
|
271
|
-
entry.resolve('rejected') // 触发 Promise,防止泄漏
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
resolveApproval(text, senderId = null) {
|
|
276
|
-
const entries = [...this.pending.entries()]
|
|
277
|
-
if (entries.length === 0) return false
|
|
278
|
-
let outcome
|
|
279
|
-
if (text === '/yes') outcome = 'allowed-once'
|
|
280
|
-
else if (text === '/no') outcome = 'rejected'
|
|
281
|
-
if (outcome) {
|
|
282
|
-
const [number, entry] = entries[entries.length - 1]
|
|
283
|
-
// 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
|
|
284
|
-
if (!this.pending.has(number)) return false
|
|
285
|
-
if (!this._approvalAllowed(entry, senderId)) return false
|
|
286
|
-
this.clearApproval(number)
|
|
287
|
-
entry.resolve(outcome)
|
|
288
|
-
return true
|
|
289
|
-
}
|
|
290
|
-
if ((text === '1' || text === '2') && entries.length === 1) {
|
|
291
|
-
const [number, entry] = entries[0]
|
|
292
|
-
// 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
|
|
293
|
-
if (!this.pending.has(number)) return false
|
|
294
|
-
if (!this._approvalAllowed(entry, senderId)) return false
|
|
295
|
-
this.clearApproval(number)
|
|
296
|
-
entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
|
|
297
|
-
return true
|
|
298
|
-
}
|
|
299
|
-
return false
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// 审批发起者校验:仅允许发起审批时的 peer(handleInbound 的 sender)决议,
|
|
303
|
-
// 防止群聊/多用户场景下其他成员代批工具执行。senderId 为 null 时放行(内部路径)。
|
|
304
|
-
_approvalAllowed(entry, senderId) {
|
|
305
|
-
if (senderId == null) return true
|
|
306
|
-
if (entry.peerId && entry.peerId !== senderId) {
|
|
307
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform?.id}] approval #${entry.number} blocked: sender ${senderId} is not the initiator ${entry.peerId}`)
|
|
308
|
-
return false
|
|
309
|
-
}
|
|
310
|
-
return true
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
stopAllHeartbeats() {
|
|
314
|
-
if (this._digestState) {
|
|
315
|
-
for (const state of this._digestState.values()) {
|
|
316
|
-
if (state && state.heartbeat) {
|
|
317
|
-
clearInterval(state.heartbeat)
|
|
318
|
-
state.heartbeat = undefined
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
dispose() {
|
|
325
|
-
this.stopAllHeartbeats()
|
|
326
|
-
for (const disposer of this.disposers) {
|
|
327
|
-
try { disposer() } catch { /* 忽略 */ }
|
|
328
|
-
}
|
|
329
|
-
this.disposers = []
|
|
330
|
-
for (const number of [...this.pending.keys()]) this.cancelApproval(number)
|
|
331
|
-
// 清理所有引用,防止内存泄漏
|
|
332
|
-
this._turnPeers?.clear()
|
|
333
|
-
this._inboundQueue = Promise.resolve()
|
|
334
|
-
this.peerId = null
|
|
335
|
-
this.activeSessionId = null
|
|
336
|
-
this._restoringConfig = null
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ---- 入站核心(平台无关)----
|
|
340
|
-
//
|
|
341
|
-
// 子类解析出平台消息后调用本方法:
|
|
342
|
-
// await bridge.handleInbound({ senderId, text, isGroup, outboundPeer })
|
|
343
|
-
// - outboundPeer(可选):该平台的"会话级发送目标"(如 QQ 群 { peerId, scope }、
|
|
344
|
-
// Telegram { peerId: chatId }),用于把本轮的出站事件流绑定回发起会话;
|
|
345
|
-
// 不传时出站回退到 this.peerId。
|
|
346
|
-
//
|
|
347
|
-
// 入站按 bridge 串行执行:处理过程会读写共享的 peer/会话状态,并发入站会互相踩踏。
|
|
348
|
-
// 消息处理本身轻量(重活是异步的 turn 事件流),串行代价可忽略。
|
|
349
|
-
//
|
|
350
|
-
// 返回:
|
|
351
|
-
// 'ignored' 消息被忽略(未授权/群消息/空消息)
|
|
352
|
-
// 'routed' 消息已路由到 agent
|
|
353
|
-
handleInbound(message) {
|
|
354
|
-
const task = this._inboundQueue.catch(() => {}).then(() => this._handleInboundSerialized(message))
|
|
355
|
-
this._inboundQueue = task.catch(() => {})
|
|
356
|
-
return task
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async _handleInboundSerialized({ senderId, text, isGroup = false, outboundPeer }) {
|
|
360
|
-
try {
|
|
361
|
-
// 等待配置恢复完成(防止启动时竞态)
|
|
362
|
-
if (this._restoringConfig) {
|
|
363
|
-
await this._restoringConfig
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const sender = String(senderId ?? '').trim()
|
|
367
|
-
if (!sender) return 'ignored'
|
|
368
|
-
|
|
369
|
-
if (!this.isAllowed(sender)) {
|
|
370
|
-
// 自动授权:
|
|
371
|
-
// - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
|
|
372
|
-
// - 群聊:白名单为空时随首条消息自动授权;白名单非空时仅在
|
|
373
|
-
// config.groupAutoApprove 显式开启后才授权新群(默认关闭,
|
|
374
|
-
// 防止任意陌生群 @机器人 一次即整群获得访问权)
|
|
375
|
-
const shouldAutoApprove = Boolean(text?.trim()) && (
|
|
376
|
-
this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
|
|
377
|
-
(isGroup && this.config.groupAutoApprove === true)
|
|
378
|
-
)
|
|
379
|
-
if (shouldAutoApprove) {
|
|
380
|
-
this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
|
|
381
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
|
|
382
|
-
try {
|
|
383
|
-
await this.onFirstSender?.(sender)
|
|
384
|
-
} catch (err) {
|
|
385
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
|
|
386
|
-
}
|
|
387
|
-
} else {
|
|
388
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// 如果仍未通过白名单,拒绝处理(防止绕过白名单)
|
|
392
|
-
if (!this.isAllowed(sender)) {
|
|
393
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
|
|
394
|
-
return 'ignored'
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
399
|
-
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
400
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
401
|
-
return 'ignored'
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const fullText = text?.trim() ?? ''
|
|
405
|
-
if (!fullText) {
|
|
406
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
|
|
407
|
-
return 'ignored'
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
this.peerId = sender
|
|
411
|
-
|
|
412
|
-
// 记录本轮发起者:出站事件流绑定回发起会话(T2.3)。outboundPeer 由平台节点传入
|
|
413
|
-
// 其"会话级发送目标"(QQ 群/Telegram chat 等),未传则不记录(出站回退 this.peerId)。
|
|
414
|
-
if (this.activeSessionId && outboundPeer) {
|
|
415
|
-
this._turnPeers.set(this.activeSessionId, { outboundPeer, senderId: sender })
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (await routeCommand(this, fullText, sender)) return 'routed'
|
|
419
|
-
|
|
420
|
-
let agent = this.activeAgent()
|
|
421
|
-
if (!agent && this.activeSessionId) {
|
|
422
|
-
const sessionId = this.activeSessionId
|
|
423
|
-
if (this._restoringSessionMap.has(sessionId)) {
|
|
424
|
-
try {
|
|
425
|
-
await this._restoringSessionMap.get(sessionId)
|
|
426
|
-
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
427
|
-
agent = this.activeAgent()
|
|
428
|
-
} else {
|
|
429
|
-
const restorePromise = (async () => {
|
|
430
|
-
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
431
|
-
try {
|
|
432
|
-
const agentOptions = {}
|
|
433
|
-
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
434
|
-
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
435
|
-
if (!agentOptions.provider || !agentOptions.model) {
|
|
436
|
-
try {
|
|
437
|
-
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
438
|
-
if (def) {
|
|
439
|
-
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
440
|
-
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
441
|
-
}
|
|
442
|
-
} catch { /* ignore */ }
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
446
|
-
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
447
|
-
let persisted = false
|
|
448
|
-
try {
|
|
449
|
-
const headers = await this.ctx.sessionPersistence?.list?.()
|
|
450
|
-
persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
|
|
451
|
-
} catch { /* 读取失败则按未持久化处理 */ }
|
|
452
|
-
|
|
453
|
-
let handle
|
|
454
|
-
if (persisted) {
|
|
455
|
-
handle = await this.ctx.agents.resume({
|
|
456
|
-
resumeSessionId: sessionId,
|
|
457
|
-
agentOptions,
|
|
458
|
-
})
|
|
459
|
-
} else {
|
|
460
|
-
handle = await this.ctx.agents.create({
|
|
461
|
-
sessionId,
|
|
462
|
-
meta: { cwd: this.config.cwd || process.cwd() },
|
|
463
|
-
agentOptions,
|
|
464
|
-
})
|
|
465
|
-
}
|
|
466
|
-
const resumedAgent = handle?.agent
|
|
467
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
|
|
468
|
-
return resumedAgent
|
|
469
|
-
} catch (err) {
|
|
470
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
471
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
|
|
472
|
-
// 清除失效的 activeSessionId,避免用户误以为还在旧会话中
|
|
473
|
-
if (this.activeSessionId === sessionId) {
|
|
474
|
-
this.activeSessionId = null
|
|
475
|
-
}
|
|
476
|
-
await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
|
|
477
|
-
return null
|
|
478
|
-
}
|
|
479
|
-
})().finally(() => {
|
|
480
|
-
this._restoringSessionMap.delete(sessionId)
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
this._restoringSessionMap.set(sessionId, restorePromise)
|
|
484
|
-
agent = await restorePromise
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
if (!agent) {
|
|
488
|
-
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
489
|
-
return 'routed'
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
|
|
493
|
-
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
494
|
-
|
|
495
|
-
const messageValue = createUserMessage({
|
|
496
|
-
content: [{ type: 'text', text: promptWithContext }],
|
|
497
|
-
source: { kind: 'user' },
|
|
498
|
-
})
|
|
499
|
-
agent.followup(messageValue)
|
|
500
|
-
await this.sendTyping(1).catch(() => {})
|
|
501
|
-
return 'routed'
|
|
502
|
-
} catch (err) {
|
|
503
|
-
const errMsg = err instanceof Error ? err.message : String(err)
|
|
504
|
-
this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}\n${err instanceof Error ? err.stack : ''}`)
|
|
505
|
-
await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
|
|
506
|
-
return 'ignored'
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ---- 发送(子类覆盖 _sendTextNow,不要覆盖 sendText)----
|
|
511
|
-
|
|
512
|
-
// 出站发送串行队列:所有 sendText 依次执行,杜绝并发分块交错乱序,
|
|
513
|
-
// 以及 QQ 流式 replace 共享 _msgSeq 的并发冲突
|
|
514
|
-
_enqueueSend(task) {
|
|
515
|
-
const queue = (this._sendQueue ??= Promise.resolve())
|
|
516
|
-
const run = queue.then(task, task)
|
|
517
|
-
this._sendQueue = run.catch(() => {})
|
|
518
|
-
return run
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** 向当前 peer 发送文本(自动分块 + typing 指示)。经串行队列执行。
|
|
522
|
-
* opts.outboundPeer(可选):覆盖发送目标(T2.3,绑定到发起轮次的会话 peer)。 */
|
|
523
|
-
sendText(text, opts = {}) {
|
|
524
|
-
return this._enqueueSend(() => this._sendTextNow(text, opts))
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
async _sendTextNow(text, opts = {}) {
|
|
528
|
-
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
529
|
-
const peer = opts?.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
|
|
530
|
-
if (!peer) return
|
|
531
|
-
const chunks = splitForIM(text, this.config.maxMessageChars)
|
|
532
|
-
if (chunks.length === 0) return
|
|
533
|
-
await this.sendTyping(1).catch(() => {})
|
|
534
|
-
try {
|
|
535
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
536
|
-
let result
|
|
537
|
-
try {
|
|
538
|
-
result = await this.platform.sendText(peer, chunks[i])
|
|
539
|
-
} catch (err) {
|
|
540
|
-
// 适配器可能直接透传 gateway 抛出的异常(telegram/feishu),统一按失败分块处理,
|
|
541
|
-
// 避免调用方 `void this.sendText(...)` 逃逸成 unhandled rejection
|
|
542
|
-
result = { success: false, error: err?.message ?? String(err) }
|
|
543
|
-
}
|
|
544
|
-
if (result && result.success === false) {
|
|
545
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
|
|
546
|
-
break
|
|
547
|
-
}
|
|
548
|
-
if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
|
|
549
|
-
await sleep(this.config.sendChunkDelayMs)
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
} finally {
|
|
553
|
-
await this.sendTyping(2).catch(() => {})
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
|
|
558
|
-
async sendTyping(state) {
|
|
559
|
-
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
560
|
-
if (!this.platform?.sendTyping || peer == null) return
|
|
561
|
-
return this.platform.sendTyping(peer, state)
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// ---- 出站事件绑定 ----
|
|
565
|
-
|
|
566
|
-
_attachOutbound() {
|
|
567
|
-
this._digestState = new Map()
|
|
568
|
-
const stopHeartbeat = (state) => {
|
|
569
|
-
if (state && state.heartbeat) {
|
|
570
|
-
clearInterval(state.heartbeat)
|
|
571
|
-
state.heartbeat = undefined
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
const startHeartbeat = (session, state) => {
|
|
575
|
-
stopHeartbeat(state)
|
|
576
|
-
if (this.config.digestIntervalSec <= 0) return
|
|
577
|
-
state.heartbeat = setInterval(() => {
|
|
578
|
-
// 1. 必须依然是当前活动会话
|
|
579
|
-
if (this.activeSessionId !== session.id) {
|
|
580
|
-
stopHeartbeat(state)
|
|
581
|
-
return
|
|
582
|
-
}
|
|
583
|
-
// 2. 检查会话当前是否真正处于 inTurn 状态中
|
|
584
|
-
const line = digestLine(session)
|
|
585
|
-
if (!line) {
|
|
586
|
-
stopHeartbeat(state)
|
|
587
|
-
return
|
|
588
|
-
}
|
|
589
|
-
// 心跳时同时刷新 typing 状态(微信 typing 只维持 15 秒)
|
|
590
|
-
if (this.peerId) this.sendTyping(1).catch(() => {})
|
|
591
|
-
const heartbeatTurn = this._turnPeers.get(session.id)
|
|
592
|
-
void this.sendText(line, heartbeatTurn ? { outboundPeer: heartbeatTurn.outboundPeer } : {})
|
|
593
|
-
}, this.config.digestIntervalSec * 1000)
|
|
594
|
-
if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
|
|
595
|
-
}
|
|
596
|
-
const onEvent = async (session, event) => {
|
|
597
|
-
// 仅为活动会话创建 digest 状态:非活动会话即使有心跳残留,也会被心跳回调里的
|
|
598
|
-
// 失活检查在下一个周期自行停止。若不过滤,任意会话事件都会在此堆积 Map entry(内存泄漏)
|
|
599
|
-
if (session.id !== this.activeSessionId) return
|
|
600
|
-
const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
|
|
601
|
-
this._digestState.set(session.id, state)
|
|
602
|
-
|
|
603
|
-
if (event.type === 'turn/end') {
|
|
604
|
-
stopHeartbeat(state)
|
|
605
|
-
// 本轮已结束,清空轮次集合防止长期运行下无限累积
|
|
606
|
-
state.startedTurns.clear()
|
|
607
|
-
}
|
|
608
|
-
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
609
|
-
|
|
610
|
-
// 出站事件绑定到发起本轮的会话 peer(T2.3)
|
|
611
|
-
const turn = this._turnPeers.get(session.id)
|
|
612
|
-
const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
|
|
613
|
-
|
|
614
|
-
if (event.type === 'turn/start') {
|
|
615
|
-
const turn = event.data?.turn
|
|
616
|
-
state.createdFiles = new Set()
|
|
617
|
-
if (turn != null && !state.startedTurns.has(turn)) {
|
|
618
|
-
state.startedTurns.add(turn)
|
|
619
|
-
// 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
|
|
620
|
-
if (this.peerId) this.sendTyping(1).catch(() => {})
|
|
621
|
-
}
|
|
622
|
-
startHeartbeat(session, state)
|
|
623
|
-
return
|
|
624
|
-
}
|
|
625
|
-
const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
|
|
626
|
-
|
|
627
|
-
if (event.type === 'tool/call') {
|
|
628
|
-
// 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
|
|
629
|
-
return
|
|
630
|
-
}
|
|
631
|
-
if (event.type === 'assistant/message') {
|
|
632
|
-
const rawText = textOfAssistantMessage(event.data.message)
|
|
633
|
-
if (rawText.trim()) {
|
|
634
|
-
const cwd = getSessionCwd(session)
|
|
635
|
-
const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
|
|
636
|
-
for (const f of files) {
|
|
637
|
-
state.createdFiles.add(f)
|
|
638
|
-
}
|
|
639
|
-
// 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
|
|
640
|
-
if (cleanText) {
|
|
641
|
-
void this.sendText(cleanText, sendOpts)
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return
|
|
645
|
-
}
|
|
646
|
-
if (event.type === 'turn/end') {
|
|
647
|
-
stopHeartbeat(state)
|
|
648
|
-
if (this.peerId) this.sendTyping(2).catch(() => {})
|
|
649
|
-
const reason = event.data?.reason || {}
|
|
650
|
-
if (reason.kind === 'error') {
|
|
651
|
-
void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`, sendOpts)
|
|
652
|
-
} else if (reason.kind === 'aborted') {
|
|
653
|
-
void this.sendText(`⏹ **任务已停止**`, sendOpts)
|
|
654
|
-
} else if (reason.kind === 'max-tokens') {
|
|
655
|
-
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`, sendOpts)
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
|
|
659
|
-
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
660
|
-
const rawFiles = Array.from(state.createdFiles)
|
|
661
|
-
const cwd = getSessionCwd(session)
|
|
662
|
-
const targetPeer = sendOpts.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
|
|
663
|
-
|
|
664
|
-
if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
|
|
665
|
-
const uniqueFilesToSend = []
|
|
666
|
-
for (const f of rawFiles) {
|
|
667
|
-
const resolved = resolveFilePath(f, cwd)
|
|
668
|
-
if (!resolved || uniqueFilesToSend.includes(resolved)) continue
|
|
669
|
-
// 发送白名单:仅允许会话 cwd(及其子目录)内、且不命中敏感路径的文件,
|
|
670
|
-
// 防止模型被提示注入后借 [SEND_FILE] 外发 .ssh/.credentials/.env 等任意本地文件。
|
|
671
|
-
if (!isPathAllowedForSend(resolved, cwd)) {
|
|
672
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] blocked SEND_FILE outside allowed workspace: ${resolved}`)
|
|
673
|
-
continue
|
|
674
|
-
}
|
|
675
|
-
uniqueFilesToSend.push(resolved)
|
|
676
|
-
}
|
|
677
|
-
for (const resolved of uniqueFilesToSend) {
|
|
678
|
-
try {
|
|
679
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
|
|
680
|
-
const res = await this.platform.sendMediaFile(targetPeer, resolved)
|
|
681
|
-
if (res && res.success === false) {
|
|
682
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
|
|
683
|
-
}
|
|
684
|
-
} catch (err) {
|
|
685
|
-
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
state.createdFiles.clear()
|
|
690
|
-
}
|
|
691
|
-
// 本轮结束,释放轮次绑定(审批/心跳等已不再需要)
|
|
692
|
-
this._turnPeers.delete(session.id)
|
|
693
|
-
return
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
const listener = (session, event) => { void onEvent(session, event) }
|
|
697
|
-
const disposer = this.ctx.on('session/event', listener)
|
|
698
|
-
this.disposers.push(() => {
|
|
699
|
-
for (const state of this._digestState.values()) stopHeartbeat(state)
|
|
700
|
-
disposer()
|
|
701
|
-
})
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// ---- 审批桥 ----
|
|
705
|
-
//
|
|
706
|
-
// DSH 的审批分发是 cordis waterfall(顺序链):监听器按注册序执行,
|
|
707
|
-
// 不调用 next() 的监听器否决整条链。宿主 apiproxy 注册在先的 GUI 认领监听器
|
|
708
|
-
// 会认领 approval/asked 事件并 veto 等待网页回答——若不 prepend,本桥永远没有
|
|
709
|
-
// 机会执行,IM 端永远收不到审批卡片(工具调用最终以 unavailable 失败)。
|
|
710
|
-
// 因此以 { prepend: true } 注册到链条最外层。
|
|
711
|
-
//
|
|
712
|
-
// 归属模型:IM 发起的轮次审批**只在 IM 决议**——不调用 next(),宿主 GUI 通道
|
|
713
|
-
// 根本不打开。此前曾让 GUI 弹窗与 IM 卡片并行 race,但宿主的 pending 认领没有
|
|
714
|
-
// 插件可用的收尾接口(只有 Web /api/respond 或 signal abort),IM 决议后 Web
|
|
715
|
-
// 弹窗会永久残留(用户实测报告)。各通道只决议自己发起的轮次。
|
|
716
|
-
|
|
717
|
-
_attachApprovalBridge() {
|
|
718
|
-
const listener = async (req, next) => {
|
|
719
|
-
// 只有"本轮由本桥发起"(_turnPeers 有该会话的轮次记录)时才拦截审批。
|
|
720
|
-
// 仅凭 activeSessionId 匹配是不够的:重启恢复/默认挑选后它可能指向一个
|
|
721
|
-
// Web 端发起的会话——那会让 Web 轮次的审批被劫持发去 IM(GUI 不弹窗、
|
|
722
|
-
// 无人响应即自动拒绝)。Web 轮次必须直接放行给宿主 GUI 处理。
|
|
723
|
-
const sessionId = req.agent?.session?.id
|
|
724
|
-
const turn = sessionId ? this._turnPeers.get(sessionId) : null
|
|
725
|
-
if (!turn || !this.ownsAgent(req.agent)) {
|
|
726
|
-
// info 级别:这是用户可自诊的关键判定点(IM 没收到卡片时先看这行)
|
|
727
|
-
this.logger?.info?.('[dsh-bridge %s] approval falls through to GUI: not an IM-initiated turn (session=%s, activeSession=%s, turnTracked=%s)', this.platform?.id, sessionId ?? '(none)', this.activeSessionId ?? '(none)', Boolean(turn))
|
|
728
|
-
return next?.()
|
|
729
|
-
}
|
|
730
|
-
const peer = turn.outboundPeer?.peerId
|
|
731
|
-
const initiator = turn.senderId
|
|
732
|
-
if (!peer) {
|
|
733
|
-
this.logger?.debug?.('[dsh-bridge %s] approval/request ignored: no active peer', this.platform?.id)
|
|
734
|
-
return next?.()
|
|
735
|
-
}
|
|
736
|
-
const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
|
|
737
|
-
|
|
738
|
-
const number = this.nextApprovalNumber()
|
|
739
|
-
const timeoutSec = this.config.approvalTimeoutSec
|
|
740
|
-
const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
|
|
741
|
-
const prompt = [
|
|
742
|
-
`## ⚠️ 操作权限确认 (#${number})`,
|
|
743
|
-
'',
|
|
744
|
-
'| 项目 | 详情 |',
|
|
745
|
-
'| :--- | :--- |',
|
|
746
|
-
`| **调用工具** | \`${req.toolName}\` |`,
|
|
747
|
-
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
748
|
-
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
749
|
-
'',
|
|
750
|
-
`> 回复 \`/yes\` (或 \`1\`) 批准执行`,
|
|
751
|
-
`> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
|
|
752
|
-
].join('\n')
|
|
753
|
-
|
|
754
|
-
void this.sendText(prompt, sendOpts)
|
|
755
|
-
|
|
756
|
-
let settled = false
|
|
757
|
-
let timeoutFired = false
|
|
758
|
-
let resolveIm
|
|
759
|
-
const imPromise = new Promise((resolve) => { resolveIm = resolve })
|
|
760
|
-
const settleIm = (outcome) => {
|
|
761
|
-
if (settled) return
|
|
762
|
-
settled = true
|
|
763
|
-
this.clearApproval(number)
|
|
764
|
-
resolveIm(outcome)
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const timer = setTimeout(() => {
|
|
768
|
-
timeoutFired = true
|
|
769
|
-
settleIm('rejected')
|
|
770
|
-
}, timeoutSec * 1000)
|
|
771
|
-
if (typeof timer.unref === 'function') timer.unref()
|
|
772
|
-
|
|
773
|
-
// turn 被停止/工具调用中止时 DSH 会 abort req.signal:同步取消 IM 侧待决审批
|
|
774
|
-
const onSignalAbort = () => {
|
|
775
|
-
timeoutFired = true
|
|
776
|
-
settleIm('cancelled')
|
|
777
|
-
}
|
|
778
|
-
req.signal?.addEventListener('abort', onSignalAbort, { once: true })
|
|
779
|
-
|
|
780
|
-
this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
|
|
781
|
-
|
|
782
|
-
let outcome
|
|
783
|
-
try {
|
|
784
|
-
outcome = await imPromise
|
|
785
|
-
} finally {
|
|
786
|
-
req.signal?.removeEventListener('abort', onSignalAbort)
|
|
787
|
-
clearTimeout(timer)
|
|
788
|
-
settled = true
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
this.logger?.info?.('[dsh-bridge %s] approval #%d resolved: outcome=%s', this.platform?.id, number, outcome)
|
|
792
|
-
|
|
793
|
-
// 仅在非超时/非中止路径发送确认消息(那些路径 resolve 已发生在定时器/abort 回调)
|
|
794
|
-
if (!timeoutFired) {
|
|
795
|
-
const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
|
|
796
|
-
void this.sendText(`${label}(#${number})`, sendOpts)
|
|
797
|
-
}
|
|
798
|
-
return outcome
|
|
799
|
-
}
|
|
800
|
-
const disposer = this.ctx.on('approval/request', listener, { prepend: true })
|
|
801
|
-
this.disposers.push(disposer)
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// ---------------------------------------------------------------------------
|
|
806
|
-
// 辅助
|
|
807
|
-
// ---------------------------------------------------------------------------
|
|
808
|
-
|
|
809
|
-
// 导出,便于测试与复用
|
|
810
|
-
export const conversationBridgeHelpers = {
|
|
811
|
-
splitForIM,
|
|
812
|
-
digestLine,
|
|
813
|
-
textOfAssistantMessage,
|
|
814
|
-
resolveFilePath,
|
|
815
|
-
extractFilePathsFromText,
|
|
816
|
-
extractAndStripSendFileDirectives,
|
|
817
|
-
sessionsInDisplayOrder,
|
|
818
|
-
listSessions,
|
|
819
|
-
renderSessions,
|
|
820
|
-
listWorkspaces,
|
|
821
|
-
}
|
|
1
|
+
// dsh-bridge 平台无关的会话桥(核心类)
|
|
2
|
+
//
|
|
3
|
+
// 本模块只保留 ConversationBridge 类本身(白名单、会话生命周期、审批桥、
|
|
4
|
+
// 出站 digest/心跳、入站路由入口)。拆分出去的模块:
|
|
5
|
+
// - message-split.js 出站分块 + [SEND_FILE] 指令解析(纯函数)
|
|
6
|
+
// - dsh-storage.js DSH 私有存储读取兜底(workspace.json / projcache)
|
|
7
|
+
// - session-catalog.js 会话目录组织 / 渲染 / 格式化
|
|
8
|
+
// - commands.js 斜杠命令解释器(routeCommand)
|
|
9
|
+
//
|
|
10
|
+
// 平台相关的部分由子类(或组合)提供:
|
|
11
|
+
// - sendText(text, opts) / sendTyping(state):向当前 peer 发送
|
|
12
|
+
// - extractTextFrom(message):从平台消息提取文本
|
|
13
|
+
// - isGroupMessage(message):判断群消息
|
|
14
|
+
// - handlePlatformInbound(message):消息解析(返回 { senderId, text, isGroup } 或 null)
|
|
15
|
+
//
|
|
16
|
+
// 本类不直接依赖任何 IM 协议,只消费 DSH 官方服务:
|
|
17
|
+
// ctx.sessions / ctx.agents / ctx.approval / ctx.sessionPersistence / ctx.workspaceRegistry
|
|
18
|
+
|
|
19
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import { randomUUID } from 'node:crypto'
|
|
21
|
+
import { stat } from 'node:fs/promises'
|
|
22
|
+
import { normalize } from 'node:path'
|
|
23
|
+
import { resolveFilePath } from './message-split.js'
|
|
24
|
+
import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText, isPathAllowedForSend } from './message-split.js'
|
|
25
|
+
import { routeCommand } from './commands.js'
|
|
26
|
+
import { listSessions, listWorkspaces, validateWorkspacePath, renderSessions, sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId, sessionLabel } from './session-catalog.js'
|
|
27
|
+
|
|
28
|
+
export { textOfAssistantMessage } from './message-split.js'
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 出站分块(通用:按平台 maxMessageChars 硬分块 + 保留 fenced code block)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
function digestLine(session) {
|
|
36
|
+
let turn = 0
|
|
37
|
+
let tools = 0
|
|
38
|
+
let lastTool = undefined
|
|
39
|
+
let inTurn = false
|
|
40
|
+
for (const event of session.events ?? []) {
|
|
41
|
+
if (event.type === 'turn/start') {
|
|
42
|
+
turn = event.data.turn
|
|
43
|
+
inTurn = true
|
|
44
|
+
tools = 0
|
|
45
|
+
lastTool = undefined
|
|
46
|
+
} else if (event.type === 'turn/end') {
|
|
47
|
+
inTurn = false
|
|
48
|
+
} else if (event.type === 'tool/call' && inTurn) {
|
|
49
|
+
tools += 1
|
|
50
|
+
lastTool = event.data.name
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!inTurn || turn === 0) return null
|
|
54
|
+
const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
|
|
55
|
+
const last = lastTool ? ` | 最近: ${lastTool}` : ''
|
|
56
|
+
return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function summarizeError(error) {
|
|
60
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
61
|
+
return String(error.message).slice(0, 200)
|
|
62
|
+
}
|
|
63
|
+
return String(error).slice(0, 200)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// 会话桥基类
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export class ConversationBridge {
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} opts
|
|
77
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
78
|
+
* @param {object} opts.logger 日志器
|
|
79
|
+
* @param {object} [opts.config] 已持久化配置(allowFrom/间隔/活动会话等)
|
|
80
|
+
* @param {object} opts.platform 所属 Platform 实例(提供 accountId/capabilities)—— 必需
|
|
81
|
+
* @param {(senderId: string) => void} [opts.onFirstSender]
|
|
82
|
+
* @param {(sessionId: string) => void} [opts.onActiveSessionChange]
|
|
83
|
+
*/
|
|
84
|
+
constructor({ ctx, logger, config = {}, platform, onFirstSender, onActiveSessionChange } = {}) {
|
|
85
|
+
if (!platform) {
|
|
86
|
+
throw new Error('ConversationBridge requires a platform instance')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.ctx = ctx
|
|
90
|
+
this.logger = logger
|
|
91
|
+
this.platform = platform
|
|
92
|
+
this.onFirstSender = onFirstSender
|
|
93
|
+
this.onActiveSessionChange = onActiveSessionChange
|
|
94
|
+
|
|
95
|
+
const maxChars = platform.capabilities?.maxMessageChars ?? 2000
|
|
96
|
+
const rawMax = Number(config.maxMessageChars)
|
|
97
|
+
const safeMaxChars = (Number.isFinite(rawMax) && rawMax > 0) ? rawMax : maxChars
|
|
98
|
+
this.config = {
|
|
99
|
+
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
100
|
+
digestIntervalSec: config.digestIntervalSec ?? 300,
|
|
101
|
+
approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
|
|
102
|
+
maxMessageChars: safeMaxChars,
|
|
103
|
+
sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
|
|
104
|
+
cwd: config.cwd,
|
|
105
|
+
agentPreset: config.agentPreset,
|
|
106
|
+
agentProvider: config.agentProvider,
|
|
107
|
+
agentModel: config.agentModel,
|
|
108
|
+
// 群聊自动授权开关:默认关闭,防止任意陌生群 @机器人 即获得访问权(T2.5)
|
|
109
|
+
groupAutoApprove: config.groupAutoApprove === true,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 从配置恢复活动会话(v0.2.1:重启后保持会话)
|
|
113
|
+
// 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
|
|
114
|
+
this.activeSessionId = config.activeSessionId ?? null
|
|
115
|
+
this.peerId = null
|
|
116
|
+
this.pending = new Map() // number -> PendingApproval
|
|
117
|
+
this.approvalCounter = 0
|
|
118
|
+
this.disposers = []
|
|
119
|
+
|
|
120
|
+
// 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
|
|
121
|
+
this._restoringConfig = null
|
|
122
|
+
this._restoringSessionMap = new Map() // sessionId -> Promise<Agent|null>
|
|
123
|
+
|
|
124
|
+
// T2.3:sessionId -> { outboundPeer, senderId }
|
|
125
|
+
// 记录每轮对话的发起者:出站事件流(assistant/turn/end/心跳/SEND_FILE/审批)绑定到
|
|
126
|
+
// 发起轮次的 peer,而不是"最近一条入站的 peer",防止 A 任务进行中 B 来消息把回复串到 B 的窗口
|
|
127
|
+
this._turnPeers = new Map()
|
|
128
|
+
// 入站串行队列:handleInbound 内部读写 this.peerId 等共享状态,并发入站会造成串扰
|
|
129
|
+
this._inboundQueue = Promise.resolve()
|
|
130
|
+
|
|
131
|
+
this._attachOutbound()
|
|
132
|
+
this._attachApprovalBridge()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get gatewayAccountId() {
|
|
136
|
+
return this.platform?.accountId ?? ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
activeSession() {
|
|
140
|
+
if (!this.activeSessionId) return undefined
|
|
141
|
+
return this.ctx.sessions?.get(this.activeSessionId)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
activeAgent() {
|
|
145
|
+
if (!this.activeSessionId) return undefined
|
|
146
|
+
return this.ctx.agents?.get(this.activeSessionId)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ownsAgent(agent) {
|
|
150
|
+
return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
isAllowed(senderId) {
|
|
154
|
+
if (!Array.isArray(this.config.allowFrom)) return false
|
|
155
|
+
if (this.config.allowFrom.length === 0) return false
|
|
156
|
+
return this.config.allowFrom.includes(senderId)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setActiveSession(session) {
|
|
160
|
+
this.stopAllHeartbeats()
|
|
161
|
+
this._dropDigestState(this.activeSessionId)
|
|
162
|
+
this.activeSessionId = session.id
|
|
163
|
+
try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
|
|
167
|
+
// 发消息时通过 re-attach 逻辑拉起 agent。
|
|
168
|
+
setActiveSessionById(id) {
|
|
169
|
+
if (!id) return
|
|
170
|
+
this.stopAllHeartbeats()
|
|
171
|
+
this._dropDigestState(this.activeSessionId)
|
|
172
|
+
this.activeSessionId = id
|
|
173
|
+
try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 释放指定会话的 digest 状态(活动会话切换后旧状态不再使用)
|
|
177
|
+
_dropDigestState(sessionId) {
|
|
178
|
+
if (sessionId && this._digestState) this._digestState.delete(sessionId)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _pickDefaultSession() {
|
|
182
|
+
const sessions = await listSessions(this)
|
|
183
|
+
if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async createSession(prompt, cwdOverride) {
|
|
187
|
+
// 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
|
|
188
|
+
const sessionId = `session-${randomUUID()}`
|
|
189
|
+
try {
|
|
190
|
+
const workspaces = await listWorkspaces(this)
|
|
191
|
+
const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
|
|
192
|
+
const cwd = cwdOverride || this.config.cwd || defaultWsPath
|
|
193
|
+
// 校验指定目录存在且是目录,防止路径遍历
|
|
194
|
+
if (cwdOverride) {
|
|
195
|
+
const validation = await validateWorkspacePath(this, cwdOverride)
|
|
196
|
+
if (!validation.valid) {
|
|
197
|
+
await this.sendText(validation.error)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const meta = {
|
|
202
|
+
cwd,
|
|
203
|
+
agentPreset: this.config.agentPreset || 'routing-suite',
|
|
204
|
+
}
|
|
205
|
+
const agentOptions = {}
|
|
206
|
+
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
207
|
+
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
208
|
+
if (!agentOptions.provider || !agentOptions.model) {
|
|
209
|
+
try {
|
|
210
|
+
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
211
|
+
if (def) {
|
|
212
|
+
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
213
|
+
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
214
|
+
}
|
|
215
|
+
} catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
|
|
216
|
+
}
|
|
217
|
+
// 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
|
|
218
|
+
const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
|
|
219
|
+
this.setActiveSession(handle.agent.session)
|
|
220
|
+
|
|
221
|
+
// 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
|
|
222
|
+
try {
|
|
223
|
+
const reg = this.ctx.workspaceRegistry
|
|
224
|
+
const entities = reg?.list ? (await reg.list()) : []
|
|
225
|
+
const normCwd = normalize(cwd).toLowerCase()
|
|
226
|
+
const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
|
|
227
|
+
if (match?.attachSession) await match.attachSession(sessionId)
|
|
228
|
+
} catch { /* 账本写入失败不影响会话创建 */ }
|
|
229
|
+
|
|
230
|
+
if (prompt) {
|
|
231
|
+
const platformName = this.platform?.name || 'IM客户端'
|
|
232
|
+
const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
233
|
+
handle.agent.followup(createUserMessage({
|
|
234
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
235
|
+
source: { kind: 'user' },
|
|
236
|
+
}))
|
|
237
|
+
}
|
|
238
|
+
const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`` : ''
|
|
239
|
+
const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
|
|
240
|
+
await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${hint}`)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
await this.sendText(`❌ **创建会话失败**:${error instanceof Error ? error.message : String(error)}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- 审批 ----
|
|
247
|
+
|
|
248
|
+
nextApprovalNumber() {
|
|
249
|
+
this.approvalCounter += 1
|
|
250
|
+
return this.approvalCounter
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
registerApproval(number, approval) {
|
|
254
|
+
this.pending.set(number, approval)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
clearApproval(number) {
|
|
258
|
+
const entry = this.pending.get(number)
|
|
259
|
+
if (entry) {
|
|
260
|
+
clearTimeout(entry.timer)
|
|
261
|
+
this.pending.delete(number)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 取消并拒绝审批(用于 dispose 清理)
|
|
266
|
+
cancelApproval(number) {
|
|
267
|
+
const entry = this.pending.get(number)
|
|
268
|
+
if (entry) {
|
|
269
|
+
clearTimeout(entry.timer)
|
|
270
|
+
this.pending.delete(number)
|
|
271
|
+
entry.resolve('rejected') // 触发 Promise,防止泄漏
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
resolveApproval(text, senderId = null) {
|
|
276
|
+
const entries = [...this.pending.entries()]
|
|
277
|
+
if (entries.length === 0) return false
|
|
278
|
+
let outcome
|
|
279
|
+
if (text === '/yes') outcome = 'allowed-once'
|
|
280
|
+
else if (text === '/no') outcome = 'rejected'
|
|
281
|
+
if (outcome) {
|
|
282
|
+
const [number, entry] = entries[entries.length - 1]
|
|
283
|
+
// 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
|
|
284
|
+
if (!this.pending.has(number)) return false
|
|
285
|
+
if (!this._approvalAllowed(entry, senderId)) return false
|
|
286
|
+
this.clearApproval(number)
|
|
287
|
+
entry.resolve(outcome)
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
if ((text === '1' || text === '2') && entries.length === 1) {
|
|
291
|
+
const [number, entry] = entries[0]
|
|
292
|
+
// 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
|
|
293
|
+
if (!this.pending.has(number)) return false
|
|
294
|
+
if (!this._approvalAllowed(entry, senderId)) return false
|
|
295
|
+
this.clearApproval(number)
|
|
296
|
+
entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 审批发起者校验:仅允许发起审批时的 peer(handleInbound 的 sender)决议,
|
|
303
|
+
// 防止群聊/多用户场景下其他成员代批工具执行。senderId 为 null 时放行(内部路径)。
|
|
304
|
+
_approvalAllowed(entry, senderId) {
|
|
305
|
+
if (senderId == null) return true
|
|
306
|
+
if (entry.peerId && entry.peerId !== senderId) {
|
|
307
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform?.id}] approval #${entry.number} blocked: sender ${senderId} is not the initiator ${entry.peerId}`)
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
stopAllHeartbeats() {
|
|
314
|
+
if (this._digestState) {
|
|
315
|
+
for (const state of this._digestState.values()) {
|
|
316
|
+
if (state && state.heartbeat) {
|
|
317
|
+
clearInterval(state.heartbeat)
|
|
318
|
+
state.heartbeat = undefined
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
dispose() {
|
|
325
|
+
this.stopAllHeartbeats()
|
|
326
|
+
for (const disposer of this.disposers) {
|
|
327
|
+
try { disposer() } catch { /* 忽略 */ }
|
|
328
|
+
}
|
|
329
|
+
this.disposers = []
|
|
330
|
+
for (const number of [...this.pending.keys()]) this.cancelApproval(number)
|
|
331
|
+
// 清理所有引用,防止内存泄漏
|
|
332
|
+
this._turnPeers?.clear()
|
|
333
|
+
this._inboundQueue = Promise.resolve()
|
|
334
|
+
this.peerId = null
|
|
335
|
+
this.activeSessionId = null
|
|
336
|
+
this._restoringConfig = null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- 入站核心(平台无关)----
|
|
340
|
+
//
|
|
341
|
+
// 子类解析出平台消息后调用本方法:
|
|
342
|
+
// await bridge.handleInbound({ senderId, text, isGroup, outboundPeer })
|
|
343
|
+
// - outboundPeer(可选):该平台的"会话级发送目标"(如 QQ 群 { peerId, scope }、
|
|
344
|
+
// Telegram { peerId: chatId }),用于把本轮的出站事件流绑定回发起会话;
|
|
345
|
+
// 不传时出站回退到 this.peerId。
|
|
346
|
+
//
|
|
347
|
+
// 入站按 bridge 串行执行:处理过程会读写共享的 peer/会话状态,并发入站会互相踩踏。
|
|
348
|
+
// 消息处理本身轻量(重活是异步的 turn 事件流),串行代价可忽略。
|
|
349
|
+
//
|
|
350
|
+
// 返回:
|
|
351
|
+
// 'ignored' 消息被忽略(未授权/群消息/空消息)
|
|
352
|
+
// 'routed' 消息已路由到 agent
|
|
353
|
+
handleInbound(message) {
|
|
354
|
+
const task = this._inboundQueue.catch(() => {}).then(() => this._handleInboundSerialized(message))
|
|
355
|
+
this._inboundQueue = task.catch(() => {})
|
|
356
|
+
return task
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async _handleInboundSerialized({ senderId, text, isGroup = false, outboundPeer }) {
|
|
360
|
+
try {
|
|
361
|
+
// 等待配置恢复完成(防止启动时竞态)
|
|
362
|
+
if (this._restoringConfig) {
|
|
363
|
+
await this._restoringConfig
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const sender = String(senderId ?? '').trim()
|
|
367
|
+
if (!sender) return 'ignored'
|
|
368
|
+
|
|
369
|
+
if (!this.isAllowed(sender)) {
|
|
370
|
+
// 自动授权:
|
|
371
|
+
// - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
|
|
372
|
+
// - 群聊:白名单为空时随首条消息自动授权;白名单非空时仅在
|
|
373
|
+
// config.groupAutoApprove 显式开启后才授权新群(默认关闭,
|
|
374
|
+
// 防止任意陌生群 @机器人 一次即整群获得访问权)
|
|
375
|
+
const shouldAutoApprove = Boolean(text?.trim()) && (
|
|
376
|
+
this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
|
|
377
|
+
(isGroup && this.config.groupAutoApprove === true)
|
|
378
|
+
)
|
|
379
|
+
if (shouldAutoApprove) {
|
|
380
|
+
this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
|
|
381
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
|
|
382
|
+
try {
|
|
383
|
+
await this.onFirstSender?.(sender)
|
|
384
|
+
} catch (err) {
|
|
385
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 如果仍未通过白名单,拒绝处理(防止绕过白名单)
|
|
392
|
+
if (!this.isAllowed(sender)) {
|
|
393
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
|
|
394
|
+
return 'ignored'
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
399
|
+
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
400
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
401
|
+
return 'ignored'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const fullText = text?.trim() ?? ''
|
|
405
|
+
if (!fullText) {
|
|
406
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
|
|
407
|
+
return 'ignored'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.peerId = sender
|
|
411
|
+
|
|
412
|
+
// 记录本轮发起者:出站事件流绑定回发起会话(T2.3)。outboundPeer 由平台节点传入
|
|
413
|
+
// 其"会话级发送目标"(QQ 群/Telegram chat 等),未传则不记录(出站回退 this.peerId)。
|
|
414
|
+
if (this.activeSessionId && outboundPeer) {
|
|
415
|
+
this._turnPeers.set(this.activeSessionId, { outboundPeer, senderId: sender })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (await routeCommand(this, fullText, sender)) return 'routed'
|
|
419
|
+
|
|
420
|
+
let agent = this.activeAgent()
|
|
421
|
+
if (!agent && this.activeSessionId) {
|
|
422
|
+
const sessionId = this.activeSessionId
|
|
423
|
+
if (this._restoringSessionMap.has(sessionId)) {
|
|
424
|
+
try {
|
|
425
|
+
await this._restoringSessionMap.get(sessionId)
|
|
426
|
+
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
427
|
+
agent = this.activeAgent()
|
|
428
|
+
} else {
|
|
429
|
+
const restorePromise = (async () => {
|
|
430
|
+
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
431
|
+
try {
|
|
432
|
+
const agentOptions = {}
|
|
433
|
+
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
434
|
+
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
435
|
+
if (!agentOptions.provider || !agentOptions.model) {
|
|
436
|
+
try {
|
|
437
|
+
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
438
|
+
if (def) {
|
|
439
|
+
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
440
|
+
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
441
|
+
}
|
|
442
|
+
} catch { /* ignore */ }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
446
|
+
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
447
|
+
let persisted = false
|
|
448
|
+
try {
|
|
449
|
+
const headers = await this.ctx.sessionPersistence?.list?.()
|
|
450
|
+
persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
|
|
451
|
+
} catch { /* 读取失败则按未持久化处理 */ }
|
|
452
|
+
|
|
453
|
+
let handle
|
|
454
|
+
if (persisted) {
|
|
455
|
+
handle = await this.ctx.agents.resume({
|
|
456
|
+
resumeSessionId: sessionId,
|
|
457
|
+
agentOptions,
|
|
458
|
+
})
|
|
459
|
+
} else {
|
|
460
|
+
handle = await this.ctx.agents.create({
|
|
461
|
+
sessionId,
|
|
462
|
+
meta: { cwd: this.config.cwd || process.cwd() },
|
|
463
|
+
agentOptions,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
const resumedAgent = handle?.agent
|
|
467
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
|
|
468
|
+
return resumedAgent
|
|
469
|
+
} catch (err) {
|
|
470
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
471
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
|
|
472
|
+
// 清除失效的 activeSessionId,避免用户误以为还在旧会话中
|
|
473
|
+
if (this.activeSessionId === sessionId) {
|
|
474
|
+
this.activeSessionId = null
|
|
475
|
+
}
|
|
476
|
+
await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
})().finally(() => {
|
|
480
|
+
this._restoringSessionMap.delete(sessionId)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
this._restoringSessionMap.set(sessionId, restorePromise)
|
|
484
|
+
agent = await restorePromise
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!agent) {
|
|
488
|
+
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
489
|
+
return 'routed'
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
|
|
493
|
+
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
494
|
+
|
|
495
|
+
const messageValue = createUserMessage({
|
|
496
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
497
|
+
source: { kind: 'user' },
|
|
498
|
+
})
|
|
499
|
+
agent.followup(messageValue)
|
|
500
|
+
await this.sendTyping(1).catch(() => {})
|
|
501
|
+
return 'routed'
|
|
502
|
+
} catch (err) {
|
|
503
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
504
|
+
this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}\n${err instanceof Error ? err.stack : ''}`)
|
|
505
|
+
await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
|
|
506
|
+
return 'ignored'
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---- 发送(子类覆盖 _sendTextNow,不要覆盖 sendText)----
|
|
511
|
+
|
|
512
|
+
// 出站发送串行队列:所有 sendText 依次执行,杜绝并发分块交错乱序,
|
|
513
|
+
// 以及 QQ 流式 replace 共享 _msgSeq 的并发冲突
|
|
514
|
+
_enqueueSend(task) {
|
|
515
|
+
const queue = (this._sendQueue ??= Promise.resolve())
|
|
516
|
+
const run = queue.then(task, task)
|
|
517
|
+
this._sendQueue = run.catch(() => {})
|
|
518
|
+
return run
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 向当前 peer 发送文本(自动分块 + typing 指示)。经串行队列执行。
|
|
522
|
+
* opts.outboundPeer(可选):覆盖发送目标(T2.3,绑定到发起轮次的会话 peer)。 */
|
|
523
|
+
sendText(text, opts = {}) {
|
|
524
|
+
return this._enqueueSend(() => this._sendTextNow(text, opts))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async _sendTextNow(text, opts = {}) {
|
|
528
|
+
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
529
|
+
const peer = opts?.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
|
|
530
|
+
if (!peer) return
|
|
531
|
+
const chunks = splitForIM(text, this.config.maxMessageChars)
|
|
532
|
+
if (chunks.length === 0) return
|
|
533
|
+
await this.sendTyping(1).catch(() => {})
|
|
534
|
+
try {
|
|
535
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
536
|
+
let result
|
|
537
|
+
try {
|
|
538
|
+
result = await this.platform.sendText(peer, chunks[i])
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// 适配器可能直接透传 gateway 抛出的异常(telegram/feishu),统一按失败分块处理,
|
|
541
|
+
// 避免调用方 `void this.sendText(...)` 逃逸成 unhandled rejection
|
|
542
|
+
result = { success: false, error: err?.message ?? String(err) }
|
|
543
|
+
}
|
|
544
|
+
if (result && result.success === false) {
|
|
545
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
|
|
546
|
+
break
|
|
547
|
+
}
|
|
548
|
+
if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
|
|
549
|
+
await sleep(this.config.sendChunkDelayMs)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
await this.sendTyping(2).catch(() => {})
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
|
|
558
|
+
async sendTyping(state) {
|
|
559
|
+
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
560
|
+
if (!this.platform?.sendTyping || peer == null) return
|
|
561
|
+
return this.platform.sendTyping(peer, state)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---- 出站事件绑定 ----
|
|
565
|
+
|
|
566
|
+
_attachOutbound() {
|
|
567
|
+
this._digestState = new Map()
|
|
568
|
+
const stopHeartbeat = (state) => {
|
|
569
|
+
if (state && state.heartbeat) {
|
|
570
|
+
clearInterval(state.heartbeat)
|
|
571
|
+
state.heartbeat = undefined
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const startHeartbeat = (session, state) => {
|
|
575
|
+
stopHeartbeat(state)
|
|
576
|
+
if (this.config.digestIntervalSec <= 0) return
|
|
577
|
+
state.heartbeat = setInterval(() => {
|
|
578
|
+
// 1. 必须依然是当前活动会话
|
|
579
|
+
if (this.activeSessionId !== session.id) {
|
|
580
|
+
stopHeartbeat(state)
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
// 2. 检查会话当前是否真正处于 inTurn 状态中
|
|
584
|
+
const line = digestLine(session)
|
|
585
|
+
if (!line) {
|
|
586
|
+
stopHeartbeat(state)
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
// 心跳时同时刷新 typing 状态(微信 typing 只维持 15 秒)
|
|
590
|
+
if (this.peerId) this.sendTyping(1).catch(() => {})
|
|
591
|
+
const heartbeatTurn = this._turnPeers.get(session.id)
|
|
592
|
+
void this.sendText(line, heartbeatTurn ? { outboundPeer: heartbeatTurn.outboundPeer } : {})
|
|
593
|
+
}, this.config.digestIntervalSec * 1000)
|
|
594
|
+
if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
|
|
595
|
+
}
|
|
596
|
+
const onEvent = async (session, event) => {
|
|
597
|
+
// 仅为活动会话创建 digest 状态:非活动会话即使有心跳残留,也会被心跳回调里的
|
|
598
|
+
// 失活检查在下一个周期自行停止。若不过滤,任意会话事件都会在此堆积 Map entry(内存泄漏)
|
|
599
|
+
if (session.id !== this.activeSessionId) return
|
|
600
|
+
const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
|
|
601
|
+
this._digestState.set(session.id, state)
|
|
602
|
+
|
|
603
|
+
if (event.type === 'turn/end') {
|
|
604
|
+
stopHeartbeat(state)
|
|
605
|
+
// 本轮已结束,清空轮次集合防止长期运行下无限累积
|
|
606
|
+
state.startedTurns.clear()
|
|
607
|
+
}
|
|
608
|
+
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
609
|
+
|
|
610
|
+
// 出站事件绑定到发起本轮的会话 peer(T2.3)
|
|
611
|
+
const turn = this._turnPeers.get(session.id)
|
|
612
|
+
const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
|
|
613
|
+
|
|
614
|
+
if (event.type === 'turn/start') {
|
|
615
|
+
const turn = event.data?.turn
|
|
616
|
+
state.createdFiles = new Set()
|
|
617
|
+
if (turn != null && !state.startedTurns.has(turn)) {
|
|
618
|
+
state.startedTurns.add(turn)
|
|
619
|
+
// 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
|
|
620
|
+
if (this.peerId) this.sendTyping(1).catch(() => {})
|
|
621
|
+
}
|
|
622
|
+
startHeartbeat(session, state)
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
|
|
626
|
+
|
|
627
|
+
if (event.type === 'tool/call') {
|
|
628
|
+
// 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
if (event.type === 'assistant/message') {
|
|
632
|
+
const rawText = textOfAssistantMessage(event.data.message)
|
|
633
|
+
if (rawText.trim()) {
|
|
634
|
+
const cwd = getSessionCwd(session)
|
|
635
|
+
const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
|
|
636
|
+
for (const f of files) {
|
|
637
|
+
state.createdFiles.add(f)
|
|
638
|
+
}
|
|
639
|
+
// 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
|
|
640
|
+
if (cleanText) {
|
|
641
|
+
void this.sendText(cleanText, sendOpts)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
if (event.type === 'turn/end') {
|
|
647
|
+
stopHeartbeat(state)
|
|
648
|
+
if (this.peerId) this.sendTyping(2).catch(() => {})
|
|
649
|
+
const reason = event.data?.reason || {}
|
|
650
|
+
if (reason.kind === 'error') {
|
|
651
|
+
void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`, sendOpts)
|
|
652
|
+
} else if (reason.kind === 'aborted') {
|
|
653
|
+
void this.sendText(`⏹ **任务已停止**`, sendOpts)
|
|
654
|
+
} else if (reason.kind === 'max-tokens') {
|
|
655
|
+
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`, sendOpts)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
|
|
659
|
+
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
660
|
+
const rawFiles = Array.from(state.createdFiles)
|
|
661
|
+
const cwd = getSessionCwd(session)
|
|
662
|
+
const targetPeer = sendOpts.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
|
|
663
|
+
|
|
664
|
+
if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
|
|
665
|
+
const uniqueFilesToSend = []
|
|
666
|
+
for (const f of rawFiles) {
|
|
667
|
+
const resolved = resolveFilePath(f, cwd)
|
|
668
|
+
if (!resolved || uniqueFilesToSend.includes(resolved)) continue
|
|
669
|
+
// 发送白名单:仅允许会话 cwd(及其子目录)内、且不命中敏感路径的文件,
|
|
670
|
+
// 防止模型被提示注入后借 [SEND_FILE] 外发 .ssh/.credentials/.env 等任意本地文件。
|
|
671
|
+
if (!isPathAllowedForSend(resolved, cwd)) {
|
|
672
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] blocked SEND_FILE outside allowed workspace: ${resolved}`)
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
uniqueFilesToSend.push(resolved)
|
|
676
|
+
}
|
|
677
|
+
for (const resolved of uniqueFilesToSend) {
|
|
678
|
+
try {
|
|
679
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
|
|
680
|
+
const res = await this.platform.sendMediaFile(targetPeer, resolved)
|
|
681
|
+
if (res && res.success === false) {
|
|
682
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
|
|
683
|
+
}
|
|
684
|
+
} catch (err) {
|
|
685
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
state.createdFiles.clear()
|
|
690
|
+
}
|
|
691
|
+
// 本轮结束,释放轮次绑定(审批/心跳等已不再需要)
|
|
692
|
+
this._turnPeers.delete(session.id)
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const listener = (session, event) => { void onEvent(session, event) }
|
|
697
|
+
const disposer = this.ctx.on('session/event', listener)
|
|
698
|
+
this.disposers.push(() => {
|
|
699
|
+
for (const state of this._digestState.values()) stopHeartbeat(state)
|
|
700
|
+
disposer()
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---- 审批桥 ----
|
|
705
|
+
//
|
|
706
|
+
// DSH 的审批分发是 cordis waterfall(顺序链):监听器按注册序执行,
|
|
707
|
+
// 不调用 next() 的监听器否决整条链。宿主 apiproxy 注册在先的 GUI 认领监听器
|
|
708
|
+
// 会认领 approval/asked 事件并 veto 等待网页回答——若不 prepend,本桥永远没有
|
|
709
|
+
// 机会执行,IM 端永远收不到审批卡片(工具调用最终以 unavailable 失败)。
|
|
710
|
+
// 因此以 { prepend: true } 注册到链条最外层。
|
|
711
|
+
//
|
|
712
|
+
// 归属模型:IM 发起的轮次审批**只在 IM 决议**——不调用 next(),宿主 GUI 通道
|
|
713
|
+
// 根本不打开。此前曾让 GUI 弹窗与 IM 卡片并行 race,但宿主的 pending 认领没有
|
|
714
|
+
// 插件可用的收尾接口(只有 Web /api/respond 或 signal abort),IM 决议后 Web
|
|
715
|
+
// 弹窗会永久残留(用户实测报告)。各通道只决议自己发起的轮次。
|
|
716
|
+
|
|
717
|
+
_attachApprovalBridge() {
|
|
718
|
+
const listener = async (req, next) => {
|
|
719
|
+
// 只有"本轮由本桥发起"(_turnPeers 有该会话的轮次记录)时才拦截审批。
|
|
720
|
+
// 仅凭 activeSessionId 匹配是不够的:重启恢复/默认挑选后它可能指向一个
|
|
721
|
+
// Web 端发起的会话——那会让 Web 轮次的审批被劫持发去 IM(GUI 不弹窗、
|
|
722
|
+
// 无人响应即自动拒绝)。Web 轮次必须直接放行给宿主 GUI 处理。
|
|
723
|
+
const sessionId = req.agent?.session?.id
|
|
724
|
+
const turn = sessionId ? this._turnPeers.get(sessionId) : null
|
|
725
|
+
if (!turn || !this.ownsAgent(req.agent)) {
|
|
726
|
+
// info 级别:这是用户可自诊的关键判定点(IM 没收到卡片时先看这行)
|
|
727
|
+
this.logger?.info?.('[dsh-bridge %s] approval falls through to GUI: not an IM-initiated turn (session=%s, activeSession=%s, turnTracked=%s)', this.platform?.id, sessionId ?? '(none)', this.activeSessionId ?? '(none)', Boolean(turn))
|
|
728
|
+
return next?.()
|
|
729
|
+
}
|
|
730
|
+
const peer = turn.outboundPeer?.peerId
|
|
731
|
+
const initiator = turn.senderId
|
|
732
|
+
if (!peer) {
|
|
733
|
+
this.logger?.debug?.('[dsh-bridge %s] approval/request ignored: no active peer', this.platform?.id)
|
|
734
|
+
return next?.()
|
|
735
|
+
}
|
|
736
|
+
const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
|
|
737
|
+
|
|
738
|
+
const number = this.nextApprovalNumber()
|
|
739
|
+
const timeoutSec = this.config.approvalTimeoutSec
|
|
740
|
+
const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
|
|
741
|
+
const prompt = [
|
|
742
|
+
`## ⚠️ 操作权限确认 (#${number})`,
|
|
743
|
+
'',
|
|
744
|
+
'| 项目 | 详情 |',
|
|
745
|
+
'| :--- | :--- |',
|
|
746
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
747
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
748
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
749
|
+
'',
|
|
750
|
+
`> 回复 \`/yes\` (或 \`1\`) 批准执行`,
|
|
751
|
+
`> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
|
|
752
|
+
].join('\n')
|
|
753
|
+
|
|
754
|
+
void this.sendText(prompt, sendOpts)
|
|
755
|
+
|
|
756
|
+
let settled = false
|
|
757
|
+
let timeoutFired = false
|
|
758
|
+
let resolveIm
|
|
759
|
+
const imPromise = new Promise((resolve) => { resolveIm = resolve })
|
|
760
|
+
const settleIm = (outcome) => {
|
|
761
|
+
if (settled) return
|
|
762
|
+
settled = true
|
|
763
|
+
this.clearApproval(number)
|
|
764
|
+
resolveIm(outcome)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
timeoutFired = true
|
|
769
|
+
settleIm('rejected')
|
|
770
|
+
}, timeoutSec * 1000)
|
|
771
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
772
|
+
|
|
773
|
+
// turn 被停止/工具调用中止时 DSH 会 abort req.signal:同步取消 IM 侧待决审批
|
|
774
|
+
const onSignalAbort = () => {
|
|
775
|
+
timeoutFired = true
|
|
776
|
+
settleIm('cancelled')
|
|
777
|
+
}
|
|
778
|
+
req.signal?.addEventListener('abort', onSignalAbort, { once: true })
|
|
779
|
+
|
|
780
|
+
this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
|
|
781
|
+
|
|
782
|
+
let outcome
|
|
783
|
+
try {
|
|
784
|
+
outcome = await imPromise
|
|
785
|
+
} finally {
|
|
786
|
+
req.signal?.removeEventListener('abort', onSignalAbort)
|
|
787
|
+
clearTimeout(timer)
|
|
788
|
+
settled = true
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
this.logger?.info?.('[dsh-bridge %s] approval #%d resolved: outcome=%s', this.platform?.id, number, outcome)
|
|
792
|
+
|
|
793
|
+
// 仅在非超时/非中止路径发送确认消息(那些路径 resolve 已发生在定时器/abort 回调)
|
|
794
|
+
if (!timeoutFired) {
|
|
795
|
+
const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
|
|
796
|
+
void this.sendText(`${label}(#${number})`, sendOpts)
|
|
797
|
+
}
|
|
798
|
+
return outcome
|
|
799
|
+
}
|
|
800
|
+
const disposer = this.ctx.on('approval/request', listener, { prepend: true })
|
|
801
|
+
this.disposers.push(disposer)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
// 辅助
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
// 导出,便于测试与复用
|
|
810
|
+
export const conversationBridgeHelpers = {
|
|
811
|
+
splitForIM,
|
|
812
|
+
digestLine,
|
|
813
|
+
textOfAssistantMessage,
|
|
814
|
+
resolveFilePath,
|
|
815
|
+
extractFilePathsFromText,
|
|
816
|
+
extractAndStripSendFileDirectives,
|
|
817
|
+
sessionsInDisplayOrder,
|
|
818
|
+
listSessions,
|
|
819
|
+
renderSessions,
|
|
820
|
+
listWorkspaces,
|
|
821
|
+
}
|