From 4c2889c6c681f0bd5da27c716a27636a246e7928 Mon Sep 17 00:00:00 2001 From: largezhou Date: Thu, 29 Jan 2026 23:42:22 +0800 Subject: [PATCH] feat: add Feishu channel plugins --- extensions/feishu/clawdbot.plugin.json | 11 ++ extensions/feishu/index.ts | 29 +++ extensions/feishu/package.json | 35 ++++ extensions/feishu/src/channel.ts | 259 +++++++++++++++++++++++++ extensions/feishu/src/client.ts | 92 +++++++++ extensions/feishu/src/gateway.ts | 185 ++++++++++++++++++ extensions/feishu/src/msg-context.ts | 14 ++ extensions/feishu/src/runtime.ts | 18 ++ extensions/feishu/src/types.ts | 28 +++ extensions/feishu/tsconfig.json | 14 ++ 10 files changed, 685 insertions(+) create mode 100644 extensions/feishu/clawdbot.plugin.json create mode 100644 extensions/feishu/index.ts create mode 100644 extensions/feishu/package.json create mode 100644 extensions/feishu/src/channel.ts create mode 100644 extensions/feishu/src/client.ts create mode 100644 extensions/feishu/src/gateway.ts create mode 100644 extensions/feishu/src/msg-context.ts create mode 100644 extensions/feishu/src/runtime.ts create mode 100644 extensions/feishu/src/types.ts create mode 100644 extensions/feishu/tsconfig.json diff --git a/extensions/feishu/clawdbot.plugin.json b/extensions/feishu/clawdbot.plugin.json new file mode 100644 index 000000000..8615c1876 --- /dev/null +++ b/extensions/feishu/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "feishu", + "name": "Feishu Channel", + "version": "1.0.0", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } +} diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..16eff5ffe --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,29 @@ +/** + * Clawdbot 飞书通道插件入口 + */ + +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { feishuPlugin } from "./src/channel.js"; +import { setFeishuRuntime } from "./src/runtime.js"; + +export const plugin = { + id: "feishu", + name: "Feishu", + description: "Feishu (Lark) messaging channel for Clawdbot", + + register(api: ClawdbotPluginApi) { + // 保存运行时引用 + setFeishuRuntime(api.runtime); + + // 注册飞书通道 + api.registerChannel({ plugin: feishuPlugin }); + }, +}; + +export default plugin; + +// 同时导出便于直接测试 +export { feishuPlugin } from "./src/channel.js"; +export * from "./src/types.js"; +export * from "./src/client.js"; +export * from "./src/gateway.js"; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..323c92c53 --- /dev/null +++ b/extensions/feishu/package.json @@ -0,0 +1,35 @@ +{ + "name": "feishu", + "version": "1.0.1", + "description": "Feishu (Lark) channel plugin for Clawdbot", + "type": "module", + "main": "index.ts", + "scripts": {}, + "clawdbot": { + "extensions": ["./index.ts"], + "channel": { + "id": "feishu", + "label": "Feishu", + "selectionLabel": "Feishu (Lark)", + "docsPath": "/channels/feishu", + "docsLabel": "feishu", + "blurb": "Connect to Feishu via official Feishu API", + "order": 60, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@moltbot/feishu", + "localPath": "extensions/feishu", + "defaultChoice": "npm" + } + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.46.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + }, + "peerDependencies": { + "clawdbot": "*" + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..215111350 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,259 @@ +/** + * 飞书通道插件定义 + */ + +import type { + ChannelPlugin, + ClawdbotConfig, + ChannelOnboardingAdapter, +} from "clawdbot/plugin-sdk"; +import type { ResolvedFeishuAccount, FeishuChannelConfig } from "./types.js"; +import { sendTextMessage } from "./client.js"; +import { startGateway } from "./gateway.js"; +import { getFeishuRuntime } from "./runtime.js"; +import type { MsgContext } from "./msg-context.js"; + +const DEFAULT_ACCOUNT_ID = "default"; +const CHANNEL_ID = "feishu" as const; + +/** + * 获取飞书通道配置 + */ +function getFeishuConfig(cfg: ClawdbotConfig): FeishuChannelConfig | undefined { + return (cfg as any).channels?.feishu as FeishuChannelConfig | undefined; +} + +/** + * 从配置中获取飞书账号列表(单账号模式) + */ +function listFeishuAccountIds(cfg: ClawdbotConfig): string[] { + const feishuCfg = getFeishuConfig(cfg); + if (!feishuCfg || feishuCfg.enabled === false) return []; + if (!feishuCfg.appId || !feishuCfg.appSecret) return []; + return [DEFAULT_ACCOUNT_ID]; +} + +/** + * 解析飞书账号配置(单账号模式) + */ +function resolveFeishuAccount( + cfg: ClawdbotConfig, + accountId: string +): ResolvedFeishuAccount | undefined { + if (accountId !== DEFAULT_ACCOUNT_ID) return undefined; + + const feishuCfg = getFeishuConfig(cfg); + if (!feishuCfg) return undefined; + + return { + accountId: DEFAULT_ACCOUNT_ID, + appId: feishuCfg.appId, + appSecret: feishuCfg.appSecret, + }; +} + +/** + * 飞书 Onboarding Adapter + * 用于 clawdbot onboard 交互式配置向导 + */ +const feishuOnboardingAdapter: ChannelOnboardingAdapter = { + channel: CHANNEL_ID, + + getStatus: async ({ cfg }) => { + const feishuCfg = getFeishuConfig(cfg); + const configured = !!(feishuCfg?.appId && feishuCfg?.appSecret); + return { + channel: CHANNEL_ID, + configured, + statusLines: [`Feishu: ${configured ? "configured" : "needs App ID & Secret"}`], + selectionHint: configured ? "configured" : "needs credentials", + }; + }, + + configure: async (ctx) => { + const { cfg, prompter } = ctx; + let next = cfg; + const currentCfg = getFeishuConfig(cfg); + const hasAppId = !!currentCfg?.appId; + const hasAppSecret = !!currentCfg?.appSecret; + + // 显示帮助信息 + await prompter.note( + [ + "1) 登录飞书开放平台 → 创建企业自建应用", + "2) 获取 App ID 和 App Secret", + "3) 启用机器人能力,配置消息接收方式为「使用长连接接收消息」", + "4) 发布应用并授权", + "Docs: https://open.feishu.cn/document/home/develop-a-bot-in-5-minutes", + ].join("\n"), + "飞书机器人配置" + ); + + let appId: string | null = null; + let appSecret: string | null = null; + + // App ID + if (hasAppId) { + const keep = await prompter.confirm({ + message: `App ID 已配置 (${currentCfg!.appId.slice(0, 8)}...),是否保留?`, + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "请输入飞书 App ID", + validate: (value) => (value?.trim() ? undefined : "必填"), + }) + ).trim(); + } + } else { + appId = String( + await prompter.text({ + message: "请输入飞书 App ID", + validate: (value) => (value?.trim() ? undefined : "必填"), + }) + ).trim(); + } + + // App Secret + if (hasAppSecret) { + const keep = await prompter.confirm({ + message: "App Secret 已配置,是否保留?", + initialValue: true, + }); + if (!keep) { + appSecret = String( + await prompter.text({ + message: "请输入飞书 App Secret", + validate: (value) => (value?.trim() ? undefined : "必填"), + }) + ).trim(); + } + } else { + appSecret = String( + await prompter.text({ + message: "请输入飞书 App Secret", + validate: (value) => (value?.trim() ? undefined : "必填"), + }) + ).trim(); + } + + // 更新配置 + next = { + ...next, + channels: { + ...(next as any).channels, + feishu: { + ...(next as any).channels?.feishu, + enabled: true, + ...(appId ? { appId } : {}), + ...(appSecret ? { appSecret } : {}), + }, + }, + } as ClawdbotConfig; + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + + disable: (cfg) => ({ + ...cfg, + channels: { + ...(cfg as any).channels, + feishu: { ...(cfg as any).channels?.feishu, enabled: false }, + }, + } as ClawdbotConfig), +}; + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu (Lark)", + docsPath: "https://open.feishu.cn/document", + blurb: "Connect to Feishu via official Feishu API", + }, + + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + reply: true, + media: true, + }, + + // Onboarding 配置向导 + onboarding: feishuOnboardingAdapter, + + config: { + listAccountIds: (cfg) => listFeishuAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveFeishuAccount(cfg, accountId), + isConfigured: async (account) => !!(account.appId && account.appSecret), + }, + + outbound: { + deliveryMode: "gateway", + textChunkLimit: 4000, + sendText: async (ctx) => { + const account = ctx.account as ResolvedFeishuAccount; + const result = await sendTextMessage(account, ctx.to, ctx.text); + return { + ok: result.ok, + error: result.error ? new Error(result.error) : undefined, + }; + }, + }, + + gateway: { + startAccount: async (ctx) => { + const runtime = getFeishuRuntime(); + const account = ctx.account; + const cfg = ctx.cfg; + + startGateway({ + account, + abortSignal: ctx.abortSignal, + onMessage: async (message) => { + // 只处理文本消息 + if (message.messageType !== "text" || !message.text) { + return; + } + + // 打印收到的消息内容 + console.log(`[feishu:${account.accountId}] 收到消息: ${message.text}`); + + // 构建消息上下文 + const msgCtx: MsgContext = { + From: message.senderId, + Body: message.text, + AccountId: account.accountId, + Provider: "feishu", + Surface: "feishu", + SessionKey: `feishu:${account.accountId}:${message.chatId}`, + To: message.chatId, + ChatType: message.chatType === "p2p" ? "direct" : "group", + }; + + // 使用 dispatchReplyWithBufferedBlockDispatcher + await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: msgCtx, + cfg, + dispatcherOptions: { + deliver: async (payload) => { + // 发送 AI 回复(普通发送,非回复) + const text = payload.text ?? ""; + if (text) { + await sendTextMessage(account, message.chatId, text); + } + }, + }, + }); + }, + logger: { + info: (msg) => console.log(`[feishu:${account.accountId}] ${msg}`), + error: (msg) => console.error(`[feishu:${account.accountId}] ${msg}`), + }, + }); + }, + }, +}; diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts new file mode 100644 index 000000000..7d19dceef --- /dev/null +++ b/extensions/feishu/src/client.ts @@ -0,0 +1,92 @@ +/** + * 飞书消息客户端 + * 负责发送消息 + */ + +import * as lark from "@larksuiteoapi/node-sdk"; +import type { ResolvedFeishuAccount } from "./types.js"; + +// 客户端缓存 +const clientCache = new Map(); + +/** + * 获取或创建飞书客户端 + */ +export function getFeishuClient(account: ResolvedFeishuAccount): lark.Client { + // 只使用 appId 作为缓存 key,避免敏感信息泄露 + const cacheKey = account.appId; + + let client = clientCache.get(cacheKey); + if (!client) { + client = new lark.Client({ + appId: account.appId, + appSecret: account.appSecret, + }); + clientCache.set(cacheKey, client); + } + + return client; +} + +/** + * 发送文本消息到会话 + */ +export async function sendTextMessage( + account: ResolvedFeishuAccount, + chatId: string, + text: string +): Promise<{ ok: boolean; error?: string }> { + const client = getFeishuClient(account); + + try { + const result = await client.im.v1.message.create({ + params: { + receive_id_type: "chat_id", + }, + data: { + receive_id: chatId, + msg_type: "text", + content: JSON.stringify({ text }), + }, + }); + + if (result.code === 0) { + return { ok: true }; + } else { + return { ok: false, error: result.msg }; + } + } catch (error) { + return { ok: false, error: String(error) }; + } +} + +/** + * 回复指定消息 + */ +export async function replyTextMessage( + account: ResolvedFeishuAccount, + messageId: string, + text: string +): Promise<{ ok: boolean; error?: string }> { + const client = getFeishuClient(account); + + try { + const result = await client.im.v1.message.reply({ + path: { + message_id: messageId, + }, + data: { + msg_type: "text", + content: JSON.stringify({ text }), + }, + }); + + if (result.code === 0) { + return { ok: true }; + } else { + return { ok: false, error: result.msg }; + } + } catch (error) { + return { ok: false, error: String(error) }; + } +} diff --git a/extensions/feishu/src/gateway.ts b/extensions/feishu/src/gateway.ts new file mode 100644 index 000000000..36ae57b2f --- /dev/null +++ b/extensions/feishu/src/gateway.ts @@ -0,0 +1,185 @@ +/** + * 飞书长连接网关 + * 负责接收消息 + */ + +import * as lark from "@larksuiteoapi/node-sdk"; +import type { ResolvedFeishuAccount, FeishuMessage } from "./types.js"; +import { sendTextMessage } from "./client.js"; + +// WebSocket 客户端缓存 +const wsClientCache = new Map(); + +// 消息去重缓存 (messageId -> timestamp) +const processedMessages = new Map(); +const MESSAGE_DEDUPE_TTL_MS = 60 * 1000; // 60秒过期 + +// 消息过期时间(30分钟) +// 如果消息发送时间距离当前时间超过此值,则不处理 +// 用于避免服务重启后处理一堆过时消息(飞书对未确认消息会重试4次) +const MESSAGE_EXPIRE_TTL_MS = 30 * 60 * 1000; // 30分钟 + +/** + * 清理过期的去重缓存 + */ +function cleanupDedupeCache(): void { + const now = Date.now(); + for (const [messageId, timestamp] of processedMessages) { + if (now - timestamp > MESSAGE_DEDUPE_TTL_MS) { + processedMessages.delete(messageId); + } + } +} + +/** + * 检查消息是否已处理过(前置去重) + */ +function isDuplicateMessage(messageId: string): boolean { + if (processedMessages.has(messageId)) { + return true; + } + processedMessages.set(messageId, Date.now()); + // 定期清理 + if (processedMessages.size > 100) { + cleanupDedupeCache(); + } + return false; +} + +/** + * 检查消息是否已过期 + * @param createTimeMs 消息创建时间(毫秒时间戳字符串) + * @returns true 表示消息已过期,应该丢弃 + */ +function isMessageExpired(createTimeMs: string | undefined): boolean { + if (!createTimeMs) { + // 如果没有创建时间,默认不过期 + return false; + } + const createTime = parseInt(createTimeMs, 10); + if (isNaN(createTime)) { + return false; + } + const now = Date.now(); + return now - createTime > MESSAGE_EXPIRE_TTL_MS; +} + +export interface GatewayOptions { + account: ResolvedFeishuAccount; + onMessage: (message: FeishuMessage) => Promise; + abortSignal?: AbortSignal; + logger?: { + info: (msg: string) => void; + error: (msg: string) => void; + }; +} + +/** + * 启动飞书长连接网关 + */ +export function startGateway(options: GatewayOptions): lark.WSClient { + const { account, onMessage, abortSignal, logger } = options; + const cacheKey = account.accountId; + + // 如果已存在,先停止 + const existing = wsClientCache.get(cacheKey); + if (existing) { + stopGateway(cacheKey); + } + + const wsClient = new lark.WSClient({ + appId: account.appId, + appSecret: account.appSecret, + loggerLevel: lark.LoggerLevel.error, + }); + + // 监听 abortSignal,支持框架优雅停止 + if (abortSignal) { + abortSignal.addEventListener("abort", () => { + logger?.info("received abort signal, stopping gateway"); + stopGateway(cacheKey); + }, { once: true }); + } + + wsClient.start({ + eventDispatcher: new lark.EventDispatcher({}).register({ + "im.message.receive_v1": async (data) => { + const message = data.message; + if (!message) return {}; + + const messageId = message.message_id || ""; + const createTime = message.create_time; + + // 前置去重检查 + if (isDuplicateMessage(messageId)) { + return {}; + } + + // 检查消息是否过期(超过30分钟的消息不处理) + // 用于避免服务重启后处理一堆过时消息 + if (isMessageExpired(createTime)) { + logger?.info(`Skipping expired message ${message.content}, create_time: ${createTime}`) + return {}; + } + + const feishuMessage: FeishuMessage = { + messageId, + chatId: message.chat_id || "", + chatType: message.chat_type === "p2p" ? "p2p" : "group", + senderId: data.sender?.sender_id?.open_id || "", + messageType: message.message_type || "", + content: message.content || "", + }; + + // 解析文本内容 + if (feishuMessage.messageType === "text") { + try { + const parsed = JSON.parse(feishuMessage.content); + feishuMessage.text = parsed.text; + } catch { + // ignore + } + } + + // 异步处理,不阻塞返回 + setImmediate(async () => { + try { + await onMessage(feishuMessage); + } catch (error) { + logger?.error(`Error handling message: ${error}`); + } + }); + + // 立即返回,避免飞书超时重推 + return {}; + }, + }), + }); + + // 登录成功日志 + logger?.info(`logged in to feishu as ${account.appId}`); + + wsClientCache.set(cacheKey, wsClient); + return wsClient; +} + +/** + * 停止网关 + */ +export function stopGateway(accountId: string): void { + const wsClient = wsClientCache.get(accountId); + if (wsClient) { + try { + // 调用 SDK 提供的关闭方法(如果有的话) + const client = wsClient as unknown as Record; + if (typeof client.close === "function") { + (client.close as () => void)(); + } else if (typeof client.stop === "function") { + (client.stop as () => void)(); + } + } catch { + // 忽略关闭错误 + } + wsClientCache.delete(accountId); + } +} diff --git a/extensions/feishu/src/msg-context.ts b/extensions/feishu/src/msg-context.ts new file mode 100644 index 000000000..909ef2ed3 --- /dev/null +++ b/extensions/feishu/src/msg-context.ts @@ -0,0 +1,14 @@ +/** + * 消息上下文类型定义 + */ + +export type MsgContext = { + From: string; + Body: string; + AccountId: string; + Provider: string; + Surface: string; + SessionKey: string; + To: string; + ChatType: "direct" | "group"; +}; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..13d289a80 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -0,0 +1,18 @@ +/** + * 运行时上下文管理 + */ + +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let feishuRuntime: PluginRuntime | null = null; + +export function setFeishuRuntime(runtime: PluginRuntime) { + feishuRuntime = runtime; +} + +export function getFeishuRuntime(): PluginRuntime { + if (!feishuRuntime) { + throw new Error("Feishu runtime not initialized"); + } + return feishuRuntime; +} diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts new file mode 100644 index 000000000..cf836b239 --- /dev/null +++ b/extensions/feishu/src/types.ts @@ -0,0 +1,28 @@ +/** + * 飞书通道配置类型(单账号模式) + */ + +export interface FeishuChannelConfig { + /** 是否启用 */ + enabled?: boolean; + /** 飞书应用 App ID */ + appId: string; + /** 飞书应用 App Secret */ + appSecret: string; +} + +export interface ResolvedFeishuAccount { + accountId: string; + appId: string; + appSecret: string; +} + +export interface FeishuMessage { + messageId: string; + chatId: string; + chatType: "p2p" | "group"; + senderId: string; + messageType: string; + content: string; + text?: string; +} diff --git a/extensions/feishu/tsconfig.json b/extensions/feishu/tsconfig.json new file mode 100644 index 000000000..0a540c137 --- /dev/null +++ b/extensions/feishu/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true + }, + "include": ["*.ts", "src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}