From 70d55bfff72ffef4fdae9b8620b0304965833afb Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 26 Jan 2026 16:05:16 +0800 Subject: [PATCH] feat(feishu): add Feishu channel extension --- extensions/feishu/clawdbot.plugin.json | 9 + extensions/feishu/docs/feishu.md | 104 +++ extensions/feishu/index.ts | 20 + extensions/feishu/package.json | 38 + extensions/feishu/spec/design.md | 78 ++ extensions/feishu/spec/proposal.md | 300 +++++++ extensions/feishu/spec/spec.md | 85 ++ extensions/feishu/spec/tasks.md | 54 ++ extensions/feishu/src/accounts.ts | 78 ++ extensions/feishu/src/api.ts | 230 +++++ extensions/feishu/src/auth.test.ts | 67 ++ extensions/feishu/src/auth.ts | 35 + extensions/feishu/src/channel.ts | 529 ++++++++++++ extensions/feishu/src/config-schema.ts | 85 ++ extensions/feishu/src/monitor.ts | 799 ++++++++++++++++++ extensions/feishu/src/monitor.webhook.test.ts | 216 +++++ extensions/feishu/src/onboarding.ts | 266 ++++++ extensions/feishu/src/probe.ts | 21 + extensions/feishu/src/runtime.ts | 14 + extensions/feishu/src/smoke.test.ts | 16 + extensions/feishu/src/targets.test.ts | 34 + extensions/feishu/src/targets.ts | 57 ++ extensions/feishu/src/types.config.ts | 41 + 23 files changed, 3176 insertions(+) create mode 100644 extensions/feishu/clawdbot.plugin.json create mode 100644 extensions/feishu/docs/feishu.md create mode 100644 extensions/feishu/index.ts create mode 100644 extensions/feishu/package.json create mode 100644 extensions/feishu/spec/design.md create mode 100644 extensions/feishu/spec/proposal.md create mode 100644 extensions/feishu/spec/spec.md create mode 100644 extensions/feishu/spec/tasks.md create mode 100644 extensions/feishu/src/accounts.ts create mode 100644 extensions/feishu/src/api.ts create mode 100644 extensions/feishu/src/auth.test.ts create mode 100644 extensions/feishu/src/auth.ts create mode 100644 extensions/feishu/src/channel.ts create mode 100644 extensions/feishu/src/config-schema.ts create mode 100644 extensions/feishu/src/monitor.ts create mode 100644 extensions/feishu/src/monitor.webhook.test.ts create mode 100644 extensions/feishu/src/onboarding.ts create mode 100644 extensions/feishu/src/probe.ts create mode 100644 extensions/feishu/src/runtime.ts create mode 100644 extensions/feishu/src/smoke.test.ts create mode 100644 extensions/feishu/src/targets.test.ts create mode 100644 extensions/feishu/src/targets.ts create mode 100644 extensions/feishu/src/types.config.ts diff --git a/extensions/feishu/clawdbot.plugin.json b/extensions/feishu/clawdbot.plugin.json new file mode 100644 index 000000000..93fb800f4 --- /dev/null +++ b/extensions/feishu/clawdbot.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "feishu", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/feishu/docs/feishu.md b/extensions/feishu/docs/feishu.md new file mode 100644 index 000000000..e60b3e21c --- /dev/null +++ b/extensions/feishu/docs/feishu.md @@ -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:` (example open id format: `ou_xxx`) +- Group chat: `chat:` (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.`: group allowlist entry and options (`allow`, `requireMention`, `users`, `systemPrompt`). + +Multi-account options: + +- `channels.feishu.accounts..appId` +- `channels.feishu.accounts..appSecret` +- `channels.feishu.accounts..verificationToken` +- `channels.feishu.accounts..encryptKey` +- `channels.feishu.accounts..webhookPath` +- `channels.feishu.accounts..webhookUrl` +- `channels.feishu.accounts..dm` +- `channels.feishu.accounts..groups` diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..6b13cc58d --- /dev/null +++ b/extensions/feishu/index.ts @@ -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; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..af6ad0d1f --- /dev/null +++ b/extensions/feishu/package.json @@ -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" + } + } +} diff --git a/extensions/feishu/spec/design.md b/extensions/feishu/spec/design.md new file mode 100644 index 000000000..bafffa941 --- /dev/null +++ b/extensions/feishu/spec/design.md @@ -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":""}`. + +### 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. diff --git a/extensions/feishu/spec/proposal.md b/extensions/feishu/spec/proposal.md new file mode 100644 index 000000000..4bae2f861 --- /dev/null +++ b/extensions/feishu/spec/proposal.md @@ -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 Clawdbot’s 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 `. + +## 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":""}`. + +### 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":""}` (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 --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:`, `chat:`) 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. diff --git a/extensions/feishu/spec/spec.md b/extensions/feishu/spec/spec.md new file mode 100644 index 000000000..b4ce05d74 --- /dev/null +++ b/extensions/feishu/spec/spec.md @@ -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":""}` (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 --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) diff --git a/extensions/feishu/spec/tasks.md b/extensions/feishu/spec/tasks.md new file mode 100644 index 000000000..4cbe53612 --- /dev/null +++ b/extensions/feishu/spec/tasks.md @@ -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:`, `chat:`) 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. diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts new file mode 100644 index 000000000..bce65c019 --- /dev/null +++ b/extensions/feishu/src/accounts.ts @@ -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, + }; +} diff --git a/extensions/feishu/src/api.ts b/extensions/feishu/src/api.ts new file mode 100644 index 000000000..08f30bb68 --- /dev/null +++ b/extensions/feishu/src/api.ts @@ -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 = { ok: true; data: T }; +type FeishuApiErr = { ok: false; error: string; code?: number; logId?: string }; + +type FeishuApiResult = FeishuApiOk | FeishuApiErr; + +type TenantToken = { + token: string; + expiresAt: number; +}; + +type BotIdentity = { + openId?: string; + userId?: string; + name?: string; + fetchedAt: number; +}; + +const tokenCache = new Map(); +const botCache = new Map(); + +function isRecord(value: unknown): value is Record { + 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 { + 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 & { 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> { + 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).tenant_access_token) ?? + readString((payload as Record).tenantAccessToken) ?? + readString( + (payload as { data?: unknown }).data && + (payload as { data?: Record }).data?.tenant_access_token, + ); + const expireSeconds = + typeof (payload as Record).expire === "number" + ? (payload as Record).expire + : typeof (payload as { data?: Record }).data?.expire === "number" + ? (payload as { data?: Record }).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 { + 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).bot) + ? ((container as Record).bot as Record) + : (container as Record); + return { + openId: readString(bot.open_id) ?? readString((container as Record).open_id), + userId: readString(bot.user_id) ?? readString((container as Record).user_id), + name: readString(bot.name) ?? readString((container as Record).name), + }; +} + +export async function getBotIdentity( + account: ResolvedFeishuAccount, + opts?: { maxAgeMs?: number }, +): Promise { + 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 }).data as Record) + : (json as Record); + 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 }; +} diff --git a/extensions/feishu/src/auth.test.ts b/extensions/feishu/src/auth.test.ts new file mode 100644 index 000000000..cd2fca505 --- /dev/null +++ b/extensions/feishu/src/auth.test.ts @@ -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); + }); +}); diff --git a/extensions/feishu/src/auth.ts b/extensions/feishu/src/auth.ts new file mode 100644 index 000000000..bfd53cdac --- /dev/null +++ b/extensions/feishu/src/auth.ts @@ -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; +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..280d2378d --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -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 ""; +} + +export const feishuPlugin: ChannelPlugin = { + 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(); + 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(), + }); + }; + }, + }, +}; diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts new file mode 100644 index 000000000..0d94bd480 --- /dev/null +++ b/extensions/feishu/src/config-schema.ts @@ -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(), +}); diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 000000000..01b0952d2 --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -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; + +type WebhookTarget = { + account: ResolvedFeishuAccount; + config: ClawdbotConfig; + runtime: FeishuRuntimeEnv; + core: FeishuCoreRuntime; + path: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +const webhookTargets = new Map(); + +function isRecord(value: unknown): value is Record { + 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; +}; + +function extractVerificationToken(payload: Record): 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; + 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> { + if (!Array.isArray(value)) return []; + return value.filter(isRecord); +} + +function wasBotMentioned(params: { + mentions: Array>; + 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>; + 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 { + 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" + ); +} diff --git a/extensions/feishu/src/monitor.webhook.test.ts b/extensions/feishu/src/monitor.webhook.test.ts new file mode 100644 index 000000000..5b4efa335 --- /dev/null +++ b/extensions/feishu/src/monitor.webhook.test.ts @@ -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[0], + fn: (baseUrl: string) => Promise, +) { + const server = createServer(handler); + await new Promise((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((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(); + } + }); +}); diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 000000000..5462000d7 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -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 { + 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; +}): 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 { + 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 = { 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 }; + }, +}; diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts new file mode 100644 index 000000000..b6264afbd --- /dev/null +++ b/extensions/feishu/src/probe.ts @@ -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) }; + } +} diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..9fbb18629 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -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; +} diff --git a/extensions/feishu/src/smoke.test.ts b/extensions/feishu/src/smoke.test.ts new file mode 100644 index 000000000..7484267bb --- /dev/null +++ b/extensions/feishu/src/smoke.test.ts @@ -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"); + }); +}); diff --git a/extensions/feishu/src/targets.test.ts b/extensions/feishu/src/targets.test.ts new file mode 100644 index 000000000..cf5f0976e --- /dev/null +++ b/extensions/feishu/src/targets.test.ts @@ -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); + }); +}); diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts new file mode 100644 index 000000000..057237470 --- /dev/null +++ b/extensions/feishu/src/targets.ts @@ -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; +} diff --git a/extensions/feishu/src/types.config.ts b/extensions/feishu/src/types.config.ts new file mode 100644 index 000000000..e8df8bb6f --- /dev/null +++ b/extensions/feishu/src/types.config.ts @@ -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; +}; + +export type FeishuGroupConfig = { + enabled?: boolean; + allow?: boolean; + requireMention?: boolean; + users?: Array; + 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; + groups?: Record; + textChunkLimit?: number; + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; +}; + +export type FeishuConfig = FeishuAccountConfig & { + accounts?: Record; + defaultAccount?: string; +};