diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index e706b59ad0e..5f4b9ed56f5 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -6,296 +6,32 @@ read_when: title: Feishu --- -# Feishu bot +# Feishu / Lark -Feishu (Lark) is a team chat platform used by companies for messaging and collaboration. This plugin connects OpenClaw to a Feishu/Lark bot using the platform’s WebSocket event subscription so messages can be received without exposing a public webhook URL. +Feishu/Lark is an all-in-one collaboration platform where teams chat, share documents, manage calendars, and get work done together. + +**Status:** production-ready for bot DMs + group chats. WebSocket is the default mode; webhook mode is optional. --- -## Bundled plugin - -Feishu ships bundled with current OpenClaw releases, so no separate plugin install -is required. - -If you are using an older build or a custom install that does not include bundled -Feishu, install it manually: - -```bash -openclaw plugins install @openclaw/feishu -``` - ---- - -## Quickstart - -There are two ways to add the Feishu channel: - -### Method 1: onboarding (recommended) - -If you just installed OpenClaw, run onboarding: - -```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](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", - "cardkit:card:read", - "cardkit:card: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` -3. (Optional) For Drive comment workflows, also add: `drive.notice.comment_add_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", - name: "My AI assistant", - }, - }, - }, - }, -} -``` - -If you use `connectionMode: "webhook"`, set both `verificationToken` and `encryptKey`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. - -#### Verification Token and Encrypt Key (webhook mode) - -When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values: - -1. In Feishu Open Platform, open your app -2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调) -3. Open the **Encryption** tab (加密策略) -4. Copy **Verification Token** and **Encrypt Key** - -The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section. - -![Verification Token location](/images/feishu-verification-token.png) - -### 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", - }, - }, - }, - }, -} -``` - -### Quota optimization flags - -You can reduce Feishu API usage with two optional flags: - -- `typingIndicator` (default `true`): when `false`, skip typing reaction calls. -- `resolveSenderNames` (default `true`): when `false`, skip sender profile lookup calls. - -Set them at top level or per account: - -```json5 -{ - channels: { - feishu: { - typingIndicator: false, - resolveSenderNames: false, - accounts: { - main: { - appId: "cli_xxx", - appSecret: "xxx", - typingIndicator: true, - resolveSenderNames: false, - }, - }, - }, - }, -} -``` - ---- - -## 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 +## Quick start + +> **Requires OpenClaw 2026.4.10 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`. + + + + ```bash + openclaw channels login --channel feishu + ``` + Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically. + + + + ```bash + openclaw gateway restart + ``` + + --- @@ -303,38 +39,43 @@ After approval, you can chat normally. ### Direct messages -- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code) -- **Approve pairing**: +Configure `dmPolicy` to control who can DM the bot: - ```bash - openclaw pairing list feishu - openclaw pairing approve feishu - ``` +- `"pairing"` — unknown users receive a pairing code; approve via CLI +- `"allowlist"` — only users listed in `allowFrom` can chat (default: bot owner only) +- `"open"` — allow all users +- `"disabled"` — disable all DMs -- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs +**Approve a pairing request:** + +```bash +openclaw pairing list feishu +openclaw pairing approve feishu +``` ### Group chats -**1. Group policy** (`channels.feishu.groupPolicy`): +**Group policy** (`channels.feishu.groupPolicy`): -- `"open"` = allow everyone in groups -- `"allowlist"` = only allow `groupAllowFrom` -- `"disabled"` = disable group messages +| Value | Behavior | +| ------------- | ------------------------------------------ | +| `"open"` | Respond to all messages in groups | +| `"allowlist"` | Only respond to groups in `groupAllowFrom` | +| `"disabled"` | Disable all group messages | Default: `allowlist` -**2. Mention requirement** (`channels.feishu.requireMention`, overridable via `channels.feishu.groups..requireMention`): +**Mention requirement** (`channels.feishu.requireMention`): -- explicit `true` = require @mention -- explicit `false` = respond without mentions -- when unset and `groupPolicy: "open"` = default to `false` -- when unset and `groupPolicy` is not `"open"` = default to `true` +- `true` — require @mention (default) +- `false` — respond without @mention +- Per-group override: `channels.feishu.groups..requireMention` --- ## Group configuration examples -### Allow all groups, no @mention required (default for open groups) +### Allow all groups, no @mention required ```json5 { @@ -346,7 +87,7 @@ Default: `allowlist` } ``` -### Allow all groups, but still require @mention +### Allow all groups, still require @mention ```json5 { @@ -366,16 +107,14 @@ Default: `allowlist` channels: { feishu: { groupPolicy: "allowlist", - // Feishu group IDs (chat_id) look like: oc_xxx + // Group IDs look like: oc_xxx groupAllowFrom: ["oc_xxx", "oc_yyy"], }, }, } ``` -### Restrict which senders can message in a group (sender allowlist) - -In addition to allowing the group itself, **all messages** in that group are gated by the sender open_id: only users listed in `groups..allowFrom` have their messages processed; messages from other members are ignored (this is full sender-level gating, not only for control commands like /reset or /new). +### Restrict senders within a group ```json5 { @@ -385,7 +124,7 @@ In addition to allowing the group itself, **all messages** in that group are gat groupAllowFrom: ["oc_xxx"], groups: { oc_xxx: { - // Feishu user IDs (open_id) look like: ou_xxx + // User open_ids look like: ou_xxx allowFrom: ["ou_user1", "ou_user2"], }, }, @@ -396,35 +135,23 @@ In addition to allowing the group itself, **all messages** in that group are gat --- - - ## Get group/user IDs -### Group IDs (chat_id) +### Group IDs (`chat_id`, format: `oc_xxx`) -Group IDs look like `oc_xxx`. +Open the group in Feishu/Lark, click the menu icon in the top-right corner, and go to **Settings**. The group ID (`chat_id`) is listed on the settings page. -**Method 1 (recommended)** +![Get Group ID](/images/feishu-get-group-id.png) -1. Start the gateway and @mention the bot in the group -2. Run `openclaw logs --follow` and look for `chat_id` +### User IDs (`open_id`, format: `ou_xxx`) -**Method 2** +Start the gateway, send a DM to the bot, then check the logs: -Use the Feishu API debugger to list group chats. +```bash +openclaw logs --follow +``` -### 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: +Look for `open_id` in the log output. You can also check pending pairing requests: ```bash openclaw pairing list feishu @@ -434,23 +161,13 @@ openclaw pairing list feishu ## Common commands -| Command | Description | -| --------- | ----------------- | -| `/status` | Show bot status | -| `/reset` | Reset the session | -| `/model` | Show/switch model | +| Command | Description | +| --------- | --------------------------- | +| `/status` | Show bot status | +| `/reset` | Reset the current session | +| `/model` | Show or switch the AI 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 | +> Feishu/Lark does not support native slash-command menus, so send these as plain text messages. --- @@ -459,30 +176,24 @@ openclaw pairing list feishu ### 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"` +2. Ensure you @mention the bot (required by default) +3. Verify `groupPolicy` is not `"disabled"` 4. Check logs: `openclaw logs --follow` ### Bot does not receive messages -1. Ensure the app is published and approved +1. Ensure the bot is published and approved in Feishu Open Platform / Lark Developer 2. Ensure event subscription includes `im.message.receive_v1` -3. Ensure **long connection** is enabled -4. Ensure app permissions are complete +3. Ensure **persistent connection** (WebSocket) is selected +4. Ensure all required permission scopes are granted 5. Ensure the gateway is running: `openclaw gateway status` 6. Check logs: `openclaw logs --follow` -### App Secret leak +### App Secret leaked -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 +1. Reset the App Secret in Feishu Open Platform / Lark Developer +2. Update the value in your config +3. Restart the gateway: `openclaw gateway restart` --- @@ -513,42 +224,53 @@ openclaw pairing list feishu } ``` -`defaultAccount` controls which Feishu account is used when outbound APIs do not specify an `accountId` explicitly. +`defaultAccount` controls which account is used when outbound APIs do not specify an `accountId`. ### Message limits -- `textChunkLimit`: outbound text chunk size (default: 2000 chars) -- `mediaMaxMb`: media upload/download limit (default: 30MB) +- `textChunkLimit` — outbound text chunk size (default: `2000` chars) +- `mediaMaxMb` — media upload/download limit (default: `30` MB) ### Streaming -Feishu supports streaming replies via interactive cards. When enabled, the bot updates a card as it generates text. +Feishu/Lark supports streaming replies via interactive cards. When enabled, the bot updates the card in real time as it generates text. ```json5 { channels: { feishu: { - streaming: true, // enable streaming card output (default true) - blockStreaming: true, // enable block-level streaming (default true) + streaming: true, // enable streaming card output (default: true) + blockStreaming: true, // enable block-level streaming (default: true) }, }, } ``` -Set `streaming: false` to wait for the full reply before sending. +Set `streaming: false` to send the complete reply in one message. + +### Quota optimization + +Reduce the number of Feishu/Lark API calls with two optional flags: + +- `typingIndicator` (default `true`): set `false` to skip typing reaction calls +- `resolveSenderNames` (default `true`): set `false` to skip sender profile lookups + +```json5 +{ + channels: { + feishu: { + typingIndicator: false, + resolveSenderNames: false, + }, + }, +} +``` ### ACP sessions -Feishu supports ACP for: +Feishu/Lark supports ACP for DMs and group thread messages. Feishu/Lark ACP is text-command driven — there are no native slash-command menus, so use `/acp ...` messages directly in the conversation. -- DMs -- group topic conversations - -Feishu ACP is text-command driven. There are no native slash-command menus, so use `/acp ...` messages directly in the conversation. - -#### Persistent ACP bindings - -Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a persistent ACP session. +#### Persistent ACP binding ```json5 { @@ -592,58 +314,39 @@ Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a p } ``` -#### Thread-bound ACP spawn from chat +#### Spawn ACP from chat -In a Feishu DM or topic conversation, you can spawn and bind an ACP session in place: +In a Feishu/Lark DM or thread: ```text /acp spawn codex --thread here ``` -Notes: - -- `--thread here` works for DMs and Feishu topics. -- Follow-up messages in the bound DM/topic route directly to that ACP session. -- v1 does not target generic non-topic group chats. +`--thread here` works for DMs and Feishu/Lark thread messages. Follow-up messages in the bound conversation route directly to that ACP session. ### Multi-agent routing -Use `bindings` to route Feishu DMs or groups to different agents. +Use `bindings` to route Feishu/Lark DMs or groups to different agents. ```json5 { agents: { list: [ { id: "main" }, - { - id: "clawd-fan", - workspace: "/home/user/clawd-fan", - agentDir: "/home/user/.openclaw/agents/clawd-fan/agent", - }, - { - id: "clawd-xi", - workspace: "/home/user/clawd-xi", - agentDir: "/home/user/.openclaw/agents/clawd-xi/agent", - }, + { id: "agent-a", workspace: "/home/user/agent-a" }, + { id: "agent-b", workspace: "/home/user/agent-b" }, ], }, bindings: [ { - agentId: "main", + agentId: "agent-a", match: { channel: "feishu", peer: { kind: "direct", id: "ou_xxx" }, }, }, { - agentId: "clawd-fan", - match: { - channel: "feishu", - peer: { kind: "direct", id: "ou_yyy" }, - }, - }, - { - agentId: "clawd-xi", + agentId: "agent-b", match: { channel: "feishu", peer: { kind: "group", id: "oc_zzz" }, @@ -656,7 +359,7 @@ Use `bindings` to route Feishu DMs or groups to different agents. Routing fields: - `match.channel`: `"feishu"` -- `match.peer.kind`: `"direct"` or `"group"` +- `match.peer.kind`: `"direct"` (DM) or `"group"` (group chat) - `match.peer.id`: user Open ID (`ou_xxx`) or group ID (`oc_xxx`) See [Get group/user IDs](#get-groupuser-ids) for lookup tips. @@ -667,44 +370,33 @@ See [Get group/user IDs](#get-groupuser-ids) for lookup tips. 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.connectionMode` | Event transport mode | `websocket` | -| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` | -| `channels.feishu.verificationToken` | Required for webhook mode | - | -| `channels.feishu.encryptKey` | Required for webhook mode | - | -| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | -| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | -| `channels.feishu.webhookPort` | Webhook bind port | `3000` | -| `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 | `allowlist` | -| `channels.feishu.groupAllowFrom` | Group allowlist | - | -| `channels.feishu.requireMention` | Default require @mention | conditional | -| `channels.feishu.groups..requireMention` | Per-group require @mention override | inherited | -| `channels.feishu.groups..enabled` | Enable group | `true` | -| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | -| `channels.feishu.mediaMaxMb` | Media size limit | `30` | -| `channels.feishu.streaming` | Enable streaming card output | `true` | -| `channels.feishu.blockStreaming` | Enable block 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 | +| Setting | Description | Default | +| ------------------------------------------------- | ------------------------------------------ | ---------------- | +| `channels.feishu.enabled` | Enable/disable the channel | `true` | +| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | +| `channels.feishu.connectionMode` | Event transport (`websocket` or `webhook`) | `websocket` | +| `channels.feishu.defaultAccount` | Default account for outbound routing | `default` | +| `channels.feishu.verificationToken` | Required for webhook mode | — | +| `channels.feishu.encryptKey` | Required for webhook mode | — | +| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | +| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | +| `channels.feishu.webhookPort` | Webhook bind port | `3000` | +| `channels.feishu.accounts..appId` | App ID | — | +| `channels.feishu.accounts..appSecret` | App Secret | — | +| `channels.feishu.accounts..domain` | Per-account domain override | `feishu` | +| `channels.feishu.dmPolicy` | DM policy | `allowlist` | +| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] | +| `channels.feishu.groupPolicy` | Group policy | `allowlist` | +| `channels.feishu.groupAllowFrom` | Group allowlist | — | +| `channels.feishu.requireMention` | Require @mention in groups | `true` | +| `channels.feishu.groups..requireMention` | Per-group @mention override | inherited | +| `channels.feishu.groups..enabled` | Enable/disable a specific group | `true` | +| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | +| `channels.feishu.mediaMaxMb` | Media size limit | `30` | +| `channels.feishu.streaming` | Streaming card output | `true` | +| `channels.feishu.blockStreaming` | Block-level streaming | `true` | +| `channels.feishu.typingIndicator` | Send typing reactions | `true` | +| `channels.feishu.resolveSenderNames` | Resolve sender display names | `true` | --- @@ -727,62 +419,16 @@ Key options: - ✅ Files - ✅ Audio - ✅ Video/media -- ✅ Interactive cards -- ⚠️ Rich text (post-style formatting and cards, not arbitrary Feishu authoring features) +- ✅ Interactive cards (including streaming updates) +- ⚠️ Rich text (post-style formatting; doesn't support full Feishu/Lark authoring capabilities) ### Threads and replies - ✅ Inline replies -- ✅ Topic-thread replies where Feishu exposes `reply_in_thread` -- ✅ Media replies stay thread-aware when replying to a thread/topic message +- ✅ Thread replies +- ✅ Media replies stay thread-aware when replying to a thread message -## Drive comments - -Feishu can trigger the agent when someone adds a comment on a Feishu Drive document (Docs, Sheets, -etc.). The agent receives the comment text, document context, and the comment thread so it can -respond in-thread or make document edits. - -Requirements: - -- Subscribe to `drive.notice.comment_add_v1` in your Feishu app event subscription settings - (alongside the existing `im.message.receive_v1`) -- The Drive tool is enabled by default; disable with `channels.feishu.tools.drive: false` - -The `feishu_drive` tool exposes these comment actions: - -| Action | Description | -| ---------------------- | ----------------------------------- | -| `list_comments` | List comments on a document | -| `list_comment_replies` | List replies in a comment thread | -| `add_comment` | Add a new top-level comment | -| `reply_comment` | Reply to an existing comment thread | - -When the agent handles a Drive comment event, it receives: - -- the comment text and sender -- document metadata (title, type, URL) -- the comment thread context for in-thread replies - -After making document edits, the agent is guided to use `feishu_drive.reply_comment` to notify the -commenter and then output the exact silent token `NO_REPLY` / `no_reply` to -avoid duplicate sends. - -## Runtime action surface - -Feishu currently exposes these runtime actions: - -- `send` -- `read` -- `edit` -- `thread-reply` -- `pin` -- `list-pins` -- `unpin` -- `member-info` -- `channel-info` -- `channel-list` -- `react` and `reactions` when reactions are enabled in config -- `feishu_drive` comment actions: `list_comments`, `list_comment_replies`, `add_comment`, `reply_comment` +--- ## Related diff --git a/docs/images/feishu-get-group-id.png b/docs/images/feishu-get-group-id.png new file mode 100644 index 00000000000..1dec08d9f2d Binary files /dev/null and b/docs/images/feishu-get-group-id.png differ diff --git a/docs/images/feishu-step2-create-app.png b/docs/images/feishu-step2-create-app.png deleted file mode 100644 index c759a8f7e59..00000000000 Binary files a/docs/images/feishu-step2-create-app.png and /dev/null differ diff --git a/docs/images/feishu-step3-credentials.png b/docs/images/feishu-step3-credentials.png deleted file mode 100644 index 45c69a075c6..00000000000 Binary files a/docs/images/feishu-step3-credentials.png and /dev/null differ diff --git a/docs/images/feishu-step4-permissions.png b/docs/images/feishu-step4-permissions.png deleted file mode 100644 index 180f83c15af..00000000000 Binary files a/docs/images/feishu-step4-permissions.png and /dev/null differ diff --git a/docs/images/feishu-step5-bot-capability.png b/docs/images/feishu-step5-bot-capability.png deleted file mode 100644 index 9bac00c5398..00000000000 Binary files a/docs/images/feishu-step5-bot-capability.png and /dev/null differ diff --git a/docs/images/feishu-step6-event-subscription.png b/docs/images/feishu-step6-event-subscription.png deleted file mode 100644 index a97932d7a2b..00000000000 Binary files a/docs/images/feishu-step6-event-subscription.png and /dev/null differ diff --git a/docs/images/feishu-verification-token.png b/docs/images/feishu-verification-token.png deleted file mode 100644 index 0d6d72d1040..00000000000 Binary files a/docs/images/feishu-verification-token.png and /dev/null differ diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 52aa868f503..7b979fe1f2b 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -5,7 +5,8 @@ "type": "module", "dependencies": { "@larksuiteoapi/node-sdk": "^1.60.0", - "@sinclair/typebox": "0.34.49" + "@sinclair/typebox": "0.34.49", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", diff --git a/extensions/feishu/src/app-registration.ts b/extensions/feishu/src/app-registration.ts new file mode 100644 index 00000000000..7bfe364bea5 --- /dev/null +++ b/extensions/feishu/src/app-registration.ts @@ -0,0 +1,309 @@ +/** + * Feishu app registration via OAuth device-code flow. + * + * Migrated from feishu-plugin-cli's `feishu-auth.ts` and `install-prompts.ts`. + * Replaces axios with native fetch, removes inquirer/ora/chalk in favor of + * the openclaw WizardPrompter surface. + */ + +import type { FeishuDomain } from "./types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FEISHU_ACCOUNTS_URL = "https://accounts.feishu.cn"; +const LARK_ACCOUNTS_URL = "https://accounts.larksuite.com"; + +const REGISTRATION_PATH = "/oauth/v1/app/registration"; + +const REQUEST_TIMEOUT_MS = 10_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AppRegistrationResult { + appId: string; + appSecret: string; + domain: FeishuDomain; + openId?: string; +} + +interface InitResponse { + nonce: string; + supported_auth_methods: string[]; +} + +export interface BeginResult { + deviceCode: string; + qrUrl: string; + userCode: string; + interval: number; + expireIn: number; +} + +interface RawBeginResponse { + device_code: string; + verification_uri: string; + user_code: string; + verification_uri_complete: string; + interval: number; + expire_in: number; +} + +interface PollResponse { + client_id?: string; + client_secret?: string; + user_info?: { + open_id?: string; + tenant_brand?: "feishu" | "lark"; + }; + error?: string; + error_description?: string; +} + +export type PollOutcome = + | { status: "success"; result: AppRegistrationResult } + | { status: "access_denied" } + | { status: "expired" } + | { status: "timeout" } + | { status: "error"; message: string }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function accountsBaseUrl(domain: FeishuDomain): string { + return domain === "lark" ? LARK_ACCOUNTS_URL : FEISHU_ACCOUNTS_URL; +} + +async function postRegistration(baseUrl: string, body: Record): Promise { + const response = await fetch(`${baseUrl}${REGISTRATION_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + // The poll endpoint returns 4xx for pending/error states with a JSON body. + const data = (await response.json()) as T; + return data; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Step 1: Initialize registration and verify the environment supports + * `client_secret` auth. + * + * @throws If the environment does not support `client_secret`. + */ +export async function initAppRegistration(domain: FeishuDomain = "feishu"): Promise { + const baseUrl = accountsBaseUrl(domain); + const res = await postRegistration(baseUrl, { action: "init" }); + + if (!res.supported_auth_methods?.includes("client_secret")) { + throw new Error("Current environment does not support client_secret auth method"); + } +} + +/** + * Step 2: Begin the device-code flow. Returns a device code and a QR URL + * that the user should scan with Feishu/Lark mobile app. + */ +export async function beginAppRegistration(domain: FeishuDomain = "feishu"): Promise { + const baseUrl = accountsBaseUrl(domain); + const res = await postRegistration(baseUrl, { + action: "begin", + archetype: "PersonalAgent", + auth_method: "client_secret", + request_user_info: "open_id", + }); + + const qrUrl = new URL(res.verification_uri_complete); + qrUrl.searchParams.set("from", "oc_onboard"); + qrUrl.searchParams.set("tp", "ob_cli_app"); + + return { + deviceCode: res.device_code, + qrUrl: qrUrl.toString(), + userCode: res.user_code, + interval: res.interval || 5, + expireIn: res.expire_in || 600, + }; +} + +/** + * Step 3: Poll for authorization result until success, denial, expiry, or + * timeout. Automatically handles domain switching when `tenant_brand` is + * detected as "lark". + */ +export async function pollAppRegistration(params: { + deviceCode: string; + interval: number; + expireIn: number; + initialDomain?: FeishuDomain; + abortSignal?: AbortSignal; + /** Registration type parameter: "ob_user" for user mode, "ob_app" for bot mode. */ + tp?: string; +}): Promise { + const { deviceCode, expireIn, initialDomain = "feishu", abortSignal, tp } = params; + let currentInterval = params.interval; + let domain: FeishuDomain = initialDomain; + let domainSwitched = false; + + const deadline = Date.now() + expireIn * 1000; + + while (Date.now() < deadline) { + if (abortSignal?.aborted) { + return { status: "timeout" }; + } + + const baseUrl = accountsBaseUrl(domain); + + let pollRes: PollResponse; + try { + pollRes = await postRegistration(baseUrl, { + action: "poll", + device_code: deviceCode, + ...(tp ? { tp } : {}), + }); + } catch { + // Transient network error — keep polling. + await sleep(currentInterval * 1000); + continue; + } + + // Domain auto-detection: switch to lark if tenant_brand says so. + if (pollRes.user_info?.tenant_brand) { + const isLark = pollRes.user_info.tenant_brand === "lark"; + if (!domainSwitched && isLark) { + domain = "lark"; + domainSwitched = true; + // Retry poll immediately with the correct domain. + continue; + } + } + + // Success. + if (pollRes.client_id && pollRes.client_secret) { + return { + status: "success", + result: { + appId: pollRes.client_id, + appSecret: pollRes.client_secret, + domain, + openId: pollRes.user_info?.open_id, + }, + }; + } + + // Error handling. + if (pollRes.error) { + if (pollRes.error === "authorization_pending") { + // Continue waiting. + } else if (pollRes.error === "slow_down") { + currentInterval += 5; + } else if (pollRes.error === "access_denied") { + return { status: "access_denied" }; + } else if (pollRes.error === "expired_token") { + return { status: "expired" }; + } else { + return { + status: "error", + message: `${pollRes.error}: ${pollRes.error_description ?? "unknown"}`, + }; + } + } + + await sleep(currentInterval * 1000); + } + + return { status: "timeout" }; +} + +/** + * Print QR code directly to stdout. + * + * QR codes must be printed without any surrounding box/border decoration, + * otherwise the pattern is corrupted and cannot be scanned. + */ +export async function printQrCode(url: string): Promise { + const mod = await import("qrcode-terminal"); + const qrcode = mod.default ?? mod; + qrcode.generate(url, { small: true }); +} + +/** + * Fetch the app owner's open_id using the application.v6.application.get API. + * + * Used during setup to auto-populate security policy allowlists. + * Returns undefined on any failure (fail-open). + */ +export async function getAppOwnerOpenId(params: { + appId: string; + appSecret: string; + domain?: FeishuDomain; +}): Promise { + const baseUrl = + params.domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn"; + + try { + // First, get a tenant_access_token. + const tokenRes = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_id: params.appId, app_secret: params.appSecret }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const tokenData = (await tokenRes.json()) as { + code?: number; + tenant_access_token?: string; + }; + if (!tokenData.tenant_access_token) { + return undefined; + } + + // Query app info for the owner's open_id. + const appRes = await fetch( + `${baseUrl}/open-apis/application/v6/applications/${params.appId}?user_id_type=open_id`, + { + method: "GET", + headers: { + Authorization: `Bearer ${tokenData.tenant_access_token}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + const appData = (await appRes.json()) as { + code?: number; + data?: { + app?: { + owner?: { owner_id?: string; owner_type?: number; type?: number }; + creator_id?: string; + }; + }; + }; + if (appData.code !== 0) { + return undefined; + } + + const app = appData.data?.app; + const owner = app?.owner; + const ownerType = owner?.owner_type ?? owner?.type; + // owner_type=2 means enterprise member; use owner_id. Otherwise fallback to creator_id. + return ownerType === 2 && owner?.owner_id + ? owner.owner_id + : (app?.creator_id ?? owner?.owner_id); + } catch { + return undefined; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/feishu/src/bitable.ts b/extensions/feishu/src/bitable.ts index ae166b2802f..b2cb8ea107f 100644 --- a/extensions/feishu/src/bitable.ts +++ b/extensions/feishu/src/bitable.ts @@ -730,5 +730,5 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) { }, }); - api.logger.info?.("feishu_bitable: Registered bitable tools"); + api.logger.debug?.("feishu_bitable: Registered bitable tools"); } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 38d2efb51f9..c7e31762f98 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1091,6 +1091,18 @@ export const feishuPlugin: ChannelPlugin { + const { createClackPrompter } = await import("openclaw/plugin-sdk/feishu"); + const { writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime"); + const prompter = createClackPrompter(); + const { runFeishuLogin } = await import("./setup-surface.js"); + const nextCfg = await runFeishuLogin({ cfg, prompter }); + if (nextCfg !== cfg) { + await writeConfigFile(nextCfg); + } + }, + }, setup: feishuSetupAdapter, setupWizard: feishuSetupWizard, messaging: { diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index 39e345c820d..1f402a81047 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -189,5 +189,5 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { { name: "feishu_chat" }, ); - api.logger.info?.("feishu_chat: Registered feishu_chat tool"); + api.logger.debug?.("feishu_chat: Registered feishu_chat tool"); } diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index c12b5748afe..7faf53bedfd 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -1617,6 +1617,6 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { } if (registered.length > 0) { - api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`); + api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`); } } diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 9f73a0c67a6..e300a7ca384 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -845,5 +845,5 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { { name: "feishu_drive" }, ); - api.logger.info?.(`feishu_drive: Registered feishu_drive tool`); + api.logger.debug?.(`feishu_drive: Registered feishu_drive tool`); } diff --git a/extensions/feishu/src/monitor.startup.ts b/extensions/feishu/src/monitor.startup.ts index 225a5e3af38..2c684038ff1 100644 --- a/extensions/feishu/src/monitor.startup.ts +++ b/extensions/feishu/src/monitor.startup.ts @@ -59,11 +59,12 @@ export async function fetchBotIdentityForMonitor( return { botOpenId: result.botOpenId, botName: result.botName }; } - if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) { + const probeError = result.error ?? undefined; + if (options.abortSignal?.aborted || isAbortErrorMessage(probeError)) { return {}; } - if (isTimeoutErrorMessage(result.error)) { + if (isTimeoutErrorMessage(probeError)) { const error = options.runtime?.error ?? console.error; error( `feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`, diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index b30aac5a26b..1ef078c94e3 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -171,5 +171,5 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) { { name: "feishu_perm" }, ); - api.logger.info?.(`feishu_perm: Registered feishu_perm tool`); + api.logger.debug?.(`feishu_perm: Registered feishu_perm tool`); } diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index 3f70bc8aefa..98fc669f360 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -1,4 +1,3 @@ -import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { describe, expect, it, vi } from "vitest"; import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js"; import { @@ -6,45 +5,28 @@ import { createPluginSetupWizardStatus, createTestWizardPrompter, runSetupWizardConfigure, - type WizardPrompter, } from "../../../test/helpers/plugins/setup-wizard.js"; -import { - listFeishuAccountIds, - resolveDefaultFeishuAccountId, - resolveFeishuAccount, -} from "./accounts.js"; -import { feishuSetupAdapter } from "./setup-core.js"; -import { feishuSetupWizard } from "./setup-surface.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), })); +vi.mock("./app-registration.js", () => ({ + initAppRegistration: vi.fn(async () => { + throw new Error("mocked: scan-to-create not available"); + }), + beginAppRegistration: vi.fn(), + pollAppRegistration: vi.fn(), + printQrCode: vi.fn(async () => {}), + getAppOwnerOpenId: vi.fn(async () => undefined), +})); + +import { feishuPlugin } from "./channel.js"; + const baseStatusContext = { accountOverrides: {}, }; -const feishuSetupPlugin = { - id: "feishu", - meta: { - id: "feishu", - label: "Feishu", - selectionLabel: "Feishu/Lark (飞书)", - docsPath: "/channels/feishu", - blurb: "飞书/Lark enterprise messaging.", - }, - capabilities: { - chatTypes: ["direct", "group"] as Array<"direct" | "group">, - }, - config: { - listAccountIds: (cfg: unknown) => listFeishuAccountIds(cfg as never), - defaultAccountId: (cfg: unknown) => resolveDefaultFeishuAccountId(cfg as never), - resolveAccount: adaptScopedAccountAccessor(resolveFeishuAccount), - }, - setup: feishuSetupAdapter, - setupWizard: feishuSetupWizard, -} as const; - async function withEnvVars(values: Record, run: () => Promise) { const previous = new Map(); for (const [key, value] of Object.entries(values)) { @@ -83,54 +65,21 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -const feishuConfigure = createPluginSetupWizardConfigure(feishuSetupPlugin); -const feishuGetStatus = createPluginSetupWizardStatus(feishuSetupPlugin); +const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin); +const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin); type FeishuConfigureRuntime = Parameters[0]["runtime"]; describe("feishu setup wizard", () => { - it("setup adapter preserves a selected named account id", () => { - expect( - feishuSetupPlugin.setup?.resolveAccountId?.({ - cfg: {} as never, - accountId: "work", - input: {}, - } as never), - ).toBe("work"); - }); - - it("setup adapter uses configured defaultAccount when accountId is omitted", () => { - expect( - feishuSetupPlugin.setup?.resolveAccountId?.({ - cfg: { - channels: { - feishu: { - defaultAccount: "work", - accounts: { - work: { - appId: "work-app", - appSecret: "work-secret", // pragma: allowlist secret - }, - }, - }, - }, - } as never, - accountId: undefined, - input: {}, - } as never), - ).toBe("work"); - }); - it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi .fn() .mockResolvedValueOnce("cli_from_prompt") - .mockResolvedValueOnce("secret_from_prompt") - .mockResolvedValueOnce("oc_group_1"); + .mockResolvedValueOnce("secret_from_prompt"); const prompter = createTestWizardPrompter({ text, confirm: vi.fn(async () => true), select: vi.fn( - async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist", + async ({ initialValue }: { initialValue?: string }) => initialValue ?? "bot", ) as never, }); @@ -150,131 +99,6 @@ describe("feishu setup wizard", () => { }), ).resolves.toBeTruthy(); }); - - it("writes selected-account credentials instead of overwriting the channel root", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Enter Feishu App Secret") { - return "work-secret"; // pragma: allowlist secret - } - if (message === "Enter Feishu App ID") { - return "work-app"; - } - if (message === "Group chat allowlist (chat_ids)") { - return ""; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - select: vi.fn( - async ({ initialValue }: { initialValue?: string }) => initialValue ?? "websocket", - ) as never, - }); - - const result = await runSetupWizardConfigure({ - configure: feishuConfigure, - cfg: { - channels: { - feishu: { - appId: "top-level-app", - appSecret: "top-level-secret", // pragma: allowlist secret - accounts: { - work: { - appId: "", - }, - }, - }, - }, - } as never, - prompter, - accountOverrides: { - feishu: "work", - }, - runtime: createNonExitingTypedRuntimeEnv(), - }); - - expect(result.cfg.channels?.feishu?.appId).toBe("top-level-app"); - expect(result.cfg.channels?.feishu?.appSecret).toBe("top-level-secret"); - expect(result.cfg.channels?.feishu?.accounts?.work).toMatchObject({ - enabled: true, - appId: "work-app", - appSecret: "work-secret", - }); - }); - - it("uses configured defaultAccount for omitted finalize writes", async () => { - const prompter = createTestWizardPrompter({ - text: vi.fn(async ({ message }: { message: string }) => { - if (message === "Enter Feishu App Secret") { - return "work-secret"; // pragma: allowlist secret - } - if (message === "Enter Feishu App ID") { - return "work-app"; - } - if (message === "Feishu webhook path") { - return "/feishu/events"; - } - if (message === "Group chat allowlist (chat_ids)") { - return ""; - } - throw new Error(`Unexpected prompt: ${message}`); - }) as WizardPrompter["text"], - select: vi.fn( - async ({ message, initialValue }: { message: string; initialValue?: string }) => { - if (message === "Feishu connection mode") { - return initialValue ?? "websocket"; - } - if (message === "Which Feishu domain?") { - return initialValue ?? "feishu"; - } - if (message === "Group chat policy") { - return "disabled"; - } - return initialValue ?? "websocket"; - }, - ) as never, - note: vi.fn(async () => {}), - }); - - const setupWizard = feishuSetupPlugin.setupWizard; - if (!setupWizard || !("finalize" in setupWizard) || !setupWizard.finalize) { - throw new Error("feishu setupWizard.finalize unavailable"); - } - - const result = await setupWizard.finalize({ - cfg: { - channels: { - feishu: { - appId: "top-level-app", - appSecret: "top-level-secret", // pragma: allowlist secret - defaultAccount: "work", - accounts: { - work: { - appId: "", - }, - }, - }, - }, - } as never, - accountId: "work", - credentialValues: {}, - forceAllowFrom: false, - prompter, - runtime: createNonExitingTypedRuntimeEnv(), - options: {}, - }); - - expect(result && typeof result === "object" && "cfg" in result).toBe(true); - const nextCfg = - result && typeof result === "object" && "cfg" in result ? result.cfg : undefined; - expect(nextCfg?.channels?.feishu).toBeDefined(); - expect(nextCfg?.channels?.feishu?.appId).toBe("top-level-app"); - expect(nextCfg?.channels?.feishu?.appSecret).toBe("top-level-secret"); - expect(nextCfg?.channels?.feishu?.accounts?.work).toMatchObject({ - enabled: true, - appId: "work-app", - appSecret: "work-secret", - }); - }); }); describe("feishu setup wizard status", () => { @@ -319,97 +143,6 @@ describe("feishu setup wizard status", () => { expect(status.configured).toBe(false); }); - it("setup status honors the selected named account", async () => { - const status = await feishuGetStatus({ - cfg: { - channels: { - feishu: { - appId: "top_level_app", - appSecret: "top-level-secret", // pragma: allowlist secret - accounts: { - work: { - appId: "", - appSecret: "work-secret", // pragma: allowlist secret - }, - }, - }, - }, - } as never, - accountOverrides: { - feishu: "work", - }, - }); - - expect(status.configured).toBe(false); - expect(status.statusLines).toEqual(["Feishu: needs app credentials"]); - }); - - it("uses configured defaultAccount for omitted setup configured state", async () => { - const status = await feishuGetStatus({ - cfg: { - channels: { - feishu: { - defaultAccount: "work", - appId: "top_level_app", - appSecret: "top-level-secret", // pragma: allowlist secret - accounts: { - alerts: { - appId: "alerts-app", - appSecret: "alerts-secret", // pragma: allowlist secret - }, - work: { - appId: "", - appSecret: "work-secret", // pragma: allowlist secret - }, - }, - }, - }, - } as never, - accountOverrides: {}, - }); - - expect(status.configured).toBe(false); - expect(status.statusLines).toEqual(["Feishu: needs app credentials"]); - }); - - it("uses configured defaultAccount for omitted DM policy account context", async () => { - const cfg = { - channels: { - feishu: { - allowFrom: ["ou_root"], - defaultAccount: "work", - accounts: { - work: { - appId: "work-app", - appSecret: "work-secret", // pragma: allowlist secret - dmPolicy: "allowlist", - allowFrom: ["ou_work"], - }, - }, - }, - }, - } as const; - - expect(feishuSetupWizard.dmPolicy?.getCurrent?.(cfg as never)).toBe("allowlist"); - expect(feishuSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg as never)).toEqual({ - policyKey: "channels.feishu.accounts.work.dmPolicy", - allowFromKey: "channels.feishu.accounts.work.allowFrom", - }); - - const next = feishuSetupWizard.dmPolicy?.setPolicy?.(cfg as never, "open"); - const workAccount = next?.channels?.feishu?.accounts?.work as - | { - dmPolicy?: string; - allowFrom?: string[]; - } - | undefined; - - expect(next?.channels?.feishu?.dmPolicy).toBeUndefined(); - expect(next?.channels?.feishu?.allowFrom).toEqual(["ou_root"]); - expect(workAccount?.dmPolicy).toBe("open"); - expect(workAccount?.allowFrom).toEqual(["ou_work", "*"]); - }); - it("treats env SecretRef appId as not configured when env var is missing", async () => { const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST"; const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 6354f941263..feeb0168257 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,5 +1,4 @@ import { - buildSingleChannelSecretPromptState, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, @@ -9,31 +8,82 @@ import { splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, + type DmPolicy, type OpenClawConfig, type SecretInput, } from "openclaw/plugin-sdk/setup"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; +import { inspectFeishuCredentials, resolveDefaultFeishuAccountId } from "./accounts.js"; import { - inspectFeishuCredentials, - resolveDefaultFeishuAccountId, - resolveFeishuAccount, -} from "./accounts.js"; -import { normalizeString } from "./comment-shared.js"; + beginAppRegistration, + getAppOwnerOpenId, + initAppRegistration, + pollAppRegistration, + printQrCode, + type AppRegistrationResult, +} from "./app-registration.js"; import { probeFeishu } from "./probe.js"; -import type { FeishuAccountConfig, FeishuConfig } from "./types.js"; +import type { FeishuConfig, FeishuDomain } from "./types.js"; const channel = "feishu" as const; -type ScopedFeishuConfig = Partial & Partial; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (accountId === DEFAULT_ACCOUNT_ID) { - return feishuCfg ?? {}; +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; } - return feishuCfg?.accounts?.[accountId] ?? {}; + const trimmed = value.trim(); + return trimmed || undefined; } +function isFeishuConfigured(cfg: OpenClawConfig): boolean { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + + const isAppIdConfigured = (value: unknown): boolean => { + const asString = normalizeString(value); + if (asString) { + return true; + } + if (!value || typeof value !== "object") { + return false; + } + const rec = value as Record; + const source = normalizeString(rec.source)?.toLowerCase(); + const id = normalizeString(rec.id); + if (source === "env" && id) { + return Boolean(normalizeString(process.env[id])); + } + return hasConfiguredSecretInput(value); + }; + + const topLevelConfigured = + isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret); + + const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { + if (!account || typeof account !== "object") { + return false; + } + const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); + const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); + const accountAppIdConfigured = hasOwnAppId + ? isAppIdConfigured((account as Record).appId) + : isAppIdConfigured(feishuCfg?.appId); + const accountSecretConfigured = hasOwnAppSecret + ? hasConfiguredSecretInput((account as Record).appSecret) + : hasConfiguredSecretInput(feishuCfg?.appSecret); + return accountAppIdConfigured && accountSecretConfigured; + }); + + return topLevelConfigured || accountConfigured; +} + +/** + * Patch feishu config at the correct location based on accountId. + * - DEFAULT_ACCOUNT_ID → writes to top-level channels.feishu + * - named account → writes to channels.feishu.accounts[accountId] + */ function patchFeishuConfig( cfg: OpenClawConfig, accountId: string, @@ -66,85 +116,20 @@ function patchFeishuConfig( }); } -function setFeishuAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchFeishuConfig(cfg, accountId, { allowFrom }); -} - -function setFeishuGroupPolicy( - cfg: OpenClawConfig, - accountId: string, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return patchFeishuConfig(cfg, accountId, { groupPolicy }); -} - -function setFeishuGroupAllowFrom( - cfg: OpenClawConfig, - accountId: string, - groupAllowFrom: string[], -): OpenClawConfig { - return patchFeishuConfig(cfg, accountId, { groupAllowFrom }); -} - -function isFeishuConfigured(cfg: OpenClawConfig, accountId?: string | null): boolean { - const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig; - const resolvedAccountId = normalizeString(accountId) ?? resolveDefaultFeishuAccountId(cfg); - - const isAppIdConfigured = (value: unknown): boolean => { - const asString = normalizeString(value); - if (asString) { - return true; - } - if (!value || typeof value !== "object") { - return false; - } - const rec = value as Record; - const source = normalizeOptionalLowercaseString(normalizeString(rec.source)); - const id = normalizeString(rec.id); - if (source === "env" && id) { - return Boolean(normalizeString(process.env[id])); - } - return hasConfiguredSecretInput(value); - }; - - const topLevelConfigured = - isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret); - - if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { - return topLevelConfigured; - } - - const account = feishuCfg.accounts?.[resolvedAccountId]; - if (!account || typeof account !== "object") { - return topLevelConfigured; - } - - const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); - const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); - const accountAppIdConfigured = hasOwnAppId - ? isAppIdConfigured((account as Record).appId) - : isAppIdConfigured(feishuCfg?.appId); - const accountSecretConfigured = hasOwnAppSecret - ? hasConfiguredSecretInput((account as Record).appSecret) - : hasConfiguredSecretInput(feishuCfg?.appSecret); - - return accountAppIdConfigured && accountSecretConfigured; -} - async function promptFeishuAllowFrom(params: { cfg: OpenClawConfig; - accountId: string; + accountId?: string; prompter: Parameters>[0]["prompter"]; }): Promise { - const existingAllowFrom = - resolveFeishuAccount({ - cfg: params.cfg, - accountId: params.accountId, - }).config.allowFrom ?? []; + const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedAccountId = params.accountId ?? resolveDefaultFeishuAccountId(params.cfg); + const account = + resolvedAccountId !== DEFAULT_ACCOUNT_ID + ? (feishuCfg?.accounts?.[resolvedAccountId] as Record | undefined) + : undefined; + const existingAllowFrom = (account?.allowFrom ?? feishuCfg?.allowFrom ?? []) as Array< + string | number + >; await params.prompter.note( [ "Allowlist Feishu DMs by open_id or user_id.", @@ -162,7 +147,7 @@ async function promptFeishuAllowFrom(params: { existingAllowFrom.length > 0 ? existingAllowFrom.map(String).join(", ") : undefined, }); const mergedAllowFrom = mergeAllowFromEntries(existingAllowFrom, splitSetupEntries(entry)); - return setFeishuAllowFrom(params.cfg, params.accountId, mergedAllowFrom); + return patchFeishuConfig(params.cfg, resolvedAccountId, { allowFrom: mergedAllowFrom }); } async function noteFeishuCredentialHelp( @@ -212,36 +197,322 @@ const feishuDmPolicy: ChannelSetupDmPolicy = { allowFromKey: "channels.feishu.allowFrom", }; }, - getCurrent: (cfg, accountId) => - resolveFeishuAccount({ - cfg, - accountId: accountId ?? resolveDefaultFeishuAccountId(cfg), - }).config.dmPolicy ?? "pairing", + getCurrent: (cfg, accountId) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg); + if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) { + const account = feishuCfg?.accounts?.[resolvedAccountId] as + | Record + | undefined; + if (account?.dmPolicy) { + return account.dmPolicy as DmPolicy; + } + } + return (feishuCfg?.dmPolicy as DmPolicy | undefined) ?? "pairing"; + }, setPolicy: (cfg, policy, accountId) => { const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg); - const currentAllowFrom = resolveFeishuAccount({ - cfg, - accountId: resolvedAccountId, - }).config.allowFrom; return patchFeishuConfig(cfg, resolvedAccountId, { dmPolicy: policy, - ...(policy === "open" ? { allowFrom: mergeAllowFromEntries(currentAllowFrom, ["*"]) } : {}), + ...(policy === "open" ? { allowFrom: mergeAllowFromEntries([], ["*"]) } : {}), }); }, - promptAllowFrom: async ({ cfg, accountId, prompter }) => - await promptFeishuAllowFrom({ - cfg, - accountId: accountId ?? resolveDefaultFeishuAccountId(cfg), - prompter, - }), + promptAllowFrom: promptFeishuAllowFrom, }; +type WizardPrompter = Parameters>[0]["prompter"]; + +// --------------------------------------------------------------------------- +// Security policy helpers +// --------------------------------------------------------------------------- + +function applyNewAppSecurityPolicy( + cfg: OpenClawConfig, + accountId: string, + openId: string | undefined, + groupPolicy: "allowlist" | "open" | "disabled", +): OpenClawConfig { + let next = cfg; + + if (openId) { + // dmPolicy=allowlist, allowFrom=[openId] + next = patchFeishuConfig(next, accountId, { dmPolicy: "allowlist", allowFrom: [openId] }); + } + + // Apply group policy. + const groupPatch: Record = { groupPolicy }; + if (groupPolicy === "open") { + groupPatch.requireMention = true; + } + next = patchFeishuConfig(next, accountId, groupPatch); + + return next; +} + +// --------------------------------------------------------------------------- +// Scan-to-create flow +// --------------------------------------------------------------------------- + +async function runScanToCreate(prompter: WizardPrompter): Promise { + try { + await initAppRegistration("feishu"); + } catch { + await prompter.note( + "Scan-to-create is not available in this environment. Falling back to manual input.", + "Feishu setup", + ); + return null; + } + + const begin = await beginAppRegistration("feishu"); + + await prompter.note("Scan the QR with Lark/Feishu on your phone.", "Feishu scan-to-create"); + await printQrCode(begin.qrUrl); + + const progress = prompter.progress("Fetching configuration results..."); + + const outcome = await pollAppRegistration({ + deviceCode: begin.deviceCode, + interval: begin.interval, + expireIn: begin.expireIn, + initialDomain: "feishu", + tp: "ob_app", + }); + + switch (outcome.status) { + case "success": + progress.stop("Scan completed."); + return outcome.result; + case "access_denied": + progress.stop("User denied authorization. Falling back to manual input."); + return null; + case "expired": + progress.stop("Session expired. Falling back to manual input."); + return null; + case "timeout": + progress.stop("Scan timed out. Falling back to manual input."); + return null; + case "error": + progress.stop(`Registration error: ${outcome.message}. Falling back to manual input.`); + return null; + } + return null; +} + +// --------------------------------------------------------------------------- +// New app configuration flow +// --------------------------------------------------------------------------- + +async function runNewAppFlow(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + options: Parameters>[0]["options"]; +}): Promise<{ cfg: OpenClawConfig }> { + const { prompter, options } = params; + let next = params.cfg; + + // Resolve target account: defaultAccount > first account key > top-level. + const targetAccountId = resolveDefaultFeishuAccountId(next); + + // ----- QR scan flow ----- + let appId: string | null = null; + let appSecret: SecretInput | null = null; + let appSecretProbeValue: string | null = null; + let scanDomain: FeishuDomain | undefined; + let scanOpenId: string | undefined; + + const scanResult = await runScanToCreate(prompter); + if (scanResult) { + appId = scanResult.appId; + appSecret = scanResult.appSecret; + appSecretProbeValue = scanResult.appSecret; + scanDomain = scanResult.domain; + scanOpenId = scanResult.openId; + } else { + // Fallback to manual input: collect domain, appId, appSecret. + const feishuCfg = next.channels?.feishu as FeishuConfig | undefined; + await noteFeishuCredentialHelp(prompter); + + // Domain selection first (needed for API calls). + const currentDomain = feishuCfg?.domain ?? "feishu"; + const domain = (await prompter.select({ + message: "Which Feishu domain?", + options: [ + { value: "feishu", label: "Feishu (feishu.cn) - China" }, + { value: "lark", label: "Lark (larksuite.com) - International" }, + ], + initialValue: currentDomain, + })) as FeishuDomain; + scanDomain = domain; + + appId = await promptFeishuAppId({ + prompter, + initialValue: normalizeString(process.env.FEISHU_APP_ID), + }); + + const appSecretResult = await promptSingleChannelSecretInput({ + cfg: next, + prompter, + providerHint: "feishu", + credentialLabel: "App Secret", + secretInputMode: options?.secretInputMode, + accountConfigured: false, + canUseEnv: false, + hasConfigToken: false, + envPrompt: "", + keepPrompt: "Feishu App Secret already configured. Keep it?", + inputPrompt: "Enter Feishu App Secret", + preferredEnvVar: "FEISHU_APP_SECRET", + }); + if (appSecretResult.action === "set") { + appSecret = appSecretResult.value; + appSecretProbeValue = appSecretResult.resolvedValue; + } + + // Fetch openId via API for manual flow. + if (appId && appSecretProbeValue) { + scanOpenId = await getAppOwnerOpenId({ + appId, + appSecret: appSecretProbeValue, + domain: scanDomain, + }); + } + } + + // ----- Group chat policy ----- + const groupPolicy = (await prompter.select({ + message: "Group chat policy", + options: [ + { value: "allowlist", label: "Allowlist - only respond in specific groups" }, + { value: "open", label: "Open - respond in all groups (requires mention)" }, + { value: "disabled", label: "Disabled - don't respond in groups" }, + ], + initialValue: "allowlist", + })) as "allowlist" | "open" | "disabled"; + + // ----- Apply credentials & security policy ----- + const configProgress = prompter.progress("Configuring..."); + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (appId && appSecret) { + next = patchFeishuConfig(next, targetAccountId, { + appId, + appSecret, + connectionMode: "websocket", + ...(scanDomain ? { domain: scanDomain } : {}), + }); + } else if (scanDomain) { + next = patchFeishuConfig(next, targetAccountId, { domain: scanDomain }); + } + + next = applyNewAppSecurityPolicy(next, targetAccountId, scanOpenId, groupPolicy); + + configProgress.stop("Bot configured."); + + return { cfg: next }; +} + +// --------------------------------------------------------------------------- +// Edit configuration flow +// --------------------------------------------------------------------------- + +async function runEditFlow(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + options: Parameters>[0]["options"]; +}): Promise<{ cfg: OpenClawConfig } | null> { + const { prompter, options } = params; + const next = params.cfg; + const feishuCfg = next.channels?.feishu as FeishuConfig | undefined; + + // Check existing appId (top-level or first configured account). + // Supports both plain string and SecretRef (env-backed) appId values. + const resolveAppIdLabel = (value: unknown): string | undefined => { + const asString = normalizeString(value); + if (asString) { + return asString; + } + if (value && typeof value === "object") { + const rec = value as Record; + if (normalizeString(rec.source) && normalizeString(rec.id)) { + const envValue = normalizeString(process.env[rec.id as string]); + return envValue ?? `env:${String(rec.id)}`; + } + if (hasConfiguredSecretInput(value)) { + return "(configured)"; + } + } + return undefined; + }; + const existingAppId = + resolveAppIdLabel(feishuCfg?.appId) ?? + Object.values(feishuCfg?.accounts ?? {}).reduce((found, account) => { + if (found) { + return found; + } + if (account && typeof account === "object") { + return resolveAppIdLabel((account as Record).appId); + } + return undefined; + }, undefined); + if (existingAppId) { + const useExisting = await prompter.confirm({ + message: `We found an existing bot (App ID: ${existingAppId}). Use it for this setup?`, + initialValue: true, + }); + + if (!useExisting) { + // User wants a new bot — run new app flow. + return runNewAppFlow({ cfg: next, prompter, options }); + } + } else { + // No existing appId — run new app flow. + return runNewAppFlow({ cfg: next, prompter, options }); + } + + await prompter.note("Bot configured.", ""); + + return { cfg: next }; +} + +// --------------------------------------------------------------------------- +// Standalone login entry point (for `channels login --channel feishu`) +// --------------------------------------------------------------------------- + +export async function runFeishuLogin(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const { cfg, prompter } = params; + const options = {}; + const alreadyConfigured = isFeishuConfigured(cfg); + + if (alreadyConfigured) { + const result = await runEditFlow({ cfg, prompter, options }); + if (result === null) { + return cfg; + } + return result.cfg; + } + + const result = await runNewAppFlow({ cfg, prompter, options }); + return result.cfg; +} + +// --------------------------------------------------------------------------- +// Exported wizard +// --------------------------------------------------------------------------- + export { feishuSetupAdapter } from "./setup-core.js"; export const feishuSetupWizard: ChannelSetupWizard = { channel, - resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) => - normalizeString(accountOverride) ?? defaultAccountId, + resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId, cfg }) => + (typeof accountOverride === "string" && accountOverride.trim() + ? accountOverride.trim() + : undefined) ?? + resolveDefaultFeishuAccountId(cfg) ?? + defaultAccountId, resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", @@ -250,22 +521,10 @@ export const feishuSetupWizard: ChannelSetupWizard = { unconfiguredHint: "needs app creds", configuredScore: 2, unconfiguredScore: 0, - resolveConfigured: ({ cfg, accountId }) => isFeishuConfigured(cfg, accountId), - resolveStatusLines: async ({ cfg, accountId, configured }) => { - const resolvedCredentials = accountId - ? (() => { - const account = resolveFeishuAccount({ cfg, accountId }); - return account.configured && account.appId && account.appSecret - ? { - appId: account.appId, - appSecret: account.appSecret, - encryptKey: account.encryptKey, - verificationToken: account.verificationToken, - domain: account.domain, - } - : null; - })() - : inspectFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined); + resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg), + resolveStatusLines: async ({ cfg, configured }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedCredentials = inspectFeishuCredentials(feishuCfg); let probeResult = null; if (configured && resolvedCredentials) { try { @@ -281,215 +540,43 @@ export const feishuSetupWizard: ChannelSetupWizard = { return ["Feishu: configured (connection not verified)"]; }, }, - credentials: [], - finalize: async ({ cfg, accountId, prompter, options }) => { - const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg); - const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId }); - const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId); - const resolved = - resolvedAccount.configured && resolvedAccount.appId && resolvedAccount.appSecret - ? { - appId: resolvedAccount.appId, - appSecret: resolvedAccount.appSecret, - encryptKey: resolvedAccount.encryptKey, - verificationToken: resolvedAccount.verificationToken, - domain: resolvedAccount.domain, - } - : null; - const hasConfigSecret = hasConfiguredSecretInput(scopedConfig.appSecret); - const hasConfigCreds = Boolean( - typeof scopedConfig.appId === "string" && scopedConfig.appId.trim() && hasConfigSecret, - ); - const appSecretPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(resolved), - hasConfigToken: hasConfigSecret, - allowEnv: !hasConfigCreds && Boolean(process.env.FEISHU_APP_ID?.trim()), - envValue: process.env.FEISHU_APP_SECRET, - }); - let next = cfg; - let appId: string | null = null; - let appSecret: SecretInput | null = null; - let appSecretProbeValue: string | null = null; + // ------------------------------------------------------------------------- + // prepare: determine flow based on existing configuration + // ------------------------------------------------------------------------- + prepare: async ({ cfg, credentialValues }) => { + const alreadyConfigured = isFeishuConfigured(cfg); - if (!resolved) { - await noteFeishuCredentialHelp(prompter); + if (alreadyConfigured) { + return { + credentialValues: { ...credentialValues, _flow: "edit" }, + }; } - const appSecretResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "feishu", - credentialLabel: "App Secret", - secretInputMode: options?.secretInputMode, - accountConfigured: appSecretPromptState.accountConfigured, - canUseEnv: appSecretPromptState.canUseEnv, - hasConfigToken: appSecretPromptState.hasConfigToken, - envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?", - keepPrompt: "Feishu App Secret already configured. Keep it?", - inputPrompt: "Enter Feishu App Secret", - preferredEnvVar: "FEISHU_APP_SECRET", - }); - - if (appSecretResult.action === "use-env") { - next = patchFeishuConfig(next, resolvedAccountId, {}); - } else if (appSecretResult.action === "set") { - appSecret = appSecretResult.value; - appSecretProbeValue = appSecretResult.resolvedValue; - appId = await promptFeishuAppId({ - prompter, - initialValue: - normalizeString(scopedConfig.appId) ?? normalizeString(process.env.FEISHU_APP_ID), - }); - } - - if (appId && appSecret) { - next = patchFeishuConfig(next, resolvedAccountId, { - appId, - appSecret, - }); - - try { - const probe = await probeFeishu({ - appId, - appSecret: appSecretProbeValue ?? undefined, - domain: resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain, - }); - if (probe.ok) { - await prompter.note( - `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`, - "Feishu connection test", - ); - } else { - await prompter.note( - `Connection failed: ${probe.error ?? "unknown error"}`, - "Feishu connection test", - ); - } - } catch (err) { - await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test"); - } - } - - const currentMode = - resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.connectionMode ?? - "websocket"; - const connectionMode = (await prompter.select({ - message: "Feishu connection mode", - options: [ - { value: "websocket", label: "WebSocket (default)" }, - { value: "webhook", label: "Webhook" }, - ], - initialValue: currentMode, - })) as "websocket" | "webhook"; - next = patchFeishuConfig(next, resolvedAccountId, { connectionMode }); - - if (connectionMode === "webhook") { - const currentVerificationToken = getScopedFeishuConfig( - next, - resolvedAccountId, - ).verificationToken; - const verificationTokenResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "feishu-webhook", - credentialLabel: "verification token", - secretInputMode: options?.secretInputMode, - ...buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentVerificationToken), - hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), - allowEnv: false, - }), - envPrompt: "", - keepPrompt: "Feishu verification token already configured. Keep it?", - inputPrompt: "Enter Feishu verification token", - preferredEnvVar: "FEISHU_VERIFICATION_TOKEN", - }); - if (verificationTokenResult.action === "set") { - next = patchFeishuConfig(next, resolvedAccountId, { - verificationToken: verificationTokenResult.value, - }); - } - - const currentEncryptKey = getScopedFeishuConfig(next, resolvedAccountId).encryptKey; - const encryptKeyResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "feishu-webhook", - credentialLabel: "encrypt key", - secretInputMode: options?.secretInputMode, - ...buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentEncryptKey), - hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), - allowEnv: false, - }), - envPrompt: "", - keepPrompt: "Feishu encrypt key already configured. Keep it?", - inputPrompt: "Enter Feishu encrypt key", - preferredEnvVar: "FEISHU_ENCRYPT_KEY", - }); - if (encryptKeyResult.action === "set") { - next = patchFeishuConfig(next, resolvedAccountId, { - encryptKey: encryptKeyResult.value, - }); - } - - const currentWebhookPath = getScopedFeishuConfig(next, resolvedAccountId).webhookPath; - const webhookPath = ( - await prompter.text({ - message: "Feishu webhook path", - initialValue: currentWebhookPath ?? "/feishu/events", - validate: (value) => ((value ?? "").trim() ? undefined : "Required"), - }) - ).trim(); - next = patchFeishuConfig(next, resolvedAccountId, { webhookPath }); - } - - const currentDomain = resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain; - const domain = await prompter.select({ - message: "Which Feishu domain?", - options: [ - { value: "feishu", label: "Feishu (feishu.cn) - China" }, - { value: "lark", label: "Lark (larksuite.com) - International" }, - ], - initialValue: currentDomain, - }); - next = patchFeishuConfig(next, resolvedAccountId, { - domain: domain as "feishu" | "lark", - }); - - const groupPolicy = (await prompter.select({ - message: "Group chat policy", - options: [ - { value: "allowlist", label: "Allowlist - only respond in specific groups" }, - { value: "open", label: "Open - respond in all groups (requires mention)" }, - { value: "disabled", label: "Disabled - don't respond in groups" }, - ], - initialValue: - resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupPolicy ?? - "allowlist", - })) as "allowlist" | "open" | "disabled"; - next = setFeishuGroupPolicy(next, resolvedAccountId, groupPolicy); - - if (groupPolicy === "allowlist") { - const existing = - resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupAllowFrom ?? - []; - const entry = await prompter.text({ - message: "Group chat allowlist (chat_ids)", - placeholder: "oc_xxxxx, oc_yyyyy", - initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, - }); - if (entry) { - const parts = splitSetupEntries(entry); - if (parts.length > 0) { - next = setFeishuGroupAllowFrom(next, resolvedAccountId, parts); - } - } - } - - return { cfg: next }; + return { + credentialValues: { ...credentialValues, _flow: "new" }, + }; }, + + credentials: [], + + // ------------------------------------------------------------------------- + // finalize: run the appropriate flow + // ------------------------------------------------------------------------- + finalize: async ({ cfg, prompter, options, credentialValues }) => { + const flow = credentialValues._flow ?? "new"; + + if (flow === "edit") { + const result = await runEditFlow({ cfg, prompter, options }); + if (result === null) { + return { cfg }; + } + return result; + } + + return runNewAppFlow({ cfg, prompter, options }); + }, + dmPolicy: feishuDmPolicy, disable: (cfg) => patchTopLevelChannelConfigSection({ diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index d24214516d1..a790cf7296e 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -76,11 +76,11 @@ export type FeishuMessageInfo = { threadId?: string; }; -export type FeishuProbeResult = BaseProbeResult & { +export interface FeishuProbeResult extends BaseProbeResult { appId?: string; botName?: string; botOpenId?: string; -}; +} export type FeishuMediaInfo = { path: string; diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index 02238006621..80832901b11 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -228,5 +228,5 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) { { name: "feishu_wiki" }, ); - api.logger.info?.(`feishu_wiki: Registered feishu_wiki tool`); + api.logger.debug?.(`feishu_wiki: Registered feishu_wiki tool`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6629bb6766..f105f25933c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,9 @@ importers: '@sinclair/typebox': specifier: 0.34.49 version: 0.34.49 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 57dbf05ed76..6966c97434a 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -75,6 +75,7 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { createClackPrompter } from "../wizard/clack-prompter.js"; export { feishuSetupWizard, feishuSetupAdapter } from "./feishu-setup.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js";