232 lines
7.2 KiB
TypeScript
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' });
|
|
}
|