@wenbin_wb/dsh-bridge 2.2.8 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/client/client.js +44 -156
- package/client/index.js +42 -118
- package/docs/feishu-usage.md +93 -0
- package/lib/feishu/gateway.js +373 -0
- package/lib/feishu/index.js +203 -0
- package/lib/feishu/node.js +351 -0
- package/lib/index.js +50 -0
- package/lib/platform/conversation-bridge.js +92 -43
- package/lib/qq/gateway.js +1 -1
- package/lib/qq/node.js +5 -21
- package/lib/wechat/gateway.js +3 -0
- package/package.json +5 -3
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// dsh-bridge Feishu / Lark conversation node
|
|
2
|
+
// 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
|
|
3
|
+
// ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
|
|
4
|
+
|
|
5
|
+
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
6
|
+
|
|
7
|
+
function makePlatform(gateway) {
|
|
8
|
+
return {
|
|
9
|
+
id: 'feishu',
|
|
10
|
+
name: 'Feishu / Lark',
|
|
11
|
+
capabilities: { supportsGroup: true, group: true, media: true, approvals: true },
|
|
12
|
+
async sendText(peerId, text, opts = {}) {
|
|
13
|
+
return gateway?.sendMarkdownCard(peerId, text, opts)
|
|
14
|
+
},
|
|
15
|
+
async sendTyping() {},
|
|
16
|
+
dispose() {},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function splitIntoIncremental(content, maxChunk = 150) {
|
|
25
|
+
const chunks = []
|
|
26
|
+
let start = 0
|
|
27
|
+
while (start < content.length) {
|
|
28
|
+
if (content.length - start <= maxChunk) {
|
|
29
|
+
chunks.push(content.slice(start))
|
|
30
|
+
break
|
|
31
|
+
}
|
|
32
|
+
const windowStart = start + Math.floor(maxChunk * 0.6)
|
|
33
|
+
const windowEnd = start + maxChunk
|
|
34
|
+
let cut = content.lastIndexOf('\n', windowEnd)
|
|
35
|
+
if (cut <= windowStart || cut === -1) cut = windowEnd
|
|
36
|
+
chunks.push(content.slice(start, cut))
|
|
37
|
+
start = cut
|
|
38
|
+
}
|
|
39
|
+
if (chunks.length <= 1 && content.length > 30) {
|
|
40
|
+
const step = Math.max(20, Math.floor(content.length / 4))
|
|
41
|
+
chunks.length = 0
|
|
42
|
+
for (let i = 0; i < content.length; i += step) {
|
|
43
|
+
chunks.push(content.slice(i, Math.min(i + step, content.length)))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const slices = []
|
|
47
|
+
let acc = ''
|
|
48
|
+
for (const s of chunks) {
|
|
49
|
+
acc += s
|
|
50
|
+
slices.push(acc)
|
|
51
|
+
}
|
|
52
|
+
return slices
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class FeishuConversationNode extends ConversationBridge {
|
|
56
|
+
constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
|
|
57
|
+
super({
|
|
58
|
+
ctx,
|
|
59
|
+
logger,
|
|
60
|
+
config,
|
|
61
|
+
platform: makePlatform(ctx.feishu),
|
|
62
|
+
onFirstSender,
|
|
63
|
+
onActiveSessionChange,
|
|
64
|
+
})
|
|
65
|
+
this.gateway = ctx.feishu
|
|
66
|
+
this._lastPeer = null // { peerId, senderId, isGroup }
|
|
67
|
+
this._streamCardId = null // 当前轮次的流式卡片 message_id
|
|
68
|
+
this._streamContent = '' // 当前轮次流式卡片的内容
|
|
69
|
+
this._patchTimer = null // 节流定时器
|
|
70
|
+
this._inTurn = false
|
|
71
|
+
|
|
72
|
+
// 订阅网关入站消息事件
|
|
73
|
+
this.ctx.on('feishu/message', (event) => {
|
|
74
|
+
void this._handleInbound(event)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// 订阅卡片交互事件(审批按钮点击)
|
|
78
|
+
this.ctx.on('feishu/action', (event) => {
|
|
79
|
+
void this._handleAction(event)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
|
|
83
|
+
this.ctx.on('session/event', (session, event) => {
|
|
84
|
+
if (session.id !== this.activeSessionId) return
|
|
85
|
+
if (event.type === 'turn/start') {
|
|
86
|
+
this._inTurn = true
|
|
87
|
+
this._streamCardId = null
|
|
88
|
+
this._streamContent = ''
|
|
89
|
+
if (this._patchTimer) {
|
|
90
|
+
clearTimeout(this._patchTimer)
|
|
91
|
+
this._patchTimer = null
|
|
92
|
+
}
|
|
93
|
+
} else if (event.type === 'turn/end') {
|
|
94
|
+
this._inTurn = false
|
|
95
|
+
if (this._patchTimer) {
|
|
96
|
+
clearTimeout(this._patchTimer)
|
|
97
|
+
this._patchTimer = null
|
|
98
|
+
}
|
|
99
|
+
if (this._streamCardId && this._streamContent) {
|
|
100
|
+
void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
101
|
+
}
|
|
102
|
+
this._streamCardId = null
|
|
103
|
+
this._streamContent = ''
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _handleInbound(event) {
|
|
109
|
+
const { peerId, senderId, isGroup, text } = event
|
|
110
|
+
this._lastPeer = { peerId, senderId, isGroup }
|
|
111
|
+
|
|
112
|
+
this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${text}`)
|
|
113
|
+
|
|
114
|
+
// 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
|
|
115
|
+
if (!this.activeSessionId) {
|
|
116
|
+
await this._pickDefaultSession().catch(() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 转交通用 ConversationBridge 路由
|
|
120
|
+
return super.handleInbound({
|
|
121
|
+
senderId: isGroup ? peerId : senderId,
|
|
122
|
+
peerId,
|
|
123
|
+
isGroup,
|
|
124
|
+
text,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async _handleAction(event) {
|
|
129
|
+
const { operatorId, value } = event
|
|
130
|
+
const approvalId = Number(value.approvalId)
|
|
131
|
+
const action = value.action
|
|
132
|
+
|
|
133
|
+
if (approvalId && (action === 'approve' || action === 'reject')) {
|
|
134
|
+
const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
|
|
135
|
+
const pending = this.pending.get(approvalId)
|
|
136
|
+
if (pending) {
|
|
137
|
+
this.clearApproval(approvalId)
|
|
138
|
+
pending.resolve(outcome)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async sendText(text) {
|
|
144
|
+
const peerId = this._lastPeer?.peerId || this.peerId
|
|
145
|
+
if (!peerId) return
|
|
146
|
+
const isGroup = this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_'))
|
|
147
|
+
const content = String(text || '').trim()
|
|
148
|
+
if (!content) return
|
|
149
|
+
|
|
150
|
+
// 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
|
|
151
|
+
if (this._inTurn) {
|
|
152
|
+
const maxChunk = 150
|
|
153
|
+
const slices = splitIntoIncremental(content, maxChunk)
|
|
154
|
+
const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
|
|
155
|
+
|
|
156
|
+
if (!this._streamCardId) {
|
|
157
|
+
// 首片:创建卡片并记录 message_id
|
|
158
|
+
const firstSlice = slices[0] || content
|
|
159
|
+
try {
|
|
160
|
+
const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
|
|
161
|
+
this._streamCardId = res?.message_id || null
|
|
162
|
+
this._streamContent = firstSlice
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
|
|
165
|
+
await this.gateway.sendText(peerId, content, { isGroup })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
|
|
170
|
+
for (let i = 1; i < slices.length; i++) {
|
|
171
|
+
await sleep(delayMs)
|
|
172
|
+
this._streamContent = slices[i]
|
|
173
|
+
if (this._streamCardId) {
|
|
174
|
+
await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// 如果卡片已存在(同轮次后续输出):更新到最终内容
|
|
179
|
+
this._streamContent = content
|
|
180
|
+
await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
|
|
186
|
+
try {
|
|
187
|
+
if (content.length <= 25000) {
|
|
188
|
+
await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
|
|
189
|
+
} else {
|
|
190
|
+
const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
|
|
191
|
+
for (const chunk of chunks) {
|
|
192
|
+
await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
|
|
197
|
+
await this.gateway.sendText(peerId, content, { isGroup })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- 飞书专属卡片审批桥 ----
|
|
202
|
+
|
|
203
|
+
_attachApprovalBridge() {
|
|
204
|
+
const listener = async (req, next) => {
|
|
205
|
+
if (!this.ownsAgent(req.agent)) return next?.()
|
|
206
|
+
const peer = this._lastPeer
|
|
207
|
+
if (!peer?.peerId) return next?.()
|
|
208
|
+
|
|
209
|
+
const number = this.nextApprovalNumber()
|
|
210
|
+
const timeoutSec = this.config.approvalTimeoutSec || 600
|
|
211
|
+
const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
|
|
212
|
+
|
|
213
|
+
// 构建飞书原生交互卡片 (JSON 2.0)
|
|
214
|
+
const card = {
|
|
215
|
+
schema: '2.0',
|
|
216
|
+
header: {
|
|
217
|
+
title: {
|
|
218
|
+
tag: 'plain_text',
|
|
219
|
+
content: `⚠️ 操作权限确认 (#${number})`,
|
|
220
|
+
},
|
|
221
|
+
template: 'orange',
|
|
222
|
+
},
|
|
223
|
+
body: {
|
|
224
|
+
elements: [
|
|
225
|
+
{
|
|
226
|
+
tag: 'markdown',
|
|
227
|
+
content: [
|
|
228
|
+
`| 项目 | 详情 |`,
|
|
229
|
+
`| :--- | :--- |`,
|
|
230
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
231
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
232
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
233
|
+
].join('\n'),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
tag: 'column_set',
|
|
237
|
+
flex_mode: 'flow',
|
|
238
|
+
columns: [
|
|
239
|
+
{
|
|
240
|
+
tag: 'column',
|
|
241
|
+
width: 'auto',
|
|
242
|
+
elements: [
|
|
243
|
+
{
|
|
244
|
+
tag: 'button',
|
|
245
|
+
text: {
|
|
246
|
+
tag: 'plain_text',
|
|
247
|
+
content: '✓ 批准执行',
|
|
248
|
+
},
|
|
249
|
+
type: 'primary',
|
|
250
|
+
value: {
|
|
251
|
+
action: 'approve',
|
|
252
|
+
approvalId: number,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
tag: 'column',
|
|
259
|
+
width: 'auto',
|
|
260
|
+
elements: [
|
|
261
|
+
{
|
|
262
|
+
tag: 'button',
|
|
263
|
+
text: {
|
|
264
|
+
tag: 'plain_text',
|
|
265
|
+
content: '✕ 拒绝执行',
|
|
266
|
+
},
|
|
267
|
+
type: 'danger',
|
|
268
|
+
value: {
|
|
269
|
+
action: 'reject',
|
|
270
|
+
approvalId: number,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 发送飞书卡片
|
|
282
|
+
try {
|
|
283
|
+
await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// 卡片发送失败降级发送 Markdown
|
|
286
|
+
const textFallback = [
|
|
287
|
+
`## ⚠️ 操作权限确认 (#${number})`,
|
|
288
|
+
'',
|
|
289
|
+
`| 项目 | 详情 |`,
|
|
290
|
+
`| :--- | :--- |`,
|
|
291
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
292
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
293
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
294
|
+
'',
|
|
295
|
+
`> 回复 \`/yes\` (或 \`1\`) 批准执行`,
|
|
296
|
+
`> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
|
|
297
|
+
].join('\n')
|
|
298
|
+
void this.gateway.sendMarkdownCard(peer.peerId, textFallback, { isGroup: peer.isGroup })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
|
|
302
|
+
let nextPromise = null
|
|
303
|
+
if (typeof next === 'function') {
|
|
304
|
+
try {
|
|
305
|
+
const res = next()
|
|
306
|
+
if (res && typeof res.then === 'function') {
|
|
307
|
+
nextPromise = res
|
|
308
|
+
}
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let timeoutFired = false
|
|
313
|
+
let winner = 'feishu'
|
|
314
|
+
const feishuPromise = new Promise((resolve) => {
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
timeoutFired = true
|
|
317
|
+
this.clearApproval(number)
|
|
318
|
+
resolve('rejected')
|
|
319
|
+
}, timeoutSec * 1000)
|
|
320
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
321
|
+
this.registerApproval(number, { number, request: req, resolve, timer })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const outcome = await (nextPromise
|
|
325
|
+
? Promise.race([
|
|
326
|
+
feishuPromise.then(res => { winner = 'feishu'; return res }),
|
|
327
|
+
nextPromise.then(res => { winner = 'web'; return res }),
|
|
328
|
+
])
|
|
329
|
+
: feishuPromise)
|
|
330
|
+
|
|
331
|
+
// 如果 Web 端先决议,清除飞书端 pending 记录
|
|
332
|
+
if (winner === 'web') {
|
|
333
|
+
this.clearApproval(number)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!timeoutFired) {
|
|
337
|
+
const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
|
|
338
|
+
const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
|
|
339
|
+
void this.gateway.sendMarkdownCard(peer.peerId, `${label}(#${number})`, { isGroup: peer.isGroup })
|
|
340
|
+
}
|
|
341
|
+
return outcome
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const disposer = this.ctx.on('approval/request', listener)
|
|
345
|
+
this.disposers.push(disposer)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const feishuNodeHelpers = {
|
|
350
|
+
makePlatform,
|
|
351
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { CloudflaredManager } from './cloudflared-manager.mjs';
|
|
|
20
20
|
import { PlatformManager } from './platform/manager.js';
|
|
21
21
|
import { WechatService } from './wechat/index.js';
|
|
22
22
|
import { QqService } from './qq/index.js';
|
|
23
|
+
import { FeishuService } from './feishu/index.js';
|
|
23
24
|
|
|
24
25
|
const name = 'dsh-bridge';
|
|
25
26
|
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
@@ -555,6 +556,19 @@ function apply(ctx, config = {}) {
|
|
|
555
556
|
});
|
|
556
557
|
platformManager.register(qq);
|
|
557
558
|
|
|
559
|
+
// 飞书 Bot(官方 OpenAPI / WebSocket 长连接)—— 作为 Platform 子类注册进平台管理器
|
|
560
|
+
const feishu = new FeishuService({
|
|
561
|
+
ctx,
|
|
562
|
+
logger,
|
|
563
|
+
config: config.feishu ?? {},
|
|
564
|
+
onPersist: async (patch) => {
|
|
565
|
+
const stored = await loadConfig();
|
|
566
|
+
stored.feishu = { ...(stored.feishu ?? {}), ...patch };
|
|
567
|
+
await saveConfig(stored);
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
platformManager.register(feishu);
|
|
571
|
+
|
|
558
572
|
// 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
|
|
559
573
|
loadConfig().then(async (stored) => {
|
|
560
574
|
if (stored?.wechat) {
|
|
@@ -632,10 +646,46 @@ function apply(ctx, config = {}) {
|
|
|
632
646
|
}
|
|
633
647
|
}).catch(() => {});
|
|
634
648
|
|
|
649
|
+
// 启动时读取已保存的飞书 Bot 配置(凭证 + 白名单 + 活动会话)
|
|
650
|
+
loadConfig().then(async (stored) => {
|
|
651
|
+
if (stored?.feishu) {
|
|
652
|
+
const cfg = stored.feishu;
|
|
653
|
+
feishu.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
654
|
+
|
|
655
|
+
feishu.node._restoringConfig = (async () => {
|
|
656
|
+
try {
|
|
657
|
+
if (cfg.activeSessionId) {
|
|
658
|
+
feishu.node.activeSessionId = cfg.activeSessionId;
|
|
659
|
+
logger.info('dsh-bridge: restored feishu active session: %s', cfg.activeSessionId);
|
|
660
|
+
} else {
|
|
661
|
+
await feishu.node._pickDefaultSession().catch(() => {});
|
|
662
|
+
}
|
|
663
|
+
} finally {
|
|
664
|
+
feishu.node._configRestored = true;
|
|
665
|
+
}
|
|
666
|
+
})();
|
|
667
|
+
|
|
668
|
+
await feishu.node._restoringConfig;
|
|
669
|
+
|
|
670
|
+
if (cfg.appId && cfg.appSecret) {
|
|
671
|
+
feishu.gateway.updateConfig({
|
|
672
|
+
appId: cfg.appId,
|
|
673
|
+
appSecret: cfg.appSecret,
|
|
674
|
+
domain: cfg.domain || 'feishu',
|
|
675
|
+
});
|
|
676
|
+
logger.info('dsh-bridge: loaded saved feishu bot config, starting gateway');
|
|
677
|
+
await feishu.start().catch((err) => {
|
|
678
|
+
logger.error('dsh-bridge: feishu auto-start failed: %s', err?.message ?? err);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}).catch(() => {});
|
|
683
|
+
|
|
635
684
|
const disposeRpc = installBridgeRpc(ctx, {
|
|
636
685
|
service,
|
|
637
686
|
wechat,
|
|
638
687
|
qq,
|
|
688
|
+
feishu,
|
|
639
689
|
platformManager,
|
|
640
690
|
logger,
|
|
641
691
|
saveCustomTunnelConfig: async (serverUrl, accessToken) => {
|
|
@@ -626,9 +626,9 @@ export class ConversationBridge {
|
|
|
626
626
|
|
|
627
627
|
_attachApprovalBridge() {
|
|
628
628
|
const listener = async (req, next) => {
|
|
629
|
-
if (!this.ownsAgent(req.agent)) return next()
|
|
629
|
+
if (!this.ownsAgent(req.agent)) return next?.()
|
|
630
630
|
const peer = this.peerId
|
|
631
|
-
if (!peer) return next()
|
|
631
|
+
if (!peer) return next?.()
|
|
632
632
|
|
|
633
633
|
const number = this.nextApprovalNumber()
|
|
634
634
|
const timeoutSec = this.config.approvalTimeoutSec
|
|
@@ -636,9 +636,11 @@ export class ConversationBridge {
|
|
|
636
636
|
const prompt = [
|
|
637
637
|
`## ⚠️ 操作权限确认 (#${number})`,
|
|
638
638
|
'',
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
639
|
+
'| 项目 | 详情 |',
|
|
640
|
+
'| :--- | :--- |',
|
|
641
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
642
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
643
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
642
644
|
'',
|
|
643
645
|
`> 回复 \`/yes\` (或 \`1\`) 批准执行`,
|
|
644
646
|
`> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
|
|
@@ -646,8 +648,20 @@ export class ConversationBridge {
|
|
|
646
648
|
|
|
647
649
|
void this.sendText(prompt)
|
|
648
650
|
|
|
651
|
+
// 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
|
|
652
|
+
let nextPromise = null
|
|
653
|
+
if (typeof next === 'function') {
|
|
654
|
+
try {
|
|
655
|
+
const res = next()
|
|
656
|
+
if (res && typeof res.then === 'function') {
|
|
657
|
+
nextPromise = res
|
|
658
|
+
}
|
|
659
|
+
} catch {}
|
|
660
|
+
}
|
|
661
|
+
|
|
649
662
|
let timeoutFired = false
|
|
650
|
-
|
|
663
|
+
let winner = 'im'
|
|
664
|
+
const imPromise = new Promise((resolve) => {
|
|
651
665
|
const timer = setTimeout(() => {
|
|
652
666
|
timeoutFired = true
|
|
653
667
|
this.clearApproval(number)
|
|
@@ -657,9 +671,22 @@ export class ConversationBridge {
|
|
|
657
671
|
this.registerApproval(number, { number, request: req, resolve, timer })
|
|
658
672
|
})
|
|
659
673
|
|
|
674
|
+
const outcome = await (nextPromise
|
|
675
|
+
? Promise.race([
|
|
676
|
+
imPromise.then(res => { winner = 'im'; return res }),
|
|
677
|
+
nextPromise.then(res => { winner = 'web'; return res }),
|
|
678
|
+
])
|
|
679
|
+
: imPromise)
|
|
680
|
+
|
|
681
|
+
// 如果 Web 端先决议,清除 IM 端 pending 记录
|
|
682
|
+
if (winner === 'web') {
|
|
683
|
+
this.clearApproval(number)
|
|
684
|
+
}
|
|
685
|
+
|
|
660
686
|
// 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
|
|
661
687
|
if (!timeoutFired) {
|
|
662
|
-
const
|
|
688
|
+
const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
|
|
689
|
+
const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
|
|
663
690
|
void this.sendText(`${label}(#${number})`)
|
|
664
691
|
}
|
|
665
692
|
return outcome
|
|
@@ -827,11 +854,19 @@ async function routeCommand(node, text) {
|
|
|
827
854
|
await node.sendText(`## 🗂️ 可用工作区\n\n> 当前没有已注册的工作区。可使用 \`/new <提示词> @<路径>\` 指定项目目录。`)
|
|
828
855
|
return true
|
|
829
856
|
}
|
|
830
|
-
const
|
|
831
|
-
const titleStr = w.title && w.title !== w.path ?
|
|
832
|
-
|
|
857
|
+
const rows = workspaces.map((w, i) => {
|
|
858
|
+
const titleStr = w.title && w.title !== w.path ? w.title : getWorkspaceBasename(w.path)
|
|
859
|
+
const safeTitle = titleStr.replace(/\|/g, '|')
|
|
860
|
+
return `| **@${i + 1}** | ${safeTitle} | \`${w.path}\` |`
|
|
833
861
|
})
|
|
834
|
-
await node.sendText(
|
|
862
|
+
await node.sendText([
|
|
863
|
+
`## 🗂️ 可用工作区 (共 ${workspaces.length} 个)`,
|
|
864
|
+
`> 新建会话:发送 \`/new <提示词> @序号\` 或 \`/new <提示词> @路径\``,
|
|
865
|
+
'',
|
|
866
|
+
'| 序号 | 工作区名称 | 目录路径 |',
|
|
867
|
+
'| :--- | :--- | :--- |',
|
|
868
|
+
...rows,
|
|
869
|
+
].join('\n'))
|
|
835
870
|
return true
|
|
836
871
|
}
|
|
837
872
|
case 'new': {
|
|
@@ -901,10 +936,23 @@ async function routeCommand(node, text) {
|
|
|
901
936
|
const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
|
|
902
937
|
const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
|
|
903
938
|
const title = session.title || (session.events ? sessionLabel(session) : '')
|
|
904
|
-
const
|
|
905
|
-
const
|
|
939
|
+
const shortId = fmtSessionId(session.id)
|
|
940
|
+
const cwd = session.header?.cwd || node.config?.cwd || ''
|
|
906
941
|
|
|
907
|
-
|
|
942
|
+
const content = [
|
|
943
|
+
`## 📊 Agent 状态看板`,
|
|
944
|
+
'',
|
|
945
|
+
'| 属性 | 当前状态 / 参数 |',
|
|
946
|
+
'| :--- | :--- |',
|
|
947
|
+
`| **会话 ID** | \`${shortId}\` |`,
|
|
948
|
+
...(title ? [`| **会话标题** | ${title.replace(/\|/g, '|')} |`] : []),
|
|
949
|
+
...(cwd ? [`| **工作区** | \`${cwd}\` |`] : []),
|
|
950
|
+
`| **Agent 状态** | ${status} |`,
|
|
951
|
+
`| **累计事件** | ${session.seq ?? 0} 条 |`,
|
|
952
|
+
`| **最近执行** | ${reason} |`,
|
|
953
|
+
].join('\n')
|
|
954
|
+
|
|
955
|
+
await node.sendText(content)
|
|
908
956
|
return true
|
|
909
957
|
}
|
|
910
958
|
case 'start': // 别名:首次扫码自动开始一个会话
|
|
@@ -949,22 +997,21 @@ async function renderSessions(node) {
|
|
|
949
997
|
let idx = 0
|
|
950
998
|
for (const [cwd, sessions] of sortedGroups) {
|
|
951
999
|
const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${getWorkspaceBasename(cwd)}**`
|
|
952
|
-
|
|
953
|
-
parts.push(
|
|
954
|
-
parts.push('
|
|
1000
|
+
parts.push(groupName)
|
|
1001
|
+
parts.push('')
|
|
1002
|
+
parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
|
|
1003
|
+
parts.push('| :--- | :--- | :--- | :--- |')
|
|
955
1004
|
for (const session of sessions.slice(0, 20)) {
|
|
956
1005
|
idx += 1
|
|
957
1006
|
const isActive = session.id === node.activeSessionId
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
const
|
|
961
|
-
const when = session.createdAt ? fmtTime(session.createdAt) : ''
|
|
962
|
-
|
|
963
|
-
parts.push(`- **#${idx}** ${label}${activeTag}`)
|
|
964
|
-
parts.push(` \`${fmtSessionId(session.id)}\`${timeStr}`)
|
|
1007
|
+
const statusTag = isActive ? '`[当前]`' : '-'
|
|
1008
|
+
const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
|
|
1009
|
+
const safeTitle = (rawTitle || '新会话 (待输入)').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
|
|
1010
|
+
const when = session.createdAt ? fmtTime(session.createdAt) : '-'
|
|
1011
|
+
parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
|
|
965
1012
|
}
|
|
966
1013
|
if (sessions.length > 20) {
|
|
967
|
-
parts.push(
|
|
1014
|
+
parts.push(`*…该工作区共 ${sessions.length} 个会话,仅显示前 20 个*`)
|
|
968
1015
|
}
|
|
969
1016
|
parts.push('')
|
|
970
1017
|
}
|
|
@@ -1009,26 +1056,28 @@ function helpText() {
|
|
|
1009
1056
|
return [
|
|
1010
1057
|
'## 🤖 常用指令帮助',
|
|
1011
1058
|
'',
|
|
1012
|
-
'
|
|
1013
|
-
'',
|
|
1014
|
-
'
|
|
1015
|
-
'
|
|
1016
|
-
'
|
|
1017
|
-
'
|
|
1018
|
-
'
|
|
1019
|
-
'
|
|
1020
|
-
'
|
|
1021
|
-
'',
|
|
1022
|
-
'**📁 环境与状态**',
|
|
1023
|
-
'',
|
|
1024
|
-
'- `/workspaces` — 查看已注册的工作区列表',
|
|
1025
|
-
'- `/status` — 查看 Agent 运行状态与事件摘要',
|
|
1026
|
-
'- `/help` — 查看此帮助菜单',
|
|
1059
|
+
'### 💬 会话控制',
|
|
1060
|
+
'| 指令 | 说明 | 示例 |',
|
|
1061
|
+
'| :--- | :--- | :--- |',
|
|
1062
|
+
'| `/sessions` | 查看所有会话表格列表 | `/sessions` 或 `/list` |',
|
|
1063
|
+
'| `/use <编号>` | 切换到指定编号会话 | `/use 1` 或 `/resume 1` |',
|
|
1064
|
+
'| `/new <提示词>` | 在当前工作区新建会话 | `/new 帮我写个脚本` |',
|
|
1065
|
+
'| `/new <词> @N` | 在指定工作区新建会话 | `/new 帮我写个脚本 @1` |',
|
|
1066
|
+
'| `/stop` | 中断停止当前正在执行的任务 | `/stop` |',
|
|
1067
|
+
'| `/end` | 结束当前会话(回到空闲) | `/end` |',
|
|
1027
1068
|
'',
|
|
1028
|
-
'
|
|
1069
|
+
'### 📁 环境与状态',
|
|
1070
|
+
'| 指令 | 说明 |',
|
|
1071
|
+
'| :--- | :--- |',
|
|
1072
|
+
'| `/workspaces` | 查看可用工作区表格列表 |',
|
|
1073
|
+
'| `/status` | 查看 Agent 运行状态看板 |',
|
|
1074
|
+
'| `/help` | 查看此帮助菜单 |',
|
|
1029
1075
|
'',
|
|
1030
|
-
'
|
|
1031
|
-
'
|
|
1076
|
+
'### 🔐 权限确认',
|
|
1077
|
+
'| 指令 | 快捷数字 | 说明 |',
|
|
1078
|
+
'| :--- | :--- | :--- |',
|
|
1079
|
+
'| `/yes` | `1` | 批准当前工具执行请求 |',
|
|
1080
|
+
'| `/no` | `2` | 拒绝当前工具执行请求 |',
|
|
1032
1081
|
].join('\n')
|
|
1033
1082
|
}
|
|
1034
1083
|
|
package/lib/qq/gateway.js
CHANGED
|
@@ -245,7 +245,7 @@ export class QqGateway extends Service {
|
|
|
245
245
|
if (this.ws === ws) this.ws = null
|
|
246
246
|
error ? reject(error) : resolve()
|
|
247
247
|
}
|
|
248
|
-
ws.on('open', () => this.logger?.info?.('[dsh-bridge qq]
|
|
248
|
+
ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform'))
|
|
249
249
|
ws.on('message', (raw) => {
|
|
250
250
|
let payload
|
|
251
251
|
try { payload = JSON.parse(String(raw)) } catch { return }
|