Merge 902f89840c into da71eaebd2
This commit is contained in:
commit
9c0eb08e2d
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