@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.
@@ -1,439 +1,439 @@
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
- // 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
186
- // 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
187
- if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
188
- this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
189
- return
190
- }
191
- this.clearApproval(approvalId)
192
- pending.resolve(outcome)
193
- }
194
- }
195
- }
196
-
197
- async _sendTextNow(text, opts = {}) {
198
- // T2.3:轮次绑定的 outboundPeer({ peerId, isGroup })优先生效
199
- const bound = opts?.outboundPeer && typeof opts.outboundPeer.isGroup === 'boolean' ? opts.outboundPeer : null
200
- const peerId = bound?.peerId || this._lastPeer?.peerId || this.peerId
201
- if (!peerId) return
202
- const isGroup = bound ? bound.isGroup : (this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_')))
203
- const content = String(text || '').trim()
204
- if (!content) return
205
-
206
- // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
207
- if (this._inTurn) {
208
- const maxChunk = 150
209
- const slices = splitIntoIncremental(content, maxChunk)
210
- const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
211
-
212
- if (!this._streamCardId) {
213
- // 首片:创建卡片并记录 message_id
214
- const firstSlice = slices[0] || content
215
- try {
216
- const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
217
- this._streamCardId = res?.message_id || null
218
- this._streamContent = firstSlice
219
- } catch (err) {
220
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
221
- await this.gateway.sendText(peerId, content, { isGroup })
222
- return
223
- }
224
-
225
- // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
226
- for (let i = 1; i < slices.length; i++) {
227
- await sleep(delayMs)
228
- this._streamContent = slices[i]
229
- if (this._streamCardId) {
230
- await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
231
- }
232
- }
233
- } else {
234
- // 如果卡片已存在(同轮次后续输出):更新到最终内容
235
- this._streamContent = content
236
- await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
237
- }
238
- return
239
- }
240
-
241
- // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
242
- try {
243
- if (content.length <= 25000) {
244
- await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
245
- } else {
246
- const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
247
- for (const chunk of chunks) {
248
- await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
249
- }
250
- }
251
- } catch (err) {
252
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
253
- await this.gateway.sendText(peerId, content, { isGroup })
254
- }
255
- }
256
-
257
- // ---- 飞书专属卡片审批桥 ----
258
- //
259
- // 与基类(conversation-bridge)同一归属模型,仅卡片渲染不同:
260
- // - 必须 { prepend: true } 注册:否则宿主 apiproxy 的 GUI 认领监听器先行否决,
261
- // 飞书卡片永远发不出(用户实测:飞书发起审批收不到卡片、Web 反而弹窗);
262
- // - 只拦截"本轮由飞书发起"的轮次(_turnPeers 门槛),Web 轮次放行给宿主;
263
- // - 不调用 next():IM 发起的审批只在 IM 决议,避免 Web 弹窗在 IM 决议后残留。
264
-
265
- _attachApprovalBridge() {
266
- const listener = async (req, next) => {
267
- const sessionId = req.agent?.session?.id
268
- const turn = sessionId ? this._turnPeers.get(sessionId) : null
269
- if (!turn || !this.ownsAgent(req.agent)) {
270
- this.logger?.info?.('[dsh-bridge feishu] approval falls through to GUI: not a feishu-initiated turn (session=%s)', sessionId ?? '(none)')
271
- return next?.()
272
- }
273
- const peer = turn.outboundPeer
274
- if (!peer?.peerId) return next?.()
275
- const initiator = turn.senderId
276
- const sendOpts = { outboundPeer: peer }
277
-
278
- const number = this.nextApprovalNumber()
279
- const timeoutSec = this.config.approvalTimeoutSec || 600
280
- const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
281
-
282
- // 构建飞书原生交互卡片 (JSON 2.0)
283
- const card = {
284
- schema: '2.0',
285
- header: {
286
- title: {
287
- tag: 'plain_text',
288
- content: `⚠️ 操作权限确认 (#${number})`,
289
- },
290
- template: 'orange',
291
- },
292
- body: {
293
- elements: [
294
- {
295
- tag: 'markdown',
296
- content: [
297
- `| 项目 | 详情 |`,
298
- `| :--- | :--- |`,
299
- `| **调用工具** | \`${req.toolName}\` |`,
300
- ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
301
- `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
302
- ].join('\n'),
303
- },
304
- {
305
- tag: 'column_set',
306
- flex_mode: 'flow',
307
- columns: [
308
- {
309
- tag: 'column',
310
- width: 'auto',
311
- elements: [
312
- {
313
- tag: 'button',
314
- text: {
315
- tag: 'plain_text',
316
- content: '✓ 批准执行',
317
- },
318
- type: 'primary',
319
- value: {
320
- action: 'approve',
321
- approvalId: number,
322
- },
323
- },
324
- ],
325
- },
326
- {
327
- tag: 'column',
328
- width: 'auto',
329
- elements: [
330
- {
331
- tag: 'button',
332
- text: {
333
- tag: 'plain_text',
334
- content: '✕ 拒绝执行',
335
- },
336
- type: 'danger',
337
- value: {
338
- action: 'reject',
339
- approvalId: number,
340
- },
341
- },
342
- ],
343
- },
344
- ],
345
- },
346
- ],
347
- },
348
- }
349
-
350
- // 发送飞书卡片;失败降级 Markdown 文本
351
- try {
352
- await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
353
- } catch (err) {
354
- this.logger?.warn?.('[dsh-bridge feishu] approval card failed (%s), fallback to markdown', err?.message ?? err)
355
- const textFallback = [
356
- `## ⚠️ 操作权限确认 (#${number})`,
357
- '',
358
- `| 项目 | 详情 |`,
359
- `| :--- | :--- |`,
360
- `| **调用工具** | \`${req.toolName}\` |`,
361
- ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
362
- `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
363
- '',
364
- `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
365
- `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
366
- ].join('\n')
367
- void this.sendText(textFallback, sendOpts)
368
- }
369
-
370
- let settled = false
371
- let timeoutFired = false
372
- let resolveIm
373
- const imPromise = new Promise((resolve) => { resolveIm = resolve })
374
- const settleIm = (outcome) => {
375
- if (settled) return
376
- settled = true
377
- this.clearApproval(number)
378
- resolveIm(outcome)
379
- }
380
-
381
- const timer = setTimeout(() => {
382
- timeoutFired = true
383
- settleIm('rejected')
384
- }, timeoutSec * 1000)
385
- if (typeof timer.unref === 'function') timer.unref()
386
-
387
- // turn 被停止时 DSH 会 abort req.signal:同步取消飞书侧待决审批
388
- const onSignalAbort = () => {
389
- timeoutFired = true
390
- settleIm('cancelled')
391
- }
392
- req.signal?.addEventListener('abort', onSignalAbort, { once: true })
393
-
394
- // peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
395
- // 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
396
- this.registerApproval(number, {
397
- number, request: req, resolve: resolveIm, timer,
398
- peerId: initiator,
399
- isGroup: Boolean(peer.isGroup),
400
- })
401
-
402
- let outcome
403
- try {
404
- outcome = await imPromise
405
- } finally {
406
- req.signal?.removeEventListener('abort', onSignalAbort)
407
- clearTimeout(timer)
408
- settled = true
409
- }
410
-
411
- this.logger?.info?.('[dsh-bridge feishu] approval #%d resolved: outcome=%s', number, outcome)
412
-
413
- if (!timeoutFired) {
414
- const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
415
- void this.sendText(`${label}(#${number})`, sendOpts)
416
- }
417
- return outcome
418
- }
419
-
420
- const disposer = this.ctx.on('approval/request', listener, { prepend: true })
421
- this.disposers.push(disposer)
422
- }
423
-
424
- dispose() {
425
- if (this._patchTimer) {
426
- clearTimeout(this._patchTimer)
427
- this._patchTimer = null
428
- }
429
- this._inTurn = false
430
- this._streamCardId = null
431
- this._streamContent = ''
432
- this._lastPeer = null
433
- super.dispose()
434
- }
435
- }
436
-
437
- export const feishuNodeHelpers = {
438
- makePlatform,
439
- }
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
+ // 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
186
+ // 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
187
+ if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
188
+ this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
189
+ return
190
+ }
191
+ this.clearApproval(approvalId)
192
+ pending.resolve(outcome)
193
+ }
194
+ }
195
+ }
196
+
197
+ async _sendTextNow(text, opts = {}) {
198
+ // T2.3:轮次绑定的 outboundPeer({ peerId, isGroup })优先生效
199
+ const bound = opts?.outboundPeer && typeof opts.outboundPeer.isGroup === 'boolean' ? opts.outboundPeer : null
200
+ const peerId = bound?.peerId || this._lastPeer?.peerId || this.peerId
201
+ if (!peerId) return
202
+ const isGroup = bound ? bound.isGroup : (this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_')))
203
+ const content = String(text || '').trim()
204
+ if (!content) return
205
+
206
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
207
+ if (this._inTurn) {
208
+ const maxChunk = 150
209
+ const slices = splitIntoIncremental(content, maxChunk)
210
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
211
+
212
+ if (!this._streamCardId) {
213
+ // 首片:创建卡片并记录 message_id
214
+ const firstSlice = slices[0] || content
215
+ try {
216
+ const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
217
+ this._streamCardId = res?.message_id || null
218
+ this._streamContent = firstSlice
219
+ } catch (err) {
220
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
221
+ await this.gateway.sendText(peerId, content, { isGroup })
222
+ return
223
+ }
224
+
225
+ // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
226
+ for (let i = 1; i < slices.length; i++) {
227
+ await sleep(delayMs)
228
+ this._streamContent = slices[i]
229
+ if (this._streamCardId) {
230
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
231
+ }
232
+ }
233
+ } else {
234
+ // 如果卡片已存在(同轮次后续输出):更新到最终内容
235
+ this._streamContent = content
236
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
237
+ }
238
+ return
239
+ }
240
+
241
+ // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
242
+ try {
243
+ if (content.length <= 25000) {
244
+ await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
245
+ } else {
246
+ const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
247
+ for (const chunk of chunks) {
248
+ await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
249
+ }
250
+ }
251
+ } catch (err) {
252
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
253
+ await this.gateway.sendText(peerId, content, { isGroup })
254
+ }
255
+ }
256
+
257
+ // ---- 飞书专属卡片审批桥 ----
258
+ //
259
+ // 与基类(conversation-bridge)同一归属模型,仅卡片渲染不同:
260
+ // - 必须 { prepend: true } 注册:否则宿主 apiproxy 的 GUI 认领监听器先行否决,
261
+ // 飞书卡片永远发不出(用户实测:飞书发起审批收不到卡片、Web 反而弹窗);
262
+ // - 只拦截"本轮由飞书发起"的轮次(_turnPeers 门槛),Web 轮次放行给宿主;
263
+ // - 不调用 next():IM 发起的审批只在 IM 决议,避免 Web 弹窗在 IM 决议后残留。
264
+
265
+ _attachApprovalBridge() {
266
+ const listener = async (req, next) => {
267
+ const sessionId = req.agent?.session?.id
268
+ const turn = sessionId ? this._turnPeers.get(sessionId) : null
269
+ if (!turn || !this.ownsAgent(req.agent)) {
270
+ this.logger?.info?.('[dsh-bridge feishu] approval falls through to GUI: not a feishu-initiated turn (session=%s)', sessionId ?? '(none)')
271
+ return next?.()
272
+ }
273
+ const peer = turn.outboundPeer
274
+ if (!peer?.peerId) return next?.()
275
+ const initiator = turn.senderId
276
+ const sendOpts = { outboundPeer: peer }
277
+
278
+ const number = this.nextApprovalNumber()
279
+ const timeoutSec = this.config.approvalTimeoutSec || 600
280
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
281
+
282
+ // 构建飞书原生交互卡片 (JSON 2.0)
283
+ const card = {
284
+ schema: '2.0',
285
+ header: {
286
+ title: {
287
+ tag: 'plain_text',
288
+ content: `⚠️ 操作权限确认 (#${number})`,
289
+ },
290
+ template: 'orange',
291
+ },
292
+ body: {
293
+ elements: [
294
+ {
295
+ tag: 'markdown',
296
+ content: [
297
+ `| 项目 | 详情 |`,
298
+ `| :--- | :--- |`,
299
+ `| **调用工具** | \`${req.toolName}\` |`,
300
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
301
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
302
+ ].join('\n'),
303
+ },
304
+ {
305
+ tag: 'column_set',
306
+ flex_mode: 'flow',
307
+ columns: [
308
+ {
309
+ tag: 'column',
310
+ width: 'auto',
311
+ elements: [
312
+ {
313
+ tag: 'button',
314
+ text: {
315
+ tag: 'plain_text',
316
+ content: '✓ 批准执行',
317
+ },
318
+ type: 'primary',
319
+ value: {
320
+ action: 'approve',
321
+ approvalId: number,
322
+ },
323
+ },
324
+ ],
325
+ },
326
+ {
327
+ tag: 'column',
328
+ width: 'auto',
329
+ elements: [
330
+ {
331
+ tag: 'button',
332
+ text: {
333
+ tag: 'plain_text',
334
+ content: '✕ 拒绝执行',
335
+ },
336
+ type: 'danger',
337
+ value: {
338
+ action: 'reject',
339
+ approvalId: number,
340
+ },
341
+ },
342
+ ],
343
+ },
344
+ ],
345
+ },
346
+ ],
347
+ },
348
+ }
349
+
350
+ // 发送飞书卡片;失败降级 Markdown 文本
351
+ try {
352
+ await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
353
+ } catch (err) {
354
+ this.logger?.warn?.('[dsh-bridge feishu] approval card failed (%s), fallback to markdown', err?.message ?? err)
355
+ const textFallback = [
356
+ `## ⚠️ 操作权限确认 (#${number})`,
357
+ '',
358
+ `| 项目 | 详情 |`,
359
+ `| :--- | :--- |`,
360
+ `| **调用工具** | \`${req.toolName}\` |`,
361
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
362
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
363
+ '',
364
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
365
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
366
+ ].join('\n')
367
+ void this.sendText(textFallback, sendOpts)
368
+ }
369
+
370
+ let settled = false
371
+ let timeoutFired = false
372
+ let resolveIm
373
+ const imPromise = new Promise((resolve) => { resolveIm = resolve })
374
+ const settleIm = (outcome) => {
375
+ if (settled) return
376
+ settled = true
377
+ this.clearApproval(number)
378
+ resolveIm(outcome)
379
+ }
380
+
381
+ const timer = setTimeout(() => {
382
+ timeoutFired = true
383
+ settleIm('rejected')
384
+ }, timeoutSec * 1000)
385
+ if (typeof timer.unref === 'function') timer.unref()
386
+
387
+ // turn 被停止时 DSH 会 abort req.signal:同步取消飞书侧待决审批
388
+ const onSignalAbort = () => {
389
+ timeoutFired = true
390
+ settleIm('cancelled')
391
+ }
392
+ req.signal?.addEventListener('abort', onSignalAbort, { once: true })
393
+
394
+ // peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
395
+ // 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
396
+ this.registerApproval(number, {
397
+ number, request: req, resolve: resolveIm, timer,
398
+ peerId: initiator,
399
+ isGroup: Boolean(peer.isGroup),
400
+ })
401
+
402
+ let outcome
403
+ try {
404
+ outcome = await imPromise
405
+ } finally {
406
+ req.signal?.removeEventListener('abort', onSignalAbort)
407
+ clearTimeout(timer)
408
+ settled = true
409
+ }
410
+
411
+ this.logger?.info?.('[dsh-bridge feishu] approval #%d resolved: outcome=%s', number, outcome)
412
+
413
+ if (!timeoutFired) {
414
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
415
+ void this.sendText(`${label}(#${number})`, sendOpts)
416
+ }
417
+ return outcome
418
+ }
419
+
420
+ const disposer = this.ctx.on('approval/request', listener, { prepend: true })
421
+ this.disposers.push(disposer)
422
+ }
423
+
424
+ dispose() {
425
+ if (this._patchTimer) {
426
+ clearTimeout(this._patchTimer)
427
+ this._patchTimer = null
428
+ }
429
+ this._inTurn = false
430
+ this._streamCardId = null
431
+ this._streamContent = ''
432
+ this._lastPeer = null
433
+ super.dispose()
434
+ }
435
+ }
436
+
437
+ export const feishuNodeHelpers = {
438
+ makePlatform,
439
+ }