feat(channels): add Feishu/Lark channel integration
Add complete Feishu (Lark) channel plugin with WebSocket support for real-time messaging. Features: - Send and receive text messages in direct chats and group chats - WebSocket-based event subscription (im.message.receive_v1) - Multi-account support - Interactive CLI onboarding (pnpm clawdbot channels add feishu) - Optional encryption and verification token support Implementation: - Client wrapper for @larksuiteoapi/node-sdk - Event dispatcher for message handling - Account management and configuration - Comprehensive README with setup guide and troubleshooting Files: - package.json, clawdbot.plugin.json: Plugin metadata and dependencies - index.ts: Plugin entry point - src/accounts.ts: Account resolution and validation - src/channel.ts: Channel plugin and dock definitions - src/client.ts: Feishu API client wrapper - src/monitor.ts: WebSocket event monitoring - src/onboarding.ts: Interactive CLI setup wizard - src/runtime.ts: Runtime context management - src/types.ts: TypeScript type definitions - README.md: Configuration guide and documentation
This commit is contained in:
parent
3f83afe4a6
commit
902f89840c
143
extensions/feishu/README.md
Normal file
143
extensions/feishu/README.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Feishu Extension for Clawdbot
|
||||||
|
|
||||||
|
This extension allows Clawdbot to integrate with Feishu (Lark), enabling it to send and receive messages within your Feishu organization.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To use this extension, you need to configure it with credentials from the Feishu Open Platform.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. A Feishu (Lark) account and an organization.
|
||||||
|
2. Access to the [Feishu Open Platform](https://open.feishu.cn/app?lang=en-US).
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
You can interactively configure this extension using the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm clawdbot channels add feishu
|
||||||
|
```
|
||||||
|
|
||||||
|
This wizard will guide you through entering the required credentials.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **Send & Receive Messages**: Supports sending and receiving **Text** messages in direct chats and group chats.
|
||||||
|
* *Note: Other message types (images, files, etc.) may be displayed as generic placeholders.*
|
||||||
|
* **Multi-Account Support**: Configure multiple Feishu bots/accounts.
|
||||||
|
|
||||||
|
### Step-by-Step Configuration Guide
|
||||||
|
|
||||||
|
1. **Create a Feishu Application:**
|
||||||
|
* Log in to the [Feishu Open Platform](https://open.feishu.cn/app?lang=en-US).
|
||||||
|
* Create a specific "Enterprise Self-Built App" for your bot.
|
||||||
|
|
||||||
|
2. **Get App Credentials:**
|
||||||
|
* Navigate to **Credentials & Basic Info**.
|
||||||
|
* Copy the **App ID** and **App Secret**. These correspond to `appId` and `appSecret` in the configuration.
|
||||||
|
|
||||||
|
3. **Configure Event Subscriptions:**
|
||||||
|
* Navigate to **Event Subscriptions**.
|
||||||
|
* Set the **Encrypt Key** (Optional, but recommended).
|
||||||
|
* Set the **Verification Token** (Optional).
|
||||||
|
* Set the Request URL to your bot's endpoint (e.g., `https://your-bot-domain.com/api/feishu`).
|
||||||
|
* **Add Events**: Search for and add the following event:
|
||||||
|
* `im.message.receive_v1` (Receive messages)
|
||||||
|
|
||||||
|
4. **Add Permissions:**
|
||||||
|
* Navigate to **Permissions & Scopes**.
|
||||||
|
* Add the necessary permissions:
|
||||||
|
* `im:message` (Access messages)
|
||||||
|
* `im:message:send_as_bot` (Send messages as bot)
|
||||||
|
* `im:chat` (Access group chats)
|
||||||
|
* **Important**: Create and publish a version of your app to apply these permissions.
|
||||||
|
|
||||||
|
5. **Enable Bot Capability:**
|
||||||
|
* Navigate to **App Capabilities** -> **Bot**.
|
||||||
|
* Enable the bot capability.
|
||||||
|
|
||||||
|
### Configuration Example
|
||||||
|
|
||||||
|
Add the following to your `clawdbot` configuration (e.g., in `clawdbot.config.json` or via environment variables):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extensions": {
|
||||||
|
"feishu": {
|
||||||
|
"appId": "cli_...",
|
||||||
|
"appSecret": "...",
|
||||||
|
"encryptKey": "...", // Optional: Required if encryption is enabled
|
||||||
|
"verificationToken": "..." // Optional: Required for event verification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `encryptKey` and `verificationToken` are **optional** for basic bot functionality (sending messages). However, they are **required** if you want to:
|
||||||
|
> * Receive events securely (verify the source).
|
||||||
|
> * Have enabled **Encrypt Key** in the Feishu Event Subscriptions settings.
|
||||||
|
|
||||||
|
### Multi-Account Configuration
|
||||||
|
|
||||||
|
If you need to configure multiple Feishu bots, you can use the accounts structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"accounts": {
|
||||||
|
"default": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "cli_xxx",
|
||||||
|
"appSecret": "xxx",
|
||||||
|
"encryptKey": "xxx",
|
||||||
|
"verificationToken": "xxx"
|
||||||
|
},
|
||||||
|
"team-bot": {
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Team Bot",
|
||||||
|
"appId": "cli_yyy",
|
||||||
|
"appSecret": "yyy",
|
||||||
|
"encryptKey": "yyy",
|
||||||
|
"verificationToken": "yyy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bot not receiving messages
|
||||||
|
|
||||||
|
1. **Check Event Subscription URL**: Ensure the Request URL is correctly configured and accessible from Feishu servers.
|
||||||
|
2. **Verify Event Subscription**: Make sure `im.message.receive_v1` event is added and the app version is published.
|
||||||
|
3. **Check Permissions**: Ensure all required permissions are granted and the app version is published.
|
||||||
|
4. **Review Logs**: Check Clawdbot logs for connection errors or event processing issues.
|
||||||
|
|
||||||
|
### Authentication errors
|
||||||
|
|
||||||
|
1. **Verify Credentials**: Double-check that `appId` and `appSecret` are correct.
|
||||||
|
2. **Check App Status**: Ensure the app is enabled and not suspended in Feishu Open Platform.
|
||||||
|
|
||||||
|
### Encryption/Verification errors
|
||||||
|
|
||||||
|
1. **Match Configuration**: Ensure `encryptKey` and `verificationToken` in your config match exactly what's set in Feishu Event Subscriptions.
|
||||||
|
2. **Optional Fields**: If you haven't enabled encryption in Feishu, you can leave these fields empty.
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
* **Message Types**: Currently only **text messages** are fully supported. Other types (images, files, cards) will be displayed as generic placeholders.
|
||||||
|
* **Reactions**: Message reactions are not yet supported.
|
||||||
|
* **Threads**: Message threads are not yet supported.
|
||||||
|
* **Media Upload**: Sending images/files is not yet implemented.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
* [Feishu Open Platform Documentation](https://open.feishu.cn/document/home/index)
|
||||||
|
* [Feishu Bot Development Guide](https://open.feishu.cn/document/home/develop-a-bot-in-5-minutes/create-an-app)
|
||||||
|
|
||||||
24
extensions/feishu/clawdbot.plugin.json
Normal file
24
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "feishu",
|
||||||
|
"channels": [
|
||||||
|
"feishu"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"appId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"appSecret": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"encryptKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"verificationToken": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
extensions/feishu/index.ts
Normal file
18
extensions/feishu/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { feishuDock, feishuPlugin } from "./src/channel.js";
|
||||||
|
import { setFeishuRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "feishu",
|
||||||
|
name: "Feishu",
|
||||||
|
description: "Clawdbot Feishu (Lark) channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setFeishuRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: feishuPlugin, dock: feishuDock });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
38
extensions/feishu/package.json
Normal file
38
extensions/feishu/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@moltbot/feishu",
|
||||||
|
"version": "2026.1.27",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot Feishu (Lark) channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "feishu",
|
||||||
|
"label": "Feishu (Lark)",
|
||||||
|
"selectionLabel": "Feishu / Lark (WebSocket)",
|
||||||
|
"detailLabel": "Feishu / Lark",
|
||||||
|
"docsPath": "/channels/feishu",
|
||||||
|
"docsLabel": "feishu",
|
||||||
|
"blurb": "Feishu/Lark bot via WebSocket.",
|
||||||
|
"aliases": [
|
||||||
|
"lark"
|
||||||
|
],
|
||||||
|
"order": 56
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@moltbot/feishu",
|
||||||
|
"localPath": "extensions/feishu",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@larksuiteoapi/node-sdk": "^1.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"clawdbot": ">=2026.1.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
extensions/feishu/src/accounts.ts
Normal file
56
extensions/feishu/src/accounts.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import type { FeishuConfig, FeishuAccount } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
export function resolveFeishuAccount(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
|
}): FeishuAccount {
|
||||||
|
const { cfg, accountId = DEFAULT_ACCOUNT_ID } = params;
|
||||||
|
|
||||||
|
// Type assertion to access channel specific config
|
||||||
|
// In a real plugin structure, config is typed, but here we access structure dynamically
|
||||||
|
const feishuCfg = (cfg.channels as any)?.feishu;
|
||||||
|
|
||||||
|
// Config can be at root of feishu block (default) or in accounts map
|
||||||
|
const defaults = feishuCfg;
|
||||||
|
const account = feishuCfg?.accounts?.[accountId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
name: account?.name ?? accountId,
|
||||||
|
enabled: account?.enabled ?? defaults?.enabled ?? true,
|
||||||
|
// Merge defaults with account specific overrides
|
||||||
|
config: {
|
||||||
|
appId: account?.appId ?? defaults?.appId,
|
||||||
|
appSecret: account?.appSecret ?? defaults?.appSecret,
|
||||||
|
encryptKey: account?.encryptKey ?? defaults?.encryptKey,
|
||||||
|
verificationToken: account?.verificationToken ?? defaults?.verificationToken,
|
||||||
|
} as FeishuConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const feishuCfg = (cfg.channels as any)?.feishu;
|
||||||
|
if (!feishuCfg) return [];
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
// If base fields exist, "default" is an account
|
||||||
|
if (feishuCfg.appId) {
|
||||||
|
ids.add(DEFAULT_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add explicit accounts
|
||||||
|
if (feishuCfg.accounts) {
|
||||||
|
Object.keys(feishuCfg.accounts).forEach(id => ids.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeishuConfigured(account: FeishuAccount): boolean {
|
||||||
|
return Boolean(account.config.appId && account.config.appSecret);
|
||||||
|
}
|
||||||
174
extensions/feishu/src/channel.ts
Normal file
174
extensions/feishu/src/channel.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import type {
|
||||||
|
ChannelDock,
|
||||||
|
ChannelPlugin,
|
||||||
|
ClawdbotConfig,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
emptyPluginConfigSchema,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
normalizeAccountId,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import type { FeishuAccount, FeishuConfig } from "./types.js";
|
||||||
|
import { startFeishuMonitor } from "./monitor.js";
|
||||||
|
import { sendFeishuMessage } from "./client.js";
|
||||||
|
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { listFeishuAccountIds, resolveFeishuAccount, isFeishuConfigured } from "./accounts.js";
|
||||||
|
|
||||||
|
// Dock Definition
|
||||||
|
export const feishuDock: ChannelDock = {
|
||||||
|
id: "feishu",
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
reactions: false, // Pending implementation
|
||||||
|
media: false, // Pending implementation
|
||||||
|
threads: false, // Pending implementation
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
outbound: { textChunkLimit: 2000 }, // Feishu limit is usually ~4k chars, safely 2k
|
||||||
|
config: {
|
||||||
|
resolveAllowFrom: () => [],
|
||||||
|
formatAllowFrom: () => [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plugin Definition
|
||||||
|
export const feishuPlugin: ChannelPlugin<FeishuAccount> = {
|
||||||
|
id: "feishu",
|
||||||
|
meta: {
|
||||||
|
id: "feishu",
|
||||||
|
label: "Feishu",
|
||||||
|
blurb: "Feishu/Lark Workspace",
|
||||||
|
},
|
||||||
|
onboarding: feishuOnboardingAdapter,
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
reactions: false,
|
||||||
|
media: false,
|
||||||
|
threads: false,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listFeishuAccountIds(cfg as ClawdbotConfig),
|
||||||
|
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
||||||
|
defaultAccountId: () => "default",
|
||||||
|
isConfigured: (account) => isFeishuConfigured(account),
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: Boolean(account.config.appId && account.config.appSecret),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
ctx.log?.info(`[${ctx.account.accountId}] Starting Feishu monitor...`);
|
||||||
|
const monitor = await startFeishuMonitor({
|
||||||
|
account: ctx.account,
|
||||||
|
config: ctx.cfg as ClawdbotConfig,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.account.accountId, ...patch }),
|
||||||
|
});
|
||||||
|
ctx.setStatus({ accountId: ctx.account.accountId, running: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
monitor.stop().catch(console.error);
|
||||||
|
ctx.setStatus({ accountId: ctx.account.accountId, running: false });
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
outbound: {
|
||||||
|
sendText: async ({ cfg, to, text, accountId }: { cfg: ClawdbotConfig, to: string, text: string, accountId?: string }) => {
|
||||||
|
const account = feishuPlugin.config.resolveAccount(cfg, accountId || "default");
|
||||||
|
const res = await sendFeishuMessage({
|
||||||
|
account,
|
||||||
|
receiveId: to,
|
||||||
|
msgType: "text",
|
||||||
|
content: JSON.stringify({ text }), // Feishu content is JSON string
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
channel: "feishu",
|
||||||
|
messageId: res?.message_id,
|
||||||
|
chatId: to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }: { accountId: string }) => normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }: { cfg: ClawdbotConfig, accountId: string, name: string }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "feishu",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
validateInput: ({ accountId, input }: { accountId: string, input: any }) => {
|
||||||
|
if (!input.appId || !input.appSecret) {
|
||||||
|
return "Feishu requires --app-id and --app-secret.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }: { cfg: ClawdbotConfig, accountId: string, input: any }) => {
|
||||||
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
channelKey: "feishu",
|
||||||
|
accountId,
|
||||||
|
name: input.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? migrateBaseNameToDefaultAccount({
|
||||||
|
cfg: namedConfig as ClawdbotConfig,
|
||||||
|
channelKey: "feishu",
|
||||||
|
})
|
||||||
|
: namedConfig;
|
||||||
|
|
||||||
|
const configPatch = {
|
||||||
|
...(input.appId ? { appId: input.appId } : {}),
|
||||||
|
...(input.appSecret ? { appSecret: input.appSecret } : {}),
|
||||||
|
...(input.encryptKey ? { encryptKey: input.encryptKey } : {}),
|
||||||
|
...(input.verificationToken ? { verificationToken: input.verificationToken } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
"feishu": {
|
||||||
|
...(next.channels?.["feishu"] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
...configPatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
channels: {
|
||||||
|
...next.channels,
|
||||||
|
"feishu": {
|
||||||
|
...(next.channels?.["feishu"] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...(next.channels?.["feishu"]?.accounts ?? {}),
|
||||||
|
[accountId]: {
|
||||||
|
...(next.channels?.["feishu"]?.accounts?.[accountId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
...configPatch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
70
extensions/feishu/src/client.ts
Normal file
70
extensions/feishu/src/client.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import * as lark from "@larksuiteoapi/node-sdk";
|
||||||
|
|
||||||
|
import type { FeishuAccount } from "./types.js";
|
||||||
|
|
||||||
|
export function createFeishuClient(account: FeishuAccount) {
|
||||||
|
if (!account.config.appId || !account.config.appSecret) {
|
||||||
|
throw new Error("Feishu appId and appSecret are required");
|
||||||
|
}
|
||||||
|
return new lark.Client({
|
||||||
|
appId: account.config.appId,
|
||||||
|
appSecret: account.config.appSecret,
|
||||||
|
disableTokenCache: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFeishuMessage(params: {
|
||||||
|
account: FeishuAccount;
|
||||||
|
receiveId: string;
|
||||||
|
receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id";
|
||||||
|
msgType: "text" | "post" | "image" | "interactive" | "share_chat" | "share_user" | "audio" | "media" | "file" | "sticker";
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const { account, receiveId, receiveIdType = "chat_id", msgType, content } = params;
|
||||||
|
const client = createFeishuClient(account);
|
||||||
|
|
||||||
|
const response = await client.im.message.create({
|
||||||
|
params: {
|
||||||
|
receive_id_type: receiveIdType,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: msgType,
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
throw new Error(`Feishu send message failed: ${response.msg} (code: ${response.code}, logId: ${response.log_id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFeishuImage(params: {
|
||||||
|
account: FeishuAccount;
|
||||||
|
imagePath: string;
|
||||||
|
imageType: "message";
|
||||||
|
}) {
|
||||||
|
const { account, imagePath, imageType } = params;
|
||||||
|
const client = createFeishuClient(account);
|
||||||
|
|
||||||
|
// Note: SDK wrapper might handle reading file, or we pass stream.
|
||||||
|
// Using standard fs.createReadStream if SDK supports it.
|
||||||
|
// Checking SDK docs or type definition would be ideal, but assuming standard node stream support for now.
|
||||||
|
const fs = await import("node:fs");
|
||||||
|
const file = fs.createReadStream(imagePath);
|
||||||
|
|
||||||
|
const response = await client.im.image.create({
|
||||||
|
data: {
|
||||||
|
image_type: imageType,
|
||||||
|
image: file,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
throw new Error(`Feishu upload image failed: ${response.msg} (code: ${response.code})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
163
extensions/feishu/src/monitor.ts
Normal file
163
extensions/feishu/src/monitor.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import * as lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { createFeishuClient, sendFeishuMessage } from "./client.js";
|
||||||
|
import type { FeishuAccount, FeishuMessageEvent } from "./types.js";
|
||||||
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
export type FeishuRuntimeEnv = {
|
||||||
|
log?: (message: string) => void;
|
||||||
|
error?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function startFeishuMonitor(params: {
|
||||||
|
account: FeishuAccount;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
runtime: FeishuRuntimeEnv;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
}) {
|
||||||
|
const { account, config, runtime, statusSink } = params;
|
||||||
|
|
||||||
|
// Feishu WS Client
|
||||||
|
const client = new lark.WSClient({
|
||||||
|
appId: account.config.appId || "",
|
||||||
|
appSecret: account.config.appSecret || "",
|
||||||
|
loggerLevel: 2, // Info
|
||||||
|
logger: {
|
||||||
|
// Adaptation for Logger
|
||||||
|
debug: () => { },
|
||||||
|
info: (msg) => runtime.log?.(`[feishu-sdk] ${msg}`),
|
||||||
|
warn: (msg) => runtime.log?.(`[feishu-sdk] WARN: ${msg}`),
|
||||||
|
error: (msg) => runtime.error?.(`[feishu-sdk] ERROR: ${msg}`),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Dispatcher
|
||||||
|
const eventDispatcher = new lark.EventDispatcher({
|
||||||
|
encryptKey: account.config.encryptKey || "",
|
||||||
|
verificationToken: account.config.verificationToken || "",
|
||||||
|
})
|
||||||
|
|
||||||
|
eventDispatcher.register({
|
||||||
|
"im.message.receive_v1": async (data) => {
|
||||||
|
try {
|
||||||
|
// Feishu SDK EventDispatcher unpacks the payload.
|
||||||
|
// 'data' IS the event object (containing message, sender, etc.)
|
||||||
|
const event = data as FeishuMessageEvent;
|
||||||
|
const message = event.message;
|
||||||
|
const sender = event.sender;
|
||||||
|
|
||||||
|
if (!message || !sender) {
|
||||||
|
runtime.log?.(`[feishu] Received incomplete message event`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatId = message.chat_id;
|
||||||
|
const messageId = message.message_id;
|
||||||
|
const senderId = sender.sender_id.user_id || sender.sender_id.open_id || sender.sender_id.union_id;
|
||||||
|
|
||||||
|
runtime.log?.(`[feishu] Received message ${messageId} from ${chatId}`);
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
let rawBody = "";
|
||||||
|
|
||||||
|
// Handle message type compatibility (SDK vs API raw)
|
||||||
|
const msgType = message.message_type || (message as any).msg_type;
|
||||||
|
|
||||||
|
if (msgType === "text") {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(message.content);
|
||||||
|
text = content.text;
|
||||||
|
rawBody = content.text;
|
||||||
|
} catch {
|
||||||
|
text = "[Invalid JSON Content]";
|
||||||
|
rawBody = message.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawBody = `[${msgType}]`;
|
||||||
|
text = rawBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = getFeishuRuntime();
|
||||||
|
if (!core) {
|
||||||
|
runtime.error?.("[feishu] Core runtime not available during message processing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromLabel = `feishu:${senderId}`;
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: text,
|
||||||
|
RawBody: rawBody,
|
||||||
|
CommandBody: text,
|
||||||
|
From: fromLabel,
|
||||||
|
To: `feishu:${chatId}`,
|
||||||
|
SessionKey: `feishu:${chatId}`,
|
||||||
|
AccountId: account.accountId,
|
||||||
|
ChatType: message.chat_type === "group" ? "channel" : "direct",
|
||||||
|
ConversationLabel: message.chat_type === "group" ? `Group ${chatId}` : `User ${senderId}`,
|
||||||
|
SenderId: senderId,
|
||||||
|
SenderName: "FeishuUser",
|
||||||
|
Provider: "feishu",
|
||||||
|
Surface: "feishu",
|
||||||
|
MessageSid: messageId,
|
||||||
|
MessageSidFull: messageId,
|
||||||
|
OriginatingChannel: "feishu",
|
||||||
|
OriginatingTo: `feishu:${chatId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg: config,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload) => {
|
||||||
|
if (payload.text) {
|
||||||
|
await sendFeishuMessage({
|
||||||
|
account,
|
||||||
|
receiveId: chatId,
|
||||||
|
msgType: "text",
|
||||||
|
content: JSON.stringify({ text: payload.text }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
runtime.error?.(`[feishu] Reply failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
statusSink?.({ lastInboundAt: Date.now() });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`[feishu] Process message failed: ${err}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"im.message.message_read_v1": async (data) => {
|
||||||
|
// Optional: Handle read receipts or just log for debug
|
||||||
|
// runtime.log?.(`[feishu] Message read event received: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.start({ eventDispatcher });
|
||||||
|
runtime.log?.(`[feishu] WebSocket client started for account ${account.accountId}`);
|
||||||
|
return {
|
||||||
|
stop: async () => {
|
||||||
|
try {
|
||||||
|
// WSClient may have close/stop method - attempt graceful shutdown
|
||||||
|
if (typeof (client as any).close === "function") {
|
||||||
|
await (client as any).close();
|
||||||
|
} else if (typeof (client as any).stop === "function") {
|
||||||
|
await (client as any).stop();
|
||||||
|
}
|
||||||
|
runtime.log?.(`[feishu] WebSocket client stopped for account ${account.accountId}`);
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`[feishu] Error stopping WebSocket client: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`[feishu] Failed to start WebSocket client: ${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
extensions/feishu/src/onboarding.ts
Normal file
167
extensions/feishu/src/onboarding.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
formatDocsLink,
|
||||||
|
promptAccountId,
|
||||||
|
normalizeAccountId,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
type ChannelOnboardingAdapter,
|
||||||
|
type ChannelOnboardingConfigureContext,
|
||||||
|
type ChannelOnboardingResult,
|
||||||
|
type ChannelOnboardingStatus,
|
||||||
|
type ChannelOnboardingStatusContext,
|
||||||
|
type WizardPrompter,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import { listFeishuAccountIds, resolveFeishuAccount, isFeishuConfigured } from "./accounts.js";
|
||||||
|
|
||||||
|
const channel = "feishu" as const;
|
||||||
|
|
||||||
|
function applyFeishuConfig(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId: string;
|
||||||
|
patch: Record<string, unknown>;
|
||||||
|
}): ClawdbotConfig {
|
||||||
|
const { cfg, accountId, patch } = params;
|
||||||
|
|
||||||
|
// Helper to ensure structure exists
|
||||||
|
const ensureAccount = (config: ClawdbotConfig, accId: string) => {
|
||||||
|
const next = { ...config };
|
||||||
|
next.channels = { ...(next.channels ?? {}) };
|
||||||
|
next.channels.feishu = { ...(next.channels.feishu ?? {}) };
|
||||||
|
|
||||||
|
if (accId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
next.channels.feishu = {
|
||||||
|
...next.channels.feishu,
|
||||||
|
enabled: true,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.channels.feishu.accounts = { ...(next.channels.feishu.accounts ?? {}) };
|
||||||
|
next.channels.feishu.accounts[accId] = {
|
||||||
|
...(next.channels.feishu.accounts[accId] ?? {}),
|
||||||
|
enabled: true,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
return ensureAccount(cfg, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptCredentials(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
accountId: string;
|
||||||
|
}): Promise<ClawdbotConfig> {
|
||||||
|
const { cfg, prompter, accountId } = params;
|
||||||
|
const current = resolveFeishuAccount({ cfg, accountId });
|
||||||
|
|
||||||
|
const appId = await prompter.text({
|
||||||
|
message: "Feishu App ID",
|
||||||
|
placeholder: "cli_...",
|
||||||
|
initialValue: current.config.appId,
|
||||||
|
validate: (value: unknown) => {
|
||||||
|
const val = String(value ?? "").trim();
|
||||||
|
if (!val) return "Required";
|
||||||
|
if (!val.startsWith("cli_")) return "App ID usually starts with 'cli_'";
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const appSecret = await prompter.text({
|
||||||
|
message: "Feishu App Secret",
|
||||||
|
placeholder: "...",
|
||||||
|
initialValue: current.config.appSecret,
|
||||||
|
validate: (value: unknown) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptKey = await prompter.text({
|
||||||
|
message: "Encrypt Key",
|
||||||
|
hint: "Optional; required only if you configured Encrypt Key in Feishu Event Subscriptions",
|
||||||
|
initialValue: current.config.encryptKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationToken = await prompter.text({
|
||||||
|
message: "Verification Token",
|
||||||
|
hint: "Optional; required only if you configured Verification Token in Feishu Event Subscriptions",
|
||||||
|
initialValue: current.config.verificationToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return applyFeishuConfig({
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
patch: {
|
||||||
|
appId: String(appId).trim(),
|
||||||
|
appSecret: String(appSecret).trim(),
|
||||||
|
...(encryptKey ? { encryptKey: String(encryptKey).trim() } : {}),
|
||||||
|
...(verificationToken ? { verificationToken: String(verificationToken).trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> {
|
||||||
|
const { cfg } = ctx;
|
||||||
|
// Simple check: is default account configured?
|
||||||
|
// Ideally we iterate all accounts, but for status summary usually checking if ANY are configured is enough,
|
||||||
|
// or just the generic status.
|
||||||
|
// Let's use listAccountIds from plugin config.
|
||||||
|
const accountIds = listFeishuAccountIds(cfg);
|
||||||
|
const configured = accountIds.some((accId: string) => isFeishuConfigured(resolveFeishuAccount({ cfg, accountId: accId })));
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
channel,
|
||||||
|
configured,
|
||||||
|
statusLines: [
|
||||||
|
`Feishu: ${configured ? "configured" : "needs credentials"}`,
|
||||||
|
],
|
||||||
|
selectionHint: configured ? "configured" : "setup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure(ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> {
|
||||||
|
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
||||||
|
|
||||||
|
const override = accountOverrides["feishu"]?.trim();
|
||||||
|
// Feishu defaults to "default" account ID
|
||||||
|
const defaultAccountId = "default";
|
||||||
|
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||||
|
|
||||||
|
if (shouldPromptAccountIds && !override) {
|
||||||
|
accountId = await promptAccountId({
|
||||||
|
cfg,
|
||||||
|
prompter,
|
||||||
|
label: "Feishu",
|
||||||
|
currentId: accountId,
|
||||||
|
listAccountIds: (c: ClawdbotConfig) => listFeishuAccountIds(c),
|
||||||
|
defaultAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"Feishu setup requires App ID and App Secret from the Feishu Open Platform.",
|
||||||
|
"Encrypt Key and Verification Token are optional but recommended for event security.",
|
||||||
|
`Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`,
|
||||||
|
].join("\n"),
|
||||||
|
"Feishu Setup"
|
||||||
|
);
|
||||||
|
|
||||||
|
let next = cfg;
|
||||||
|
next = await promptCredentials({ cfg: next, prompter, accountId });
|
||||||
|
|
||||||
|
// Ensure migration if needed (standard pattern)
|
||||||
|
const namedConfig = migrateBaseNameToDefaultAccount({
|
||||||
|
cfg: next,
|
||||||
|
channelKey: "feishu",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cfg: namedConfig, accountId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
|
channel,
|
||||||
|
getStatus,
|
||||||
|
configure,
|
||||||
|
};
|
||||||
16
extensions/feishu/src/runtime.ts
Normal file
16
extensions/feishu/src/runtime.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { ClawdbotPluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
type FeishuRuntime = ClawdbotPluginRuntime;
|
||||||
|
|
||||||
|
let _runtime: FeishuRuntime | undefined;
|
||||||
|
|
||||||
|
export function setFeishuRuntime(runtime: FeishuRuntime) {
|
||||||
|
_runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeishuRuntime(): FeishuRuntime {
|
||||||
|
if (!_runtime) {
|
||||||
|
throw new Error("Feishu runtime not initialized");
|
||||||
|
}
|
||||||
|
return _runtime;
|
||||||
|
}
|
||||||
32
extensions/feishu/src/types.ts
Normal file
32
extensions/feishu/src/types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type FeishuConfig = {
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
encryptKey?: string;
|
||||||
|
verificationToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuAccount = {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
config: FeishuConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FeishuMessageEvent {
|
||||||
|
message: {
|
||||||
|
chat_id: string;
|
||||||
|
message_id: string;
|
||||||
|
chat_type: string;
|
||||||
|
message_type: string; // SDK types say message_type, raw might be msg_type
|
||||||
|
content: string; // JSON string
|
||||||
|
create_time: string;
|
||||||
|
};
|
||||||
|
sender: {
|
||||||
|
sender_id: {
|
||||||
|
user_id?: string;
|
||||||
|
open_id?: string;
|
||||||
|
union_id?: string;
|
||||||
|
};
|
||||||
|
sender_type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user