@wenbin_wb/dsh-bridge 2.2.8 → 2.3.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/README.en.md +2 -1
- package/README.md +2 -1
- package/client/client.js +44 -156
- package/client/index.js +42 -118
- package/docs/feishu-usage.md +93 -0
- package/lib/feishu/gateway.js +373 -0
- package/lib/feishu/index.js +203 -0
- package/lib/feishu/node.js +351 -0
- package/lib/index.js +50 -0
- package/lib/platform/conversation-bridge.js +92 -43
- package/lib/qq/gateway.js +1 -1
- package/lib/qq/node.js +5 -21
- package/lib/wechat/gateway.js +3 -0
- package/package.json +5 -3
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# 飞书 (Feishu / Lark) 机器人接入指南
|
|
2
|
+
|
|
3
|
+
> 通过飞书官方开放平台 **WebSocket 长连接(免公网 IP)** 模式,将本地 DeepSeek Harness 接入飞书,支持单聊、群聊、表格命令与原生交互卡片权限审批。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🌟 核心优势
|
|
8
|
+
|
|
9
|
+
- **100% 免公网 IP**:基于飞书官方最新 WebSocket 长连接协议,无需配置公网服务器、无需域名、无需内网穿透工具。
|
|
10
|
+
- **免验签与加密配置**:长连接由飞书官方 SDK 在建立通道时完成认证,无需开发者手动处理 HTTP 回调验签。
|
|
11
|
+
- **飞书原生交互卡片**:Agent 触发敏感工具审批时,飞书端下发带点击按钮的交互卡片,手机/电脑端单手一键点击即可完成批准或拒绝。
|
|
12
|
+
- **多工作区与会话持久化**:支持 `/sessions` 表格查看历史会话、`/use <序号>` 极速切换、`/workspaces` 工作区调度。
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 🛠️ 第一步:创建飞书企业自建应用
|
|
17
|
+
|
|
18
|
+
1. 登录 [飞书开放平台开发者后台](https://open.feishu.cn/app);
|
|
19
|
+
2. 点击右上角 **「创建企业自建应用」**,填写应用名称(如 `DeepSeek Harness`)与应用描述,上传机器人头像;
|
|
20
|
+
3. 创建完成后,在左侧导航栏点击 **「凭证与基础信息」**,即可查看到:
|
|
21
|
+
- **App ID**(格式如 `cli_a1b2c3d4...`)
|
|
22
|
+
- **App Secret**(点击复制密钥)
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 🤖 第二步:添加机器人能力
|
|
27
|
+
|
|
28
|
+
1. 在应用详情页左侧导航栏,点击 **「添加应用能力」**;
|
|
29
|
+
2. 找到 **「机器人」**,点击 **「添加」** 开启机器人能力。
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 🔐 第三步:开通必要权限
|
|
34
|
+
|
|
35
|
+
在左侧导航栏点击 **「权限管理」**,搜索并开通以下权限:
|
|
36
|
+
|
|
37
|
+
| 权限名称 | 权限 Key | 权限说明 |
|
|
38
|
+
| :--- | :--- | :--- |
|
|
39
|
+
| **获取与发送单聊/群聊消息** | `im:message` | 基础消息收发权限 |
|
|
40
|
+
| **以应用的身份发消息** | `im:message:send_as_bot` | 允许机器人向用户/群回复消息 |
|
|
41
|
+
| **获取群组中所有消息** | `im:message.group_msg` | 允许机器人在群聊中被 `@` 时接收消息 |
|
|
42
|
+
| **获取用户 user ID** | `contact:user.id:readonly` | (可选) 获取用户信息 |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## ⚡ 第四步:开启 WebSocket 长连接事件订阅
|
|
47
|
+
|
|
48
|
+
1. 在左侧导航栏点击 **「事件与回调」**;
|
|
49
|
+
2. 在 **「事件配置」** 页面,将事件接收方式切换为 **「使用长连接接收事件」**(WebSocket 模式);
|
|
50
|
+
3. 点击 **「添加事件」**,勾选并添加:
|
|
51
|
+
- **`im.message.receive_v1`**(接收消息)
|
|
52
|
+
- **`card.action.trigger`**(消息卡片回传交互 / 审批按钮点击)
|
|
53
|
+
4. 保存配置。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🚀 第五步:版本发布
|
|
58
|
+
|
|
59
|
+
1. 在左侧导航栏点击 **「版本管理与发布」**;
|
|
60
|
+
2. 点击 **「创建版本」**,填写版本号(如 `1.0.0`),设置应用可用范围(如「所有员工」或「仅自己」);
|
|
61
|
+
3. 点击 **「申请发布」**(自建应用通常由企业管理员直接免审或一键通过)。
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📱 第六步:在 DSH 客户端中连接
|
|
66
|
+
|
|
67
|
+
1. 打开 DeepSeek Harness,进入「设置」➔「远程访问」➔「IM 机器人」➔ 选择 **「飞书」**;
|
|
68
|
+
2. 在表单中填入刚才复制的 **App ID** 和 **App Secret**;
|
|
69
|
+
3. 点击 **「保存并连接」**;
|
|
70
|
+
4. 状态显示为绿色 **「已连接」** 即表示长连接成功建立!
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 💬 常用操作与指令
|
|
75
|
+
|
|
76
|
+
在飞书与机器人单聊或在群里 `@机器人` 即可开始对话:
|
|
77
|
+
|
|
78
|
+
### 1. 会话与工作区管理
|
|
79
|
+
| 指令 | 说明 | 示例 |
|
|
80
|
+
| :--- | :--- | :--- |
|
|
81
|
+
| `/sessions` | 查看所有历史会话表格 | `/sessions` 或 `/list` |
|
|
82
|
+
| `/use <编号>` | 切换到指定编号会话 | `/use 1` 或 `/resume 1` |
|
|
83
|
+
| `/new <提示词>` | 在当前工作区创建新会话并开始 | `/new 帮我写个脚本` |
|
|
84
|
+
| `/new <词> @N` | 在指定工作区新建会话 | `/new 帮我写个脚本 @1` |
|
|
85
|
+
| `/workspaces` | 查看所有已注册的工作区列表 | `/workspaces` |
|
|
86
|
+
| `/status` | 查看 Agent 运行状态看板 | `/status` |
|
|
87
|
+
| `/stop` | 中断停止当前正在执行的任务 | `/stop` |
|
|
88
|
+
| `/end` | 结束当前会话回到空闲状态 | `/end` |
|
|
89
|
+
|
|
90
|
+
### 2. 权限审批交互
|
|
91
|
+
当 Agent 尝试执行需授权的操作(如终端命令、写敏感文件)时,飞书端会自动推送 **原生交互卡片**:
|
|
92
|
+
- 点击卡片上的 **「✓ 批准执行」** 或 **「✕ 拒绝执行」** 按钮即可一键处理;
|
|
93
|
+
- 亦可直接回复文字 `/yes` (或 `1`) / `/no` (或 `2`)。
|
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
// 1. 初始化 REST API Client
|
|
73
|
+
this.client = new Lark.Client({
|
|
74
|
+
appId: this.config.appId,
|
|
75
|
+
appSecret: this.config.appSecret,
|
|
76
|
+
domain,
|
|
77
|
+
appType: Lark.AppType.SelfBuild,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// 2. 初始化长连接 WSClient
|
|
81
|
+
this.wsClient = new Lark.WSClient({
|
|
82
|
+
appId: this.config.appId,
|
|
83
|
+
appSecret: this.config.appSecret,
|
|
84
|
+
domain,
|
|
85
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// 3. 构建事件分发器
|
|
89
|
+
this.eventDispatcher = new Lark.EventDispatcher({})
|
|
90
|
+
|
|
91
|
+
// 注册接收消息事件
|
|
92
|
+
this.eventDispatcher.register({
|
|
93
|
+
'im.message.receive_v1': async (data) => {
|
|
94
|
+
try {
|
|
95
|
+
await this._handleMessageReceive(data)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
this.logger.error?.('[dsh-bridge feishu] error in im.message.receive_v1:', err)
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
'card.action.trigger': async (data) => {
|
|
101
|
+
try {
|
|
102
|
+
return await this._handleCardActionTrigger(data)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
this.logger.error?.('[dsh-bridge feishu] error in card.action.trigger:', err)
|
|
105
|
+
return { toast: { type: 'error', content: '处理失败' } }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
'im.message.message_read_v1': async () => {
|
|
109
|
+
// 消息已读回执事件,忽略
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// 4. 尝试获取机器人信息(验证凭证,非阻断)
|
|
114
|
+
try {
|
|
115
|
+
const botRes = await this.client.bot.v3.bot.get({}).catch(() => null)
|
|
116
|
+
if (botRes?.bot) {
|
|
117
|
+
this.botInfo = {
|
|
118
|
+
openId: botRes.bot.open_id,
|
|
119
|
+
appName: botRes.bot.app_name,
|
|
120
|
+
avatarUrl: botRes.bot.avatar_url,
|
|
121
|
+
}
|
|
122
|
+
this.logger.info?.(`[dsh-bridge feishu] bot authenticated: ${this.botInfo.appName} (${this.botInfo.openId})`)
|
|
123
|
+
}
|
|
124
|
+
} catch (authErr) {
|
|
125
|
+
this.logger.warn?.(`[dsh-bridge feishu] fetch bot info error: ${authErr.message}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 5. 启动 WebSocket 长连接
|
|
129
|
+
await this.wsClient.start({ eventDispatcher: this.eventDispatcher })
|
|
130
|
+
this.setStatus('online')
|
|
131
|
+
this.logger.info?.('[dsh-bridge feishu] WebSocket connected to Feishu Open Platform')
|
|
132
|
+
return true
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
|
|
135
|
+
this.setStatus('error', err)
|
|
136
|
+
return false
|
|
137
|
+
} finally {
|
|
138
|
+
this._startingPromise = null
|
|
139
|
+
}
|
|
140
|
+
})()
|
|
141
|
+
|
|
142
|
+
return this._startingPromise
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async stop() {
|
|
146
|
+
this._closing = true
|
|
147
|
+
try {
|
|
148
|
+
if (this.wsClient) {
|
|
149
|
+
const wsInstance = this.wsClient.wsConfig?.getWSInstance?.()
|
|
150
|
+
if (wsInstance) {
|
|
151
|
+
try { wsInstance.close?.() } catch {}
|
|
152
|
+
try { wsInstance.terminate?.() } catch {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.logger.warn?.('[dsh-bridge feishu] stop error:', err)
|
|
157
|
+
} finally {
|
|
158
|
+
this.client = null
|
|
159
|
+
this.wsClient = null
|
|
160
|
+
this.eventDispatcher = null
|
|
161
|
+
this.setStatus('offline')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- 消息收发 ----
|
|
166
|
+
|
|
167
|
+
async _handleMessageReceive(data) {
|
|
168
|
+
if (!data?.message) return
|
|
169
|
+
const { message, sender } = data
|
|
170
|
+
const messageId = message.message_id
|
|
171
|
+
if (!messageId) return
|
|
172
|
+
|
|
173
|
+
// 消息去重
|
|
174
|
+
if (this.seenMessageIds.has(messageId)) return
|
|
175
|
+
this.seenMessageIds.add(messageId)
|
|
176
|
+
if (this.seenMessageIds.size > 500) {
|
|
177
|
+
const first = this.seenMessageIds.values().next().value
|
|
178
|
+
this.seenMessageIds.delete(first)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const senderOpenId = sender?.sender_id?.open_id || ''
|
|
182
|
+
const chatId = message.chat_id || ''
|
|
183
|
+
const chatType = message.chat_type || 'p2p' // 'p2p' or 'group'
|
|
184
|
+
const isGroup = chatType === 'group'
|
|
185
|
+
|
|
186
|
+
// 提取文本内容
|
|
187
|
+
let text = ''
|
|
188
|
+
if (message.message_type === 'text') {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(message.content || '{}')
|
|
191
|
+
text = parsed.text || ''
|
|
192
|
+
} catch {
|
|
193
|
+
text = message.content || ''
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 群聊中剥离机器人 @ 占位符(例如 @_user_1 等)
|
|
198
|
+
if (isGroup && text) {
|
|
199
|
+
text = text.replace(/@_user_\d+/g, '').trim()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const peerId = isGroup ? chatId : senderOpenId
|
|
203
|
+
if (!peerId) return
|
|
204
|
+
|
|
205
|
+
this.ctx.emit('feishu/message', {
|
|
206
|
+
peerId,
|
|
207
|
+
senderId: senderOpenId,
|
|
208
|
+
chatId,
|
|
209
|
+
chatType,
|
|
210
|
+
isGroup,
|
|
211
|
+
messageId,
|
|
212
|
+
text,
|
|
213
|
+
messageType: message.message_type,
|
|
214
|
+
raw: data,
|
|
215
|
+
createTime: Number(message.create_time || Date.now()),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async _handleCardActionTrigger(data) {
|
|
220
|
+
const action = data?.action || {}
|
|
221
|
+
const operator = data?.operator || {}
|
|
222
|
+
const operatorOpenId = operator.open_id || ''
|
|
223
|
+
const value = action.value || {}
|
|
224
|
+
|
|
225
|
+
this.ctx.emit('feishu/action', {
|
|
226
|
+
operatorId: operatorOpenId,
|
|
227
|
+
action: value.action || action.tag,
|
|
228
|
+
value,
|
|
229
|
+
raw: data,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const actionName = value.action === 'approve' ? '已批准' : value.action === 'reject' ? '已拒绝' : '已操作'
|
|
233
|
+
return {
|
|
234
|
+
toast: {
|
|
235
|
+
type: 'success',
|
|
236
|
+
content: `操作成功:${actionName}`,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 发送文本消息
|
|
243
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
244
|
+
* @param {string} text - 发送文本
|
|
245
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
246
|
+
*/
|
|
247
|
+
async sendText(receiveId, text, opts = {}) {
|
|
248
|
+
if (!this.client || !receiveId || !text) return null
|
|
249
|
+
let receiveIdType = opts.receiveIdType
|
|
250
|
+
if (!receiveIdType) {
|
|
251
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
252
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
253
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
254
|
+
else receiveIdType = 'open_id'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
this.logger.debug?.(`[dsh-bridge feishu] sending text to ${receiveId} (${receiveIdType})`)
|
|
259
|
+
const res = await this.client.im.v1.message.create({
|
|
260
|
+
params: { receive_id_type: receiveIdType },
|
|
261
|
+
data: {
|
|
262
|
+
receive_id: receiveId,
|
|
263
|
+
msg_type: 'text',
|
|
264
|
+
content: JSON.stringify({ text }),
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
return res?.data
|
|
268
|
+
} catch (err) {
|
|
269
|
+
this.logger.error?.(`[dsh-bridge feishu] sendText error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
|
|
270
|
+
throw err
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 发送飞书原生交互卡片
|
|
276
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
277
|
+
* @param {object} cardData - 卡片结构对象 (JSON 2.0)
|
|
278
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
279
|
+
*/
|
|
280
|
+
async sendCard(receiveId, cardData, opts = {}) {
|
|
281
|
+
if (!this.client || !receiveId || !cardData) return null
|
|
282
|
+
let receiveIdType = opts.receiveIdType
|
|
283
|
+
if (!receiveIdType) {
|
|
284
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
285
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
286
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
287
|
+
else receiveIdType = 'open_id'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
this.logger.debug?.(`[dsh-bridge feishu] sending card to ${receiveId} (${receiveIdType})`)
|
|
292
|
+
const res = await this.client.im.v1.message.create({
|
|
293
|
+
params: { receive_id_type: receiveIdType },
|
|
294
|
+
data: {
|
|
295
|
+
receive_id: receiveId,
|
|
296
|
+
msg_type: 'interactive',
|
|
297
|
+
content: JSON.stringify(cardData),
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
return res?.data
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.logger.error?.(`[dsh-bridge feishu] sendCard error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
|
|
303
|
+
throw err
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 发送 Markdown 交互卡片 (JSON 2.0)
|
|
309
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
310
|
+
* @param {string} markdownText - Markdown 文本
|
|
311
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
312
|
+
*/
|
|
313
|
+
async sendMarkdownCard(receiveId, markdownText, opts = {}) {
|
|
314
|
+
const card = {
|
|
315
|
+
schema: '2.0',
|
|
316
|
+
config: {
|
|
317
|
+
wide_screen_mode: true,
|
|
318
|
+
update_multi: true,
|
|
319
|
+
},
|
|
320
|
+
body: {
|
|
321
|
+
elements: [
|
|
322
|
+
{
|
|
323
|
+
tag: 'markdown',
|
|
324
|
+
content: String(markdownText || '').trim(),
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
return this.sendCard(receiveId, card, opts)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 更新已发送的交互卡片(流式更新 / 原地替换,JSON 2.0)
|
|
334
|
+
* @param {string} messageId - 已发送消息的 message_id
|
|
335
|
+
* @param {string} markdownText - 最新完整 Markdown 文本
|
|
336
|
+
*/
|
|
337
|
+
async patchCard(messageId, markdownText) {
|
|
338
|
+
if (!this.client || !messageId) return null
|
|
339
|
+
const card = {
|
|
340
|
+
schema: '2.0',
|
|
341
|
+
config: {
|
|
342
|
+
wide_screen_mode: true,
|
|
343
|
+
update_multi: true,
|
|
344
|
+
},
|
|
345
|
+
body: {
|
|
346
|
+
elements: [
|
|
347
|
+
{
|
|
348
|
+
tag: 'markdown',
|
|
349
|
+
content: String(markdownText || '').trim(),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
this.logger.debug?.(`[dsh-bridge feishu] patching card message ${messageId}`)
|
|
356
|
+
const res = await this.client.im.v1.message.patch({
|
|
357
|
+
path: { message_id: messageId },
|
|
358
|
+
data: {
|
|
359
|
+
content: JSON.stringify(card),
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
return res?.data
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.logger.error?.(`[dsh-bridge feishu] patchCard error (${messageId}):`, err?.message ?? err)
|
|
365
|
+
throw err
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 释放资源
|
|
370
|
+
dispose() {
|
|
371
|
+
void this.stop()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// dsh-bridge Feishu / Lark platform adapter
|
|
2
|
+
// 编排 FeishuGateway(官方 OpenAPI/WSClient 网关)+ FeishuConversationNode(飞书⇄DSH 会话桥)。
|
|
3
|
+
// 作为 Platform 子类,注册进 PlatformManager 统一管理。
|
|
4
|
+
|
|
5
|
+
import { Platform } from '../platform/base.js'
|
|
6
|
+
import { FeishuGateway } from './gateway.js'
|
|
7
|
+
import { FeishuConversationNode } from './node.js'
|
|
8
|
+
|
|
9
|
+
export class FeishuService extends Platform {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
13
|
+
* @param {object} opts.logger 日志器
|
|
14
|
+
* @param {object} [opts.config] 已持久化的 feishu 配置(凭证 + allowFrom + 间隔)
|
|
15
|
+
* @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
|
|
16
|
+
*/
|
|
17
|
+
constructor({ ctx, logger, config = {}, onPersist }) {
|
|
18
|
+
super({ ctx, logger, config, onPersist })
|
|
19
|
+
this.id = 'feishu'
|
|
20
|
+
this.name = 'Feishu'
|
|
21
|
+
|
|
22
|
+
this.gateway = new FeishuGateway(ctx, {
|
|
23
|
+
appId: config.appId ?? '',
|
|
24
|
+
appSecret: config.appSecret ?? '',
|
|
25
|
+
domain: config.domain ?? 'feishu',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// 挂到 ctx 供会话节点读取
|
|
29
|
+
try { ctx.feishu = this.gateway } catch { /* 挂载失败不致命 */ }
|
|
30
|
+
|
|
31
|
+
this.node = new FeishuConversationNode(ctx, {
|
|
32
|
+
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
33
|
+
digestIntervalSec: config.digestIntervalSec,
|
|
34
|
+
approvalTimeoutSec: config.approvalTimeoutSec,
|
|
35
|
+
maxMessageChars: config.maxMessageChars || 2000,
|
|
36
|
+
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
37
|
+
activeSessionId: config.activeSessionId,
|
|
38
|
+
}, logger, {
|
|
39
|
+
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
40
|
+
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
|
|
41
|
+
})
|
|
42
|
+
this.bridge = this.node
|
|
43
|
+
|
|
44
|
+
if (this.gateway.configured) {
|
|
45
|
+
void this.start().catch((err) => {
|
|
46
|
+
this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Platform 接口 ----
|
|
52
|
+
|
|
53
|
+
get configured() { return this.gateway.configured }
|
|
54
|
+
get accountId() { return this.gateway.botInfo?.openId || '' }
|
|
55
|
+
|
|
56
|
+
get capabilities() {
|
|
57
|
+
return {
|
|
58
|
+
group: true,
|
|
59
|
+
media: true,
|
|
60
|
+
approvals: true,
|
|
61
|
+
maxMessageChars: this.node.config.maxMessageChars || 2000,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async sendText(peerId, text, opts = {}) {
|
|
66
|
+
return this.gateway.sendMarkdownCard(peerId, text, opts)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async sendTyping(peerId, opts = {}) {
|
|
70
|
+
return this.gateway.sendTyping?.(peerId, opts)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- 生命周期控制 ----
|
|
74
|
+
|
|
75
|
+
async start() {
|
|
76
|
+
if (!this.gateway.configured) {
|
|
77
|
+
this.setStatus('idle')
|
|
78
|
+
return { success: false, error: 'App ID 与 App Secret 未配置' }
|
|
79
|
+
}
|
|
80
|
+
this.setStatus('starting')
|
|
81
|
+
const ok = await this.gateway.start()
|
|
82
|
+
if (ok) {
|
|
83
|
+
this.setStatus('connected')
|
|
84
|
+
return { success: true }
|
|
85
|
+
} else {
|
|
86
|
+
this.setStatus('error', this.gateway.error || '连接失败')
|
|
87
|
+
return { success: false, error: this.gateway.error }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async stop() {
|
|
92
|
+
await this.gateway.stop()
|
|
93
|
+
this.setStatus('offline')
|
|
94
|
+
return { success: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 配置或登录
|
|
99
|
+
* @param {object} creds - { appId, appSecret, domain }
|
|
100
|
+
*/
|
|
101
|
+
async login(creds = {}) {
|
|
102
|
+
const patch = {}
|
|
103
|
+
if (creds.appId !== undefined) patch.appId = String(creds.appId).trim()
|
|
104
|
+
if (creds.appSecret !== undefined) patch.appSecret = String(creds.appSecret).trim()
|
|
105
|
+
if (creds.domain !== undefined) patch.domain = creds.domain === 'lark' ? 'lark' : 'feishu'
|
|
106
|
+
|
|
107
|
+
this.gateway.updateConfig(patch)
|
|
108
|
+
this.persist(patch)
|
|
109
|
+
|
|
110
|
+
if (!this.gateway.configured) {
|
|
111
|
+
await this.stop()
|
|
112
|
+
return { success: false, error: '请填写完整的 App ID 和 App Secret' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return this.start()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async unbind() {
|
|
119
|
+
await this.stop()
|
|
120
|
+
this.gateway.updateConfig({ appId: '', appSecret: '' })
|
|
121
|
+
this.persist({ appId: '', appSecret: '', allowFrom: [] })
|
|
122
|
+
if (this.node) this.node.config.allowFrom = []
|
|
123
|
+
return { success: true }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getStatus() {
|
|
127
|
+
const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
|
|
128
|
+
return {
|
|
129
|
+
id: this.id,
|
|
130
|
+
name: this.name,
|
|
131
|
+
status: this.status === 'connected' ? 'connected' : this.gateway.status,
|
|
132
|
+
configured: this.gateway.configured,
|
|
133
|
+
accountId: this.gateway.botInfo?.appName
|
|
134
|
+
? `${this.gateway.botInfo.appName} (${this.gateway.botInfo.openId || this.gateway.config.appId})`
|
|
135
|
+
: (this.gateway.botInfo?.openId || this.gateway.config.appId || ''),
|
|
136
|
+
allowFrom,
|
|
137
|
+
peerId: this.node?.peerId,
|
|
138
|
+
sessionId: this.node?.activeSessionId,
|
|
139
|
+
login: {
|
|
140
|
+
phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
|
|
141
|
+
error: this.gateway.error,
|
|
142
|
+
},
|
|
143
|
+
capabilities: { ...this.capabilities },
|
|
144
|
+
config: {
|
|
145
|
+
digestIntervalSec: this.node?.config?.digestIntervalSec,
|
|
146
|
+
approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
|
|
147
|
+
maxMessageChars: this.node?.config?.maxMessageChars,
|
|
148
|
+
sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
|
|
149
|
+
appId: this.gateway.config.appId,
|
|
150
|
+
appSecret: '',
|
|
151
|
+
domain: this.gateway.config.domain,
|
|
152
|
+
},
|
|
153
|
+
botInfo: this.gateway.botInfo,
|
|
154
|
+
error: this.gateway.error,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setAllowFrom(allowFrom) {
|
|
159
|
+
const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
|
|
160
|
+
if (this.node) this.node.config.allowFrom = list
|
|
161
|
+
this.persist({ allowFrom: list })
|
|
162
|
+
return { success: true, allowFrom: list }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, appSecret, domain } = {}) {
|
|
166
|
+
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
167
|
+
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
168
|
+
if (maxMessageChars != null) this.node.config.maxMessageChars = Number(maxMessageChars)
|
|
169
|
+
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
170
|
+
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
171
|
+
this.gateway.updateConfig({
|
|
172
|
+
appId: appId !== undefined ? appId.trim() : this.gateway.config.appId,
|
|
173
|
+
appSecret: appSecret?.trim() ? appSecret.trim() : this.gateway.config.appSecret,
|
|
174
|
+
domain: domain || this.gateway.config.domain || 'feishu',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
const patch = {
|
|
178
|
+
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
179
|
+
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
180
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
181
|
+
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
182
|
+
}
|
|
183
|
+
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
184
|
+
patch.appId = this.gateway.config.appId
|
|
185
|
+
patch.appSecret = this.gateway.config.appSecret
|
|
186
|
+
patch.domain = this.gateway.config.domain
|
|
187
|
+
}
|
|
188
|
+
await this.persist(patch)
|
|
189
|
+
|
|
190
|
+
// 凭证配置完成后自动启动网关(前端「保存并连接」自动连接)
|
|
191
|
+
if (this.gateway.configured && (appId !== undefined || appSecret !== undefined)) {
|
|
192
|
+
await this.start().catch((err) => {
|
|
193
|
+
this.logger?.warn?.('[dsh-bridge feishu] auto-start failed: %s', err?.message ?? err)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
return { success: true }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
dispose() {
|
|
200
|
+
super.dispose()
|
|
201
|
+
this.gateway?.dispose?.()
|
|
202
|
+
}
|
|
203
|
+
}
|