@hpplay-lebo/cluster-hub 2.0.0 → 3.0.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.md +23 -16
- package/docs/GUIDE.md +286 -0
- package/package.json +1 -1
- package/src/feishu-tools.ts +586 -0
- package/src/hub-client.ts +34 -1
- package/src/index.ts +68 -0
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -10,27 +10,15 @@ OpenClaw Hub 集群插件 — 让多台 OpenClaw 节点跨网络协作,实现
|
|
|
10
10
|
- 🌳 **树形集群** — 支持多层树形结构(最大深度 5 层),邀请码加入
|
|
11
11
|
- 📊 **状态监控** — 实时心跳、在线状态、任务统计
|
|
12
12
|
- 🔌 **自动重连** — 断线后自动重连,离线消息队列
|
|
13
|
+
- 📄 **飞书工具下发** — Hub 自动下发飞书凭据,子节点无需配置即可操作飞书文档/知识库/云空间
|
|
13
14
|
- 🛠️ **CLI + AI 工具 + RPC** — 三种使用方式
|
|
14
15
|
|
|
15
16
|
## 安装
|
|
16
17
|
|
|
17
|
-
### 方式一:npm 安装(推荐)
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install @hpplay-lebo/cluster-hub
|
|
21
|
-
|
|
22
|
-
# 链接到 OpenClaw 插件目录
|
|
23
|
-
ln -s $(npm root)/@hpplay-lebo/cluster-hub ~/.openclaw/extensions/cluster-hub
|
|
24
|
-
|
|
25
|
-
# 重启 Gateway
|
|
26
|
-
openclaw gateway restart
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
### 方式二:手动安装
|
|
30
|
-
|
|
31
18
|
```bash
|
|
32
|
-
#
|
|
33
|
-
|
|
19
|
+
# 克隆到 OpenClaw 插件目录
|
|
20
|
+
cd ~/.openclaw/extensions
|
|
21
|
+
git clone https://github.com/shenyingjun5/cluster-hub.git cluster-hub
|
|
34
22
|
|
|
35
23
|
# 重启 Gateway
|
|
36
24
|
openclaw gateway restart
|
|
@@ -143,6 +131,20 @@ openclaw hub tasks
|
|
|
143
131
|
| `hub_wait_all` | 等待多个任务全部完成并汇总 |
|
|
144
132
|
| `hub_tasks` | 查看任务队列和历史 |
|
|
145
133
|
|
|
134
|
+
### 飞书工具(Hub 自动下发)
|
|
135
|
+
|
|
136
|
+
如果 Hub 配置了飞书应用凭据,节点连接后自动获得以下工具:
|
|
137
|
+
|
|
138
|
+
| 工具 | 说明 |
|
|
139
|
+
|------|------|
|
|
140
|
+
| `feishu_doc` | 文档操作(读/写/追加/创建/块编辑) |
|
|
141
|
+
| `feishu_wiki` | 知识库操作(空间/节点/创建) |
|
|
142
|
+
| `feishu_drive` | 云空间管理(文件列表/创建文件夹/移动/删除) |
|
|
143
|
+
| `feishu_perm` | 权限管理(查看/添加/移除协作者) |
|
|
144
|
+
| `feishu_app_scopes` | 查看应用权限 |
|
|
145
|
+
|
|
146
|
+
> **冲突处理**: 如果节点已安装并启用了 OpenClaw 飞书插件,Hub 下发的工具会自动跳过,不会冲突。
|
|
147
|
+
|
|
146
148
|
**AI 对话示例:**
|
|
147
149
|
|
|
148
150
|
```
|
|
@@ -213,6 +215,7 @@ cluster-hub/
|
|
|
213
215
|
└── src/
|
|
214
216
|
├── index.ts # 插件入口(RPC + AI 工具 + CLI + 后台服务)
|
|
215
217
|
├── hub-client.ts # Hub 通讯客户端(WebSocket + REST)
|
|
218
|
+
├── feishu-tools.ts # 飞书工具集(Hub 下发凭据,自动注册)
|
|
216
219
|
├── store.ts # 持久化存储(任务/聊天/节点事件)
|
|
217
220
|
└── types.ts # 类型定义
|
|
218
221
|
```
|
|
@@ -238,6 +241,10 @@ cluster-hub/
|
|
|
238
241
|
└─────────────────┘ └─────────────────┘
|
|
239
242
|
```
|
|
240
243
|
|
|
244
|
+
## 文档
|
|
245
|
+
|
|
246
|
+
- **[CLI 命令参考](docs/CLI.md)** — 所有 CLI 命令、AI 工具、Gateway RPC 详细说明
|
|
247
|
+
|
|
241
248
|
## License
|
|
242
249
|
|
|
243
250
|
MIT
|
package/docs/GUIDE.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# OpenClaw Hub 集群 — 新手安装指引
|
|
2
|
+
|
|
3
|
+
## 目录
|
|
4
|
+
|
|
5
|
+
- [前置条件](#前置条件)
|
|
6
|
+
- [第一步:安装插件](#第一步安装插件)
|
|
7
|
+
- [第二步:创建集群(根节点)](#第二步创建集群根节点)
|
|
8
|
+
- [第三步:添加子节点](#第三步添加子节点)
|
|
9
|
+
- [第四步:飞书机器人](#第四步飞书机器人)
|
|
10
|
+
- [AI 对话指令](#ai-对话指令)
|
|
11
|
+
- [CLI 命令速查](#cli-命令速查)
|
|
12
|
+
- [常见问题](#常见问题)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 前置条件
|
|
17
|
+
|
|
18
|
+
- 已安装 [OpenClaw](https://github.com/openclaw/openclaw)
|
|
19
|
+
- OpenClaw Gateway 正常运行(`openclaw gateway status`)
|
|
20
|
+
- 有终端/命令行访问权限
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 第一步:安装插件
|
|
25
|
+
|
|
26
|
+
在终端执行:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 克隆插件到 OpenClaw 插件目录
|
|
30
|
+
cd ~/.openclaw/extensions
|
|
31
|
+
git clone https://github.com/shenyingjun5/cluster-hub.git cluster-hub
|
|
32
|
+
|
|
33
|
+
# 重启 Gateway 加载插件
|
|
34
|
+
kill -9 $(pgrep -f "openclaw.*gateway")
|
|
35
|
+
openclaw gateway start
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
验证安装:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
openclaw plugins list
|
|
42
|
+
# 应看到 cluster-hub 状态为 loaded
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 第二步:创建集群(根节点)
|
|
48
|
+
|
|
49
|
+
> 每个集群需要一个根节点作为管理者。一般由团队负责人创建。
|
|
50
|
+
|
|
51
|
+
### 2.1 注册根节点
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
openclaw hub register --name "我的Mac" --alias home
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `--name`:节点显示名称(支持中文)
|
|
58
|
+
- `--alias`:节点别名,集群内唯一,用于 `#别名` 提及(仅允许字母数字下划线横线,1-64位)
|
|
59
|
+
|
|
60
|
+
注册成功后会输出:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
✅ 注册成功! 节点 ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
64
|
+
WebSocket: 已连接
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
配置自动写入 `~/.openclaw/openclaw.json`,无需手动修改。
|
|
68
|
+
|
|
69
|
+
### 2.2 生成邀请码
|
|
70
|
+
|
|
71
|
+
根节点创建后,生成邀请码供其他人加入:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
openclaw hub invite --new
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
输出示例:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
✅ 新邀请码: K2XPWV
|
|
81
|
+
|
|
82
|
+
子节点加入命令:
|
|
83
|
+
openclaw hub register --parent xxxxxxxx --invite K2XPWV --name "节点名" --alias "别名"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**把节点 ID、邀请码一起发给要加入集群的人。**
|
|
87
|
+
|
|
88
|
+
飞书机器人绑定也需要这两个信息。
|
|
89
|
+
|
|
90
|
+
### 2.3 查看当前邀请码
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
openclaw hub invite
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 第三步:添加子节点
|
|
99
|
+
|
|
100
|
+
> 每个想加入集群的人在自己的电脑上操作。
|
|
101
|
+
|
|
102
|
+
### 3.1 安装插件(同第一步)
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd ~/.openclaw/extensions
|
|
106
|
+
git clone https://github.com/shenyingjun5/cluster-hub.git cluster-hub
|
|
107
|
+
kill -9 $(pgrep -f "openclaw.*gateway")
|
|
108
|
+
openclaw gateway start
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 3.2 用邀请码注册
|
|
112
|
+
|
|
113
|
+
使用根节点管理者提供的命令:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
openclaw hub register --parent <根节点ID> --invite <邀请码> --name "办公室Mac" --alias office
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
注册成功后自动连接。
|
|
120
|
+
|
|
121
|
+
### 3.3 验证连接
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
openclaw hub status
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
应看到 `WebSocket: 已连接`,以及集群中的其他节点。
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 第四步:飞书机器人
|
|
132
|
+
|
|
133
|
+
通过飞书机器人「进宝」,可以直接在飞书里和集群中的任意节点对话。
|
|
134
|
+
|
|
135
|
+
### 4.1 添加机器人
|
|
136
|
+
|
|
137
|
+
在飞书中搜索并添加「进宝」机器人为好友。
|
|
138
|
+
|
|
139
|
+
### 4.2 绑定集群
|
|
140
|
+
|
|
141
|
+
首次给进宝发消息会收到欢迎卡片。向集群管理员获取**集群 ID**(即根节点 ID)和**邀请码**,然后发送:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
/bind <集群ID> <邀请码>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
示例:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
/bind 16578344-xxxx-xxxx-xxxx-xxxxxxxxxxxx K2XPWV
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
绑定成功后会显示集群信息和默认对话节点。
|
|
154
|
+
|
|
155
|
+
### 4.3 聊天方式
|
|
156
|
+
|
|
157
|
+
| 你发送 | 效果 |
|
|
158
|
+
|--------|------|
|
|
159
|
+
| `你好` | 发给默认节点 |
|
|
160
|
+
| `#home 检查磁盘` | 发给指定节点 #home |
|
|
161
|
+
| `#all 报告系统负载` | 广播给所有在线节点,每个节点单独回复 |
|
|
162
|
+
| `#home`(仅别名无文字) | 显示该节点状态信息 |
|
|
163
|
+
|
|
164
|
+
### 4.4 机器人命令
|
|
165
|
+
|
|
166
|
+
| 命令 | 中文别名 | 说明 |
|
|
167
|
+
|------|----------|------|
|
|
168
|
+
| `/bind <ID> <邀请码>` | `/绑定` | 绑定集群 |
|
|
169
|
+
| `/unbind` | `/解绑` | 解除绑定 |
|
|
170
|
+
| `/list` 或 `/ls` | `/节点` | 查看集群所有节点及在线状态 |
|
|
171
|
+
| `/who` | `/谁` | 查看当前默认对话节点 |
|
|
172
|
+
| `/switch #别名` | `/切换 #别名` | 切换默认对话节点 |
|
|
173
|
+
| `/status` | `/状态` | 查看集群状态总览 |
|
|
174
|
+
| `/tasks` | `/任务` | 查看任务完成情况 |
|
|
175
|
+
| `/info` | `/信息` | 查看绑定信息 |
|
|
176
|
+
| `/help` | `/帮助` | 帮助 |
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## AI 对话指令
|
|
181
|
+
|
|
182
|
+
安装插件后,你的 OpenClaw AI 自动获得集群能力。直接用自然语言对话即可:
|
|
183
|
+
|
|
184
|
+
### 状态查询
|
|
185
|
+
|
|
186
|
+
| 你说 | AI 做什么 |
|
|
187
|
+
|------|-----------|
|
|
188
|
+
| "查看集群状态" | 显示 Hub 连接状态、在线节点数 |
|
|
189
|
+
| "列出所有节点" | 显示每个节点的名称、别名、在线状态 |
|
|
190
|
+
| "查看任务列表" | 显示任务队列和历史 |
|
|
191
|
+
|
|
192
|
+
### 任务下发
|
|
193
|
+
|
|
194
|
+
| 你说 | AI 做什么 |
|
|
195
|
+
|------|-----------|
|
|
196
|
+
| "让 macAir 检查磁盘空间" | 给 macAir 节点发任务 |
|
|
197
|
+
| "给 office 执行 npm test" | 给 office 节点发任务 |
|
|
198
|
+
| "给所有节点发送'报告系统负载'" | 批量下发到所有子节点 |
|
|
199
|
+
|
|
200
|
+
### 任务编排(高级)
|
|
201
|
+
|
|
202
|
+
| 你说 | AI 做什么 |
|
|
203
|
+
|------|-----------|
|
|
204
|
+
| "让 home 写代码,macAir 负责测试" | 分解任务 → 分发到不同节点 → 等待汇总 |
|
|
205
|
+
| "同时让三个节点各自检查安全更新" | 并行下发 → 等待全部完成 → 汇总结果 |
|
|
206
|
+
| "先让 gpu-server 训练模型,完成后让 home 分析结果" | 串行编排(Pipeline 模式) |
|
|
207
|
+
|
|
208
|
+
### 邀请码管理
|
|
209
|
+
|
|
210
|
+
| 你说 | AI 做什么 |
|
|
211
|
+
|------|-----------|
|
|
212
|
+
| "查看邀请码" | 显示当前邀请码 |
|
|
213
|
+
| "生成新邀请码" | 创建新的邀请码 |
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## CLI 命令速查
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# 状态
|
|
221
|
+
openclaw hub status # 连接状态 + 节点列表
|
|
222
|
+
openclaw hub nodes # 所有节点详情
|
|
223
|
+
openclaw hub tree # 树形结构
|
|
224
|
+
|
|
225
|
+
# 注册
|
|
226
|
+
openclaw hub register # 注册为根节点
|
|
227
|
+
openclaw hub register --name "名称" --alias "别名" # 指定名称和别名
|
|
228
|
+
openclaw hub register --parent <ID> --invite <邀请码> # 注册为子节点
|
|
229
|
+
openclaw hub unregister # 注销本节点
|
|
230
|
+
openclaw hub unregister --node <ID> # 注销指定节点
|
|
231
|
+
|
|
232
|
+
# 邀请码
|
|
233
|
+
openclaw hub invite # 查看当前邀请码
|
|
234
|
+
openclaw hub invite --new # 生成新邀请码
|
|
235
|
+
|
|
236
|
+
# 任务
|
|
237
|
+
openclaw hub send <别名> "指令" # 发送任务
|
|
238
|
+
openclaw hub tasks # 查看任务
|
|
239
|
+
|
|
240
|
+
# 连接
|
|
241
|
+
openclaw hub connect # 手动连接
|
|
242
|
+
openclaw hub disconnect # 断开连接
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 常见问题
|
|
248
|
+
|
|
249
|
+
### Q: 注册时提示 "别名已被使用"
|
|
250
|
+
|
|
251
|
+
别名在集群内必须唯一(不区分大小写)。换一个别名,或联系管理员清除旧节点。
|
|
252
|
+
|
|
253
|
+
### Q: 显示已注册但 WebSocket 未连接
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# 完全重启 Gateway
|
|
257
|
+
kill -9 $(pgrep -f "openclaw.*gateway")
|
|
258
|
+
openclaw gateway start
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Q: 插件更新后命令没变化
|
|
262
|
+
|
|
263
|
+
必须 `kill -9` 完全重启 Gateway,普通重启不会重新加载插件代码。
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
# 更新插件
|
|
267
|
+
cd ~/.openclaw/extensions/cluster-hub && git pull
|
|
268
|
+
|
|
269
|
+
# 完全重启
|
|
270
|
+
kill -9 $(pgrep -f "openclaw.*gateway")
|
|
271
|
+
openclaw gateway start
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Q: 子节点无法给父节点发任务
|
|
275
|
+
|
|
276
|
+
这是设计如此。任务下发只能从父到子。节点间聊天(通过飞书机器人)不受此限制。
|
|
277
|
+
|
|
278
|
+
### Q: 飞书机器人没有回复
|
|
279
|
+
|
|
280
|
+
1. 确认已执行 `/bind <集群ID> <邀请码>` 绑定成功
|
|
281
|
+
2. 确认目标节点在线(`/list` 查看)
|
|
282
|
+
3. 目标节点的 Gateway 必须在运行
|
|
283
|
+
|
|
284
|
+
### Q: 怎么切换飞书默认对话节点
|
|
285
|
+
|
|
286
|
+
发送 `/switch #别名`,例如 `/switch #home`。之后直接发文字就会发给新的默认节点。用 `/who` 查看当前默认节点。
|
package/package.json
CHANGED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书工具集 — 轻量 REST 实现(不依赖 @larksuiteoapi/node-sdk)
|
|
3
|
+
* 由 Hub 下发 appId/appSecret,自动注册 AI 工具
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// Feishu REST Client
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
interface FeishuCredentials {
|
|
11
|
+
appId: string;
|
|
12
|
+
appSecret: string;
|
|
13
|
+
domain?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let _credentials: FeishuCredentials | null = null;
|
|
17
|
+
let _tenantToken: string | null = null;
|
|
18
|
+
let _tokenExpiresAt = 0;
|
|
19
|
+
|
|
20
|
+
function getBaseUrl(domain?: string): string {
|
|
21
|
+
if (domain === 'lark') return 'https://open.larksuite.com';
|
|
22
|
+
if (domain && domain !== 'feishu') return domain.replace(/\/+$/, '');
|
|
23
|
+
return 'https://open.feishu.cn';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setCredentials(creds: FeishuCredentials) {
|
|
27
|
+
_credentials = creds;
|
|
28
|
+
_tenantToken = null;
|
|
29
|
+
_tokenExpiresAt = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hasCredentials(): boolean {
|
|
33
|
+
return !!_credentials?.appId && !!_credentials?.appSecret;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getTenantToken(): Promise<string> {
|
|
37
|
+
if (!_credentials) throw new Error('飞书凭据未配置');
|
|
38
|
+
|
|
39
|
+
if (_tenantToken && Date.now() < _tokenExpiresAt) {
|
|
40
|
+
return _tenantToken;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const base = getBaseUrl(_credentials.domain);
|
|
44
|
+
const res = await fetch(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
app_id: _credentials.appId,
|
|
49
|
+
app_secret: _credentials.appSecret,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const data = await res.json() as any;
|
|
54
|
+
if (data.code !== 0) {
|
|
55
|
+
throw new Error(`获取 tenant_access_token 失败: ${data.msg}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_tenantToken = data.tenant_access_token;
|
|
59
|
+
_tokenExpiresAt = Date.now() + (data.expire - 300) * 1000; // 提前5分钟刷新
|
|
60
|
+
return _tenantToken!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function feishuGet(path: string, params?: Record<string, string>): Promise<any> {
|
|
64
|
+
const token = await getTenantToken();
|
|
65
|
+
const base = getBaseUrl(_credentials?.domain);
|
|
66
|
+
const url = new URL(`${base}${path}`);
|
|
67
|
+
if (params) {
|
|
68
|
+
for (const [k, v] of Object.entries(params)) {
|
|
69
|
+
if (v !== undefined) url.searchParams.set(k, v);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const res = await fetch(url.toString(), {
|
|
73
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
74
|
+
});
|
|
75
|
+
const data = await res.json() as any;
|
|
76
|
+
if (data.code !== 0) throw new Error(data.msg || `API error ${data.code}`);
|
|
77
|
+
return data.data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function feishuPost(path: string, body: any): Promise<any> {
|
|
81
|
+
const token = await getTenantToken();
|
|
82
|
+
const base = getBaseUrl(_credentials?.domain);
|
|
83
|
+
const res = await fetch(`${base}${path}`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${token}`,
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
const data = await res.json() as any;
|
|
92
|
+
if (data.code !== 0) throw new Error(data.msg || `API error ${data.code}`);
|
|
93
|
+
return data.data;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function feishuPatch(path: string, body: any): Promise<any> {
|
|
97
|
+
const token = await getTenantToken();
|
|
98
|
+
const base = getBaseUrl(_credentials?.domain);
|
|
99
|
+
const res = await fetch(`${base}${path}`, {
|
|
100
|
+
method: 'PATCH',
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: `Bearer ${token}`,
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
});
|
|
107
|
+
const data = await res.json() as any;
|
|
108
|
+
if (data.code !== 0) throw new Error(data.msg || `API error ${data.code}`);
|
|
109
|
+
return data.data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function feishuDelete(path: string, params?: Record<string, string>): Promise<any> {
|
|
113
|
+
const token = await getTenantToken();
|
|
114
|
+
const base = getBaseUrl(_credentials?.domain);
|
|
115
|
+
const url = new URL(`${base}${path}`);
|
|
116
|
+
if (params) {
|
|
117
|
+
for (const [k, v] of Object.entries(params)) {
|
|
118
|
+
if (v !== undefined) url.searchParams.set(k, v);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const res = await fetch(url.toString(), {
|
|
122
|
+
method: 'DELETE',
|
|
123
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
124
|
+
});
|
|
125
|
+
const data = await res.json() as any;
|
|
126
|
+
if (data.code !== 0) throw new Error(data.msg || `API error ${data.code}`);
|
|
127
|
+
return data.data;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function feishuPut(path: string, body: any): Promise<any> {
|
|
131
|
+
const token = await getTenantToken();
|
|
132
|
+
const base = getBaseUrl(_credentials?.domain);
|
|
133
|
+
const res = await fetch(`${base}${path}`, {
|
|
134
|
+
method: 'PUT',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
const data = await res.json() as any;
|
|
142
|
+
if (data.code !== 0) throw new Error(data.msg || `API error ${data.code}`);
|
|
143
|
+
return data.data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================
|
|
147
|
+
// Document API
|
|
148
|
+
// ============================================================
|
|
149
|
+
|
|
150
|
+
async function docRead(docToken: string) {
|
|
151
|
+
const [content, info, blocks] = await Promise.all([
|
|
152
|
+
feishuGet(`/open-apis/docx/v1/documents/${docToken}/raw_content`),
|
|
153
|
+
feishuGet(`/open-apis/docx/v1/documents/${docToken}`),
|
|
154
|
+
feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks`),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const BLOCK_TYPE_NAMES: Record<number, string> = {
|
|
158
|
+
1: 'Page', 2: 'Text', 3: 'Heading1', 4: 'Heading2', 5: 'Heading3',
|
|
159
|
+
12: 'Bullet', 13: 'Ordered', 14: 'Code', 15: 'Quote', 17: 'Todo',
|
|
160
|
+
18: 'Bitable', 22: 'Divider', 27: 'Image', 31: 'Table',
|
|
161
|
+
};
|
|
162
|
+
const blockItems = blocks?.items || [];
|
|
163
|
+
const blockCounts: Record<string, number> = {};
|
|
164
|
+
for (const b of blockItems) {
|
|
165
|
+
const name = BLOCK_TYPE_NAMES[b.block_type] || `type_${b.block_type}`;
|
|
166
|
+
blockCounts[name] = (blockCounts[name] || 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
title: info?.document?.title,
|
|
171
|
+
content: content?.content,
|
|
172
|
+
revision_id: info?.document?.revision_id,
|
|
173
|
+
block_count: blockItems.length,
|
|
174
|
+
block_types: blockCounts,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function docCreate(title: string, folderToken?: string) {
|
|
179
|
+
const data = await feishuPost('/open-apis/docx/v1/documents', {
|
|
180
|
+
title,
|
|
181
|
+
folder_token: folderToken,
|
|
182
|
+
});
|
|
183
|
+
const doc = data?.document;
|
|
184
|
+
return {
|
|
185
|
+
document_id: doc?.document_id,
|
|
186
|
+
title: doc?.title,
|
|
187
|
+
url: `https://feishu.cn/docx/${doc?.document_id}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function docWrite(docToken: string, markdown: string) {
|
|
192
|
+
// 1. 转换 markdown 为 blocks
|
|
193
|
+
const converted = await feishuPost('/open-apis/docx/v1/documents/convert', {
|
|
194
|
+
content_type: 'markdown',
|
|
195
|
+
content: markdown,
|
|
196
|
+
});
|
|
197
|
+
const blocks = converted?.blocks || [];
|
|
198
|
+
|
|
199
|
+
// 2. 清除现有内容
|
|
200
|
+
const existing = await feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks`);
|
|
201
|
+
const childIds = (existing?.items || [])
|
|
202
|
+
.filter((b: any) => b.parent_id === docToken && b.block_type !== 1)
|
|
203
|
+
.map((b: any) => b.block_id);
|
|
204
|
+
|
|
205
|
+
if (childIds.length > 0) {
|
|
206
|
+
await feishuDelete(`/open-apis/docx/v1/documents/${docToken}/blocks/${docToken}/children`, {
|
|
207
|
+
start_index: '0',
|
|
208
|
+
end_index: String(childIds.length),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 3. 插入新内容
|
|
213
|
+
if (blocks.length === 0) {
|
|
214
|
+
return { success: true, blocks_deleted: childIds.length, blocks_added: 0 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const inserted = await feishuPost(
|
|
218
|
+
`/open-apis/docx/v1/documents/${docToken}/blocks/${docToken}/children`,
|
|
219
|
+
{ children: blocks },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
success: true,
|
|
224
|
+
blocks_deleted: childIds.length,
|
|
225
|
+
blocks_added: inserted?.children?.length || 0,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function docAppend(docToken: string, markdown: string) {
|
|
230
|
+
const converted = await feishuPost('/open-apis/docx/v1/documents/convert', {
|
|
231
|
+
content_type: 'markdown',
|
|
232
|
+
content: markdown,
|
|
233
|
+
});
|
|
234
|
+
const blocks = converted?.blocks || [];
|
|
235
|
+
if (blocks.length === 0) throw new Error('Content is empty');
|
|
236
|
+
|
|
237
|
+
const inserted = await feishuPost(
|
|
238
|
+
`/open-apis/docx/v1/documents/${docToken}/blocks/${docToken}/children`,
|
|
239
|
+
{ children: blocks },
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
success: true,
|
|
244
|
+
blocks_added: inserted?.children?.length || 0,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function docListBlocks(docToken: string) {
|
|
249
|
+
return await feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function docGetBlock(docToken: string, blockId: string) {
|
|
253
|
+
return await feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks/${blockId}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function docUpdateBlock(docToken: string, blockId: string, content: string) {
|
|
257
|
+
await feishuPatch(`/open-apis/docx/v1/documents/${docToken}/blocks/${blockId}`, {
|
|
258
|
+
update_text_elements: {
|
|
259
|
+
elements: [{ text_run: { content } }],
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
return { success: true, block_id: blockId };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function docDeleteBlock(docToken: string, blockId: string) {
|
|
266
|
+
const blockInfo = await feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks/${blockId}`);
|
|
267
|
+
const parentId = blockInfo?.block?.parent_id || docToken;
|
|
268
|
+
const children = await feishuGet(`/open-apis/docx/v1/documents/${docToken}/blocks/${parentId}/children`);
|
|
269
|
+
const items = children?.items || [];
|
|
270
|
+
const index = items.findIndex((item: any) => item.block_id === blockId);
|
|
271
|
+
if (index === -1) throw new Error('Block not found');
|
|
272
|
+
|
|
273
|
+
await feishuDelete(`/open-apis/docx/v1/documents/${docToken}/blocks/${parentId}/children`, {
|
|
274
|
+
start_index: String(index),
|
|
275
|
+
end_index: String(index + 1),
|
|
276
|
+
});
|
|
277
|
+
return { success: true, deleted_block_id: blockId };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================
|
|
281
|
+
// Wiki API
|
|
282
|
+
// ============================================================
|
|
283
|
+
|
|
284
|
+
async function wikiSpaces() {
|
|
285
|
+
const data = await feishuGet('/open-apis/wiki/v2/spaces');
|
|
286
|
+
return {
|
|
287
|
+
spaces: (data?.items || []).map((s: any) => ({
|
|
288
|
+
space_id: s.space_id, name: s.name, description: s.description, visibility: s.visibility,
|
|
289
|
+
})),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function wikiNodes(spaceId: string, parentNodeToken?: string) {
|
|
294
|
+
const params: Record<string, string> = {};
|
|
295
|
+
if (parentNodeToken) params.parent_node_token = parentNodeToken;
|
|
296
|
+
const data = await feishuGet(`/open-apis/wiki/v2/spaces/${spaceId}/nodes`, params);
|
|
297
|
+
return {
|
|
298
|
+
nodes: (data?.items || []).map((n: any) => ({
|
|
299
|
+
node_token: n.node_token, obj_token: n.obj_token, obj_type: n.obj_type,
|
|
300
|
+
title: n.title, has_child: n.has_child,
|
|
301
|
+
})),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function wikiGet(token: string) {
|
|
306
|
+
const data = await feishuGet('/open-apis/wiki/v2/spaces/get_node', { token });
|
|
307
|
+
const node = data?.node;
|
|
308
|
+
return {
|
|
309
|
+
node_token: node?.node_token, space_id: node?.space_id, obj_token: node?.obj_token,
|
|
310
|
+
obj_type: node?.obj_type, title: node?.title, parent_node_token: node?.parent_node_token,
|
|
311
|
+
has_child: node?.has_child,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function wikiCreate(spaceId: string, title: string, objType?: string, parentNodeToken?: string) {
|
|
316
|
+
const data = await feishuPost(`/open-apis/wiki/v2/spaces/${spaceId}/nodes`, {
|
|
317
|
+
obj_type: objType || 'docx', node_type: 'origin', title, parent_node_token: parentNodeToken,
|
|
318
|
+
});
|
|
319
|
+
const node = data?.node;
|
|
320
|
+
return { node_token: node?.node_token, obj_token: node?.obj_token, title: node?.title };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ============================================================
|
|
324
|
+
// Drive API
|
|
325
|
+
// ============================================================
|
|
326
|
+
|
|
327
|
+
async function driveList(folderToken?: string) {
|
|
328
|
+
const params: Record<string, string> = {};
|
|
329
|
+
if (folderToken && folderToken !== '0') params.folder_token = folderToken;
|
|
330
|
+
const data = await feishuGet('/open-apis/drive/v1/files', params);
|
|
331
|
+
return {
|
|
332
|
+
files: (data?.files || []).map((f: any) => ({
|
|
333
|
+
token: f.token, name: f.name, type: f.type, url: f.url,
|
|
334
|
+
created_time: f.created_time, modified_time: f.modified_time,
|
|
335
|
+
})),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function driveCreateFolder(name: string, folderToken?: string) {
|
|
340
|
+
const data = await feishuPost('/open-apis/drive/v1/files/create_folder', {
|
|
341
|
+
name, folder_token: folderToken || '0',
|
|
342
|
+
});
|
|
343
|
+
return { token: data?.token, url: data?.url };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function driveMove(fileToken: string, type: string, folderToken: string) {
|
|
347
|
+
const data = await feishuPost(`/open-apis/drive/v1/files/${fileToken}/move`, {
|
|
348
|
+
type, folder_token: folderToken,
|
|
349
|
+
});
|
|
350
|
+
return { success: true, task_id: data?.task_id };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function driveDelete(fileToken: string, type: string) {
|
|
354
|
+
const data = await feishuDelete(`/open-apis/drive/v1/files/${fileToken}`, { type });
|
|
355
|
+
return { success: true, task_id: data?.task_id };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ============================================================
|
|
359
|
+
// Permission API
|
|
360
|
+
// ============================================================
|
|
361
|
+
|
|
362
|
+
async function permList(token: string, type: string) {
|
|
363
|
+
const data = await feishuGet(`/open-apis/drive/v1/permissions/${token}/members`, { type });
|
|
364
|
+
return {
|
|
365
|
+
members: (data?.items || []).map((m: any) => ({
|
|
366
|
+
member_type: m.member_type, member_id: m.member_id, perm: m.perm, name: m.name,
|
|
367
|
+
})),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function permAdd(token: string, type: string, memberType: string, memberId: string, perm: string) {
|
|
372
|
+
const data = await feishuPost(`/open-apis/drive/v1/permissions/${token}/members?type=${type}&need_notification=false`, {
|
|
373
|
+
member_type: memberType, member_id: memberId, perm,
|
|
374
|
+
});
|
|
375
|
+
return { success: true, member: data?.member };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function permRemove(token: string, type: string, memberType: string, memberId: string) {
|
|
379
|
+
await feishuDelete(`/open-apis/drive/v1/permissions/${token}/members/${memberId}`, {
|
|
380
|
+
type, member_type: memberType,
|
|
381
|
+
});
|
|
382
|
+
return { success: true };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============================================================
|
|
386
|
+
// App Scopes API
|
|
387
|
+
// ============================================================
|
|
388
|
+
|
|
389
|
+
async function appScopes() {
|
|
390
|
+
const data = await feishuGet('/open-apis/application/v6/scopes');
|
|
391
|
+
const scopes = data?.scopes || [];
|
|
392
|
+
const granted = scopes.filter((s: any) => s.grant_status === 1);
|
|
393
|
+
const pending = scopes.filter((s: any) => s.grant_status !== 1);
|
|
394
|
+
return {
|
|
395
|
+
granted: granted.map((s: any) => ({ name: s.scope_name, type: s.scope_type })),
|
|
396
|
+
pending: pending.map((s: any) => ({ name: s.scope_name, type: s.scope_type })),
|
|
397
|
+
summary: `${granted.length} granted, ${pending.length} pending`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============================================================
|
|
402
|
+
// Tool Registration
|
|
403
|
+
// ============================================================
|
|
404
|
+
|
|
405
|
+
function json(data: unknown) {
|
|
406
|
+
return {
|
|
407
|
+
content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
|
|
408
|
+
details: data,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let _registered = false;
|
|
413
|
+
|
|
414
|
+
export function isFeishuToolsRegistered(): boolean {
|
|
415
|
+
return _registered;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function registerFeishuTools(api: any, logger: any): boolean {
|
|
419
|
+
if (_registered) {
|
|
420
|
+
logger.info('[feishu-tools] 已注册,跳过');
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!hasCredentials()) {
|
|
425
|
+
logger.info('[feishu-tools] 无飞书凭据,跳过');
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 检测 OpenClaw 飞书插件是否已启用
|
|
430
|
+
// 只有飞书插件 enabled 且有配置时才跳过;disabled 或不存在时正常注册
|
|
431
|
+
try {
|
|
432
|
+
const config = api.config;
|
|
433
|
+
const entries = config?.plugins?.entries || {};
|
|
434
|
+
const feishuEntry = entries['feishu'] as any;
|
|
435
|
+
// 飞书插件存在且未明确 disable → 说明它会注册工具,我们跳过
|
|
436
|
+
if (feishuEntry && feishuEntry.enabled !== false) {
|
|
437
|
+
// 还要确认它有实际的飞书账号配置(有 appId),否则它也不会注册工具
|
|
438
|
+
const accounts = feishuEntry.config?.accounts || feishuEntry.accounts;
|
|
439
|
+
const hasAccount = Array.isArray(accounts)
|
|
440
|
+
? accounts.some((a: any) => a.appId || a.config?.appId)
|
|
441
|
+
: (feishuEntry.config?.appId || feishuEntry.appId);
|
|
442
|
+
if (hasAccount) {
|
|
443
|
+
logger.info('[feishu-tools] 检测到 OpenClaw 飞书插件已启用,跳过注册');
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch {}
|
|
448
|
+
|
|
449
|
+
// ---- feishu_doc ----
|
|
450
|
+
api.registerTool({
|
|
451
|
+
name: 'feishu_doc',
|
|
452
|
+
label: 'Feishu Doc',
|
|
453
|
+
description: 'Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block',
|
|
454
|
+
parameters: {
|
|
455
|
+
type: 'object',
|
|
456
|
+
properties: {
|
|
457
|
+
action: { type: 'string', enum: ['read', 'write', 'append', 'create', 'list_blocks', 'get_block', 'update_block', 'delete_block'], description: 'Action to perform' },
|
|
458
|
+
doc_token: { type: 'string', description: 'Document token (from URL /docx/XXX)' },
|
|
459
|
+
content: { type: 'string', description: 'Markdown content (for write/append/update_block)' },
|
|
460
|
+
title: { type: 'string', description: 'Document title (for create)' },
|
|
461
|
+
folder_token: { type: 'string', description: 'Target folder token (optional)' },
|
|
462
|
+
block_id: { type: 'string', description: 'Block ID (for get_block/update_block/delete_block)' },
|
|
463
|
+
},
|
|
464
|
+
required: ['action'],
|
|
465
|
+
},
|
|
466
|
+
async execute(_id: string, p: any) {
|
|
467
|
+
try {
|
|
468
|
+
switch (p.action) {
|
|
469
|
+
case 'read': return json(await docRead(p.doc_token));
|
|
470
|
+
case 'write': return json(await docWrite(p.doc_token, p.content));
|
|
471
|
+
case 'append': return json(await docAppend(p.doc_token, p.content));
|
|
472
|
+
case 'create': return json(await docCreate(p.title, p.folder_token));
|
|
473
|
+
case 'list_blocks': return json(await docListBlocks(p.doc_token));
|
|
474
|
+
case 'get_block': return json(await docGetBlock(p.doc_token, p.block_id));
|
|
475
|
+
case 'update_block': return json(await docUpdateBlock(p.doc_token, p.block_id, p.content));
|
|
476
|
+
case 'delete_block': return json(await docDeleteBlock(p.doc_token, p.block_id));
|
|
477
|
+
default: return json({ error: `Unknown action: ${p.action}` });
|
|
478
|
+
}
|
|
479
|
+
} catch (err: any) { return json({ error: err.message }); }
|
|
480
|
+
},
|
|
481
|
+
}, { name: 'feishu_doc' });
|
|
482
|
+
|
|
483
|
+
// ---- feishu_wiki ----
|
|
484
|
+
api.registerTool({
|
|
485
|
+
name: 'feishu_wiki',
|
|
486
|
+
label: 'Feishu Wiki',
|
|
487
|
+
description: 'Feishu knowledge base operations. Actions: spaces, nodes, get, create',
|
|
488
|
+
parameters: {
|
|
489
|
+
type: 'object',
|
|
490
|
+
properties: {
|
|
491
|
+
action: { type: 'string', enum: ['spaces', 'nodes', 'get', 'create'], description: 'Action' },
|
|
492
|
+
space_id: { type: 'string', description: 'Knowledge space ID' },
|
|
493
|
+
token: { type: 'string', description: 'Wiki node token (from URL /wiki/XXX)' },
|
|
494
|
+
title: { type: 'string', description: 'Node title' },
|
|
495
|
+
obj_type: { type: 'string', description: 'Object type (default: docx)' },
|
|
496
|
+
parent_node_token: { type: 'string', description: 'Parent node token' },
|
|
497
|
+
},
|
|
498
|
+
required: ['action'],
|
|
499
|
+
},
|
|
500
|
+
async execute(_id: string, p: any) {
|
|
501
|
+
try {
|
|
502
|
+
switch (p.action) {
|
|
503
|
+
case 'spaces': return json(await wikiSpaces());
|
|
504
|
+
case 'nodes': return json(await wikiNodes(p.space_id, p.parent_node_token));
|
|
505
|
+
case 'get': return json(await wikiGet(p.token));
|
|
506
|
+
case 'create': return json(await wikiCreate(p.space_id, p.title, p.obj_type, p.parent_node_token));
|
|
507
|
+
default: return json({ error: `Unknown action: ${p.action}` });
|
|
508
|
+
}
|
|
509
|
+
} catch (err: any) { return json({ error: err.message }); }
|
|
510
|
+
},
|
|
511
|
+
}, { name: 'feishu_wiki' });
|
|
512
|
+
|
|
513
|
+
// ---- feishu_drive ----
|
|
514
|
+
api.registerTool({
|
|
515
|
+
name: 'feishu_drive',
|
|
516
|
+
label: 'Feishu Drive',
|
|
517
|
+
description: 'Feishu cloud storage operations. Actions: list, create_folder, move, delete',
|
|
518
|
+
parameters: {
|
|
519
|
+
type: 'object',
|
|
520
|
+
properties: {
|
|
521
|
+
action: { type: 'string', enum: ['list', 'create_folder', 'move', 'delete'], description: 'Action' },
|
|
522
|
+
folder_token: { type: 'string', description: 'Folder token' },
|
|
523
|
+
file_token: { type: 'string', description: 'File token' },
|
|
524
|
+
name: { type: 'string', description: 'Folder name (for create_folder)' },
|
|
525
|
+
type: { type: 'string', description: 'File type (doc/docx/sheet/bitable/folder/file)' },
|
|
526
|
+
},
|
|
527
|
+
required: ['action'],
|
|
528
|
+
},
|
|
529
|
+
async execute(_id: string, p: any) {
|
|
530
|
+
try {
|
|
531
|
+
switch (p.action) {
|
|
532
|
+
case 'list': return json(await driveList(p.folder_token));
|
|
533
|
+
case 'create_folder': return json(await driveCreateFolder(p.name, p.folder_token));
|
|
534
|
+
case 'move': return json(await driveMove(p.file_token, p.type, p.folder_token));
|
|
535
|
+
case 'delete': return json(await driveDelete(p.file_token, p.type));
|
|
536
|
+
default: return json({ error: `Unknown action: ${p.action}` });
|
|
537
|
+
}
|
|
538
|
+
} catch (err: any) { return json({ error: err.message }); }
|
|
539
|
+
},
|
|
540
|
+
}, { name: 'feishu_drive' });
|
|
541
|
+
|
|
542
|
+
// ---- feishu_perm ----
|
|
543
|
+
api.registerTool({
|
|
544
|
+
name: 'feishu_perm',
|
|
545
|
+
label: 'Feishu Perm',
|
|
546
|
+
description: 'Feishu permission management. Actions: list, add, remove',
|
|
547
|
+
parameters: {
|
|
548
|
+
type: 'object',
|
|
549
|
+
properties: {
|
|
550
|
+
action: { type: 'string', enum: ['list', 'add', 'remove'], description: 'Action' },
|
|
551
|
+
token: { type: 'string', description: 'File token' },
|
|
552
|
+
type: { type: 'string', description: 'Token type (doc/docx/sheet/bitable/folder/file/wiki)' },
|
|
553
|
+
member_type: { type: 'string', description: 'Member type (email/openid/userid)' },
|
|
554
|
+
member_id: { type: 'string', description: 'Member ID' },
|
|
555
|
+
perm: { type: 'string', description: 'Permission (view/edit/full_access)' },
|
|
556
|
+
},
|
|
557
|
+
required: ['action'],
|
|
558
|
+
},
|
|
559
|
+
async execute(_id: string, p: any) {
|
|
560
|
+
try {
|
|
561
|
+
switch (p.action) {
|
|
562
|
+
case 'list': return json(await permList(p.token, p.type));
|
|
563
|
+
case 'add': return json(await permAdd(p.token, p.type, p.member_type, p.member_id, p.perm));
|
|
564
|
+
case 'remove': return json(await permRemove(p.token, p.type, p.member_type, p.member_id));
|
|
565
|
+
default: return json({ error: `Unknown action: ${p.action}` });
|
|
566
|
+
}
|
|
567
|
+
} catch (err: any) { return json({ error: err.message }); }
|
|
568
|
+
},
|
|
569
|
+
}, { name: 'feishu_perm' });
|
|
570
|
+
|
|
571
|
+
// ---- feishu_app_scopes ----
|
|
572
|
+
api.registerTool({
|
|
573
|
+
name: 'feishu_app_scopes',
|
|
574
|
+
label: 'Feishu App Scopes',
|
|
575
|
+
description: 'List current Feishu app permissions (scopes).',
|
|
576
|
+
parameters: { type: 'object', properties: {} },
|
|
577
|
+
async execute() {
|
|
578
|
+
try { return json(await appScopes()); }
|
|
579
|
+
catch (err: any) { return json({ error: err.message }); }
|
|
580
|
+
},
|
|
581
|
+
}, { name: 'feishu_app_scopes' });
|
|
582
|
+
|
|
583
|
+
_registered = true;
|
|
584
|
+
logger.info('[feishu-tools] ✅ 已注册 5 个飞书工具(feishu_doc/wiki/drive/perm/app_scopes)');
|
|
585
|
+
return true;
|
|
586
|
+
}
|
package/src/hub-client.ts
CHANGED
|
@@ -164,6 +164,27 @@ export class HubClient {
|
|
|
164
164
|
return res.json();
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
async httpPut(path: string, body: any): Promise<any> {
|
|
168
|
+
const url = `${this.config.hubUrl}${path}`;
|
|
169
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
170
|
+
if (this.config.token) {
|
|
171
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
172
|
+
}
|
|
173
|
+
if (this.config.adminKey) {
|
|
174
|
+
headers['X-Admin-Key'] = this.config.adminKey;
|
|
175
|
+
}
|
|
176
|
+
const res = await fetch(url, {
|
|
177
|
+
method: 'PUT',
|
|
178
|
+
headers,
|
|
179
|
+
body: JSON.stringify(body),
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const text = await res.text();
|
|
183
|
+
throw new Error(`Hub HTTP ${res.status}: ${text}`);
|
|
184
|
+
}
|
|
185
|
+
return res.json();
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
private async httpDelete(path: string): Promise<any> {
|
|
168
189
|
const url = `${this.config.hubUrl}${path}`;
|
|
169
190
|
const headers: Record<string, string> = {};
|
|
@@ -515,9 +536,21 @@ export class HubClient {
|
|
|
515
536
|
pending.resolve(result);
|
|
516
537
|
}
|
|
517
538
|
|
|
539
|
+
// 共享配置回调
|
|
540
|
+
public onSharedConfig?: (config: any) => void;
|
|
541
|
+
|
|
518
542
|
private handleDirect(msg: WSMessage): void {
|
|
519
|
-
if (msg.payload?.action === 'connected') {
|
|
543
|
+
if (msg.payload?.action === 'connected' || msg.payload?.event === 'connected') {
|
|
520
544
|
this.logger.info(`[cluster-hub] 连接确认: nodeId=${msg.payload.nodeId}`);
|
|
545
|
+
// 连接时附带的共享配置
|
|
546
|
+
if (msg.payload?.sharedConfig && this.onSharedConfig) {
|
|
547
|
+
this.onSharedConfig(msg.payload.sharedConfig);
|
|
548
|
+
}
|
|
549
|
+
} else if (msg.payload?.event === 'shared_config') {
|
|
550
|
+
// Hub 推送的配置更新
|
|
551
|
+
if (msg.payload?.config && this.onSharedConfig) {
|
|
552
|
+
this.onSharedConfig(msg.payload.config);
|
|
553
|
+
}
|
|
521
554
|
}
|
|
522
555
|
}
|
|
523
556
|
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import fs from 'fs';
|
|
18
18
|
import { HubClient } from './hub-client.js';
|
|
19
19
|
import { TaskStore, ChatStore, NodeEventStore } from './store.js';
|
|
20
|
+
import { setCredentials, registerFeishuTools, hasCredentials } from './feishu-tools.js';
|
|
20
21
|
import type {
|
|
21
22
|
HubPluginConfig, DEFAULT_CONFIG, ResultPayload, WSMessage,
|
|
22
23
|
QueuedTask, ChatConfig, StoredTask, StoredChatMessage, StoredNodeEvent,
|
|
@@ -667,6 +668,15 @@ const plugin = {
|
|
|
667
668
|
handleNodeEvent('node_offline', { nodeId });
|
|
668
669
|
};
|
|
669
670
|
|
|
671
|
+
// Hub 下发共享配置 → 注册飞书工具
|
|
672
|
+
client.onSharedConfig = (config: any) => {
|
|
673
|
+
api.logger.info(`[cluster-hub] 收到共享配置: ${JSON.stringify(Object.keys(config))}`);
|
|
674
|
+
if (config.feishu?.appId && config.feishu?.appSecret) {
|
|
675
|
+
setCredentials(config.feishu);
|
|
676
|
+
registerFeishuTools(api, api.logger);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
|
|
670
680
|
// ------------------------------------------------------------------
|
|
671
681
|
// Gateway RPC 方法 — 每个 handler 都捕获 broadcast 引用
|
|
672
682
|
// ------------------------------------------------------------------
|
|
@@ -840,6 +850,32 @@ const plugin = {
|
|
|
840
850
|
}
|
|
841
851
|
});
|
|
842
852
|
|
|
853
|
+
// hub.shared-config.get — 获取共享配置
|
|
854
|
+
api.registerGatewayMethod('hub.shared-config.get', async ({ context, respond }: any) => {
|
|
855
|
+
captureBroadcast(context);
|
|
856
|
+
try {
|
|
857
|
+
const clusterId = client.getConfig().clusterId;
|
|
858
|
+
if (!clusterId) { respond(false, { message: '未注册' }); return; }
|
|
859
|
+
const data = await client.httpGet(`/api/clusters/${clusterId}/shared-config`);
|
|
860
|
+
respond(true, data.data || data);
|
|
861
|
+
} catch (err: any) {
|
|
862
|
+
respond(false, { message: err.message });
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// hub.shared-config.set — 设置共享配置(仅根节点)
|
|
867
|
+
api.registerGatewayMethod('hub.shared-config.set', async ({ context, respond, params }: any) => {
|
|
868
|
+
captureBroadcast(context);
|
|
869
|
+
try {
|
|
870
|
+
const clusterId = client.getConfig().clusterId;
|
|
871
|
+
if (!clusterId) { respond(false, { message: '未注册' }); return; }
|
|
872
|
+
const data = await client.httpPut(`/api/clusters/${clusterId}/shared-config`, params || {});
|
|
873
|
+
respond(true, data.data || data);
|
|
874
|
+
} catch (err: any) {
|
|
875
|
+
respond(false, { message: err.message });
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
843
879
|
// hub.unregister — 注销节点
|
|
844
880
|
api.registerGatewayMethod('hub.unregister', async ({ context, respond, params }: any) => {
|
|
845
881
|
captureBroadcast(context);
|
|
@@ -1582,12 +1618,14 @@ const plugin = {
|
|
|
1582
1618
|
.option('--name <name>', '节点名称')
|
|
1583
1619
|
.option('--alias <alias>', '节点别名')
|
|
1584
1620
|
.option('--parent <parentId>', '父节点 ID')
|
|
1621
|
+
.option('--invite <code>', '邀请码(加入已有集群时需要)')
|
|
1585
1622
|
.action(async (opts: any) => {
|
|
1586
1623
|
try {
|
|
1587
1624
|
const result = await client.register({
|
|
1588
1625
|
name: opts.name || client.getConfig().nodeName || 'OpenClaw Node',
|
|
1589
1626
|
alias: opts.alias || client.getConfig().nodeAlias || `node-${Date.now()}`,
|
|
1590
1627
|
parentId: opts.parent || null,
|
|
1628
|
+
inviteCode: opts.invite || undefined,
|
|
1591
1629
|
capabilities: client.getConfig().capabilities,
|
|
1592
1630
|
});
|
|
1593
1631
|
await persistConfig();
|
|
@@ -1658,6 +1696,36 @@ const plugin = {
|
|
|
1658
1696
|
client.disconnect();
|
|
1659
1697
|
console.log('✅ 已断开');
|
|
1660
1698
|
});
|
|
1699
|
+
|
|
1700
|
+
hub.command('invite')
|
|
1701
|
+
.description('查看或生成邀请码')
|
|
1702
|
+
.option('--new', '生成新邀请码')
|
|
1703
|
+
.option('--node <nodeId>', '指定节点 ID')
|
|
1704
|
+
.action(async (opts: any) => {
|
|
1705
|
+
const nodeId = opts.node || client.getConfig().nodeId;
|
|
1706
|
+
if (!nodeId) { console.error('❌ 没有节点 ID'); return; }
|
|
1707
|
+
try {
|
|
1708
|
+
if (opts.new) {
|
|
1709
|
+
const data = await client.httpPost(`/api/nodes/${nodeId}/invite-code`, {});
|
|
1710
|
+
const code = data.data?.inviteCode || data.inviteCode;
|
|
1711
|
+
console.log(`✅ 新邀请码: ${code}`);
|
|
1712
|
+
console.log(`\n子节点加入命令:`);
|
|
1713
|
+
console.log(` openclaw hub register --parent ${nodeId} --invite ${code} --name "节点名" --alias "别名"`);
|
|
1714
|
+
} else {
|
|
1715
|
+
const data = await client.httpGet(`/api/nodes/${nodeId}/invite-code`);
|
|
1716
|
+
const code = data.data?.inviteCode || data.inviteCode;
|
|
1717
|
+
if (code) {
|
|
1718
|
+
console.log(`📋 当前邀请码: ${code}`);
|
|
1719
|
+
console.log(`\n子节点加入命令:`);
|
|
1720
|
+
console.log(` openclaw hub register --parent ${nodeId} --invite ${code} --name "节点名" --alias "别名"`);
|
|
1721
|
+
} else {
|
|
1722
|
+
console.log('暂无邀请码,使用 openclaw hub invite --new 生成');
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
} catch (err: any) {
|
|
1726
|
+
console.error(`❌ 失败: ${err.message}`);
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1661
1729
|
}, { commands: ['hub'] });
|
|
1662
1730
|
|
|
1663
1731
|
// ------------------------------------------------------------------
|