@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.
- package/README.en.md +39 -1
- package/README.md +39 -1
- package/client/client.js +99 -156
- package/client/index.js +87 -118
- package/docs/feishu-usage.md +93 -0
- package/lib/feishu/gateway.js +389 -0
- package/lib/feishu/index.js +219 -0
- package/lib/feishu/node.js +351 -0
- package/lib/index.js +50 -0
- package/lib/platform/conversation-bridge.js +29 -4
- package/lib/qq/gateway.js +1 -1
- package/lib/wechat/gateway.js +3 -0
- package/package.json +5 -3
|
@@ -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
|
+
}
|