Channels: add Feishu/Lark support

This commit is contained in:
Josh Palmer
2026-02-03 14:27:13 -08:00
parent 4027b3583e
commit 2483f26c23
14 changed files with 1966 additions and 0 deletions

6
.github/labeler.yml vendored
View File

@@ -9,6 +9,12 @@
- "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: feishu":
- changed-files:
- any-glob-to-any-file:
- "src/feishu/**"
- "extensions/feishu/**"
- "docs/channels/feishu.md"
"channel: googlechat":
- changed-files:
- any-glob-to-any-file:

507
docs/channels/feishu.md Normal file
View File

@@ -0,0 +1,507 @@
---
summary: "Feishu bot support status, features, and configuration"
read_when:
- You want to connect a Feishu/Lark bot
- You are configuring the Feishu channel
title: Feishu
---
# Feishu bot
Status: production-ready, supports bot DMs and group chats. Uses WebSocket long connection mode to receive events.
---
## Plugin required
Install the Feishu plugin:
```bash
openclaw plugins install @openclaw/feishu
```
Local checkout (when running from a git repo):
```bash
openclaw plugins install ./extensions/feishu
```
---
## Quickstart
There are two ways to add the Feishu channel:
### Method 1: onboarding wizard (recommended)
If you just installed OpenClaw, run the wizard:
```bash
openclaw onboard
```
The wizard guides you through:
1. Creating a Feishu app and collecting credentials
2. Configuring app credentials in OpenClaw
3. Starting the gateway
**After configuration**, check gateway status:
- `openclaw gateway status`
- `openclaw logs --follow`
### Method 2: CLI setup
If you already completed initial install, add the channel via CLI:
```bash
openclaw channels add
```
Choose **Feishu**, then enter the App ID and App Secret.
**After configuration**, manage the gateway:
- `openclaw gateway status`
- `openclaw gateway restart`
- `openclaw logs --follow`
---
## Step 1: Create a Feishu app
### 1. Open Feishu Open Platform
Visit [Feishu Open Platform](https://open.feishu.cn/app) and sign in.
Lark (global) tenants should use https://open.larksuite.com/app and set `domain: "lark"` in the Feishu config.
### 2. Create an app
1. Click **Create enterprise app**
2. Fill in the app name + description
3. Choose an app icon
![Create enterprise app](../images/feishu-step2-create-app.png)
### 3. Copy credentials
From **Credentials & Basic Info**, copy:
- **App ID** (format: `cli_xxx`)
- **App Secret**
**Important:** keep the App Secret private.
![Get credentials](../images/feishu-step3-credentials.png)
### 4. Configure permissions
On **Permissions**, click **Batch import** and paste:
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"]
}
}
```
![Configure permissions](../images/feishu-step4-permissions.png)
### 5. Enable bot capability
In **App Capability** > **Bot**:
1. Enable bot capability
2. Set the bot name
![Enable bot capability](../images/feishu-step5-bot-capability.png)
### 6. Configure event subscription
⚠️ **Important:** before setting event subscription, make sure:
1. You already ran `openclaw channels add` for Feishu
2. The gateway is running (`openclaw gateway status`)
In **Event Subscription**:
1. Choose **Use long connection to receive events** (WebSocket)
2. Add the event: `im.message.receive_v1`
⚠️ If the gateway is not running, the long-connection setup may fail to save.
![Configure event subscription](../images/feishu-step6-event-subscription.png)
### 7. Publish the app
1. Create a version in **Version Management & Release**
2. Submit for review and publish
3. Wait for admin approval (enterprise apps usually auto-approve)
---
## Step 2: Configure OpenClaw
### Configure with the wizard (recommended)
```bash
openclaw channels add
```
Choose **Feishu** and paste your App ID + App Secret.
### Configure via config file
Edit `~/.openclaw/openclaw.json`:
```json5
{
channels: {
feishu: {
enabled: true,
dmPolicy: "pairing",
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "My AI assistant",
},
},
},
},
}
```
### Configure via environment variables
```bash
export FEISHU_APP_ID="cli_xxx"
export FEISHU_APP_SECRET="xxx"
```
### Lark (global) domain
If your tenant is on Lark (international), set the domain to `lark` (or a full domain string). You can set it at `channels.feishu.domain` or per account (`channels.feishu.accounts.<id>.domain`).
```json5
{
channels: {
feishu: {
domain: "lark",
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
},
},
},
},
}
```
---
## Step 3: Start + test
### 1. Start the gateway
```bash
openclaw gateway
```
### 2. Send a test message
In Feishu, find your bot and send a message.
### 3. Approve pairing
By default, the bot replies with a pairing code. Approve it:
```bash
openclaw pairing approve feishu <CODE>
```
After approval, you can chat normally.
---
## Overview
- **Feishu bot channel**: Feishu bot managed by the gateway
- **Deterministic routing**: replies always return to Feishu
- **Session isolation**: DMs share a main session; groups are isolated
- **WebSocket connection**: long connection via Feishu SDK, no public URL needed
---
## Access control
### Direct messages
- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code)
- **Approve pairing**:
```bash
openclaw pairing list feishu
openclaw pairing approve feishu <CODE>
```
- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs
### Group chats
**1. Group policy** (`channels.feishu.groupPolicy`):
- `"open"` = allow everyone in groups (default)
- `"allowlist"` = only allow `groupAllowFrom`
- `"disabled"` = disable group messages
**2. Mention requirement** (`channels.feishu.groups.<chat_id>.requireMention`):
- `true` = require @mention (default)
- `false` = respond without mentions
---
## Group configuration examples
### Allow all groups, require @mention (default)
```json5
{
channels: {
feishu: {
groupPolicy: "open",
// Default requireMention: true
},
},
}
```
### Allow all groups, no @mention required
```json5
{
channels: {
feishu: {
groups: {
oc_xxx: { requireMention: false },
},
},
},
}
```
### Allow specific users in groups only
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
groupAllowFrom: ["ou_xxx", "ou_yyy"],
},
},
}
```
---
## Get group/user IDs
### Group IDs (chat_id)
Group IDs look like `oc_xxx`.
**Method 1 (recommended)**
1. Start the gateway and @mention the bot in the group
2. Run `openclaw logs --follow` and look for `chat_id`
**Method 2**
Use the Feishu API debugger to list group chats.
### User IDs (open_id)
User IDs look like `ou_xxx`.
**Method 1 (recommended)**
1. Start the gateway and DM the bot
2. Run `openclaw logs --follow` and look for `open_id`
**Method 2**
Check pairing requests for user Open IDs:
```bash
openclaw pairing list feishu
```
---
## Common commands
| Command | Description |
| --------- | ----------------- |
| `/status` | Show bot status |
| `/reset` | Reset the session |
| `/model` | Show/switch model |
> Note: Feishu does not support native command menus yet, so commands must be sent as text.
## Gateway management commands
| Command | Description |
| -------------------------- | ----------------------------- |
| `openclaw gateway status` | Show gateway status |
| `openclaw gateway install` | Install/start gateway service |
| `openclaw gateway stop` | Stop gateway service |
| `openclaw gateway restart` | Restart gateway service |
| `openclaw logs --follow` | Tail gateway logs |
---
## Troubleshooting
### Bot does not respond in group chats
1. Ensure the bot is added to the group
2. Ensure you @mention the bot (default behavior)
3. Check `groupPolicy` is not set to `"disabled"`
4. Check logs: `openclaw logs --follow`
### Bot does not receive messages
1. Ensure the app is published and approved
2. Ensure event subscription includes `im.message.receive_v1`
3. Ensure **long connection** is enabled
4. Ensure app permissions are complete
5. Ensure the gateway is running: `openclaw gateway status`
6. Check logs: `openclaw logs --follow`
### App Secret leak
1. Reset the App Secret in Feishu Open Platform
2. Update the App Secret in your config
3. Restart the gateway
### Message send failures
1. Ensure the app has `im:message:send_as_bot` permission
2. Ensure the app is published
3. Check logs for detailed errors
---
## Advanced configuration
### Multiple accounts
```json5
{
channels: {
feishu: {
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "Primary bot",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "Backup bot",
enabled: false,
},
},
},
},
}
```
### Message limits
- `textChunkLimit`: outbound text chunk size (default: 2000 chars)
- `mediaMaxMb`: media upload/download limit (default: 30MB)
### Streaming
Feishu does not support message editing, so block streaming is enabled by default (`blockStreaming: true`). The bot waits for the full reply before sending.
---
## Configuration reference
Full configuration: [Gateway configuration](/gateway/configuration)
Key options:
| Setting | Description | Default |
| ------------------------------------------------- | ------------------------------- | --------- |
| `channels.feishu.enabled` | Enable/disable channel | `true` |
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
| `channels.feishu.accounts.<id>.appId` | App ID | - |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
| `channels.feishu.groupPolicy` | Group policy | `open` |
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
| `channels.feishu.blockStreaming` | Disable streaming | `true` |
---
## dmPolicy reference
| Value | Behavior |
| ------------- | --------------------------------------------------------------- |
| `"pairing"` | **Default.** Unknown users get a pairing code; must be approved |
| `"allowlist"` | Only users in `allowFrom` can chat |
| `"open"` | Allow all users (requires `"*"` in allowFrom) |
| `"disabled"` | Disable DMs |
---
## Supported message types
### Receive
- ✅ Text
- ✅ Images
- ✅ Files
- ✅ Audio
- ✅ Video
- ✅ Stickers
### Send
- ✅ Text
- ✅ Images
- ✅ Files
- ✅ Audio
- ⚠️ Rich text (partial support)

View File

@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.

View File

@@ -772,6 +772,7 @@
"channels/grammy",
"channels/discord",
"channels/slack",
"channels/feishu",
"channels/googlechat",
"channels/mattermost",
"channels/signal",
@@ -1199,6 +1200,7 @@
"zh-CN/channels/grammy",
"zh-CN/channels/discord",
"zh-CN/channels/slack",
"zh-CN/channels/feishu",
"zh-CN/channels/googlechat",
"zh-CN/channels/mattermost",
"zh-CN/channels/signal",

View File

@@ -0,0 +1,513 @@
---
summary: "飞书机器人支持状态、功能和配置"
read_when:
- 您想要连接飞书机器人
- 您正在配置飞书渠道
title: 飞书
---
# 飞书机器人
状态:生产就绪,支持机器人私聊和群组。使用 WebSocket 长连接模式接收消息。
---
## 需要插件
安装 Feishu 插件:
```bash
openclaw plugins install @openclaw/feishu
```
本地 checkout在 git 仓库内运行):
```bash
openclaw plugins install ./extensions/feishu
```
---
## 快速开始
添加飞书渠道有两种方式:
### 方式一:通过安装向导添加(推荐)
如果您刚安装完 OpenClaw可以直接运行向导根据提示添加飞书
```bash
openclaw onboard
```
向导会引导您完成:
1. 创建飞书应用并获取凭证
2. 配置应用凭证
3. 启动网关
**完成配置后**,您可以使用以下命令检查网关状态:
- `openclaw gateway status` - 查看网关运行状态
- `openclaw logs --follow` - 查看实时日志
### 方式二:通过命令行添加
如果您已经完成了初始安装,可以用以下命令添加飞书渠道:
```bash
openclaw channels add
```
然后根据交互式提示选择 Feishu输入 App ID 和 App Secret 即可。
**完成配置后**,您可以使用以下命令管理网关:
- `openclaw gateway status` - 查看网关运行状态
- `openclaw gateway restart` - 重启网关以应用新配置
- `openclaw logs --follow` - 查看实时日志
---
## 第一步:创建飞书应用
### 1. 打开飞书开放平台
访问 [飞书开放平台](https://open.feishu.cn/app),使用飞书账号登录。
Lark国际版请使用 https://open.larksuite.com/app并在配置中设置 `domain: "lark"`
### 2. 创建应用
1. 点击 **创建企业自建应用**
2. 填写应用名称和描述
3. 选择应用图标
![创建企业自建应用](../images/feishu-step2-create-app.png)
### 3. 获取应用凭证
在应用的 **凭证与基础信息** 页面,复制:
- **App ID**(格式如 `cli_xxx`
- **App Secret**
**重要**:请妥善保管 App Secret不要分享给他人。
![获取应用凭证](../images/feishu-step3-credentials.png)
### 4. 配置应用权限
**权限管理** 页面,点击 **批量导入** 按钮,粘贴以下 JSON 配置一键导入所需权限:
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"]
}
}
```
![配置应用权限](../images/feishu-step4-permissions.png)
### 5. 启用机器人能力
**应用能力** > **机器人** 页面:
1. 开启机器人能力
2. 配置机器人名称
![启用机器人能力](../images/feishu-step5-bot-capability.png)
### 6. 配置事件订阅
⚠️ **重要提醒**:在配置事件订阅前,请务必确保已完成以下步骤:
1. 运行 `openclaw channels add` 添加了 Feishu 渠道
2. 网关处于启动状态(可通过 `openclaw gateway status` 检查状态)
**事件订阅** 页面:
1. 选择 **使用长连接接收事件**WebSocket 模式)
2. 添加事件:`im.message.receive_v1`(接收消息)
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
![配置事件订阅](../images/feishu-step6-event-subscription.png)
### 7. 发布应用
1.**版本管理与发布** 页面创建版本
2. 提交审核并发布
3. 等待管理员审批(企业自建应用通常自动通过)
---
## 第二步:配置 OpenClaw
### 通过向导配置(推荐)
运行以下命令,根据提示粘贴 App ID 和 App Secret
```bash
openclaw channels add
```
选择 **Feishu**,然后输入您在第一步获取的凭证即可。
### 通过配置文件配置
编辑 `~/.openclaw/openclaw.json`
```json5
{
channels: {
feishu: {
enabled: true,
dmPolicy: "pairing",
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "我的AI助手",
},
},
},
},
}
```
### 通过环境变量配置
```bash
export FEISHU_APP_ID="cli_xxx"
export FEISHU_APP_SECRET="xxx"
```
### Lark国际版域名
如果您的租户在 Lark国际版请设置域名为 `lark`(或完整域名),可配置 `channels.feishu.domain``channels.feishu.accounts.<id>.domain`
```json5
{
channels: {
feishu: {
domain: "lark",
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
},
},
},
},
}
```
---
## 第三步:启动并测试
### 1. 启动网关
```bash
openclaw gateway
```
### 2. 发送测试消息
在飞书中找到您创建的机器人,发送一条消息。
### 3. 配对授权
默认情况下,机器人会回复一个 **配对码**。您需要批准此代码:
```bash
openclaw pairing approve feishu <配对码>
```
批准后即可正常对话。
---
## 介绍
- **飞书机器人渠道**:由网关管理的飞书机器人
- **确定性路由**:回复始终返回飞书,模型不会选择渠道
- **会话隔离**:私聊共享主会话;群组独立隔离
- **WebSocket 连接**:使用飞书 SDK 的长连接模式,无需公网 URL
---
## 访问控制
### 私聊访问
- **默认**`dmPolicy: "pairing"`,陌生用户会收到配对码
- **批准配对**
```bash
openclaw pairing list feishu # 查看待审批列表
openclaw pairing approve feishu <CODE> # 批准
```
- **白名单模式**:通过 `channels.feishu.allowFrom` 配置允许的用户 Open ID
### 群组访问
**1. 群组策略**`channels.feishu.groupPolicy`
- `"open"` = 允许群组中所有人(默认)
- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户
- `"disabled"` = 禁用群组消息
**2. @提及要求**`channels.feishu.groups.<chat_id>.requireMention`
- `true` = 需要 @机器人才响应(默认)
- `false` = 无需 @也响应
---
## 群组配置示例
### 允许所有群组,需要 @提及(默认行为)
```json5
{
channels: {
feishu: {
groupPolicy: "open",
// 默认 requireMention: true
},
},
}
```
### 允许所有群组,无需 @提及
需要为特定群组配置:
```json5
{
channels: {
feishu: {
groups: {
oc_xxx: { requireMention: false },
},
},
},
}
```
### 仅允许特定用户在群组中使用
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
groupAllowFrom: ["ou_xxx", "ou_yyy"],
},
},
}
```
---
## 获取群组/用户 ID
### 获取群组 IDchat_id
群组 ID 格式为 `oc_xxx`,可以通过以下方式获取:
**方法一**(推荐):
1. 启动网关并在群组中 @机器人发消息
2. 运行 `openclaw logs --follow` 查看日志中的 `chat_id`
**方法二**
使用飞书 API 调试工具获取机器人所在群组列表。
### 获取用户 IDopen_id
用户 ID 格式为 `ou_xxx`,可以通过以下方式获取:
**方法一**(推荐):
1. 启动网关并给机器人发消息
2. 运行 `openclaw logs --follow` 查看日志中的 `open_id`
**方法二**
查看配对请求列表,其中包含用户的 Open ID
```bash
openclaw pairing list feishu
```
---
## 常用命令
| 命令 | 说明 |
| --------- | -------------- |
| `/status` | 查看机器人状态 |
| `/reset` | 重置对话会话 |
| `/model` | 查看/切换模型 |
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送。
## 网关管理命令
在配置和使用飞书渠道时,您可能需要使用以下网关管理命令:
| 命令 | 说明 |
| -------------------------- | ----------------- |
| `openclaw gateway status` | 查看网关运行状态 |
| `openclaw gateway install` | 安装/启动网关服务 |
| `openclaw gateway stop` | 停止网关服务 |
| `openclaw gateway restart` | 重启网关服务 |
| `openclaw logs --follow` | 实时查看日志输出 |
---
## 故障排除
### 机器人在群组中不响应
1. 检查机器人是否已添加到群组
2. 检查是否 @了机器人(默认需要 @提及)
3. 检查 `groupPolicy` 是否为 `"disabled"`
4. 查看日志:`openclaw logs --follow`
### 机器人收不到消息
1. 检查应用是否已发布并审批通过
2. 检查事件订阅是否配置正确(`im.message.receive_v1`
3. 检查是否选择了 **长连接** 模式
4. 检查应用权限是否完整
5. 检查网关是否正在运行:`openclaw gateway status`
6. 查看实时日志:`openclaw logs --follow`
### App Secret 泄露怎么办
1. 在飞书开放平台重置 App Secret
2. 更新配置文件中的 App Secret
3. 重启网关
### 发送消息失败
1. 检查应用是否有 `im:message:send_as_bot` 权限
2. 检查应用是否已发布
3. 查看日志获取详细错误信息
---
## 高级配置
### 多账号配置
如果需要管理多个飞书机器人:
```json5
{
channels: {
feishu: {
accounts: {
main: {
appId: "cli_xxx",
appSecret: "xxx",
botName: "主机器人",
},
backup: {
appId: "cli_yyy",
appSecret: "yyy",
botName: "备用机器人",
enabled: false, // 暂时禁用
},
},
},
},
}
```
### 消息限制
- `textChunkLimit`:出站文本分块大小(默认 2000 字符)
- `mediaMaxMb`:媒体上传/下载限制(默认 30MB
### 流式输出
飞书目前不支持消息编辑,因此默认禁用流式输出(`blockStreaming: true`)。机器人会等待完整回复后一次性发送。
---
## 配置参考
完整配置请参考:[网关配置](/gateway/configuration)
主要选项:
| 配置项 | 说明 | 默认值 |
| ------------------------------------------------- | ------------------------------ | --------- |
| `channels.feishu.enabled` | 启用/禁用渠道 | `true` |
| `channels.feishu.domain` | API 域名(`feishu` 或 `lark` | `feishu` |
| `channels.feishu.accounts.<id>.appId` | 应用 App ID | - |
| `channels.feishu.accounts.<id>.appSecret` | 应用 App Secret | - |
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.blockStreaming` | 禁用流式输出 | `true` |
---
## dmPolicy 策略说明
| 值 | 行为 |
| ------------- | -------------------------------------------------- |
| `"pairing"` | **默认**。未知用户收到配对码,管理员批准后才能对话 |
| `"allowlist"` | 仅 `allowFrom` 列表中的用户可对话,其他静默忽略 |
| `"open"` | 允许所有人对话(需在 allowFrom 中加 `"*"` |
| `"disabled"` | 完全禁止私聊 |
---
## 支持的消息类型
### 接收
- ✅ 文本消息
- ✅ 图片
- ✅ 文件
- ✅ 音频
- ✅ 视频
- ✅ 表情包
### 发送
- ✅ 文本消息
- ✅ 图片
- ✅ 文件
- ✅ 音频
- ⚠️ 富文本(部分支持)

View File

@@ -24,6 +24,7 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API支持群组。
- [Discord](/channels/discord) — Discord Bot API + Gateway支持服务器、频道和私信。
- [Slack](/channels/slack) — Bolt SDK工作区应用。
- [飞书](/channels/feishu) — 飞书Lark机器人插件需单独安装
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
- [Mattermost](/channels/mattermost) — Bot API + WebSocket频道、群组、私信插件需单独安装
- [Signal](/channels/signal) — signal-cli注重隐私。

View File

@@ -0,0 +1,33 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.2",
"description": "OpenClaw Feishu channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "feishu",
"label": "Feishu",
"selectionLabel": "Feishu (Lark Open Platform)",
"detailLabel": "Feishu Bot",
"docsPath": "/channels/feishu",
"docsLabel": "feishu",
"blurb": "Feishu/Lark bot via WebSocket.",
"aliases": [
"lark"
],
"order": 35,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/feishu",
"localPath": "extensions/feishu",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,276 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
feishuOutbound,
formatPairingApproveHint,
listFeishuAccountIds,
monitorFeishuProvider,
normalizeFeishuTarget,
PAIRING_APPROVED_MESSAGE,
probeFeishu,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
resolveFeishuConfig,
resolveFeishuGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelAccountSnapshot,
type ChannelPlugin,
type ChannelStatusIssue,
type ResolvedFeishuAccount,
} from "openclaw/plugin-sdk";
import { FeishuConfigSchema } from "./config-schema.js";
import { feishuOnboardingAdapter } from "./onboarding.js";
const meta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu (Lark Open Platform)",
detailLabel: "Feishu Bot",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "Feishu/Lark bot via WebSocket.",
aliases: ["lark"],
order: 35,
quickstartAllowFrom: true,
};
const normalizeAllowEntry = (entry: string) => entry.replace(/^(feishu|lark):/i, "").trim();
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta,
onboarding: feishuOnboardingAdapter,
pairing: {
idLabel: "feishuOpenId",
normalizeAllowEntry: normalizeAllowEntry,
notifyApproval: async ({ cfg, id }) => {
const account = resolveFeishuAccount({ cfg });
if (!account.config.appId || !account.config.appSecret) {
throw new Error("Feishu app credentials not configured");
}
await feishuOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE });
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.feishu"] },
outbound: feishuOutbound,
messaging: {
normalizeTarget: normalizeFeishuTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {
const value = (normalized ?? raw).trim();
if (!value) {
return false;
}
return /^o[cun]_[a-zA-Z0-9]+$/.test(value) || /^(user|group|chat):/i.test(value);
},
hint: "<open_id|union_id|chat_id>",
},
},
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "feishu",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "feishu",
accountId,
clearBaseFields: ["appId", "appSecret", "appSecretFile", "name", "botName"],
}),
isConfigured: (account) => account.tokenSource !== "none",
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.tokenSource !== "none",
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry)))
.map((entry) => (entry === "*" ? entry : entry.toLowerCase())),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.feishu?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.feishu.accounts.${resolvedAccountId}.`
: "channels.feishu.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("feishu"),
normalizeEntry: normalizeAllowEntry,
};
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
if (!groupId) {
return true;
}
return resolveFeishuGroupRequireMention({
cfg,
accountId: accountId ?? undefined,
chatId: groupId,
});
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
const normalizedQuery = query?.trim().toLowerCase() ?? "";
const peers = resolved.allowFrom
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => normalizeAllowEntry(entry))
.filter((entry) => (normalizedQuery ? entry.toLowerCase().includes(normalizedQuery) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
const normalizedQuery = query?.trim().toLowerCase() ?? "";
const groups = Object.keys(resolved.groups ?? {})
.filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return groups;
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) => {
const issues: ChannelStatusIssue[] = [];
for (const account of accounts) {
if (!account.configured) {
issues.push({
channel: "feishu",
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
kind: "config",
message: "Feishu app ID/secret not configured",
});
}
}
return issues;
},
buildChannelSummary: async ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeFeishu(account.config.appId, account.config.appSecret, timeoutMs, account.config.domain),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = account.tokenSource !== "none";
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
logSelfId: ({ account, runtime }) => {
const appId = account.config.appId;
if (appId) {
runtime.log?.(`feishu:${appId}`);
}
},
},
gateway: {
startAccount: async (ctx) => {
const { account, log, setStatus, abortSignal, cfg, runtime } = ctx;
const { appId, appSecret, domain } = account.config;
if (!appId || !appSecret) {
throw new Error("Feishu app ID/secret not configured");
}
let feishuBotLabel = "";
try {
const probe = await probeFeishu(appId, appSecret, 5000, domain);
if (probe.ok && probe.bot?.appName) {
feishuBotLabel = ` (${probe.bot.appName})`;
}
if (probe.ok && probe.bot) {
setStatus({ accountId: account.accountId, bot: probe.bot });
}
} catch (err) {
log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
log?.info(`[${account.accountId}] starting Feishu provider${feishuBotLabel}`);
setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
});
try {
await monitorFeishuProvider({
appId,
appSecret,
accountId: account.accountId,
config: cfg,
runtime,
abortSignal,
});
} catch (err) {
setStatus({
accountId: account.accountId,
running: false,
lastError: err instanceof Error ? err.message : String(err),
});
throw err;
}
},
},
};

View File

@@ -0,0 +1,46 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const toolsBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
const FeishuGroupSchema = z
.object({
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowFrom: z.array(allowFromEntry).optional(),
tools: ToolPolicySchema,
toolsBySender: toolsBySenderSchema,
systemPrompt: z.string().optional(),
skills: z.array(z.string()).optional(),
})
.strict();
const FeishuAccountSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
appId: z.string().optional(),
appSecret: z.string().optional(),
appSecretFile: z.string().optional(),
domain: z.string().optional(),
botName: z.string().optional(),
markdown: MarkdownConfigSchema,
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
historyLimit: z.number().optional(),
dmHistoryLimit: z.number().optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
streaming: z.boolean().optional(),
mediaMaxMb: z.number().optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
})
.strict();
export const FeishuConfigSchema = FeishuAccountSchema.extend({
accounts: z.object({}).catchall(FeishuAccountSchema).optional(),
});

View File

@@ -0,0 +1,278 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
DmPolicy,
OpenClawConfig,
WizardPrompter,
} from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
normalizeAccountId,
promptAccountId,
} from "openclaw/plugin-sdk";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
} from "openclaw/plugin-sdk";
const channel = "feishu" as const;
function setFeishuDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
dmPolicy: policy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
async function noteFeishuSetup(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).",
"Copy the App ID and App Secret from the app credentials page.",
'Lark (global): use open.larksuite.com and set domain="lark".',
`Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`,
].join("\n"),
"Feishu setup",
);
}
function normalizeAllowEntry(entry: string): string {
return entry.replace(/^(feishu|lark):/i, "").trim();
}
function resolveDomainChoice(domain?: string | null): "feishu" | "lark" {
const normalized = String(domain ?? "").toLowerCase();
if (normalized.includes("lark") || normalized.includes("larksuite")) {
return "lark";
}
return "feishu";
}
async function promptFeishuAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string | null;
}): Promise<OpenClawConfig> {
const { cfg, prompter } = params;
const accountId = normalizeAccountId(params.accountId);
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const existingAllowFrom = isDefault
? (cfg.channels?.feishu?.allowFrom ?? [])
: (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []);
const entry = await prompter.text({
message: "Feishu allowFrom (open_id or union_id)",
placeholder: "ou_xxx",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const entries = raw
.split(/[\n,;]+/g)
.map((item) => normalizeAllowEntry(item))
.filter(Boolean);
const invalid = entries.filter((item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item));
if (invalid.length > 0) {
return `Invalid Feishu ids: ${invalid.join(", ")}`;
}
return undefined;
},
});
const parsed = String(entry)
.split(/[\n,;]+/g)
.map((item) => normalizeAllowEntry(item))
.filter(Boolean);
const merged = [
...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))),
...parsed,
].filter(Boolean);
const unique = Array.from(new Set(merged));
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
accounts: {
...cfg.channels?.feishu?.accounts,
[accountId]: {
...cfg.channels?.feishu?.accounts?.[accountId],
enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Feishu",
channel,
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
function updateFeishuConfig(
cfg: OpenClawConfig,
accountId: string,
updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean },
): OpenClawConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const next = { ...cfg } as OpenClawConfig;
const feishu = { ...next.channels?.feishu } as Record<string, unknown>;
const accounts = feishu.accounts
? { ...(feishu.accounts as Record<string, unknown>) }
: undefined;
if (isDefault && !accounts) {
return {
...next,
channels: {
...next.channels,
feishu: {
...feishu,
...updates,
enabled: updates.enabled ?? true,
},
},
};
}
const resolvedAccounts = accounts ?? {};
const existing = (resolvedAccounts[accountId] as Record<string, unknown>) ?? {};
resolvedAccounts[accountId] = {
...existing,
...updates,
enabled: updates.enabled ?? true,
};
return {
...next,
channels: {
...next.channels,
feishu: {
...feishu,
accounts: resolvedAccounts,
},
},
};
}
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listFeishuAccountIds(cfg).some((id) => {
const acc = resolveFeishuAccount({ cfg, accountId: id });
return acc.tokenSource !== "none";
});
return {
channel,
configured,
statusLines: [`Feishu: ${configured ? "configured" : "needs app credentials"}`],
selectionHint: configured ? "configured" : "requires app credentials",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
let next = cfg;
const override = accountOverrides.feishu?.trim();
const defaultId = resolveDefaultFeishuAccountId(next);
let accountId = override ? normalizeAccountId(override) : defaultId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg: next,
prompter,
label: "Feishu",
currentId: accountId,
listAccountIds: listFeishuAccountIds,
defaultAccountId: defaultId,
});
}
await noteFeishuSetup(prompter);
const resolved = resolveFeishuAccount({ cfg: next, accountId });
const domainChoice = await prompter.select({
message: "Feishu domain",
options: [
{ value: "feishu", label: "Feishu (China) — open.feishu.cn" },
{ value: "lark", label: "Lark (global) — open.larksuite.com" },
],
initialValue: resolveDomainChoice(resolved.config.domain),
});
const domain = domainChoice === "lark" ? "lark" : "feishu";
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
const envAppId = process.env.FEISHU_APP_ID?.trim();
const envSecret = process.env.FEISHU_APP_SECRET?.trim();
if (isDefault && envAppId && envSecret) {
const useEnv = await prompter.confirm({
message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?",
initialValue: true,
});
if (useEnv) {
next = updateFeishuConfig(next, accountId, { enabled: true, domain });
return { cfg: next, accountId };
}
}
const appId = String(
await prompter.text({
message: "Feishu App ID (cli_...)",
initialValue: resolved.config.appId?.trim() || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Feishu App Secret",
initialValue: resolved.config.appSecret?.trim() || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true });
return { cfg: next, accountId };
},
};

View File

@@ -105,6 +105,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@larksuiteoapi/node-sdk": "^1.42.0",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.51.1",

View File

@@ -0,0 +1,52 @@
import type { ChannelOutboundAdapter } from "../types.js";
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { getFeishuClient } from "../../../feishu/client.js";
import { sendMessageFeishu } from "../../../feishu/send.js";
function resolveReceiveIdType(target: string): "open_id" | "union_id" | "chat_id" {
const trimmed = target.trim().toLowerCase();
if (trimmed.startsWith("ou_")) {
return "open_id";
}
if (trimmed.startsWith("on_")) {
return "union_id";
}
return "chat_id";
}
export const feishuOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId }) => {
const client = getFeishuClient(accountId ?? undefined);
const result = await sendMessageFeishu(
client,
to,
{ text },
{
receiveIdType: resolveReceiveIdType(to),
},
);
return {
channel: "feishu",
messageId: result?.message_id || "unknown",
chatId: to,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const client = getFeishuClient(accountId ?? undefined);
const result = await sendMessageFeishu(
client,
to,
{ text: text || "" },
{ mediaUrl, receiveIdType: resolveReceiveIdType(to) },
);
return {
channel: "feishu",
messageId: result?.message_id || "unknown",
chatId: to,
};
},
};

View File

@@ -0,0 +1,98 @@
import type { DmPolicy, GroupPolicy, MarkdownConfig, OutboundRetryConfig } from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
export type FeishuGroupConfig = {
requireMention?: boolean;
/** Optional tool policy overrides for this group. */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
/** If specified, only load these skills for this group. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this group. */
enabled?: boolean;
/** Optional allowlist for group senders (open_ids). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
systemPrompt?: string;
};
export type FeishuAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Feishu app ID (cli_xxx). */
appId?: string;
/** Feishu app secret. */
appSecret?: string;
/** Path to file containing app secret (for secret managers). */
appSecretFile?: string;
/** API domain override: "feishu" (default), "lark" (global), or full https:// domain. */
domain?: string;
/** Bot display name (used for streaming card title). */
botName?: string;
/** If false, do not start this Feishu account. Default: true. */
enabled?: boolean;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Feishu (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/**
* Controls how Feishu direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve
* - "allowlist": only allow senders in allowFrom (or paired allow store)
* - "open": allow all inbound DMs (requires allowFrom to include "*")
* - "disabled": ignore all inbound DMs
*/
dmPolicy?: DmPolicy;
/**
* Controls how group messages are handled:
* - "open": groups bypass allowFrom, only mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Allowlist for DM senders (open_id or union_id). */
allowFrom?: Array<string | number>;
/** Optional allowlist for Feishu group senders. */
groupAllowFrom?: Array<string | number>;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user open_id. */
dms?: Record<string, DmConfig>;
/** Per-group config keyed by chat_id (oc_xxx). */
groups?: Record<string, FeishuGroupConfig>;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/**
* Enable streaming card mode for replies (shows typing indicator).
* When true, replies are streamed via Feishu's CardKit API with typewriter effect.
* Default: true.
*/
streaming?: boolean;
/** Media max size in MB. */
mediaMaxMb?: number;
/** Retry policy for outbound Feishu API calls. */
retry?: OutboundRetryConfig;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type FeishuConfig = {
/** Optional per-account Feishu configuration (multi-account). */
accounts?: Record<string, FeishuAccountConfig>;
/** Top-level app ID (alternative to accounts). */
appId?: string;
/** Top-level app secret (alternative to accounts). */
appSecret?: string;
/** Top-level app secret file (alternative to accounts). */
appSecretFile?: string;
} & Omit<FeishuAccountConfig, "appId" | "appSecret" | "appSecretFile">;

152
src/feishu/monitor.ts Normal file
View File

@@ -0,0 +1,152 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadConfig } from "../config/config.js";
import { getChildLogger } from "../logging.js";
import { resolveFeishuAccount } from "./accounts.js";
import { resolveFeishuConfig } from "./config.js";
import { normalizeFeishuDomain } from "./domain.js";
import { processFeishuMessage } from "./message.js";
const logger = getChildLogger({ module: "feishu-monitor" });
export type MonitorFeishuOpts = {
appId?: string;
appSecret?: string;
accountId?: string;
config?: OpenClawConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
};
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
const cfg = opts.config ?? loadConfig();
const account = resolveFeishuAccount({
cfg,
accountId: opts.accountId,
});
const appId = opts.appId?.trim() || account.config.appId;
const appSecret = opts.appSecret?.trim() || account.config.appSecret;
const domain = normalizeFeishuDomain(account.config.domain);
const accountId = account.accountId;
if (!appId || !appSecret) {
throw new Error(
`Feishu app ID/secret missing for account "${accountId}" (set channels.feishu.accounts.${accountId}.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET).`,
);
}
// Resolve effective config for this account
const feishuCfg = resolveFeishuConfig({ cfg, accountId });
// Check if account is enabled
if (!feishuCfg.enabled) {
logger.info(`Feishu account "${accountId}" is disabled, skipping monitor`);
return;
}
// Create Lark client for API calls
const client = new Lark.Client({
appId,
appSecret,
...(domain ? { domain } : {}),
logger: {
debug: (msg) => {
logger.debug?.(msg);
},
info: (msg) => {
logger.info(msg);
},
warn: (msg) => {
logger.warn(msg);
},
error: (msg) => {
logger.error(msg);
},
trace: (msg) => {
logger.silly?.(msg);
},
},
});
// Create event dispatcher
const eventDispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
logger.info(`Received Feishu message event`);
try {
await processFeishuMessage(client, data, appId, {
cfg,
accountId,
resolvedConfig: feishuCfg,
credentials: { appId, appSecret, domain },
botName: account.name,
});
} catch (err) {
logger.error(`Error processing Feishu message: ${String(err)}`);
}
},
});
// Create WebSocket client
const wsClient = new Lark.WSClient({
appId,
appSecret,
...(domain ? { domain } : {}),
loggerLevel: Lark.LoggerLevel.info,
logger: {
debug: (msg) => {
logger.debug?.(msg);
},
info: (msg) => {
logger.info(msg);
},
warn: (msg) => {
logger.warn(msg);
},
error: (msg) => {
logger.error(msg);
},
trace: (msg) => {
logger.silly?.(msg);
},
},
});
// Handle abort signal
const handleAbort = () => {
logger.info("Stopping Feishu WS client...");
// WSClient doesn't have a stop method exposed, but it should handle disconnection
// We'll let the process handle cleanup
};
if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", handleAbort, { once: true });
}
try {
logger.info("Starting Feishu WebSocket client...");
await wsClient.start({ eventDispatcher });
logger.info("Feishu WebSocket connection established");
// The WSClient.start() should keep running until disconnected
// If it returns, we need to keep the process alive
// Wait for abort signal
if (opts.abortSignal) {
await new Promise<void>((resolve) => {
if (opts.abortSignal?.aborted) {
resolve();
return;
}
opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
} else {
// If no abort signal, wait indefinitely
await new Promise<void>(() => {});
}
} finally {
if (opts.abortSignal) {
opts.abortSignal.removeEventListener("abort", handleAbort);
}
}
}