🤖 feat: add Feishu/Lark channel support

What:
- clean up Feishu port for lint/type safety and error handling
- allow buffers in Feishu media uploads and tighten config merging
- increase Discord/Telegram unit test timeouts to reduce parallel flake

Why:
- backport Feishu support from openclaw-cn
- keep local test runs stable under parallel load

Tests:
- pnpm lint
- pnpm build
- pnpm vitest run --config vitest.unit.config.ts src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts --maxWorkers 1
- pnpm vitest run --config vitest.unit.config.ts src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts --maxWorkers 1
- pnpm test:all (fails in pnpm test:e2e: missing OPENCLAW_GATEWAY_TOKEN / gateway token mismatch; failing e2e tests include server.ios-client-id, gateway.multi, server.agent/chat/health/hooks/roles-allowlist/sessions-send, media-understanding.auto gemini, directive model list/help)
This commit is contained in:
Josh Palmer
2026-02-02 18:33:24 +01:00
parent 966228a6a9
commit d8a88971ba
42 changed files with 4765 additions and 143 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Docs: expand zh-Hans navigation and fix zh-CN index asset paths. (#7242) Thanks @joshp123.
- Docs: add zh-CN landing notice + AI-translated image. (#7303) Thanks @joshp123.
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
- Feishu: add Feishu/Lark plugin support + docs. Thanks @jiulingyun (openclaw-cn).
### Fixes

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View File

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

View File

@@ -0,0 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { feishuPlugin } from "./src/channel.js";
const plugin = {
id: "feishu",
name: "Feishu",
description: "Feishu (Lark) channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerChannel({ plugin: feishuPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "feishu",
"channels": ["feishu"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@
"dist/cron/**",
"dist/daemon/**",
"dist/discord/**",
"dist/feishu/**",
"dist/gateway/**",
"dist/hooks/**",
"dist/imessage/**",
@@ -159,6 +160,7 @@
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@line/bot-sdk": "^10.6.0",
"@larksuiteoapi/node-sdk": "^1.42.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.51.0",
"@mariozechner/pi-ai": "0.51.0",

41
pnpm-lock.yaml generated
View File

@@ -39,6 +39,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.4
version: 1.3.4
'@larksuiteoapi/node-sdk':
specifier: ^1.42.0
version: 1.58.0
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0
@@ -297,6 +300,12 @@ importers:
specifier: workspace:*
version: link:../..
extensions/feishu:
devDependencies:
openclaw:
specifier: workspace:*
version: link:../..
extensions/google-antigravity-auth:
devDependencies:
openclaw:
@@ -1290,6 +1299,9 @@ packages:
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@larksuiteoapi/node-sdk@1.58.0':
resolution: {integrity: sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==}
'@line/bot-sdk@10.6.0':
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
engines: {node: '>=20'}
@@ -4006,6 +4018,9 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.identity@3.0.0:
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -4024,9 +4039,15 @@ packages:
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.pickby@4.6.0:
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -6319,6 +6340,20 @@ snapshots:
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
'@larksuiteoapi/node-sdk@1.58.0':
dependencies:
axios: 1.13.4(debug@4.4.3)
lodash.identity: 3.0.0
lodash.merge: 4.6.2
lodash.pickby: 4.6.0
protobufjs: 7.5.4
qs: 6.14.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- debug
- utf-8-validate
'@line/bot-sdk@10.6.0':
dependencies:
'@types/node': 24.10.9
@@ -9369,6 +9404,8 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.identity@3.0.0: {}
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@@ -9381,8 +9418,12 @@ snapshots:
lodash.isstring@4.0.1: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash.pickby@4.6.0: {}
lodash@4.17.23: {}
log-symbols@6.0.0:

View File

@@ -0,0 +1,5 @@
export function normalizeFeishuTarget(raw: string): string {
let normalized = raw.replace(/^(feishu|lark):/i, "").trim();
normalized = normalized.replace(/^(group|chat|user|dm):/i, "").trim();
return normalized;
}

View File

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

View File

@@ -1,5 +1,6 @@
import type { GroupPolicy } from "./types.base.js";
import type { DiscordConfig } from "./types.discord.js";
import type { FeishuConfig } from "./types.feishu.js";
import type { GoogleChatConfig } from "./types.googlechat.js";
import type { IMessageConfig } from "./types.imessage.js";
import type { MSTeamsConfig } from "./types.msteams.js";
@@ -28,6 +29,7 @@ export type ChannelsConfig = {
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
feishu?: FeishuConfig;
googlechat?: GoogleChatConfig;
slack?: SlackConfig;
signal?: SignalConfig;

103
src/config/types.feishu.ts Normal file
View File

@@ -0,0 +1,103 @@
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">;

View File

@@ -10,6 +10,7 @@ export * from "./types.channels.js";
export * from "./types.openclaw.js";
export * from "./types.cron.js";
export * from "./types.discord.js";
export * from "./types.feishu.js";
export * from "./types.googlechat.js";
export * from "./types.gateway.js";
export * from "./types.hooks.js";

View File

@@ -53,166 +53,176 @@ beforeEach(() => {
__resetDiscordChannelInfoCacheForTest();
});
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
describe("discord tool result dispatch", () => {
it("accepts guild messages when mentionPatterns match", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
it(
"accepts guild messages when mentionPatterns match",
async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
},
},
},
session: { store: "/tmp/openclaw-sessions.json" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: true } },
session: { store: "/tmp/openclaw-sessions.json" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: true } },
},
},
},
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
await handler(
{
message: {
id: "m2",
content: "openclaw: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
await handler(
{
message: {
id: "m2",
content: "openclaw: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
client,
);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
},
MENTION_PATTERNS_TEST_TIMEOUT_MS,
);
it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
it(
"accepts guild messages when mentionPatterns match even if another user is mentioned",
async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/openclaw",
},
},
},
session: { store: "/tmp/openclaw-sessions.json" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: true } },
session: { store: "/tmp/openclaw-sessions.json" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: true } },
},
},
},
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
await handler(
{
message: {
id: "m2",
content: "openclaw: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }],
mentionedRoles: [],
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
await handler(
{
message: {
id: "m2",
content: "openclaw: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
client,
);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
},
MENTION_PATTERNS_TEST_TIMEOUT_MS,
);
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");

91
src/feishu/access.ts Normal file
View File

@@ -0,0 +1,91 @@
import type { AllowlistMatch } from "../channels/allowlist-match.js";
export type NormalizedAllowFrom = {
entries: string[];
entriesLower: string[];
hasWildcard: boolean;
hasEntries: boolean;
};
export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">;
/**
* Normalize an allowlist for Feishu.
* Feishu IDs are open_id (ou_xxx) or union_id (on_xxx), no usernames.
*/
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
const hasWildcard = entries.includes("*");
// Strip optional "feishu:" prefix
const normalized = entries
.filter((value) => value !== "*")
.map((value) => value.replace(/^(feishu|lark):/i, ""));
const normalizedLower = normalized.map((value) => value.toLowerCase());
return {
entries: normalized,
entriesLower: normalizedLower,
hasWildcard,
hasEntries: entries.length > 0,
};
};
export const normalizeAllowFromWithStore = (params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: string[];
}): NormalizedAllowFrom => {
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]
.map((value) => String(value).trim())
.filter(Boolean);
return normalizeAllowFrom(combined);
};
export const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") {
return value;
}
}
return undefined;
};
/**
* Check if a sender is allowed based on the normalized allowlist.
* Feishu uses open_id (ou_xxx) or union_id (on_xxx) - no usernames.
*/
export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string }) => {
const { allow, senderId } = params;
if (!allow.hasEntries) {
return true;
}
if (allow.hasWildcard) {
return true;
}
if (senderId && allow.entries.includes(senderId)) {
return true;
}
// Also check case-insensitive (though Feishu IDs are typically lowercase)
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
return true;
}
return false;
};
export const resolveSenderAllowMatch = (params: {
allow: NormalizedAllowFrom;
senderId?: string;
}): AllowFromMatch => {
const { allow, senderId } = params;
if (allow.hasWildcard) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
if (!allow.hasEntries) {
return { allowed: false };
}
if (senderId && allow.entries.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
return { allowed: true, matchKey: senderId.toLowerCase(), matchSource: "id" };
}
return { allowed: false };
};

142
src/feishu/accounts.ts Normal file
View File

@@ -0,0 +1,142 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import type { FeishuAccountConfig } from "../config/types.feishu.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export type FeishuTokenSource = "config" | "file" | "env" | "none";
export type ResolvedFeishuAccount = {
accountId: string;
config: FeishuAccountConfig;
tokenSource: FeishuTokenSource;
name?: string;
enabled: boolean;
};
function readFileIfExists(filePath?: string): string | undefined {
if (!filePath) {
return undefined;
}
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch {
return undefined;
}
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): FeishuAccountConfig | undefined {
const accounts = cfg.channels?.feishu?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as FeishuAccountConfig | undefined;
if (direct) {
return direct;
}
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined;
}
function mergeFeishuAccountConfig(cfg: OpenClawConfig, accountId: string): FeishuAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & {
accounts?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveAppSecret(config?: { appSecret?: string; appSecretFile?: string }): {
value?: string;
source?: Exclude<FeishuTokenSource, "env" | "none">;
} {
const direct = config?.appSecret?.trim();
if (direct) {
return { value: direct, source: "config" };
}
const fromFile = readFileIfExists(config?.appSecretFile);
if (fromFile) {
return { value: fromFile, source: "file" };
}
return {};
}
export function listFeishuAccountIds(cfg: OpenClawConfig): string[] {
const feishuCfg = cfg.channels?.feishu;
const accounts = feishuCfg?.accounts;
const ids = new Set<string>();
const baseConfigured = Boolean(
feishuCfg?.appId?.trim() && (feishuCfg?.appSecret?.trim() || Boolean(feishuCfg?.appSecretFile)),
);
const envConfigured = Boolean(
process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
);
if (baseConfigured || envConfigured) {
ids.add(DEFAULT_ACCOUNT_ID);
}
if (accounts) {
for (const id of Object.keys(accounts)) {
ids.add(normalizeAccountId(id));
}
}
return Array.from(ids);
}
export function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string {
const ids = listFeishuAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveFeishuAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.feishu?.enabled !== false;
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envAppId = allowEnv ? process.env.FEISHU_APP_ID?.trim() : undefined;
const envAppSecret = allowEnv ? process.env.FEISHU_APP_SECRET?.trim() : undefined;
const appId = merged.appId?.trim() || envAppId || "";
const secretResolution = resolveAppSecret(merged);
const appSecret = secretResolution.value ?? envAppSecret ?? "";
let tokenSource: FeishuTokenSource = "none";
if (secretResolution.value) {
tokenSource = secretResolution.source ?? "config";
} else if (envAppSecret) {
tokenSource = "env";
}
if (!appId || !appSecret) {
tokenSource = "none";
}
const config: FeishuAccountConfig = {
...merged,
appId,
appSecret,
};
const name = config.name?.trim() || config.botName?.trim() || undefined;
return {
accountId,
config,
tokenSource,
name,
enabled,
};
}

58
src/feishu/bot.ts Normal file
View File

@@ -0,0 +1,58 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { getFeishuClient } from "./client.js";
import { processFeishuMessage } from "./message.js";
const logger = getChildLogger({ module: "feishu-bot" });
export type FeishuBotOptions = {
appId: string;
appSecret: string;
};
export function createFeishuBot(opts: FeishuBotOptions) {
const { appId, appSecret } = opts;
const client = getFeishuClient(appId, appSecret);
const eventDispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
try {
await processFeishuMessage(client, data, appId);
} catch (err) {
logger.error(`Error processing Feishu message: ${formatErrorMessage(err)}`);
}
},
});
const wsClient = new Lark.WSClient({
appId,
appSecret,
logger: {
debug: (...args) => {
logger.debug(args.join(" "));
},
info: (...args) => {
logger.info(args.join(" "));
},
warn: (...args) => {
logger.warn(args.join(" "));
},
error: (...args) => {
logger.error(args.join(" "));
},
trace: (...args) => {
logger.silly(args.join(" "));
},
},
});
return { client, wsClient, eventDispatcher };
}
export async function startFeishuBot(bot: ReturnType<typeof createFeishuBot>) {
logger.info("Starting Feishu bot WS client...");
await bot.wsClient.start({
eventDispatcher: bot.eventDispatcher,
});
}

134
src/feishu/client.ts Normal file
View File

@@ -0,0 +1,134 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import fs from "node:fs";
import { loadConfig } from "../config/config.js";
import { getChildLogger } from "../logging.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { normalizeFeishuDomain } from "./domain.js";
const logger = getChildLogger({ module: "feishu-client" });
function readFileIfExists(filePath?: string): string | undefined {
if (!filePath) {
return undefined;
}
try {
return fs.readFileSync(filePath, "utf-8").trim();
} catch {
return undefined;
}
}
function resolveAppSecret(config?: {
appSecret?: string;
appSecretFile?: string;
}): string | undefined {
const direct = config?.appSecret?.trim();
if (direct) {
return direct;
}
return readFileIfExists(config?.appSecretFile);
}
export function getFeishuClient(accountIdOrAppId?: string, explicitAppSecret?: string) {
const cfg = loadConfig();
const feishuCfg = cfg.channels?.feishu;
let appId: string | undefined;
let appSecret: string | undefined = explicitAppSecret?.trim() || undefined;
let domain: string | undefined;
// Determine if we received an accountId or an appId
const isAppId = accountIdOrAppId?.startsWith("cli_");
const accountId = isAppId ? undefined : accountIdOrAppId || DEFAULT_ACCOUNT_ID;
if (!appSecret && feishuCfg?.accounts) {
if (isAppId) {
// When given an appId, find the account with matching appId
for (const [, acc] of Object.entries(feishuCfg.accounts)) {
if (acc.appId === accountIdOrAppId) {
appId = acc.appId;
appSecret = resolveAppSecret(acc);
domain = acc.domain ?? feishuCfg?.domain;
break;
}
}
// If not found in accounts, use the appId directly (secret from first account as fallback)
if (!appSecret) {
appId = accountIdOrAppId;
const firstKey = Object.keys(feishuCfg.accounts)[0];
if (firstKey) {
const acc = feishuCfg.accounts[firstKey];
appSecret = resolveAppSecret(acc);
domain = acc.domain ?? feishuCfg?.domain;
}
}
} else if (accountId && feishuCfg.accounts[accountId]) {
// Try to get from accounts config by accountId
const acc = feishuCfg.accounts[accountId];
appId = acc.appId;
appSecret = resolveAppSecret(acc);
domain = acc.domain ?? feishuCfg?.domain;
} else if (!accountId) {
// Fallback to first account if accountId is not specified
const firstKey = Object.keys(feishuCfg.accounts)[0];
if (firstKey) {
const acc = feishuCfg.accounts[firstKey];
appId = acc.appId;
appSecret = resolveAppSecret(acc);
domain = acc.domain ?? feishuCfg?.domain;
}
}
}
// Fallback to top-level feishu config (for backward compatibility)
if (!appId && feishuCfg?.appId) {
appId = feishuCfg.appId.trim();
}
if (!appSecret) {
appSecret = resolveAppSecret(feishuCfg);
}
if (!domain) {
domain = feishuCfg?.domain;
}
// Environment variables fallback
if (!appId) {
appId = process.env.FEISHU_APP_ID?.trim();
}
if (!appSecret) {
appSecret = process.env.FEISHU_APP_SECRET?.trim();
}
if (!appId || !appSecret) {
throw new Error(
"Feishu app ID/secret not configured. Set channels.feishu.accounts.<id>.appId/appSecret (or appSecretFile) or FEISHU_APP_ID/FEISHU_APP_SECRET.",
);
}
const resolvedDomain = normalizeFeishuDomain(domain);
const client = new Lark.Client({
appId,
appSecret,
...(resolvedDomain ? { domain: resolvedDomain } : {}),
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);
},
},
});
return client;
}

91
src/feishu/config.ts Normal file
View File

@@ -0,0 +1,91 @@
import type { OpenClawConfig } from "../config/config.js";
import type { DmPolicy, GroupPolicy } from "../config/types.base.js";
import type { FeishuGroupConfig } from "../config/types.feishu.js";
import { firstDefined } from "./access.js";
export type ResolvedFeishuConfig = {
enabled: boolean;
dmPolicy: DmPolicy;
groupPolicy: GroupPolicy;
allowFrom: string[];
groupAllowFrom: string[];
historyLimit: number;
dmHistoryLimit: number;
textChunkLimit: number;
chunkMode: "length" | "newline";
blockStreaming: boolean;
streaming: boolean;
mediaMaxMb: number;
groups: Record<string, FeishuGroupConfig>;
};
/**
* Resolve effective Feishu configuration for an account.
* Account-level config overrides top-level feishu config, which overrides channel defaults.
*/
export function resolveFeishuConfig(params: {
cfg: OpenClawConfig;
accountId?: string;
}): ResolvedFeishuConfig {
const { cfg, accountId } = params;
const feishuCfg = cfg.channels?.feishu;
const accountCfg = accountId ? feishuCfg?.accounts?.[accountId] : undefined;
const defaults = cfg.channels?.defaults;
// Merge with precedence: account > feishu top-level > channel defaults > hardcoded defaults
return {
enabled: firstDefined(accountCfg?.enabled, feishuCfg?.enabled, true) ?? true,
dmPolicy: firstDefined(accountCfg?.dmPolicy, feishuCfg?.dmPolicy) ?? "pairing",
groupPolicy:
firstDefined(accountCfg?.groupPolicy, feishuCfg?.groupPolicy, defaults?.groupPolicy) ??
"open",
allowFrom: (accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []).map(String),
groupAllowFrom: (accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []).map(String),
historyLimit: firstDefined(accountCfg?.historyLimit, feishuCfg?.historyLimit) ?? 10,
dmHistoryLimit: firstDefined(accountCfg?.dmHistoryLimit, feishuCfg?.dmHistoryLimit) ?? 20,
textChunkLimit: firstDefined(accountCfg?.textChunkLimit, feishuCfg?.textChunkLimit) ?? 2000,
chunkMode: firstDefined(accountCfg?.chunkMode, feishuCfg?.chunkMode) ?? "length",
blockStreaming: firstDefined(accountCfg?.blockStreaming, feishuCfg?.blockStreaming) ?? true,
streaming: firstDefined(accountCfg?.streaming, feishuCfg?.streaming) ?? true,
mediaMaxMb: firstDefined(accountCfg?.mediaMaxMb, feishuCfg?.mediaMaxMb) ?? 30,
groups: { ...feishuCfg?.groups, ...accountCfg?.groups },
};
}
/**
* Resolve group-specific configuration for a Feishu chat.
*/
export function resolveFeishuGroupConfig(params: {
cfg: OpenClawConfig;
accountId?: string;
chatId: string;
}): { groupConfig?: FeishuGroupConfig } {
const resolved = resolveFeishuConfig({ cfg: params.cfg, accountId: params.accountId });
const groupConfig = resolved.groups[params.chatId];
return { groupConfig };
}
/**
* Check if a group requires @mention for the bot to respond.
*/
export function resolveFeishuGroupRequireMention(params: {
cfg: OpenClawConfig;
accountId?: string;
chatId: string;
}): boolean {
const { groupConfig } = resolveFeishuGroupConfig(params);
// Default: require mention in groups
return groupConfig?.requireMention ?? true;
}
/**
* Check if a group is enabled.
*/
export function resolveFeishuGroupEnabled(params: {
cfg: OpenClawConfig;
accountId?: string;
chatId: string;
}): boolean {
const { groupConfig } = resolveFeishuGroupConfig(params);
return groupConfig?.enabled ?? true;
}

31
src/feishu/domain.ts Normal file
View File

@@ -0,0 +1,31 @@
export const FEISHU_DOMAIN = "https://open.feishu.cn";
export const LARK_DOMAIN = "https://open.larksuite.com";
export type FeishuDomainInput = string | null | undefined;
export function normalizeFeishuDomain(value?: FeishuDomainInput): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase();
if (lower === "feishu" || lower === "cn" || lower === "china") {
return FEISHU_DOMAIN;
}
if (lower === "lark" || lower === "global" || lower === "intl" || lower === "international") {
return LARK_DOMAIN;
}
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const withoutTrailing = withScheme.replace(/\/+$/, "");
return withoutTrailing.replace(/\/open-apis$/i, "");
}
export function resolveFeishuDomain(value?: FeishuDomainInput): string {
return normalizeFeishuDomain(value) ?? FEISHU_DOMAIN;
}
export function resolveFeishuApiBase(value?: FeishuDomainInput): string {
const base = resolveFeishuDomain(value);
return `${base.replace(/\/+$/, "")}/open-apis`;
}

183
src/feishu/download.ts Normal file
View File

@@ -0,0 +1,183 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { saveMediaBuffer } from "../media/store.js";
const logger = getChildLogger({ module: "feishu-download" });
export type FeishuMediaRef = {
path: string;
contentType?: string;
placeholder: string;
};
type FeishuMessagePayload = {
message_type?: string;
message_id?: string;
content?: string;
};
/**
* Download a resource from a user message using messageResource.get
* This is the correct API for downloading resources from messages sent by users.
*
* @param type - Resource type: "image", "file", "audio", or "video"
*/
export async function downloadFeishuMessageResource(
client: Client,
messageId: string,
fileKey: string,
type: "image" | "file" | "audio" | "video",
maxBytes: number = 30 * 1024 * 1024,
): Promise<FeishuMediaRef> {
logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`);
const res = await client.im.messageResource.get({
params: { type },
path: {
message_id: messageId,
file_key: fileKey,
},
});
if (!res) {
throw new Error(`Failed to get ${type} resource: no response`);
}
const stream = res.getReadableStream();
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of stream) {
totalSize += chunk.length;
if (totalSize > maxBytes) {
throw new Error(`${type} resource exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
}
chunks.push(Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
// Try to detect content type from headers
const contentType =
res.headers?.["content-type"] ?? res.headers?.["Content-Type"] ?? getDefaultContentType(type);
const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: getPlaceholder(type),
};
}
function getDefaultContentType(type: string): string {
switch (type) {
case "image":
return "image/jpeg";
case "audio":
return "audio/ogg";
case "video":
return "video/mp4";
default:
return "application/octet-stream";
}
}
function getPlaceholder(type: string): string {
switch (type) {
case "image":
return "<media:image>";
case "audio":
return "<media:audio>";
case "video":
return "<media:video>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message
* Returns the downloaded media reference or null if no media
*
* Uses messageResource.get API to download resources from user messages.
*/
export async function resolveFeishuMedia(
client: Client,
message: FeishuMessagePayload,
maxBytes: number = 30 * 1024 * 1024,
): Promise<FeishuMediaRef | null> {
const msgType = message.message_type;
const messageId = message.message_id;
if (!messageId) {
logger.warn(`Cannot download media: message_id is missing`);
return null;
}
try {
const rawContent = message.content;
if (!rawContent) {
return null;
}
if (msgType === "image") {
// Image message: content = { image_key: "..." }
const content = JSON.parse(rawContent);
if (content.image_key) {
return await downloadFeishuMessageResource(
client,
messageId,
content.image_key,
"image",
maxBytes,
);
}
} else if (msgType === "file") {
// File message: content = { file_key: "...", file_name: "..." }
const content = JSON.parse(rawContent);
if (content.file_key) {
return await downloadFeishuMessageResource(
client,
messageId,
content.file_key,
"file",
maxBytes,
);
}
} else if (msgType === "audio") {
// Audio message: content = { file_key: "..." }
const content = JSON.parse(rawContent);
if (content.file_key) {
return await downloadFeishuMessageResource(
client,
messageId,
content.file_key,
"audio",
maxBytes,
);
}
} else if (msgType === "media") {
// Video message: content = { file_key: "...", image_key: "..." (thumbnail) }
const content = JSON.parse(rawContent);
if (content.file_key) {
return await downloadFeishuMessageResource(
client,
messageId,
content.file_key,
"video",
maxBytes,
);
}
} else if (msgType === "sticker") {
// Sticker - not supported for download via messageResource API
logger.debug(`Sticker messages are not supported for download`);
return null;
}
} catch (err) {
logger.error(`Failed to resolve Feishu media (${msgType}): ${formatErrorMessage(err)}`);
}
return null;
}

94
src/feishu/format.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { containsMarkdown, markdownToFeishuPost } from "./format.js";
describe("containsMarkdown", () => {
it("detects bold text", () => {
expect(containsMarkdown("Hello **world**")).toBe(true);
});
it("detects italic text", () => {
expect(containsMarkdown("Hello *world*")).toBe(true);
});
it("detects inline code", () => {
expect(containsMarkdown("Run `npm install`")).toBe(true);
});
it("detects code blocks", () => {
expect(containsMarkdown("```js\nconsole.log('hi')\n```")).toBe(true);
});
it("detects links", () => {
expect(containsMarkdown("Visit [Google](https://google.com)")).toBe(true);
});
it("detects headings", () => {
expect(containsMarkdown("# Title")).toBe(true);
});
it("returns false for plain text", () => {
expect(containsMarkdown("Hello world")).toBe(false);
});
it("returns false for empty string", () => {
expect(containsMarkdown("")).toBe(false);
});
});
describe("markdownToFeishuPost", () => {
it("converts plain text", () => {
const result = markdownToFeishuPost("Hello world");
expect(result.zh_cn?.content).toBeDefined();
expect(result.zh_cn?.content[0]).toContainEqual({
tag: "text",
text: "Hello world",
});
});
it("converts bold text", () => {
const result = markdownToFeishuPost("Hello **bold** text");
const content = result.zh_cn?.content[0];
expect(content).toBeDefined();
// Should have at least one element with bold style
const boldElement = content?.find((el) => el.tag === "text" && el.style?.includes("bold"));
expect(boldElement).toBeDefined();
});
it("converts italic text", () => {
const result = markdownToFeishuPost("Hello *italic* text");
const content = result.zh_cn?.content[0];
expect(content).toBeDefined();
const italicElement = content?.find((el) => el.tag === "text" && el.style?.includes("italic"));
expect(italicElement).toBeDefined();
});
it("converts links", () => {
const result = markdownToFeishuPost("Visit [Google](https://google.com)");
const content = result.zh_cn?.content[0];
expect(content).toBeDefined();
const linkElement = content?.find((el) => el.tag === "a");
expect(linkElement).toBeDefined();
if (linkElement && linkElement.tag === "a") {
expect(linkElement.href).toBe("https://google.com");
expect(linkElement.text).toBe("Google");
}
});
it("handles multi-line text", () => {
const result = markdownToFeishuPost("Line 1\nLine 2\nLine 3");
expect(result.zh_cn?.content.length).toBe(3);
});
it("converts code to code style", () => {
const result = markdownToFeishuPost("Run `npm install`");
const content = result.zh_cn?.content[0];
expect(content).toBeDefined();
const codeElement = content?.find((el) => el.tag === "text" && el.style?.includes("code"));
expect(codeElement).toBeDefined();
});
it("handles empty input", () => {
const result = markdownToFeishuPost("");
expect(result.zh_cn?.content).toBeDefined();
});
});

267
src/feishu/format.ts Normal file
View File

@@ -0,0 +1,267 @@
import type { MarkdownTableMode } from "../config/types.base.js";
import {
chunkMarkdownIR,
markdownToIR,
type MarkdownIR,
type MarkdownLinkSpan,
type MarkdownStyleSpan,
} from "../markdown/ir.js";
/**
* Feishu Post (rich text) format
* Reference: https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json#c9e08671
*/
export type FeishuPostElement =
| { tag: "text"; text: string; style?: string[] }
| { tag: "a"; text: string; href: string; style?: string[] }
| { tag: "at"; user_id: string }
| { tag: "img"; image_key: string }
| { tag: "media"; file_key: string }
| { tag: "emotion"; emoji_type: string };
export type FeishuPostLine = FeishuPostElement[];
export type FeishuPostContent = {
zh_cn?: {
title?: string;
content: FeishuPostLine[];
};
en_us?: {
title?: string;
content: FeishuPostLine[];
};
};
export type FeishuFormattedChunk = {
post: FeishuPostContent;
text: string;
};
type StyleState = {
bold: boolean;
italic: boolean;
strikethrough: boolean;
code: boolean;
};
/**
* Convert MarkdownIR to Feishu Post format
*/
function renderFeishuPost(ir: MarkdownIR): FeishuPostContent {
const lines: FeishuPostLine[] = [];
const text = ir.text;
if (!text) {
return { zh_cn: { content: [[{ tag: "text", text: "" }]] } };
}
// Build a map of style ranges for quick lookup
const styleRanges = buildStyleRanges(ir.styles, text.length);
const linkMap = buildLinkMap(ir.links);
// Split text into lines
const textLines = text.split("\n");
let charIndex = 0;
for (const line of textLines) {
const lineElements: FeishuPostElement[] = [];
if (line.length === 0) {
// Empty line - add empty text element
lineElements.push({ tag: "text", text: "" });
} else {
// Process each character segment with consistent styling
let segmentStart = charIndex;
let currentStyles = getStylesAt(styleRanges, segmentStart);
let currentLink = getLinkAt(linkMap, segmentStart);
for (let i = 0; i < line.length; i++) {
const pos = charIndex + i;
const newStyles = getStylesAt(styleRanges, pos);
const newLink = getLinkAt(linkMap, pos);
// Check if style or link changed
const stylesChanged = !stylesEqual(currentStyles, newStyles);
const linkChanged = currentLink !== newLink;
if (stylesChanged || linkChanged) {
// Emit previous segment
const segmentText = text.slice(segmentStart, pos);
if (segmentText) {
lineElements.push(createPostElement(segmentText, currentStyles, currentLink));
}
segmentStart = pos;
currentStyles = newStyles;
currentLink = newLink;
}
}
// Emit final segment of the line
const finalText = text.slice(segmentStart, charIndex + line.length);
if (finalText) {
lineElements.push(createPostElement(finalText, currentStyles, currentLink));
}
}
lines.push(lineElements.length > 0 ? lineElements : [{ tag: "text", text: "" }]);
charIndex += line.length + 1; // +1 for newline
}
return {
zh_cn: {
content: lines,
},
};
}
function buildStyleRanges(styles: MarkdownStyleSpan[], textLength: number): StyleState[] {
const ranges: StyleState[] = Array(textLength)
.fill(null)
.map(() => ({
bold: false,
italic: false,
strikethrough: false,
code: false,
}));
for (const span of styles) {
for (let i = span.start; i < span.end && i < textLength; i++) {
switch (span.style) {
case "bold":
ranges[i].bold = true;
break;
case "italic":
ranges[i].italic = true;
break;
case "strikethrough":
ranges[i].strikethrough = true;
break;
case "code":
case "code_block":
ranges[i].code = true;
break;
}
}
}
return ranges;
}
function buildLinkMap(links: MarkdownLinkSpan[]): Map<number, string> {
const map = new Map<number, string>();
for (const link of links) {
for (let i = link.start; i < link.end; i++) {
map.set(i, link.href);
}
}
return map;
}
function getStylesAt(ranges: StyleState[], pos: number): StyleState {
return ranges[pos] ?? { bold: false, italic: false, strikethrough: false, code: false };
}
function getLinkAt(linkMap: Map<number, string>, pos: number): string | undefined {
return linkMap.get(pos);
}
function stylesEqual(a: StyleState, b: StyleState): boolean {
return (
a.bold === b.bold &&
a.italic === b.italic &&
a.strikethrough === b.strikethrough &&
a.code === b.code
);
}
function createPostElement(text: string, styles: StyleState, link?: string): FeishuPostElement {
const styleArray: string[] = [];
if (styles.bold) {
styleArray.push("bold");
}
if (styles.italic) {
styleArray.push("italic");
}
if (styles.strikethrough) {
styleArray.push("lineThrough");
}
if (styles.code) {
styleArray.push("code");
}
if (link) {
return {
tag: "a",
text,
href: link,
...(styleArray.length > 0 ? { style: styleArray } : {}),
};
}
return {
tag: "text",
text,
...(styleArray.length > 0 ? { style: styleArray } : {}),
};
}
/**
* Convert Markdown to Feishu Post format
*/
export function markdownToFeishuPost(
markdown: string,
options: { tableMode?: MarkdownTableMode } = {},
): FeishuPostContent {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "bold",
blockquotePrefix: " ",
tableMode: options.tableMode,
});
return renderFeishuPost(ir);
}
/**
* Convert Markdown to Feishu Post chunks (for long messages)
*/
export function markdownToFeishuChunks(
markdown: string,
limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): FeishuFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
headingStyle: "bold",
blockquotePrefix: " ",
tableMode: options.tableMode,
});
const chunks = chunkMarkdownIR(ir, limit);
return chunks.map((chunk) => ({
post: renderFeishuPost(chunk),
text: chunk.text,
}));
}
/**
* Check if text contains Markdown formatting
*/
export function containsMarkdown(text: string): boolean {
if (!text) {
return false;
}
// Check for common Markdown patterns
const markdownPatterns = [
/\*\*[^*]+\*\*/, // bold
/\*[^*]+\*/, // italic
/~~[^~]+~~/, // strikethrough
/`[^`]+`/, // inline code
/```[\s\S]*```/, // code block
/\[.+\]\(.+\)/, // links
/^#{1,6}\s/m, // headings
/^[-*]\s/m, // unordered list
/^\d+\.\s/m, // ordered list
];
return markdownPatterns.some((pattern) => pattern.test(text));
}

8
src/feishu/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from "./types.js";
export * from "./client.js";
export * from "./bot.js";
export * from "./send.js";
export * from "./message.js";
export * from "./probe.js";
export * from "./accounts.js";
export * from "./monitor.js";

426
src/feishu/message.ts Normal file
View File

@@ -0,0 +1,426 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import type { OpenClawConfig } from "../config/config.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js";
import {
resolveFeishuConfig,
resolveFeishuGroupConfig,
resolveFeishuGroupEnabled,
type ResolvedFeishuConfig,
} from "./config.js";
import { resolveFeishuMedia, type FeishuMediaRef } from "./download.js";
import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js";
import { sendMessageFeishu } from "./send.js";
import { FeishuStreamingSession } from "./streaming-card.js";
const logger = getChildLogger({ module: "feishu-message" });
type FeishuSender = {
sender_id?: {
open_id?: string;
user_id?: string;
union_id?: string;
};
};
type FeishuMention = {
key?: string;
};
type FeishuMessage = {
chat_id?: string;
chat_type?: string;
message_type?: string;
content?: string;
mentions?: FeishuMention[];
create_time?: string | number;
message_id?: string;
};
type FeishuEventPayload = {
message?: FeishuMessage;
event?: {
message?: FeishuMessage;
sender?: FeishuSender;
};
sender?: FeishuSender;
mentions?: FeishuMention[];
};
// Supported message types for processing
const SUPPORTED_MSG_TYPES = new Set(["text", "image", "file", "audio", "media", "sticker"]);
export type ProcessFeishuMessageOptions = {
cfg?: OpenClawConfig;
accountId?: string;
resolvedConfig?: ResolvedFeishuConfig;
/** Feishu app credentials for streaming card API */
credentials?: { appId: string; appSecret: string; domain?: string };
/** Bot name for streaming card title (optional, defaults to no title) */
botName?: string;
};
export async function processFeishuMessage(
client: Client,
data: unknown,
appId: string,
options: ProcessFeishuMessageOptions = {},
) {
const cfg = options.cfg ?? loadConfig();
const accountId = options.accountId ?? appId;
const feishuCfg = options.resolvedConfig ?? resolveFeishuConfig({ cfg, accountId });
const payload = data as FeishuEventPayload;
// SDK 2.0 schema: data directly contains message, sender, etc.
const message = payload.message ?? payload.event?.message;
const sender = payload.sender ?? payload.event?.sender;
if (!message) {
logger.warn(`Received event without message field`);
return;
}
const chatId = message.chat_id;
if (!chatId) {
logger.warn("Received message without chat_id");
return;
}
const isGroup = message.chat_type === "group";
const msgType = message.message_type;
const senderId = sender?.sender_id?.open_id || sender?.sender_id?.user_id || "unknown";
const senderUnionId = sender?.sender_id?.union_id;
const maxMediaBytes = feishuCfg.mediaMaxMb * 1024 * 1024;
// Check if this is a supported message type
if (!msgType || !SUPPORTED_MSG_TYPES.has(msgType)) {
logger.debug(`Skipping unsupported message type: ${msgType ?? "unknown"}`);
return;
}
// Load allowlist from store
const storeAllowFrom = await readFeishuAllowFromStore().catch(() => []);
// ===== Access Control =====
// Group access control
if (isGroup) {
// Check if group is enabled
if (!resolveFeishuGroupEnabled({ cfg, accountId, chatId })) {
logVerbose(`Blocked feishu group ${chatId} (group disabled)`);
return;
}
const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId });
// Check group-level allowFrom override
if (groupConfig?.allowFrom) {
const groupAllow = normalizeAllowFromWithStore({
allowFrom: groupConfig.allowFrom,
storeAllowFrom,
});
if (!isSenderAllowed({ allow: groupAllow, senderId })) {
logVerbose(`Blocked feishu group sender ${senderId} (group allowFrom override)`);
return;
}
}
// Apply groupPolicy
const groupPolicy = feishuCfg.groupPolicy;
if (groupPolicy === "disabled") {
logVerbose(`Blocked feishu group message (groupPolicy: disabled)`);
return;
}
if (groupPolicy === "allowlist") {
const groupAllow = normalizeAllowFromWithStore({
allowFrom:
feishuCfg.groupAllowFrom.length > 0 ? feishuCfg.groupAllowFrom : feishuCfg.allowFrom,
storeAllowFrom,
});
if (!groupAllow.hasEntries) {
logVerbose(`Blocked feishu group message (groupPolicy: allowlist, no entries)`);
return;
}
if (!isSenderAllowed({ allow: groupAllow, senderId })) {
logVerbose(`Blocked feishu group sender ${senderId} (groupPolicy: allowlist)`);
return;
}
}
}
// DM access control
if (!isGroup) {
const dmPolicy = feishuCfg.dmPolicy;
if (dmPolicy === "disabled") {
logVerbose(`Blocked feishu DM (dmPolicy: disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllow = normalizeAllowFromWithStore({
allowFrom: feishuCfg.allowFrom,
storeAllowFrom,
});
const allowMatch = resolveSenderAllowMatch({ allow: dmAllow, senderId });
const allowed = dmAllow.hasWildcard || (dmAllow.hasEntries && allowMatch.allowed);
if (!allowed) {
if (dmPolicy === "pairing") {
// Generate pairing code for unknown sender
try {
const { code, created } = await upsertFeishuPairingRequest({
openId: senderId,
unionId: senderUnionId,
name: sender?.sender_id?.user_id,
});
if (created) {
logger.info({ openId: senderId, unionId: senderUnionId }, "feishu pairing request");
await sendMessageFeishu(
client,
senderId,
{
text: [
"OpenClaw access not configured.",
"",
`Your Feishu Open ID: ${senderId}`,
"",
`Pairing code: ${code}`,
"",
"Ask the OpenClaw admin to approve with:",
`openclaw pairing approve feishu ${code}`,
].join("\n"),
},
{ receiveIdType: "open_id" },
);
}
} catch (err) {
logger.error(`Failed to create pairing request: ${formatErrorMessage(err)}`);
}
return;
}
// allowlist policy: silently block
logVerbose(`Blocked feishu DM from ${senderId} (dmPolicy: allowlist)`);
return;
}
}
}
// Handle @mentions for group chats
const mentions = message.mentions ?? payload.mentions ?? [];
const wasMentioned = mentions.length > 0;
// In group chat, check requireMention setting
if (isGroup) {
const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId });
const requireMention = groupConfig?.requireMention ?? true;
if (requireMention && !wasMentioned) {
logger.debug(`Ignoring group message without @mention (requireMention: true)`);
return;
}
}
// Extract text content (for text messages or captions)
let text = "";
if (msgType === "text") {
try {
if (message.content) {
const content = JSON.parse(message.content);
text = content.text || "";
}
} catch (err) {
logger.error(`Failed to parse text message content: ${formatErrorMessage(err)}`);
}
}
// Remove @mention placeholders from text
for (const mention of mentions) {
if (mention.key) {
text = text.replace(mention.key, "").trim();
}
}
// Resolve media if present
let media: FeishuMediaRef | null = null;
if (msgType !== "text") {
try {
media = await resolveFeishuMedia(client, message, maxMediaBytes);
} catch (err) {
logger.error(`Failed to download media: ${formatErrorMessage(err)}`);
}
}
// Build body text
let bodyText = text;
if (!bodyText && media) {
bodyText = media.placeholder;
}
// Skip if no content
if (!bodyText && !media) {
logger.debug(`Empty message after processing, skipping`);
return;
}
const senderName = sender?.sender_id?.user_id || "unknown";
// Streaming mode support
const streamingEnabled = (feishuCfg.streaming ?? true) && Boolean(options.credentials);
const streamingSession =
streamingEnabled && options.credentials
? new FeishuStreamingSession(client, options.credentials)
: null;
let streamingStarted = false;
let lastPartialText = "";
// Context construction
const ctx = {
Body: bodyText,
RawBody: text || media?.placeholder || "",
From: senderId,
To: chatId,
SenderId: senderId,
SenderName: senderName,
ChatType: isGroup ? "group" : "dm",
Provider: "feishu",
Surface: "feishu",
Timestamp: Number(message.create_time),
MessageSid: message.message_id,
AccountId: accountId,
OriginatingChannel: "feishu",
OriginatingTo: chatId,
// Media fields (similar to Telegram)
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
WasMentioned: isGroup ? wasMentioned : undefined,
};
await dispatchReplyWithBufferedBlockDispatcher({
ctx,
cfg,
dispatcherOptions: {
deliver: async (payload, info) => {
const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0);
if (!payload.text && !hasMedia) {
return;
}
// Handle block replies - update streaming card with partial text
if (streamingSession?.isActive() && info?.kind === "block" && payload.text) {
logger.debug(`Updating streaming card with block text: ${payload.text.length} chars`);
await streamingSession.update(payload.text);
return;
}
// If streaming was active, close it with the final text
if (streamingSession?.isActive() && info?.kind === "final") {
await streamingSession.close(payload.text);
streamingStarted = false;
return; // Card already contains the final text
}
// Handle media URLs
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaUrls.length > 0) {
// Close streaming session before sending media
if (streamingSession?.isActive()) {
await streamingSession.close();
streamingStarted = false;
}
// Send each media item
for (let i = 0; i < mediaUrls.length; i++) {
const mediaUrl = mediaUrls[i];
const caption = i === 0 ? payload.text || "" : "";
await sendMessageFeishu(
client,
chatId,
{ text: caption },
{
mediaUrl,
receiveIdType: "chat_id",
},
);
}
} else if (payload.text) {
// If streaming wasn't used, send as regular message
if (!streamingSession?.isActive()) {
await sendMessageFeishu(
client,
chatId,
{ text: payload.text },
{
msgType: "text",
receiveIdType: "chat_id",
},
);
}
}
},
onError: (err) => {
logger.error(`Reply error: ${formatErrorMessage(err)}`);
// Clean up streaming session on error
if (streamingSession?.isActive()) {
streamingSession.close().catch(() => {});
}
},
onReplyStart: async () => {
// Start streaming card when reply generation begins
if (streamingSession && !streamingStarted) {
try {
await streamingSession.start(chatId, "chat_id", options.botName);
streamingStarted = true;
logger.debug(`Started streaming card for chat ${chatId}`);
} catch (err) {
logger.warn(`Failed to start streaming card: ${formatErrorMessage(err)}`);
// Continue without streaming
}
}
},
},
replyOptions: {
disableBlockStreaming: !feishuCfg.blockStreaming,
onPartialReply: streamingSession
? async (payload) => {
if (!streamingSession.isActive() || !payload.text) {
return;
}
if (payload.text === lastPartialText) {
return;
}
lastPartialText = payload.text;
await streamingSession.update(payload.text);
}
: undefined,
onReasoningStream: streamingSession
? async (payload) => {
// Also update on reasoning stream for extended thinking models
if (!streamingSession.isActive() || !payload.text) {
return;
}
if (payload.text === lastPartialText) {
return;
}
lastPartialText = payload.text;
await streamingSession.update(payload.text);
}
: undefined,
},
});
// Ensure streaming session is closed on completion
if (streamingSession?.isActive()) {
await streamingSession.close();
}
}

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

@@ -0,0 +1,152 @@
import * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { getChildLogger } from "../logging.js";
import type { RuntimeEnv } from "../runtime.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);
}
}
}

129
src/feishu/pairing-store.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { OpenClawConfig } from "../config/config.js";
import {
addChannelAllowFromStoreEntry,
approveChannelPairingCode,
listChannelPairingRequests,
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
export type FeishuPairingListEntry = {
openId: string;
unionId?: string;
name?: string;
code: string;
createdAt: string;
lastSeenAt: string;
};
const PROVIDER = "feishu" as const;
export async function readFeishuAllowFromStore(
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
return readChannelAllowFromStore(PROVIDER, env);
}
export async function addFeishuAllowFromStoreEntry(params: {
entry: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
return addChannelAllowFromStoreEntry({
channel: PROVIDER,
entry: params.entry,
env: params.env,
});
}
export async function listFeishuPairingRequests(
env: NodeJS.ProcessEnv = process.env,
): Promise<FeishuPairingListEntry[]> {
const list = await listChannelPairingRequests(PROVIDER, env);
return list.map((r) => ({
openId: r.id,
code: r.code,
createdAt: r.createdAt,
lastSeenAt: r.lastSeenAt,
unionId: r.meta?.unionId,
name: r.meta?.name,
}));
}
export async function upsertFeishuPairingRequest(params: {
openId: string;
unionId?: string;
name?: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ code: string; created: boolean }> {
return upsertChannelPairingRequest({
channel: PROVIDER,
id: params.openId,
env: params.env,
meta: {
unionId: params.unionId,
name: params.name,
},
});
}
export async function approveFeishuPairingCode(params: {
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ openId: string; entry?: FeishuPairingListEntry } | null> {
const res = await approveChannelPairingCode({
channel: PROVIDER,
code: params.code,
env: params.env,
});
if (!res) {
return null;
}
const entry = res.entry
? {
openId: res.entry.id,
code: res.entry.code,
createdAt: res.entry.createdAt,
lastSeenAt: res.entry.lastSeenAt,
unionId: res.entry.meta?.unionId,
name: res.entry.meta?.name,
}
: undefined;
return { openId: res.id, entry };
}
export async function resolveFeishuEffectiveAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ dm: string[]; group: string[] }> {
const env = params.env ?? process.env;
const feishuCfg = params.cfg.channels?.feishu;
const accountCfg = params.accountId ? feishuCfg?.accounts?.[params.accountId] : undefined;
// Account-level config takes precedence over top-level
const allowFrom = accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? [];
const groupAllowFrom = accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? [];
const cfgAllowFrom = allowFrom
.map((v) => String(v).trim())
.filter(Boolean)
.map((v) => v.replace(/^feishu:/i, ""))
.filter((v) => v !== "*");
const cfgGroupAllowFrom = groupAllowFrom
.map((v) => String(v).trim())
.filter(Boolean)
.map((v) => v.replace(/^feishu:/i, ""))
.filter((v) => v !== "*");
const storeAllowFrom = await readFeishuAllowFromStore(env);
const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom]));
const group = Array.from(
new Set([
...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom),
...storeAllowFrom,
]),
);
return { dm, group };
}

122
src/feishu/probe.ts Normal file
View File

@@ -0,0 +1,122 @@
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { resolveFeishuApiBase } from "./domain.js";
const logger = getChildLogger({ module: "feishu-probe" });
export type FeishuProbe = {
ok: boolean;
error?: string | null;
elapsedMs: number;
bot?: {
appId?: string | null;
appName?: string | null;
avatarUrl?: string | null;
};
};
type TokenResponse = {
code: number;
msg: string;
tenant_access_token?: string;
expire?: number;
};
type BotInfoResponse = {
code: number;
msg: string;
bot?: {
app_name?: string;
avatar_url?: string;
open_id?: string;
};
};
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function probeFeishu(
appId: string,
appSecret: string,
timeoutMs: number = 5000,
domain?: string,
): Promise<FeishuProbe> {
const started = Date.now();
const result: FeishuProbe = {
ok: false,
error: null,
elapsedMs: 0,
};
const apiBase = resolveFeishuApiBase(domain);
try {
// Step 1: Get tenant_access_token
const tokenRes = await fetchWithTimeout(
`${apiBase}/auth/v3/tenant_access_token/internal`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
},
timeoutMs,
);
const tokenJson = (await tokenRes.json()) as TokenResponse;
if (tokenJson.code !== 0 || !tokenJson.tenant_access_token) {
result.error = tokenJson.msg || `Failed to get access token: code ${tokenJson.code}`;
result.elapsedMs = Date.now() - started;
return result;
}
const accessToken = tokenJson.tenant_access_token;
// Step 2: Get bot info
const botRes = await fetchWithTimeout(
`${apiBase}/bot/v3/info`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
timeoutMs,
);
const botJson = (await botRes.json()) as BotInfoResponse;
if (botJson.code !== 0) {
result.error = botJson.msg || `Failed to get bot info: code ${botJson.code}`;
result.elapsedMs = Date.now() - started;
return result;
}
result.ok = true;
result.bot = {
appId: appId,
appName: botJson.bot?.app_name ?? null,
avatarUrl: botJson.bot?.avatar_url ?? null,
};
result.elapsedMs = Date.now() - started;
return result;
} catch (err) {
const errMsg = formatErrorMessage(err);
logger.debug?.(`Feishu probe failed: ${errMsg}`);
return {
...result,
error: errMsg,
elapsedMs: Date.now() - started,
};
}
}

319
src/feishu/send.ts Normal file
View File

@@ -0,0 +1,319 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js";
import { loadWebMedia } from "../web/media.js";
import { containsMarkdown, markdownToFeishuPost } from "./format.js";
const logger = getChildLogger({ module: "feishu-send" });
export type FeishuMsgType = "text" | "image" | "file" | "audio" | "media" | "post" | "interactive";
export type FeishuSendOpts = {
msgType?: FeishuMsgType;
receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id";
/** URL of media to upload and send (for image/file/audio/media types) */
mediaUrl?: string;
/** Max bytes for media download */
maxBytes?: number;
/** Whether to auto-convert Markdown to rich text (post). Default: true */
autoRichText?: boolean;
};
export type FeishuSendResult = {
message_id?: string;
};
type FeishuMessageContent = ({ text?: string } & Record<string, unknown>) | string;
/**
* Upload an image to Feishu and get image_key
*/
export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise<string> {
const res = await client.im.image.create({
data: {
image_type: "message",
image: imageBuffer,
},
});
if (!res?.image_key) {
throw new Error(`Feishu image upload failed: no image_key returned`);
}
return res.image_key;
}
/**
* Upload a file to Feishu and get file_key
* @param fileType - opus (audio), mp4 (video), pdf, doc, xls, ppt, stream (other)
*/
export async function uploadFileFeishu(
client: Client,
fileBuffer: Buffer,
fileName: string,
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream",
duration?: number,
): Promise<string> {
logger.info(
`Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`,
);
let res: Awaited<ReturnType<typeof client.im.file.create>>;
try {
res = await client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
file: fileBuffer,
...(duration ? { duration } : {}),
},
});
} catch (err) {
const errMsg = formatErrorMessage(err);
// Log the full error details
logger.error(`Feishu file upload exception: ${errMsg}`);
if (err && typeof err === "object") {
const response = (err as { response?: { data?: unknown; status?: number } }).response;
if (response?.data) {
logger.error(`Response data: ${JSON.stringify(response.data)}`);
}
if (response?.status) {
logger.error(`Response status: ${response.status}`);
}
}
throw new Error(`Feishu file upload failed: ${errMsg}`, { cause: err });
}
// Log full response for debugging
logger.info(`Feishu file upload response: ${JSON.stringify(res)}`);
const responseMeta =
res && typeof res === "object" ? (res as { code?: number; msg?: string }) : {};
// Check for API error code (if provided by SDK)
if (typeof responseMeta.code === "number" && responseMeta.code !== 0) {
const code = responseMeta.code;
const msg = responseMeta.msg || "unknown error";
logger.error(`Feishu file upload API error: code=${code}, msg=${msg}`);
throw new Error(`Feishu file upload failed: ${msg} (code: ${code})`);
}
const fileKey = res?.file_key;
if (!fileKey) {
logger.error(`Feishu file upload failed - no file_key in response: ${JSON.stringify(res)}`);
throw new Error(`Feishu file upload failed: no file_key returned`);
}
logger.info(`Feishu file upload successful: file_key=${fileKey}`);
return fileKey;
}
/**
* Determine Feishu file_type from content type
*/
function resolveFeishuFileType(
contentType?: string,
fileName?: string,
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
const ct = contentType?.toLowerCase() ?? "";
const fn = fileName?.toLowerCase() ?? "";
// Audio - Feishu only supports opus for audio messages
if (ct.includes("audio/") || fn.endsWith(".opus") || fn.endsWith(".ogg")) {
return "opus";
}
// Video
if (ct.includes("video/") || fn.endsWith(".mp4") || fn.endsWith(".mov")) {
return "mp4";
}
// Documents
if (ct.includes("pdf") || fn.endsWith(".pdf")) {
return "pdf";
}
if (
ct.includes("msword") ||
ct.includes("wordprocessingml") ||
fn.endsWith(".doc") ||
fn.endsWith(".docx")
) {
return "doc";
}
if (
ct.includes("excel") ||
ct.includes("spreadsheetml") ||
fn.endsWith(".xls") ||
fn.endsWith(".xlsx")
) {
return "xls";
}
if (
ct.includes("powerpoint") ||
ct.includes("presentationml") ||
fn.endsWith(".ppt") ||
fn.endsWith(".pptx")
) {
return "ppt";
}
return "stream";
}
/**
* Send a message to Feishu
*/
export async function sendMessageFeishu(
client: Client,
receiveId: string,
content: FeishuMessageContent,
opts: FeishuSendOpts = {},
): Promise<FeishuSendResult | null> {
const receiveIdType = opts.receiveIdType || "chat_id";
let msgType = opts.msgType || "text";
let finalContent = content;
const contentText =
typeof content === "object" && content !== null && "text" in content
? (content as { text?: string }).text
: undefined;
// Handle media URL - upload first, then send
if (opts.mediaUrl) {
try {
logger.info(`Loading media from: ${opts.mediaUrl}`);
const media = await loadWebMedia(opts.mediaUrl, opts.maxBytes);
const kind = mediaKindFromMime(media.contentType ?? undefined);
const fileName = media.fileName ?? "file";
logger.info(
`Media loaded: kind=${kind}, contentType=${media.contentType}, fileName=${fileName}, size=${media.buffer.length}`,
);
if (kind === "image") {
// Upload image and send as image message
const imageKey = await uploadImageFeishu(client, media.buffer);
msgType = "image";
finalContent = { image_key: imageKey };
} else if (kind === "video") {
// Upload video file and send as media message
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "mp4");
msgType = "media";
finalContent = { file_key: fileKey };
} else if (kind === "audio") {
// Feishu audio messages (msg_type: "audio") only support opus format
// For other audio formats (mp3, wav, etc.), send as file instead
const isOpus =
media.contentType?.includes("opus") ||
media.contentType?.includes("ogg") ||
fileName.toLowerCase().endsWith(".opus") ||
fileName.toLowerCase().endsWith(".ogg");
if (isOpus) {
logger.info(`Uploading opus audio: ${fileName}`);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "opus");
logger.info(`Opus upload successful, file_key: ${fileKey}`);
msgType = "audio";
finalContent = { file_key: fileKey };
} else {
// Send non-opus audio as file attachment
logger.info(`Uploading non-opus audio as file: ${fileName}`);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "stream");
logger.info(`File upload successful, file_key: ${fileKey}`);
msgType = "file";
finalContent = { file_key: fileKey };
}
} else {
// Upload as file
const fileType = resolveFeishuFileType(media.contentType, fileName);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, fileType);
msgType = "file";
finalContent = { file_key: fileKey };
}
// If there's text alongside media, we need to send two messages
// First send the media, then send text as a follow-up
if (typeof contentText === "string" && contentText.trim()) {
// Send media first
const mediaRes = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: msgType,
content: JSON.stringify(finalContent),
},
});
if (mediaRes.code !== 0) {
logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`);
throw new Error(`Feishu API Error: ${mediaRes.msg}`);
}
// Then send text
const textRes = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "text",
content: JSON.stringify({ text: contentText }),
},
});
return textRes.data ?? null;
}
} catch (err) {
const errMsg = formatErrorMessage(err);
const errStack = err instanceof Error ? err.stack : undefined;
logger.error(`Feishu media upload/send error: ${errMsg}`);
if (errStack) {
logger.error(`Stack: ${errStack}`);
}
// Re-throw the error instead of falling back to text
// This makes debugging easier and prevents silent failures
throw new Error(`Feishu media upload failed: ${errMsg}`, { cause: err });
}
}
// Auto-convert Markdown to rich text if enabled and content is text with Markdown
const autoRichText = opts.autoRichText !== false;
const finalText =
typeof finalContent === "object" && finalContent !== null && "text" in finalContent
? (finalContent as { text?: string }).text
: undefined;
if (
autoRichText &&
msgType === "text" &&
typeof finalText === "string" &&
containsMarkdown(finalText)
) {
try {
const postContent = markdownToFeishuPost(finalText);
msgType = "post";
finalContent = postContent;
logger.debug(`Converted Markdown to Feishu post format`);
} catch (err) {
logger.warn(
`Failed to convert Markdown to post, falling back to text: ${formatErrorMessage(err)}`,
);
// Fall back to plain text
}
}
const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent);
try {
const res = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
},
});
if (res.code !== 0) {
logger.error(`Feishu send failed: ${res.code} - ${res.msg}`);
throw new Error(`Feishu API Error: ${res.msg}`);
}
return res.data ?? null;
} catch (err) {
logger.error(`Feishu send error: ${formatErrorMessage(err)}`);
throw err;
}
}

View File

@@ -0,0 +1,404 @@
/**
* Feishu Streaming Card Support
*
* Implements typing indicator and streaming text output for Feishu using
* the Card Kit streaming API.
*
* Flow:
* 1. Create a card entity with streaming_mode: true
* 2. Send the card as a message (shows "[Generating...]" in chat preview)
* 3. Stream text updates to the card using the cardkit API
* 4. Close streaming mode when done
*/
import type { Client } from "@larksuiteoapi/node-sdk";
import { getChildLogger } from "../logging.js";
import { resolveFeishuApiBase, resolveFeishuDomain } from "./domain.js";
const logger = getChildLogger({ module: "feishu-streaming" });
export type FeishuStreamingCredentials = {
appId: string;
appSecret: string;
domain?: string;
};
export type FeishuStreamingCardState = {
cardId: string;
messageId: string;
sequence: number;
elementId: string;
currentText: string;
};
// Token cache (keyed by domain + appId)
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
const getTokenCacheKey = (credentials: FeishuStreamingCredentials) =>
`${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`;
/**
* Get tenant access token (with caching)
*/
async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise<string> {
const cacheKey = getTokenCacheKey(credentials);
const cached = tokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now() + 60000) {
return cached.token;
}
const apiBase = resolveFeishuApiBase(credentials.domain);
const response = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: credentials.appId,
app_secret: credentials.appSecret,
}),
});
const result = (await response.json()) as {
code: number;
msg: string;
tenant_access_token?: string;
expire?: number;
};
if (result.code !== 0 || !result.tenant_access_token) {
throw new Error(`Failed to get tenant access token: ${result.msg}`);
}
// Cache token (expire 2 hours, we refresh 1 minute early)
tokenCache.set(cacheKey, {
token: result.tenant_access_token,
expiresAt: Date.now() + (result.expire ?? 7200) * 1000,
});
return result.tenant_access_token;
}
/**
* Create a streaming card entity
*/
export async function createStreamingCard(
credentials: FeishuStreamingCredentials,
title?: string,
): Promise<{ cardId: string }> {
const cardJson = {
schema: "2.0",
...(title
? {
header: {
title: {
content: title,
tag: "plain_text",
},
},
}
: {}),
config: {
streaming_mode: true,
summary: {
content: "[Generating...]",
},
streaming_config: {
print_frequency_ms: { default: 50 },
print_step: { default: 2 },
print_strategy: "fast",
},
},
body: {
elements: [
{
tag: "markdown",
content: "⏳ Thinking...",
element_id: "streaming_content",
},
],
},
};
const apiBase = resolveFeishuApiBase(credentials.domain);
const response = await fetch(`${apiBase}/cardkit/v1/cards`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "card_json",
data: JSON.stringify(cardJson),
}),
});
const result = (await response.json()) as {
code: number;
msg: string;
data?: { card_id: string };
};
if (result.code !== 0 || !result.data?.card_id) {
throw new Error(`Failed to create streaming card: ${result.msg}`);
}
logger.debug(`Created streaming card: ${result.data.card_id}`);
return { cardId: result.data.card_id };
}
/**
* Send a streaming card as a message
*/
export async function sendStreamingCard(
client: Client,
receiveId: string,
cardId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
): Promise<{ messageId: string }> {
const content = JSON.stringify({
type: "card",
data: { card_id: cardId },
});
const res = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "interactive",
content,
},
});
if (res.code !== 0 || !res.data?.message_id) {
throw new Error(`Failed to send streaming card: ${res.msg}`);
}
logger.debug(`Sent streaming card message: ${res.data.message_id}`);
return { messageId: res.data.message_id };
}
/**
* Update streaming card text content
*/
export async function updateStreamingCardText(
credentials: FeishuStreamingCredentials,
cardId: string,
elementId: string,
text: string,
sequence: number,
): Promise<void> {
const apiBase = resolveFeishuApiBase(credentials.domain);
const response = await fetch(
`${apiBase}/cardkit/v1/cards/${cardId}/elements/${elementId}/content`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence,
uuid: `stream_${cardId}_${sequence}`,
}),
},
);
const result = (await response.json()) as { code: number; msg: string };
if (result.code !== 0) {
logger.warn(`Failed to update streaming card text: ${result.msg}`);
// Don't throw - streaming updates can fail occasionally
}
}
/**
* Close streaming mode on a card
*/
export async function closeStreamingMode(
credentials: FeishuStreamingCredentials,
cardId: string,
sequence: number,
finalSummary?: string,
): Promise<void> {
// Build config object - summary must be set to clear "[Generating...]"
const configObj: Record<string, unknown> = {
streaming_mode: false,
summary: { content: finalSummary || "" },
};
const settings = { config: configObj };
const apiBase = resolveFeishuApiBase(credentials.domain);
const response = await fetch(`${apiBase}/cardkit/v1/cards/${cardId}/settings`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
settings: JSON.stringify(settings),
sequence,
uuid: `close_${cardId}_${sequence}`,
}),
});
// Check response
const result = (await response.json()) as { code: number; msg: string };
if (result.code !== 0) {
logger.warn(`Failed to close streaming mode: ${result.msg}`);
} else {
logger.debug(`Closed streaming mode for card: ${cardId}`);
}
}
/**
* High-level streaming card manager
*/
export class FeishuStreamingSession {
private client: Client;
private credentials: FeishuStreamingCredentials;
private state: FeishuStreamingCardState | null = null;
private updateQueue: Promise<void> = Promise.resolve();
private closed = false;
constructor(client: Client, credentials: FeishuStreamingCredentials) {
this.client = client;
this.credentials = credentials;
}
/**
* Start a streaming session - creates and sends a streaming card
*/
async start(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
title?: string,
): Promise<void> {
if (this.state) {
logger.warn("Streaming session already started");
return;
}
try {
const { cardId } = await createStreamingCard(this.credentials, title);
const { messageId } = await sendStreamingCard(this.client, receiveId, cardId, receiveIdType);
this.state = {
cardId,
messageId,
sequence: 1,
elementId: "streaming_content",
currentText: "",
};
logger.info(`Started streaming session: cardId=${cardId}, messageId=${messageId}`);
} catch (err) {
logger.error(`Failed to start streaming session: ${String(err)}`);
throw err;
}
}
/**
* Update the streaming card with new text (appends to existing)
*/
async update(text: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
// Queue updates to ensure order
this.updateQueue = this.updateQueue.then(async () => {
if (!this.state || this.closed) {
return;
}
this.state.currentText = text;
this.state.sequence += 1;
try {
await updateStreamingCardText(
this.credentials,
this.state.cardId,
this.state.elementId,
text,
this.state.sequence,
);
} catch (err) {
logger.debug(`Streaming update failed (will retry): ${String(err)}`);
}
});
await this.updateQueue;
}
/**
* Finalize and close the streaming session
*/
async close(finalText?: string, summary?: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
this.closed = true;
// Wait for pending updates
await this.updateQueue;
const text = finalText ?? this.state.currentText;
this.state.sequence += 1;
try {
// Update final text
if (text) {
await updateStreamingCardText(
this.credentials,
this.state.cardId,
this.state.elementId,
text,
this.state.sequence,
);
}
// Close streaming mode
this.state.sequence += 1;
await closeStreamingMode(
this.credentials,
this.state.cardId,
this.state.sequence,
summary ?? truncateForSummary(text),
);
logger.info(`Closed streaming session: cardId=${this.state.cardId}`);
} catch (err) {
logger.error(`Failed to close streaming session: ${String(err)}`);
}
}
/**
* Check if session is active
*/
isActive(): boolean {
return this.state !== null && !this.closed;
}
/**
* Get the message ID of the streaming card
*/
getMessageId(): string | null {
return this.state?.messageId ?? null;
}
}
/**
* Truncate text to create a summary for chat preview
*/
function truncateForSummary(text: string, maxLength: number = 50): string {
if (!text) {
return "";
}
const cleaned = text.replace(/\n/g, " ").trim();
if (cleaned.length <= maxLength) {
return cleaned;
}
return cleaned.slice(0, maxLength - 3) + "...";
}

14
src/feishu/types.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { FeishuAccountConfig, FeishuConfig } from "../config/types.feishu.js";
export type { FeishuConfig, FeishuAccountConfig };
export type FeishuContext = {
appId: string;
chatId?: string;
openId?: string;
userId?: string;
messageId?: string;
messageType?: string;
text?: string;
raw?: unknown;
};

View File

@@ -816,6 +816,61 @@ function resolveTlonSession(
};
}
/**
* Feishu ID formats:
* - oc_xxx: chat_id (group chat)
* - ou_xxx: user open_id (DM)
* - on_xxx: user union_id (DM)
* - cli_xxx: app_id (not a valid send target)
*/
function resolveFeishuSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = stripProviderPrefix(params.target, "feishu");
trimmed = stripProviderPrefix(trimmed, "lark").trim();
if (!trimmed) {
return null;
}
const lower = trimmed.toLowerCase();
let isGroup = false;
if (lower.startsWith("group:") || lower.startsWith("chat:")) {
trimmed = trimmed.replace(/^(group|chat):/i, "").trim();
isGroup = true;
} else if (lower.startsWith("user:") || lower.startsWith("dm:")) {
trimmed = trimmed.replace(/^(user|dm):/i, "").trim();
isGroup = false;
}
const idLower = trimmed.toLowerCase();
if (idLower.startsWith("oc_")) {
isGroup = true;
} else if (idLower.startsWith("ou_") || idLower.startsWith("on_")) {
isGroup = false;
}
const peer: RoutePeer = {
kind: isGroup ? "group" : "dm",
id: trimmed,
};
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "feishu",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: isGroup ? `feishu:group:${trimmed}` : `feishu:${trimmed}`,
to: trimmed,
};
}
function resolveFallbackSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
@@ -890,6 +945,8 @@ export async function resolveOutboundSessionRoute(
return resolveNostrSession({ ...params, target });
case "tlon":
return resolveTlonSession({ ...params, target });
case "feishu":
return resolveFeishuSession({ ...params, target });
default:
return resolveFallbackSession({ ...params, target });
}

View File

@@ -369,5 +369,22 @@ export {
} from "../line/markdown-to-line.js";
export type { ProcessedLineMessage } from "../line/markdown-to-line.js";
// Channel: Feishu
export {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
type ResolvedFeishuAccount,
} from "../feishu/accounts.js";
export {
resolveFeishuConfig,
resolveFeishuGroupEnabled,
resolveFeishuGroupRequireMention,
} from "../feishu/config.js";
export { feishuOutbound } from "../channels/plugins/outbound/feishu.js";
export { normalizeFeishuTarget } from "../channels/plugins/normalize/feishu.js";
export { probeFeishu, type FeishuProbe } from "../feishu/probe.js";
export { monitorFeishuProvider } from "../feishu/monitor.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";

View File

@@ -120,7 +120,7 @@ vi.mock("../auto-reply/reply.js", () => {
describe("telegram inbound media", () => {
// Parallel vitest shards can make this suite slower than the standalone run.
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000;
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
it(
"downloads media via file_path (no file.download)",