diff --git a/CHANGELOG.md b/CHANGELOG.md index f318f24c41..b4e9dee565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md new file mode 100644 index 0000000000..e9f165575f --- /dev/null +++ b/docs/channels/feishu.md @@ -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..domain`). + +```json5 +{ + channels: { + feishu: { + domain: "lark", + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx" + } + } + } + } +} +``` + +--- + +## Step 3: Start + test + +### 1. Start the gateway + +```bash +openclaw gateway +``` + +### 2. Send a test message + +In Feishu, find your bot and send a message. + +### 3. Approve pairing + +By default, the bot replies with a pairing code. Approve it: + +```bash +openclaw pairing approve feishu +``` + +After approval, you can chat normally. + +--- + +## Overview + +- **Feishu bot channel**: Feishu bot managed by the gateway +- **Deterministic routing**: replies always return to Feishu +- **Session isolation**: DMs share a main session; groups are isolated +- **WebSocket connection**: long connection via Feishu SDK, no public URL needed + +--- + +## Access control + +### Direct messages + +- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code) +- **Approve pairing**: + ```bash + openclaw pairing list feishu + openclaw pairing approve feishu + ``` +- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs + +### Group chats + +**1. Group policy** (`channels.feishu.groupPolicy`): + +- `"open"` = allow everyone in groups (default) +- `"allowlist"` = only allow `groupAllowFrom` +- `"disabled"` = disable group messages + +**2. Mention requirement** (`channels.feishu.groups..requireMention`): + +- `true` = require @mention (default) +- `false` = respond without mentions + +--- + +## Group configuration examples + +### Allow all groups, require @mention (default) + +```json5 +{ + channels: { + feishu: { + groupPolicy: "open" + // Default requireMention: true + } + } +} +``` + +### Allow all groups, no @mention required + +```json5 +{ + channels: { + feishu: { + groups: { + "oc_xxx": { requireMention: false } + } + } + } +} +``` + +### Allow specific users in groups only + +```json5 +{ + channels: { + feishu: { + groupPolicy: "allowlist", + groupAllowFrom: ["ou_xxx", "ou_yyy"] + } + } +} +``` + +--- + +## Get group/user IDs + +### Group IDs (chat_id) + +Group IDs look like `oc_xxx`. + +**Method 1 (recommended)** + +1. Start the gateway and @mention the bot in the group +2. Run `openclaw logs --follow` and look for `chat_id` + +**Method 2** + +Use the Feishu API debugger to list group chats. + +### User IDs (open_id) + +User IDs look like `ou_xxx`. + +**Method 1 (recommended)** + +1. Start the gateway and DM the bot +2. Run `openclaw logs --follow` and look for `open_id` + +**Method 2** + +Check pairing requests for user Open IDs: + +```bash +openclaw pairing list feishu +``` + +--- + +## Common commands + +| Command | Description | +|---------|-------------| +| `/status` | Show bot status | +| `/reset` | Reset the session | +| `/model` | Show/switch model | + +> Note: Feishu does not support native command menus yet, so commands must be sent as text. + +## Gateway management commands + +| Command | Description | +|---------|-------------| +| `openclaw gateway status` | Show gateway status | +| `openclaw gateway install` | Install/start gateway service | +| `openclaw gateway stop` | Stop gateway service | +| `openclaw gateway restart` | Restart gateway service | +| `openclaw logs --follow` | Tail gateway logs | + +--- + +## Troubleshooting + +### Bot does not respond in group chats + +1. Ensure the bot is added to the group +2. Ensure you @mention the bot (default behavior) +3. Check `groupPolicy` is not set to `"disabled"` +4. Check logs: `openclaw logs --follow` + +### Bot does not receive messages + +1. Ensure the app is published and approved +2. Ensure event subscription includes `im.message.receive_v1` +3. Ensure **long connection** is enabled +4. Ensure app permissions are complete +5. Ensure the gateway is running: `openclaw gateway status` +6. Check logs: `openclaw logs --follow` + +### App Secret leak + +1. Reset the App Secret in Feishu Open Platform +2. Update the App Secret in your config +3. Restart the gateway + +### Message send failures + +1. Ensure the app has `im:message:send_as_bot` permission +2. Ensure the app is published +3. Check logs for detailed errors + +--- + +## Advanced configuration + +### Multiple accounts + +```json5 +{ + channels: { + feishu: { + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + botName: "Primary bot" + }, + backup: { + appId: "cli_yyy", + appSecret: "yyy", + botName: "Backup bot", + enabled: false + } + } + } + } +} +``` + +### Message limits + +- `textChunkLimit`: outbound text chunk size (default: 2000 chars) +- `mediaMaxMb`: media upload/download limit (default: 30MB) + +### Streaming + +Feishu does not support message editing, so block streaming is enabled by default (`blockStreaming: true`). The bot waits for the full reply before sending. + +--- + +## Configuration reference + +Full configuration: [Gateway configuration](/gateway/configuration) + +Key options: + +| Setting | Description | Default | +|---------|-------------|---------| +| `channels.feishu.enabled` | Enable/disable channel | `true` | +| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | +| `channels.feishu.accounts..appId` | App ID | - | +| `channels.feishu.accounts..appSecret` | App Secret | - | +| `channels.feishu.accounts..domain` | Per-account API domain override | `feishu` | +| `channels.feishu.dmPolicy` | DM policy | `pairing` | +| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | +| `channels.feishu.groupPolicy` | Group policy | `open` | +| `channels.feishu.groupAllowFrom` | Group allowlist | - | +| `channels.feishu.groups..requireMention` | Require @mention | `true` | +| `channels.feishu.groups..enabled` | Enable group | `true` | +| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | +| `channels.feishu.mediaMaxMb` | Media size limit | `30` | +| `channels.feishu.blockStreaming` | Disable streaming | `true` | + +--- + +## dmPolicy reference + +| Value | Behavior | +|-------|----------| +| `"pairing"` | **Default.** Unknown users get a pairing code; must be approved | +| `"allowlist"` | Only users in `allowFrom` can chat | +| `"open"` | Allow all users (requires `"*"` in allowFrom) | +| `"disabled"` | Disable DMs | + +--- + +## Supported message types + +### Receive + +- ✅ Text +- ✅ Images +- ✅ Files +- ✅ Audio +- ✅ Video +- ✅ Stickers + +### Send + +- ✅ Text +- ✅ Images +- ✅ Files +- ✅ Audio +- ⚠️ Rich text (partial support) diff --git a/docs/images/feishu-step2-create-app.png b/docs/images/feishu-step2-create-app.png new file mode 100644 index 0000000000..c759a8f7e5 Binary files /dev/null and b/docs/images/feishu-step2-create-app.png differ diff --git a/docs/images/feishu-step3-credentials.png b/docs/images/feishu-step3-credentials.png new file mode 100644 index 0000000000..45c69a075c Binary files /dev/null and b/docs/images/feishu-step3-credentials.png differ diff --git a/docs/images/feishu-step4-permissions.png b/docs/images/feishu-step4-permissions.png new file mode 100644 index 0000000000..180f83c15a Binary files /dev/null and b/docs/images/feishu-step4-permissions.png differ diff --git a/docs/images/feishu-step5-bot-capability.png b/docs/images/feishu-step5-bot-capability.png new file mode 100644 index 0000000000..9bac00c539 Binary files /dev/null and b/docs/images/feishu-step5-bot-capability.png differ diff --git a/docs/images/feishu-step6-event-subscription.png b/docs/images/feishu-step6-event-subscription.png new file mode 100644 index 0000000000..a97932d7a2 Binary files /dev/null and b/docs/images/feishu-step6-event-subscription.png differ diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md new file mode 100644 index 0000000000..aaab4dca50 --- /dev/null +++ b/docs/zh-CN/channels/feishu.md @@ -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..domain`: + +```json5 +{ + channels: { + feishu: { + domain: "lark", + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx" + } + } + } + } +} +``` + +--- + +## 第三步:启动并测试 + +### 1. 启动网关 + +```bash +openclaw gateway +``` + +### 2. 发送测试消息 + +在飞书中找到您创建的机器人,发送一条消息。 + +### 3. 配对授权 + +默认情况下,机器人会回复一个 **配对码**。您需要批准此代码: + +```bash +openclaw pairing approve feishu <配对码> +``` + +批准后即可正常对话。 + +--- + +## 介绍 + +- **飞书机器人渠道**:由网关管理的飞书机器人 +- **确定性路由**:回复始终返回飞书,模型不会选择渠道 +- **会话隔离**:私聊共享主会话;群组独立隔离 +- **WebSocket 连接**:使用飞书 SDK 的长连接模式,无需公网 URL + +--- + +## 访问控制 + +### 私聊访问 + +- **默认**:`dmPolicy: "pairing"`,陌生用户会收到配对码 +- **批准配对**: + ```bash + openclaw pairing list feishu # 查看待审批列表 + openclaw pairing approve feishu # 批准 + ``` +- **白名单模式**:通过 `channels.feishu.allowFrom` 配置允许的用户 Open ID + +### 群组访问 + +**1. 群组策略**(`channels.feishu.groupPolicy`): +- `"open"` = 允许群组中所有人(默认) +- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户 +- `"disabled"` = 禁用群组消息 + +**2. @提及要求**(`channels.feishu.groups..requireMention`): +- `true` = 需要 @机器人才响应(默认) +- `false` = 无需 @也响应 + +--- + +## 群组配置示例 + +### 允许所有群组,需要 @提及(默认行为) + +```json5 +{ + channels: { + feishu: { + groupPolicy: "open" + // 默认 requireMention: true + } + } +} +``` + +### 允许所有群组,无需 @提及 + +需要为特定群组配置: + +```json5 +{ + channels: { + feishu: { + groups: { + "oc_xxx": { requireMention: false } + } + } + } +} +``` + +### 仅允许特定用户在群组中使用 + +```json5 +{ + channels: { + feishu: { + groupPolicy: "allowlist", + groupAllowFrom: ["ou_xxx", "ou_yyy"] + } + } +} +``` + +--- + +## 获取群组/用户 ID + +### 获取群组 ID(chat_id) + +群组 ID 格式为 `oc_xxx`,可以通过以下方式获取: + +**方法一**(推荐): +1. 启动网关并在群组中 @机器人发消息 +2. 运行 `openclaw logs --follow` 查看日志中的 `chat_id` + +**方法二**: +使用飞书 API 调试工具获取机器人所在群组列表。 + +### 获取用户 ID(open_id) + +用户 ID 格式为 `ou_xxx`,可以通过以下方式获取: + +**方法一**(推荐): +1. 启动网关并给机器人发消息 +2. 运行 `openclaw logs --follow` 查看日志中的 `open_id` + +**方法二**: +查看配对请求列表,其中包含用户的 Open ID: +```bash +openclaw pairing list feishu +``` + +--- + +## 常用命令 + +| 命令 | 说明 | +|------|------| +| `/status` | 查看机器人状态 | +| `/reset` | 重置对话会话 | +| `/model` | 查看/切换模型 | + +> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送。 + +## 网关管理命令 + +在配置和使用飞书渠道时,您可能需要使用以下网关管理命令: + +| 命令 | 说明 | +|------|------| +| `openclaw gateway status` | 查看网关运行状态 | +| `openclaw gateway install` | 安装/启动网关服务 | +| `openclaw gateway stop` | 停止网关服务 | +| `openclaw gateway restart` | 重启网关服务 | +| `openclaw logs --follow` | 实时查看日志输出 | + +--- + +## 故障排除 + +### 机器人在群组中不响应 + +1. 检查机器人是否已添加到群组 +2. 检查是否 @了机器人(默认需要 @提及) +3. 检查 `groupPolicy` 是否为 `"disabled"` +4. 查看日志:`openclaw logs --follow` + +### 机器人收不到消息 + +1. 检查应用是否已发布并审批通过 +2. 检查事件订阅是否配置正确(`im.message.receive_v1`) +3. 检查是否选择了 **长连接** 模式 +4. 检查应用权限是否完整 +5. 检查网关是否正在运行:`openclaw gateway status` +6. 查看实时日志:`openclaw logs --follow` + +### App Secret 泄露怎么办 + +1. 在飞书开放平台重置 App Secret +2. 更新配置文件中的 App Secret +3. 重启网关 + +### 发送消息失败 + +1. 检查应用是否有 `im:message:send_as_bot` 权限 +2. 检查应用是否已发布 +3. 查看日志获取详细错误信息 + +--- + +## 高级配置 + +### 多账号配置 + +如果需要管理多个飞书机器人: + +```json5 +{ + channels: { + feishu: { + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + botName: "主机器人" + }, + backup: { + appId: "cli_yyy", + appSecret: "yyy", + botName: "备用机器人", + enabled: false // 暂时禁用 + } + } + } + } +} +``` + +### 消息限制 + +- `textChunkLimit`:出站文本分块大小(默认 2000 字符) +- `mediaMaxMb`:媒体上传/下载限制(默认 30MB) + +### 流式输出 + +飞书目前不支持消息编辑,因此默认禁用流式输出(`blockStreaming: true`)。机器人会等待完整回复后一次性发送。 + +--- + +## 配置参考 + +完整配置请参考:[网关配置](/gateway/configuration) + +主要选项: + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `channels.feishu.enabled` | 启用/禁用渠道 | `true` | +| `channels.feishu.domain` | API 域名(`feishu` 或 `lark`) | `feishu` | +| `channels.feishu.accounts..appId` | 应用 App ID | - | +| `channels.feishu.accounts..appSecret` | 应用 App Secret | - | +| `channels.feishu.accounts..domain` | 单账号 API 域名覆盖 | `feishu` | +| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` | +| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - | +| `channels.feishu.groupPolicy` | 群组策略 | `open` | +| `channels.feishu.groupAllowFrom` | 群组白名单 | - | +| `channels.feishu.groups..requireMention` | 是否需要 @提及 | `true` | +| `channels.feishu.groups..enabled` | 是否启用该群组 | `true` | +| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | +| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | +| `channels.feishu.blockStreaming` | 禁用流式输出 | `true` | + +--- + +## dmPolicy 策略说明 + +| 值 | 行为 | +|----|------| +| `"pairing"` | **默认**。未知用户收到配对码,管理员批准后才能对话 | +| `"allowlist"` | 仅 `allowFrom` 列表中的用户可对话,其他静默忽略 | +| `"open"` | 允许所有人对话(需在 allowFrom 中加 `"*"`) | +| `"disabled"` | 完全禁止私聊 | + +--- + +## 支持的消息类型 + +### 接收 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ 音频 +- ✅ 视频 +- ✅ 表情包 + +### 发送 + +- ✅ 文本消息 +- ✅ 图片 +- ✅ 文件 +- ✅ 音频 +- ⚠️ 富文本(部分支持) diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 0000000000..adeeba5f6c --- /dev/null +++ b/extensions/feishu/index.ts @@ -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; diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json new file mode 100644 index 0000000000..93fb800f4d --- /dev/null +++ b/extensions/feishu/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "feishu", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 0000000000..be06e427d6 --- /dev/null +++ b/extensions/feishu/package.json @@ -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" + } + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 0000000000..b79ceed1ef --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,276 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + feishuOutbound, + formatPairingApproveHint, + listFeishuAccountIds, + monitorFeishuProvider, + normalizeFeishuTarget, + PAIRING_APPROVED_MESSAGE, + probeFeishu, + resolveFeishuAccount, + resolveFeishuConfig, + resolveFeishuGroupRequireMention, + setAccountEnabledInConfigSection, + type ChannelAccountSnapshot, + type ChannelPlugin, + type ChannelStatusIssue, + type ResolvedFeishuAccount, +} from "openclaw/plugin-sdk"; +import { FeishuConfigSchema } from "./config-schema.js"; +import { feishuOnboardingAdapter } from "./onboarding.js"; + +const meta = { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu (Lark Open Platform)", + detailLabel: "Feishu Bot", + docsPath: "/channels/feishu", + docsLabel: "feishu", + blurb: "Feishu/Lark bot via WebSocket.", + aliases: ["lark"], + order: 35, + quickstartAllowFrom: true, +}; + +const normalizeAllowEntry = (entry: string) => + entry.replace(/^(feishu|lark):/i, "").trim(); + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + meta, + onboarding: feishuOnboardingAdapter, + pairing: { + idLabel: "feishuOpenId", + normalizeAllowEntry: normalizeAllowEntry, + notifyApproval: async ({ cfg, id }) => { + const account = resolveFeishuAccount({ cfg }); + if (!account.config.appId || !account.config.appSecret) { + throw new Error("Feishu app credentials not configured"); + } + await feishuOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE }); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.feishu"] }, + outbound: feishuOutbound, + messaging: { + normalizeTarget: normalizeFeishuTarget, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = (normalized ?? raw).trim(); + if (!value) { + return false; + } + return /^o[cun]_[a-zA-Z0-9]+$/.test(value) || /^(user|group|chat):/i.test(value); + }, + hint: "", + }, + }, + configSchema: buildChannelConfigSchema(FeishuConfigSchema), + config: { + listAccountIds: (cfg) => listFeishuAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "feishu", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "feishu", + accountId, + clearBaseFields: ["appId", "appSecret", "appSecretFile", "name", "botName"], + }), + isConfigured: (account) => account.tokenSource !== "none", + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.tokenSource !== "none", + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry))) + .map((entry) => (entry === "*" ? entry : entry.toLowerCase())), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.feishu?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.feishu.accounts.${resolvedAccountId}.` + : "channels.feishu."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("feishu"), + normalizeEntry: normalizeAllowEntry, + }; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + if (!groupId) { + return true; + } + return resolveFeishuGroupRequireMention({ + cfg, + accountId: accountId ?? undefined, + chatId: groupId, + }); + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }); + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + const peers = resolved.allowFrom + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => normalizeAllowEntry(entry)) + .filter((entry) => (normalizedQuery ? entry.toLowerCase().includes(normalizedQuery) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }); + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + const groups = Object.keys(resolved.groups ?? {}) + .filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return groups; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + const issues: ChannelStatusIssue[] = []; + for (const account of accounts) { + if (!account.configured) { + issues.push({ + channel: "feishu", + accountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + kind: "config", + message: "Feishu app ID/secret not configured", + }); + } + } + return issues; + }, + buildChannelSummary: async ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeFeishu(account.config.appId, account.config.appSecret, timeoutMs, account.config.domain), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const configured = account.tokenSource !== "none"; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + logSelfId: ({ account, runtime }) => { + const appId = account.config.appId; + if (appId) { + runtime.log?.(`feishu:${appId}`); + } + }, + }, + gateway: { + startAccount: async (ctx) => { + const { account, log, setStatus, abortSignal, cfg, runtime } = ctx; + const { appId, appSecret, domain } = account.config; + if (!appId || !appSecret) { + throw new Error("Feishu app ID/secret not configured"); + } + + let feishuBotLabel = ""; + try { + const probe = await probeFeishu(appId, appSecret, 5000, domain); + if (probe.ok && probe.bot?.appName) { + feishuBotLabel = ` (${probe.bot.appName})`; + } + if (probe.ok && probe.bot) { + setStatus({ accountId: account.accountId, bot: probe.bot }); + } + } catch (err) { + log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } + + log?.info(`[${account.accountId}] starting Feishu provider${feishuBotLabel}`); + setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + }); + + try { + await monitorFeishuProvider({ + appId, + appSecret, + accountId: account.accountId, + config: cfg, + runtime, + abortSignal, + }); + } catch (err) { + setStatus({ + accountId: account.accountId, + running: false, + lastError: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, + }, +}; diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts new file mode 100644 index 0000000000..beb7060ded --- /dev/null +++ b/extensions/feishu/src/config-schema.ts @@ -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(), +}); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 0000000000..a3992b3ba9 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -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 { + await prompter.note( + [ + "Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).", + "Copy the App ID and App Secret from the app credentials page.", + "Lark (global): use open.larksuite.com and set domain=\"lark\".", + `Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`, + ].join("\n"), + "Feishu setup", + ); +} + +function normalizeAllowEntry(entry: string): string { + return entry.replace(/^(feishu|lark):/i, "").trim(); +} + +function resolveDomainChoice(domain?: string | null): "feishu" | "lark" { + const normalized = String(domain ?? "").toLowerCase(); + if (normalized.includes("lark") || normalized.includes("larksuite")) { + return "lark"; + } + return "feishu"; +} + +async function promptFeishuAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string | null; +}): Promise { + const { cfg, prompter } = params; + const accountId = normalizeAccountId(params.accountId); + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + const existingAllowFrom = isDefault + ? (cfg.channels?.feishu?.allowFrom ?? []) + : (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []); + + const entry = await prompter.text({ + message: "Feishu allowFrom (open_id or union_id)", + placeholder: "ou_xxx", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const entries = raw + .split(/[\n,;]+/g) + .map((item) => normalizeAllowEntry(item)) + .filter(Boolean); + const invalid = entries.filter( + (item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item), + ); + if (invalid.length > 0) { + return `Invalid Feishu ids: ${invalid.join(", ")}`; + } + return undefined; + }, + }); + + const parsed = String(entry) + .split(/[\n,;]+/g) + .map((item) => normalizeAllowEntry(item)) + .filter(Boolean); + const merged = [ + ...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))), + ...parsed, + ].filter(Boolean); + const unique = Array.from(new Set(merged)); + + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + accounts: { + ...cfg.channels?.feishu?.accounts, + [accountId]: { + ...cfg.channels?.feishu?.accounts?.[accountId], + enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Feishu", + channel, + policyKey: "channels.feishu.dmPolicy", + allowFromKey: "channels.feishu.allowFrom", + getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), + promptAllowFrom: promptFeishuAllowFrom, +}; + +function updateFeishuConfig( + cfg: OpenClawConfig, + accountId: string, + updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean }, +): OpenClawConfig { + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + const next = { ...cfg } as OpenClawConfig; + const feishu = { ...next.channels?.feishu } as Record; + const accounts = feishu.accounts ? { ...(feishu.accounts as Record) } : undefined; + + if (isDefault && !accounts) { + return { + ...next, + channels: { + ...next.channels, + feishu: { + ...feishu, + ...updates, + enabled: updates.enabled ?? true, + }, + }, + }; + } + + const resolvedAccounts = accounts ?? {}; + const existing = (resolvedAccounts[accountId] as Record) ?? {}; + resolvedAccounts[accountId] = { + ...existing, + ...updates, + enabled: updates.enabled ?? true, + }; + + return { + ...next, + channels: { + ...next.channels, + feishu: { + ...feishu, + accounts: resolvedAccounts, + }, + }, + }; +} + +export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + dmPolicy, + getStatus: async ({ cfg }) => { + const configured = listFeishuAccountIds(cfg).some((id) => { + const acc = resolveFeishuAccount({ cfg, accountId: id }); + return acc.tokenSource !== "none"; + }); + return { + channel, + configured, + statusLines: [ + `Feishu: ${configured ? "configured" : "needs app credentials"}`, + ], + selectionHint: configured ? "configured" : "requires app credentials", + quickstartScore: configured ? 1 : 10, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + let next = cfg; + const override = accountOverrides.feishu?.trim(); + const defaultId = resolveDefaultFeishuAccountId(next); + let accountId = override ? normalizeAccountId(override) : defaultId; + + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg: next, + prompter, + label: "Feishu", + currentId: accountId, + listAccountIds: listFeishuAccountIds, + defaultAccountId: defaultId, + }); + } + + await noteFeishuSetup(prompter); + + const resolved = resolveFeishuAccount({ cfg: next, accountId }); + const domainChoice = await prompter.select({ + message: "Feishu domain", + options: [ + { value: "feishu", label: "Feishu (China) — open.feishu.cn" }, + { value: "lark", label: "Lark (global) — open.larksuite.com" }, + ], + initialValue: resolveDomainChoice(resolved.config.domain), + }); + const domain = domainChoice === "lark" ? "lark" : "feishu"; + + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + const envAppId = process.env.FEISHU_APP_ID?.trim(); + const envSecret = process.env.FEISHU_APP_SECRET?.trim(); + if (isDefault && envAppId && envSecret) { + const useEnv = await prompter.confirm({ + message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?", + initialValue: true, + }); + if (useEnv) { + next = updateFeishuConfig(next, accountId, { enabled: true, domain }); + return { cfg: next, accountId }; + } + } + const appId = String( + await prompter.text({ + message: "Feishu App ID (cli_...)", + initialValue: resolved.config.appId?.trim() || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + const appSecret = String( + await prompter.text({ + message: "Feishu App Secret", + initialValue: resolved.config.appSecret?.trim() || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true }); + + return { cfg: next, accountId }; + }, +}; diff --git a/package.json b/package.json index 951f3d79a7..52df6a21ea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 124e396696..be243a85a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/channels/plugins/normalize/feishu.ts b/src/channels/plugins/normalize/feishu.ts new file mode 100644 index 0000000000..bd5efae754 --- /dev/null +++ b/src/channels/plugins/normalize/feishu.ts @@ -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; +} diff --git a/src/channels/plugins/outbound/feishu.ts b/src/channels/plugins/outbound/feishu.ts new file mode 100644 index 0000000000..707d0d0d00 --- /dev/null +++ b/src/channels/plugins/outbound/feishu.ts @@ -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, + }; + }, +}; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index b6319f3a53..f48f516955 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -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; diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts new file mode 100644 index 0000000000..a775dc723a --- /dev/null +++ b/src/config/types.feishu.ts @@ -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; + /** Optional system prompt snippet for this group. */ + systemPrompt?: string; +}; + +export type FeishuAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Feishu app ID (cli_xxx). */ + appId?: string; + /** Feishu app secret. */ + appSecret?: string; + /** Path to file containing app secret (for secret managers). */ + appSecretFile?: string; + /** API domain override: "feishu" (default), "lark" (global), or full https:// domain. */ + domain?: string; + /** Bot display name (used for streaming card title). */ + botName?: string; + /** If false, do not start this Feishu account. Default: true. */ + enabled?: boolean; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; + /** Override native command registration for Feishu (bool or "auto"). */ + commands?: ProviderCommandsConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** + * Controls how Feishu direct chats (DMs) are handled: + * - "pairing" (default): unknown senders get a pairing code; owner must approve + * - "allowlist": only allow senders in allowFrom (or paired allow store) + * - "open": allow all inbound DMs (requires allowFrom to include "*") + * - "disabled": ignore all inbound DMs + */ + dmPolicy?: DmPolicy; + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Allowlist for DM senders (open_id or union_id). */ + allowFrom?: Array; + /** Optional allowlist for Feishu group senders. */ + groupAllowFrom?: Array; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user open_id. */ + dms?: Record; + /** Per-group config keyed by chat_id (oc_xxx). */ + groups?: Record; + /** Outbound text chunk size (chars). Default: 2000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** + * Enable streaming card mode for replies (shows typing indicator). + * When true, replies are streamed via Feishu's CardKit API with typewriter effect. + * Default: true. + */ + streaming?: boolean; + /** Media max size in MB. */ + mediaMaxMb?: number; + /** Retry policy for outbound Feishu API calls. */ + retry?: OutboundRetryConfig; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; +}; + +export type FeishuConfig = { + /** Optional per-account Feishu configuration (multi-account). */ + accounts?: Record; + /** Top-level app ID (alternative to accounts). */ + appId?: string; + /** Top-level app secret (alternative to accounts). */ + appSecret?: string; + /** Top-level app secret file (alternative to accounts). */ + appSecretFile?: string; +} & Omit; diff --git a/src/config/types.ts b/src/config/types.ts index 96249e41de..8575b7be5d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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"; diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 44f2589148..3f4ed10177 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -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; - - 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; - 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; - - 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; - 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"); diff --git a/src/feishu/access.ts b/src/feishu/access.ts new file mode 100644 index 0000000000..12a0df57d1 --- /dev/null +++ b/src/feishu/access.ts @@ -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): 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; + storeAllowFrom?: string[]; +}): NormalizedAllowFrom => { + const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])] + .map((value) => String(value).trim()) + .filter(Boolean); + return normalizeAllowFrom(combined); +}; + +export const firstDefined = (...values: Array) => { + 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 }; +}; diff --git a/src/feishu/accounts.ts b/src/feishu/accounts.ts new file mode 100644 index 0000000000..5b917a7eeb --- /dev/null +++ b/src/feishu/accounts.ts @@ -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; +} { + 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(); + + 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, + }; +} diff --git a/src/feishu/bot.ts b/src/feishu/bot.ts new file mode 100644 index 0000000000..c9ba9d8722 --- /dev/null +++ b/src/feishu/bot.ts @@ -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) { + logger.info("Starting Feishu bot WS client..."); + await bot.wsClient.start({ + eventDispatcher: bot.eventDispatcher, + }); +} diff --git a/src/feishu/client.ts b/src/feishu/client.ts new file mode 100644 index 0000000000..083c010612 --- /dev/null +++ b/src/feishu/client.ts @@ -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..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; +} diff --git a/src/feishu/config.ts b/src/feishu/config.ts new file mode 100644 index 0000000000..0c82e7740c --- /dev/null +++ b/src/feishu/config.ts @@ -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; +}; + +/** + * 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; +} diff --git a/src/feishu/domain.ts b/src/feishu/domain.ts new file mode 100644 index 0000000000..49c8e593b3 --- /dev/null +++ b/src/feishu/domain.ts @@ -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`; +} diff --git a/src/feishu/download.ts b/src/feishu/download.ts new file mode 100644 index 0000000000..9beccdb67c --- /dev/null +++ b/src/feishu/download.ts @@ -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 { + 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 ""; + case "audio": + return ""; + case "video": + return ""; + default: + return ""; + } +} + +/** + * 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 { + 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; +} diff --git a/src/feishu/format.test.ts b/src/feishu/format.test.ts new file mode 100644 index 0000000000..dea45a8d42 --- /dev/null +++ b/src/feishu/format.test.ts @@ -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(); + }); +}); diff --git a/src/feishu/format.ts b/src/feishu/format.ts new file mode 100644 index 0000000000..444af5f797 --- /dev/null +++ b/src/feishu/format.ts @@ -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 { + const map = new Map(); + 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, 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)); +} diff --git a/src/feishu/index.ts b/src/feishu/index.ts new file mode 100644 index 0000000000..1f4aaaeae5 --- /dev/null +++ b/src/feishu/index.ts @@ -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"; diff --git a/src/feishu/message.ts b/src/feishu/message.ts new file mode 100644 index 0000000000..a3724588ce --- /dev/null +++ b/src/feishu/message.ts @@ -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(); + } +} diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts new file mode 100644 index 0000000000..7085874497 --- /dev/null +++ b/src/feishu/monitor.ts @@ -0,0 +1,152 @@ +import * as Lark from "@larksuiteoapi/node-sdk"; +import type { OpenClawConfig } from "../config/config.js"; +import { 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 { + const cfg = opts.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: opts.accountId, + }); + + const appId = opts.appId?.trim() || account.config.appId; + const appSecret = opts.appSecret?.trim() || account.config.appSecret; + const domain = normalizeFeishuDomain(account.config.domain); + const accountId = account.accountId; + + if (!appId || !appSecret) { + throw new Error( + `Feishu app ID/secret missing for account "${accountId}" (set channels.feishu.accounts.${accountId}.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET).`, + ); + } + + // Resolve effective config for this account + const feishuCfg = resolveFeishuConfig({ cfg, accountId }); + + // Check if account is enabled + if (!feishuCfg.enabled) { + logger.info(`Feishu account "${accountId}" is disabled, skipping monitor`); + return; + } + + // Create Lark client for API calls + const client = new Lark.Client({ + appId, + appSecret, + ...(domain ? { domain } : {}), + logger: { + debug: (msg) => { + logger.debug?.(msg); + }, + info: (msg) => { + logger.info(msg); + }, + warn: (msg) => { + logger.warn(msg); + }, + error: (msg) => { + logger.error(msg); + }, + trace: (msg) => { + logger.silly?.(msg); + }, + }, + }); + + // Create event dispatcher + const eventDispatcher = new Lark.EventDispatcher({}).register({ + "im.message.receive_v1": async (data) => { + logger.info(`Received Feishu message event`); + try { + await processFeishuMessage(client, data, appId, { + cfg, + accountId, + resolvedConfig: feishuCfg, + credentials: { appId, appSecret, domain }, + botName: account.name, + }); + } catch (err) { + logger.error(`Error processing Feishu message: ${String(err)}`); + } + }, + }); + + // Create WebSocket client + const wsClient = new Lark.WSClient({ + appId, + appSecret, + ...(domain ? { domain } : {}), + loggerLevel: Lark.LoggerLevel.info, + logger: { + debug: (msg) => { + logger.debug?.(msg); + }, + info: (msg) => { + logger.info(msg); + }, + warn: (msg) => { + logger.warn(msg); + }, + error: (msg) => { + logger.error(msg); + }, + trace: (msg) => { + logger.silly?.(msg); + }, + }, + }); + + // Handle abort signal + const handleAbort = () => { + logger.info("Stopping Feishu WS client..."); + // WSClient doesn't have a stop method exposed, but it should handle disconnection + // We'll let the process handle cleanup + }; + + if (opts.abortSignal) { + opts.abortSignal.addEventListener("abort", handleAbort, { once: true }); + } + + try { + logger.info("Starting Feishu WebSocket client..."); + await wsClient.start({ eventDispatcher }); + logger.info("Feishu WebSocket connection established"); + + // The WSClient.start() should keep running until disconnected + // If it returns, we need to keep the process alive + // Wait for abort signal + if (opts.abortSignal) { + await new Promise((resolve) => { + if (opts.abortSignal?.aborted) { + resolve(); + return; + } + opts.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } else { + // If no abort signal, wait indefinitely + await new Promise(() => {}); + } + } finally { + if (opts.abortSignal) { + opts.abortSignal.removeEventListener("abort", handleAbort); + } + } +} diff --git a/src/feishu/pairing-store.ts b/src/feishu/pairing-store.ts new file mode 100644 index 0000000000..44f9015de7 --- /dev/null +++ b/src/feishu/pairing-store.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/feishu/probe.ts b/src/feishu/probe.ts new file mode 100644 index 0000000000..bfe33eab22 --- /dev/null +++ b/src/feishu/probe.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/src/feishu/send.ts b/src/feishu/send.ts new file mode 100644 index 0000000000..977e2a107c --- /dev/null +++ b/src/feishu/send.ts @@ -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; + +/** + * Upload an image to Feishu and get image_key + */ +export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise { + 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 { + logger.info( + `Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`, + ); + + let res: Awaited>; + 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 { + 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; + } +} diff --git a/src/feishu/streaming-card.ts b/src/feishu/streaming-card.ts new file mode 100644 index 0000000000..ecc1c9fa37 --- /dev/null +++ b/src/feishu/streaming-card.ts @@ -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(); + +const getTokenCacheKey = (credentials: FeishuStreamingCredentials) => + `${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`; + +/** + * Get tenant access token (with caching) + */ +async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise { + 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 { + 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 { + // Build config object - summary must be set to clear "[Generating...]" + const configObj: Record = { + 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 = 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 { + 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 { + 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 { + 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) + "..."; +} diff --git a/src/feishu/types.ts b/src/feishu/types.ts new file mode 100644 index 0000000000..32eef75441 --- /dev/null +++ b/src/feishu/types.ts @@ -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; +}; diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c31b18aca5..c1538b8424 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -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 }); } diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5eb5cbfbe2..e010e3749d 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -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"; diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 6e4b70c434..7b9f851671 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -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)",