feat: add Feishu channel plugins
This commit is contained in:
parent
5f4715acfc
commit
4c2889c6c6
11
extensions/feishu/clawdbot.plugin.json
Normal file
11
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "feishu",
|
||||||
|
"name": "Feishu Channel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"channels": ["feishu"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
29
extensions/feishu/index.ts
Normal file
29
extensions/feishu/index.ts
Normal 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";
|
||||||
35
extensions/feishu/package.json
Normal file
35
extensions/feishu/package.json
Normal 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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
259
extensions/feishu/src/channel.ts
Normal file
259
extensions/feishu/src/channel.ts
Normal 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}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
92
extensions/feishu/src/client.ts
Normal file
92
extensions/feishu/src/client.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
185
extensions/feishu/src/gateway.ts
Normal file
185
extensions/feishu/src/gateway.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/feishu/src/msg-context.ts
Normal file
14
extensions/feishu/src/msg-context.ts
Normal 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";
|
||||||
|
};
|
||||||
18
extensions/feishu/src/runtime.ts
Normal file
18
extensions/feishu/src/runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
28
extensions/feishu/src/types.ts
Normal file
28
extensions/feishu/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
14
extensions/feishu/tsconfig.json
Normal file
14
extensions/feishu/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user