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