feat(feishu): add Feishu channel extension
This commit is contained in:
parent
8b91ceb7c9
commit
70d55bfff7
9
extensions/feishu/clawdbot.plugin.json
Normal file
9
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "feishu",
|
||||
"channels": ["feishu"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
104
extensions/feishu/docs/feishu.md
Normal file
104
extensions/feishu/docs/feishu.md
Normal file
@ -0,0 +1,104 @@
|
||||
---
|
||||
summary: "Feishu bot support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- You want to connect Clawdbot to Feishu
|
||||
- You are debugging Feishu webhook callbacks
|
||||
---
|
||||
|
||||
# Feishu (Bot API)
|
||||
|
||||
Status: experimental. HTTP callback only. Text messages only (V1).
|
||||
|
||||
## Plugin required
|
||||
|
||||
Feishu ships as a plugin and is not bundled with the core install.
|
||||
|
||||
- Install via CLI: `clawdbot plugins install @clawdbot/feishu`
|
||||
- Or select **Feishu** during onboarding and confirm the install prompt
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
1. Install the Feishu plugin:
|
||||
- From a source checkout: `clawdbot plugins install ./extensions/feishu`
|
||||
- From npm (if published): `clawdbot plugins install @clawdbot/feishu`
|
||||
2. Create a Feishu app, enable the bot feature, and get:
|
||||
- `appId`
|
||||
- `appSecret`
|
||||
3. Configure event subscription:
|
||||
- Callback URL: `https://gateway.example.com/feishu`
|
||||
- Events: subscribe to `im.message.receive_v1`
|
||||
- Set either a **verification token** or an **encrypt key**
|
||||
4. Configure Clawdbot:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
verificationToken: "xxx", // or encryptKey: "xxx"
|
||||
webhookPath: "/feishu",
|
||||
dm: { policy: "pairing" },
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
oc_xxx: { allow: true, requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
5. Start the gateway. Feishu will POST to the webhook path.
|
||||
6. DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
## Webhook security
|
||||
|
||||
The plugin validates webhook requests using one of:
|
||||
|
||||
- `verificationToken`: checks the token provided in the callback payload.
|
||||
- `encryptKey`: validates the Feishu signature header and decrypts encrypted payloads when present.
|
||||
|
||||
## Group behavior
|
||||
|
||||
- Default `groupPolicy` is `allowlist`.
|
||||
- Groups require mention by default (`requireMention: true`).
|
||||
- Configure allowlisted groups by **chat id** under `channels.feishu.groups`.
|
||||
|
||||
## Targets (delivery and allowlists)
|
||||
|
||||
- Direct message user: `user:<open_id>` (example open id format: `ou_xxx`)
|
||||
- Group chat: `chat:<chat_id>` (example chat id format: `oc_xxx`)
|
||||
|
||||
Example outbound send:
|
||||
|
||||
```bash
|
||||
clawdbot message send --channel feishu --target user:ou_xxx --message "hello"
|
||||
```
|
||||
|
||||
## Configuration reference (Feishu)
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.feishu.enabled`: enable/disable the channel.
|
||||
- `channels.feishu.appId`: Feishu appId.
|
||||
- `channels.feishu.appSecret`: Feishu appSecret.
|
||||
- `channels.feishu.verificationToken`: webhook verification token.
|
||||
- `channels.feishu.encryptKey`: webhook encrypt key (signature validation + optional decryption).
|
||||
- `channels.feishu.webhookPath`: webhook path on the gateway HTTP server (default `/feishu`).
|
||||
- `channels.feishu.webhookUrl`: optional; used to derive the webhook path.
|
||||
- `channels.feishu.dm.policy`: `pairing | allowlist | open | disabled` (default `pairing`).
|
||||
- `channels.feishu.dm.allowFrom`: allowlist entries (`ou_...`) or `"*"`.
|
||||
- `channels.feishu.groupPolicy`: `allowlist | open | disabled` (default `allowlist`).
|
||||
- `channels.feishu.groups.<chatId>`: group allowlist entry and options (`allow`, `requireMention`, `users`, `systemPrompt`).
|
||||
|
||||
Multi-account options:
|
||||
|
||||
- `channels.feishu.accounts.<id>.appId`
|
||||
- `channels.feishu.accounts.<id>.appSecret`
|
||||
- `channels.feishu.accounts.<id>.verificationToken`
|
||||
- `channels.feishu.accounts.<id>.encryptKey`
|
||||
- `channels.feishu.accounts.<id>.webhookPath`
|
||||
- `channels.feishu.accounts.<id>.webhookUrl`
|
||||
- `channels.feishu.accounts.<id>.dm`
|
||||
- `channels.feishu.accounts.<id>.groups`
|
||||
20
extensions/feishu/index.ts
Normal file
20
extensions/feishu/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { feishuDock, feishuPlugin } from "./src/channel.js";
|
||||
import { handleFeishuWebhookRequest } from "./src/monitor.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "feishu",
|
||||
name: "Feishu",
|
||||
description: "Feishu channel plugin (Bot API)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin, dock: feishuDock });
|
||||
api.registerHttpHandler(handleFeishuWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
38
extensions/feishu/package.json
Normal file
38
extensions/feishu/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@clawdbot/feishu",
|
||||
"version": "2026.1.25",
|
||||
"description": "Clawdbot Feishu channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.25"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu (Bot API)",
|
||||
"docsPath": "/channels/feishu",
|
||||
"docsLabel": "feishu",
|
||||
"blurb": "Feishu bot with HTTP webhook events.",
|
||||
"aliases": [
|
||||
"fs"
|
||||
],
|
||||
"order": 85,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/feishu",
|
||||
"localPath": "extensions/feishu",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
78
extensions/feishu/spec/design.md
Normal file
78
extensions/feishu/spec/design.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Tech design: Feishu (飞书) channel plugin
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `@clawdbot/feishu` as a channel extension that:
|
||||
|
||||
- Registers a channel plugin (`api.registerChannel`) and an HTTP webhook handler (`api.registerHttpHandler`).
|
||||
- Starts a per-account “monitor” on gateway start (via `plugin.gateway.startAccount`) that registers webhook targets and manages runtime status.
|
||||
- Uses the existing Clawdbot reply pipeline (`runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher`) to route inbound messages to an agent and send outbound replies back to Feishu.
|
||||
|
||||
This follows the proven “webhook channel” structure in `extensions/googlechat`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
- **Feishu plugin entrypoint**: `extensions/feishu/index.ts`
|
||||
- Stores `PluginRuntime` (like `extensions/googlechat/src/runtime.ts`).
|
||||
- Registers channel + HTTP handler.
|
||||
- **Channel plugin**: `extensions/feishu/src/channel.ts`
|
||||
- Config adapter (accounts, enabled/configured, allowFrom formatting).
|
||||
- Outbound adapter (sendText).
|
||||
- Security adapter (DM policy warnings, group policy warnings).
|
||||
- Status adapter (probe + runtime snapshot).
|
||||
- Gateway adapter (startAccount → register webhook target).
|
||||
- **Monitor / webhook handler**: `extensions/feishu/src/monitor.ts`
|
||||
- Parses raw body, validates signature/token, decrypts if needed.
|
||||
- Handles `url_verification`.
|
||||
- Handles `im.message.receive_v1` text messages.
|
||||
- Applies DM + group policies and mention gating.
|
||||
- Builds inbound context and calls reply dispatcher.
|
||||
- Sends replies via Feishu REST API.
|
||||
- **API client**: `extensions/feishu/src/api.ts`
|
||||
- Token manager: `tenant_access_token/internal` (self-built apps).
|
||||
- Send message / reply message endpoints.
|
||||
- Bot info (`/open-apis/bot/v3/info`) to detect mentions.
|
||||
|
||||
## Webhook verification and decryption
|
||||
|
||||
### URL verification
|
||||
|
||||
- Feishu sends a payload (plain or encrypted) where `type === "url_verification"` and includes `challenge`.
|
||||
- The handler responds `200` with JSON: `{"challenge":"<challenge>"}`.
|
||||
|
||||
### Decrypt algorithm
|
||||
|
||||
- If payload has `encrypt`:
|
||||
- Compute AES key = `sha256(encryptKey)` bytes (32 bytes).
|
||||
- Decode `encrypt` from base64.
|
||||
- IV = first 16 bytes; ciphertext = remaining bytes.
|
||||
- Decrypt via `aes-256-cbc` to UTF-8 JSON.
|
||||
|
||||
### Signature validation (encrypted mode)
|
||||
|
||||
- Headers: `x-lark-request-timestamp`, `x-lark-request-nonce`, `x-lark-signature`.
|
||||
- Compute: `sha256(timestamp + nonce + encryptKey + rawBodyString)` (hex).
|
||||
- Compare with `x-lark-signature`.
|
||||
- Use the raw request body string exactly as received (do not re-stringify parsed JSON).
|
||||
|
||||
### Token validation (non-encrypted mode)
|
||||
|
||||
- When `encryptKey` is not configured, validate `verificationToken` against payload `token` (or `header.token`).
|
||||
|
||||
## Mention gating
|
||||
|
||||
- Fetch and cache bot identity via `GET /open-apis/bot/v3/info`.
|
||||
- In group chats:
|
||||
- `wasMentioned = mentions.some(m => m.id.open_id === botOpenId || m.id.user_id === botUserId)`
|
||||
- Apply `resolveMentionGatingWithBypass` with `requireMention` from group config or channel default.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Colocate tests in `extensions/feishu/src/` (Vitest).
|
||||
|
||||
- Signature verification (valid/invalid).
|
||||
- Decrypt (known encryptKey + payload → expected JSON).
|
||||
- Target normalization.
|
||||
- URL verification and event parsing.
|
||||
300
extensions/feishu/spec/proposal.md
Normal file
300
extensions/feishu/spec/proposal.md
Normal file
@ -0,0 +1,300 @@
|
||||
# PRD: Feishu (飞书) channel extension
|
||||
|
||||
## Why
|
||||
|
||||
Clawdbot already supports several chat surfaces (e.g. Slack, Google Chat, Telegram). Feishu (飞书) is a common “primary chat” surface for many teams. Adding a Feishu channel plugin lets a user run Clawdbot as a personal assistant inside Feishu with the same security posture (pairing/allowlists) and the same agent routing + reply pipeline.
|
||||
|
||||
## Goals
|
||||
|
||||
- Ship a Feishu **channel plugin** under `extensions/` (installable from npm) that can:
|
||||
- Receive inbound messages (DM + group) via Feishu event subscription callback.
|
||||
- Respond with text replies (agent replies + `clawdbot message send`).
|
||||
- Respect 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 <target>`.
|
||||
|
||||
## Decisions (confirmed)
|
||||
|
||||
1. **Region**: Feishu only.
|
||||
2. **Inbound transport**: HTTP callback (event subscription webhook).
|
||||
3. **V1 scope**: text-only.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- Misconfigured public exposure: document “only expose `/feishu` path” guidance and recommend a reverse proxy/Tailscale.
|
||||
- Secret leakage: never log app secrets; store secrets only in config.
|
||||
- Rate limits: cache tokens; chunk outbound messages via existing chunker helpers.
|
||||
|
||||
\*\*\* Add File: extensions/feishu/spec/design.md
|
||||
|
||||
# Tech design: Feishu (飞书) channel plugin
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `@clawdbot/feishu` as a channel extension that:
|
||||
|
||||
- Registers a channel plugin (`api.registerChannel`) and an HTTP webhook handler (`api.registerHttpHandler`).
|
||||
- Starts a per-account “monitor” on gateway start (via `plugin.gateway.startAccount`) that registers webhook targets and manages runtime status.
|
||||
- Uses the existing Clawdbot reply pipeline (`runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher`) to route inbound messages to an agent and send outbound replies back to Feishu.
|
||||
|
||||
This follows the proven “webhook channel” structure in `extensions/googlechat`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
- **Feishu plugin entrypoint**: `extensions/feishu/index.ts`
|
||||
- Stores `PluginRuntime` (like `extensions/googlechat/src/runtime.ts`).
|
||||
- Registers channel + HTTP handler.
|
||||
- **Channel plugin**: `extensions/feishu/src/channel.ts`
|
||||
- Config adapter (accounts, enabled/configured, allowFrom formatting).
|
||||
- Outbound adapter (sendText).
|
||||
- Security adapter (DM policy warnings, group policy warnings).
|
||||
- Status adapter (probe + runtime snapshot).
|
||||
- Gateway adapter (startAccount → register webhook target).
|
||||
- **Monitor / webhook handler**: `extensions/feishu/src/monitor.ts`
|
||||
- Parses raw body, validates signature/token, decrypts if needed.
|
||||
- Handles `url_verification`.
|
||||
- Handles `im.message.receive_v1` text messages.
|
||||
- Applies DM + group policies and mention gating.
|
||||
- Builds inbound context and calls reply dispatcher.
|
||||
- Sends replies via Feishu REST API.
|
||||
- **API client**: `extensions/feishu/src/api.ts`
|
||||
- Token manager: `tenant_access_token/internal` (self-built apps).
|
||||
- Send message / reply message endpoints.
|
||||
- Bot info (`/open-apis/bot/v3/info`) to detect mentions.
|
||||
|
||||
## Webhook verification and decryption
|
||||
|
||||
### URL verification
|
||||
|
||||
- Feishu sends a payload (plain or encrypted) where `type === "url_verification"` and includes `challenge`.
|
||||
- The handler responds `200` with JSON: `{"challenge":"<challenge>"}`.
|
||||
|
||||
### Decrypt algorithm
|
||||
|
||||
- If payload has `encrypt`:
|
||||
- Compute AES key = `sha256(encryptKey)` bytes (32 bytes).
|
||||
- Decode `encrypt` from base64.
|
||||
- IV = first 16 bytes; ciphertext = remaining bytes.
|
||||
- Decrypt via `aes-256-cbc` to UTF-8 JSON.
|
||||
|
||||
### Signature validation (encrypted mode)
|
||||
|
||||
- Headers: `x-lark-request-timestamp`, `x-lark-request-nonce`, `x-lark-signature`.
|
||||
- Compute: `sha256(timestamp + nonce + encryptKey + rawBodyString)` (hex).
|
||||
- Compare with `x-lark-signature`.
|
||||
- Use the raw request body string exactly as received (do not re-stringify parsed JSON).
|
||||
|
||||
### Token validation (non-encrypted mode)
|
||||
|
||||
- When `encryptKey` is not configured, validate `verificationToken` against payload `token` (or `header.token`).
|
||||
|
||||
## Mention gating
|
||||
|
||||
- Fetch and cache bot identity via `GET /open-apis/bot/v3/info`.
|
||||
- In group chats:
|
||||
- `wasMentioned = mentions.some(m => m.id.open_id === botOpenId || m.id.user_id === botUserId)`
|
||||
- Apply `resolveMentionGatingWithBypass` with `requireMention` from group config or channel default.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Colocate tests in `extensions/feishu/src/` (Vitest).
|
||||
|
||||
- Signature verification (valid/invalid).
|
||||
- Decrypt (known encryptKey + payload → expected JSON).
|
||||
- Target normalization.
|
||||
- URL verification and event parsing.
|
||||
|
||||
\*\*\* Add File: extensions/feishu/spec/spec.md
|
||||
|
||||
# Spec: Feishu channel extension
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Channel plugin availability
|
||||
|
||||
The system SHALL provide a Feishu chat channel as a Clawdbot extension plugin that can be installed and enabled without modifying core channel code.
|
||||
|
||||
#### Scenario: Plugin appears in onboarding catalog
|
||||
|
||||
- **GIVEN** a user runs `clawdbot onboard` in a workspace that contains the Feishu plugin (local path) or can access it on npm
|
||||
- **WHEN** the user reaches the channel selection step
|
||||
- **THEN** Feishu is listed as an installable channel plugin with a docs link
|
||||
|
||||
### Requirement: Webhook endpoint and URL verification
|
||||
|
||||
The system SHALL accept Feishu event subscription callbacks over HTTP and complete the platform “request URL verification” handshake.
|
||||
|
||||
#### Scenario: URL verification succeeds
|
||||
|
||||
- **GIVEN** Feishu sends a `type="url_verification"` callback payload with a `challenge`
|
||||
- **WHEN** Clawdbot receives the POST at the configured webhook path
|
||||
- **THEN** the response status is `200` and the response body is `{"challenge":"<value>"}` (JSON)
|
||||
|
||||
### Requirement: Request validation
|
||||
|
||||
The system SHALL validate inbound callback requests before processing events.
|
||||
|
||||
#### Scenario: Invalid signature/token is rejected
|
||||
|
||||
- **GIVEN** a callback request with an invalid signature (encrypted mode) OR mismatched verification token (non-encrypted mode)
|
||||
- **WHEN** the request is received
|
||||
- **THEN** the request is rejected with `401` and no message processing occurs
|
||||
|
||||
### Requirement: Encrypted payload support
|
||||
|
||||
When configured with an encrypt key, the system SHALL decrypt payloads that use the `encrypt` envelope.
|
||||
|
||||
#### Scenario: Encrypted event is processed
|
||||
|
||||
- **GIVEN** a callback request containing an `encrypt` field
|
||||
- **WHEN** the plugin is configured with the correct `encryptKey`
|
||||
- **THEN** the decrypted JSON is used for URL verification and event handling
|
||||
|
||||
### Requirement: Inbound message handling (DM)
|
||||
|
||||
The system SHALL process `im.message.receive_v1` DMs and route them into the Clawdbot agent pipeline with DM security policies.
|
||||
|
||||
#### Scenario: Unknown DM triggers pairing flow
|
||||
|
||||
- **GIVEN** `channels.feishu.dm.policy="pairing"`
|
||||
- **AND** a DM sender is not allowlisted and not previously paired
|
||||
- **WHEN** the sender DMs the bot
|
||||
- **THEN** the system records a pairing request and replies with a pairing code message
|
||||
|
||||
### Requirement: Inbound message handling (groups)
|
||||
|
||||
The system SHALL process `im.message.receive_v1` group messages with group allowlists and mention gating.
|
||||
|
||||
#### Scenario: Group message is mention-gated
|
||||
|
||||
- **GIVEN** `channels.feishu.groupPolicy="open"` (or allowlisted group)
|
||||
- **AND** `requireMention=true`
|
||||
- **WHEN** a group message arrives without mentioning the bot
|
||||
- **THEN** the system ignores the message and does not invoke the agent
|
||||
|
||||
### Requirement: Outbound text delivery
|
||||
|
||||
The system SHALL be able to send text messages to Feishu users and group chats.
|
||||
|
||||
#### Scenario: CLI message send delivers text
|
||||
|
||||
- **GIVEN** the user runs `clawdbot message send --to <feishu-target> --message "hi"`
|
||||
- **WHEN** the target is a valid Feishu user (`open_id`) or chat (`chat_id`)
|
||||
- **THEN** the plugin sends a Feishu API request that results in a visible message in the correct conversation
|
||||
|
||||
### Requirement: Status and probe visibility
|
||||
|
||||
The system SHALL expose Feishu channel health via `clawdbot channels status`, including an active probe that validates credentials.
|
||||
|
||||
#### Scenario: Probe fails with actionable error
|
||||
|
||||
- **GIVEN** the plugin is enabled but credentials are invalid
|
||||
- **WHEN** the user runs `clawdbot channels status --probe`
|
||||
- **THEN** the Feishu channel shows `probe=error` with an actionable message (e.g. token fetch failed)
|
||||
|
||||
\*\*\* Add File: extensions/feishu/spec/tasks.md
|
||||
|
||||
# Tasks: Add Feishu (飞书) channel extension
|
||||
|
||||
## 1. Extension scaffold
|
||||
|
||||
- [x] Create `extensions/feishu/` workspace package (`package.json`, `clawdbot.plugin.json`, `index.ts`, `src/`).
|
||||
- [x] Add plugin catalog metadata in `extensions/feishu/package.json` (`clawdbot.channel` + `clawdbot.install`) so onboarding can install it.
|
||||
|
||||
## 2. Config + normalization
|
||||
|
||||
- [x] Define Feishu config Zod schema (extension-local) and expose it as `configSchema` via `buildChannelConfigSchema`.
|
||||
- [x] Implement target normalization (`user:<open_id>`, `chat:<chat_id>`) and allowlist formatting.
|
||||
- [x] Implement account model (start with default account; keep `accounts` extensible).
|
||||
|
||||
## 3. Token manager + API client
|
||||
|
||||
- [x] Implement tenant access token fetch + cache (`/auth/v3/tenant_access_token/internal`).
|
||||
- [x] Implement Feishu “bot info” fetch (`/bot/v3/info`) for mention gating.
|
||||
- [x] Implement send message (`/im/v1/messages`) and reply message (`/im/v1/messages/:message_id/reply`).
|
||||
|
||||
## 4. Webhook handler
|
||||
|
||||
- [x] Implement `handleFeishuWebhookRequest(req,res)` and register via `api.registerHttpHandler`.
|
||||
- [x] Support `url_verification` challenge response (plain + encrypted).
|
||||
- [x] Implement secure validation:
|
||||
- [x] Raw-body capture with size limit.
|
||||
- [x] Signature verification when `encryptKey` is configured.
|
||||
- [x] Verification token checks when `encryptKey` is not configured.
|
||||
- [x] Implement decrypt for payloads with `encrypt`.
|
||||
|
||||
## 5. Event processing + routing
|
||||
|
||||
- [x] Handle `im.message.receive_v1` (text-only V1):
|
||||
- [x] DM policy + pairing store integration (mirror GoogleChat monitor behavior).
|
||||
- [x] Group policy allowlists + mention gating integration.
|
||||
- [x] Control command gating for group chats.
|
||||
- [x] Build inbound context via `runtime.channel.reply.finalizeInboundContext` and dispatch replies via buffered dispatcher.
|
||||
|
||||
## 6. Outbound adapters
|
||||
|
||||
- [x] Implement `outbound.sendText` for `clawdbot message send` and heartbeats.
|
||||
|
||||
## 7. Status + probe
|
||||
|
||||
- [x] Implement `status` adapter snapshot + summary.
|
||||
- [x] Implement `probeAccount` to validate credentials (token + bot info).
|
||||
|
||||
## 8. Onboarding
|
||||
|
||||
- [x] Add onboarding adapter with prompts for app id/secret and webhook settings.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
- [x] Add unit tests for: signature, decrypt, target normalization.
|
||||
- [ ] Add unit tests for: url_verification, event parsing.
|
||||
85
extensions/feishu/spec/spec.md
Normal file
85
extensions/feishu/spec/spec.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Spec: Feishu channel extension
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Channel plugin availability
|
||||
|
||||
The system SHALL provide a Feishu chat channel as a Clawdbot extension plugin that can be installed and enabled without modifying core channel code.
|
||||
|
||||
#### Scenario: Plugin appears in onboarding catalog
|
||||
|
||||
- **GIVEN** a user runs `clawdbot onboard` in a workspace that contains the Feishu plugin (local path) or can access it on npm
|
||||
- **WHEN** the user reaches the channel selection step
|
||||
- **THEN** Feishu is listed as an installable channel plugin with a docs link
|
||||
|
||||
### Requirement: Webhook endpoint and URL verification
|
||||
|
||||
The system SHALL accept Feishu event subscription callbacks over HTTP and complete the platform “request URL verification” handshake.
|
||||
|
||||
#### Scenario: URL verification succeeds
|
||||
|
||||
- **GIVEN** Feishu sends a `type="url_verification"` callback payload with a `challenge`
|
||||
- **WHEN** Clawdbot receives the POST at the configured webhook path
|
||||
- **THEN** the response status is `200` and the response body is `{"challenge":"<value>"}` (JSON)
|
||||
|
||||
### Requirement: Request validation
|
||||
|
||||
The system SHALL validate inbound callback requests before processing events.
|
||||
|
||||
#### Scenario: Invalid signature/token is rejected
|
||||
|
||||
- **GIVEN** a callback request with an invalid signature (encrypted mode) OR mismatched verification token (non-encrypted mode)
|
||||
- **WHEN** the request is received
|
||||
- **THEN** the request is rejected with `401` and no message processing occurs
|
||||
|
||||
### Requirement: Encrypted payload support
|
||||
|
||||
When configured with an encrypt key, the system SHALL decrypt payloads that use the `encrypt` envelope.
|
||||
|
||||
#### Scenario: Encrypted event is processed
|
||||
|
||||
- **GIVEN** a callback request containing an `encrypt` field
|
||||
- **WHEN** the plugin is configured with the correct `encryptKey`
|
||||
- **THEN** the decrypted JSON is used for URL verification and event handling
|
||||
|
||||
### Requirement: Inbound message handling (DM)
|
||||
|
||||
The system SHALL process `im.message.receive_v1` DMs and route them into the Clawdbot agent pipeline with DM security policies.
|
||||
|
||||
#### Scenario: Unknown DM triggers pairing flow
|
||||
|
||||
- **GIVEN** `channels.feishu.dm.policy="pairing"`
|
||||
- **AND** a DM sender is not allowlisted and not previously paired
|
||||
- **WHEN** the sender DMs the bot
|
||||
- **THEN** the system records a pairing request and replies with a pairing code message
|
||||
|
||||
### Requirement: Inbound message handling (groups)
|
||||
|
||||
The system SHALL process `im.message.receive_v1` group messages with group allowlists and mention gating.
|
||||
|
||||
#### Scenario: Group message is mention-gated
|
||||
|
||||
- **GIVEN** `channels.feishu.groupPolicy="open"` (or allowlisted group)
|
||||
- **AND** `requireMention=true`
|
||||
- **WHEN** a group message arrives without mentioning the bot
|
||||
- **THEN** the system ignores the message and does not invoke the agent
|
||||
|
||||
### Requirement: Outbound text delivery
|
||||
|
||||
The system SHALL be able to send text messages to Feishu users and group chats.
|
||||
|
||||
#### Scenario: CLI message send delivers text
|
||||
|
||||
- **GIVEN** the user runs `clawdbot message send --to <feishu-target> --message "hi"`
|
||||
- **WHEN** the target is a valid Feishu user (`open_id`) or chat (`chat_id`)
|
||||
- **THEN** the plugin sends a Feishu API request that results in a visible message in the correct conversation
|
||||
|
||||
### Requirement: Status and probe visibility
|
||||
|
||||
The system SHALL expose Feishu channel health via `clawdbot channels status`, including an active probe that validates credentials.
|
||||
|
||||
#### Scenario: Probe fails with actionable error
|
||||
|
||||
- **GIVEN** the plugin is enabled but credentials are invalid
|
||||
- **WHEN** the user runs `clawdbot channels status --probe`
|
||||
- **THEN** the Feishu channel shows `probe=error` with an actionable message (e.g. token fetch failed)
|
||||
54
extensions/feishu/spec/tasks.md
Normal file
54
extensions/feishu/spec/tasks.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Tasks: Add Feishu (飞书) channel extension
|
||||
|
||||
## 1. Extension scaffold
|
||||
|
||||
- [x] Create `extensions/feishu/` workspace package (`package.json`, `clawdbot.plugin.json`, `index.ts`, `src/`).
|
||||
- [x] Add plugin catalog metadata in `extensions/feishu/package.json` (`clawdbot.channel` + `clawdbot.install`) so onboarding can install it.
|
||||
|
||||
## 2. Config + normalization
|
||||
|
||||
- [x] Define Feishu config Zod schema (extension-local) and expose it as `configSchema` via `buildChannelConfigSchema`.
|
||||
- [x] Implement target normalization (`user:<open_id>`, `chat:<chat_id>`) and allowlist formatting.
|
||||
- [x] Implement account model (start with default account; keep `accounts` extensible).
|
||||
|
||||
## 3. Token manager + API client
|
||||
|
||||
- [x] Implement tenant access token fetch + cache (`/auth/v3/tenant_access_token/internal`).
|
||||
- [x] Implement Feishu “bot info” fetch (`/bot/v3/info`) for mention gating.
|
||||
- [x] Implement send message (`/im/v1/messages`) and reply message (`/im/v1/messages/:message_id/reply`).
|
||||
|
||||
## 4. Webhook handler
|
||||
|
||||
- [x] Implement `handleFeishuWebhookRequest(req,res)` and register via `api.registerHttpHandler`.
|
||||
- [x] Support `url_verification` challenge response (plain + encrypted).
|
||||
- [x] Implement secure validation:
|
||||
- [x] Raw-body capture with size limit.
|
||||
- [x] Signature verification when `encryptKey` is configured.
|
||||
- [x] Verification token checks when `encryptKey` is not configured.
|
||||
- [x] Implement decrypt for payloads with `encrypt`.
|
||||
|
||||
## 5. Event processing + routing
|
||||
|
||||
- [x] Handle `im.message.receive_v1` (text-only V1):
|
||||
- [x] DM policy + pairing store integration (mirror GoogleChat monitor behavior).
|
||||
- [x] Group policy allowlists + mention gating integration.
|
||||
- [x] Control command gating for group chats.
|
||||
- [x] Build inbound context via `runtime.channel.reply.finalizeInboundContext` and dispatch replies via buffered dispatcher.
|
||||
|
||||
## 6. Outbound adapters
|
||||
|
||||
- [x] Implement `outbound.sendText` for `clawdbot message send` and heartbeats.
|
||||
|
||||
## 7. Status + probe
|
||||
|
||||
- [x] Implement `status` adapter snapshot + summary.
|
||||
- [x] Implement `probeAccount` to validate credentials (token + bot info).
|
||||
|
||||
## 8. Onboarding
|
||||
|
||||
- [x] Add onboarding adapter with prompts for app id/secret and webhook settings.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
- [x] Add unit tests for: signature, decrypt, target normalization.
|
||||
- [x] Add unit tests for: url_verification, event parsing.
|
||||
78
extensions/feishu/src/accounts.ts
Normal file
78
extensions/feishu/src/accounts.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { FeishuAccountConfig, FeishuConfig } from "./types.config.js";
|
||||
|
||||
export type FeishuCredentialSource = "config" | "none";
|
||||
|
||||
export type ResolvedFeishuAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
config: FeishuAccountConfig;
|
||||
credentialSource: FeishuCredentialSource;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = (cfg.channels?.feishu as FeishuConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
|
||||
const channel = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim();
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): FeishuAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.feishu as FeishuConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as FeishuAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuAccountConfig {
|
||||
const raw = (cfg.channels?.feishu ?? {}) as FeishuConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account } as FeishuAccountConfig;
|
||||
}
|
||||
|
||||
function hasCredentials(cfg: FeishuAccountConfig): boolean {
|
||||
const appId = cfg.appId?.trim();
|
||||
const appSecret = cfg.appSecret?.trim();
|
||||
const token = cfg.verificationToken?.trim();
|
||||
const encryptKey = cfg.encryptKey?.trim();
|
||||
return Boolean(appId && appSecret && (token || encryptKey));
|
||||
}
|
||||
|
||||
export function resolveFeishuAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedFeishuAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.feishu as FeishuConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const credentialSource: FeishuCredentialSource = hasCredentials(merged) ? "config" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
config: merged,
|
||||
credentialSource,
|
||||
};
|
||||
}
|
||||
230
extensions/feishu/src/api.ts
Normal file
230
extensions/feishu/src/api.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||
import type { FeishuMessagingTarget } from "./targets.js";
|
||||
|
||||
const FEISHU_API_ORIGIN = "https://open.feishu.cn";
|
||||
const FEISHU_API_BASE = `${FEISHU_API_ORIGIN}/open-apis`;
|
||||
|
||||
type FeishuApiOk<T> = { ok: true; data: T };
|
||||
type FeishuApiErr = { ok: false; error: string; code?: number; logId?: string };
|
||||
|
||||
type FeishuApiResult<T> = FeishuApiOk<T> | FeishuApiErr;
|
||||
|
||||
type TenantToken = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type BotIdentity = {
|
||||
openId?: string;
|
||||
userId?: string;
|
||||
name?: string;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
const tokenCache = new Map<string, TenantToken>();
|
||||
const botCache = new Map<string, BotIdentity>();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveAccountKey(account: ResolvedFeishuAccount): string {
|
||||
const appId = account.config.appId?.trim() ?? "";
|
||||
return `${FEISHU_API_ORIGIN}|${appId}`;
|
||||
}
|
||||
|
||||
async function readJson(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`invalid JSON response (${res.status}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function extractFeishuError(payload: unknown): FeishuApiErr {
|
||||
if (!isRecord(payload)) return { ok: false, error: "invalid response" };
|
||||
const code = typeof payload.code === "number" ? payload.code : undefined;
|
||||
const msg = readString(payload.msg) ?? readString(payload.message) ?? "request failed";
|
||||
const logId =
|
||||
readString((payload.error as { log_id?: unknown } | undefined)?.log_id) ??
|
||||
readString(payload.log_id);
|
||||
return { ok: false, error: msg, code, logId };
|
||||
}
|
||||
|
||||
function isFeishuOk(payload: unknown): payload is Record<string, unknown> & { code: number } {
|
||||
return isRecord(payload) && typeof payload.code === "number" && payload.code === 0;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export async function fetchTenantAccessToken(
|
||||
account: ResolvedFeishuAccount,
|
||||
): Promise<FeishuApiResult<{ token: string; expiresAt: number }>> {
|
||||
const appId = account.config.appId?.trim() ?? "";
|
||||
const appSecret = account.config.appSecret?.trim() ?? "";
|
||||
if (!appId || !appSecret) {
|
||||
return { ok: false, error: "missing appId/appSecret" };
|
||||
}
|
||||
|
||||
const res = await fetch(`${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||
});
|
||||
const payload = await readJson(res);
|
||||
if (!res.ok) {
|
||||
return extractFeishuError(payload);
|
||||
}
|
||||
if (!isFeishuOk(payload)) {
|
||||
return extractFeishuError(payload);
|
||||
}
|
||||
const token =
|
||||
readString((payload as Record<string, unknown>).tenant_access_token) ??
|
||||
readString((payload as Record<string, unknown>).tenantAccessToken) ??
|
||||
readString(
|
||||
(payload as { data?: unknown }).data &&
|
||||
(payload as { data?: Record<string, unknown> }).data?.tenant_access_token,
|
||||
);
|
||||
const expireSeconds =
|
||||
typeof (payload as Record<string, unknown>).expire === "number"
|
||||
? (payload as Record<string, unknown>).expire
|
||||
: typeof (payload as { data?: Record<string, unknown> }).data?.expire === "number"
|
||||
? (payload as { data?: Record<string, unknown> }).data!.expire
|
||||
: undefined;
|
||||
if (!token || !expireSeconds || expireSeconds <= 0) {
|
||||
return { ok: false, error: "token response missing tenant_access_token/expire" };
|
||||
}
|
||||
const expiresAt = Date.now() + expireSeconds * 1000 - 3 * 60 * 1000;
|
||||
return { ok: true, data: { token, expiresAt } };
|
||||
}
|
||||
|
||||
export async function getTenantAccessToken(account: ResolvedFeishuAccount): Promise<string> {
|
||||
const key = resolveAccountKey(account);
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
||||
const fetched = await fetchTenantAccessToken(account);
|
||||
if (!fetched.ok) {
|
||||
const suffix = fetched.logId ? ` (log_id=${fetched.logId})` : "";
|
||||
throw new Error(`Feishu token fetch failed: ${fetched.error}${suffix}`);
|
||||
}
|
||||
tokenCache.set(key, { token: fetched.data.token, expiresAt: fetched.data.expiresAt });
|
||||
return fetched.data.token;
|
||||
}
|
||||
|
||||
function extractBotIdentity(payload: unknown): { openId?: string; userId?: string; name?: string } {
|
||||
if (!isRecord(payload)) return {};
|
||||
const data = (payload as { data?: unknown }).data;
|
||||
const container = isRecord(data) ? data : payload;
|
||||
const bot = isRecord((container as Record<string, unknown>).bot)
|
||||
? ((container as Record<string, unknown>).bot as Record<string, unknown>)
|
||||
: (container as Record<string, unknown>);
|
||||
return {
|
||||
openId: readString(bot.open_id) ?? readString((container as Record<string, unknown>).open_id),
|
||||
userId: readString(bot.user_id) ?? readString((container as Record<string, unknown>).user_id),
|
||||
name: readString(bot.name) ?? readString((container as Record<string, unknown>).name),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBotIdentity(
|
||||
account: ResolvedFeishuAccount,
|
||||
opts?: { maxAgeMs?: number },
|
||||
): Promise<BotIdentity> {
|
||||
const maxAgeMs = typeof opts?.maxAgeMs === "number" ? Math.max(1, opts.maxAgeMs) : 10 * 60 * 1000;
|
||||
const key = resolveAccountKey(account);
|
||||
const cached = botCache.get(key);
|
||||
if (cached && Date.now() - cached.fetchedAt < maxAgeMs) return cached;
|
||||
|
||||
const token = await getTenantAccessToken(account);
|
||||
const res = await fetch(`${FEISHU_API_BASE}/bot/v3/info`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const payload = await readJson(res);
|
||||
if (!res.ok || !isFeishuOk(payload)) {
|
||||
const err = extractFeishuError(payload);
|
||||
const suffix = err.logId ? ` (log_id=${err.logId})` : "";
|
||||
throw new Error(`Feishu bot info failed: ${err.error}${suffix}`);
|
||||
}
|
||||
const identity = extractBotIdentity(payload);
|
||||
const next: BotIdentity = {
|
||||
openId: identity.openId,
|
||||
userId: identity.userId,
|
||||
name: identity.name,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
botCache.set(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function sendFeishuTextMessage(params: {
|
||||
account: ResolvedFeishuAccount;
|
||||
target: FeishuMessagingTarget;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
}): Promise<{ messageId: string }> {
|
||||
const token = await getTenantAccessToken(params.account);
|
||||
const body = {
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text: params.text }),
|
||||
};
|
||||
|
||||
const endpoint = params.replyToMessageId
|
||||
? `${FEISHU_API_BASE}/im/v1/messages/${encodeURIComponent(params.replyToMessageId)}/reply`
|
||||
: `${FEISHU_API_BASE}/im/v1/messages`;
|
||||
const query = params.replyToMessageId
|
||||
? null
|
||||
: params.target.kind === "user"
|
||||
? "receive_id_type=open_id"
|
||||
: "receive_id_type=chat_id";
|
||||
const url = query ? `${endpoint}?${query}` : endpoint;
|
||||
const payload = params.replyToMessageId
|
||||
? {
|
||||
...body,
|
||||
reply_in_thread: false,
|
||||
}
|
||||
: {
|
||||
...body,
|
||||
receive_id: params.target.kind === "user" ? params.target.openId : params.target.chatId,
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await readJson(res);
|
||||
if (!res.ok || !isFeishuOk(json)) {
|
||||
const err = extractFeishuError(json);
|
||||
const suffix = err.logId ? ` (log_id=${err.logId})` : "";
|
||||
throw new Error(`Feishu send failed: ${err.error}${suffix}`);
|
||||
}
|
||||
|
||||
const data = isRecord((json as { data?: unknown }).data)
|
||||
? ((json as { data?: Record<string, unknown> }).data as Record<string, unknown>)
|
||||
: (json as Record<string, unknown>);
|
||||
const messageId = readString(data.message_id) ?? readString(data.messageId) ?? "";
|
||||
if (!messageId) {
|
||||
// The API returns a `message_id` in most cases; if missing, still treat as success.
|
||||
return { messageId: sha256Hex(`${Date.now()}${Math.random()}`) };
|
||||
}
|
||||
return { messageId };
|
||||
}
|
||||
67
extensions/feishu/src/auth.test.ts
Normal file
67
extensions/feishu/src/auth.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
|
||||
|
||||
function encryptFeishuPayload(params: { plaintext: string; encryptKey: string }): string {
|
||||
const key = crypto.createHash("sha256").update(params.encryptKey).digest();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(params.plaintext, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([iv, ciphertext]).toString("base64");
|
||||
}
|
||||
|
||||
describe("feishu/auth", () => {
|
||||
it("decrypts encrypt payloads", () => {
|
||||
const encryptKey = "encrypt-key-example";
|
||||
const plaintext = JSON.stringify({
|
||||
type: "url_verification",
|
||||
challenge: "challenge-value",
|
||||
token: "verification-token",
|
||||
});
|
||||
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
|
||||
|
||||
const decrypted = decryptFeishuEncrypt({ encrypt, encryptKey });
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("rejects decrypt with wrong key", () => {
|
||||
const encryptKey = "encrypt-key-example";
|
||||
const plaintext = '{"type":"event_callback"}';
|
||||
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
|
||||
|
||||
expect(() => decryptFeishuEncrypt({ encrypt, encryptKey: "wrong-key" })).toThrow();
|
||||
});
|
||||
|
||||
it("verifies Feishu signatures", () => {
|
||||
const rawBody = '{"encrypt":"abc"}';
|
||||
const encryptKey = "encrypt-key-example";
|
||||
const timestamp = "1700000000";
|
||||
const nonce = "nonce";
|
||||
const signature = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${timestamp}${nonce}${encryptKey}${rawBody}`)
|
||||
.digest("hex");
|
||||
|
||||
expect(
|
||||
verifyFeishuSignature({
|
||||
rawBody,
|
||||
encryptKey,
|
||||
timestamp,
|
||||
nonce,
|
||||
signature,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
verifyFeishuSignature({
|
||||
rawBody,
|
||||
encryptKey,
|
||||
timestamp,
|
||||
nonce: "other",
|
||||
signature,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
35
extensions/feishu/src/auth.ts
Normal file
35
extensions/feishu/src/auth.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function decryptFeishuEncrypt(params: { encrypt: string; encryptKey: string }): string {
|
||||
const encrypt = params.encrypt.trim();
|
||||
const encryptKey = params.encryptKey.trim();
|
||||
if (!encrypt) throw new Error("missing encrypt payload");
|
||||
if (!encryptKey) throw new Error("missing encryptKey");
|
||||
|
||||
const key = crypto.createHash("sha256").update(encryptKey).digest();
|
||||
const raw = Buffer.from(encrypt, "base64");
|
||||
if (raw.length < 17) throw new Error("invalid encrypt payload");
|
||||
const iv = raw.subarray(0, 16);
|
||||
const ciphertext = raw.subarray(16);
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
export function verifyFeishuSignature(params: {
|
||||
rawBody: string;
|
||||
encryptKey: string;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
signature: string;
|
||||
}): boolean {
|
||||
const rawBody = params.rawBody;
|
||||
const encryptKey = params.encryptKey.trim();
|
||||
const timestamp = params.timestamp.trim();
|
||||
const nonce = params.nonce.trim();
|
||||
const signature = params.signature.trim().toLowerCase();
|
||||
if (!encryptKey || !timestamp || !nonce || !signature) return false;
|
||||
const content = `${timestamp}${nonce}${encryptKey}${rawBody}`;
|
||||
const computed = crypto.createHash("sha256").update(content).digest("hex").toLowerCase();
|
||||
return computed === signature;
|
||||
}
|
||||
529
extensions/feishu/src/channel.ts
Normal file
529
extensions/feishu/src/channel.ts
Normal file
@ -0,0 +1,529 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDock,
|
||||
ChannelPlugin,
|
||||
ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
missingTargetError,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
type ResolvedFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { resolveFeishuWebhookPath, startFeishuMonitor } from "./monitor.js";
|
||||
import {
|
||||
looksLikeFeishuTargetId,
|
||||
normalizeFeishuMessagingTarget,
|
||||
parseFeishuMessagingTarget,
|
||||
} from "./targets.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
|
||||
const meta = {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu (Bot API)",
|
||||
docsPath: "/channels/feishu",
|
||||
docsLabel: "feishu",
|
||||
blurb: "Feishu bot with HTTP webhook events.",
|
||||
aliases: ["fs"],
|
||||
order: 85,
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
const formatAllowFromEntry = (entry: string) =>
|
||||
entry
|
||||
.trim()
|
||||
.replace(/^feishu:/i, "")
|
||||
.replace(/^fs:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^open_id:/i, "")
|
||||
.replace(/^openid:/i, "")
|
||||
.toLowerCase();
|
||||
|
||||
export const feishuDock: ChannelDock = {
|
||||
id: "feishu",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 4000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(
|
||||
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry))
|
||||
.filter(Boolean)
|
||||
.map(formatAllowFromEntry),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
};
|
||||
|
||||
function targetHint() {
|
||||
return "<user:OPEN_ID|chat:CHAT_ID>";
|
||||
}
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: { ...meta },
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "feishuOpenId",
|
||||
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
|
||||
if (account.credentialSource === "none") return;
|
||||
const openId = formatAllowFromEntry(id);
|
||||
await sendFeishuTextMessage({
|
||||
account,
|
||||
target: { kind: "user", openId },
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listFeishuAccountIds(cfg as ClawdbotConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg as ClawdbotConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
sectionKey: "feishu",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
sectionKey: "feishu",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"appId",
|
||||
"appSecret",
|
||||
"verificationToken",
|
||||
"encryptKey",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"name",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(
|
||||
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? []
|
||||
).map((entry) => String(entry)),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry))
|
||||
.filter(Boolean)
|
||||
.map(formatAllowFromEntry),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as ClawdbotConfig).channels?.feishu?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.feishu.accounts.${resolvedAccountId}.`
|
||||
: "channels.feishu.";
|
||||
return {
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
policyPath: `${basePath}dm.policy`,
|
||||
allowFromPath: `${basePath}dm.`,
|
||||
approveHint: formatPairingApproveHint("feishu"),
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ cfg, account }) => {
|
||||
const warnings: string[] = [];
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy === "open") {
|
||||
warnings.push(
|
||||
`- Feishu groups: groupPolicy="open" allows any group not explicitly denied to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" and configure channels.feishu.groups.`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: () => ({ mode: "allow" }),
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFeishuMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuTargetId,
|
||||
hint: targetHint(),
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map(formatAllowFromEntry)
|
||||
.filter((id) => (q ? id.includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = account.config.groups ?? {};
|
||||
const ids = Object.keys(groups)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id && id !== "*")
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeFeishuMessagingTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && normalized.toLowerCase().startsWith("user:")) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && normalized.toLowerCase().startsWith("chat:")) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return { input, resolved: false, note: targetHint() };
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "feishu",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (input.useEnv) {
|
||||
return "Feishu does not support --use-env; set appId/appSecret in config.";
|
||||
}
|
||||
const appId = (input as { appId?: unknown }).appId;
|
||||
const appSecret = (input as { appSecret?: unknown }).appSecret;
|
||||
const verificationToken = (input as { verificationToken?: unknown }).verificationToken;
|
||||
const encryptKey = (input as { encryptKey?: unknown }).encryptKey;
|
||||
if (typeof appId !== "string" || !appId.trim()) {
|
||||
return "Feishu requires --app-id.";
|
||||
}
|
||||
if (typeof appSecret !== "string" || !appSecret.trim()) {
|
||||
return "Feishu requires --app-secret.";
|
||||
}
|
||||
if (
|
||||
(typeof verificationToken !== "string" || !verificationToken.trim()) &&
|
||||
(typeof encryptKey !== "string" || !encryptKey.trim())
|
||||
) {
|
||||
return "Feishu requires --verification-token or --encrypt-key.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "feishu",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig as ClawdbotConfig,
|
||||
channelKey: "feishu",
|
||||
})
|
||||
: namedConfig;
|
||||
|
||||
const appId = String((input as { appId?: unknown }).appId ?? "").trim();
|
||||
const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim();
|
||||
const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken;
|
||||
const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey;
|
||||
const verificationToken =
|
||||
typeof verificationTokenRaw === "string" && verificationTokenRaw.trim()
|
||||
? verificationTokenRaw.trim()
|
||||
: undefined;
|
||||
const encryptKey =
|
||||
typeof encryptKeyRaw === "string" && encryptKeyRaw.trim()
|
||||
? encryptKeyRaw.trim()
|
||||
: undefined;
|
||||
const webhookPath = input.webhookPath?.trim() || undefined;
|
||||
const webhookUrl = input.webhookUrl?.trim() || undefined;
|
||||
|
||||
const configPatch = {
|
||||
appId,
|
||||
appSecret,
|
||||
...(verificationToken ? { verificationToken } : {}),
|
||||
...(encryptKey ? { encryptKey } : {}),
|
||||
...(webhookPath ? { webhookPath } : {}),
|
||||
...(webhookUrl ? { webhookUrl } : {}),
|
||||
};
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...(next.channels?.feishu ?? {}),
|
||||
enabled: true,
|
||||
...configPatch,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...(next.channels?.feishu ?? {}),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.feishu?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.feishu?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...configPatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowList = (allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.filter((entry) => entry !== "*")
|
||||
.map((entry) => normalizeFeishuMessagingTarget(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeFeishuMessagingTarget(trimmed);
|
||||
if (!normalized) {
|
||||
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError(
|
||||
"Feishu",
|
||||
`${targetHint()} or channels.feishu.dm.allowFrom[0]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
|
||||
if (allowList.length > 0) {
|
||||
return { ok: true, to: allowList[0] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Feishu", `${targetHint()} or channels.feishu.dm.allowFrom[0]`),
|
||||
};
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
if (account.credentialSource === "none") {
|
||||
throw new Error(
|
||||
"Feishu not configured: missing appId/appSecret and webhook validation secret",
|
||||
);
|
||||
}
|
||||
const target = parseFeishuMessagingTarget(to);
|
||||
if (!target) {
|
||||
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
|
||||
}
|
||||
const bot = await getBotIdentity(account).catch(() => null);
|
||||
const replyToMessageId = replyToId?.trim() || undefined;
|
||||
const result = await sendFeishuTextMessage({
|
||||
account,
|
||||
target,
|
||||
text,
|
||||
replyToMessageId,
|
||||
});
|
||||
return {
|
||||
channel: "feishu",
|
||||
messageId: result.messageId,
|
||||
chatId: target.kind === "chat" ? target.chatId : undefined,
|
||||
meta: {
|
||||
target: target.kind === "chat" ? `chat:${target.chatId}` : `user:${target.openId}`,
|
||||
botOpenId: bot?.openId,
|
||||
},
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const caption = text?.trim() ? `${text.trim()}\n${mediaUrl}` : mediaUrl;
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
if (account.credentialSource === "none") {
|
||||
throw new Error(
|
||||
"Feishu not configured: missing appId/appSecret and webhook validation secret",
|
||||
);
|
||||
}
|
||||
const target = parseFeishuMessagingTarget(to);
|
||||
if (!target) {
|
||||
throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`);
|
||||
}
|
||||
const result = await sendFeishuTextMessage({
|
||||
account,
|
||||
target,
|
||||
text: caption,
|
||||
replyToMessageId: replyToId?.trim() || undefined,
|
||||
});
|
||||
return {
|
||||
channel: "feishu",
|
||||
messageId: result.messageId,
|
||||
chatId: target.kind === "chat" ? target.chatId : undefined,
|
||||
meta: {
|
||||
warning: "media send is not supported yet; delivered as text URL",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
webhookPath: null,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
lastInboundAt: snapshot.lastInboundAt ?? null,
|
||||
lastOutboundAt: snapshot.lastOutboundAt ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => await probeFeishu(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.log?.info(`[${account.accountId}] starting Feishu webhook`);
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveFeishuWebhookPath({ account }),
|
||||
});
|
||||
const unregister = await startFeishuMonitor({
|
||||
account,
|
||||
config: ctx.cfg as ClawdbotConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
|
||||
});
|
||||
return () => {
|
||||
unregister?.();
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
85
extensions/feishu/src/config-schema.ts
Normal file
85
extensions/feishu/src/config-schema.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { DmPolicySchema, GroupPolicySchema, requireOpenAllowFrom } from "clawdbot/plugin-sdk";
|
||||
|
||||
const StringListSchema = z.array(z.union([z.string(), z.number()])).optional();
|
||||
|
||||
export const FeishuDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: StringListSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.policy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.feishu.dm.policy="open" requires channels.feishu.dm.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const FeishuGroupSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
users: StringListSchema,
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const FeishuAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
appId: z.string().optional(),
|
||||
appSecret: z.string().optional(),
|
||||
verificationToken: z.string().optional(),
|
||||
encryptKey: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groupAllowFrom: StringListSchema,
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
dm: FeishuDmSchema.optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
const appId = value.appId?.trim();
|
||||
const appSecret = value.appSecret?.trim();
|
||||
if (appId && !appSecret) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "appSecret is required when appId is set",
|
||||
path: ["appSecret"],
|
||||
});
|
||||
}
|
||||
if (appSecret && !appId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "appId is required when appSecret is set",
|
||||
path: ["appId"],
|
||||
});
|
||||
}
|
||||
const verificationToken = value.verificationToken?.trim();
|
||||
const encryptKey = value.encryptKey?.trim();
|
||||
if ((appId || appSecret) && !verificationToken && !encryptKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "verificationToken or encryptKey is required for webhook validation",
|
||||
path: ["verificationToken"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const FeishuConfigSchema = FeishuAccountSchema.extend({
|
||||
accounts: z.record(z.string(), FeishuAccountSchema.optional()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
799
extensions/feishu/src/monitor.ts
Normal file
799
extensions/feishu/src/monitor.ts
Normal file
@ -0,0 +1,799 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||
import { getBotIdentity, sendFeishuTextMessage } from "./api.js";
|
||||
import { decryptFeishuEncrypt, verifyFeishuSignature } from "./auth.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import type { FeishuMessagingTarget } from "./targets.js";
|
||||
|
||||
export type FeishuRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type FeishuMonitorOptions = {
|
||||
account: ResolvedFeishuAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: FeishuRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
|
||||
|
||||
type WebhookTarget = {
|
||||
account: ResolvedFeishuAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: FeishuRuntimeEnv;
|
||||
core: FeishuCoreRuntime;
|
||||
path: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string): void {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[feishu] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "/feishu";
|
||||
}
|
||||
|
||||
export function registerFeishuWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
const next = [...existing, normalizedTarget];
|
||||
webhookTargets.set(key, next);
|
||||
return () => {
|
||||
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
||||
if (updated.length > 0) {
|
||||
webhookTargets.set(key, updated);
|
||||
} else {
|
||||
webhookTargets.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readRawBody(req: IncomingMessage, maxBytes: number) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
return await new Promise<{ ok: boolean; raw?: string; error?: string }>((resolve) => {
|
||||
let resolved = false;
|
||||
const doResolve = (value: { ok: boolean; raw?: string; error?: string }) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
req.removeAllListeners();
|
||||
resolve(value);
|
||||
};
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
doResolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
doResolve({ ok: false, error: "empty payload" });
|
||||
return;
|
||||
}
|
||||
doResolve({ ok: true, raw });
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; error: string } {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) as unknown };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
type SelectedRequest = {
|
||||
target: WebhookTarget;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractVerificationToken(payload: Record<string, unknown>): string | undefined {
|
||||
const token = readString(payload.token);
|
||||
if (token) return token;
|
||||
const header = isRecord(payload.header) ? payload.header : undefined;
|
||||
return header ? readString(header.token) : undefined;
|
||||
}
|
||||
|
||||
function selectTarget(params: {
|
||||
targets: WebhookTarget[];
|
||||
rawBody: string;
|
||||
outer: Record<string, unknown>;
|
||||
headers: {
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
signature: string;
|
||||
};
|
||||
}): SelectedRequest | null {
|
||||
const { targets, rawBody, outer, headers } = params;
|
||||
const encrypt = readString(outer.encrypt);
|
||||
|
||||
if (encrypt) {
|
||||
for (const candidate of targets) {
|
||||
const encryptKey = candidate.account.config.encryptKey?.trim();
|
||||
if (!encryptKey) continue;
|
||||
if (
|
||||
verifyFeishuSignature({
|
||||
rawBody,
|
||||
encryptKey,
|
||||
timestamp: headers.timestamp,
|
||||
nonce: headers.nonce,
|
||||
signature: headers.signature,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const decrypted = decryptFeishuEncrypt({ encrypt, encryptKey });
|
||||
const json = parseJson(decrypted);
|
||||
if (!json.ok || !isRecord(json.value)) continue;
|
||||
return { target: candidate, payload: json.value };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = extractVerificationToken(outer);
|
||||
if (token) {
|
||||
const match = targets.find(
|
||||
(candidate) => candidate.account.config.verificationToken?.trim() === token,
|
||||
);
|
||||
if (match) return { target: match, payload: outer };
|
||||
}
|
||||
|
||||
if (headers.signature && headers.timestamp && headers.nonce) {
|
||||
for (const candidate of targets) {
|
||||
const encryptKey = candidate.account.config.encryptKey?.trim();
|
||||
if (!encryptKey) continue;
|
||||
if (
|
||||
verifyFeishuSignature({
|
||||
rawBody,
|
||||
encryptKey,
|
||||
timestamp: headers.timestamp,
|
||||
nonce: headers.nonce,
|
||||
signature: headers.signature,
|
||||
})
|
||||
) {
|
||||
return { target: candidate, payload: outer };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readHeaderValues(req: IncomingMessage) {
|
||||
const timestamp = String(
|
||||
req.headers["x-lark-request-timestamp"] ?? req.headers["x-feishu-request-timestamp"] ?? "",
|
||||
).trim();
|
||||
const nonce = String(
|
||||
req.headers["x-lark-request-nonce"] ?? req.headers["x-feishu-request-nonce"] ?? "",
|
||||
).trim();
|
||||
const signature = String(req.headers["x-lark-signature"] ?? "").trim();
|
||||
return { timestamp, nonce, signature };
|
||||
}
|
||||
|
||||
function parseFeishuTimestampMs(value: string | undefined): number | undefined {
|
||||
const raw = value?.trim();
|
||||
if (!raw) return undefined;
|
||||
const num = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(num)) return undefined;
|
||||
if (raw.length >= 13) return num;
|
||||
return num * 1000;
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^feishu:/i, "")
|
||||
.replace(/^fs:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^open_id:/i, "")
|
||||
.replace(/^openid:/i, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isSenderAllowed(
|
||||
senderOpenId: string,
|
||||
senderUserId: string | undefined,
|
||||
allowFrom: string[],
|
||||
): boolean {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const openId = normalizeAllowEntry(senderOpenId);
|
||||
const userId = senderUserId?.trim().toLowerCase() ?? "";
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = normalizeAllowEntry(String(entry));
|
||||
if (!normalized) return false;
|
||||
if (normalized === openId) return true;
|
||||
if (userId && normalized === userId) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGroupEntry(account: ResolvedFeishuAccount, chatId: string) {
|
||||
const groups = account.config.groups ?? {};
|
||||
const keys = Object.keys(groups).filter(Boolean);
|
||||
if (keys.length === 0) return { entry: undefined, allowlistConfigured: false };
|
||||
const entry = groups[chatId] ?? groups["*"];
|
||||
return { entry, allowlistConfigured: true };
|
||||
}
|
||||
|
||||
function extractTextFromMessageContent(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return "";
|
||||
const parsed = parseJson(trimmed);
|
||||
if (parsed.ok && isRecord(parsed.value)) {
|
||||
const text = readString(parsed.value.text);
|
||||
if (text) return text;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractMentions(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
|
||||
function wasBotMentioned(params: {
|
||||
mentions: Array<Record<string, unknown>>;
|
||||
bot: { openId?: string; userId?: string };
|
||||
}): boolean {
|
||||
const botOpenId = params.bot.openId?.trim();
|
||||
const botUserId = params.bot.userId?.trim();
|
||||
if (!botOpenId && !botUserId) return false;
|
||||
return params.mentions.some((mention) => {
|
||||
const id = isRecord(mention.id) ? mention.id : null;
|
||||
const openId = readString((id ?? mention).open_id) ?? readString((id ?? mention).openId);
|
||||
const userId = readString((id ?? mention).user_id) ?? readString((id ?? mention).userId);
|
||||
if (botOpenId && openId && botOpenId === openId) return true;
|
||||
if (botUserId && userId && botUserId === userId) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverFeishuReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||
account: ResolvedFeishuAccount;
|
||||
target: FeishuMessagingTarget;
|
||||
runtime: FeishuRuntimeEnv;
|
||||
core: FeishuCoreRuntime;
|
||||
cfg: ClawdbotConfig;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
const { payload, account, target, runtime, core, cfg, statusSink } = params;
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const replyToMessageId = payload.replyToId?.trim() || undefined;
|
||||
|
||||
let text = payload.text ?? "";
|
||||
if (mediaList.length > 0) {
|
||||
const suffix = mediaList.join("\n");
|
||||
text = text.trim() ? `${text.trim()}\n${suffix}` : suffix;
|
||||
}
|
||||
|
||||
if (!text.trim()) return;
|
||||
|
||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu", account.accountId);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendFeishuTextMessage({
|
||||
account,
|
||||
target,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`[${account.accountId}] Feishu send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processFeishuMessage(params: {
|
||||
account: ResolvedFeishuAccount;
|
||||
config: ClawdbotConfig;
|
||||
runtime: FeishuRuntimeEnv;
|
||||
core: FeishuCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
chatType: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
mentions: Array<Record<string, unknown>>;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string;
|
||||
senderType?: string;
|
||||
createdAt?: number;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
statusSink,
|
||||
messageId,
|
||||
chatId,
|
||||
chatType,
|
||||
messageType,
|
||||
content,
|
||||
mentions,
|
||||
senderOpenId,
|
||||
senderUserId,
|
||||
senderType,
|
||||
createdAt,
|
||||
} = params;
|
||||
|
||||
const isGroup = chatType.toLowerCase() !== "p2p";
|
||||
|
||||
if (senderType && senderType.toLowerCase() !== "user") {
|
||||
logVerbose(core, runtime, `skip non-user sender (type=${senderType})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType.toLowerCase() !== "text") {
|
||||
logVerbose(core, runtime, `skip non-text message (type=${messageType})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = extractTextFromMessageContent(content).trim();
|
||||
if (!rawBody) return;
|
||||
|
||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const groupResolved = isGroup ? resolveGroupEntry(account, chatId) : null;
|
||||
const groupEntry = groupResolved?.entry;
|
||||
|
||||
const groupUsers = isGroup
|
||||
? (groupEntry?.users ?? account.config.groupAllowFrom ?? []).map((v) => String(v))
|
||||
: [];
|
||||
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
|
||||
return;
|
||||
}
|
||||
const allowlistConfigured = groupResolved?.allowlistConfigured ?? false;
|
||||
const allowlisted = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!allowlistConfigured) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!allowlisted) {
|
||||
logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
|
||||
logVerbose(core, runtime, `drop group message (group disabled, chat=${chatId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupUsers.length > 0) {
|
||||
const ok = isSenderAllowed(senderOpenId, senderUserId, groupUsers);
|
||||
if (!ok) {
|
||||
logVerbose(core, runtime, `drop group message (sender not allowed, user=${senderOpenId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const commandAllowFrom = isGroup ? groupUsers : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderOpenId, senderUserId, commandAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "feishu",
|
||||
});
|
||||
let botMentioned = false;
|
||||
if (requireMention) {
|
||||
try {
|
||||
const bot = await getBotIdentity(account).catch(() => null);
|
||||
if (bot?.openId || bot?.userId) {
|
||||
botMentioned = wasBotMentioned({
|
||||
mentions,
|
||||
bot: { openId: bot.openId, userId: bot.userId },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `bot identity lookup failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
canDetectMention: true,
|
||||
wasMentioned: botMentioned,
|
||||
implicitMention: false,
|
||||
hasAnyMention: mentions.length > 0,
|
||||
allowTextCommands,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
});
|
||||
if (mentionGate.shouldSkip) {
|
||||
logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
|
||||
logVerbose(core, runtime, `blocked Feishu DM from ${senderOpenId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
if (!senderAllowedForCommands) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "feishu",
|
||||
id: senderOpenId,
|
||||
meta: { userId: senderUserId },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `feishu pairing request sender=${senderOpenId}`);
|
||||
try {
|
||||
await sendFeishuTextMessage({
|
||||
account,
|
||||
target: { kind: "user", openId: senderOpenId },
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "feishu",
|
||||
idLine: `Your Feishu open id: ${senderOpenId}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `pairing reply failed for ${senderOpenId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`blocked unauthorized Feishu sender ${senderOpenId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `feishu: drop control command from ${senderOpenId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = isGroup ? chatId : senderOpenId;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderOpenId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: fromLabel,
|
||||
timestamp: createdAt,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = isGroup ? groupEntry?.systemPrompt?.trim() || undefined : undefined;
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: `feishu:${senderOpenId}`,
|
||||
To: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "channel" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderId: senderOpenId,
|
||||
SenderUsername: senderUserId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
MessageSid: messageId,
|
||||
MessageSidFull: messageId,
|
||||
ReplyToId: messageId,
|
||||
ReplyToIdFull: messageId,
|
||||
GroupSpace: isGroup ? chatId : undefined,
|
||||
GroupSystemPrompt: groupSystemPrompt,
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: isGroup ? `feishu:${chatId}` : `feishu:${senderOpenId}`,
|
||||
});
|
||||
|
||||
void core.channel.session
|
||||
.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
|
||||
});
|
||||
|
||||
const target: FeishuMessagingTarget = isGroup
|
||||
? { kind: "chat", chatId }
|
||||
: { kind: "user", openId: senderOpenId };
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverFeishuReply({
|
||||
payload,
|
||||
account,
|
||||
target,
|
||||
runtime,
|
||||
core,
|
||||
cfg: config,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleFeishuWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) return false;
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readRawBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseJson(body.raw ?? "");
|
||||
if (!parsed.ok || !isRecord(parsed.value)) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const headers = readHeaderValues(req);
|
||||
const selected = selectTarget({
|
||||
targets,
|
||||
rawBody: body.raw ?? "",
|
||||
outer: parsed.value,
|
||||
headers,
|
||||
});
|
||||
if (!selected) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = selected.payload;
|
||||
const eventType = readString(isRecord(payload.header) ? payload.header.event_type : undefined);
|
||||
const type = readString(payload.type);
|
||||
|
||||
if (type === "url_verification") {
|
||||
const challenge = readString(payload.challenge);
|
||||
if (!challenge) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ challenge }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (eventType !== "im.message.receive_v1") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
const event = isRecord(payload.event) ? payload.event : null;
|
||||
const message = event && isRecord(event.message) ? event.message : null;
|
||||
const sender = event && isRecord(event.sender) ? event.sender : null;
|
||||
if (!message || !sender) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const messageId =
|
||||
readString(message.message_id) ?? readString(message.messageId) ?? readString(message.id);
|
||||
const chatId = readString(message.chat_id) ?? readString(message.chatId);
|
||||
const chatType = readString(message.chat_type) ?? readString(message.chatType) ?? "";
|
||||
const messageType = readString(message.message_type) ?? readString(message.messageType) ?? "";
|
||||
const content = readString(message.content) ?? "";
|
||||
if (!messageId || !chatId || !chatType || !messageType || !content) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const senderId = isRecord(sender.sender_id) ? sender.sender_id : null;
|
||||
const senderOpenId =
|
||||
readString((senderId ?? sender).open_id) ?? readString((senderId ?? sender).openId) ?? "";
|
||||
const senderUserId =
|
||||
readString((senderId ?? sender).user_id) ?? readString((senderId ?? sender).userId);
|
||||
const senderType = readString(sender.sender_type) ?? readString(sender.senderType);
|
||||
if (!senderOpenId) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const mentions = extractMentions(message.mentions);
|
||||
const createdAt = parseFeishuTimestampMs(
|
||||
readString(isRecord(payload.header) ? payload.header.create_time : undefined),
|
||||
);
|
||||
|
||||
selected.target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processFeishuMessage({
|
||||
account: selected.target.account,
|
||||
config: selected.target.config,
|
||||
runtime: selected.target.runtime,
|
||||
core: selected.target.core,
|
||||
statusSink: selected.target.statusSink,
|
||||
messageId,
|
||||
chatId,
|
||||
chatType,
|
||||
messageType,
|
||||
content,
|
||||
mentions,
|
||||
senderOpenId,
|
||||
senderUserId,
|
||||
senderType,
|
||||
createdAt,
|
||||
}).catch((err) => {
|
||||
selected.target.runtime.error?.(
|
||||
`[${selected.target.account.accountId}] Feishu webhook failed: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function startFeishuMonitor(options: FeishuMonitorOptions): Promise<() => void> {
|
||||
const core = getFeishuRuntime();
|
||||
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
|
||||
if (!webhookPath) {
|
||||
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const unregister = registerFeishuWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
core,
|
||||
path: webhookPath,
|
||||
statusSink: options.statusSink,
|
||||
});
|
||||
|
||||
return unregister;
|
||||
}
|
||||
|
||||
export function resolveFeishuWebhookPath(params: { account: ResolvedFeishuAccount }): string {
|
||||
return (
|
||||
resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ??
|
||||
"/feishu"
|
||||
);
|
||||
}
|
||||
216
extensions/feishu/src/monitor.webhook.test.ts
Normal file
216
extensions/feishu/src/monitor.webhook.test.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||
import { handleFeishuWebhookRequest, registerFeishuWebhookTarget } from "./monitor.js";
|
||||
|
||||
async function withServer(
|
||||
handler: Parameters<typeof createServer>[0],
|
||||
fn: (baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address) throw new Error("missing server address");
|
||||
try {
|
||||
await fn(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
function encryptFeishuPayload(params: { plaintext: string; encryptKey: string }): string {
|
||||
const key = crypto.createHash("sha256").update(params.encryptKey).digest();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(params.plaintext, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([iv, ciphertext]).toString("base64");
|
||||
}
|
||||
|
||||
function computeFeishuSignature(params: {
|
||||
rawBody: string;
|
||||
encryptKey: string;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
}): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(`${params.timestamp}${params.nonce}${params.encryptKey}${params.rawBody}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
describe("handleFeishuWebhookRequest", () => {
|
||||
it("responds to url_verification (verificationToken)", async () => {
|
||||
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
|
||||
const account: ResolvedFeishuAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: { verificationToken: "vtok" },
|
||||
credentialSource: "config",
|
||||
};
|
||||
const error = vi.fn();
|
||||
const statusSink = vi.fn();
|
||||
const unregister = registerFeishuWebhookTarget({
|
||||
account,
|
||||
config: {} as ClawdbotConfig,
|
||||
runtime: { error },
|
||||
core,
|
||||
path: "/hook",
|
||||
statusSink,
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
async (req, res) => {
|
||||
const handled = await handleFeishuWebhookRequest(req, res);
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: "vtok", challenge: "abc", type: "url_verification" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ challenge: "abc" });
|
||||
expect(statusSink).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("responds to url_verification (encryptKey)", async () => {
|
||||
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
|
||||
const encryptKey = "encrypt-key-example";
|
||||
const account: ResolvedFeishuAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: { encryptKey },
|
||||
credentialSource: "config",
|
||||
};
|
||||
const error = vi.fn();
|
||||
const statusSink = vi.fn();
|
||||
const unregister = registerFeishuWebhookTarget({
|
||||
account,
|
||||
config: {} as ClawdbotConfig,
|
||||
runtime: { error },
|
||||
core,
|
||||
path: "/hook",
|
||||
statusSink,
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
async (req, res) => {
|
||||
const handled = await handleFeishuWebhookRequest(req, res);
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const plaintext = JSON.stringify({ type: "url_verification", challenge: "abc" });
|
||||
const encrypt = encryptFeishuPayload({ plaintext, encryptKey });
|
||||
const rawBody = JSON.stringify({ encrypt });
|
||||
const timestamp = "1700000000";
|
||||
const nonce = "nonce";
|
||||
const signature = computeFeishuSignature({ rawBody, encryptKey, timestamp, nonce });
|
||||
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-lark-request-timestamp": timestamp,
|
||||
"x-lark-request-nonce": nonce,
|
||||
"x-lark-signature": signature,
|
||||
},
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ challenge: "abc" });
|
||||
expect(statusSink).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses im.message.receive_v1 payloads and returns 200", async () => {
|
||||
const core = { logging: { shouldLogVerbose: () => false } } as unknown as PluginRuntime;
|
||||
const account: ResolvedFeishuAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
config: { verificationToken: "vtok" },
|
||||
credentialSource: "config",
|
||||
};
|
||||
const error = vi.fn();
|
||||
const statusSink = vi.fn();
|
||||
const unregister = registerFeishuWebhookTarget({
|
||||
account,
|
||||
config: {} as ClawdbotConfig,
|
||||
runtime: { error },
|
||||
core,
|
||||
path: "/hook",
|
||||
statusSink,
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
async (req, res) => {
|
||||
const handled = await handleFeishuWebhookRequest(req, res);
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const payload = {
|
||||
token: "vtok",
|
||||
header: { event_type: "im.message.receive_v1", create_time: "1700000000000" },
|
||||
event: {
|
||||
message: {
|
||||
message_id: "m1",
|
||||
chat_id: "oc_123",
|
||||
chat_type: "group",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ text: "ignored" }),
|
||||
},
|
||||
sender: {
|
||||
sender_id: { open_id: "ou_123", user_id: "u_1" },
|
||||
sender_type: "user",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(statusSink).toHaveBeenCalledTimes(1);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
266
extensions/feishu/src/onboarding.ts
Normal file
266
extensions/feishu/src/onboarding.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
function setFeishuDmPolicy(cfg: ClawdbotConfig, policy: DmPolicy) {
|
||||
const allowFrom =
|
||||
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.dm?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...(cfg.channels?.feishu ?? {}),
|
||||
dm: {
|
||||
...(cfg.channels?.feishu?.dm ?? {}),
|
||||
policy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
async function noteFeishuAppHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Create a Feishu app and enable the bot capability",
|
||||
"2) Configure event subscription callback: https://gateway.example.com/feishu",
|
||||
"3) Subscribe to the im.message.receive_v1 event",
|
||||
"4) Copy appId/appSecret + verification token (or encrypt key) into Clawdbot",
|
||||
"Docs: https://docs.clawd.bot/channels/feishu",
|
||||
].join("\n"),
|
||||
"Feishu bot app",
|
||||
);
|
||||
}
|
||||
|
||||
function applyAccountPatch(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
patch: Record<string, unknown>;
|
||||
}): ClawdbotConfig {
|
||||
const { cfg, accountId, patch } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...(cfg.channels?.feishu ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...(cfg.channels?.feishu ?? {}),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.feishu?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.feishu?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const resolved = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const existingAllowFrom = resolved.config.dm?.allowFrom ?? [];
|
||||
const entry = await params.prompter.text({
|
||||
message: "Feishu allowFrom (open id)",
|
||||
placeholder: "ou_xxx",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^ou_/i.test(raw)) return "Use a Feishu open id (starts with ou_)";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
const existingDm = resolved.config.dm ?? {};
|
||||
return applyAccountPatch({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
patch: {
|
||||
dm: { ...existingDm, policy: "allowlist", allowFrom: unique },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Feishu",
|
||||
channel,
|
||||
policyKey: "channels.feishu.dm.policy",
|
||||
allowFromKey: "channels.feishu.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.feishu?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as ClawdbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultFeishuAccountId(cfg as ClawdbotConfig);
|
||||
return await promptFeishuAllowFrom({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listFeishuAccountIds(cfg as ClawdbotConfig).some((accountId) => {
|
||||
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
return account.credentialSource !== "none";
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Feishu: ${configured ? "configured" : "needs appId/appSecret"}`],
|
||||
selectionHint: configured ? "configured" : "plugin · webhook",
|
||||
quickstartScore: configured ? 1 : 18,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const override = accountOverrides.feishu?.trim();
|
||||
const defaultAccountId = resolveDefaultFeishuAccountId(cfg as ClawdbotConfig);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
prompter,
|
||||
label: "Feishu",
|
||||
currentId: accountId,
|
||||
listAccountIds: listFeishuAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as ClawdbotConfig;
|
||||
const resolved = resolveFeishuAccount({ cfg: next, accountId });
|
||||
const alreadyConfigured = resolved.credentialSource !== "none";
|
||||
|
||||
if (!alreadyConfigured) {
|
||||
await noteFeishuAppHelp(prompter);
|
||||
}
|
||||
|
||||
const keepCredentials = alreadyConfigured
|
||||
? await prompter.confirm({
|
||||
message: "Feishu credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!keepCredentials) {
|
||||
const appId = String(
|
||||
await prompter.text({
|
||||
message: "Feishu appId",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Feishu appSecret",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const validationMode = await prompter.select({
|
||||
message: "Webhook validation",
|
||||
options: [
|
||||
{ value: "token", label: "Verification token", hint: "Simple shared secret check" },
|
||||
{ value: "encrypt", label: "Encrypt key", hint: "Signature + encrypted payload" },
|
||||
],
|
||||
initialValue: "token",
|
||||
});
|
||||
|
||||
const patch: Record<string, unknown> = { appId, appSecret };
|
||||
if (validationMode === "encrypt") {
|
||||
const encryptKey = String(
|
||||
await prompter.text({
|
||||
message: "Feishu encrypt key",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
patch.encryptKey = encryptKey;
|
||||
} else {
|
||||
const verificationToken = String(
|
||||
await prompter.text({
|
||||
message: "Feishu verification token",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
patch.verificationToken = verificationToken;
|
||||
}
|
||||
|
||||
const wantsPath = await prompter.confirm({
|
||||
message: "Customize webhook path? (default: /feishu)",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsPath) {
|
||||
const webhookPath = String(
|
||||
await prompter.text({
|
||||
message: "Webhook path (starts with /)",
|
||||
initialValue: resolved.config.webhookPath ?? "/feishu",
|
||||
}),
|
||||
).trim();
|
||||
if (webhookPath) patch.webhookPath = webhookPath;
|
||||
}
|
||||
|
||||
next = applyAccountPatch({ cfg: next, accountId, patch });
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptFeishuAllowFrom({ cfg: next, prompter, accountId });
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
};
|
||||
21
extensions/feishu/src/probe.ts
Normal file
21
extensions/feishu/src/probe.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ResolvedFeishuAccount } from "./accounts.js";
|
||||
import { getBotIdentity, getTenantAccessToken } from "./api.js";
|
||||
|
||||
export async function probeFeishu(
|
||||
account: ResolvedFeishuAccount,
|
||||
): Promise<
|
||||
| { ok: true; token: "ok"; bot: { openId?: string; userId?: string; name?: string } }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
try {
|
||||
await getTenantAccessToken(account);
|
||||
const bot = await getBotIdentity(account);
|
||||
return {
|
||||
ok: true,
|
||||
token: "ok",
|
||||
bot: { openId: bot.openId, userId: bot.userId, name: bot.name },
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
14
extensions/feishu/src/runtime.ts
Normal file
14
extensions/feishu/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setFeishuRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getFeishuRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Feishu runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
16
extensions/feishu/src/smoke.test.ts
Normal file
16
extensions/feishu/src/smoke.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { feishuDock, feishuPlugin } from "./channel.js";
|
||||
|
||||
describe("feishu/smoke", () => {
|
||||
it("registers channel metadata", () => {
|
||||
expect(feishuDock.id).toBe("feishu");
|
||||
expect(feishuPlugin.id).toBe("feishu");
|
||||
});
|
||||
|
||||
it("exports a plugin entrypoint", async () => {
|
||||
const mod = await import("../index.ts");
|
||||
expect(mod.default.id).toBe("feishu");
|
||||
expect(typeof mod.default.register).toBe("function");
|
||||
});
|
||||
});
|
||||
34
extensions/feishu/src/targets.test.ts
Normal file
34
extensions/feishu/src/targets.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
looksLikeFeishuTargetId,
|
||||
normalizeFeishuMessagingTarget,
|
||||
parseFeishuMessagingTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("feishu/targets", () => {
|
||||
it("normalizes user and chat targets", () => {
|
||||
expect(normalizeFeishuMessagingTarget("user:ou_123")).toBe("user:ou_123");
|
||||
expect(normalizeFeishuMessagingTarget("chat:oc_123")).toBe("chat:oc_123");
|
||||
expect(normalizeFeishuMessagingTarget("feishu:user:ou_123")).toBe("user:ou_123");
|
||||
expect(normalizeFeishuMessagingTarget("fs:chat:oc_123")).toBe("chat:oc_123");
|
||||
expect(normalizeFeishuMessagingTarget("ou_abc")).toBe("user:ou_abc");
|
||||
expect(normalizeFeishuMessagingTarget("oc_abc")).toBe("chat:oc_abc");
|
||||
});
|
||||
|
||||
it("parses targets into typed objects", () => {
|
||||
expect(parseFeishuMessagingTarget("user:ou_123")).toEqual({ kind: "user", openId: "ou_123" });
|
||||
expect(parseFeishuMessagingTarget("chat:oc_123")).toEqual({ kind: "chat", chatId: "oc_123" });
|
||||
expect(parseFeishuMessagingTarget("ou_123")).toEqual({ kind: "user", openId: "ou_123" });
|
||||
expect(parseFeishuMessagingTarget("oc_123")).toEqual({ kind: "chat", chatId: "oc_123" });
|
||||
});
|
||||
|
||||
it("detects target ids", () => {
|
||||
expect(looksLikeFeishuTargetId("user:ou_123")).toBe(true);
|
||||
expect(looksLikeFeishuTargetId("chat:oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuTargetId("ou_123")).toBe(true);
|
||||
expect(looksLikeFeishuTargetId("oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuTargetId("")).toBe(false);
|
||||
expect(looksLikeFeishuTargetId("foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
57
extensions/feishu/src/targets.ts
Normal file
57
extensions/feishu/src/targets.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export type FeishuMessagingTarget =
|
||||
| { kind: "user"; openId: string }
|
||||
| { kind: "chat"; chatId: string };
|
||||
|
||||
function stripPrefixes(value: string): string {
|
||||
return value.replace(/^feishu:/i, "").replace(/^fs:/i, "");
|
||||
}
|
||||
|
||||
function normalizeBareId(value: string): FeishuMessagingTarget | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^ou_/i.test(trimmed)) return { kind: "user", openId: trimmed };
|
||||
if (/^oc_/i.test(trimmed)) return { kind: "chat", chatId: trimmed };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeFeishuMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = stripPrefixes(raw.trim());
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const match = trimmed.match(/^(user|open_id|openid|chat|chat_id|chatid):(.+)$/i);
|
||||
if (match) {
|
||||
const kind = match[1].toLowerCase();
|
||||
const id = match[2].trim();
|
||||
if (!id) return undefined;
|
||||
if (kind === "user" || kind === "open_id" || kind === "openid") return `user:${id}`;
|
||||
if (kind === "chat" || kind === "chat_id" || kind === "chatid") return `chat:${id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bare = normalizeBareId(trimmed);
|
||||
if (bare?.kind === "user") return `user:${bare.openId}`;
|
||||
if (bare?.kind === "chat") return `chat:${bare.chatId}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseFeishuMessagingTarget(raw: string): FeishuMessagingTarget | null {
|
||||
const normalized = normalizeFeishuMessagingTarget(raw);
|
||||
if (!normalized) return null;
|
||||
if (normalized.toLowerCase().startsWith("user:")) {
|
||||
const openId = normalized.slice("user:".length).trim();
|
||||
return openId ? { kind: "user", openId } : null;
|
||||
}
|
||||
if (normalized.toLowerCase().startsWith("chat:")) {
|
||||
const chatId = normalized.slice("chat:".length).trim();
|
||||
return chatId ? { kind: "chat", chatId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function looksLikeFeishuTargetId(raw: string): boolean {
|
||||
const normalized = normalizeFeishuMessagingTarget(raw);
|
||||
if (!normalized) return false;
|
||||
if (normalized.toLowerCase().startsWith("user:")) return true;
|
||||
if (normalized.toLowerCase().startsWith("chat:")) return true;
|
||||
return false;
|
||||
}
|
||||
41
extensions/feishu/src/types.config.ts
Normal file
41
extensions/feishu/src/types.config.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export type FeishuDmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
|
||||
export type FeishuGroupPolicy = "open" | "disabled" | "allowlist";
|
||||
|
||||
export type FeishuDmConfig = {
|
||||
enabled?: boolean;
|
||||
policy?: FeishuDmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type FeishuGroupConfig = {
|
||||
enabled?: boolean;
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type FeishuAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
verificationToken?: string;
|
||||
encryptKey?: string;
|
||||
webhookPath?: string;
|
||||
webhookUrl?: string;
|
||||
dm?: FeishuDmConfig;
|
||||
requireMention?: boolean;
|
||||
groupPolicy?: FeishuGroupPolicy;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groups?: Record<string, FeishuGroupConfig | undefined>;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
};
|
||||
|
||||
export type FeishuConfig = FeishuAccountConfig & {
|
||||
accounts?: Record<string, FeishuAccountConfig | undefined>;
|
||||
defaultAccount?: string;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user