/** * ZCLAW Feishu Channel Plugin * * Registers Feishu (飞书/Lark) as a messaging channel for OpenClaw Gateway. * Supports: * - Receiving messages via Feishu Event Subscription * - Sending text/rich messages back to Feishu * - OAuth token management (tenant_access_token) * - Webhook-based event handling */ interface PluginAPI { config: Record; registerChannel(opts: { plugin: ChannelPlugin }): void; registerHook(event: string, handler: (...args: any[]) => any, meta?: Record): void; registerGatewayMethod(method: string, handler: (ctx: any) => void): void; registerService(id: string, service: BackgroundService): void; } interface BackgroundService { start(): Promise; stop(): Promise; } interface ChannelPlugin { id: string; meta: { id: string; label: string; selectionLabel: string; docsPath?: string; blurb: string; aliases: string[]; detailLabel?: string; }; capabilities: { chatTypes: string[]; }; config: { listAccountIds: (cfg: any) => string[]; resolveAccount: (cfg: any, accountId?: string) => any; }; outbound: { deliveryMode: string; sendText: (opts: SendTextOpts) => Promise<{ ok: boolean; error?: string }>; sendRichText?: (opts: SendRichTextOpts) => Promise<{ ok: boolean; error?: string }>; }; gateway?: { start?: (ctx: any) => Promise; stop?: (ctx: any) => Promise; }; } interface SendTextOpts { text: string; chatId: string; accountId?: string; } interface SendRichTextOpts { content: any; chatId: string; accountId?: string; } // Feishu API helpers class FeishuClient { private baseUrl = 'https://open.feishu.cn/open-apis'; private appId: string; private appSecret: string; private tenantToken: string | null = null; private tokenExpiry: number = 0; constructor(appId: string, appSecret: string) { this.appId = appId; this.appSecret = appSecret; } async getTenantToken(): Promise { if (this.tenantToken && Date.now() < this.tokenExpiry) { return this.tenantToken; } const response = await fetch(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret, }), }); const data: any = await response.json(); if (data.code !== 0) { throw new Error(`Feishu auth failed: ${data.msg}`); } this.tenantToken = data.tenant_access_token; // Token valid for 2 hours, refresh at 1.5h this.tokenExpiry = Date.now() + 90 * 60 * 1000; return this.tenantToken!; } async sendMessage(chatId: string, msgType: string, content: any): Promise<{ ok: boolean; error?: string }> { try { const token = await this.getTenantToken(); const response = await fetch(`${this.baseUrl}/im/v1/messages?receive_id_type=chat_id`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ receive_id: chatId, msg_type: msgType, content: JSON.stringify(content), }), }); const data: any = await response.json(); if (data.code !== 0) { return { ok: false, error: data.msg }; } return { ok: true }; } catch (error: any) { return { ok: false, error: error.message }; } } async sendText(chatId: string, text: string): Promise<{ ok: boolean; error?: string }> { return this.sendMessage(chatId, 'text', { text }); } async sendRichText(chatId: string, content: any): Promise<{ ok: boolean; error?: string }> { return this.sendMessage(chatId, 'post', content); } } // Global client instance per account const clients = new Map(); function getClient(account: { appId: string; appSecret: string }, accountId: string): FeishuClient { if (!clients.has(accountId)) { clients.set(accountId, new FeishuClient(account.appId, account.appSecret)); } return clients.get(accountId)!; } // Channel definition const feishuChannel: ChannelPlugin = { id: 'feishu', meta: { id: 'feishu', label: '飞书 (Feishu)', selectionLabel: 'Feishu / Lark', blurb: 'Connect to Feishu (飞书) for messaging via Feishu Bot API.', aliases: ['lark', 'feishu'], detailLabel: '飞书机器人', }, capabilities: { chatTypes: ['direct', 'group'], }, config: { listAccountIds: (cfg: any) => Object.keys(cfg.channels?.feishu?.accounts ?? {}), resolveAccount: (cfg: any, accountId?: string) => cfg.channels?.feishu?.accounts?.[accountId ?? 'default'] ?? { accountId }, }, outbound: { deliveryMode: 'direct', sendText: async ({ text, chatId, accountId }: SendTextOpts) => { const cfg = (globalThis as any).__openclaw_config; const account = feishuChannel.config.resolveAccount(cfg, accountId); if (!account?.appId || !account?.appSecret) { return { ok: false, error: 'Feishu account not configured (missing appId/appSecret)' }; } const client = getClient(account, accountId ?? 'default'); return client.sendText(chatId, text); }, sendRichText: async ({ content, chatId, accountId }: SendRichTextOpts) => { const cfg = (globalThis as any).__openclaw_config; const account = feishuChannel.config.resolveAccount(cfg, accountId); if (!account?.appId || !account?.appSecret) { return { ok: false, error: 'Feishu account not configured' }; } const client = getClient(account, accountId ?? 'default'); return client.sendRichText(chatId, content); }, }, }; /** * Plugin entry point */ export default function register(api: PluginAPI) { // Register Feishu as a channel api.registerChannel({ plugin: feishuChannel }); // Register custom RPC method for Feishu status api.registerGatewayMethod('feishu.status', ({ respond }: any) => { const accountIds = feishuChannel.config.listAccountIds(api.config); respond(true, { channel: 'feishu', accounts: accountIds.length, configured: accountIds.length > 0, }); }); // Startup hook api.registerHook('gateway:startup', async () => { const accountIds = feishuChannel.config.listAccountIds(api.config); if (accountIds.length > 0) { console.log(`[ZCLAW] Feishu channel registered with ${accountIds.length} account(s)`); // Pre-warm token for each account for (const id of accountIds) { try { const account = feishuChannel.config.resolveAccount(api.config, id); if (account?.appId && account?.appSecret && account?.enabled !== false) { const client = getClient(account, id); await client.getTenantToken(); console.log(`[ZCLAW] Feishu account "${id}" token acquired`); } } catch (err: any) { console.warn(`[ZCLAW] Feishu account "${id}" token failed: ${err.message}`); } } } else { console.log('[ZCLAW] Feishu channel registered (no accounts configured yet)'); } }, { name: 'zclaw-feishu.startup', description: 'Initialize Feishu connections on startup' }); }