diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index c2fda3ea1d4..5afe487f145 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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(); // 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 { 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}` diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ad5974b99a8..d4c8e102016 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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 = { }, }, 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 = { 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 = { 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 = { cfg.channels as Record | 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.`, ]; diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index b97b67150dd..9c09af9ec99 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -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(), }) diff --git a/extensions/feishu/src/dynamic-agent.ts b/extensions/feishu/src/dynamic-agent.ts new file mode 100644 index 00000000000..d10f3ecc26d --- /dev/null +++ b/extensions/feishu/src/dynamic-agent.ts @@ -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 { + 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; +} diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 24ba1211c9c..31a890c2f92 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -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(); +const httpServers = new Map(); const botOpenIds = new Map(); async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise { @@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise { - const { cfg, account, runtime, abortSignal } = params; - const { accountId } = account; +function registerEventHandlers( + eventDispatcher: Lark.EventDispatcher, + context: { + cfg: ClawdbotConfig; + accountId: string; + runtime?: RuntimeEnv; + chatHistories: Map; + 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(); - 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 { + 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(); + + 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 { + 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 { + 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(); } } diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 48f7453eba4..4ca735361f6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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 { return { + schema: "2.0", config: { wide_screen_mode: true, }, - elements: [ - { - tag: "markdown", - content: text, - }, - ], + body: { + elements: [ + { + tag: "markdown", + content: text, + }, + ], + }, }; } diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 9892e860a29..dbfde807806 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -73,3 +73,10 @@ export type FeishuToolsConfig = { perm?: boolean; scopes?: boolean; }; + +export type DynamicAgentCreationConfig = { + enabled?: boolean; + workspaceTemplate?: string; + agentDirTemplate?: string; + maxAgents?: number; +};