feat: support feshu/lark

This commit is contained in:
ie2718 2026-01-26 22:27:53 +08:00
parent 83de980d6c
commit b560bdeab6
20 changed files with 16109 additions and 462 deletions

5
.github/labeler.yml vendored
View File

@ -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
View 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"`

View File

@ -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

View File

@ -0,0 +1,11 @@
{
"id": "feishu",
"channels": [
"feishu"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View 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;

View 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"
}
}

View 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);
}

View 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 });
}

View 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 }),
});
},
},
};

View 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;
};

View 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 };
}

View 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 };
},
};

View 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 };
}
}

View 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;
}

View 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) };
}
}

View 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;
}

View 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() || "";
}

View 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

File diff suppressed because it is too large Load Diff

562
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff