@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.
- package/CHANGELOG.md +295 -250
- package/README.en.md +408 -572
- package/README.md +430 -570
- package/client/client.js +3844 -3764
- package/client/index.js +140 -876
- package/client/mobile-styles.js +802 -0
- package/docs/fix-plan-202608.md +108 -0
- package/lib/auth/login-template.js +381 -381
- package/lib/auth/manager.js +531 -469
- package/lib/bridge-rpc.js +436 -511
- package/lib/cloudflared-manager.mjs +361 -345
- package/lib/compat.js +129 -0
- package/lib/feishu/index.js +225 -222
- package/lib/feishu/node.js +433 -409
- package/lib/index.js +1765 -1804
- package/lib/platform/base.js +147 -156
- package/lib/platform/commands.js +221 -0
- package/lib/platform/conversation-bridge.js +816 -1570
- package/lib/platform/dsh-storage.js +117 -0
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +191 -0
- package/lib/platform/session-catalog.js +372 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/index.js +312 -309
- package/lib/qq/node.js +532 -533
- package/lib/telegram/index.js +215 -212
- package/lib/telegram/node.js +348 -350
- package/lib/tunnel-client.mjs +39 -15
- package/lib/wechat/gateway.js +973 -960
- package/lib/wechat/index.js +244 -241
- package/lib/wechat/media.js +285 -281
- package/lib/wechat/node.js +352 -350
- package/package.json +106 -102
package/lib/feishu/node.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
},
|
|
17
|
-
async
|
|
18
|
-
return gateway?.
|
|
19
|
-
},
|
|
20
|
-
async
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
this.
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
75
|
-
this.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this._patchTimer
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this._patchTimer
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
isGroup,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}
|