This commit is contained in:
头上有灰机 2026-01-30 20:24:47 +08:00 committed by GitHub
commit d7cfd283ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 685 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"id": "feishu",
"name": "Feishu Channel",
"version": "1.0.0",
"channels": ["feishu"],
"configSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,92 @@
/**
*
*
*/
import * as lark from "@larksuiteoapi/node-sdk";
import type { ResolvedFeishuAccount } from "./types.js";
// 客户端缓存
const clientCache = new Map<string, lark.Client>();
/**
*
*/
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) };
}
}

View File

@ -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<string, lark.WSClient>();
// 消息去重缓存 (messageId -> timestamp)
const processedMessages = new Map<string, number>();
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<void>;
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<string, unknown>;
if (typeof client.close === "function") {
(client.close as () => void)();
} else if (typeof client.stop === "function") {
(client.stop as () => void)();
}
} catch {
// 忽略关闭错误
}
wsClientCache.delete(accountId);
}
}

View File

@ -0,0 +1,14 @@
/**
*
*/
export type MsgContext = {
From: string;
Body: string;
AccountId: string;
Provider: string;
Surface: string;
SessionKey: string;
To: string;
ChatType: "direct" | "group";
};

View File

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

View File

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

View File

@ -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"]
}