feat: Streamline Feishu channel onboarding with QR code scan-to-create flow (#65680)

Add QR-based app registration, improve Feishu onboarding flows, support direct login entry, add group chat policy setup, reduce log noise, and update docs.
This commit is contained in:
mazhe-nerd
2026-04-13 18:03:44 +08:00
committed by GitHub
parent 3b9fb972da
commit 9e2ac8a1cb
23 changed files with 909 additions and 1116 deletions
+140 -494
View File
@@ -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 platforms 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.<id>.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 <CODE>
```
After approval, you can chat normally.
---
## Overview
- **Feishu bot channel**: Feishu bot managed by the gateway
- **Deterministic routing**: replies always return to Feishu
- **Session isolation**: DMs share a main session; groups are isolated
- **WebSocket connection**: long connection via Feishu SDK, no public URL needed
## Quick start
> **Requires OpenClaw 2026.4.10 or above.** Run `openclaw --version` to check. Upgrade with `openclaw update`.
<Steps>
<Step title="Run the channel setup wizard">
```bash
openclaw channels login --channel feishu
```
Scan the QR code with your Feishu/Lark mobile app to create a Feishu/Lark bot automatically.
</Step>
<Step title="After setup completes, restart the gateway to apply the changes">
```bash
openclaw gateway restart
```
</Step>
</Steps>
---
@@ -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 <CODE>
```
- `"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 <CODE>
```
### 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.<chat_id>.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.<chat_id>.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.<chat_id>.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
---
<a id="get-groupuser-ids"></a>
## 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.<id>.appId` | App ID | - |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
| `channels.feishu.requireMention` | Default require @mention | conditional |
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group require @mention override | inherited |
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
| `channels.feishu.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.<id>.appId` | App ID | |
| `channels.feishu.accounts.<id>.appSecret` | App Secret | |
| `channels.feishu.accounts.<id>.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.<chat_id>.requireMention` | Per-group @mention override | inherited |
| `channels.feishu.groups.<chat_id>.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

+2 -1
View File
@@ -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:*",
+309
View File
@@ -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<T>(baseUrl: string, body: Record<string, string>): Promise<T> {
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<void> {
const baseUrl = accountsBaseUrl(domain);
const res = await postRegistration<InitResponse>(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<BeginResult> {
const baseUrl = accountsBaseUrl(domain);
const res = await postRegistration<RawBeginResponse>(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<PollOutcome> {
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<PollResponse>(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<void> {
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<string | undefined> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+1 -1
View File
@@ -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");
}
+12
View File
@@ -1091,6 +1091,18 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
fallbackTo,
}),
},
auth: {
login: async ({ cfg }) => {
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: {
+1 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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(", ")}`);
}
}
+1 -1
View File
@@ -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`);
}
+3 -2
View File
@@ -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`,
+1 -1
View File
@@ -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`);
}
+16 -283
View File
@@ -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<string, string | undefined>, run: () => Promise<void>) {
const previous = new Map<string, string | undefined>();
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<typeof feishuConfigure>[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<FeishuConfigureRuntime>(),
});
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<FeishuConfigureRuntime>(),
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
+415 -328
View File
@@ -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<FeishuConfig> & Partial<FeishuAccountConfig>;
// ---------------------------------------------------------------------------
// 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<string, unknown>;
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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).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<string, unknown>;
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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return accountAppIdConfigured && accountSecretConfigured;
}
async function promptFeishuAllowFrom(params: {
cfg: OpenClawConfig;
accountId: string;
accountId?: string;
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
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<string, unknown> | 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<string, unknown>
| 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<NonNullable<ChannelSetupWizard["finalize"]>>[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<string, unknown> = { groupPolicy };
if (groupPolicy === "open") {
groupPatch.requireMention = true;
}
next = patchFeishuConfig(next, accountId, groupPatch);
return next;
}
// ---------------------------------------------------------------------------
// Scan-to-create flow
// ---------------------------------------------------------------------------
async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistrationResult | null> {
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<NonNullable<ChannelSetupWizard["finalize"]>>[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<NonNullable<ChannelSetupWizard["finalize"]>>[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<string, unknown>;
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<string | undefined>((found, account) => {
if (found) {
return found;
}
if (account && typeof account === "object") {
return resolveAppIdLabel((account as Record<string, unknown>).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<OpenClawConfig> {
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({
+2 -2
View File
@@ -76,11 +76,11 @@ export type FeishuMessageInfo = {
threadId?: string;
};
export type FeishuProbeResult = BaseProbeResult<string> & {
export interface FeishuProbeResult extends BaseProbeResult {
appId?: string;
botName?: string;
botOpenId?: string;
};
}
export type FeishuMediaInfo = {
path: string;
+1 -1
View File
@@ -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`);
}
+3
View File
@@ -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:*
+1
View File
@@ -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";