From 2483f26c235a2a3271cae733f653ff6d78641726 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Tue, 3 Feb 2026 14:27:13 -0800 Subject: [PATCH] Channels: add Feishu/Lark support --- .github/labeler.yml | 6 + docs/channels/feishu.md | 507 +++++++++++++++++++++++ docs/channels/index.md | 1 + docs/docs.json | 2 + docs/zh-CN/channels/feishu.md | 513 ++++++++++++++++++++++++ docs/zh-CN/channels/index.md | 1 + extensions/feishu/package.json | 33 ++ extensions/feishu/src/channel.ts | 276 +++++++++++++ extensions/feishu/src/config-schema.ts | 46 +++ extensions/feishu/src/onboarding.ts | 278 +++++++++++++ package.json | 1 + src/channels/plugins/outbound/feishu.ts | 52 +++ src/config/types.feishu.ts | 98 +++++ src/feishu/monitor.ts | 152 +++++++ 14 files changed, 1966 insertions(+) create mode 100644 docs/channels/feishu.md create mode 100644 docs/zh-CN/channels/feishu.md create mode 100644 extensions/feishu/package.json create mode 100644 extensions/feishu/src/channel.ts create mode 100644 extensions/feishu/src/config-schema.ts create mode 100644 extensions/feishu/src/onboarding.ts create mode 100644 src/channels/plugins/outbound/feishu.ts create mode 100644 src/config/types.feishu.ts create mode 100644 src/feishu/monitor.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa4187..a1259f44aa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md new file mode 100644 index 0000000000..33517547d9 --- /dev/null +++ b/docs/channels/feishu.md @@ -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..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 +``` + +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 + ``` +- **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..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..appId` | App ID | - | +| `channels.feishu.accounts..appSecret` | App Secret | - | +| `channels.feishu.accounts..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..requireMention` | Require @mention | `true` | +| `channels.feishu.groups..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) diff --git a/docs/channels/index.md b/docs/channels/index.md index eba433a7fd..1f161c9c6a 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -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. diff --git a/docs/docs.json b/docs/docs.json index 1b33488596..7e6950c658 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md new file mode 100644 index 0000000000..0240a7d01d --- /dev/null +++ b/docs/zh-CN/channels/feishu.md @@ -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..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 # 批准 + ``` +- **白名单模式**:通过 `channels.feishu.allowFrom` 配置允许的用户 Open ID + +### 群组访问 + +**1. 群组策略**(`channels.feishu.groupPolicy`): + +- `"open"` = 允许群组中所有人(默认) +- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户 +- `"disabled"` = 禁用群组消息 + +**2. @提及要求**(`channels.feishu.groups..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..appId` | 应用 App ID | - | +| `channels.feishu.accounts..appSecret` | 应用 App Secret | - | +| `channels.feishu.accounts..domain` | 单账号 API 域名覆盖 | `feishu` | +| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` | +| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - | +| `channels.feishu.groupPolicy` | 群组策略 | `open` | +| `channels.feishu.groupAllowFrom` | 群组白名单 | - | +| `channels.feishu.groups..requireMention` | 是否需要 @提及 | `true` | +| `channels.feishu.groups..enabled` | 是否启用该群组 | `true` | +| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | +| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | +| `channels.feishu.blockStreaming` | 禁用流式输出 | `true` | + +--- + +## dmPolicy 策略说明 + +| 值 | 行为 | +| ------------- | -------------------------------------------------- | +| `"pairing"` | **默认**。未知用户收到配对码,管理员批准后才能对话 | +| `"allowlist"` | 仅 `allowFrom` 列表中的用户可对话,其他静默忽略 | +| `"open"` | 允许所有人对话(需在 allowFrom 中加 `"*"`) | +| `"disabled"` | 完全禁止私聊 | + +--- + +## 支持的消息类型 + +### 接收 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ 音频 +- ✅ 视频 +- ✅ 表情包 + +### 发送 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ 音频 +- ⚠️ 富文本(部分支持) diff --git a/docs/zh-CN/channels/index.md b/docs/zh-CN/channels/index.md index 9bafe7cbd9..fcf0aadf47 100644 --- a/docs/zh-CN/channels/index.md +++ b/docs/zh-CN/channels/index.md @@ -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;注重隐私。 diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 0000000000..abe76bac21 --- /dev/null +++ b/extensions/feishu/package.json @@ -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" + } + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 0000000000..e0ef296972 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -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 = { + 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: "", + }, + }, + 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; + } + }, + }, +}; diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts new file mode 100644 index 0000000000..3c8903c81e --- /dev/null +++ b/extensions/feishu/src/config-schema.ts @@ -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(), +}); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 0000000000..07ee973673 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -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 { + 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 { + 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; + const accounts = feishu.accounts + ? { ...(feishu.accounts as Record) } + : 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) ?? {}; + 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 }; + }, +}; diff --git a/package.json b/package.json index 17c7860171..19f7fd7281 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/channels/plugins/outbound/feishu.ts b/src/channels/plugins/outbound/feishu.ts new file mode 100644 index 0000000000..20a2b78cdc --- /dev/null +++ b/src/channels/plugins/outbound/feishu.ts @@ -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, + }; + }, +}; diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts new file mode 100644 index 0000000000..a021029dae --- /dev/null +++ b/src/config/types.feishu.ts @@ -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; + /** 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; + /** Optional allowlist for Feishu group senders. */ + groupAllowFrom?: Array; + /** 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; + /** Per-group config keyed by chat_id (oc_xxx). */ + groups?: Record; + /** 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; + /** 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; diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts new file mode 100644 index 0000000000..2b36ca95a5 --- /dev/null +++ b/src/feishu/monitor.ts @@ -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 { + 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((resolve) => { + if (opts.abortSignal?.aborted) { + resolve(); + return; + } + opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } else { + // If no abort signal, wait indefinitely + await new Promise(() => {}); + } + } finally { + if (opts.abortSignal) { + opts.abortSignal.removeEventListener("abort", handleAbort); + } + } +}