diff --git a/extensions/feishu/README.md b/extensions/feishu/README.md new file mode 100644 index 000000000..ad71e381c --- /dev/null +++ b/extensions/feishu/README.md @@ -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) + diff --git a/extensions/feishu/clawdbot.plugin.json b/extensions/feishu/clawdbot.plugin.json new file mode 100644 index 000000000..e714dcc7f --- /dev/null +++ b/extensions/feishu/clawdbot.plugin.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..6b6291e55 --- /dev/null +++ b/extensions/feishu/index.ts @@ -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; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..d3df25620 --- /dev/null +++ b/extensions/feishu/package.json @@ -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" + } +} \ No newline at end of file diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts new file mode 100644 index 000000000..bd89c316c --- /dev/null +++ b/extensions/feishu/src/accounts.ts @@ -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(); + + // 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); +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..f00281e29 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -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 = { + 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; + }, + } +}; diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts new file mode 100644 index 000000000..ba9eec31c --- /dev/null +++ b/extensions/feishu/src/client.ts @@ -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; +} diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 000000000..6c38ef5f4 --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -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; + } +} diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 000000000..a0077f179 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -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; +}): 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 { + 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 { + 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 { + 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, +}; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..9498d85b3 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -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; +} diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts new file mode 100644 index 000000000..92c76d488 --- /dev/null +++ b/extensions/feishu/src/types.ts @@ -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; + }; +}