This commit is contained in:
Wang Haifeng 2026-01-30 17:05:45 +05:30 committed by GitHub
commit 9fa66c4131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 579 additions and 0 deletions

View File

@ -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": "[\"*\"]"
}
}
}

View File

@ -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;

View File

@ -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<ResolvedAccount> = {
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 }),
});
},
},
};

View File

@ -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<void>((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 }));
},
});
}

View File

@ -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;
}