@wenbin_wb/dsh-bridge 2.3.3 → 2.5.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.
@@ -2,6 +2,8 @@
2
2
  // Official SDK: @larksuiteoapi/node-sdk
3
3
  // Reference: https://open.feishu.cn/document/home/event-subscription-via-websocket/overview
4
4
 
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
5
7
  import { Service } from '@deepseek-ai/cordis'
6
8
  import * as Lark from '@larksuiteoapi/node-sdk'
7
9
 
@@ -144,6 +146,7 @@ export class FeishuGateway extends Service {
144
146
  // 5. 启动 WebSocket 长连接
145
147
  await this.wsClient.start({ eventDispatcher: this.eventDispatcher })
146
148
  this.setStatus('online')
149
+ this._startWatchdog()
147
150
  this.logger.info?.('[dsh-bridge feishu] WebSocket connected to Feishu Open Platform')
148
151
  return true
149
152
  } catch (err) {
@@ -158,8 +161,28 @@ export class FeishuGateway extends Service {
158
161
  return this._startingPromise
159
162
  }
160
163
 
164
+ _startWatchdog() {
165
+ this._stopWatchdog()
166
+ this._watchdogTimer = setInterval(async () => {
167
+ if (this.status === 'online' && !this._closing && this.configured && this.client) {
168
+ try {
169
+ await this.client.bot.v3.bot.get({}).catch(() => null)
170
+ } catch (err) {
171
+ this.logger?.warn?.('[dsh-bridge feishu] liveness probe warning: %s', err?.message ?? err)
172
+ }
173
+ }
174
+ }, 60_000)
175
+ if (typeof this._watchdogTimer?.unref === 'function') this._watchdogTimer.unref()
176
+ }
177
+
178
+ _stopWatchdog() {
179
+ if (this._watchdogTimer) clearInterval(this._watchdogTimer)
180
+ this._watchdogTimer = null
181
+ }
182
+
161
183
  async stop() {
162
184
  this._closing = true
185
+ this._stopWatchdog()
163
186
  try {
164
187
  if (this.wsClient) {
165
188
  const wsInstance = this.wsClient.wsConfig?.getWSInstance?.()
@@ -199,15 +222,16 @@ export class FeishuGateway extends Service {
199
222
  const chatType = message.chat_type || 'p2p' // 'p2p' or 'group'
200
223
  const isGroup = chatType === 'group'
201
224
 
202
- // 提取文本内容
225
+ // 解析消息体(文本 / 图片 / 文件 / 音频 / 视频)
203
226
  let text = ''
204
- if (message.message_type === 'text') {
205
- try {
206
- const parsed = JSON.parse(message.content || '{}')
207
- text = parsed.text || ''
208
- } catch {
209
- text = message.content || ''
227
+ let contentObj = {}
228
+ try {
229
+ contentObj = JSON.parse(message.content || '{}')
230
+ if (message.message_type === 'text') {
231
+ text = contentObj.text || ''
210
232
  }
233
+ } catch {
234
+ text = message.content || ''
211
235
  }
212
236
 
213
237
  // 群聊中剥离机器人 @ 占位符(例如 @_user_1 等)
@@ -226,12 +250,54 @@ export class FeishuGateway extends Service {
226
250
  isGroup,
227
251
  messageId,
228
252
  text,
253
+ contentObj,
229
254
  messageType: message.message_type,
230
255
  raw: data,
231
256
  createTime: Number(message.create_time || Date.now()),
232
257
  })
233
258
  }
234
259
 
260
+ /**
261
+ * 下载消息中的资源文件(图片/文件/音频/媒体)
262
+ * @param {object} opts
263
+ * @param {string} opts.messageId 消息 ID
264
+ * @param {string} opts.fileKey 资源 Key
265
+ * @param {string} [opts.type] 'file' | 'image'
266
+ * @returns {Promise<Buffer|null>}
267
+ */
268
+ async downloadMessageResource({ messageId, fileKey, type = 'file' }) {
269
+ if (!this.client) throw new Error('Feishu client not initialized')
270
+ try {
271
+ const resp = await this.client.im.messageResource.get({
272
+ path: {
273
+ message_id: messageId,
274
+ file_key: fileKey,
275
+ },
276
+ params: {
277
+ type: type === 'image' ? 'image' : 'file',
278
+ },
279
+ })
280
+ if (Buffer.isBuffer(resp)) return resp
281
+ if (resp && typeof resp.pipe === 'function') {
282
+ const chunks = []
283
+ for await (const chunk of resp) chunks.push(Buffer.from(chunk))
284
+ return Buffer.concat(chunks)
285
+ }
286
+ if (resp?.data) {
287
+ if (Buffer.isBuffer(resp.data)) return resp.data
288
+ if (resp.data.pipe) {
289
+ const chunks = []
290
+ for await (const chunk of resp.data) chunks.push(Buffer.from(chunk))
291
+ return Buffer.concat(chunks)
292
+ }
293
+ }
294
+ return null
295
+ } catch (err) {
296
+ this.logger?.warn?.('[dsh-bridge feishu] downloadMessageResource error: %s', err?.message ?? err)
297
+ return null
298
+ }
299
+ }
300
+
235
301
  async _handleCardActionTrigger(data) {
236
302
  const action = data?.action || {}
237
303
  const operator = data?.operator || {}
@@ -382,6 +448,99 @@ export class FeishuGateway extends Service {
382
448
  }
383
449
  }
384
450
 
451
+ /**
452
+ * 上传并发送本地图片
453
+ */
454
+ async sendLocalImage(receiveId, filePath, opts = {}) {
455
+ if (!this.client || !receiveId || !filePath) return null
456
+ let receiveIdType = opts.receiveIdType
457
+ if (!receiveIdType) {
458
+ if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
459
+ else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
460
+ else if (opts.isGroup) receiveIdType = 'chat_id'
461
+ else receiveIdType = 'open_id'
462
+ }
463
+
464
+ try {
465
+ const imageStream = fs.createReadStream(filePath)
466
+ const uploadRes = await this.client.im.v1.image.create({
467
+ data: {
468
+ image_type: 'message',
469
+ image: imageStream,
470
+ },
471
+ })
472
+ const imageKey = uploadRes?.image_key
473
+ if (!imageKey) throw new Error('Feishu image upload returned no image_key')
474
+
475
+ const res = await this.client.im.v1.message.create({
476
+ params: { receive_id_type: receiveIdType },
477
+ data: {
478
+ receive_id: receiveId,
479
+ msg_type: 'image',
480
+ content: JSON.stringify({ image_key: imageKey }),
481
+ },
482
+ })
483
+ return res?.data
484
+ } catch (err) {
485
+ this.logger.warn?.(`[dsh-bridge feishu] sendLocalImage error (${receiveId}, ${filePath}):`, err?.message ?? err)
486
+ return null
487
+ }
488
+ }
489
+
490
+ /**
491
+ * 上传并发送本地文件
492
+ */
493
+ async sendLocalFile(receiveId, filePath, opts = {}) {
494
+ if (!this.client || !receiveId || !filePath) return null
495
+ let receiveIdType = opts.receiveIdType
496
+ if (!receiveIdType) {
497
+ if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
498
+ else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
499
+ else if (opts.isGroup) receiveIdType = 'chat_id'
500
+ else receiveIdType = 'open_id'
501
+ }
502
+
503
+ try {
504
+ const fileName = path.basename(filePath)
505
+ const fileStream = fs.createReadStream(filePath)
506
+ const uploadRes = await this.client.im.v1.file.create({
507
+ data: {
508
+ file_type: 'stream',
509
+ file_name: fileName,
510
+ file: fileStream,
511
+ },
512
+ })
513
+ const fileKey = uploadRes?.file_key
514
+ if (!fileKey) throw new Error('Feishu file upload returned no file_key')
515
+
516
+ const res = await this.client.im.v1.message.create({
517
+ params: { receive_id_type: receiveIdType },
518
+ data: {
519
+ receive_id: receiveId,
520
+ msg_type: 'file',
521
+ content: JSON.stringify({ file_key: fileKey }),
522
+ },
523
+ })
524
+ return res?.data
525
+ } catch (err) {
526
+ this.logger.warn?.(`[dsh-bridge feishu] sendLocalFile error (${receiveId}, ${filePath}):`, err?.message ?? err)
527
+ return null
528
+ }
529
+ }
530
+
531
+ /**
532
+ * 发送本地媒体文件(自动识别图片/文档)
533
+ */
534
+ async sendMediaFile(receiveId, filePath, opts = {}) {
535
+ if (!fs.existsSync(filePath)) return null
536
+ const ext = path.extname(filePath).toLowerCase()
537
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
538
+ if (isImage) {
539
+ return this.sendLocalImage(receiveId, filePath, opts)
540
+ }
541
+ return this.sendLocalFile(receiveId, filePath, opts)
542
+ }
543
+
385
544
  // 释放资源
386
545
  dispose() {
387
546
  void this.stop()
@@ -2,6 +2,8 @@
2
2
  // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
3
  // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
4
 
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
5
7
  import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
6
8
 
7
9
  function makePlatform(gateway) {
@@ -12,6 +14,9 @@ function makePlatform(gateway) {
12
14
  async sendText(peerId, text, opts = {}) {
13
15
  return gateway?.sendMarkdownCard(peerId, text, opts)
14
16
  },
17
+ async sendMediaFile(peerId, filePath, opts = {}) {
18
+ return gateway?.sendMediaFile(peerId, filePath, opts)
19
+ },
15
20
  async sendTyping() {},
16
21
  dispose() {},
17
22
  }
@@ -70,14 +75,10 @@ export class FeishuConversationNode extends ConversationBridge {
70
75
  this._inTurn = false
71
76
 
72
77
  // 订阅网关入站消息事件
73
- this.ctx.on('feishu/message', (event) => {
74
- void this._handleInbound(event)
75
- })
78
+ this.ctx.on('feishu/message', (event) => this._handleInbound(event))
76
79
 
77
80
  // 订阅卡片交互事件(审批按钮点击)
78
- this.ctx.on('feishu/action', (event) => {
79
- void this._handleAction(event)
80
- })
81
+ this.ctx.on('feishu/action', (event) => this._handleAction(event))
81
82
 
82
83
  // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
83
84
  this.ctx.on('session/event', (session, event) => {
@@ -106,10 +107,26 @@ export class FeishuConversationNode extends ConversationBridge {
106
107
  }
107
108
 
108
109
  async _handleInbound(event) {
109
- const { peerId, senderId, isGroup, text } = event
110
+ const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
110
111
  this._lastPeer = { peerId, senderId, isGroup }
111
112
 
112
- this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${text}`)
113
+ let mediaFiles = []
114
+ if (messageType && messageType !== 'text') {
115
+ mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
116
+ }
117
+
118
+ if (!text && mediaFiles.length === 0) {
119
+ this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
120
+ return
121
+ }
122
+
123
+ let fullText = text || ''
124
+ if (mediaFiles.length > 0) {
125
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
126
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
127
+ }
128
+
129
+ this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
113
130
 
114
131
  // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
115
132
  if (!this.activeSessionId) {
@@ -117,14 +134,42 @@ export class FeishuConversationNode extends ConversationBridge {
117
134
  }
118
135
 
119
136
  // 转交通用 ConversationBridge 路由
120
- return super.handleInbound({
137
+ return this.handleInbound({
121
138
  senderId: isGroup ? peerId : senderId,
122
139
  peerId,
123
140
  isGroup,
124
- text,
141
+ text: fullText,
125
142
  })
126
143
  }
127
144
 
145
+ async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
146
+ if (!messageId || !messageType) return []
147
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
148
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
149
+
150
+ const downloaded = []
151
+ const fileKey = contentObj.image_key || contentObj.file_key
152
+ const fileName = contentObj.file_name
153
+
154
+ if (fileKey) {
155
+ try {
156
+ const type = messageType === 'image' ? 'image' : 'file'
157
+ const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
158
+ if (buf && buf.length > 0) {
159
+ const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
160
+ const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
161
+ const filePath = path.join(mediaDir, safeName)
162
+ await fs.promises.writeFile(filePath, buf)
163
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
164
+ this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
165
+ }
166
+ } catch (err) {
167
+ this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
168
+ }
169
+ }
170
+ return downloaded
171
+ }
172
+
128
173
  async _handleAction(event) {
129
174
  const { operatorId, value } = event
130
175
  const approvalId = Number(value.approvalId)