@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.
@@ -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
- const outcome = await new Promise((resolve) => {
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 label = outcome === 'allowed-once' ? ' **已批准执行**' : outcome === 'rejected' ? '❌ **已拒绝执行**' : `**[${outcome}]**`
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] gateway connected'))
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 }
@@ -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.2.9",
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"