feat(feishu): add Feishu channel extension

This commit is contained in:
Johnny 2026-01-26 16:05:16 +08:00
parent 8b91ceb7c9
commit 70d55bfff7
23 changed files with 3176 additions and 0 deletions

View File

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

View File

@ -0,0 +1,104 @@
---
summary: "Feishu bot support status, capabilities, and configuration"
read_when:
- You want to connect Clawdbot to Feishu
- You are debugging Feishu webhook callbacks
---
# Feishu (Bot API)
Status: experimental. HTTP callback only. Text messages only (V1).
## Plugin required
Feishu ships as a plugin and is not bundled with the core install.
- Install via CLI: `clawdbot plugins install @clawdbot/feishu`
- Or select **Feishu** during onboarding and confirm the install prompt
## Quick setup (beginner)
1. Install the Feishu plugin:
- From a source checkout: `clawdbot plugins install ./extensions/feishu`
- From npm (if published): `clawdbot plugins install @clawdbot/feishu`
2. Create a Feishu app, enable the bot feature, and get:
- `appId`
- `appSecret`
3. Configure event subscription:
- Callback URL: `https://gateway.example.com/feishu`
- Events: subscribe to `im.message.receive_v1`
- Set either a **verification token** or an **encrypt key**
4. Configure Clawdbot:
```json5
{
channels: {
feishu: {
enabled: true,
appId: "cli_xxx",
appSecret: "xxx",
verificationToken: "xxx", // or encryptKey: "xxx"
webhookPath: "/feishu",
dm: { policy: "pairing" },
groupPolicy: "allowlist",
groups: {
oc_xxx: { allow: true, requireMention: true },
},
},
},
}
```
5. Start the gateway. Feishu will POST to the webhook path.
6. DM access is pairing by default; approve the pairing code on first contact.
## Webhook security
The plugin validates webhook requests using one of:
- `verificationToken`: checks the token provided in the callback payload.
- `encryptKey`: validates the Feishu signature header and decrypts encrypted payloads when present.
## Group behavior
- Default `groupPolicy` is `allowlist`.
- Groups require mention by default (`requireMention: true`).
- Configure allowlisted groups by **chat id** under `channels.feishu.groups`.
## Targets (delivery and allowlists)
- Direct message user: `user:<open_id>` (example open id format: `ou_xxx`)
- Group chat: `chat:<chat_id>` (example chat id format: `oc_xxx`)
Example outbound send:
```bash
clawdbot message send --channel feishu --target user:ou_xxx --message "hello"
```
## Configuration reference (Feishu)
Provider options:
- `channels.feishu.enabled`: enable/disable the channel.
- `channels.feishu.appId`: Feishu appId.
- `channels.feishu.appSecret`: Feishu appSecret.
- `channels.feishu.verificationToken`: webhook verification token.
- `channels.feishu.encryptKey`: webhook encrypt key (signature validation + optional decryption).
- `channels.feishu.webhookPath`: webhook path on the gateway HTTP server (default `/feishu`).
- `channels.feishu.webhookUrl`: optional; used to derive the webhook path.
- `channels.feishu.dm.policy`: `pairing | allowlist | open | disabled` (default `pairing`).
- `channels.feishu.dm.allowFrom`: allowlist entries (`ou_...`) or `"*"`.
- `channels.feishu.groupPolicy`: `allowlist | open | disabled` (default `allowlist`).
- `channels.feishu.groups.<chatId>`: group allowlist entry and options (`allow`, `requireMention`, `users`, `systemPrompt`).
Multi-account options:
- `channels.feishu.accounts.<id>.appId`
- `channels.feishu.accounts.<id>.appSecret`
- `channels.feishu.accounts.<id>.verificationToken`
- `channels.feishu.accounts.<id>.encryptKey`
- `channels.feishu.accounts.<id>.webhookPath`
- `channels.feishu.accounts.<id>.webhookUrl`
- `channels.feishu.accounts.<id>.dm`
- `channels.feishu.accounts.<id>.groups`

View File

@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { feishuDock, feishuPlugin } from "./src/channel.js";
import { handleFeishuWebhookRequest } from "./src/monitor.js";
import { setFeishuRuntime } from "./src/runtime.js";
const plugin = {
id: "feishu",
name: "Feishu",
description: "Feishu channel plugin (Bot API)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setFeishuRuntime(api.runtime);
api.registerChannel({ plugin: feishuPlugin, dock: feishuDock });
api.registerHttpHandler(handleFeishuWebhookRequest);
},
};
export default plugin;

View File

@ -0,0 +1,38 @@
{
"name": "@clawdbot/feishu",
"version": "2026.1.25",
"description": "Clawdbot Feishu channel plugin",
"type": "module",
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"clawdbot": "workspace:*"
},
"peerDependencies": {
"clawdbot": ">=2026.1.25"
},
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "feishu",
"label": "Feishu",
"selectionLabel": "Feishu (Bot API)",
"docsPath": "/channels/feishu",
"docsLabel": "feishu",
"blurb": "Feishu bot with HTTP webhook events.",
"aliases": [
"fs"
],
"order": 85,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/feishu",
"localPath": "extensions/feishu",
"defaultChoice": "npm"
}
}
}

View File

@ -0,0 +1,78 @@
# Tech design: Feishu (飞书) channel plugin
## Summary
Implement `@clawdbot/feishu` as a channel extension that:
- Registers a channel plugin (`api.registerChannel`) and an HTTP webhook handler (`api.registerHttpHandler`).
- Starts a per-account “monitor” on gateway start (via `plugin.gateway.startAccount`) that registers webhook targets and manages runtime status.
- Uses the existing Clawdbot reply pipeline (`runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher`) to route inbound messages to an agent and send outbound replies back to Feishu.
This follows the proven “webhook channel” structure in `extensions/googlechat`.
## Architecture
### Components
- **Feishu plugin entrypoint**: `extensions/feishu/index.ts`
- Stores `PluginRuntime` (like `extensions/googlechat/src/runtime.ts`).
- Registers channel + HTTP handler.
- **Channel plugin**: `extensions/feishu/src/channel.ts`
- Config adapter (accounts, enabled/configured, allowFrom formatting).
- Outbound adapter (sendText).
- Security adapter (DM policy warnings, group policy warnings).
- Status adapter (probe + runtime snapshot).
- Gateway adapter (startAccount → register webhook target).
- **Monitor / webhook handler**: `extensions/feishu/src/monitor.ts`
- Parses raw body, validates signature/token, decrypts if needed.
- Handles `url_verification`.
- Handles `im.message.receive_v1` text messages.
- Applies DM + group policies and mention gating.
- Builds inbound context and calls reply dispatcher.
- Sends replies via Feishu REST API.
- **API client**: `extensions/feishu/src/api.ts`
- Token manager: `tenant_access_token/internal` (self-built apps).
- Send message / reply message endpoints.
- Bot info (`/open-apis/bot/v3/info`) to detect mentions.
## Webhook verification and decryption
### URL verification
- Feishu sends a payload (plain or encrypted) where `type === "url_verification"` and includes `challenge`.
- The handler responds `200` with JSON: `{"challenge":"<challenge>"}`.
### Decrypt algorithm
- If payload has `encrypt`:
- Compute AES key = `sha256(encryptKey)` bytes (32 bytes).
- Decode `encrypt` from base64.
- IV = first 16 bytes; ciphertext = remaining bytes.
- Decrypt via `aes-256-cbc` to UTF-8 JSON.
### Signature validation (encrypted mode)
- Headers: `x-lark-request-timestamp`, `x-lark-request-nonce`, `x-lark-signature`.
- Compute: `sha256(timestamp + nonce + encryptKey + rawBodyString)` (hex).
- Compare with `x-lark-signature`.
- Use the raw request body string exactly as received (do not re-stringify parsed JSON).
### Token validation (non-encrypted mode)
- When `encryptKey` is not configured, validate `verificationToken` against payload `token` (or `header.token`).
## Mention gating
- Fetch and cache bot identity via `GET /open-apis/bot/v3/info`.
- In group chats:
- `wasMentioned = mentions.some(m => m.id.open_id === botOpenId || m.id.user_id === botUserId)`
- Apply `resolveMentionGatingWithBypass` with `requireMention` from group config or channel default.
## Test strategy
Colocate tests in `extensions/feishu/src/` (Vitest).
- Signature verification (valid/invalid).
- Decrypt (known encryptKey + payload → expected JSON).
- Target normalization.
- URL verification and event parsing.

View File

@ -0,0 +1,300 @@
# PRD: Feishu (飞书) channel extension
## Why
Clawdbot already supports several chat surfaces (e.g. Slack, Google Chat, Telegram). Feishu (飞书) is a common “primary chat” surface for many teams. Adding a Feishu channel plugin lets a user run Clawdbot as a personal assistant inside Feishu with the same security posture (pairing/allowlists) and the same agent routing + reply pipeline.
## Goals
- Ship a Feishu **channel plugin** under `extensions/` (installable from npm) that can:
- Receive inbound messages (DM + group) via Feishu event subscription callback.
- Respond with text replies (agent replies + `clawdbot message send`).
- Respect Clawdbots DM security model (pairing/allowlist/open/disabled).
- Support group allowlists + mention gating behavior consistent with other channels.
- Show up in onboarding as an installable channel (like Matrix/MSTeams plugins).
- Keep V1 minimal and consistent with existing integration patterns:
- `extensions/googlechat` for “HTTP webhook → monitor → reply dispatcher”
- `extensions/msteams` for “extension owns provider + status + onboarding”
## Non-goals (V1)
- Full message-card interactivity (buttons / card callbacks).
- Full media pipeline parity (file upload/download for every Feishu message type).
- Full directory/lookup parity (live user/group directory browsing).
- Multi-tenant ISV (app store) flow in the first pass (internal/self-built app only).
## Primary user
Single human operator running Clawdbot for personal use, inside one Feishu tenant.
## User journeys (high-level)
1. User installs and enables the Feishu plugin.
2. User creates a Feishu app (self-built/internal) with Bot capability enabled.
3. User configures event subscription callback URL (served by Clawdbot Gateway).
4. User DMs the bot; unknown DMs get pairing code; after approval bot answers.
5. User adds bot to a group; bot responds only when allowed and mention-gated.
## Functional requirements (high-level)
- Inbound:
- HTTP handler for Feishu event subscription callback.
- Support `url_verification` challenge handshake.
- Validate inbound requests (signature and/or verification token).
- Handle `im.message.receive_v1` events.
- Outbound:
- Send text messages to `open_id` (DM) and `chat_id` (group).
- Reply-to behavior: best-effort thread/reply mapping if Feishu supports it.
- Security:
- DM policy: `pairing` default; allowlists; `open`; `disabled`.
- Group policy: `allowlist` default; optional `open` with mention gating; `disabled`.
- Control command gating: ignore unauthorized control commands in group chats.
- Ops:
- `channels status` shows configured/running/probe/last inbound/outbound.
- `channels status --probe` validates token acquisition.
## Success criteria
- A user can complete onboarding and successfully:
- Receive a DM and get a pairing code.
- Approve pairing and receive a reply.
- Receive a group message and respond only when allowlisted + mention-gated.
- Send a message via `clawdbot message send --to <target>`.
## Decisions (confirmed)
1. **Region**: Feishu only.
2. **Inbound transport**: HTTP callback (event subscription webhook).
3. **V1 scope**: text-only.
## Risks and mitigations
- Misconfigured public exposure: document “only expose `/feishu` path” guidance and recommend a reverse proxy/Tailscale.
- Secret leakage: never log app secrets; store secrets only in config.
- Rate limits: cache tokens; chunk outbound messages via existing chunker helpers.
\*\*\* Add File: extensions/feishu/spec/design.md
# Tech design: Feishu (飞书) channel plugin
## Summary
Implement `@clawdbot/feishu` as a channel extension that:
- Registers a channel plugin (`api.registerChannel`) and an HTTP webhook handler (`api.registerHttpHandler`).
- Starts a per-account “monitor” on gateway start (via `plugin.gateway.startAccount`) that registers webhook targets and manages runtime status.
- Uses the existing Clawdbot reply pipeline (`runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher`) to route inbound messages to an agent and send outbound replies back to Feishu.
This follows the proven “webhook channel” structure in `extensions/googlechat`.
## Architecture
### Components
- **Feishu plugin entrypoint**: `extensions/feishu/index.ts`
- Stores `PluginRuntime` (like `extensions/googlechat/src/runtime.ts`).
- Registers channel + HTTP handler.
- **Channel plugin**: `extensions/feishu/src/channel.ts`
- Config adapter (accounts, enabled/configured, allowFrom formatting).
- Outbound adapter (sendText).
- Security adapter (DM policy warnings, group policy warnings).
- Status adapter (probe + runtime snapshot).
- Gateway adapter (startAccount → register webhook target).
- **Monitor / webhook handler**: `extensions/feishu/src/monitor.ts`
- Parses raw body, validates signature/token, decrypts if needed.
- Handles `url_verification`.
- Handles `im.message.receive_v1` text messages.
- Applies DM + group policies and mention gating.
- Builds inbound context and calls reply dispatcher.
- Sends replies via Feishu REST API.
- **API client**: `extensions/feishu/src/api.ts`
- Token manager: `tenant_access_token/internal` (self-built apps).
- Send message / reply message endpoints.
- Bot info (`/open-apis/bot/v3/info`) to detect mentions.
## Webhook verification and decryption
### URL verification
- Feishu sends a payload (plain or encrypted) where `type === "url_verification"` and includes `challenge`.
- The handler responds `200` with JSON: `{"challenge":"<challenge>"}`.
### Decrypt algorithm
- If payload has `encrypt`:
- Compute AES key = `sha256(encryptKey)` bytes (32 bytes).
- Decode `encrypt` from base64.
- IV = first 16 bytes; ciphertext = remaining bytes.
- Decrypt via `aes-256-cbc` to UTF-8 JSON.
### Signature validation (encrypted mode)
- Headers: `x-lark-request-timestamp`, `x-lark-request-nonce`, `x-lark-signature`.
- Compute: `sha256(timestamp + nonce + encryptKey + rawBodyString)` (hex).
- Compare with `x-lark-signature`.
- Use the raw request body string exactly as received (do not re-stringify parsed JSON).
### Token validation (non-encrypted mode)
- When `encryptKey` is not configured, validate `verificationToken` against payload `token` (or `header.token`).
## Mention gating
- Fetch and cache bot identity via `GET /open-apis/bot/v3/info`.
- In group chats:
- `wasMentioned = mentions.some(m => m.id.open_id === botOpenId || m.id.user_id === botUserId)`
- Apply `resolveMentionGatingWithBypass` with `requireMention` from group config or channel default.
## Test strategy
Colocate tests in `extensions/feishu/src/` (Vitest).
- Signature verification (valid/invalid).
- Decrypt (known encryptKey + payload → expected JSON).
- Target normalization.
- URL verification and event parsing.
\*\*\* Add File: extensions/feishu/spec/spec.md
# Spec: Feishu channel extension
## Requirements
### Requirement: Channel plugin availability
The system SHALL provide a Feishu chat channel as a Clawdbot extension plugin that can be installed and enabled without modifying core channel code.
#### Scenario: Plugin appears in onboarding catalog
- **GIVEN** a user runs `clawdbot onboard` in a workspace that contains the Feishu plugin (local path) or can access it on npm
- **WHEN** the user reaches the channel selection step
- **THEN** Feishu is listed as an installable channel plugin with a docs link
### Requirement: Webhook endpoint and URL verification
The system SHALL accept Feishu event subscription callbacks over HTTP and complete the platform “request URL verification” handshake.
#### Scenario: URL verification succeeds
- **GIVEN** Feishu sends a `type="url_verification"` callback payload with a `challenge`
- **WHEN** Clawdbot receives the POST at the configured webhook path
- **THEN** the response status is `200` and the response body is `{"challenge":"<value>"}` (JSON)
### Requirement: Request validation
The system SHALL validate inbound callback requests before processing events.
#### Scenario: Invalid signature/token is rejected
- **GIVEN** a callback request with an invalid signature (encrypted mode) OR mismatched verification token (non-encrypted mode)
- **WHEN** the request is received
- **THEN** the request is rejected with `401` and no message processing occurs
### Requirement: Encrypted payload support
When configured with an encrypt key, the system SHALL decrypt payloads that use the `encrypt` envelope.
#### Scenario: Encrypted event is processed
- **GIVEN** a callback request containing an `encrypt` field
- **WHEN** the plugin is configured with the correct `encryptKey`
- **THEN** the decrypted JSON is used for URL verification and event handling
### Requirement: Inbound message handling (DM)
The system SHALL process `im.message.receive_v1` DMs and route them into the Clawdbot agent pipeline with DM security policies.
#### Scenario: Unknown DM triggers pairing flow
- **GIVEN** `channels.feishu.dm.policy="pairing"`
- **AND** a DM sender is not allowlisted and not previously paired
- **WHEN** the sender DMs the bot
- **THEN** the system records a pairing request and replies with a pairing code message
### Requirement: Inbound message handling (groups)
The system SHALL process `im.message.receive_v1` group messages with group allowlists and mention gating.
#### Scenario: Group message is mention-gated
- **GIVEN** `channels.feishu.groupPolicy="open"` (or allowlisted group)
- **AND** `requireMention=true`
- **WHEN** a group message arrives without mentioning the bot
- **THEN** the system ignores the message and does not invoke the agent
### Requirement: Outbound text delivery
The system SHALL be able to send text messages to Feishu users and group chats.
#### Scenario: CLI message send delivers text
- **GIVEN** the user runs `clawdbot message send --to <feishu-target> --message "hi"`
- **WHEN** the target is a valid Feishu user (`open_id`) or chat (`chat_id`)
- **THEN** the plugin sends a Feishu API request that results in a visible message in the correct conversation
### Requirement: Status and probe visibility
The system SHALL expose Feishu channel health via `clawdbot channels status`, including an active probe that validates credentials.
#### Scenario: Probe fails with actionable error
- **GIVEN** the plugin is enabled but credentials are invalid
- **WHEN** the user runs `clawdbot channels status --probe`
- **THEN** the Feishu channel shows `probe=error` with an actionable message (e.g. token fetch failed)
\*\*\* Add File: extensions/feishu/spec/tasks.md
# Tasks: Add Feishu (飞书) channel extension
## 1. Extension scaffold
- [x] Create `extensions/feishu/` workspace package (`package.json`, `clawdbot.plugin.json`, `index.ts`, `src/`).
- [x] Add plugin catalog metadata in `extensions/feishu/package.json` (`clawdbot.channel` + `clawdbot.install`) so onboarding can install it.
## 2. Config + normalization
- [x] Define Feishu config Zod schema (extension-local) and expose it as `configSchema` via `buildChannelConfigSchema`.
- [x] Implement target normalization (`user:<open_id>`, `chat:<chat_id>`) and allowlist formatting.
- [x] Implement account model (start with default account; keep `accounts` extensible).
## 3. Token manager + API client
- [x] Implement tenant access token fetch + cache (`/auth/v3/tenant_access_token/internal`).
- [x] Implement Feishu “bot info” fetch (`/bot/v3/info`) for mention gating.
- [x] Implement send message (`/im/v1/messages`) and reply message (`/im/v1/messages/:message_id/reply`).
## 4. Webhook handler
- [x] Implement `handleFeishuWebhookRequest(req,res)` and register via `api.registerHttpHandler`.
- [x] Support `url_verification` challenge response (plain + encrypted).
- [x] Implement secure validation:
- [x] Raw-body capture with size limit.
- [x] Signature verification when `encryptKey` is configured.
- [x] Verification token checks when `encryptKey` is not configured.
- [x] Implement decrypt for payloads with `encrypt`.
## 5. Event processing + routing
- [x] Handle `im.message.receive_v1` (text-only V1):
- [x] DM policy + pairing store integration (mirror GoogleChat monitor behavior).
- [x] Group policy allowlists + mention gating integration.
- [x] Control command gating for group chats.
- [x] Build inbound context via `runtime.channel.reply.finalizeInboundContext` and dispatch replies via buffered dispatcher.
## 6. Outbound adapters
- [x] Implement `outbound.sendText` for `clawdbot message send` and heartbeats.
## 7. Status + probe
- [x] Implement `status` adapter snapshot + summary.
- [x] Implement `probeAccount` to validate credentials (token + bot info).
## 8. Onboarding
- [x] Add onboarding adapter with prompts for app id/secret and webhook settings.
## 9. Tests
- [x] Add unit tests for: signature, decrypt, target normalization.
- [ ] Add unit tests for: url_verification, event parsing.

View File

@ -0,0 +1,85 @@
# Spec: Feishu channel extension
## Requirements
### Requirement: Channel plugin availability
The system SHALL provide a Feishu chat channel as a Clawdbot extension plugin that can be installed and enabled without modifying core channel code.
#### Scenario: Plugin appears in onboarding catalog
- **GIVEN** a user runs `clawdbot onboard` in a workspace that contains the Feishu plugin (local path) or can access it on npm
- **WHEN** the user reaches the channel selection step
- **THEN** Feishu is listed as an installable channel plugin with a docs link
### Requirement: Webhook endpoint and URL verification
The system SHALL accept Feishu event subscription callbacks over HTTP and complete the platform “request URL verification” handshake.
#### Scenario: URL verification succeeds
- **GIVEN** Feishu sends a `type="url_verification"` callback payload with a `challenge`
- **WHEN** Clawdbot receives the POST at the configured webhook path
- **THEN** the response status is `200` and the response body is `{"challenge":"<value>"}` (JSON)
### Requirement: Request validation
The system SHALL validate inbound callback requests before processing events.
#### Scenario: Invalid signature/token is rejected
- **GIVEN** a callback request with an invalid signature (encrypted mode) OR mismatched verification token (non-encrypted mode)
- **WHEN** the request is received
- **THEN** the request is rejected with `401` and no message processing occurs
### Requirement: Encrypted payload support
When configured with an encrypt key, the system SHALL decrypt payloads that use the `encrypt` envelope.
#### Scenario: Encrypted event is processed
- **GIVEN** a callback request containing an `encrypt` field
- **WHEN** the plugin is configured with the correct `encryptKey`
- **THEN** the decrypted JSON is used for URL verification and event handling
### Requirement: Inbound message handling (DM)
The system SHALL process `im.message.receive_v1` DMs and route them into the Clawdbot agent pipeline with DM security policies.
#### Scenario: Unknown DM triggers pairing flow
- **GIVEN** `channels.feishu.dm.policy="pairing"`
- **AND** a DM sender is not allowlisted and not previously paired
- **WHEN** the sender DMs the bot
- **THEN** the system records a pairing request and replies with a pairing code message
### Requirement: Inbound message handling (groups)
The system SHALL process `im.message.receive_v1` group messages with group allowlists and mention gating.
#### Scenario: Group message is mention-gated
- **GIVEN** `channels.feishu.groupPolicy="open"` (or allowlisted group)
- **AND** `requireMention=true`
- **WHEN** a group message arrives without mentioning the bot
- **THEN** the system ignores the message and does not invoke the agent
### Requirement: Outbound text delivery
The system SHALL be able to send text messages to Feishu users and group chats.
#### Scenario: CLI message send delivers text
- **GIVEN** the user runs `clawdbot message send --to <feishu-target> --message "hi"`
- **WHEN** the target is a valid Feishu user (`open_id`) or chat (`chat_id`)
- **THEN** the plugin sends a Feishu API request that results in a visible message in the correct conversation
### Requirement: Status and probe visibility
The system SHALL expose Feishu channel health via `clawdbot channels status`, including an active probe that validates credentials.
#### Scenario: Probe fails with actionable error
- **GIVEN** the plugin is enabled but credentials are invalid
- **WHEN** the user runs `clawdbot channels status --probe`
- **THEN** the Feishu channel shows `probe=error` with an actionable message (e.g. token fetch failed)

View File

@ -0,0 +1,54 @@
# Tasks: Add Feishu (飞书) channel extension
## 1. Extension scaffold
- [x] Create `extensions/feishu/` workspace package (`package.json`, `clawdbot.plugin.json`, `index.ts`, `src/`).
- [x] Add plugin catalog metadata in `extensions/feishu/package.json` (`clawdbot.channel` + `clawdbot.install`) so onboarding can install it.
## 2. Config + normalization
- [x] Define Feishu config Zod schema (extension-local) and expose it as `configSchema` via `buildChannelConfigSchema`.
- [x] Implement target normalization (`user:<open_id>`, `chat:<chat_id>`) and allowlist formatting.
- [x] Implement account model (start with default account; keep `accounts` extensible).
## 3. Token manager + API client
- [x] Implement tenant access token fetch + cache (`/auth/v3/tenant_access_token/internal`).
- [x] Implement Feishu “bot info” fetch (`/bot/v3/info`) for mention gating.
- [x] Implement send message (`/im/v1/messages`) and reply message (`/im/v1/messages/:message_id/reply`).
## 4. Webhook handler
- [x] Implement `handleFeishuWebhookRequest(req,res)` and register via `api.registerHttpHandler`.
- [x] Support `url_verification` challenge response (plain + encrypted).
- [x] Implement secure validation:
- [x] Raw-body capture with size limit.
- [x] Signature verification when `encryptKey` is configured.
- [x] Verification token checks when `encryptKey` is not configured.
- [x] Implement decrypt for payloads with `encrypt`.
## 5. Event processing + routing
- [x] Handle `im.message.receive_v1` (text-only V1):
- [x] DM policy + pairing store integration (mirror GoogleChat monitor behavior).
- [x] Group policy allowlists + mention gating integration.
- [x] Control command gating for group chats.
- [x] Build inbound context via `runtime.channel.reply.finalizeInboundContext` and dispatch replies via buffered dispatcher.
## 6. Outbound adapters
- [x] Implement `outbound.sendText` for `clawdbot message send` and heartbeats.
## 7. Status + probe
- [x] Implement `status` adapter snapshot + summary.
- [x] Implement `probeAccount` to validate credentials (token + bot info).
## 8. Onboarding
- [x] Add onboarding adapter with prompts for app id/secret and webhook settings.
## 9. Tests
- [x] Add unit tests for: signature, decrypt, target normalization.
- [x] Add unit tests for: url_verification, event parsing.

View File

@ -0,0 +1,78 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { FeishuAccountConfig, FeishuConfig } from "./types.config.js";
export type FeishuCredentialSource = "config" | "none";
export type ResolvedFeishuAccount = {
accountId: string;
name?: string;
enabled: boolean;
config: FeishuAccountConfig;
credentialSource: FeishuCredentialSource;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.feishu as FeishuConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
const channel = cfg.channels?.feishu as FeishuConfig | undefined;
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim();
const ids = listFeishuAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): FeishuAccountConfig | undefined {
const accounts = (cfg.channels?.feishu as FeishuConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as FeishuAccountConfig | undefined;
}
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuAccountConfig {
const raw = (cfg.channels?.feishu ?? {}) as FeishuConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account } as FeishuAccountConfig;
}
function hasCredentials(cfg: FeishuAccountConfig): boolean {
const appId = cfg.appId?.trim();
const appSecret = cfg.appSecret?.trim();
const token = cfg.verificationToken?.trim();
const encryptKey = cfg.encryptKey?.trim();
return Boolean(appId && appSecret && (token || encryptKey));
}
export function resolveFeishuAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedFeishuAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.feishu as FeishuConfig | undefined)?.enabled !== false;
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const credentialSource: FeishuCredentialSource = hasCredentials(merged) ? "config" : "none";
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
config: merged,
credentialSource,
};
}

View File

@ -0,0 +1,230 @@
import crypto from "node:crypto";
import type { ResolvedFeishuAccount } from "./accounts.js";
import type { FeishuMessagingTarget } from "./targets.js";
const FEISHU_API_ORIGIN = "https://open.feishu.cn";
const FEISHU_API_BASE = `${FEISHU_API_ORIGIN}/open-apis`;
type FeishuApiOk<T> = { ok: true; data: T };
type FeishuApiErr = { ok: false; error: string; code?: number; logId?: string };
type FeishuApiResult<T> = FeishuApiOk<T> | FeishuApiErr;
type TenantToken = {
token: string;
expiresAt: number;
};
type BotIdentity = {
openId?: string;
userId?: string;
name?: string;
fetchedAt: number;
};
const tokenCache = new Map<string, TenantToken>();
const botCache = new Map<string, BotIdentity>();
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolveAccountKey(account: ResolvedFeishuAccount): string {
const appId = account.config.appId?.trim() ?? "";
return `${FEISHU_API_ORIGIN}|${appId}`;
}
async function readJson(res: Response): Promise<unknown> {
const text = await res.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch (err) {
throw new Error(
`invalid JSON response (${res.status}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
function extractFeishuError(payload: unknown): FeishuApiErr {
if (!isRecord(payload)) return { ok: false, error: "invalid response" };
const code = typeof payload.code === "number" ? payload.code : undefined;
const msg = readString(payload.msg) ?? readString(payload.message) ?? "request failed";
const logId =
readString((payload.error as { log_id?: unknown } | undefined)?.log_id) ??
readString(payload.log_id);
return { ok: false, error: msg, code, logId };
}
function isFeishuOk(payload: unknown): payload is Record<string, unknown> & { code: number } {
return isRecord(payload) && typeof payload.code === "number" && payload.code === 0;
}
function sha256Hex(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
export async function fetchTenantAccessToken(
account: ResolvedFeishuAccount,
): Promise<FeishuApiResult<{ token: string; expiresAt: number }>> {
const appId = account.config.appId?.trim() ?? "";
const appSecret = account.config.appSecret?.trim() ?? "";
if (!appId || !appSecret) {
return { ok: false, error: "missing appId/appSecret" };
}
const res = await fetch(`${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
const payload = await readJson(res);
if (!res.ok) {
return extractFeishuError(payload);
}
if (!isFeishuOk(payload)) {
return extractFeishuError(payload);
}
const token =
readString((payload as Record<string, unknown>).tenant_access_token) ??
readString((payload as Record<string, unknown>).tenantAccessToken) ??
readString(
(payload as { data?: unknown }).data &&
(payload as { data?: Record<string, unknown> }).data?.tenant_access_token,
);
const expireSeconds =
typeof (payload as Record<string, unknown>).expire === "number"
? (payload as Record<string, unknown>).expire
: typeof (payload as { data?: Record<string, unknown> }).data?.expire === "number"
? (payload as { data?: Record<string, unknown> }).data!.expire
: undefined;
if (!token || !expireSeconds || expireSeconds <= 0) {
return { ok: false, error: "token response missing tenant_access_token/expire" };
}
const expiresAt = Date.now() + expireSeconds * 1000 - 3 * 60 * 1000;
return { ok: true, data: { token, expiresAt } };
}
export async function getTenantAccessToken(account: ResolvedFeishuAccount): Promise<string> {
const key = resolveAccountKey(account);
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.token;
const fetched = await fetchTenantAccessToken(account);
if (!fetched.ok) {
const suffix = fetched.logId ? ` (log_id=${fetched.logId})` : "";
throw new Error(`Feishu token fetch failed: ${fetched.error}${suffix}`);
}
tokenCache.set(key, { token: fetched.data.token, expiresAt: fetched.data.expiresAt });
return fetched.data.token;
}
function extractBotIdentity(payload: unknown): { openId?: string; userId?: string; name?: string } {
if (!isRecord(payload)) return {};
const data = (payload as { data?: unknown }).data;
const container = isRecord(data) ? data : payload;
const bot = isRecord((container as Record<string, unknown>).bot)
? ((container as Record<string, unknown>).bot as Record<string, unknown>)
: (container as Record<string, unknown>);
return {
openId: readString(bot.open_id) ?? readString((container as Record<string, unknown>).open_id),
userId: readString(bot.user_id) ?? readString((container as Record<string, unknown>).user_id),
name: readString(bot.name) ?? readString((container as Record<string, unknown>).name),
};
}
export async function getBotIdentity(
account: ResolvedFeishuAccount,
opts?: { maxAgeMs?: number },
): Promise<BotIdentity> {
const maxAgeMs = typeof opts?.maxAgeMs === "number" ? Math.max(1, opts.maxAgeMs) : 10 * 60 * 1000;
const key = resolveAccountKey(account);
const cached = botCache.get(key);
if (cached && Date.now() - cached.fetchedAt < maxAgeMs) return cached;
const token = await getTenantAccessToken(account);
const res = await fetch(`${FEISHU_API_BASE}/bot/v3/info`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const payload = await readJson(res);
if (!res.ok || !isFeishuOk(payload)) {
const err = extractFeishuError(payload);
const suffix = err.logId ? ` (log_id=${err.logId})` : "";
throw new Error(`Feishu bot info failed: ${err.error}${suffix}`);
}
const identity = extractBotIdentity(payload);
const next: BotIdentity = {
openId: identity.openId,
userId: identity.userId,
name: identity.name,
fetchedAt: Date.now(),
};
botCache.set(key, next);
return next;
}
export async function sendFeishuTextMessage(params: {
account: ResolvedFeishuAccount;
target: FeishuMessagingTarget;
text: string;
replyToMessageId?: string;
}): Promise<{ messageId: string }> {
const token = await getTenantAccessToken(params.account);
const body = {
msg_type: "text",
content: JSON.stringify({ text: params.text }),
};
const endpoint = params.replyToMessageId
? `${FEISHU_API_BASE}/im/v1/messages/${encodeURIComponent(params.replyToMessageId)}/reply`
: `${FEISHU_API_BASE}/im/v1/messages`;
const query = params.replyToMessageId
? null
: params.target.kind === "user"
? "receive_id_type=open_id"
: "receive_id_type=chat_id";
const url = query ? `${endpoint}?${query}` : endpoint;
const payload = params.replyToMessageId
? {
...body,
reply_in_thread: false,
}
: {
...body,
receive_id: params.target.kind === "user" ? params.target.openId : params.target.chatId,
};
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const json = await readJson(res);
if (!res.ok || !isFeishuOk(json)) {
const err = extractFeishuError(json);
const suffix = err.logId ? ` (log_id=${err.logId})` : "";
throw new Error(`Feishu send failed: ${err.error}${suffix}`);
}
const data = isRecord((json as { data?: unknown }).data)
? ((json as { data?: Record<string, unknown> }).data as Record<string, unknown>)
: (json as Record<string, unknown>);
const messageId = readString(data.message_id) ?? readString(data.messageId) ?? "";
if (!messageId) {
// The API returns a `message_id` in most cases; if missing, still treat as success.
return { messageId: sha256Hex(`${Date.now()}${Math.random()}`) };
}
return { messageId };
}

View File

@ -0,0 +1,67 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
function encryptFeishuPayload(params: { plaintext: string; encryptKey: string }): string {
const key = crypto.createHash("sha256").update(params.encryptKey).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const ciphertext = Buffer.concat([cipher.update(params.plaintext, "utf8"), cipher.final()]);
return Buffer.concat([iv, ciphertext]).toString("base64");
}
describe("feishu/auth", () => {
it("decrypts encrypt payloads", () => {
const encryptKey = "encrypt-key-example";
const plaintext = JSON.stringify({
type: "url_verification",
challenge: "challenge-value",
token: "verification-token",
});
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
const decrypted = decryptFeishuEncrypt({ encrypt, encryptKey });
expect(decrypted).toBe(plaintext);
});
it("rejects decrypt with wrong key", () => {
const encryptKey = "encrypt-key-example";
const plaintext = '{"type":"event_callback"}';
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
expect(() => decryptFeishuEncrypt({ encrypt, encryptKey: "wrong-key" })).toThrow();
});
it("verifies Feishu signatures", () => {
const rawBody = '{"encrypt":"abc"}';
const encryptKey = "encrypt-key-example";
const timestamp = "1700000000";
const nonce = "nonce";
const signature = crypto
.createHash("sha256")
.update(`${timestamp}${nonce}${encryptKey}${rawBody}`)
.digest("hex");
expect(
verifyFeishuSignature({
rawBody,
encryptKey,
timestamp,
nonce,
signature,
}),
).toBe(true);
expect(
verifyFeishuSignature({
rawBody,
encryptKey,
timestamp,
nonce: "other",
signature,
}),
).toBe(false);
});
});

View File

@ -0,0 +1,35 @@
import crypto from "node:crypto";
export function decryptFeishuEncrypt(params: { encrypt: string; encryptKey: string }): string {
const encrypt = params.encrypt.trim();
const encryptKey = params.encryptKey.trim();
if (!encrypt) throw new Error("missing encrypt payload");
if (!encryptKey) throw new Error("missing encryptKey");
const key = crypto.createHash("sha256").update(encryptKey).digest();
const raw = Buffer.from(encrypt, "base64");
if (raw.length < 17) throw new Error("invalid encrypt payload");
const iv = raw.subarray(0, 16);
const ciphertext = raw.subarray(16);
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
return decrypted;
}
export function verifyFeishuSignature(params: {
rawBody: string;
encryptKey: string;
timestamp: string;
nonce: string;
signature: string;
}): boolean {
const rawBody = params.rawBody;
const encryptKey = params.encryptKey.trim();
const timestamp = params.timestamp.trim();
const nonce = params.nonce.trim();
const signature = params.signature.trim().toLowerCase();
if (!encryptKey || !timestamp || !nonce || !signature) return false;
const content = `${timestamp}${nonce}${encryptKey}${rawBody}`;
const computed = crypto.createHash("sha256").update(content).digest("hex").toLowerCase();
return computed === signature;
}

View File

@ -0,0 +1,529 @@
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
missingTargetError,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
type ResolvedFeishuAccount,
} from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import { feishuOnboardingAdapter } from "./onboarding.js";
import { probeFeishu } from "./probe.js";
import { resolveFeishuWebhookPath, startFeishuMonitor } from "./monitor.js";
import {
looksLikeFeishuTargetId,
normalizeFeishuMessagingTarget,
parseFeishuMessagingTarget,
} from "./targets.js";
import { getFeishuRuntime } from "./runtime.js";
const meta = {
id: "feishu",
label: "Feishu",
selectionLabel: "Feishu (Bot API)",
docsPath: "/channels/feishu",
docsLabel: "feishu",
blurb: "Feishu bot with HTTP webhook events.",
aliases: ["fs"],
order: 85,
quickstartAllowFrom: true,
} as const;
const formatAllowFromEntry = (entry: string) =>
entry
.trim()
.replace(/^feishu:/i, "")
.replace(/^fs:/i, "")
.replace(/^user:/i, "")
.replace(/^open_id:/i, "")
.replace(/^openid:/i, "")
.toLowerCase();
export const feishuDock: ChannelDock = {
id: "feishu",
capabilities: {
chatTypes: ["direct", "group"],
blockStreaming: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
function targetHint() {
return "<user:OPEN_ID|chat:CHAT_ID>";
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: { ...meta },
onboarding: feishuOnboardingAdapter,
pairing: {
idLabel: "feishuOpenId",
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notifyApproval: async ({ cfg, id }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
if (account.credentialSource === "none") return;
const openId = formatAllowFromEntry(id);
await sendFeishuTextMessage({
account,
target: { kind: "user", openId },
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "feishu",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "feishu",
accountId,
clearBaseFields: [
"appId",
"appSecret",
"verificationToken",
"encryptKey",
"webhookPath",
"webhookUrl",
"name",
],
}),
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.feishu?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.feishu.accounts.${resolvedAccountId}.`
: "channels.feishu.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${basePath}dm.policy`,
allowFromPath: `${basePath}dm.`,
approveHint: formatPairingApproveHint("feishu"),
normalizeEntry: (raw) => formatAllowFromEntry(raw),
};
},
collectWarnings: ({ cfg, account }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "open") {
warnings.push(
`- Feishu groups: groupPolicy="open" allows any group not explicitly denied to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" and configure channels.feishu.groups.`,
);
}
return warnings;
},
},
groups: {
resolveRequireMention: () => true,
resolveToolPolicy: () => ({ mode: "allow" }),
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: normalizeFeishuMessagingTarget,
targetResolver: {
looksLikeId: looksLikeFeishuTargetId,
hint: targetHint(),
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed);
}
return Array.from(ids)
.map(formatAllowFromEntry)
.filter((id) => (q ? id.includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? {};
const ids = Object.keys(groups)
.map((id) => id.trim())
.filter((id) => id && id !== "*")
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
},
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
const normalized = normalizeFeishuMessagingTarget(input);
if (!normalized) {
return { input, resolved: false, note: "empty target" };
}
if (kind === "user" && normalized.toLowerCase().startsWith("user:")) {
return { input, resolved: true, id: normalized };
}
if (kind === "group" && normalized.toLowerCase().startsWith("chat:")) {
return { input, resolved: true, id: normalized };
}
return { input, resolved: false, note: targetHint() };
});
return resolved;
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "feishu",
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) {
return "Feishu does not support --use-env; set appId/appSecret in config.";
}
const appId = (input as { appId?: unknown }).appId;
const appSecret = (input as { appSecret?: unknown }).appSecret;
const verificationToken = (input as { verificationToken?: unknown }).verificationToken;
const encryptKey = (input as { encryptKey?: unknown }).encryptKey;
if (typeof appId !== "string" || !appId.trim()) {
return "Feishu requires --app-id.";
}
if (typeof appSecret !== "string" || !appSecret.trim()) {
return "Feishu requires --app-secret.";
}
if (
(typeof verificationToken !== "string" || !verificationToken.trim()) &&
(typeof encryptKey !== "string" || !encryptKey.trim())
) {
return "Feishu requires --verification-token or --encrypt-key.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "feishu",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig as ClawdbotConfig,
channelKey: "feishu",
})
: namedConfig;
const appId = String((input as { appId?: unknown }).appId ?? "").trim();
const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim();
const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken;
const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey;
const verificationToken =
typeof verificationTokenRaw === "string" && verificationTokenRaw.trim()
? verificationTokenRaw.trim()
: undefined;
const encryptKey =
typeof encryptKeyRaw === "string" && encryptKeyRaw.trim()
? encryptKeyRaw.trim()
: undefined;
const webhookPath = input.webhookPath?.trim() || undefined;
const webhookUrl = input.webhookUrl?.trim() || undefined;
const configPatch = {
appId,
appSecret,
...(verificationToken ? { verificationToken } : {}),
...(encryptKey ? { encryptKey } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
feishu: {
...(next.channels?.feishu ?? {}),
enabled: true,
...configPatch,
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
feishu: {
...(next.channels?.feishu ?? {}),
enabled: true,
accounts: {
...(next.channels?.feishu?.accounts ?? {}),
[accountId]: {
...(next.channels?.feishu?.accounts?.[accountId] ?? {}),
enabled: true,
...configPatch,
},
},
},
},
} as ClawdbotConfig;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowList = (allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean)
.filter((entry) => entry !== "*")
.map((entry) => normalizeFeishuMessagingTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalized = normalizeFeishuMessagingTarget(trimmed);
if (!normalized) {
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError(
"Feishu",
`${targetHint()} or channels.feishu.dm.allowFrom[0]`,
),
};
}
return { ok: true, to: normalized };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("Feishu", `${targetHint()} or channels.feishu.dm.allowFrom[0]`),
};
},
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
if (account.credentialSource === "none") {
throw new Error(
"Feishu not configured: missing appId/appSecret and webhook validation secret",
);
}
const target = parseFeishuMessagingTarget(to);
if (!target) {
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
}
const bot = await getBotIdentity(account).catch(() => null);
const replyToMessageId = replyToId?.trim() || undefined;
const result = await sendFeishuTextMessage({
account,
target,
text,
replyToMessageId,
});
return {
channel: "feishu",
messageId: result.messageId,
chatId: target.kind === "chat" ? target.chatId : undefined,
meta: {
target: target.kind === "chat" ? `chat:${target.chatId}` : `user:${target.openId}`,
botOpenId: bot?.openId,
},
};
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
const caption = text?.trim() ? `${text.trim()}\n${mediaUrl}` : mediaUrl;
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
if (account.credentialSource === "none") {
throw new Error(
"Feishu not configured: missing appId/appSecret and webhook validation secret",
);
}
const target = parseFeishuMessagingTarget(to);
if (!target) {
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
}
const result = await sendFeishuTextMessage({
account,
target,
text: caption,
replyToMessageId: replyToId?.trim() || undefined,
});
return {
channel: "feishu",
messageId: result.messageId,
chatId: target.kind === "chat" ? target.chatId : undefined,
meta: {
warning: "media send is not supported yet; delivered as text URL",
},
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
webhookPath: null,
lastInboundAt: null,
lastOutboundAt: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
webhookPath: snapshot.webhookPath ?? null,
lastInboundAt: snapshot.lastInboundAt ?? null,
lastOutboundAt: snapshot.lastOutboundAt ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.log?.info(`[${account.accountId}] starting Feishu webhook`);
ctx.setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
webhookPath: resolveFeishuWebhookPath({ account }),
});
const unregister = await startFeishuMonitor({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
return () => {
unregister?.();
ctx.setStatus({
accountId: account.accountId,
running: false,
lastStopAt: Date.now(),
});
};
},
},
};

View File

@ -0,0 +1,85 @@
import { z } from "zod";
import { DmPolicySchema, GroupPolicySchema, requireOpenAllowFrom } from "clawdbot/plugin-sdk";
const StringListSchema = z.array(z.union([z.string(), z.number()])).optional();
export const FeishuDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: StringListSchema,
})
.strict()
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.policy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.feishu.dm.policy="open" requires channels.feishu.dm.allowFrom to include "*"',
});
});
export const FeishuGroupSchema = z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
users: StringListSchema,
systemPrompt: z.string().optional(),
})
.strict();
export const FeishuAccountSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
appId: z.string().optional(),
appSecret: z.string().optional(),
verificationToken: z.string().optional(),
encryptKey: z.string().optional(),
webhookPath: z.string().optional(),
webhookUrl: z.string().optional(),
requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: StringListSchema,
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
dm: FeishuDmSchema.optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
})
.strict()
.superRefine((value, ctx) => {
const appId = value.appId?.trim();
const appSecret = value.appSecret?.trim();
if (appId && !appSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "appSecret is required when appId is set",
path: ["appSecret"],
});
}
if (appSecret && !appId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "appId is required when appSecret is set",
path: ["appId"],
});
}
const verificationToken = value.verificationToken?.trim();
const encryptKey = value.encryptKey?.trim();
if ((appId || appSecret) && !verificationToken && !encryptKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "verificationToken or encryptKey is required for webhook validation",
path: ["verificationToken"],
});
}
});
export const FeishuConfigSchema = FeishuAccountSchema.extend({
accounts: z.record(z.string(), FeishuAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
});

View File

@ -0,0 +1,799 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
import type { ResolvedFeishuAccount } from "./accounts.js";
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
import { getFeishuRuntime } from "./runtime.js";
import type { FeishuMessagingTarget } from "./targets.js";
export type FeishuRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type FeishuMonitorOptions = {
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
abortSignal: AbortSignal;
webhookPath?: string;
webhookUrl?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
type WebhookTarget = {
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
core: FeishuCoreRuntime;
path: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
const webhookTargets = new Map<string, WebhookTarget[]>();
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[feishu] ${message}`);
}
}
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1);
}
return withSlash;
}
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
const trimmedPath = webhookPath?.trim();
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
if (webhookUrl?.trim()) {
try {
const parsed = new URL(webhookUrl);
return normalizeWebhookPath(parsed.pathname || "/");
} catch {
return null;
}
}
return "/feishu";
}
export function registerFeishuWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
}
async function readRawBody(req: IncomingMessage, maxBytes: number) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; raw?: string; error?: string }>((resolve) => {
let resolved = false;
const doResolve = (value: { ok: boolean; raw?: string; error?: string }) => {
if (resolved) return;
resolved = true;
req.removeAllListeners();
resolve(value);
};
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
doResolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
doResolve({ ok: false, error: "empty payload" });
return;
}
doResolve({ ok: true, raw });
});
req.on("error", (err) => {
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
});
}
function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; error: string } {
try {
return { ok: true, value: JSON.parse(raw) as unknown };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
type SelectedRequest = {
target: WebhookTarget;
payload: Record<string, unknown>;
};
function extractVerificationToken(payload: Record<string, unknown>): string | undefined {
const token = readString(payload.token);
if (token) return token;
const header = isRecord(payload.header) ? payload.header : undefined;
return header ? readString(header.token) : undefined;
}
function selectTarget(params: {
targets: WebhookTarget[];
rawBody: string;
outer: Record<string, unknown>;
headers: {
timestamp: string;
nonce: string;
signature: string;
};
}): SelectedRequest | null {
const { targets, rawBody, outer, headers } = params;
const encrypt = readString(outer.encrypt);
if (encrypt) {
for (const candidate of targets) {
const encryptKey = candidate.account.config.encryptKey?.trim();
if (!encryptKey) continue;
if (
verifyFeishuSignature({
rawBody,
encryptKey,
timestamp: headers.timestamp,
nonce: headers.nonce,
signature: headers.signature,
})
) {
try {
const decrypted = decryptFeishuEncrypt({ encrypt, encryptKey });
const json = parseJson(decrypted);
if (!json.ok || !isRecord(json.value)) continue;
return { target: candidate, payload: json.value };
} catch {
continue;
}
}
}
return null;
}
const token = extractVerificationToken(outer);
if (token) {
const match = targets.find(
(candidate) => candidate.account.config.verificationToken?.trim() === token,
);
if (match) return { target: match, payload: outer };
}
if (headers.signature && headers.timestamp && headers.nonce) {
for (const candidate of targets) {
const encryptKey = candidate.account.config.encryptKey?.trim();
if (!encryptKey) continue;
if (
verifyFeishuSignature({
rawBody,
encryptKey,
timestamp: headers.timestamp,
nonce: headers.nonce,
signature: headers.signature,
})
) {
return { target: candidate, payload: outer };
}
}
}
return null;
}
function readHeaderValues(req: IncomingMessage) {
const timestamp = String(
req.headers["x-lark-request-timestamp"] ?? req.headers["x-feishu-request-timestamp"] ?? "",
).trim();
const nonce = String(
req.headers["x-lark-request-nonce"] ?? req.headers["x-feishu-request-nonce"] ?? "",
).trim();
const signature = String(req.headers["x-lark-signature"] ?? "").trim();
return { timestamp, nonce, signature };
}
function parseFeishuTimestampMs(value: string | undefined): number | undefined {
const raw = value?.trim();
if (!raw) return undefined;
const num = Number.parseInt(raw, 10);
if (!Number.isFinite(num)) return undefined;
if (raw.length >= 13) return num;
return num * 1000;
}
function normalizeAllowEntry(raw: string): string {
return raw
.trim()
.replace(/^feishu:/i, "")
.replace(/^fs:/i, "")
.replace(/^user:/i, "")
.replace(/^open_id:/i, "")
.replace(/^openid:/i, "")
.toLowerCase();
}
function isSenderAllowed(
senderOpenId: string,
senderUserId: string | undefined,
allowFrom: string[],
): boolean {
if (allowFrom.includes("*")) return true;
const openId = normalizeAllowEntry(senderOpenId);
const userId = senderUserId?.trim().toLowerCase() ?? "";
return allowFrom.some((entry) => {
const normalized = normalizeAllowEntry(String(entry));
if (!normalized) return false;
if (normalized === openId) return true;
if (userId && normalized === userId) return true;
return false;
});
}
function resolveGroupEntry(account: ResolvedFeishuAccount, chatId: string) {
const groups = account.config.groups ?? {};
const keys = Object.keys(groups).filter(Boolean);
if (keys.length === 0) return { entry: undefined, allowlistConfigured: false };
const entry = groups[chatId] ?? groups["*"];
return { entry, allowlistConfigured: true };
}
function extractTextFromMessageContent(content: string): string {
const trimmed = content.trim();
if (!trimmed) return "";
const parsed = parseJson(trimmed);
if (parsed.ok && isRecord(parsed.value)) {
const text = readString(parsed.value.text);
if (text) return text;
}
return trimmed;
}
function extractMentions(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return [];
return value.filter(isRecord);
}
function wasBotMentioned(params: {
mentions: Array<Record<string, unknown>>;
bot: { openId?: string; userId?: string };
}): boolean {
const botOpenId = params.bot.openId?.trim();
const botUserId = params.bot.userId?.trim();
if (!botOpenId && !botUserId) return false;
return params.mentions.some((mention) => {
const id = isRecord(mention.id) ? mention.id : null;
const openId = readString((id ?? mention).open_id) ?? readString((id ?? mention).openId);
const userId = readString((id ?? mention).user_id) ?? readString((id ?? mention).userId);
if (botOpenId && openId && botOpenId === openId) return true;
if (botUserId && userId && botUserId === userId) return true;
return false;
});
}
async function deliverFeishuReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
account: ResolvedFeishuAccount;
target: FeishuMessagingTarget;
runtime: FeishuRuntimeEnv;
core: FeishuCoreRuntime;
cfg: ClawdbotConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}) {
const { payload, account, target, runtime, core, cfg, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const replyToMessageId = payload.replyToId?.trim() || undefined;
let text = payload.text ?? "";
if (mediaList.length > 0) {
const suffix = mediaList.join("\n");
text = text.trim() ? `${text.trim()}\n${suffix}` : suffix;
}
if (!text.trim()) return;
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu", account.accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
for (const chunk of chunks) {
try {
await sendFeishuTextMessage({
account,
target,
text: chunk,
replyToMessageId,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`[${account.accountId}] Feishu send failed: ${String(err)}`);
}
}
}
async function processFeishuMessage(params: {
account: ResolvedFeishuAccount;
config: ClawdbotConfig;
runtime: FeishuRuntimeEnv;
core: FeishuCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
messageId: string;
chatId: string;
chatType: string;
messageType: string;
content: string;
mentions: Array<Record<string, unknown>>;
senderOpenId: string;
senderUserId?: string;
senderType?: string;
createdAt?: number;
}) {
const {
account,
config,
runtime,
core,
statusSink,
messageId,
chatId,
chatType,
messageType,
content,
mentions,
senderOpenId,
senderUserId,
senderType,
createdAt,
} = params;
const isGroup = chatType.toLowerCase() !== "p2p";
if (senderType && senderType.toLowerCase() !== "user") {
logVerbose(core, runtime, `skip non-user sender (type=${senderType})`);
return;
}
if (messageType.toLowerCase() !== "text") {
logVerbose(core, runtime, `skip non-text message (type=${messageType})`);
return;
}
const rawBody = extractTextFromMessageContent(content).trim();
if (!rawBody) return;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupResolved = isGroup ? resolveGroupEntry(account, chatId) : null;
const groupEntry = groupResolved?.entry;
const groupUsers = isGroup
? (groupEntry?.users ?? account.config.groupAllowFrom ?? []).map((v) => String(v))
: [];
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
return;
}
const allowlistConfigured = groupResolved?.allowlistConfigured ?? false;
const allowlisted = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
if (groupPolicy === "allowlist") {
if (!allowlistConfigured) {
logVerbose(
core,
runtime,
`drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
);
return;
}
if (!allowlisted) {
logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
return;
}
}
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
logVerbose(core, runtime, `drop group message (group disabled, chat=${chatId})`);
return;
}
if (groupUsers.length > 0) {
const ok = isSenderAllowed(senderOpenId, senderUserId, groupUsers);
if (!ok) {
logVerbose(core, runtime, `drop group message (sender not allowed, user=${senderOpenId})`);
return;
}
}
}
const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const commandAllowFrom = isGroup ? groupUsers : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderOpenId, senderUserId, commandAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
if (isGroup) {
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "feishu",
});
let botMentioned = false;
if (requireMention) {
try {
const bot = await getBotIdentity(account).catch(() => null);
if (bot?.openId || bot?.userId) {
botMentioned = wasBotMentioned({
mentions,
bot: { openId: bot.openId, userId: bot.userId },
});
}
} catch (err) {
logVerbose(core, runtime, `bot identity lookup failed: ${String(err)}`);
}
}
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned: botMentioned,
implicitMention: false,
hasAnyMention: mentions.length > 0,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
});
if (mentionGate.shouldSkip) {
logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
return;
}
}
if (!isGroup) {
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
logVerbose(core, runtime, `blocked Feishu DM from ${senderOpenId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
if (!senderAllowedForCommands) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "feishu",
id: senderOpenId,
meta: { userId: senderUserId },
});
if (created) {
logVerbose(core, runtime, `feishu pairing request sender=${senderOpenId}`);
try {
await sendFeishuTextMessage({
account,
target: { kind: "user", openId: senderOpenId },
text: core.channel.pairing.buildPairingReply({
channel: "feishu",
idLine: `Your Feishu open id: ${senderOpenId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${senderOpenId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`blocked unauthorized Feishu sender ${senderOpenId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(core, runtime, `feishu: drop control command from ${senderOpenId}`);
return;
}
const peerId = isGroup ? chatId : senderOpenId;
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "feishu",
accountId: account.accountId,
peer: { kind: isGroup ? "group" : "dm", id: peerId },
});
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderOpenId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: fromLabel,
timestamp: createdAt,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = isGroup ? groupEntry?.systemPrompt?.trim() || undefined : undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: `feishu:${senderOpenId}`,
To: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "channel" : "direct",
ConversationLabel: fromLabel,
SenderId: senderOpenId,
SenderUsername: senderUserId,
CommandAuthorized: commandAuthorized,
Provider: "feishu",
Surface: "feishu",
MessageSid: messageId,
MessageSidFull: messageId,
ReplyToId: messageId,
ReplyToIdFull: messageId,
GroupSpace: isGroup ? chatId : undefined,
GroupSystemPrompt: groupSystemPrompt,
OriginatingChannel: "feishu",
OriginatingTo: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
});
const target: FeishuMessagingTarget = isGroup
? { kind: "chat", chatId }
: { kind: "user", openId: senderOpenId };
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverFeishuReply({
payload,
account,
target,
runtime,
core,
cfg: config,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
},
},
});
}
export async function handleFeishuWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return true;
}
const body = await readRawBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.error === "payload too large" ? 413 : 400;
res.end(body.error ?? "invalid payload");
return true;
}
const parsed = parseJson(body.raw ?? "");
if (!parsed.ok || !isRecord(parsed.value)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
const headers = readHeaderValues(req);
const selected = selectTarget({
targets,
rawBody: body.raw ?? "",
outer: parsed.value,
headers,
});
if (!selected) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
const payload = selected.payload;
const eventType = readString(isRecord(payload.header) ? payload.header.event_type : undefined);
const type = readString(payload.type);
if (type === "url_verification") {
const challenge = readString(payload.challenge);
if (!challenge) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ challenge }));
return true;
}
if (eventType !== "im.message.receive_v1") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
}
const event = isRecord(payload.event) ? payload.event : null;
const message = event && isRecord(event.message) ? event.message : null;
const sender = event && isRecord(event.sender) ? event.sender : null;
if (!message || !sender) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
const messageId =
readString(message.message_id) ?? readString(message.messageId) ?? readString(message.id);
const chatId = readString(message.chat_id) ?? readString(message.chatId);
const chatType = readString(message.chat_type) ?? readString(message.chatType) ?? "";
const messageType = readString(message.message_type) ?? readString(message.messageType) ?? "";
const content = readString(message.content) ?? "";
if (!messageId || !chatId || !chatType || !messageType || !content) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
const senderId = isRecord(sender.sender_id) ? sender.sender_id : null;
const senderOpenId =
readString((senderId ?? sender).open_id) ?? readString((senderId ?? sender).openId) ?? "";
const senderUserId =
readString((senderId ?? sender).user_id) ?? readString((senderId ?? sender).userId);
const senderType = readString(sender.sender_type) ?? readString(sender.senderType);
if (!senderOpenId) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
const mentions = extractMentions(message.mentions);
const createdAt = parseFeishuTimestampMs(
readString(isRecord(payload.header) ? payload.header.create_time : undefined),
);
selected.target.statusSink?.({ lastInboundAt: Date.now() });
processFeishuMessage({
account: selected.target.account,
config: selected.target.config,
runtime: selected.target.runtime,
core: selected.target.core,
statusSink: selected.target.statusSink,
messageId,
chatId,
chatType,
messageType,
content,
mentions,
senderOpenId,
senderUserId,
senderType,
createdAt,
}).catch((err) => {
selected.target.runtime.error?.(
`[${selected.target.account.accountId}] Feishu webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
}
export async function startFeishuMonitor(options: FeishuMonitorOptions): Promise<() => void> {
const core = getFeishuRuntime();
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
if (!webhookPath) {
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
return () => {};
}
const unregister = registerFeishuWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
core,
path: webhookPath,
statusSink: options.statusSink,
});
return unregister;
}
export function resolveFeishuWebhookPath(params: { account: ResolvedFeishuAccount }): string {
return (
resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ??
"/feishu"
);
}

View File

@ -0,0 +1,216 @@
import crypto from "node:crypto";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { ResolvedFeishuAccount } from "./accounts.js";
import { handleFeishuWebhookRequest, registerFeishuWebhookTarget } from "./monitor.js";
async function withServer(
handler: Parameters<typeof createServer>[0],
fn: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo | null;
if (!address) throw new Error("missing server address");
try {
await fn(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function encryptFeishuPayload(params: { plaintext: string; encryptKey: string }): string {
const key = crypto.createHash("sha256").update(params.encryptKey).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const ciphertext = Buffer.concat([cipher.update(params.plaintext, "utf8"), cipher.final()]);
return Buffer.concat([iv, ciphertext]).toString("base64");
}
function computeFeishuSignature(params: {
rawBody: string;
encryptKey: string;
timestamp: string;
nonce: string;
}): string {
return crypto
.createHash("sha256")
.update(`${params.timestamp}${params.nonce}${params.encryptKey}${params.rawBody}`)
.digest("hex");
}
describe("handleFeishuWebhookRequest", () => {
it("responds to url_verification (verificationToken)", async () => {
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
const account: ResolvedFeishuAccount = {
accountId: "default",
enabled: true,
config: { verificationToken: "vtok" },
credentialSource: "config",
};
const error = vi.fn();
const statusSink = vi.fn();
const unregister = registerFeishuWebhookTarget({
account,
config: {} as ClawdbotConfig,
runtime: { error },
core,
path: "/hook",
statusSink,
});
try {
await withServer(
async (req, res) => {
const handled = await handleFeishuWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
body: JSON.stringify({ token: "vtok", challenge: "abc", type: "url_verification" }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ challenge: "abc" });
expect(statusSink).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
},
);
} finally {
unregister();
}
});
it("responds to url_verification (encryptKey)", async () => {
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
const encryptKey = "encrypt-key-example";
const account: ResolvedFeishuAccount = {
accountId: "default",
enabled: true,
config: { encryptKey },
credentialSource: "config",
};
const error = vi.fn();
const statusSink = vi.fn();
const unregister = registerFeishuWebhookTarget({
account,
config: {} as ClawdbotConfig,
runtime: { error },
core,
path: "/hook",
statusSink,
});
try {
await withServer(
async (req, res) => {
const handled = await handleFeishuWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const plaintext = JSON.stringify({ type: "url_verification", challenge: "abc" });
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
const rawBody = JSON.stringify({ encrypt });
const timestamp = "1700000000";
const nonce = "nonce";
const signature = computeFeishuSignature({ rawBody, encryptKey, timestamp, nonce });
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
headers: {
"x-lark-request-timestamp": timestamp,
"x-lark-request-nonce": nonce,
"x-lark-signature": signature,
},
body: rawBody,
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ challenge: "abc" });
expect(statusSink).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
},
);
} finally {
unregister();
}
});
it("parses im.message.receive_v1 payloads and returns 200", async () => {
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
const account: ResolvedFeishuAccount = {
accountId: "default",
enabled: true,
config: { verificationToken: "vtok" },
credentialSource: "config",
};
const error = vi.fn();
const statusSink = vi.fn();
const unregister = registerFeishuWebhookTarget({
account,
config: {} as ClawdbotConfig,
runtime: { error },
core,
path: "/hook",
statusSink,
});
try {
await withServer(
async (req, res) => {
const handled = await handleFeishuWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
},
async (baseUrl) => {
const payload = {
token: "vtok",
header: { event_type: "im.message.receive_v1", create_time: "1700000000000" },
event: {
message: {
message_id: "m1",
chat_id: "oc_123",
chat_type: "group",
message_type: "image",
content: JSON.stringify({ text: "ignored" }),
},
sender: {
sender_id: { open_id: "ou_123", user_id: "u_1" },
sender_type: "user",
},
},
};
const response = await fetch(`${baseUrl}/hook`, {
method: "POST",
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({});
await new Promise((r) => setTimeout(r, 0));
expect(statusSink).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
},
);
} finally {
unregister();
}
});
});

View File

@ -0,0 +1,266 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
DmPolicy,
WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
promptAccountId,
} from "clawdbot/plugin-sdk";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
} from "./accounts.js";
const channel = "feishu" as const;
function setFeishuDmPolicy(cfg: ClawdbotConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...(cfg.channels?.feishu ?? {}),
dm: {
...(cfg.channels?.feishu?.dm ?? {}),
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
} as ClawdbotConfig;
}
async function noteFeishuAppHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Create a Feishu app and enable the bot capability",
"2) Configure event subscription callback: https://gateway.example.com/feishu",
"3) Subscribe to the im.message.receive_v1 event",
"4) Copy appId/appSecret + verification token (or encrypt key) into Clawdbot",
"Docs: https://docs.clawd.bot/channels/feishu",
].join("\n"),
"Feishu bot app",
);
}
function applyAccountPatch(params: {
cfg: ClawdbotConfig;
accountId: string;
patch: Record<string, unknown>;
}): ClawdbotConfig {
const { cfg, accountId, patch } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...(cfg.channels?.feishu ?? {}),
enabled: true,
...patch,
},
},
} as ClawdbotConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...(cfg.channels?.feishu ?? {}),
enabled: true,
accounts: {
...(cfg.channels?.feishu?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.feishu?.accounts?.[accountId] ?? {}),
enabled: true,
...patch,
},
},
},
},
} as ClawdbotConfig;
}
async function promptFeishuAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
const resolved = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const existingAllowFrom = resolved.config.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
message: "Feishu allowFrom (open id)",
placeholder: "ou_xxx",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^ou_/i.test(raw)) return "Use a Feishu open id (starts with ou_)";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
const existingDm = resolved.config.dm ?? {};
return applyAccountPatch({
cfg: params.cfg,
accountId: params.accountId,
patch: {
dm: { ...existingDm, policy: "allowlist", allowFrom: unique },
},
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Feishu",
channel,
policyKey: "channels.feishu.dm.policy",
allowFromKey: "channels.feishu.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.feishu?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as ClawdbotConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultFeishuAccountId(cfg as ClawdbotConfig);
return await promptFeishuAllowFrom({
cfg: cfg as ClawdbotConfig,
prompter,
accountId: id,
});
},
};
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listFeishuAccountIds(cfg as ClawdbotConfig).some((accountId) => {
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
return account.credentialSource !== "none";
});
return {
channel,
configured,
statusLines: [`Feishu: ${configured ? "configured" : "needs appId/appSecret"}`],
selectionHint: configured ? "configured" : "plugin · webhook",
quickstartScore: configured ? 1 : 18,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const override = accountOverrides.feishu?.trim();
const defaultAccountId = resolveDefaultFeishuAccountId(cfg as ClawdbotConfig);
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg: cfg as ClawdbotConfig,
prompter,
label: "Feishu",
currentId: accountId,
listAccountIds: listFeishuAccountIds,
defaultAccountId,
});
}
let next = cfg as ClawdbotConfig;
const resolved = resolveFeishuAccount({ cfg: next, accountId });
const alreadyConfigured = resolved.credentialSource !== "none";
if (!alreadyConfigured) {
await noteFeishuAppHelp(prompter);
}
const keepCredentials = alreadyConfigured
? await prompter.confirm({
message: "Feishu credentials already configured. Keep them?",
initialValue: true,
})
: false;
if (!keepCredentials) {
const appId = String(
await prompter.text({
message: "Feishu appId",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const appSecret = String(
await prompter.text({
message: "Feishu appSecret",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const validationMode = await prompter.select({
message: "Webhook validation",
options: [
{ value: "token", label: "Verification token", hint: "Simple shared secret check" },
{ value: "encrypt", label: "Encrypt key", hint: "Signature + encrypted payload" },
],
initialValue: "token",
});
const patch: Record<string, unknown> = { appId, appSecret };
if (validationMode === "encrypt") {
const encryptKey = String(
await prompter.text({
message: "Feishu encrypt key",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
patch.encryptKey = encryptKey;
} else {
const verificationToken = String(
await prompter.text({
message: "Feishu verification token",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
patch.verificationToken = verificationToken;
}
const wantsPath = await prompter.confirm({
message: "Customize webhook path? (default: /feishu)",
initialValue: false,
});
if (wantsPath) {
const webhookPath = String(
await prompter.text({
message: "Webhook path (starts with /)",
initialValue: resolved.config.webhookPath ?? "/feishu",
}),
).trim();
if (webhookPath) patch.webhookPath = webhookPath;
}
next = applyAccountPatch({ cfg: next, accountId, patch });
}
if (forceAllowFrom) {
next = await promptFeishuAllowFrom({ cfg: next, prompter, accountId });
}
return { cfg: next, accountId };
},
};

View File

@ -0,0 +1,21 @@
import type { ResolvedFeishuAccount } from "./accounts.js";
import { getBotIdentity, getTenantAccessToken } from "./api.js";
export async function probeFeishu(
account: ResolvedFeishuAccount,
): Promise<
| { ok: true; token: "ok"; bot: { openId?: string; userId?: string; name?: string } }
| { ok: false; error: string }
> {
try {
await getTenantAccessToken(account);
const bot = await getBotIdentity(account);
return {
ok: true,
token: "ok",
bot: { openId: bot.openId, userId: bot.userId, name: bot.name },
};
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setFeishuRuntime(next: PluginRuntime) {
runtime = next;
}
export function getFeishuRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Feishu runtime not initialized");
}
return runtime;
}

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { feishuDock, feishuPlugin } from "./channel.js";
describe("feishu/smoke", () => {
it("registers channel metadata", () => {
expect(feishuDock.id).toBe("feishu");
expect(feishuPlugin.id).toBe("feishu");
});
it("exports a plugin entrypoint", async () => {
const mod = await import("../index.ts");
expect(mod.default.id).toBe("feishu");
expect(typeof mod.default.register).toBe("function");
});
});

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
looksLikeFeishuTargetId,
normalizeFeishuMessagingTarget,
parseFeishuMessagingTarget,
} from "./targets.js";
describe("feishu/targets", () => {
it("normalizes user and chat targets", () => {
expect(normalizeFeishuMessagingTarget("user:ou_123")).toBe("user:ou_123");
expect(normalizeFeishuMessagingTarget("chat:oc_123")).toBe("chat:oc_123");
expect(normalizeFeishuMessagingTarget("feishu:user:ou_123")).toBe("user:ou_123");
expect(normalizeFeishuMessagingTarget("fs:chat:oc_123")).toBe("chat:oc_123");
expect(normalizeFeishuMessagingTarget("ou_abc")).toBe("user:ou_abc");
expect(normalizeFeishuMessagingTarget("oc_abc")).toBe("chat:oc_abc");
});
it("parses targets into typed objects", () => {
expect(parseFeishuMessagingTarget("user:ou_123")).toEqual({ kind: "user", openId: "ou_123" });
expect(parseFeishuMessagingTarget("chat:oc_123")).toEqual({ kind: "chat", chatId: "oc_123" });
expect(parseFeishuMessagingTarget("ou_123")).toEqual({ kind: "user", openId: "ou_123" });
expect(parseFeishuMessagingTarget("oc_123")).toEqual({ kind: "chat", chatId: "oc_123" });
});
it("detects target ids", () => {
expect(looksLikeFeishuTargetId("user:ou_123")).toBe(true);
expect(looksLikeFeishuTargetId("chat:oc_123")).toBe(true);
expect(looksLikeFeishuTargetId("ou_123")).toBe(true);
expect(looksLikeFeishuTargetId("oc_123")).toBe(true);
expect(looksLikeFeishuTargetId("")).toBe(false);
expect(looksLikeFeishuTargetId("foo")).toBe(false);
});
});

View File

@ -0,0 +1,57 @@
export type FeishuMessagingTarget =
| { kind: "user"; openId: string }
| { kind: "chat"; chatId: string };
function stripPrefixes(value: string): string {
return value.replace(/^feishu:/i, "").replace(/^fs:/i, "");
}
function normalizeBareId(value: string): FeishuMessagingTarget | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^ou_/i.test(trimmed)) return { kind: "user", openId: trimmed };
if (/^oc_/i.test(trimmed)) return { kind: "chat", chatId: trimmed };
return null;
}
export function normalizeFeishuMessagingTarget(raw: string): string | undefined {
const trimmed = stripPrefixes(raw.trim());
if (!trimmed) return undefined;
const match = trimmed.match(/^(user|open_id|openid|chat|chat_id|chatid):(.+)$/i);
if (match) {
const kind = match[1].toLowerCase();
const id = match[2].trim();
if (!id) return undefined;
if (kind === "user" || kind === "open_id" || kind === "openid") return `user:${id}`;
if (kind === "chat" || kind === "chat_id" || kind === "chatid") return `chat:${id}`;
return undefined;
}
const bare = normalizeBareId(trimmed);
if (bare?.kind === "user") return `user:${bare.openId}`;
if (bare?.kind === "chat") return `chat:${bare.chatId}`;
return undefined;
}
export function parseFeishuMessagingTarget(raw: string): FeishuMessagingTarget | null {
const normalized = normalizeFeishuMessagingTarget(raw);
if (!normalized) return null;
if (normalized.toLowerCase().startsWith("user:")) {
const openId = normalized.slice("user:".length).trim();
return openId ? { kind: "user", openId } : null;
}
if (normalized.toLowerCase().startsWith("chat:")) {
const chatId = normalized.slice("chat:".length).trim();
return chatId ? { kind: "chat", chatId } : null;
}
return null;
}
export function looksLikeFeishuTargetId(raw: string): boolean {
const normalized = normalizeFeishuMessagingTarget(raw);
if (!normalized) return false;
if (normalized.toLowerCase().startsWith("user:")) return true;
if (normalized.toLowerCase().startsWith("chat:")) return true;
return false;
}

View File

@ -0,0 +1,41 @@
export type FeishuDmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type FeishuGroupPolicy = "open" | "disabled" | "allowlist";
export type FeishuDmConfig = {
enabled?: boolean;
policy?: FeishuDmPolicy;
allowFrom?: Array<string | number>;
};
export type FeishuGroupConfig = {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
};
export type FeishuAccountConfig = {
name?: string;
enabled?: boolean;
appId?: string;
appSecret?: string;
verificationToken?: string;
encryptKey?: string;
webhookPath?: string;
webhookUrl?: string;
dm?: FeishuDmConfig;
requireMention?: boolean;
groupPolicy?: FeishuGroupPolicy;
groupAllowFrom?: Array<string | number>;
groups?: Record<string, FeishuGroupConfig | undefined>;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
};
export type FeishuConfig = FeishuAccountConfig & {
accounts?: Record<string, FeishuAccountConfig | undefined>;
defaultAccount?: string;
};