Files
2026-03-12 00:23:42 +08:00

232 lines
7.2 KiB
TypeScript

/**
* 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<string, any>;
registerChannel(opts: { plugin: ChannelPlugin }): void;
registerHook(event: string, handler: (...args: any[]) => any, meta?: Record<string, any>): void;
registerGatewayMethod(method: string, handler: (ctx: any) => void): void;
registerService(id: string, service: BackgroundService): void;
}
interface BackgroundService {
start(): Promise<void>;
stop(): Promise<void>;
}
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<void>;
stop?: (ctx: any) => Promise<void>;
};
}
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<string> {
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<string, FeishuClient>();
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' });
}