@wenbin_wb/dsh-bridge 2.2.9 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ // Feishu / Lark Bot OpenAPI & WebSocket Gateway
2
+ // Official SDK: @larksuiteoapi/node-sdk
3
+ // Reference: https://open.feishu.cn/document/home/event-subscription-via-websocket/overview
4
+
5
+ import { Service } from '@deepseek-ai/cordis'
6
+ import * as Lark from '@larksuiteoapi/node-sdk'
7
+
8
+ export class FeishuGateway extends Service {
9
+ static name = 'feishu'
10
+
11
+ constructor(ctx, config = {}) {
12
+ super(ctx, 'feishu', true)
13
+ this.ctx = ctx
14
+ this.logger = ctx.logger?.('feishu') ?? console
15
+ this.config = {
16
+ appId: '',
17
+ appSecret: '',
18
+ domain: 'feishu', // 'feishu' | 'lark'
19
+ ...config,
20
+ }
21
+ this.status = 'idle'
22
+ this.error = null
23
+ this.botInfo = null
24
+ this.client = null
25
+ this.wsClient = null
26
+ this.eventDispatcher = null
27
+ this._closing = false
28
+ this.seenMessageIds = new Set()
29
+ }
30
+
31
+ get configured() {
32
+ return Boolean(this.config.appId && this.config.appSecret)
33
+ }
34
+
35
+ setStatus(status, error = null) {
36
+ this.status = status
37
+ this.error = error ? String(error?.message || error) : null
38
+ this.ctx.emit('feishu/status', {
39
+ status: this.status,
40
+ error: this.error,
41
+ botInfo: this.botInfo,
42
+ configured: this.configured,
43
+ })
44
+ }
45
+
46
+ updateConfig(cfg = {}) {
47
+ this.config = { ...this.config, ...cfg }
48
+ }
49
+
50
+ async start() {
51
+ if (!this.configured) {
52
+ this.setStatus('idle')
53
+ return false
54
+ }
55
+
56
+ if (this.status === 'online') {
57
+ return true
58
+ }
59
+
60
+ if (this._startingPromise) {
61
+ return this._startingPromise
62
+ }
63
+
64
+ this._closing = false
65
+ this.setStatus('starting')
66
+
67
+ this._startingPromise = (async () => {
68
+ try {
69
+ const isLark = this.config.domain === 'lark'
70
+ const domain = isLark ? Lark.Domain.Lark : Lark.Domain.Feishu
71
+
72
+ const sdkLogger = {
73
+ debug: (...args) => this.logger?.debug?.('[lark-sdk debug]', ...args),
74
+ info: (...args) => this.logger?.debug?.('[lark-sdk info]', ...args),
75
+ warn: (...args) => this.logger?.debug?.('[lark-sdk warn]', ...args),
76
+ error: (...args) => this.logger?.error?.('[lark-sdk error]', ...args),
77
+ trace: () => {},
78
+ }
79
+
80
+ // 1. 初始化 REST API Client
81
+ this.client = new Lark.Client({
82
+ appId: this.config.appId,
83
+ appSecret: this.config.appSecret,
84
+ domain,
85
+ appType: Lark.AppType.SelfBuild,
86
+ logger: sdkLogger,
87
+ loggerLevel: Lark.LoggerLevel.error,
88
+ })
89
+
90
+ // 2. 初始化长连接 WSClient
91
+ this.wsClient = new Lark.WSClient({
92
+ appId: this.config.appId,
93
+ appSecret: this.config.appSecret,
94
+ domain,
95
+ logger: sdkLogger,
96
+ loggerLevel: Lark.LoggerLevel.error,
97
+ })
98
+
99
+ // 3. 构建事件分发器
100
+ this.eventDispatcher = new Lark.EventDispatcher({
101
+ logger: sdkLogger,
102
+ })
103
+
104
+ // 注册接收消息事件
105
+ this.eventDispatcher.register({
106
+ 'im.message.receive_v1': async (data) => {
107
+ try {
108
+ await this._handleMessageReceive(data)
109
+ } catch (err) {
110
+ this.logger.error?.('[dsh-bridge feishu] error in im.message.receive_v1:', err)
111
+ }
112
+ },
113
+ 'card.action.trigger': async (data) => {
114
+ try {
115
+ return await this._handleCardActionTrigger(data)
116
+ } catch (err) {
117
+ this.logger.error?.('[dsh-bridge feishu] error in card.action.trigger:', err)
118
+ return { toast: { type: 'error', content: '处理失败' } }
119
+ }
120
+ },
121
+ 'im.message.message_read_v1': async () => {
122
+ // 消息已读回执事件,忽略
123
+ },
124
+ 'im.chat.access_event.bot_p2p_chat_entered_v1': async () => {
125
+ // 用户进入与机器人的单聊窗口事件,忽略
126
+ },
127
+ })
128
+
129
+ // 4. 尝试获取机器人信息(验证凭证,非阻断)
130
+ try {
131
+ const botRes = await this.client.bot.v3.bot.get({}).catch(() => null)
132
+ if (botRes?.bot) {
133
+ this.botInfo = {
134
+ openId: botRes.bot.open_id,
135
+ appName: botRes.bot.app_name,
136
+ avatarUrl: botRes.bot.avatar_url,
137
+ }
138
+ this.logger.info?.(`[dsh-bridge feishu] bot authenticated: ${this.botInfo.appName} (${this.botInfo.openId})`)
139
+ }
140
+ } catch (authErr) {
141
+ this.logger.warn?.(`[dsh-bridge feishu] fetch bot info error: ${authErr.message}`)
142
+ }
143
+
144
+ // 5. 启动 WebSocket 长连接
145
+ await this.wsClient.start({ eventDispatcher: this.eventDispatcher })
146
+ this.setStatus('online')
147
+ this.logger.info?.('[dsh-bridge feishu] WebSocket connected to Feishu Open Platform')
148
+ return true
149
+ } catch (err) {
150
+ this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
151
+ this.setStatus('error', err)
152
+ return false
153
+ } finally {
154
+ this._startingPromise = null
155
+ }
156
+ })()
157
+
158
+ return this._startingPromise
159
+ }
160
+
161
+ async stop() {
162
+ this._closing = true
163
+ try {
164
+ if (this.wsClient) {
165
+ const wsInstance = this.wsClient.wsConfig?.getWSInstance?.()
166
+ if (wsInstance) {
167
+ try { wsInstance.close?.() } catch {}
168
+ try { wsInstance.terminate?.() } catch {}
169
+ }
170
+ }
171
+ } catch (err) {
172
+ this.logger.warn?.('[dsh-bridge feishu] stop error:', err)
173
+ } finally {
174
+ this.client = null
175
+ this.wsClient = null
176
+ this.eventDispatcher = null
177
+ this.setStatus('offline')
178
+ }
179
+ }
180
+
181
+ // ---- 消息收发 ----
182
+
183
+ async _handleMessageReceive(data) {
184
+ if (!data?.message) return
185
+ const { message, sender } = data
186
+ const messageId = message.message_id
187
+ if (!messageId) return
188
+
189
+ // 消息去重
190
+ if (this.seenMessageIds.has(messageId)) return
191
+ this.seenMessageIds.add(messageId)
192
+ if (this.seenMessageIds.size > 500) {
193
+ const first = this.seenMessageIds.values().next().value
194
+ this.seenMessageIds.delete(first)
195
+ }
196
+
197
+ const senderOpenId = sender?.sender_id?.open_id || ''
198
+ const chatId = message.chat_id || ''
199
+ const chatType = message.chat_type || 'p2p' // 'p2p' or 'group'
200
+ const isGroup = chatType === 'group'
201
+
202
+ // 提取文本内容
203
+ 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 || ''
210
+ }
211
+ }
212
+
213
+ // 群聊中剥离机器人 @ 占位符(例如 @_user_1 等)
214
+ if (isGroup && text) {
215
+ text = text.replace(/@_user_\d+/g, '').trim()
216
+ }
217
+
218
+ const peerId = isGroup ? chatId : senderOpenId
219
+ if (!peerId) return
220
+
221
+ this.ctx.emit('feishu/message', {
222
+ peerId,
223
+ senderId: senderOpenId,
224
+ chatId,
225
+ chatType,
226
+ isGroup,
227
+ messageId,
228
+ text,
229
+ messageType: message.message_type,
230
+ raw: data,
231
+ createTime: Number(message.create_time || Date.now()),
232
+ })
233
+ }
234
+
235
+ async _handleCardActionTrigger(data) {
236
+ const action = data?.action || {}
237
+ const operator = data?.operator || {}
238
+ const operatorOpenId = operator.open_id || ''
239
+ const value = action.value || {}
240
+
241
+ this.ctx.emit('feishu/action', {
242
+ operatorId: operatorOpenId,
243
+ action: value.action || action.tag,
244
+ value,
245
+ raw: data,
246
+ })
247
+
248
+ const actionName = value.action === 'approve' ? '已批准' : value.action === 'reject' ? '已拒绝' : '已操作'
249
+ return {
250
+ toast: {
251
+ type: 'success',
252
+ content: `操作成功:${actionName}`,
253
+ },
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 发送文本消息
259
+ * @param {string} receiveId - 接收人 open_id 或群 chat_id
260
+ * @param {string} text - 发送文本
261
+ * @param {object} opts - { isGroup, receiveIdType }
262
+ */
263
+ async sendText(receiveId, text, opts = {}) {
264
+ if (!this.client || !receiveId || !text) return null
265
+ let receiveIdType = opts.receiveIdType
266
+ if (!receiveIdType) {
267
+ if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
268
+ else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
269
+ else if (opts.isGroup) receiveIdType = 'chat_id'
270
+ else receiveIdType = 'open_id'
271
+ }
272
+
273
+ try {
274
+ this.logger.debug?.(`[dsh-bridge feishu] sending text to ${receiveId} (${receiveIdType})`)
275
+ const res = await this.client.im.v1.message.create({
276
+ params: { receive_id_type: receiveIdType },
277
+ data: {
278
+ receive_id: receiveId,
279
+ msg_type: 'text',
280
+ content: JSON.stringify({ text }),
281
+ },
282
+ })
283
+ return res?.data
284
+ } catch (err) {
285
+ this.logger.error?.(`[dsh-bridge feishu] sendText error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
286
+ throw err
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 发送飞书原生交互卡片
292
+ * @param {string} receiveId - 接收人 open_id 或群 chat_id
293
+ * @param {object} cardData - 卡片结构对象 (JSON 2.0)
294
+ * @param {object} opts - { isGroup, receiveIdType }
295
+ */
296
+ async sendCard(receiveId, cardData, opts = {}) {
297
+ if (!this.client || !receiveId || !cardData) return null
298
+ let receiveIdType = opts.receiveIdType
299
+ if (!receiveIdType) {
300
+ if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
301
+ else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
302
+ else if (opts.isGroup) receiveIdType = 'chat_id'
303
+ else receiveIdType = 'open_id'
304
+ }
305
+
306
+ try {
307
+ this.logger.debug?.(`[dsh-bridge feishu] sending card to ${receiveId} (${receiveIdType})`)
308
+ const res = await this.client.im.v1.message.create({
309
+ params: { receive_id_type: receiveIdType },
310
+ data: {
311
+ receive_id: receiveId,
312
+ msg_type: 'interactive',
313
+ content: JSON.stringify(cardData),
314
+ },
315
+ })
316
+ return res?.data
317
+ } catch (err) {
318
+ this.logger.error?.(`[dsh-bridge feishu] sendCard error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
319
+ throw err
320
+ }
321
+ }
322
+
323
+ /**
324
+ * 发送 Markdown 交互卡片 (JSON 2.0)
325
+ * @param {string} receiveId - 接收人 open_id 或群 chat_id
326
+ * @param {string} markdownText - Markdown 文本
327
+ * @param {object} opts - { isGroup, receiveIdType }
328
+ */
329
+ async sendMarkdownCard(receiveId, markdownText, opts = {}) {
330
+ const card = {
331
+ schema: '2.0',
332
+ config: {
333
+ wide_screen_mode: true,
334
+ update_multi: true,
335
+ },
336
+ body: {
337
+ elements: [
338
+ {
339
+ tag: 'markdown',
340
+ content: String(markdownText || '').trim(),
341
+ },
342
+ ],
343
+ },
344
+ }
345
+ return this.sendCard(receiveId, card, opts)
346
+ }
347
+
348
+ /**
349
+ * 更新已发送的交互卡片(流式更新 / 原地替换,JSON 2.0)
350
+ * @param {string} messageId - 已发送消息的 message_id
351
+ * @param {string} markdownText - 最新完整 Markdown 文本
352
+ */
353
+ async patchCard(messageId, markdownText) {
354
+ if (!this.client || !messageId) return null
355
+ const card = {
356
+ schema: '2.0',
357
+ config: {
358
+ wide_screen_mode: true,
359
+ update_multi: true,
360
+ },
361
+ body: {
362
+ elements: [
363
+ {
364
+ tag: 'markdown',
365
+ content: String(markdownText || '').trim(),
366
+ },
367
+ ],
368
+ },
369
+ }
370
+ try {
371
+ this.logger.debug?.(`[dsh-bridge feishu] patching card message ${messageId}`)
372
+ const res = await this.client.im.v1.message.patch({
373
+ path: { message_id: messageId },
374
+ data: {
375
+ content: JSON.stringify(card),
376
+ },
377
+ })
378
+ return res?.data
379
+ } catch (err) {
380
+ this.logger.error?.(`[dsh-bridge feishu] patchCard error (${messageId}):`, err?.message ?? err)
381
+ throw err
382
+ }
383
+ }
384
+
385
+ // 释放资源
386
+ dispose() {
387
+ void this.stop()
388
+ }
389
+ }
@@ -0,0 +1,219 @@
1
+ // dsh-bridge Feishu / Lark platform adapter
2
+ // 编排 FeishuGateway(官方 OpenAPI/WSClient 网关)+ FeishuConversationNode(飞书⇄DSH 会话桥)。
3
+ // 作为 Platform 子类,注册进 PlatformManager 统一管理。
4
+
5
+ import QRCode from 'qrcode'
6
+ import { Platform } from '../platform/base.js'
7
+ import { FeishuGateway } from './gateway.js'
8
+ import { FeishuConversationNode } from './node.js'
9
+
10
+ export class FeishuService extends Platform {
11
+ /**
12
+ * @param {object} opts
13
+ * @param {object} opts.ctx Cordis 上下文
14
+ * @param {object} opts.logger 日志器
15
+ * @param {object} [opts.config] 已持久化的 feishu 配置(凭证 + allowFrom + 间隔)
16
+ * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
17
+ */
18
+ constructor({ ctx, logger, config = {}, onPersist }) {
19
+ super({ ctx, logger, config, onPersist })
20
+ this.id = 'feishu'
21
+ this.name = 'Feishu'
22
+ this._botQrCache = { appId: '', qr: '' }
23
+
24
+ this.gateway = new FeishuGateway(ctx, {
25
+ appId: config.appId ?? '',
26
+ appSecret: config.appSecret ?? '',
27
+ domain: config.domain ?? 'feishu',
28
+ })
29
+
30
+ // 挂到 ctx 供会话节点读取
31
+ try { ctx.feishu = this.gateway } catch { /* 挂载失败不致命 */ }
32
+
33
+ this.node = new FeishuConversationNode(ctx, {
34
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
35
+ digestIntervalSec: config.digestIntervalSec,
36
+ approvalTimeoutSec: config.approvalTimeoutSec,
37
+ maxMessageChars: config.maxMessageChars || 2000,
38
+ sendChunkDelayMs: config.sendChunkDelayMs,
39
+ activeSessionId: config.activeSessionId,
40
+ }, logger, {
41
+ onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
42
+ onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
43
+ })
44
+ this.bridge = this.node
45
+
46
+ if (this.gateway.configured) {
47
+ void this.start().catch((err) => {
48
+ this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
49
+ })
50
+ }
51
+ }
52
+
53
+ // ---- Platform 接口 ----
54
+
55
+ get configured() { return this.gateway.configured }
56
+ get accountId() { return this.gateway.botInfo?.openId || '' }
57
+
58
+ get capabilities() {
59
+ return {
60
+ group: true,
61
+ media: true,
62
+ approvals: true,
63
+ maxMessageChars: this.node.config.maxMessageChars || 2000,
64
+ }
65
+ }
66
+
67
+ async sendText(peerId, text, opts = {}) {
68
+ return this.gateway.sendMarkdownCard(peerId, text, opts)
69
+ }
70
+
71
+ async sendTyping(peerId, opts = {}) {
72
+ return this.gateway.sendTyping?.(peerId, opts)
73
+ }
74
+
75
+ // ---- 生命周期控制 ----
76
+
77
+ async start() {
78
+ if (!this.gateway.configured) {
79
+ this.setStatus('idle')
80
+ return { success: false, error: 'App ID 与 App Secret 未配置' }
81
+ }
82
+ this.setStatus('starting')
83
+ const ok = await this.gateway.start()
84
+ if (ok) {
85
+ this.setStatus('connected')
86
+ return { success: true }
87
+ } else {
88
+ this.setStatus('error', this.gateway.error || '连接失败')
89
+ return { success: false, error: this.gateway.error }
90
+ }
91
+ }
92
+
93
+ async stop() {
94
+ await this.gateway.stop()
95
+ this.setStatus('offline')
96
+ return { success: true }
97
+ }
98
+
99
+ /**
100
+ * 配置或登录
101
+ * @param {object} creds - { appId, appSecret, domain }
102
+ */
103
+ async login(creds = {}) {
104
+ const patch = {}
105
+ if (creds.appId !== undefined) patch.appId = String(creds.appId).trim()
106
+ if (creds.appSecret !== undefined) patch.appSecret = String(creds.appSecret).trim()
107
+ if (creds.domain !== undefined) patch.domain = creds.domain === 'lark' ? 'lark' : 'feishu'
108
+
109
+ this.gateway.updateConfig(patch)
110
+ this.persist(patch)
111
+
112
+ if (!this.gateway.configured) {
113
+ await this.stop()
114
+ return { success: false, error: '请填写完整的 App ID 和 App Secret' }
115
+ }
116
+
117
+ return this.start()
118
+ }
119
+
120
+ async unbind() {
121
+ await this.stop()
122
+ this.gateway.updateConfig({ appId: '', appSecret: '' })
123
+ this.persist({ appId: '', appSecret: '', allowFrom: [] })
124
+ if (this.node) this.node.config.allowFrom = []
125
+ return { success: true }
126
+ }
127
+
128
+ getStatus() {
129
+ const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
130
+ const appId = this.gateway.config.appId || ''
131
+ const botLink = appId ? `https://applink.feishu.cn/client/bot/open?appId=${encodeURIComponent(appId)}` : null
132
+ if (botLink && this._botQrCache.appId !== appId) {
133
+ this._botQrCache.appId = appId
134
+ void QRCode.toDataURL(botLink, {
135
+ width: 260,
136
+ margin: 2,
137
+ color: { dark: '#1F2421', light: '#FFFFFF' },
138
+ }).then((qr) => {
139
+ this._botQrCache.qr = qr
140
+ }).catch(() => {})
141
+ }
142
+ return {
143
+ id: this.id,
144
+ name: this.name,
145
+ status: this.status === 'connected' ? 'connected' : this.gateway.status,
146
+ configured: this.gateway.configured,
147
+ accountId: this.gateway.botInfo?.appName
148
+ ? `${this.gateway.botInfo.appName} (${this.gateway.botInfo.openId || this.gateway.config.appId})`
149
+ : (this.gateway.botInfo?.openId || this.gateway.config.appId || ''),
150
+ allowFrom,
151
+ peerId: this.node?.peerId,
152
+ sessionId: this.node?.activeSessionId,
153
+ login: {
154
+ phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
155
+ error: this.gateway.error,
156
+ },
157
+ capabilities: { ...this.capabilities },
158
+ config: {
159
+ digestIntervalSec: this.node?.config?.digestIntervalSec,
160
+ approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
161
+ maxMessageChars: this.node?.config?.maxMessageChars,
162
+ sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
163
+ appId: this.gateway.config.appId,
164
+ appSecret: '',
165
+ domain: this.gateway.config.domain,
166
+ },
167
+ botInfo: this.gateway.botInfo,
168
+ botLink,
169
+ botQr: this._botQrCache.qr,
170
+ error: this.gateway.error,
171
+ }
172
+ }
173
+
174
+ setAllowFrom(allowFrom) {
175
+ const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
176
+ if (this.node) this.node.config.allowFrom = list
177
+ this.persist({ allowFrom: list })
178
+ return { success: true, allowFrom: list }
179
+ }
180
+
181
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, appSecret, domain } = {}) {
182
+ if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
183
+ if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
184
+ if (maxMessageChars != null) this.node.config.maxMessageChars = Number(maxMessageChars)
185
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
186
+ if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
187
+ this.gateway.updateConfig({
188
+ appId: appId !== undefined ? appId.trim() : this.gateway.config.appId,
189
+ appSecret: appSecret?.trim() ? appSecret.trim() : this.gateway.config.appSecret,
190
+ domain: domain || this.gateway.config.domain || 'feishu',
191
+ })
192
+ }
193
+ const patch = {
194
+ digestIntervalSec: this.node.config.digestIntervalSec,
195
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
196
+ maxMessageChars: this.node.config.maxMessageChars,
197
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
198
+ }
199
+ if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
200
+ patch.appId = this.gateway.config.appId
201
+ patch.appSecret = this.gateway.config.appSecret
202
+ patch.domain = this.gateway.config.domain
203
+ }
204
+ await this.persist(patch)
205
+
206
+ // 凭证配置完成后自动启动网关(前端「保存并连接」自动连接)
207
+ if (this.gateway.configured && (appId !== undefined || appSecret !== undefined)) {
208
+ await this.start().catch((err) => {
209
+ this.logger?.warn?.('[dsh-bridge feishu] auto-start failed: %s', err?.message ?? err)
210
+ })
211
+ }
212
+ return { success: true }
213
+ }
214
+
215
+ dispose() {
216
+ super.dispose()
217
+ this.gateway?.dispose?.()
218
+ }
219
+ }