Channels: add Feishu/Lark support
This commit is contained in:
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -9,6 +9,12 @@
|
|||||||
- "src/discord/**"
|
- "src/discord/**"
|
||||||
- "extensions/discord/**"
|
- "extensions/discord/**"
|
||||||
- "docs/channels/discord.md"
|
- "docs/channels/discord.md"
|
||||||
|
"channel: feishu":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/feishu/**"
|
||||||
|
- "extensions/feishu/**"
|
||||||
|
- "docs/channels/feishu.md"
|
||||||
"channel: googlechat":
|
"channel: googlechat":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|||||||
507
docs/channels/feishu.md
Normal file
507
docs/channels/feishu.md
Normal 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 3. Copy credentials
|
||||||
|
|
||||||
|
From **Credentials & Basic Info**, copy:
|
||||||
|
|
||||||
|
- **App ID** (format: `cli_xxx`)
|
||||||
|
- **App Secret**
|
||||||
|
|
||||||
|
❗ **Important:** keep the App Secret private.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5. Enable bot capability
|
||||||
|
|
||||||
|
In **App Capability** > **Bot**:
|
||||||
|
|
||||||
|
1. Enable bot capability
|
||||||
|
2. Set the bot name
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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)
|
||||||
@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [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.
|
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
||||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
|
|||||||
@@ -772,6 +772,7 @@
|
|||||||
"channels/grammy",
|
"channels/grammy",
|
||||||
"channels/discord",
|
"channels/discord",
|
||||||
"channels/slack",
|
"channels/slack",
|
||||||
|
"channels/feishu",
|
||||||
"channels/googlechat",
|
"channels/googlechat",
|
||||||
"channels/mattermost",
|
"channels/mattermost",
|
||||||
"channels/signal",
|
"channels/signal",
|
||||||
@@ -1199,6 +1200,7 @@
|
|||||||
"zh-CN/channels/grammy",
|
"zh-CN/channels/grammy",
|
||||||
"zh-CN/channels/discord",
|
"zh-CN/channels/discord",
|
||||||
"zh-CN/channels/slack",
|
"zh-CN/channels/slack",
|
||||||
|
"zh-CN/channels/feishu",
|
||||||
"zh-CN/channels/googlechat",
|
"zh-CN/channels/googlechat",
|
||||||
"zh-CN/channels/mattermost",
|
"zh-CN/channels/mattermost",
|
||||||
"zh-CN/channels/signal",
|
"zh-CN/channels/signal",
|
||||||
|
|||||||
513
docs/zh-CN/channels/feishu.md
Normal file
513
docs/zh-CN/channels/feishu.md
Normal 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. 选择应用图标
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 3. 获取应用凭证
|
||||||
|
|
||||||
|
在应用的 **凭证与基础信息** 页面,复制:
|
||||||
|
|
||||||
|
- **App ID**(格式如 `cli_xxx`)
|
||||||
|
- **App Secret**
|
||||||
|
|
||||||
|
❗ **重要**:请妥善保管 App Secret,不要分享给他人。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5. 启用机器人能力
|
||||||
|
|
||||||
|
在 **应用能力** > **机器人** 页面:
|
||||||
|
|
||||||
|
1. 开启机器人能力
|
||||||
|
2. 配置机器人名称
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6. 配置事件订阅
|
||||||
|
|
||||||
|
⚠️ **重要提醒**:在配置事件订阅前,请务必确保已完成以下步骤:
|
||||||
|
|
||||||
|
1. 运行 `openclaw channels add` 添加了 Feishu 渠道
|
||||||
|
2. 网关处于启动状态(可通过 `openclaw gateway status` 检查状态)
|
||||||
|
|
||||||
|
在 **事件订阅** 页面:
|
||||||
|
|
||||||
|
1. 选择 **使用长连接接收事件**(WebSocket 模式)
|
||||||
|
2. 添加事件:`im.message.receive_v1`(接收消息)
|
||||||
|
|
||||||
|
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 获取群组 ID(chat_id)
|
||||||
|
|
||||||
|
群组 ID 格式为 `oc_xxx`,可以通过以下方式获取:
|
||||||
|
|
||||||
|
**方法一**(推荐):
|
||||||
|
|
||||||
|
1. 启动网关并在群组中 @机器人发消息
|
||||||
|
2. 运行 `openclaw logs --follow` 查看日志中的 `chat_id`
|
||||||
|
|
||||||
|
**方法二**:
|
||||||
|
使用飞书 API 调试工具获取机器人所在群组列表。
|
||||||
|
|
||||||
|
### 获取用户 ID(open_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"` | 完全禁止私聊 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的消息类型
|
||||||
|
|
||||||
|
### 接收
|
||||||
|
|
||||||
|
- ✅ 文本消息
|
||||||
|
- ✅ 图片
|
||||||
|
- ✅ 文件
|
||||||
|
- ✅ 音频
|
||||||
|
- ✅ 视频
|
||||||
|
- ✅ 表情包
|
||||||
|
|
||||||
|
### 发送
|
||||||
|
|
||||||
|
- ✅ 文本消息
|
||||||
|
- ✅ 图片
|
||||||
|
- ✅ 文件
|
||||||
|
- ✅ 音频
|
||||||
|
- ⚠️ 富文本(部分支持)
|
||||||
@@ -24,6 +24,7 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
|
|||||||
- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
|
- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
|
||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway;支持服务器、频道和私信。
|
- [Discord](/channels/discord) — Discord Bot API + Gateway;支持服务器、频道和私信。
|
||||||
- [Slack](/channels/slack) — Bolt SDK;工作区应用。
|
- [Slack](/channels/slack) — Bolt SDK;工作区应用。
|
||||||
|
- [飞书](/channels/feishu) — 飞书(Lark)机器人(插件,需单独安装)。
|
||||||
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
|
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
|
||||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
|
- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
|
||||||
- [Signal](/channels/signal) — signal-cli;注重隐私。
|
- [Signal](/channels/signal) — signal-cli;注重隐私。
|
||||||
|
|||||||
33
extensions/feishu/package.json
Normal file
33
extensions/feishu/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
extensions/feishu/src/channel.ts
Normal file
276
extensions/feishu/src/channel.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
46
extensions/feishu/src/config-schema.ts
Normal file
46
extensions/feishu/src/config-schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
278
extensions/feishu/src/onboarding.ts
Normal file
278
extensions/feishu/src/onboarding.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -105,6 +105,7 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
|
"@larksuiteoapi/node-sdk": "^1.42.0",
|
||||||
"@line/bot-sdk": "^10.6.0",
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.51.1",
|
"@mariozechner/pi-agent-core": "0.51.1",
|
||||||
|
|||||||
52
src/channels/plugins/outbound/feishu.ts
Normal file
52
src/channels/plugins/outbound/feishu.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
98
src/config/types.feishu.ts
Normal file
98
src/config/types.feishu.ts
Normal 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
152
src/feishu/monitor.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user