feat(feishu): sync community contributions from clawdbot-feishu (#12662)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yifeng Wang
2026-02-10 08:19:44 +08:00
committed by GitHub
parent 49c60e9065
commit 5c2cb6c591
7 changed files with 437 additions and 85 deletions

View File

@@ -6,10 +6,17 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "openclaw/plugin-sdk";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type {
FeishuConfig,
FeishuMessageContext,
FeishuMediaInfo,
ResolvedFeishuAccount,
} from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu } from "./send.js";
// --- Message deduplication ---
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
const DEDUP_MAX_SIZE = 1_000;
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
let lastCleanupTime = Date.now();
function tryRecordMessage(messageId: string): boolean {
const now = Date.now();
// Throttled cleanup: evict expired entries at most once per interval
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
for (const [id, ts] of processedMessageIds) {
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
}
lastCleanupTime = now;
}
if (processedMessageIds.has(messageId)) return false;
// Evict oldest entries if cache is full
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
const first = processedMessageIds.keys().next().value!;
processedMessageIds.delete(first);
}
processedMessageIds.set(messageId, now);
return true;
}
// --- Permission error extraction ---
// Extract permission grant URL from Feishu API error response.
type PermissionError = {
@@ -30,16 +68,12 @@ type PermissionError = {
};
function extractPermissionError(err: unknown): PermissionError | null {
if (!err || typeof err !== "object") {
return null;
}
if (!err || typeof err !== "object") return null;
// Axios error structure: err.response.data contains the Feishu error
const axiosErr = err as { response?: { data?: unknown } };
const data = axiosErr.response?.data;
if (!data || typeof data !== "object") {
return null;
}
if (!data || typeof data !== "object") return null;
const feishuErr = data as {
code?: number;
@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
};
// Feishu permission error code: 99991672
if (feishuErr.code !== 99991672) {
return null;
}
if (feishuErr.code !== 99991672) return null;
// Extract the grant URL from the error message (contains the direct link)
const msg = feishuErr.msg ?? "";
@@ -82,28 +114,20 @@ type SenderNameResult = {
async function resolveFeishuSenderName(params: {
account: ResolvedFeishuAccount;
senderOpenId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
log: (...args: any[]) => void;
}): Promise<SenderNameResult> {
const { account, senderOpenId, log } = params;
if (!account.configured) {
return {};
}
if (!senderOpenId) {
return {};
}
if (!account.configured) return {};
if (!senderOpenId) return {};
const cached = senderNameCache.get(senderOpenId);
const now = Date.now();
if (cached && cached.expireAt > now) {
return { name: cached.name };
}
if (cached && cached.expireAt > now) return { name: cached.name };
try {
const client = createFeishuClient(account);
// contact/v3/users/:user_id?user_id_type=open_id
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
const res: any = await client.contact.user.get({
path: { user_id: senderOpenId },
params: { user_id_type: "open_id" },
@@ -196,12 +220,8 @@ function parseMessageContent(content: string, messageType: string): string {
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) {
return false;
}
if (!botOpenId) {
return mentions.length > 0;
}
if (mentions.length === 0) return false;
if (!botOpenId) return mentions.length > 0;
return mentions.some((m) => m.id.open_id === botOpenId);
}
@@ -209,9 +229,7 @@ function stripBotMention(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
): string {
if (!mentions || mentions.length === 0) {
return text;
}
if (!mentions || mentions.length === 0) return text;
let result = text;
for (const mention of mentions) {
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
@@ -523,6 +541,13 @@ export async function handleFeishuMessage(params: {
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Dedup check: skip if this message was already processed
const messageId = event.message.message_id;
if (!tryRecordMessage(messageId)) {
log(`feishu: skipping duplicate message ${messageId}`);
return;
}
let ctx = parseFeishuMessageEvent(event, botOpenId);
const isGroup = ctx.chatType === "group";
@@ -532,9 +557,7 @@ export async function handleFeishuMessage(params: {
senderOpenId: ctx.senderOpenId,
log,
});
if (senderResult.name) {
ctx = { ...ctx, senderName: senderResult.name };
}
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
// Track permission error to inform agent later (with cooldown to avoid repetition)
let permissionErrorForAgent: PermissionError | undefined;
@@ -647,16 +670,61 @@ export async function handleFeishuMessage(params: {
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const route = core.channel.routing.resolveAgentRoute({
// Resolve peer ID for session routing
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
// get a separate session from the main group chat.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
if (isGroup && ctx.rootId) {
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
const topicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
if (topicSessionMode === "enabled") {
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
}
}
let route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? ctx.chatId : ctx.senderOpenId,
id: peerId,
},
});
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg;
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtime = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
log: (msg) => log(msg),
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "dm", id: ctx.senderOpenId },
});
log(
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
);
}
}
}
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isGroup
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`

View File

@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
const meta: ChannelMeta = {
id: "feishu",
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
},
capabilities: {
chatTypes: ["direct", "group"],
chatTypes: ["direct", "channel"],
polls: false,
threads: true,
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: true,
blockStreaming: true,
edit: true,
reply: true,
},
agentPrompt: {
messageToolHints: () => [
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const _account = resolveFeishuAccount({ cfg, accountId });
const account = resolveFeishuAccount({ cfg, accountId });
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
)?.defaults?.groupPolicy;
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") {
return [];
}
if (groupPolicy !== "open") return [];
return [
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
];

View File

@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
.strict()
.optional();
/**
* Dynamic agent creation configuration.
* When enabled, a new agent is created for each unique DM user.
*/
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
maxAgents: z.number().int().positive().optional(),
})
.strict()
.optional();
/**
* Feishu tools configuration.
* Controls which tool categories are enabled.
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
.strict()
.optional();
/**
* Topic session isolation mode for group chats.
* - "disabled" (default): All messages in a group share one session
* - "enabled": Messages in different topics get separate sessions
*
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
* for messages within a topic thread, allowing isolated conversations.
*/
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
export const FeishuGroupSchema = z
.object({
requireMention: z.boolean().optional(),
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topicSessionMode: TopicSessionModeSchema,
})
.strict();
@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
requireMention: z.boolean().optional().default(true),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
topicSessionMode: TopicSessionModeSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema).optional(),
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
tools: FeishuToolsConfigSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,
// Multi-account configuration
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
})

View File

@@ -0,0 +1,131 @@
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DynamicAgentCreationConfig } from "./types.js";
export type MaybeCreateDynamicAgentResult = {
created: boolean;
updatedCfg: OpenClawConfig;
agentId?: string;
};
/**
* Check if a dynamic agent should be created for a DM user and create it if needed.
* This creates a unique agent instance with its own workspace for each DM user.
*/
export async function maybeCreateDynamicAgent(params: {
cfg: OpenClawConfig;
runtime: PluginRuntime;
senderOpenId: string;
dynamicCfg: DynamicAgentCreationConfig;
log: (msg: string) => void;
}): Promise<MaybeCreateDynamicAgentResult> {
const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
// Check if there's already a binding for this user
const existingBindings = cfg.bindings ?? [];
const hasBinding = existingBindings.some(
(b) =>
b.match?.channel === "feishu" &&
b.match?.peer?.kind === "dm" &&
b.match?.peer?.id === senderOpenId,
);
if (hasBinding) {
return { created: false, updatedCfg: cfg };
}
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: cfg };
}
}
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
const agentId = `feishu-${senderOpenId}`;
// Check if agent already exists (but binding was missing)
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
if (existingAgent) {
// Agent exists but binding doesn't - just add the binding
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
const updatedCfg: OpenClawConfig = {
...cfg,
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "dm", id: senderOpenId },
},
},
],
};
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
// Resolve path templates with substitutions
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
// Create directories
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// Update configuration with new agent and binding
const updatedCfg: OpenClawConfig = {
...cfg,
agents: {
...cfg.agents,
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
},
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "dm", id: senderOpenId },
},
},
],
};
// Write updated config using PluginRuntime API
await runtime.config.writeConfigFile(updatedCfg);
return { created: true, updatedCfg, agentId };
}
/**
* Resolve a path that may start with ~ to the user's home directory.
*/
function resolveUserPath(p: string): string {
if (p.startsWith("~/")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
import * as Lark from "@larksuiteoapi/node-sdk";
import * as http from "http";
import type { ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
@@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
accountId?: string;
};
// Per-account WebSocket clients and bot info
// Per-account WebSocket clients, HTTP servers, and bot info
const wsClients = new Map<string, Lark.WSClient>();
const httpServers = new Map<string, http.Server>();
const botOpenIds = new Map<string, string>();
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
@@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
}
/**
* Monitor a single Feishu account.
* Register common event handlers on an EventDispatcher.
* When fireAndForget is true (webhook mode), message handling is not awaited
* to avoid blocking the HTTP response (Lark requires <3s response).
*/
async function monitorSingleAccount(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
}): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
function registerEventHandlers(
eventDispatcher: Lark.EventDispatcher,
context: {
cfg: ClawdbotConfig;
accountId: string;
runtime?: RuntimeEnv;
chatHistories: Map<string, HistoryEntry[]>;
fireAndForget?: boolean;
},
) {
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Fetch bot open_id
const botOpenId = await fetchBotOpenId(account);
botOpenIds.set(accountId, botOpenId ?? "");
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
const connectionMode = account.config.connectionMode ?? "websocket";
if (connectionMode !== "websocket") {
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
return;
}
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
const chatHistories = new Map<string, HistoryEntry[]>();
const eventDispatcher = createEventDispatcher(account);
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
try {
const event = data as unknown as FeishuMessageEvent;
await handleFeishuMessage({
const promise = handleFeishuMessage({
cfg,
event,
botOpenId: botOpenIds.get(accountId),
@@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
chatHistories,
accountId,
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
});
} else {
await promise;
}
} catch (err) {
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
}
@@ -96,6 +90,66 @@ async function monitorSingleAccount(params: {
}
},
});
}
type MonitorAccountParams = {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
};
/**
* Monitor a single Feishu account.
*/
async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
const { cfg, account, runtime, abortSignal } = params;
const { accountId } = account;
const log = runtime?.log ?? console.log;
// Fetch bot open_id
const botOpenId = await fetchBotOpenId(account);
botOpenIds.set(accountId, botOpenId ?? "");
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
const connectionMode = account.config.connectionMode ?? "websocket";
const eventDispatcher = createEventDispatcher(account);
const chatHistories = new Map<string, HistoryEntry[]>();
registerEventHandlers(eventDispatcher, {
cfg,
accountId,
runtime,
chatHistories,
fireAndForget: connectionMode === "webhook",
});
if (connectionMode === "webhook") {
return monitorWebhook({ params, accountId, eventDispatcher });
}
return monitorWebSocket({ params, accountId, eventDispatcher });
}
type ConnectionParams = {
params: MonitorAccountParams;
accountId: string;
eventDispatcher: Lark.EventDispatcher;
};
async function monitorWebSocket({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
log(`feishu[${accountId}]: starting WebSocket connection...`);
const wsClient = createFeishuWSClient(account);
wsClients.set(accountId, wsClient);
return new Promise((resolve, reject) => {
const cleanup = () => {
@@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
abortSignal?.addEventListener("abort", handleAbort, { once: true });
try {
void wsClient.start({ eventDispatcher });
wsClient.start({ eventDispatcher });
log(`feishu[${accountId}]: WebSocket client started`);
} catch (err) {
cleanup();
@@ -128,6 +182,57 @@ async function monitorSingleAccount(params: {
});
}
async function monitorWebhook({
params,
accountId,
eventDispatcher,
}: ConnectionParams): Promise<void> {
const { account, runtime, abortSignal } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const port = account.config.webhookPort ?? 3000;
const path = account.config.webhookPath ?? "/feishu/events";
log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
const server = http.createServer();
server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
httpServers.set(accountId, server);
return new Promise((resolve, reject) => {
const cleanup = () => {
server.close();
httpServers.delete(accountId);
botOpenIds.delete(accountId);
};
const handleAbort = () => {
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
cleanup();
resolve();
};
if (abortSignal?.aborted) {
cleanup();
resolve();
return;
}
abortSignal?.addEventListener("abort", handleAbort, { once: true });
server.listen(port, () => {
log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
});
server.on("error", (err) => {
error(`feishu[${accountId}]: Webhook server error: ${err}`);
abortSignal?.removeEventListener("abort", handleAbort);
reject(err);
});
});
}
/**
* Main entry: start monitoring for all enabled accounts.
*/
@@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
export function stopFeishuMonitor(accountId?: string): void {
if (accountId) {
wsClients.delete(accountId);
const server = httpServers.get(accountId);
if (server) {
server.close();
httpServers.delete(accountId);
}
botOpenIds.delete(accountId);
} else {
wsClients.clear();
for (const server of httpServers.values()) {
server.close();
}
httpServers.clear();
botOpenIds.clear();
}
}

View File

@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import type { MentionTarget } from "./mention.js";
import type { FeishuSendResult } from "./types.js";
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
/**
* Build a Feishu interactive card with markdown content.
* Cards render markdown properly (code blocks, tables, links, etc.)
* Uses schema 2.0 format for proper markdown rendering.
*/
export function buildMarkdownCard(text: string): Record<string, unknown> {
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
elements: [
{
tag: "markdown",
content: text,
},
],
body: {
elements: [
{
tag: "markdown",
content: text,
},
],
},
};
}

View File

@@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
perm?: boolean;
scopes?: boolean;
};
export type DynamicAgentCreationConfig = {
enabled?: boolean;
workspaceTemplate?: string;
agentDirTemplate?: string;
maxAgents?: number;
};