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/**"
|
||||
- "extensions/discord/**"
|
||||
- "docs/channels/discord.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/feishu/**"
|
||||
- "docs/channels/feishu.md"
|
||||
"channel: googlechat":
|
||||
- changed-files:
|
||||
- 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).
|
||||
- [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).
|
||||
- [Feishu](/channels/feishu) — Feishu (Lark) Bot API; China's enterprise messenger (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## 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