feat: support feshu/lark
This commit is contained in:
parent
83de980d6c
commit
b560bdeab6
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@ -9,6 +9,11 @@
|
|||||||
- "src/discord/**"
|
- "src/discord/**"
|
||||||
- "extensions/discord/**"
|
- "extensions/discord/**"
|
||||||
- "docs/channels/discord.md"
|
- "docs/channels/discord.md"
|
||||||
|
"channel: feishu":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/feishu/**"
|
||||||
|
- "docs/channels/feishu.md"
|
||||||
"channel: googlechat":
|
"channel: googlechat":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|||||||
234
docs/channels/feishu.md
Normal file
234
docs/channels/feishu.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
summary: "Feishu (Lark) bot support, capabilities, and configuration"
|
||||||
|
read_when:
|
||||||
|
- Working on Feishu features or webhooks
|
||||||
|
- Integrating with Chinese enterprise messaging
|
||||||
|
---
|
||||||
|
# Feishu (飞书/Lark)
|
||||||
|
|
||||||
|
Status: experimental. Supports direct messages and groups via Bot API.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Details: [Plugins](/plugin)
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- Or pick **Feishu** in onboarding and confirm the install prompt
|
||||||
|
2) Create an app in Feishu Open Platform and get App ID + App Secret
|
||||||
|
3) Set the credentials:
|
||||||
|
- Env: `FEISHU_APP_ID=...` and `FEISHU_APP_SECRET=...`
|
||||||
|
- Or config: `channels.feishu.appId` and `channels.feishu.appSecret`
|
||||||
|
4) Configure event subscription in Feishu console with your webhook URL
|
||||||
|
5) Restart the gateway (or finish onboarding)
|
||||||
|
6) DM access is pairing by default; approve the pairing code on first contact
|
||||||
|
|
||||||
|
Minimal config:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
enabled: true,
|
||||||
|
appId: "cli_xxxxxxxxxx",
|
||||||
|
appSecret: "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
verificationToken: "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
webhookPath: "/feishu/callback",
|
||||||
|
dmPolicy: "pairing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
Feishu (飞书) is an enterprise collaboration platform by ByteDance, also known as Lark internationally. Its Bot API allows the Gateway to run a bot for 1:1 conversations and group chats.
|
||||||
|
- A Feishu Bot API channel owned by the Gateway
|
||||||
|
- Deterministic routing: replies go back to Feishu; the model never chooses channels
|
||||||
|
- DMs share the agent's main session
|
||||||
|
- Groups require @mention by default
|
||||||
|
|
||||||
|
## Setup (fast path)
|
||||||
|
|
||||||
|
### 1) Create an app in Feishu Open Platform
|
||||||
|
1) Go to **https://open.feishu.cn/app** and sign in
|
||||||
|
2) Click "Create App" and choose "Enterprise Self-built App"
|
||||||
|
3) Fill in the basic information (name, description, icon)
|
||||||
|
4) Add **Bot** capability in "Add Application Capabilities"
|
||||||
|
5) Go to "Credentials and Basic Info" to get your **App ID** and **App Secret**
|
||||||
|
|
||||||
|
### 2) Configure permissions
|
||||||
|
1) Go to "Permission Management" in your app
|
||||||
|
2) Add at least these permissions:
|
||||||
|
- `im:message` - Send messages
|
||||||
|
- `im:message.receive_v1` - Receive messages (event subscription)
|
||||||
|
- `im:chat` - Access chat information
|
||||||
|
- `contact:user.id:readonly` - Read user info (optional, for name display)
|
||||||
|
3) Request approval if required by your organization
|
||||||
|
|
||||||
|
### 3) Configure event subscription
|
||||||
|
1) Go to "Events and Callbacks" page
|
||||||
|
2) Choose "Request URL Mode" (webhook)
|
||||||
|
3) Enter your webhook URL: `https://your-gateway-host/feishu/callback`
|
||||||
|
4) Copy the **Verification Token** and optionally the **Encrypt Key**
|
||||||
|
5) Add event subscription: `im.message.receive_v1` (Receive messages)
|
||||||
|
6) Click Save
|
||||||
|
|
||||||
|
### 4) Configure the token (env or config)
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
enabled: true,
|
||||||
|
appId: "cli_xxxxxxxxxx",
|
||||||
|
appSecret: "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
verificationToken: "xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
encryptKey: "xxxxxxxxxxxxxxxxxxxxxxxx", // optional, for encrypted events
|
||||||
|
webhookPath: "/feishu/callback",
|
||||||
|
dmPolicy: "pairing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Env option (works for the default account only):
|
||||||
|
- `FEISHU_APP_ID=cli_xxxxxxxxxx`
|
||||||
|
- `FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||||
|
- `FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||||
|
- `FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxxxxxxxxxxxx`
|
||||||
|
|
||||||
|
Multi-account support: use `channels.feishu.accounts` with per-account credentials and optional `name`.
|
||||||
|
|
||||||
|
### 5) Publish the app
|
||||||
|
1) Go to "Version Management and Release"
|
||||||
|
2) Create a new version
|
||||||
|
3) Submit for review (or use within your organization if no review required)
|
||||||
|
4) Once approved, the bot is ready to use
|
||||||
|
|
||||||
|
### 6) Start the gateway
|
||||||
|
Restart the gateway. Feishu starts when credentials are resolved.
|
||||||
|
DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||||
|
|
||||||
|
## How it works (behavior)
|
||||||
|
- Inbound messages arrive via webhook (Feishu sends HTTP POST to your configured path)
|
||||||
|
- The gateway verifies the request using the verification token
|
||||||
|
- Messages are normalized into the shared channel envelope
|
||||||
|
- Replies always route back to the same Feishu chat
|
||||||
|
- Long responses are chunked to 4000 characters (Feishu API limit)
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- Outbound text is chunked to 4000 characters (Feishu API limit)
|
||||||
|
- Media downloads/uploads are capped by `channels.feishu.mediaMaxMb` (default 20)
|
||||||
|
- Streaming is blocked by default due to webhook-based architecture
|
||||||
|
- Rate limits apply per the Feishu API documentation
|
||||||
|
|
||||||
|
## Access control (DMs)
|
||||||
|
|
||||||
|
### DM access
|
||||||
|
- Default: `channels.feishu.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour)
|
||||||
|
- Approve via:
|
||||||
|
- `clawdbot pairing list feishu`
|
||||||
|
- `clawdbot pairing approve feishu <CODE>`
|
||||||
|
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||||
|
- `channels.feishu.allowFrom` accepts Feishu user IDs (open_id like `ou_xxx` or user_id)
|
||||||
|
|
||||||
|
### Group access
|
||||||
|
- Default: `channels.feishu.groupPolicy = "allowlist"`. Only groups in the allowlist receive responses
|
||||||
|
- Configure allowed groups via `channels.feishu.groupAllowFrom` or `channels.feishu.groups`
|
||||||
|
- Groups require @mention by default; configure per-group via `channels.feishu.groups.<chat_id>.requireMention`
|
||||||
|
|
||||||
|
## Webhook verification
|
||||||
|
Feishu uses two verification mechanisms:
|
||||||
|
1) **URL Verification**: When you first configure the webhook, Feishu sends a challenge request. The gateway responds automatically.
|
||||||
|
2) **Event Verification**: Each event includes a `token` field that must match your verification token.
|
||||||
|
|
||||||
|
Optional encryption:
|
||||||
|
- If you enable encryption in Feishu console, set `channels.feishu.encryptKey`
|
||||||
|
- Events are encrypted with AES-256-CBC; the gateway decrypts them automatically
|
||||||
|
|
||||||
|
## Supported message types
|
||||||
|
- **Text messages**: Full support with 4000 character chunking
|
||||||
|
- **Image messages**: Logged but media upload requires image_key (planned)
|
||||||
|
- **Rich text (post)**: Planned support
|
||||||
|
- **Interactive cards**: Planned support
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Direct messages | Supported |
|
||||||
|
| Groups | Supported |
|
||||||
|
| Media (images) | Partial (text only for now) |
|
||||||
|
| Reactions | Not supported |
|
||||||
|
| Threads | Not supported |
|
||||||
|
| Polls | Not supported |
|
||||||
|
| Native commands | Not supported |
|
||||||
|
| Streaming | Blocked (webhook-based) |
|
||||||
|
| Rich cards | Planned |
|
||||||
|
|
||||||
|
## Delivery targets (CLI/cron)
|
||||||
|
- Use an open_id, user_id, or chat_id as the target
|
||||||
|
- Example: `clawdbot message send --channel feishu --target ou_xxxxxxxxxx --message "hi"`
|
||||||
|
- For groups: `clawdbot message send --channel feishu --target oc_xxxxxxxxxx --message "hi"`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Bot does not respond:**
|
||||||
|
- Check that the app credentials are valid: `clawdbot channels status --probe`
|
||||||
|
- Verify the webhook URL is correctly configured in Feishu console
|
||||||
|
- Check that the verification token matches
|
||||||
|
- Verify the sender is approved (pairing or allowFrom)
|
||||||
|
- Check gateway logs: `clawdbot logs --follow`
|
||||||
|
|
||||||
|
**Webhook verification fails:**
|
||||||
|
- Ensure your gateway is publicly accessible at the configured webhook path
|
||||||
|
- Check that `channels.feishu.verificationToken` matches the token in Feishu console
|
||||||
|
- If using encryption, verify `channels.feishu.encryptKey` is correct
|
||||||
|
|
||||||
|
**Permission errors:**
|
||||||
|
- Ensure the app has required permissions (`im:message`, `im:message.receive_v1`)
|
||||||
|
- Check if permissions need admin approval in your organization
|
||||||
|
- Verify the app is published and active
|
||||||
|
|
||||||
|
**Cannot send messages:**
|
||||||
|
- Check that the bot has been added to the chat (for groups)
|
||||||
|
- Verify the target ID format (open_id starts with `ou_`, chat_id starts with `oc_`)
|
||||||
|
- Check API quota limits
|
||||||
|
|
||||||
|
## Configuration reference (Feishu)
|
||||||
|
Full configuration: [Configuration](/gateway/configuration)
|
||||||
|
|
||||||
|
Provider options:
|
||||||
|
- `channels.feishu.enabled`: enable/disable channel startup
|
||||||
|
- `channels.feishu.appId`: App ID from Feishu Open Platform
|
||||||
|
- `channels.feishu.appSecret`: App Secret from Feishu Open Platform
|
||||||
|
- `channels.feishu.appSecretFile`: read app secret from file path
|
||||||
|
- `channels.feishu.verificationToken`: verification token for webhook validation
|
||||||
|
- `channels.feishu.encryptKey`: encrypt key for event decryption (optional)
|
||||||
|
- `channels.feishu.webhookPath`: webhook path on the gateway HTTP server (default: /feishu/callback)
|
||||||
|
- `channels.feishu.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
|
- `channels.feishu.allowFrom`: DM allowlist (open_id or user_id). `open` requires `"*"`
|
||||||
|
- `channels.feishu.groupPolicy`: `open | allowlist` (default: allowlist)
|
||||||
|
- `channels.feishu.groupAllowFrom`: group allowlist (chat_id)
|
||||||
|
- `channels.feishu.groups`: per-group configuration
|
||||||
|
- `channels.feishu.mediaMaxMb`: inbound/outbound media cap (MB, default 20)
|
||||||
|
|
||||||
|
Multi-account options:
|
||||||
|
- `channels.feishu.accounts.<id>.appId`: per-account App ID
|
||||||
|
- `channels.feishu.accounts.<id>.appSecret`: per-account App Secret
|
||||||
|
- `channels.feishu.accounts.<id>.appSecretFile`: per-account secret file
|
||||||
|
- `channels.feishu.accounts.<id>.name`: display name
|
||||||
|
- `channels.feishu.accounts.<id>.enabled`: enable/disable account
|
||||||
|
- `channels.feishu.accounts.<id>.dmPolicy`: per-account DM policy
|
||||||
|
- `channels.feishu.accounts.<id>.allowFrom`: per-account allowlist
|
||||||
|
- `channels.feishu.accounts.<id>.verificationToken`: per-account verification token
|
||||||
|
- `channels.feishu.accounts.<id>.encryptKey`: per-account encrypt key
|
||||||
|
- `channels.feishu.accounts.<id>.webhookPath`: per-account webhook path
|
||||||
|
|
||||||
|
## International users (Lark)
|
||||||
|
For Lark (international version), use the same configuration. The API endpoints are compatible.
|
||||||
|
Consider using `lark` or `fs` as channel aliases in CLI commands:
|
||||||
|
- `clawdbot message send --channel lark --target ou_xxx --message "hi"`
|
||||||
@ -29,6 +29,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
|
- [Feishu](/channels/feishu) — Feishu (Lark) Bot API; China's enterprise messenger (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
11
extensions/feishu/clawdbot.plugin.json
Normal file
11
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "feishu",
|
||||||
|
"channels": [
|
||||||
|
"feishu"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 (Lark) channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setFeishuRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: feishuPlugin, dock: feishuDock });
|
||||||
|
api.registerHttpHandler(handleFeishuWebhookRequest);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
36
extensions/feishu/package.json
Normal file
36
extensions/feishu/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/feishu",
|
||||||
|
"version": "2026.1.25",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Feishu (Lark) channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "feishu",
|
||||||
|
"label": "Feishu",
|
||||||
|
"selectionLabel": "Feishu (飞书/Lark)",
|
||||||
|
"docsPath": "/channels/feishu",
|
||||||
|
"docsLabel": "feishu",
|
||||||
|
"blurb": "Enterprise messaging platform with Bot API.",
|
||||||
|
"aliases": [
|
||||||
|
"lark",
|
||||||
|
"fs"
|
||||||
|
],
|
||||||
|
"order": 81,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/feishu",
|
||||||
|
"localPath": "extensions/feishu",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"clawdbot": ">=2026.1.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
extensions/feishu/src/accounts.ts
Normal file
72
extensions/feishu/src/accounts.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { FeishuAccountConfig, FeishuConfig, ResolvedFeishuAccount } from "./types.js";
|
||||||
|
import { resolveFeishuCredentials } from "./token.js";
|
||||||
|
|
||||||
|
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 feishuConfig = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||||
|
if (feishuConfig?.defaultAccount?.trim()) return feishuConfig.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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 credentialResolution = resolveFeishuCredentials(
|
||||||
|
params.cfg.channels?.feishu as FeishuConfig | undefined,
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
name: merged.name?.trim() || undefined,
|
||||||
|
enabled,
|
||||||
|
appId: credentialResolution.appId,
|
||||||
|
appSecret: credentialResolution.appSecret,
|
||||||
|
credentialSource: credentialResolution.source,
|
||||||
|
config: merged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
|
||||||
|
return listFeishuAccountIds(cfg)
|
||||||
|
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
|
||||||
|
.filter((account) => account.enabled);
|
||||||
|
}
|
||||||
287
extensions/feishu/src/api.ts
Normal file
287
extensions/feishu/src/api.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* Feishu Open Platform API client.
|
||||||
|
* @see https://open.feishu.cn/document
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, createDecipheriv } from "node:crypto";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FeishuApiResponse,
|
||||||
|
FeishuBotInfo,
|
||||||
|
FeishuReceiveIdType,
|
||||||
|
FeishuSendMessageParams,
|
||||||
|
FeishuSendMessageResponse,
|
||||||
|
FeishuTokenResponse,
|
||||||
|
FeishuWebhookPayload,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const FEISHU_API_BASE = "https://open.feishu.cn/open-apis";
|
||||||
|
|
||||||
|
export type FeishuFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export class FeishuApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code?: number,
|
||||||
|
public readonly msg?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FeishuApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Token Management
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CachedToken = {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token cache per app_id
|
||||||
|
const tokenCache = new Map<string, CachedToken>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant access token (cached with expiry buffer).
|
||||||
|
*/
|
||||||
|
export async function getTenantAccessToken(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
options?: { timeoutMs?: number; fetch?: FeishuFetch },
|
||||||
|
): Promise<string> {
|
||||||
|
const cacheKey = appId;
|
||||||
|
const cached = tokenCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached token if still valid (with 5 minute buffer)
|
||||||
|
if (cached && cached.expiresAt > now + 5 * 60 * 1000) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = options?.timeoutMs
|
||||||
|
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||||
|
: undefined;
|
||||||
|
const fetcher = options?.fetch ?? fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as FeishuApiResponse<FeishuTokenResponse> & FeishuTokenResponse;
|
||||||
|
|
||||||
|
// Feishu returns token at top level, not in data
|
||||||
|
const token = data.tenant_access_token;
|
||||||
|
const expire = data.expire ?? 7200;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new FeishuApiError(
|
||||||
|
data.msg ?? "Failed to get tenant access token",
|
||||||
|
data.code,
|
||||||
|
data.msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the token
|
||||||
|
tokenCache.set(cacheKey, {
|
||||||
|
token,
|
||||||
|
expiresAt: now + expire * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached token for an app.
|
||||||
|
*/
|
||||||
|
export function clearTokenCache(appId: string): void {
|
||||||
|
tokenCache.delete(appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Calls
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated API call to Feishu.
|
||||||
|
*/
|
||||||
|
export async function callFeishuApi<T = unknown>(
|
||||||
|
endpoint: string,
|
||||||
|
token: string,
|
||||||
|
options?: {
|
||||||
|
method?: string;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
fetch?: FeishuFetch;
|
||||||
|
},
|
||||||
|
): Promise<FeishuApiResponse<T>> {
|
||||||
|
let url = `${FEISHU_API_BASE}${endpoint}`;
|
||||||
|
if (options?.query) {
|
||||||
|
const params = new URLSearchParams(options.query);
|
||||||
|
url = `${url}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = options?.timeoutMs
|
||||||
|
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||||
|
: undefined;
|
||||||
|
const fetcher = options?.fetch ?? fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
method: options?.method ?? "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as FeishuApiResponse<T>;
|
||||||
|
|
||||||
|
if (data.code !== 0) {
|
||||||
|
throw new FeishuApiError(data.msg ?? `Feishu API error: ${endpoint}`, data.code, data.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bot info for validation.
|
||||||
|
*/
|
||||||
|
export async function getBotInfo(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
options?: { timeoutMs?: number; fetch?: FeishuFetch },
|
||||||
|
): Promise<FeishuApiResponse<FeishuBotInfo>> {
|
||||||
|
const token = await getTenantAccessToken(appId, appSecret, options);
|
||||||
|
return callFeishuApi<FeishuBotInfo>("/bot/v3/info", token, {
|
||||||
|
method: "GET",
|
||||||
|
timeoutMs: options?.timeoutMs,
|
||||||
|
fetch: options?.fetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a user or chat.
|
||||||
|
*/
|
||||||
|
export async function sendMessage(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
params: FeishuSendMessageParams,
|
||||||
|
receiveIdType: FeishuReceiveIdType = "open_id",
|
||||||
|
options?: { timeoutMs?: number; fetch?: FeishuFetch },
|
||||||
|
): Promise<FeishuApiResponse<FeishuSendMessageResponse>> {
|
||||||
|
const token = await getTenantAccessToken(appId, appSecret, options);
|
||||||
|
return callFeishuApi<FeishuSendMessageResponse>("/im/v1/messages", token, {
|
||||||
|
method: "POST",
|
||||||
|
query: { receive_id_type: receiveIdType },
|
||||||
|
body: params as unknown as Record<string, unknown>,
|
||||||
|
timeoutMs: options?.timeoutMs,
|
||||||
|
fetch: options?.fetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply to a message.
|
||||||
|
*/
|
||||||
|
export async function replyMessage(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
messageId: string,
|
||||||
|
params: Omit<FeishuSendMessageParams, "receive_id">,
|
||||||
|
options?: { timeoutMs?: number; fetch?: FeishuFetch },
|
||||||
|
): Promise<FeishuApiResponse<FeishuSendMessageResponse>> {
|
||||||
|
const token = await getTenantAccessToken(appId, appSecret, options);
|
||||||
|
return callFeishuApi<FeishuSendMessageResponse>(`/im/v1/messages/${messageId}/reply`, token, {
|
||||||
|
method: "POST",
|
||||||
|
body: params as unknown as Record<string, unknown>,
|
||||||
|
timeoutMs: options?.timeoutMs,
|
||||||
|
fetch: options?.fetch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook Verification
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an encrypted webhook payload using the encrypt key.
|
||||||
|
* Feishu uses AES-256-CBC with the key derived from SHA256 of the encrypt key.
|
||||||
|
*/
|
||||||
|
export function decryptWebhookPayload(encrypted: string, encryptKey: string): string {
|
||||||
|
// Derive key from encrypt key using SHA256
|
||||||
|
const key = createHash("sha256").update(encryptKey).digest();
|
||||||
|
|
||||||
|
// Decode base64
|
||||||
|
const ciphertext = Buffer.from(encrypted, "base64");
|
||||||
|
|
||||||
|
// First 16 bytes are the IV
|
||||||
|
const iv = ciphertext.subarray(0, 16);
|
||||||
|
const encryptedData = ciphertext.subarray(16);
|
||||||
|
|
||||||
|
// Decrypt using AES-256-CBC
|
||||||
|
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||||
|
let decrypted = decipher.update(encryptedData);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature using verification token.
|
||||||
|
*/
|
||||||
|
export function verifyWebhookToken(payload: FeishuWebhookPayload, verificationToken: string): boolean {
|
||||||
|
// For challenge verification, token is at top level
|
||||||
|
if (payload.token) {
|
||||||
|
return payload.token === verificationToken;
|
||||||
|
}
|
||||||
|
// For events, token is in header
|
||||||
|
if (payload.header?.token) {
|
||||||
|
return payload.header.token === verificationToken;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse webhook payload, decrypting if necessary.
|
||||||
|
*/
|
||||||
|
export function parseWebhookPayload(
|
||||||
|
rawBody: string,
|
||||||
|
encryptKey?: string,
|
||||||
|
): FeishuWebhookPayload | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawBody) as FeishuWebhookPayload;
|
||||||
|
|
||||||
|
// If encrypted, decrypt first
|
||||||
|
if (parsed.encrypt && encryptKey) {
|
||||||
|
const decrypted = decryptWebhookPayload(parsed.encrypt, encryptKey);
|
||||||
|
return JSON.parse(decrypted) as FeishuWebhookPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build challenge response for URL verification.
|
||||||
|
*/
|
||||||
|
export function buildChallengeResponse(challenge: string): string {
|
||||||
|
return JSON.stringify({ challenge });
|
||||||
|
}
|
||||||
412
extensions/feishu/src/channel.ts
Normal file
412
extensions/feishu/src/channel.ts
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelDock,
|
||||||
|
ChannelPlugin,
|
||||||
|
ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listFeishuAccountIds,
|
||||||
|
resolveDefaultFeishuAccountId,
|
||||||
|
resolveFeishuAccount,
|
||||||
|
type ResolvedFeishuAccount,
|
||||||
|
} from "./accounts.js";
|
||||||
|
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { probeFeishu } from "./probe.js";
|
||||||
|
import { sendMessageFeishu } from "./send.js";
|
||||||
|
import { collectFeishuStatusIssues } from "./status-issues.js";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id: "feishu",
|
||||||
|
label: "Feishu",
|
||||||
|
selectionLabel: "Feishu (飞书/Lark)",
|
||||||
|
docsPath: "/channels/feishu",
|
||||||
|
docsLabel: "feishu",
|
||||||
|
blurb: "Enterprise messaging platform with Bot API.",
|
||||||
|
aliases: ["lark", "fs"],
|
||||||
|
order: 81,
|
||||||
|
quickstartAllowFrom: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeFeishuMessagingTarget(raw: string): string | undefined {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
return trimmed.replace(/^(feishu|lark|fs):/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feishuDock: ChannelDock = {
|
||||||
|
id: "feishu",
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: true,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
outbound: { textChunkLimit: 4000 },
|
||||||
|
config: {
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^(feishu|lark|fs):/i, ""))
|
||||||
|
.map((entry) => entry.toLowerCase()),
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: () => true,
|
||||||
|
},
|
||||||
|
threading: {
|
||||||
|
resolveReplyToMode: () => "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||||
|
id: "feishu",
|
||||||
|
meta,
|
||||||
|
onboarding: feishuOnboardingAdapter,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
media: true,
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
polls: false,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.feishu"] },
|
||||||
|
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", "appSecretFile", "encryptKey", "verificationToken", "name"],
|
||||||
|
}),
|
||||||
|
isConfigured: (account) => Boolean(account.appId?.trim() && account.appSecret?.trim()),
|
||||||
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: Boolean(account.appId?.trim() && account.appSecret?.trim()),
|
||||||
|
credentialSource: account.credentialSource,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.replace(/^(feishu|lark|fs):/i, ""))
|
||||||
|
.map((entry) => entry.toLowerCase()),
|
||||||
|
},
|
||||||
|
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.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: formatPairingApproveHint("feishu"),
|
||||||
|
normalizeEntry: (raw) => raw.replace(/^(feishu|lark|fs):/i, ""),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: () => true,
|
||||||
|
},
|
||||||
|
threading: {
|
||||||
|
resolveReplyToMode: () => "off",
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: normalizeFeishuMessagingTarget,
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (raw) => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
// Feishu IDs: oc_ (chat), ou_ (open_id), on_ (union_id)
|
||||||
|
return /^(oc_|ou_|on_)[a-zA-Z0-9]+$/.test(trimmed);
|
||||||
|
},
|
||||||
|
hint: "<open_id|user_id|chat_id>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
self: async () => null,
|
||||||
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||||
|
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
|
const q = query?.trim().toLowerCase() || "";
|
||||||
|
const peers = Array.from(
|
||||||
|
new Set(
|
||||||
|
(account.config.allowFrom ?? [])
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||||
|
.map((entry) => entry.replace(/^(feishu|lark|fs):/i, "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||||
|
.map((id) => ({ kind: "user", id }) as const);
|
||||||
|
return peers;
|
||||||
|
},
|
||||||
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||||
|
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
|
const groups = account.config.groups ?? {};
|
||||||
|
const q = query?.trim().toLowerCase() || "";
|
||||||
|
const entries = Object.keys(groups)
|
||||||
|
.filter((key) => key && key !== "*")
|
||||||
|
.filter((key) => (q ? key.toLowerCase().includes(q) : true))
|
||||||
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||||
|
.map((id) => ({ kind: "group", id }) as const);
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "feishu",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
validateInput: ({ accountId, input }) => {
|
||||||
|
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
|
return "FEISHU_APP_ID/FEISHU_APP_SECRET can only be used for the default account.";
|
||||||
|
}
|
||||||
|
if (!input.useEnv && !input.appId) {
|
||||||
|
return "Feishu requires appId (or --use-env).";
|
||||||
|
}
|
||||||
|
if (!input.useEnv && !input.appSecret && !input.appSecretFile) {
|
||||||
|
return "Feishu requires appSecret or --secret-file (or --use-env).";
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
channelKey: "feishu",
|
||||||
|
})
|
||||||
|
: namedConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
...(input.useEnv
|
||||||
|
? {}
|
||||||
|
: input.appSecretFile
|
||||||
|
? { appId: input.appId, appSecretFile: input.appSecretFile }
|
||||||
|
: input.appSecret
|
||||||
|
? { appId: input.appId, appSecret: input.appSecret }
|
||||||
|
: {}),
|
||||||
|
...(input.encryptKey ? { encryptKey: input.encryptKey } : {}),
|
||||||
|
...(input.verificationToken ? { verificationToken: input.verificationToken } : {}),
|
||||||
|
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} 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,
|
||||||
|
...(input.appSecretFile
|
||||||
|
? { appId: input.appId, appSecretFile: input.appSecretFile }
|
||||||
|
: input.appSecret
|
||||||
|
? { appId: input.appId, appSecret: input.appSecret }
|
||||||
|
: {}),
|
||||||
|
...(input.encryptKey ? { encryptKey: input.encryptKey } : {}),
|
||||||
|
...(input.verificationToken ? { verificationToken: input.verificationToken } : {}),
|
||||||
|
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "feishuUserId",
|
||||||
|
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|lark|fs):/i, ""),
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig });
|
||||||
|
if (!account.appId || !account.appSecret) throw new Error("Feishu credentials not configured");
|
||||||
|
await sendMessageFeishu(id, PAIRING_APPROVED_MESSAGE, {
|
||||||
|
appId: account.appId,
|
||||||
|
appSecret: account.appSecret,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: (text, limit) => {
|
||||||
|
if (!text) return [];
|
||||||
|
if (limit <= 0 || text.length <= limit) return [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
while (remaining.length > limit) {
|
||||||
|
const window = remaining.slice(0, limit);
|
||||||
|
const lastNewline = window.lastIndexOf("\n");
|
||||||
|
const lastSpace = window.lastIndexOf(" ");
|
||||||
|
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||||
|
if (breakIdx <= 0) breakIdx = limit;
|
||||||
|
const rawChunk = remaining.slice(0, breakIdx);
|
||||||
|
const chunk = rawChunk.trimEnd();
|
||||||
|
if (chunk.length > 0) chunks.push(chunk);
|
||||||
|
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||||
|
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||||
|
remaining = remaining.slice(nextStart).trimStart();
|
||||||
|
}
|
||||||
|
if (remaining.length) chunks.push(remaining);
|
||||||
|
return chunks;
|
||||||
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
|
const result = await sendMessageFeishu(to, text, {
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel: "feishu",
|
||||||
|
ok: result.ok,
|
||||||
|
messageId: result.messageId ?? "",
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||||
|
// For now, just send text with media URL as link
|
||||||
|
// Full media upload requires /im/v1/images endpoint
|
||||||
|
const messageText = mediaUrl ? `${text}\n\n${mediaUrl}` : text;
|
||||||
|
const result = await sendMessageFeishu(to, messageText, {
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel: "feishu",
|
||||||
|
ok: result.ok,
|
||||||
|
messageId: result.messageId ?? "",
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: collectFeishuStatusIssues,
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
credentialSource: snapshot.credentialSource ?? "none",
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
probeFeishu(account.appId, account.appSecret, timeoutMs),
|
||||||
|
buildAccountSnapshot: ({ account, runtime }) => {
|
||||||
|
const configured = Boolean(account.appId?.trim() && account.appSecret?.trim());
|
||||||
|
return {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured,
|
||||||
|
credentialSource: account.credentialSource,
|
||||||
|
verificationToken: account.config.verificationToken ?? undefined,
|
||||||
|
webhookPath: account.config.webhookPath ?? undefined,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
let feishuBotLabel = "";
|
||||||
|
try {
|
||||||
|
const probe = await probeFeishu(account.appId, account.appSecret, 2500);
|
||||||
|
const name = probe.ok ? probe.bot?.app_name?.trim() : null;
|
||||||
|
if (name) feishuBotLabel = ` (${name})`;
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
bot: probe.bot,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore probe errors
|
||||||
|
}
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting provider${feishuBotLabel}`);
|
||||||
|
const { monitorFeishuProvider } = await import("./monitor.js");
|
||||||
|
return monitorFeishuProvider({
|
||||||
|
account,
|
||||||
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
webhookPath: account.config.webhookPath,
|
||||||
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
31
extensions/feishu/src/config-schema.ts
Normal file
31
extensions/feishu/src/config-schema.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// Feishu config schema - type-based (no zod for runtime simplicity)
|
||||||
|
// The config is validated at the plugin-sdk level using the clawdbot.plugin.json schema
|
||||||
|
|
||||||
|
export type FeishuGroupConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
allowFrom?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuAccountConfig = {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
appSecretFile?: string;
|
||||||
|
encryptKey?: string;
|
||||||
|
verificationToken?: string;
|
||||||
|
webhookPath?: string;
|
||||||
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
allowFrom?: string[];
|
||||||
|
groupPolicy?: "open" | "allowlist";
|
||||||
|
groupAllowFrom?: string[];
|
||||||
|
groups?: Record<string, FeishuGroupConfig>;
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuConfig = FeishuAccountConfig & {
|
||||||
|
accounts?: Record<string, FeishuAccountConfig>;
|
||||||
|
defaultAccount?: string;
|
||||||
|
};
|
||||||
617
extensions/feishu/src/monitor.ts
Normal file
617
extensions/feishu/src/monitor.ts
Normal file
@ -0,0 +1,617 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FeishuMessageEvent,
|
||||||
|
FeishuReceiveIdType,
|
||||||
|
FeishuWebhookPayload,
|
||||||
|
ResolvedFeishuAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
import {
|
||||||
|
buildChallengeResponse,
|
||||||
|
parseWebhookPayload,
|
||||||
|
sendMessage,
|
||||||
|
verifyWebhookToken,
|
||||||
|
} from "./api.js";
|
||||||
|
import { getFeishuRuntime } from "./runtime.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;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuMonitorResult = {
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEISHU_TEXT_LIMIT = 4000;
|
||||||
|
const DEFAULT_MEDIA_MAX_MB = 20;
|
||||||
|
const DEFAULT_WEBHOOK_PATH = "/feishu/callback";
|
||||||
|
|
||||||
|
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
|
||||||
|
|
||||||
|
function logVerbose(core: FeishuCoreRuntime, runtime: FeishuRuntimeEnv, message: string): void {
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
runtime.log?.(`[feishu] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWebhookEvent(message: string): void {
|
||||||
|
// Use console to ensure visibility even when runtime isn't available.
|
||||||
|
console.warn(`[feishu] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuReplyTarget = {
|
||||||
|
id: string;
|
||||||
|
type: FeishuReceiveIdType;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSenderTarget(event: FeishuMessageEvent): FeishuReplyTarget | null {
|
||||||
|
const senderId = event.sender.sender_id;
|
||||||
|
if (senderId.open_id) return { id: senderId.open_id, type: "open_id" };
|
||||||
|
if (senderId.user_id) return { id: senderId.user_id, type: "user_id" };
|
||||||
|
if (senderId.union_id) return { id: senderId.union_id, type: "union_id" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
||||||
|
if (allowFrom.includes("*")) return true;
|
||||||
|
const normalizedSenderId = senderId.toLowerCase();
|
||||||
|
return allowFrom.some((entry) => {
|
||||||
|
const normalized = entry.toLowerCase().replace(/^(feishu|lark|fs):/i, "");
|
||||||
|
return normalized === normalizedSenderId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
return await new Promise<{ ok: boolean; value?: string; error?: string }>((resolve) => {
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
resolve({ ok: false, error: "payload too large" });
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8");
|
||||||
|
if (!raw.trim()) {
|
||||||
|
resolve({ ok: false, error: "empty payload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ ok: true, value: raw });
|
||||||
|
} catch (err) {
|
||||||
|
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", (err) => {
|
||||||
|
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookTarget = {
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
core: FeishuCoreRuntime;
|
||||||
|
path: string;
|
||||||
|
encryptKey: string;
|
||||||
|
verificationToken: string;
|
||||||
|
mediaMaxMb: number;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||||
|
|
||||||
|
function normalizeWebhookPath(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "/feishu/callback";
|
||||||
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||||
|
return withSlash.slice(0, -1);
|
||||||
|
}
|
||||||
|
return withSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming Feishu webhook requests.
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
if (path === DEFAULT_WEBHOOK_PATH || path.startsWith("/feishu/")) {
|
||||||
|
logWebhookEvent(
|
||||||
|
`webhook request received for ${path}, but no active Feishu accounts are registered`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
res.end("Method Not Allowed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(req, 1024 * 1024);
|
||||||
|
if (!body.ok || !body.value) {
|
||||||
|
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
||||||
|
res.end(body.error ?? "invalid payload");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawPayload: FeishuWebhookPayload | null = null;
|
||||||
|
try {
|
||||||
|
rawPayload = JSON.parse(body.value) as FeishuWebhookPayload;
|
||||||
|
} catch {
|
||||||
|
rawPayload = null;
|
||||||
|
}
|
||||||
|
const isEncryptedRequest = Boolean(rawPayload?.encrypt);
|
||||||
|
const rawEventType = rawPayload?.header?.event_type ?? rawPayload?.type ?? "unknown";
|
||||||
|
logWebhookEvent(
|
||||||
|
`webhook hit: path=${path} method=${req.method} targets=${targets.length} encrypted=${isEncryptedRequest} event=${rawEventType}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let sawTokenMismatch = false;
|
||||||
|
let sawEncryptedWithoutKey = false;
|
||||||
|
let sawDecryptFailure = false;
|
||||||
|
|
||||||
|
// Try each target to find one that can handle this request
|
||||||
|
for (const target of targets) {
|
||||||
|
const payload = parseWebhookPayload(body.value, target.encryptKey);
|
||||||
|
if (!payload) {
|
||||||
|
if (isEncryptedRequest && target.encryptKey) {
|
||||||
|
sawDecryptFailure = true;
|
||||||
|
target.runtime.error?.(
|
||||||
|
`[${target.account.accountId}] Feishu webhook decrypt failed. Check encryptKey.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.encrypt && !target.encryptKey) {
|
||||||
|
sawEncryptedWithoutKey = true;
|
||||||
|
target.runtime.error?.(
|
||||||
|
`[${target.account.accountId}] Feishu webhook is encrypted but encryptKey is not configured.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token if configured
|
||||||
|
if (target.verificationToken && !verifyWebhookToken(payload, target.verificationToken)) {
|
||||||
|
sawTokenMismatch = true;
|
||||||
|
target.runtime.error?.(
|
||||||
|
`[${target.account.accountId}] Feishu webhook token mismatch. Check verificationToken.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL verification challenge
|
||||||
|
if (payload.type === "url_verification" && payload.challenge) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(buildChallengeResponse(payload.challenge));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle message events
|
||||||
|
if (payload.header?.event_type === "im.message.receive_v1" && payload.event) {
|
||||||
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
|
processMessageEvent(
|
||||||
|
payload.event as FeishuMessageEvent,
|
||||||
|
target.account,
|
||||||
|
target.config,
|
||||||
|
target.runtime,
|
||||||
|
target.core,
|
||||||
|
target.mediaMaxMb,
|
||||||
|
target.statusSink,
|
||||||
|
).catch((err) => {
|
||||||
|
target.runtime.error?.(`[${target.account.accountId}] Feishu webhook failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end("ok");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other event types - acknowledge but don't process
|
||||||
|
if (payload.header?.event_type) {
|
||||||
|
target.runtime.log?.(`[feishu] ignoring event type: ${payload.header.event_type}`);
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end("ok");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No target could handle the request
|
||||||
|
if (sawEncryptedWithoutKey) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end("encrypt_key required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sawDecryptFailure) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end("decrypt failed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sawTokenMismatch) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.end("unauthorized");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.end("unauthorized");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a message event from Feishu webhook.
|
||||||
|
*/
|
||||||
|
async function processMessageEvent(
|
||||||
|
event: FeishuMessageEvent,
|
||||||
|
account: ResolvedFeishuAccount,
|
||||||
|
config: ClawdbotConfig,
|
||||||
|
runtime: FeishuRuntimeEnv,
|
||||||
|
core: FeishuCoreRuntime,
|
||||||
|
mediaMaxMb: number,
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sender, message } = event;
|
||||||
|
|
||||||
|
// Parse message content
|
||||||
|
let textContent = "";
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(message.content);
|
||||||
|
textContent = content.text ?? "";
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textContent.trim()) return;
|
||||||
|
|
||||||
|
const isGroup = message.chat_type === "group";
|
||||||
|
const chatId = message.chat_id;
|
||||||
|
const senderTarget = resolveSenderTarget(event);
|
||||||
|
if (!senderTarget) {
|
||||||
|
logVerbose(core, runtime, "unable to resolve sender id for feishu message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const senderId = senderTarget.id;
|
||||||
|
const senderName = sender.sender_type === "user" ? "User" : sender.sender_type;
|
||||||
|
const messageId = message.message_id;
|
||||||
|
const replyTarget: FeishuReplyTarget = isGroup
|
||||||
|
? { id: chatId, type: "chat_id" }
|
||||||
|
: senderTarget;
|
||||||
|
runtime.log?.(
|
||||||
|
`[feishu] inbound message id=${messageId} chat=${message.chat_type} sender=${senderId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check DM policy
|
||||||
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
|
const configAllowFrom = account.config.allowFrom ?? [];
|
||||||
|
const rawBody = textContent.trim();
|
||||||
|
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 useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||||
|
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||||
|
const commandAuthorized = shouldComputeAuth
|
||||||
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
|
useAccessGroups,
|
||||||
|
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!isGroup) {
|
||||||
|
if (dmPolicy === "disabled") {
|
||||||
|
logVerbose(core, runtime, `Blocked feishu DM from ${senderId} (dmPolicy=disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
const allowed = senderAllowedForCommands;
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||||
|
channel: "feishu",
|
||||||
|
id: senderId,
|
||||||
|
meta: { name: senderName ?? undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
logVerbose(core, runtime, `feishu pairing request sender=${senderId}`);
|
||||||
|
try {
|
||||||
|
const pairingReply = core.channel.pairing.buildPairingReply({
|
||||||
|
channel: "feishu",
|
||||||
|
idLine: `Your Feishu user id: ${senderId}`,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
await sendMessage(
|
||||||
|
account.appId,
|
||||||
|
account.appSecret,
|
||||||
|
{
|
||||||
|
receive_id: senderId,
|
||||||
|
msg_type: "text",
|
||||||
|
content: JSON.stringify({ text: pairingReply }),
|
||||||
|
},
|
||||||
|
senderTarget.type,
|
||||||
|
);
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`feishu pairing reply failed for ${senderId}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`Blocked unauthorized feishu sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check group policy
|
||||||
|
if (isGroup) {
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||||
|
const groupAllowFrom = account.config.groupAllowFrom ?? [];
|
||||||
|
const groupConfig = account.config.groups?.[chatId];
|
||||||
|
|
||||||
|
if (groupPolicy === "allowlist") {
|
||||||
|
const groupAllowed = groupAllowFrom.includes("*") || groupAllowFrom.includes(chatId);
|
||||||
|
const groupEnabled = groupConfig?.enabled !== false;
|
||||||
|
if (!groupAllowed && !groupEnabled) {
|
||||||
|
logVerbose(core, runtime, `Blocked feishu group ${chatId} (not in allowlist)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mention is required
|
||||||
|
const requireMention = groupConfig?.requireMention !== false;
|
||||||
|
if (requireMention) {
|
||||||
|
const hasMention = message.mentions?.some(
|
||||||
|
(m) => m.id.open_id === account.appId || m.name === "@_all",
|
||||||
|
);
|
||||||
|
if (!hasMention) {
|
||||||
|
logVerbose(core, runtime, `Ignored feishu group message (no mention)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg: config,
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: isGroup ? "group" : "dm",
|
||||||
|
id: chatId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isGroup &&
|
||||||
|
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||||
|
commandAuthorized !== true
|
||||||
|
) {
|
||||||
|
logVerbose(core, runtime, `feishu: drop control command from unauthorized sender ${senderId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
|
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: message.create_time ? parseInt(message.create_time, 10) : undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: rawBody,
|
||||||
|
CommandBody: rawBody,
|
||||||
|
From: isGroup ? `feishu:group:${chatId}` : `feishu:${senderId}`,
|
||||||
|
To: `feishu:${chatId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: isGroup ? "group" : "direct",
|
||||||
|
ConversationLabel: fromLabel,
|
||||||
|
SenderName: senderName || undefined,
|
||||||
|
SenderId: senderId,
|
||||||
|
CommandAuthorized: commandAuthorized,
|
||||||
|
Provider: "feishu",
|
||||||
|
Surface: "feishu",
|
||||||
|
MessageSid: messageId,
|
||||||
|
OriginatingChannel: "feishu",
|
||||||
|
OriginatingTo: `feishu:${chatId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.session.recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
runtime.error?.(`feishu: failed updating session meta: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: config,
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: config,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverFeishuReply({
|
||||||
|
payload,
|
||||||
|
account,
|
||||||
|
receiveId: replyTarget.id,
|
||||||
|
receiveIdType: replyTarget.type,
|
||||||
|
runtime,
|
||||||
|
core,
|
||||||
|
config,
|
||||||
|
statusSink,
|
||||||
|
tableMode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
runtime.error?.(`[${account.accountId}] Feishu ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverFeishuReply(params: {
|
||||||
|
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||||
|
account: ResolvedFeishuAccount;
|
||||||
|
receiveId: string;
|
||||||
|
receiveIdType: FeishuReceiveIdType;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
core: FeishuCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { payload, account, receiveId, receiveIdType, runtime, core, config, statusSink } = params;
|
||||||
|
const tableMode = params.tableMode ?? "code";
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(config, "feishu", account.accountId);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
FEISHU_TEXT_LIMIT,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await sendMessage(
|
||||||
|
account.appId,
|
||||||
|
account.appSecret,
|
||||||
|
{
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: "text",
|
||||||
|
content: JSON.stringify({ text: chunk }),
|
||||||
|
},
|
||||||
|
receiveIdType,
|
||||||
|
);
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`[feishu] message send failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring Feishu webhook events.
|
||||||
|
*/
|
||||||
|
export async function monitorFeishuProvider(
|
||||||
|
options: FeishuMonitorOptions,
|
||||||
|
): Promise<FeishuMonitorResult> {
|
||||||
|
const { account, config, runtime, abortSignal, webhookPath, statusSink } = options;
|
||||||
|
|
||||||
|
const core = getFeishuRuntime();
|
||||||
|
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||||
|
const path = normalizeWebhookPath(webhookPath ?? account.config.webhookPath ?? "/feishu/callback");
|
||||||
|
|
||||||
|
const encryptKey = account.config.encryptKey ?? process.env.FEISHU_ENCRYPT_KEY ?? "";
|
||||||
|
const verificationToken = account.config.verificationToken ?? process.env.FEISHU_VERIFICATION_TOKEN ?? "";
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stopHandlers: Array<() => void> = [];
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
for (const handler of stopHandlers) {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register webhook target
|
||||||
|
const unregister = registerFeishuWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime,
|
||||||
|
core,
|
||||||
|
path,
|
||||||
|
encryptKey,
|
||||||
|
verificationToken,
|
||||||
|
mediaMaxMb: effectiveMediaMaxMb,
|
||||||
|
statusSink,
|
||||||
|
});
|
||||||
|
stopHandlers.push(unregister);
|
||||||
|
|
||||||
|
runtime.log?.(
|
||||||
|
`[feishu] webhook ready: path=${path} encrypted=${Boolean(encryptKey)} token=${Boolean(
|
||||||
|
verificationToken,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
abortSignal.addEventListener("abort", stop, { once: true });
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
363
extensions/feishu/src/onboarding.ts
Normal file
363
extensions/feishu/src/onboarding.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import type {
|
||||||
|
ChannelOnboardingAdapter,
|
||||||
|
ChannelOnboardingDmPolicy,
|
||||||
|
ClawdbotConfig,
|
||||||
|
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,
|
||||||
|
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||||
|
) {
|
||||||
|
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
dmPolicy,
|
||||||
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noteFeishuCredentialsHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"1) Open Feishu Open Platform: https://open.feishu.cn/app",
|
||||||
|
"2) Create an enterprise self-built application",
|
||||||
|
"3) Add bot capability and configure permissions",
|
||||||
|
"4) Get App ID and App Secret from Credentials page",
|
||||||
|
"5) Configure event subscription with your webhook URL",
|
||||||
|
"Tip: you can also set FEISHU_APP_ID and FEISHU_APP_SECRET in your env.",
|
||||||
|
"Docs: https://docs.clawd.bot/channels/feishu",
|
||||||
|
].join("\n"),
|
||||||
|
"Feishu app credentials",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptFeishuAllowFrom(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
accountId: string;
|
||||||
|
}): Promise<ClawdbotConfig> {
|
||||||
|
const { cfg, prompter, accountId } = params;
|
||||||
|
const resolved = resolveFeishuAccount({ cfg, accountId });
|
||||||
|
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||||
|
const entry = await prompter.text({
|
||||||
|
message: "Feishu allowFrom (open_id or user_id)",
|
||||||
|
placeholder: "ou_xxxxxxxxxx",
|
||||||
|
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||||
|
validate: (value) => {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) return "Required";
|
||||||
|
// Accept ou_, on_, or alphanumeric user_id
|
||||||
|
if (!/^(ou_|on_)?[a-zA-Z0-9]+$/.test(raw)) return "Use a valid Feishu user id";
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const normalized = String(entry).trim();
|
||||||
|
const merged = [
|
||||||
|
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||||
|
normalized,
|
||||||
|
];
|
||||||
|
const unique = [...new Set(merged)];
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: unique,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} 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: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true,
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: unique,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||||
|
label: "Feishu",
|
||||||
|
channel,
|
||||||
|
policyKey: "channels.feishu.dmPolicy",
|
||||||
|
allowFromKey: "channels.feishu.allowFrom",
|
||||||
|
getCurrent: (cfg) => (cfg.channels?.feishu?.dmPolicy ?? "pairing") as "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 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 Boolean(account.appId && account.appSecret);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
configured,
|
||||||
|
statusLines: [`Feishu: ${configured ? "configured" : "needs credentials"}`],
|
||||||
|
selectionHint: configured ? "recommended · configured" : "recommended · enterprise-ready",
|
||||||
|
quickstartScore: configured ? 1 : 8,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||||
|
const feishuOverride = accountOverrides.feishu?.trim();
|
||||||
|
const defaultFeishuAccountId = resolveDefaultFeishuAccountId(cfg as ClawdbotConfig);
|
||||||
|
let feishuAccountId = feishuOverride
|
||||||
|
? normalizeAccountId(feishuOverride)
|
||||||
|
: defaultFeishuAccountId;
|
||||||
|
if (shouldPromptAccountIds && !feishuOverride) {
|
||||||
|
feishuAccountId = await promptAccountId({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
prompter,
|
||||||
|
label: "Feishu",
|
||||||
|
currentId: feishuAccountId,
|
||||||
|
listAccountIds: listFeishuAccountIds,
|
||||||
|
defaultAccountId: defaultFeishuAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = cfg as ClawdbotConfig;
|
||||||
|
const resolvedAccount = resolveFeishuAccount({ cfg: next, accountId: feishuAccountId });
|
||||||
|
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.appSecret);
|
||||||
|
const allowEnv = feishuAccountId === DEFAULT_ACCOUNT_ID;
|
||||||
|
const canUseEnv =
|
||||||
|
allowEnv &&
|
||||||
|
Boolean(process.env.FEISHU_APP_ID?.trim()) &&
|
||||||
|
Boolean(process.env.FEISHU_APP_SECRET?.trim());
|
||||||
|
const hasConfigCredentials = Boolean(
|
||||||
|
resolvedAccount.config.appId && (resolvedAccount.config.appSecret || resolvedAccount.config.appSecretFile),
|
||||||
|
);
|
||||||
|
|
||||||
|
let appId: string | null = null;
|
||||||
|
let appSecret: string | null = null;
|
||||||
|
|
||||||
|
if (!accountConfigured) {
|
||||||
|
await noteFeishuCredentialsHelp(prompter);
|
||||||
|
}
|
||||||
|
if (canUseEnv && !resolvedAccount.config.appId) {
|
||||||
|
const keepEnv = await prompter.confirm({
|
||||||
|
message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (keepEnv) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
} else {
|
||||||
|
appId = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App ID",
|
||||||
|
placeholder: "cli_xxxxxxxxxx",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
appSecret = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App Secret",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
} else if (hasConfigCredentials) {
|
||||||
|
const keep = await prompter.confirm({
|
||||||
|
message: "Feishu credentials already configured. Keep them?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!keep) {
|
||||||
|
appId = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App ID",
|
||||||
|
placeholder: "cli_xxxxxxxxxx",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
appSecret = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App Secret",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appId = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App ID",
|
||||||
|
placeholder: "cli_xxxxxxxxxx",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
appSecret = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Enter Feishu App Secret",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appId && appSecret) {
|
||||||
|
if (feishuAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
} else {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.feishu?.accounts ?? {}),
|
||||||
|
[feishuAccountId]: {
|
||||||
|
...(next.channels?.feishu?.accounts?.[feishuAccountId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for webhook configuration
|
||||||
|
const wantsWebhook = await prompter.confirm({
|
||||||
|
message: "Configure webhook settings? (needed for receiving messages)",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (wantsWebhook) {
|
||||||
|
const encryptKey = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Encrypt Key (from Events and Callbacks page, optional)",
|
||||||
|
placeholder: "Leave empty if not using encryption",
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
const verificationToken = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Verification Token (from Events and Callbacks page)",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required for webhook verification"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
const webhookPath = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Webhook path on gateway",
|
||||||
|
initialValue: "/feishu/callback",
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (feishuAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
...(encryptKey ? { encryptKey } : {}),
|
||||||
|
...(verificationToken ? { verificationToken } : {}),
|
||||||
|
...(webhookPath ? { webhookPath } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
} else {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
feishu: {
|
||||||
|
...next.channels?.feishu,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.feishu?.accounts ?? {}),
|
||||||
|
[feishuAccountId]: {
|
||||||
|
...(next.channels?.feishu?.accounts?.[feishuAccountId] ?? {}),
|
||||||
|
...(encryptKey ? { encryptKey } : {}),
|
||||||
|
...(verificationToken ? { verificationToken } : {}),
|
||||||
|
...(webhookPath ? { webhookPath } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceAllowFrom) {
|
||||||
|
next = await promptFeishuAllowFrom({
|
||||||
|
cfg: next,
|
||||||
|
prompter,
|
||||||
|
accountId: feishuAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cfg: next, accountId: feishuAccountId };
|
||||||
|
},
|
||||||
|
};
|
||||||
51
extensions/feishu/src/probe.ts
Normal file
51
extensions/feishu/src/probe.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { getBotInfo, FeishuApiError, type FeishuFetch } from "./api.js";
|
||||||
|
import type { FeishuBotInfo } from "./types.js";
|
||||||
|
|
||||||
|
export type FeishuProbeResult = {
|
||||||
|
ok: boolean;
|
||||||
|
bot?: FeishuBotInfo;
|
||||||
|
error?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe Feishu API to verify credentials and get bot info.
|
||||||
|
*/
|
||||||
|
export async function probeFeishu(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
fetcher?: FeishuFetch,
|
||||||
|
): Promise<FeishuProbeResult> {
|
||||||
|
if (!appId?.trim() || !appSecret?.trim()) {
|
||||||
|
return { ok: false, error: "No credentials provided", elapsedMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getBotInfo(appId.trim(), appSecret.trim(), { timeoutMs, fetch: fetcher });
|
||||||
|
const elapsedMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
return { ok: true, bot: response.data, elapsedMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: response.msg ?? "Invalid response from Feishu API", elapsedMs };
|
||||||
|
} catch (err) {
|
||||||
|
const elapsedMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (err instanceof FeishuApiError) {
|
||||||
|
return { ok: false, error: err.msg ?? err.message, elapsedMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
|
||||||
|
}
|
||||||
|
return { ok: false, error: err.message, elapsedMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: String(err), elapsedMs };
|
||||||
|
}
|
||||||
|
}
|
||||||
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): void {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeishuRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Feishu runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
167
extensions/feishu/src/send.ts
Normal file
167
extensions/feishu/src/send.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
|
import { sendMessage, replyMessage, type FeishuFetch } from "./api.js";
|
||||||
|
import type { FeishuReceiveIdType } from "./types.js";
|
||||||
|
|
||||||
|
export type FeishuSendOptions = {
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
accountId?: string;
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
receiveIdType?: FeishuReceiveIdType;
|
||||||
|
replyToMessageId?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
fetch?: FeishuFetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuSendResult = {
|
||||||
|
ok: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSendContext(options: FeishuSendOptions): {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
fetcher?: FeishuFetch;
|
||||||
|
} {
|
||||||
|
if (options.cfg) {
|
||||||
|
const account = resolveFeishuAccount({
|
||||||
|
cfg: options.cfg,
|
||||||
|
accountId: options.accountId,
|
||||||
|
});
|
||||||
|
const appId = options.appId || account.appId;
|
||||||
|
const appSecret = options.appSecret || account.appSecret;
|
||||||
|
return { appId, appSecret, fetcher: options.fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
const appId = options.appId ?? "";
|
||||||
|
const appSecret = options.appSecret ?? "";
|
||||||
|
return { appId, appSecret, fetcher: options.fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the receive_id_type based on the target format.
|
||||||
|
*/
|
||||||
|
function inferReceiveIdType(target: string): FeishuReceiveIdType {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
// chat_id starts with "oc_"
|
||||||
|
if (trimmed.startsWith("oc_")) return "chat_id";
|
||||||
|
// open_id starts with "ou_"
|
||||||
|
if (trimmed.startsWith("ou_")) return "open_id";
|
||||||
|
// union_id starts with "on_"
|
||||||
|
if (trimmed.startsWith("on_")) return "union_id";
|
||||||
|
// user_id is typically numeric or alphanumeric
|
||||||
|
if (/^[a-zA-Z0-9]+$/.test(trimmed) && !trimmed.includes("@")) return "user_id";
|
||||||
|
// email contains @
|
||||||
|
if (trimmed.includes("@")) return "email";
|
||||||
|
// default to open_id
|
||||||
|
return "open_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message to a Feishu user or chat.
|
||||||
|
*/
|
||||||
|
export async function sendMessageFeishu(
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
options: FeishuSendOptions = {},
|
||||||
|
): Promise<FeishuSendResult> {
|
||||||
|
const { appId, appSecret, fetcher } = resolveSendContext(options);
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return { ok: false, error: "No Feishu app credentials configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to?.trim()) {
|
||||||
|
return { ok: false, error: "No recipient provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiveIdType = options.receiveIdType ?? inferReceiveIdType(to);
|
||||||
|
|
||||||
|
// Build message content
|
||||||
|
const content = JSON.stringify({ text });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If replying to a specific message
|
||||||
|
if (options.replyToMessageId) {
|
||||||
|
const response = await replyMessage(
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
options.replyToMessageId,
|
||||||
|
{ msg_type: "text", content },
|
||||||
|
{ fetch: fetcher },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
return { ok: true, messageId: response.data.message_id };
|
||||||
|
}
|
||||||
|
return { ok: false, error: response.msg ?? "Failed to reply" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send new message
|
||||||
|
const response = await sendMessage(
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
{ receive_id: to.trim(), msg_type: "text", content },
|
||||||
|
receiveIdType,
|
||||||
|
{ fetch: fetcher },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
return { ok: true, messageId: response.data.message_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: response.msg ?? "Failed to send message" };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an image message to a Feishu user or chat.
|
||||||
|
* Note: Requires uploading image first via /im/v1/images, then sending with image_key.
|
||||||
|
* For simplicity, this currently only supports image_key (pre-uploaded images).
|
||||||
|
*/
|
||||||
|
export async function sendImageFeishu(
|
||||||
|
to: string,
|
||||||
|
imageKey: string,
|
||||||
|
options: FeishuSendOptions = {},
|
||||||
|
): Promise<FeishuSendResult> {
|
||||||
|
const { appId, appSecret, fetcher } = resolveSendContext(options);
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return { ok: false, error: "No Feishu app credentials configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to?.trim()) {
|
||||||
|
return { ok: false, error: "No recipient provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageKey?.trim()) {
|
||||||
|
return { ok: false, error: "No image key provided" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiveIdType = options.receiveIdType ?? inferReceiveIdType(to);
|
||||||
|
const content = JSON.stringify({ image_key: imageKey.trim() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendMessage(
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
{ receive_id: to.trim(), msg_type: "image", content },
|
||||||
|
receiveIdType,
|
||||||
|
{ fetch: fetcher },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
return { ok: true, messageId: response.data.message_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: response.msg ?? "Failed to send image" };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
58
extensions/feishu/src/status-issues.ts
Normal file
58
extensions/feishu/src/status-issues.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { ChannelStatusIssue } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
type FeishuAccountSnapshot = {
|
||||||
|
accountId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
credentialSource?: string;
|
||||||
|
verificationToken?: string;
|
||||||
|
webhookPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function collectFeishuStatusIssues(
|
||||||
|
accounts: FeishuAccountSnapshot[],
|
||||||
|
): ChannelStatusIssue[] {
|
||||||
|
const issues: ChannelStatusIssue[] = [];
|
||||||
|
|
||||||
|
for (const entry of accounts) {
|
||||||
|
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||||
|
const enabled = entry.enabled !== false;
|
||||||
|
const configured = entry.configured === true;
|
||||||
|
|
||||||
|
if (!enabled) continue;
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
issues.push({
|
||||||
|
channel: "feishu",
|
||||||
|
accountId,
|
||||||
|
kind: "config",
|
||||||
|
message: "Feishu app credentials are missing.",
|
||||||
|
fix: "Set channels.feishu.appId and channels.feishu.appSecret (or use env vars FEISHU_APP_ID/FEISHU_APP_SECRET).",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.verificationToken) {
|
||||||
|
issues.push({
|
||||||
|
channel: "feishu",
|
||||||
|
accountId,
|
||||||
|
kind: "config",
|
||||||
|
message: "Feishu verification token is missing.",
|
||||||
|
fix: "Set channels.feishu.verificationToken from the Events and Callbacks page in Feishu Open Platform.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.webhookPath) {
|
||||||
|
issues.push({
|
||||||
|
channel: "feishu",
|
||||||
|
accountId,
|
||||||
|
kind: "config",
|
||||||
|
message: "Feishu webhook path is not configured.",
|
||||||
|
fix: "Set channels.feishu.webhookPath (default: /feishu/callback).",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
97
extensions/feishu/src/token.ts
Normal file
97
extensions/feishu/src/token.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import type { FeishuConfig, FeishuCredentialSource } from "./types.js";
|
||||||
|
|
||||||
|
export type FeishuCredentialResolution = {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
source: FeishuCredentialSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Feishu app credentials from env, config, or config file.
|
||||||
|
* Priority: env > config > configFile.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuCredentials(
|
||||||
|
config?: FeishuConfig,
|
||||||
|
accountId?: string,
|
||||||
|
): FeishuCredentialResolution {
|
||||||
|
// 1. Try environment variables (only for default account)
|
||||||
|
const envAppId = process.env.FEISHU_APP_ID?.trim();
|
||||||
|
const envAppSecret = process.env.FEISHU_APP_SECRET?.trim();
|
||||||
|
if (envAppId && envAppSecret && (!accountId || accountId === "default")) {
|
||||||
|
return { appId: envAppId, appSecret: envAppSecret, source: "env" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return { appId: "", appSecret: "", source: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get account-specific or base config
|
||||||
|
const accountConfig = accountId && config.accounts?.[accountId];
|
||||||
|
const effectiveConfig = accountConfig || config;
|
||||||
|
|
||||||
|
// 3. Try direct config values
|
||||||
|
const configAppId = effectiveConfig.appId?.trim();
|
||||||
|
const configAppSecret = effectiveConfig.appSecret?.trim();
|
||||||
|
if (configAppId && configAppSecret) {
|
||||||
|
return { appId: configAppId, appSecret: configAppSecret, source: "config" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try reading from file
|
||||||
|
const secretFile = effectiveConfig.appSecretFile?.trim();
|
||||||
|
if (configAppId && secretFile) {
|
||||||
|
try {
|
||||||
|
if (existsSync(secretFile)) {
|
||||||
|
const fileSecret = readFileSync(secretFile, "utf8").trim();
|
||||||
|
if (fileSecret) {
|
||||||
|
return { appId: configAppId, appSecret: fileSecret, source: "configFile" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore file read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { appId: "", appSecret: "", source: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve encrypt key for event decryption.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuEncryptKey(
|
||||||
|
config?: FeishuConfig,
|
||||||
|
accountId?: string,
|
||||||
|
): string {
|
||||||
|
// Try environment variable first
|
||||||
|
const envKey = process.env.FEISHU_ENCRYPT_KEY?.trim();
|
||||||
|
if (envKey && (!accountId || accountId === "default")) {
|
||||||
|
return envKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return "";
|
||||||
|
|
||||||
|
const accountConfig = accountId && config.accounts?.[accountId];
|
||||||
|
const effectiveConfig = accountConfig || config;
|
||||||
|
return effectiveConfig.encryptKey?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve verification token for webhook validation.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuVerificationToken(
|
||||||
|
config?: FeishuConfig,
|
||||||
|
accountId?: string,
|
||||||
|
): string {
|
||||||
|
// Try environment variable first
|
||||||
|
const envToken = process.env.FEISHU_VERIFICATION_TOKEN?.trim();
|
||||||
|
if (envToken && (!accountId || accountId === "default")) {
|
||||||
|
return envToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return "";
|
||||||
|
|
||||||
|
const accountConfig = accountId && config.accounts?.[accountId];
|
||||||
|
const effectiveConfig = accountConfig || config;
|
||||||
|
return effectiveConfig.verificationToken?.trim() || "";
|
||||||
|
}
|
||||||
178
extensions/feishu/src/types.ts
Normal file
178
extensions/feishu/src/types.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/** Feishu account configuration options. */
|
||||||
|
export type FeishuAccountConfig = {
|
||||||
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
|
name?: string;
|
||||||
|
/** If false, do not start this Feishu account. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** App ID from Feishu Open Platform console. */
|
||||||
|
appId?: string;
|
||||||
|
/** App Secret from Feishu Open Platform console. */
|
||||||
|
appSecret?: string;
|
||||||
|
/** Path to file containing the app secret. */
|
||||||
|
appSecretFile?: string;
|
||||||
|
/** Encrypt key for event decryption (from Events and Callbacks page). */
|
||||||
|
encryptKey?: string;
|
||||||
|
/** Verification token for webhook validation. */
|
||||||
|
verificationToken?: string;
|
||||||
|
/** Webhook path for the gateway HTTP server (defaults to /feishu/callback). */
|
||||||
|
webhookPath?: string;
|
||||||
|
/** Direct message access policy (default: pairing). */
|
||||||
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
/** Allowlist for DM senders (Feishu open_id or user_id). */
|
||||||
|
allowFrom?: string[];
|
||||||
|
/** Group access policy (default: allowlist). */
|
||||||
|
groupPolicy?: "open" | "allowlist";
|
||||||
|
/** Group allowlist by chat_id. */
|
||||||
|
groupAllowFrom?: string[];
|
||||||
|
/** Per-group configuration by chat_id. */
|
||||||
|
groups?: Record<string, FeishuGroupConfig>;
|
||||||
|
/** Max inbound media size in MB. */
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-group configuration. */
|
||||||
|
export type FeishuGroupConfig = {
|
||||||
|
/** If false, ignore messages from this group. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Group display name (for logging). */
|
||||||
|
name?: string;
|
||||||
|
/** Require @mention to trigger in this group. */
|
||||||
|
requireMention?: boolean;
|
||||||
|
/** Per-group allowFrom override. */
|
||||||
|
allowFrom?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Top-level Feishu channel configuration. */
|
||||||
|
export type FeishuConfig = {
|
||||||
|
/** Optional per-account Feishu configuration (multi-account). */
|
||||||
|
accounts?: Record<string, FeishuAccountConfig>;
|
||||||
|
/** Default account ID when multiple accounts are configured. */
|
||||||
|
defaultAccount?: string;
|
||||||
|
} & FeishuAccountConfig;
|
||||||
|
|
||||||
|
/** How the app credentials were resolved. */
|
||||||
|
export type FeishuCredentialSource = "env" | "config" | "configFile" | "none";
|
||||||
|
|
||||||
|
/** Resolved Feishu account with all configuration merged. */
|
||||||
|
export type ResolvedFeishuAccount = {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
credentialSource: FeishuCredentialSource;
|
||||||
|
config: FeishuAccountConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Feishu API Types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Feishu API response wrapper. */
|
||||||
|
export type FeishuApiResponse<T = unknown> = {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tenant access token response. */
|
||||||
|
export type FeishuTokenResponse = {
|
||||||
|
tenant_access_token: string;
|
||||||
|
expire: number; // seconds until expiry
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bot info from /bot/v3/info. */
|
||||||
|
export type FeishuBotInfo = {
|
||||||
|
app_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
open_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Message sender info. */
|
||||||
|
export type FeishuSender = {
|
||||||
|
sender_id: {
|
||||||
|
union_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
open_id?: string;
|
||||||
|
};
|
||||||
|
sender_type: string;
|
||||||
|
tenant_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Message content from event. */
|
||||||
|
export type FeishuMessageContent = {
|
||||||
|
message_id: string;
|
||||||
|
root_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
create_time: string;
|
||||||
|
chat_id: string;
|
||||||
|
chat_type: "p2p" | "group";
|
||||||
|
message_type: string;
|
||||||
|
content: string; // JSON string
|
||||||
|
mentions?: FeishuMention[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mention info in message. */
|
||||||
|
export type FeishuMention = {
|
||||||
|
key: string;
|
||||||
|
id: {
|
||||||
|
union_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
open_id?: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
tenant_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Event header from webhook payload. */
|
||||||
|
export type FeishuEventHeader = {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
create_time: string;
|
||||||
|
token: string;
|
||||||
|
app_id: string;
|
||||||
|
tenant_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Message receive event (im.message.receive_v1). */
|
||||||
|
export type FeishuMessageEvent = {
|
||||||
|
sender: FeishuSender;
|
||||||
|
message: FeishuMessageContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Webhook event payload. */
|
||||||
|
export type FeishuWebhookPayload = {
|
||||||
|
schema?: string;
|
||||||
|
header?: FeishuEventHeader;
|
||||||
|
event?: FeishuMessageEvent | Record<string, unknown>;
|
||||||
|
// URL verification challenge
|
||||||
|
challenge?: string;
|
||||||
|
token?: string;
|
||||||
|
type?: string;
|
||||||
|
// Encrypted payload
|
||||||
|
encrypt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Send message request body. */
|
||||||
|
export type FeishuSendMessageParams = {
|
||||||
|
receive_id: string;
|
||||||
|
msg_type: "text" | "post" | "image" | "file" | "audio" | "media" | "sticker" | "interactive";
|
||||||
|
content: string; // JSON string
|
||||||
|
uuid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Send message response. */
|
||||||
|
export type FeishuSendMessageResponse = {
|
||||||
|
message_id: string;
|
||||||
|
root_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
create_time: string;
|
||||||
|
chat_id: string;
|
||||||
|
msg_type: string;
|
||||||
|
body?: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Receive ID type for message sending. */
|
||||||
|
export type FeishuReceiveIdType = "open_id" | "user_id" | "union_id" | "email" | "chat_id";
|
||||||
13355
package-lock.json
generated
Normal file
13355
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
562
pnpm-lock.yaml
generated
562
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user