diff --git a/extensions/feishu/clawdbot.plugin.json b/extensions/feishu/clawdbot.plugin.json new file mode 100644 index 000000000..e9c674db1 --- /dev/null +++ b/extensions/feishu/clawdbot.plugin.json @@ -0,0 +1,72 @@ +{ + "id": "feishu", + "name": "Feishu (Lark)", + "version": "1.0.0", + "description": "Feishu/Lark messaging integration for Moltbot", + "kind": "channel", + "channels": ["feishu"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "appId": { + "type": "string", + "description": "Feishu app ID" + }, + "appSecret": { + "type": "string", + "description": "Feishu app secret" + }, + "encryptKey": { + "type": "string", + "description": "Feishu encrypt key" + }, + "verificationToken": { + "type": "string", + "description": "Feishu verification token" + }, + "webhookPath": { + "type": "string", + "description": "Webhook path for Feishu events" + }, + "dmPolicy": { + "type": "string", + "description": "Direct message policy" + }, + "allowFrom": { + "type": "array", + "description": "Allowed senders" + } + } + }, + "uiHints": { + "appId": { + "label": "App ID", + "sensitive": false + }, + "appSecret": { + "label": "App Secret", + "sensitive": true + }, + "encryptKey": { + "label": "Encrypt Key", + "sensitive": true + }, + "verificationToken": { + "label": "Verification Token", + "sensitive": true + }, + "webhookPath": { + "label": "Webhook Path", + "placeholder": "/webhook/feishu" + }, + "dmPolicy": { + "label": "DM Policy", + "placeholder": "pairing" + }, + "allowFrom": { + "label": "Allow From", + "placeholder": "[\"*\"]" + } + } +} \ No newline at end of file diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..7943799ad --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,34 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; +import { setGlobalDispatcher, ProxyAgent } from "undici"; + +// --- FORCE PROXY FOR GEMINI (If Env Var Set) --- +try { + const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + if (proxyUrl) { + console.log(`[Feishu Plugin] Detected proxy env var, setting global proxy to: ${proxyUrl}`); + const dispatcher = new ProxyAgent(proxyUrl); + setGlobalDispatcher(dispatcher); + } +} catch (err) { + console.error(`[Feishu Plugin] Failed to set proxy: ${err}`); +} +// ------------------------------ + +import { feishuPlugin } from "./src/channel.js"; +import { setFeishuRuntime } from "./src/runtime.js"; +import { registerFeishuWebhook } from "./src/monitor.js"; + +const plugin = { + id: "feishu", + name: "Feishu (Lark)", + description: "Feishu/Lark messaging integration", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setFeishuRuntime(api.runtime); + api.registerChannel({ plugin: feishuPlugin }); + registerFeishuWebhook(api); + }, +}; + +export default plugin; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..31dddf836 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,179 @@ +import { + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + type ChannelPlugin, + type ResolvedAccount, +} from "clawdbot/plugin-sdk"; + +import { getFeishuRuntime } from "./runtime.js"; +import { monitorFeishuProvider, sendMessageFeishu } from "./monitor.js"; + +const meta = getChatChannelMeta("feishu"); + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + pairing: { + idLabel: "feishuUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(feishu|fs):/i, ""), + notifyApproval: async ({ id }) => { + console.log(`[Feishu Plugin] Notifying user ${id} of approval`); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: false, + nativeCommands: false, + blockStreaming: false, + }, + reload: { configPrefixes: ["channels.feishu"] }, + configSchema: { + schema: { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean" }, + appId: { type: "string" }, + appSecret: { type: "string" }, + encryptKey: { type: "string" }, + verificationToken: { type: "string" }, + webhookPath: { type: "string" }, + dmPolicy: { type: "string", enum: ["pairing", "open", "allowlist"] }, + allowFrom: { type: "array", items: { type: "string" } }, + groupPolicy: { type: "string", enum: ["open", "allowlist"] }, + groupAllowFrom: { type: "array", items: { type: "string" } }, + }, + }, + }, + config: { + listAccountIds: (cfg) => { + const accounts = []; + if (cfg.channels?.feishu) { + accounts.push("default"); + } + return accounts; + }, + resolveAccount: (cfg, accountId) => { + return cfg.channels?.feishu || {}; + }, + defaultAccountId: (cfg) => { + return cfg.channels?.feishu ? "default" : undefined; + }, + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const feishuConfig = cfg.channels?.feishu || {}; + if (accountId === "default") { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuConfig, + enabled, + }, + }, + }; + } + return cfg; + }, + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "feishu", + accountId, + clearBaseFields: ["appId", "appSecret", "encryptKey", "verificationToken", "webhookPath"], + }), + isConfigured: (account) => Boolean(account.appId?.trim() && account.appSecret?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: Boolean(account.appId?.trim() && account.appSecret?.trim()), + }), + resolveAllowFrom: ({ cfg, accountId }) => { + const account = cfg.channels?.feishu || {}; + return account.allowFrom ?? []; + }, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const feishuConfig = cfg.channels?.feishu || {}; + return { + policy: feishuConfig.dmPolicy ?? "pairing", + allowFrom: feishuConfig.allowFrom ?? [], + allowFromPath: "channels.feishu.allowFrom", + normalizeEntry: (raw) => raw.replace(/^(feishu|fs):/i, ""), + }; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, account }) => { + const feishuConfig = cfg.channels?.feishu || {}; + return feishuConfig.groupPolicy === "allowlist"; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildAccountSnapshot: ({ account, runtime }) => { + const configured = Boolean(account.appId?.trim() && account.appSecret?.trim()); + return { + accountId: account.accountId, + enabled: account.enabled, + configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 2000, + sendText: async ({ to, text, accountId, cfg }) => { + const account = cfg.channels?.feishu || {}; + const result = await sendMessageFeishu( + to, + text, + account.appId, + account.appSecret, + ); + return { channel: "feishu", ...result }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const cfg = ctx.cfg; + const feishuConfig = cfg.channels?.feishu || {}; + + ctx.log?.info(`[${account.accountId}] starting Feishu provider`); + + return monitorFeishuProvider({ + appId: feishuConfig.appId, + appSecret: feishuConfig.appSecret, + encryptKey: feishuConfig.encryptKey, + webhookPath: feishuConfig.webhookPath || "/feishu/events", + accountId: account.accountId, + config: cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), + }); + }, + }, +}; diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts new file mode 100644 index 000000000..14dffc776 --- /dev/null +++ b/extensions/feishu/src/monitor.ts @@ -0,0 +1,280 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import crypto from "node:crypto"; + +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; +import { resolveEffectiveMessagesConfig } from "../../../src/agents/identity.js"; + +class FeishuCipher { + encryptKey; + constructor(encryptKey) { + this.encryptKey = encryptKey; + } + decrypt(encrypted) { + const key = crypto.createHash("sha256").update(this.encryptKey).digest(); + const encryptedBuffer = Buffer.from(encrypted, "base64"); + const iv = encryptedBuffer.subarray(0, 16); + const content = encryptedBuffer.subarray(16); + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + decipher.setAutoPadding(false); + let decrypted = decipher.update(content); + decrypted = Buffer.concat([decrypted, decipher.final()]); + const pad = decrypted[decrypted.length - 1]; + if (pad < 1 || pad > 32) { + return decrypted.toString("utf8"); + } + return decrypted.subarray(0, decrypted.length - pad).toString("utf8"); + } +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({ ok: false, error: "empty payload" }); + return; + } + resolve({ ok: true, value: JSON.parse(raw) as unknown }); + } catch (err) { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +async function getFeishuAccessToken(appId: string, appSecret: string) { + const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app_id: appId, + app_secret: appSecret, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(`Failed to get Feishu access token: ${response.status} ${JSON.stringify(error)}`); + } + + const data = await response.json(); + return data.tenant_access_token; +} + +export async function sendMessageFeishu(chatId: string, text: string, appId: string, appSecret: string) { + const accessToken = await getFeishuAccessToken(appId, appSecret); + + const response = await fetch("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + receive_id: chatId, + content: JSON.stringify({ text }), + msg_type: "text", + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(`Failed to send Feishu message: ${response.status} ${JSON.stringify(error)}`); + } + + return await response.json(); +} + +export async function monitorFeishuProvider(opts: { + appId: string; + appSecret: string; + encryptKey?: string; + webhookPath: string; + accountId: string; + config: MoltbotConfig; + runtime: any; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}) { + const { appId, appSecret, encryptKey, webhookPath, accountId, config, runtime, abortSignal, statusSink } = opts; + + console.log(`[${accountId}] Starting Feishu provider`); + + statusSink?.({ running: true, lastStartAt: Date.now() }); + + try { + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => { + console.log(`[${accountId}] Stopping Feishu provider`); + statusSink?.({ running: false, lastStopAt: Date.now() }); + resolve(); + }, { once: true }); + }); + } catch (err) { + console.error(`[${accountId}] Feishu provider error: ${String(err)}`); + statusSink?.({ running: false, lastError: String(err) }); + throw err; + } +} + +export function registerFeishuWebhook(api: any) { + api.registerHttpRoute({ + path: "/feishu/events", + handler: async (req: IncomingMessage, res: ServerResponse) => { + const config = api.runtime.config.loadConfig(); + const feishuConfig = config.channels?.feishu; + + if (!feishuConfig) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Feishu not configured"); + return; + } + + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "text/plain" }); + res.end("Method Not Allowed"); + return; + } + + const bodyResult = await readJsonBody(req, 1024 * 1024); + if (!bodyResult.ok) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`Bad Request: ${bodyResult.error}`); + return; + } + + let body = bodyResult.value as any; + console.log(`[Feishu Plugin] Received webhook event: ${JSON.stringify(body)}`); + + if (body.challenge) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ challenge: body.challenge })); + return; + } + + if (body.encrypt) { + const cipher = new FeishuCipher(feishuConfig.encryptKey); + const decrypted = cipher.decrypt(body.encrypt); + body = JSON.parse(decrypted); + console.log(`[Feishu Plugin] Decrypted webhook event: ${JSON.stringify(body)}`); + } + + if ((body.header && body.header.event_type === "im.message.receive_v1") || (body.event && body.event.type === "im.message.receive_v1")) { + const message = body.event.message; + const sender = body.event.sender; + + if (!message || !sender) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Bad Request: missing message or sender"); + return; + } + + const senderId = sender.sender_id.user_id || sender.sender_id.open_id || sender.sender_id.union_id; + const messageContent = message.content; + const text = JSON.parse(messageContent).text || ""; + const messageId = message.message_id; + const chatId = message.chat_id; + + console.log(`[Feishu Plugin] Sender ID: ${senderId}`); + console.log(`[Feishu Plugin] Text: ${text}`); + console.log(`[Feishu Plugin] Message ID: ${messageId}, Chat ID: ${chatId}`); + console.log(`[Feishu Plugin] Dispatching message to agent...`); + + const allowFrom = feishuConfig.allowFrom || ["*"]; + const dmPolicy = feishuConfig.dmPolicy || "pairing"; + + if (dmPolicy !== "open" && !allowFrom.includes("*") && !allowFrom.includes(senderId)) { + console.log(`[Feishu Plugin] Sender ${senderId} not allowed`); + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden"); + return; + } + + const ctxPayload = { + From: senderId, + To: chatId, + Body: text, + BodyForAgent: text, + RawBody: text, + CommandBody: text, + BodyForCommands: text, + SessionKey: `feishu:${chatId}`, + MessageSid: messageId, + MessageSidFull: messageId, + ChatType: message.chat_type === "p2p" ? "direct" : "group", + Provider: "feishu", + Surface: "feishu", + OriginatingChannel: "feishu", + OriginatingTo: chatId, + AccountId: "default", + SenderId: senderId, + BodyStripped: text, + IsCommand: false, + CommandSource: "native", + CommandTargetSessionKey: `feishu:${chatId}`, + }; + + try { + console.log(`[Feishu Plugin] Calling dispatchReplyWithBufferedBlockDispatcher...`); + await dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + responsePrefix: resolveEffectiveMessagesConfig(config, "default").responsePrefix, + deliver: async (payload, _info) => { + console.log(`[Feishu Plugin] Received reply payload: ${JSON.stringify(payload)}`); + const replyContent = payload.text || ""; + if (replyContent && feishuConfig.appId && feishuConfig.appSecret) { + try { + console.log(`[Feishu Plugin] Sending reply to ${chatId}: ${replyContent}`); + const result = await sendMessageFeishu(chatId, replyContent, feishuConfig.appId, feishuConfig.appSecret); + console.log(`[Feishu Plugin] Reply sent successfully: ${JSON.stringify(result)}`); + } catch (replyError) { + console.error(`[Feishu Plugin] Error sending AI reply: ${replyError.message}`); + } + } else if (!replyContent) { + console.log(`[Feishu Plugin] No reply content to send`); + } else if (!feishuConfig.appId || !feishuConfig.appSecret) { + console.log(`[Feishu Plugin] Missing Feishu appId or appSecret`); + } + }, + onError: (err, info) => { + console.error(`[Feishu Plugin] Auto-reply error callback: ${String(err)} (${info.kind})`); + }, + }, + }); + console.log(`[Feishu Plugin] dispatchReplyWithBufferedBlockDispatcher finished.`); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + } catch (error) { + console.error(`[Feishu Plugin] Critical error in dispatcher: ${error.message}\n${error.stack}`); + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end(`Internal Server Error: ${error.message}`); + } + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + }, + }); +} diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts new file mode 100644 index 000000000..9fbb18629 --- /dev/null +++ b/extensions/feishu/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setFeishuRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getFeishuRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Feishu runtime not initialized"); + } + return runtime; +}