@wenbin_wb/dsh-bridge 2.2.9 → 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 +29 -4
- package/lib/qq/gateway.js +1 -1
- 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
|
|
@@ -648,8 +648,20 @@ export class ConversationBridge {
|
|
|
648
648
|
|
|
649
649
|
void this.sendText(prompt)
|
|
650
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
|
+
|
|
651
662
|
let timeoutFired = false
|
|
652
|
-
|
|
663
|
+
let winner = 'im'
|
|
664
|
+
const imPromise = new Promise((resolve) => {
|
|
653
665
|
const timer = setTimeout(() => {
|
|
654
666
|
timeoutFired = true
|
|
655
667
|
this.clearApproval(number)
|
|
@@ -659,9 +671,22 @@ export class ConversationBridge {
|
|
|
659
671
|
this.registerApproval(number, { number, request: req, resolve, timer })
|
|
660
672
|
})
|
|
661
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
|
+
|
|
662
686
|
// 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
|
|
663
687
|
if (!timeoutFired) {
|
|
664
|
-
const
|
|
688
|
+
const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
|
|
689
|
+
const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
|
|
665
690
|
void this.sendText(`${label}(#${number})`)
|
|
666
691
|
}
|
|
667
692
|
return outcome
|
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 }
|
package/lib/wechat/gateway.js
CHANGED
|
@@ -736,6 +736,9 @@ export class WechatGateway extends Service {
|
|
|
736
736
|
|
|
737
737
|
consecutiveFailures = 0
|
|
738
738
|
if (batch.syncBuf) this.syncBuf = batch.syncBuf
|
|
739
|
+
if (this.statusValue !== 'connected') {
|
|
740
|
+
this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
|
|
741
|
+
}
|
|
739
742
|
this.setStatus('connected')
|
|
740
743
|
for (const message of batch.messages) {
|
|
741
744
|
this.dispatchInbound(message)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
"docs/banner.jpg",
|
|
22
22
|
"docs/custom-tunnel.md",
|
|
23
23
|
"docs/wechat-usage.md",
|
|
24
|
-
"docs/qq-usage.md"
|
|
24
|
+
"docs/qq-usage.md",
|
|
25
|
+
"docs/feishu-usage.md"
|
|
25
26
|
],
|
|
26
27
|
"scripts": {
|
|
27
28
|
"build:client": "node client/build.mjs",
|
|
28
29
|
"test": "node --test test/*.test.mjs"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
32
|
+
"@larksuiteoapi/node-sdk": "^1.73.0",
|
|
31
33
|
"qrcode": "^1.5.3",
|
|
32
34
|
"ws": "^8.18.0",
|
|
33
35
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6"
|