@wenbin_wb/dsh-bridge 2.8.7 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,409 +1,433 @@
1
- // dsh-bridge Feishu / Lark conversation node
2
- // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
- // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
-
5
- import fs from 'node:fs'
6
- import path from 'node:path'
7
- import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
8
-
9
- function makePlatform(gateway) {
10
- return {
11
- id: 'feishu',
12
- name: 'Feishu / Lark',
13
- capabilities: { supportsGroup: true, group: true, media: true, approvals: true },
14
- async sendText(peerId, text, opts = {}) {
15
- return gateway?.sendMarkdownCard(peerId, text, opts)
16
- },
17
- async sendMediaFile(peerId, filePath, opts = {}) {
18
- return gateway?.sendMediaFile(peerId, filePath, opts)
19
- },
20
- async sendTyping() {},
21
- dispose() {},
22
- }
23
- }
24
-
25
- function sleep(ms) {
26
- return new Promise((resolve) => setTimeout(resolve, ms))
27
- }
28
-
29
- function splitIntoIncremental(content, maxChunk = 150) {
30
- const chunks = []
31
- let start = 0
32
- while (start < content.length) {
33
- if (content.length - start <= maxChunk) {
34
- chunks.push(content.slice(start))
35
- break
36
- }
37
- const windowStart = start + Math.floor(maxChunk * 0.6)
38
- const windowEnd = start + maxChunk
39
- let cut = content.lastIndexOf('\n', windowEnd)
40
- if (cut <= windowStart || cut === -1) cut = windowEnd
41
- chunks.push(content.slice(start, cut))
42
- start = cut
43
- }
44
- if (chunks.length <= 1 && content.length > 30) {
45
- const step = Math.max(20, Math.floor(content.length / 4))
46
- chunks.length = 0
47
- for (let i = 0; i < content.length; i += step) {
48
- chunks.push(content.slice(i, Math.min(i + step, content.length)))
49
- }
50
- }
51
- const slices = []
52
- let acc = ''
53
- for (const s of chunks) {
54
- acc += s
55
- slices.push(acc)
56
- }
57
- return slices
58
- }
59
-
60
- export class FeishuConversationNode extends ConversationBridge {
61
- constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
62
- super({
63
- ctx,
64
- logger,
65
- config,
66
- platform: makePlatform(ctx.feishu),
67
- onFirstSender,
68
- onActiveSessionChange,
69
- })
70
- this.gateway = ctx.feishu
71
- this._lastPeer = null // { peerId, senderId, isGroup }
72
- this._streamCardId = null // 当前轮次的流式卡片 message_id
73
- this._streamContent = '' // 当前轮次流式卡片的内容
74
- this._patchTimer = null // 节流定时器
75
- this._inTurn = false
76
-
77
- // 订阅网关入站消息事件
78
- this.disposers.push(this.ctx.on('feishu/message', (event) => this._handleInbound(event)))
79
-
80
- // 订阅卡片交互事件(审批按钮点击)
81
- this.disposers.push(this.ctx.on('feishu/action', (event) => this._handleAction(event)))
82
-
83
- // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
84
- this.disposers.push(this.ctx.on('session/event', (session, event) => {
85
- if (session.id !== this.activeSessionId) return
86
- if (event.type === 'turn/start') {
87
- this._inTurn = true
88
- this._streamCardId = null
89
- this._streamContent = ''
90
- if (this._patchTimer) {
91
- clearTimeout(this._patchTimer)
92
- this._patchTimer = null
93
- }
94
- } else if (event.type === 'turn/end') {
95
- this._inTurn = false
96
- if (this._patchTimer) {
97
- clearTimeout(this._patchTimer)
98
- this._patchTimer = null
99
- }
100
- if (this._streamCardId && this._streamContent) {
101
- void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
102
- }
103
- this._streamCardId = null
104
- this._streamContent = ''
105
- }
106
- }))
107
- }
108
-
109
- async _handleInbound(event) {
110
- if (this.gateway?._closing || this.gateway?.status === 'offline') return
111
- const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
112
- this._lastPeer = { peerId, senderId, isGroup }
113
-
114
- let mediaFiles = []
115
- if (messageType && messageType !== 'text') {
116
- mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
117
- }
118
-
119
- if (!text && mediaFiles.length === 0) {
120
- this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
121
- return
122
- }
123
-
124
- let fullText = text || ''
125
- if (mediaFiles.length > 0) {
126
- const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
127
- fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
128
- }
129
-
130
- this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
131
-
132
- // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
133
- if (!this.activeSessionId) {
134
- await this._pickDefaultSession().catch(() => {})
135
- }
136
-
137
- // 转交通用 ConversationBridge 路由
138
- return this.handleInbound({
139
- senderId: isGroup ? peerId : senderId,
140
- peerId,
141
- isGroup,
142
- text: fullText,
143
- })
144
- }
145
-
146
- async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
147
- if (!messageId || !messageType) return []
148
- const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
149
- try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
150
-
151
- const downloaded = []
152
- const fileKey = contentObj.image_key || contentObj.file_key
153
- const fileName = contentObj.file_name
154
-
155
- if (fileKey) {
156
- try {
157
- const type = messageType === 'image' ? 'image' : 'file'
158
- const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
159
- if (buf && buf.length > 0) {
160
- const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
161
- const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
162
- const filePath = path.join(mediaDir, safeName)
163
- await fs.promises.writeFile(filePath, buf)
164
- downloaded.push({ filename: safeName, path: filePath, size: buf.length })
165
- this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
166
- }
167
- } catch (err) {
168
- this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
169
- }
170
- }
171
- return downloaded
172
- }
173
-
174
- async _handleAction(event) {
175
- const { operatorId, value } = event
176
- const approvalId = Number(value.approvalId)
177
- const action = value.action
178
-
179
- if (approvalId && (action === 'approve' || action === 'reject')) {
180
- const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
181
- const pending = this.pending.get(approvalId)
182
- if (pending) {
183
- this.clearApproval(approvalId)
184
- pending.resolve(outcome)
185
- }
186
- }
187
- }
188
-
189
- async sendText(text) {
190
- const peerId = this._lastPeer?.peerId || this.peerId
191
- if (!peerId) return
192
- const isGroup = this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_'))
193
- const content = String(text || '').trim()
194
- if (!content) return
195
-
196
- // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
197
- if (this._inTurn) {
198
- const maxChunk = 150
199
- const slices = splitIntoIncremental(content, maxChunk)
200
- const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
201
-
202
- if (!this._streamCardId) {
203
- // 首片:创建卡片并记录 message_id
204
- const firstSlice = slices[0] || content
205
- try {
206
- const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
207
- this._streamCardId = res?.message_id || null
208
- this._streamContent = firstSlice
209
- } catch (err) {
210
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
211
- await this.gateway.sendText(peerId, content, { isGroup })
212
- return
213
- }
214
-
215
- // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
216
- for (let i = 1; i < slices.length; i++) {
217
- await sleep(delayMs)
218
- this._streamContent = slices[i]
219
- if (this._streamCardId) {
220
- await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
221
- }
222
- }
223
- } else {
224
- // 如果卡片已存在(同轮次后续输出):更新到最终内容
225
- this._streamContent = content
226
- await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
227
- }
228
- return
229
- }
230
-
231
- // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
232
- try {
233
- if (content.length <= 25000) {
234
- await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
235
- } else {
236
- const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
237
- for (const chunk of chunks) {
238
- await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
239
- }
240
- }
241
- } catch (err) {
242
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
243
- await this.gateway.sendText(peerId, content, { isGroup })
244
- }
245
- }
246
-
247
- // ---- 飞书专属卡片审批桥 ----
248
-
249
- _attachApprovalBridge() {
250
- const listener = async (req, next) => {
251
- if (!this.ownsAgent(req.agent)) return next?.()
252
- const peer = this._lastPeer
253
- if (!peer?.peerId) return next?.()
254
-
255
- const number = this.nextApprovalNumber()
256
- const timeoutSec = this.config.approvalTimeoutSec || 600
257
- const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
258
-
259
- // 构建飞书原生交互卡片 (JSON 2.0)
260
- const card = {
261
- schema: '2.0',
262
- header: {
263
- title: {
264
- tag: 'plain_text',
265
- content: `⚠️ 操作权限确认 (#${number})`,
266
- },
267
- template: 'orange',
268
- },
269
- body: {
270
- elements: [
271
- {
272
- tag: 'markdown',
273
- content: [
274
- `| 项目 | 详情 |`,
275
- `| :--- | :--- |`,
276
- `| **调用工具** | \`${req.toolName}\` |`,
277
- ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
278
- `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
279
- ].join('\n'),
280
- },
281
- {
282
- tag: 'column_set',
283
- flex_mode: 'flow',
284
- columns: [
285
- {
286
- tag: 'column',
287
- width: 'auto',
288
- elements: [
289
- {
290
- tag: 'button',
291
- text: {
292
- tag: 'plain_text',
293
- content: '✓ 批准执行',
294
- },
295
- type: 'primary',
296
- value: {
297
- action: 'approve',
298
- approvalId: number,
299
- },
300
- },
301
- ],
302
- },
303
- {
304
- tag: 'column',
305
- width: 'auto',
306
- elements: [
307
- {
308
- tag: 'button',
309
- text: {
310
- tag: 'plain_text',
311
- content: '✕ 拒绝执行',
312
- },
313
- type: 'danger',
314
- value: {
315
- action: 'reject',
316
- approvalId: number,
317
- },
318
- },
319
- ],
320
- },
321
- ],
322
- },
323
- ],
324
- },
325
- }
326
-
327
- // 发送飞书卡片
328
- try {
329
- await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
330
- } catch (err) {
331
- // 卡片发送失败降级发送 Markdown
332
- const textFallback = [
333
- `## ⚠️ 操作权限确认 (#${number})`,
334
- '',
335
- `| 项目 | 详情 |`,
336
- `| :--- | :--- |`,
337
- `| **调用工具** | \`${req.toolName}\` |`,
338
- ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
339
- `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
340
- '',
341
- `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
342
- `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
343
- ].join('\n')
344
- void this.gateway.sendMarkdownCard(peer.peerId, textFallback, { isGroup: peer.isGroup })
345
- }
346
-
347
- // 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
348
- let nextPromise = null
349
- if (typeof next === 'function') {
350
- try {
351
- const res = next()
352
- if (res && typeof res.then === 'function') {
353
- nextPromise = res
354
- }
355
- } catch {}
356
- }
357
-
358
- let timeoutFired = false
359
- let winner = 'feishu'
360
- const feishuPromise = new Promise((resolve) => {
361
- const timer = setTimeout(() => {
362
- timeoutFired = true
363
- this.clearApproval(number)
364
- resolve('rejected')
365
- }, timeoutSec * 1000)
366
- if (typeof timer.unref === 'function') timer.unref()
367
- this.registerApproval(number, { number, request: req, resolve, timer })
368
- })
369
-
370
- const outcome = await (nextPromise
371
- ? Promise.race([
372
- feishuPromise.then(res => { winner = 'feishu'; return res }),
373
- nextPromise.then(res => { winner = 'web'; return res }),
374
- ])
375
- : feishuPromise)
376
-
377
- // 如果 Web 端先决议,清除飞书端 pending 记录
378
- if (winner === 'web') {
379
- this.clearApproval(number)
380
- }
381
-
382
- if (!timeoutFired) {
383
- const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
384
- const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
385
- void this.gateway.sendMarkdownCard(peer.peerId, `${label}(#${number})`, { isGroup: peer.isGroup })
386
- }
387
- return outcome
388
- }
389
-
390
- const disposer = this.ctx.on('approval/request', listener)
391
- this.disposers.push(disposer)
392
- }
393
-
394
- dispose() {
395
- if (this._patchTimer) {
396
- clearTimeout(this._patchTimer)
397
- this._patchTimer = null
398
- }
399
- this._inTurn = false
400
- this._streamCardId = null
401
- this._streamContent = ''
402
- this._lastPeer = null
403
- super.dispose()
404
- }
405
- }
406
-
407
- export const feishuNodeHelpers = {
408
- makePlatform,
409
- }
1
+ // dsh-bridge Feishu / Lark conversation node
2
+ // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
8
+ import { cumulativeSlices } from '../platform/stream-slices.js'
9
+
10
+ function makePlatform(gateway) {
11
+ return {
12
+ id: 'feishu',
13
+ name: 'Feishu / Lark',
14
+ get status() { return gateway?.status ?? 'idle' },
15
+ // 能力字段与 base.js 约定对齐(此前是 group/media/approvals 非规范命名)
16
+ capabilities: { supportsGroup: true, supportsMedia: true, supportsTyping: false, maxMessageChars: 2000 },
17
+ async sendText(peerId, text, opts = {}) {
18
+ return gateway?.sendMarkdownCard(peerId, text, opts)
19
+ },
20
+ async sendMediaFile(peerId, filePath, opts = {}) {
21
+ return gateway?.sendMediaFile(peerId, filePath, opts)
22
+ },
23
+ async sendTyping() {},
24
+ dispose() {},
25
+ }
26
+ }
27
+
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms))
30
+ }
31
+
32
+ function splitChunksByNewline(content, maxChunk = 150) {
33
+ const chunks = []
34
+ let start = 0
35
+ while (start < content.length) {
36
+ if (content.length - start <= maxChunk) {
37
+ chunks.push(content.slice(start))
38
+ break
39
+ }
40
+ const windowStart = start + Math.floor(maxChunk * 0.6)
41
+ const windowEnd = start + maxChunk
42
+ let cut = content.lastIndexOf('\n', windowEnd)
43
+ if (cut <= windowStart || cut === -1) cut = windowEnd
44
+ chunks.push(content.slice(start, cut))
45
+ start = cut
46
+ }
47
+ if (chunks.length <= 1 && content.length > 30) {
48
+ const step = Math.max(20, Math.floor(content.length / 4))
49
+ chunks.length = 0
50
+ for (let i = 0; i < content.length; i += step) {
51
+ chunks.push(content.slice(i, Math.min(i + step, content.length)))
52
+ }
53
+ }
54
+ return chunks
55
+ }
56
+
57
+ function splitIntoIncremental(content, maxChunk = 150) {
58
+ return cumulativeSlices(splitChunksByNewline(content, maxChunk))
59
+ }
60
+
61
+ export class FeishuConversationNode extends ConversationBridge {
62
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
63
+ super({
64
+ ctx,
65
+ logger,
66
+ config,
67
+ platform: makePlatform(ctx.feishu),
68
+ onFirstSender,
69
+ onActiveSessionChange,
70
+ })
71
+ this.gateway = ctx.feishu
72
+ this._lastPeer = null // { peerId, senderId, isGroup }
73
+ this._streamCardId = null // 当前轮次的流式卡片 message_id
74
+ this._streamContent = '' // 当前轮次流式卡片的内容
75
+ this._patchTimer = null // 节流定时器
76
+ this._inTurn = false
77
+
78
+ // 订阅网关入站消息事件
79
+ this.disposers.push(this.ctx.on('feishu/message', (event) => this._handleInbound(event)))
80
+
81
+ // 订阅卡片交互事件(审批按钮点击)
82
+ this.disposers.push(this.ctx.on('feishu/action', (event) => this._handleAction(event)))
83
+
84
+ // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
85
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
86
+ if (session.id !== this.activeSessionId) return
87
+ if (event.type === 'turn/start') {
88
+ this._inTurn = true
89
+ this._streamCardId = null
90
+ this._streamContent = ''
91
+ if (this._patchTimer) {
92
+ clearTimeout(this._patchTimer)
93
+ this._patchTimer = null
94
+ }
95
+ } else if (event.type === 'turn/end') {
96
+ this._inTurn = false
97
+ if (this._patchTimer) {
98
+ clearTimeout(this._patchTimer)
99
+ this._patchTimer = null
100
+ }
101
+ if (this._streamCardId && this._streamContent) {
102
+ void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
103
+ }
104
+ this._streamCardId = null
105
+ this._streamContent = ''
106
+ }
107
+ }))
108
+ }
109
+
110
+ async _handleInbound(event) {
111
+ if (this.gateway?._closing || this.gateway?.status === 'offline') return
112
+ const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
113
+ this._lastPeer = { peerId, senderId, isGroup }
114
+
115
+ let mediaFiles = []
116
+ if (messageType && messageType !== 'text') {
117
+ mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
118
+ }
119
+
120
+ if (!text && mediaFiles.length === 0) {
121
+ this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
122
+ return
123
+ }
124
+
125
+ let fullText = text || ''
126
+ if (mediaFiles.length > 0) {
127
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
128
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
129
+ }
130
+
131
+ this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
132
+
133
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
134
+ if (!this.activeSessionId) {
135
+ await this._pickDefaultSession().catch(() => {})
136
+ }
137
+
138
+ // 转交通用 ConversationBridge 路由
139
+ // outboundPeer:本轮出站事件流绑定回发起会话(此前这里传的 peerId 是被静默丢弃的幽灵参数)
140
+ return this.handleInbound({
141
+ senderId: isGroup ? peerId : senderId,
142
+ isGroup,
143
+ text: fullText,
144
+ outboundPeer: { peerId, isGroup },
145
+ })
146
+ }
147
+
148
+ async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
149
+ if (!messageId || !messageType) return []
150
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
151
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
152
+
153
+ const downloaded = []
154
+ const fileKey = contentObj.image_key || contentObj.file_key
155
+ const fileName = contentObj.file_name
156
+
157
+ if (fileKey) {
158
+ try {
159
+ const type = messageType === 'image' ? 'image' : 'file'
160
+ const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
161
+ if (buf && buf.length > 0) {
162
+ const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
163
+ const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
164
+ const filePath = path.join(mediaDir, safeName)
165
+ await fs.promises.writeFile(filePath, buf)
166
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
167
+ this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
168
+ }
169
+ } catch (err) {
170
+ this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
171
+ }
172
+ }
173
+ return downloaded
174
+ }
175
+
176
+ async _handleAction(event) {
177
+ const { operatorId, value } = event
178
+ const approvalId = Number(value.approvalId)
179
+ const action = value.action
180
+
181
+ if (approvalId && (action === 'approve' || action === 'reject')) {
182
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
183
+ const pending = this.pending.get(approvalId)
184
+ if (pending) {
185
+ // 仅审批发起者可决议(与 /yes 的 senderId 校验同一防线)
186
+ if (pending.peerId && operatorId && pending.peerId !== operatorId) {
187
+ this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
188
+ return
189
+ }
190
+ this.clearApproval(approvalId)
191
+ pending.resolve(outcome)
192
+ }
193
+ }
194
+ }
195
+
196
+ async _sendTextNow(text, opts = {}) {
197
+ // T2.3:轮次绑定的 outboundPeer({ peerId, isGroup })优先生效
198
+ const bound = opts?.outboundPeer && typeof opts.outboundPeer.isGroup === 'boolean' ? opts.outboundPeer : null
199
+ const peerId = bound?.peerId || this._lastPeer?.peerId || this.peerId
200
+ if (!peerId) return
201
+ const isGroup = bound ? bound.isGroup : (this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_')))
202
+ const content = String(text || '').trim()
203
+ if (!content) return
204
+
205
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
206
+ if (this._inTurn) {
207
+ const maxChunk = 150
208
+ const slices = splitIntoIncremental(content, maxChunk)
209
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
210
+
211
+ if (!this._streamCardId) {
212
+ // 首片:创建卡片并记录 message_id
213
+ const firstSlice = slices[0] || content
214
+ try {
215
+ const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
216
+ this._streamCardId = res?.message_id || null
217
+ this._streamContent = firstSlice
218
+ } catch (err) {
219
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
220
+ await this.gateway.sendText(peerId, content, { isGroup })
221
+ return
222
+ }
223
+
224
+ // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
225
+ for (let i = 1; i < slices.length; i++) {
226
+ await sleep(delayMs)
227
+ this._streamContent = slices[i]
228
+ if (this._streamCardId) {
229
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
230
+ }
231
+ }
232
+ } else {
233
+ // 如果卡片已存在(同轮次后续输出):更新到最终内容
234
+ this._streamContent = content
235
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
236
+ }
237
+ return
238
+ }
239
+
240
+ // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
241
+ try {
242
+ if (content.length <= 25000) {
243
+ await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
244
+ } else {
245
+ const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
246
+ for (const chunk of chunks) {
247
+ await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
248
+ }
249
+ }
250
+ } catch (err) {
251
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
252
+ await this.gateway.sendText(peerId, content, { isGroup })
253
+ }
254
+ }
255
+
256
+ // ---- 飞书专属卡片审批桥 ----
257
+ //
258
+ // 与基类(conversation-bridge)同一归属模型,仅卡片渲染不同:
259
+ // - 必须 { prepend: true } 注册:否则宿主 apiproxy 的 GUI 认领监听器先行否决,
260
+ // 飞书卡片永远发不出(用户实测:飞书发起审批收不到卡片、Web 反而弹窗);
261
+ // - 只拦截"本轮由飞书发起"的轮次(_turnPeers 门槛),Web 轮次放行给宿主;
262
+ // - 不调用 next():IM 发起的审批只在 IM 决议,避免 Web 弹窗在 IM 决议后残留。
263
+
264
+ _attachApprovalBridge() {
265
+ const listener = async (req, next) => {
266
+ const sessionId = req.agent?.session?.id
267
+ const turn = sessionId ? this._turnPeers.get(sessionId) : null
268
+ if (!turn || !this.ownsAgent(req.agent)) {
269
+ this.logger?.info?.('[dsh-bridge feishu] approval falls through to GUI: not a feishu-initiated turn (session=%s)', sessionId ?? '(none)')
270
+ return next?.()
271
+ }
272
+ const peer = turn.outboundPeer
273
+ if (!peer?.peerId) return next?.()
274
+ const initiator = turn.senderId
275
+ const sendOpts = { outboundPeer: peer }
276
+
277
+ const number = this.nextApprovalNumber()
278
+ const timeoutSec = this.config.approvalTimeoutSec || 600
279
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
280
+
281
+ // 构建飞书原生交互卡片 (JSON 2.0)
282
+ const card = {
283
+ schema: '2.0',
284
+ header: {
285
+ title: {
286
+ tag: 'plain_text',
287
+ content: `⚠️ 操作权限确认 (#${number})`,
288
+ },
289
+ template: 'orange',
290
+ },
291
+ body: {
292
+ elements: [
293
+ {
294
+ tag: 'markdown',
295
+ content: [
296
+ `| 项目 | 详情 |`,
297
+ `| :--- | :--- |`,
298
+ `| **调用工具** | \`${req.toolName}\` |`,
299
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
300
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
301
+ ].join('\n'),
302
+ },
303
+ {
304
+ tag: 'column_set',
305
+ flex_mode: 'flow',
306
+ columns: [
307
+ {
308
+ tag: 'column',
309
+ width: 'auto',
310
+ elements: [
311
+ {
312
+ tag: 'button',
313
+ text: {
314
+ tag: 'plain_text',
315
+ content: '✓ 批准执行',
316
+ },
317
+ type: 'primary',
318
+ value: {
319
+ action: 'approve',
320
+ approvalId: number,
321
+ },
322
+ },
323
+ ],
324
+ },
325
+ {
326
+ tag: 'column',
327
+ width: 'auto',
328
+ elements: [
329
+ {
330
+ tag: 'button',
331
+ text: {
332
+ tag: 'plain_text',
333
+ content: '✕ 拒绝执行',
334
+ },
335
+ type: 'danger',
336
+ value: {
337
+ action: 'reject',
338
+ approvalId: number,
339
+ },
340
+ },
341
+ ],
342
+ },
343
+ ],
344
+ },
345
+ ],
346
+ },
347
+ }
348
+
349
+ // 发送飞书卡片;失败降级 Markdown 文本
350
+ try {
351
+ await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
352
+ } catch (err) {
353
+ this.logger?.warn?.('[dsh-bridge feishu] approval card failed (%s), fallback to markdown', err?.message ?? err)
354
+ const textFallback = [
355
+ `## ⚠️ 操作权限确认 (#${number})`,
356
+ '',
357
+ `| 项目 | 详情 |`,
358
+ `| :--- | :--- |`,
359
+ `| **调用工具** | \`${req.toolName}\` |`,
360
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
361
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
362
+ '',
363
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
364
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
365
+ ].join('\n')
366
+ void this.sendText(textFallback, sendOpts)
367
+ }
368
+
369
+ let settled = false
370
+ let timeoutFired = false
371
+ let resolveIm
372
+ const imPromise = new Promise((resolve) => { resolveIm = resolve })
373
+ const settleIm = (outcome) => {
374
+ if (settled) return
375
+ settled = true
376
+ this.clearApproval(number)
377
+ resolveIm(outcome)
378
+ }
379
+
380
+ const timer = setTimeout(() => {
381
+ timeoutFired = true
382
+ settleIm('rejected')
383
+ }, timeoutSec * 1000)
384
+ if (typeof timer.unref === 'function') timer.unref()
385
+
386
+ // turn 被停止时 DSH 会 abort req.signal:同步取消飞书侧待决审批
387
+ const onSignalAbort = () => {
388
+ timeoutFired = true
389
+ settleIm('cancelled')
390
+ }
391
+ req.signal?.addEventListener('abort', onSignalAbort, { once: true })
392
+
393
+ // peerId 记录发起者:/yes 与卡片按钮都校验决议者身份
394
+ this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
395
+
396
+ let outcome
397
+ try {
398
+ outcome = await imPromise
399
+ } finally {
400
+ req.signal?.removeEventListener('abort', onSignalAbort)
401
+ clearTimeout(timer)
402
+ settled = true
403
+ }
404
+
405
+ this.logger?.info?.('[dsh-bridge feishu] approval #%d resolved: outcome=%s', number, outcome)
406
+
407
+ if (!timeoutFired) {
408
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
409
+ void this.sendText(`${label}(#${number})`, sendOpts)
410
+ }
411
+ return outcome
412
+ }
413
+
414
+ const disposer = this.ctx.on('approval/request', listener, { prepend: true })
415
+ this.disposers.push(disposer)
416
+ }
417
+
418
+ dispose() {
419
+ if (this._patchTimer) {
420
+ clearTimeout(this._patchTimer)
421
+ this._patchTimer = null
422
+ }
423
+ this._inTurn = false
424
+ this._streamCardId = null
425
+ this._streamContent = ''
426
+ this._lastPeer = null
427
+ super.dispose()
428
+ }
429
+ }
430
+
431
+ export const feishuNodeHelpers = {
432
+ makePlatform,
433
+ }