From 5f6e1c19bd18ea45addd3afedf2f88cc3064f3f6 Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Thu, 5 Feb 2026 19:18:25 +0800 Subject: [PATCH] feat(feishu): sync with clawdbot-feishu #137 (multi-account support) - Sync latest changes from clawdbot-feishu including multi-account support - Add eslint-disable comments for SDK-related any types - Remove unused imports - Fix no-floating-promises in monitor.ts Co-Authored-By: Claude Opus 4.5 --- extensions/feishu/src/accounts.ts | 115 ++++++++++-- extensions/feishu/src/bot.ts | 80 ++++++--- extensions/feishu/src/channel.ts | 209 ++++++++++++++++------ extensions/feishu/src/client.ts | 118 ++++++++---- extensions/feishu/src/config-schema.ts | 41 +++++ extensions/feishu/src/directory.ts | 24 ++- extensions/feishu/src/docx.ts | 36 ++-- extensions/feishu/src/drive.ts | 18 +- extensions/feishu/src/media.ts | 90 +++++----- extensions/feishu/src/monitor.ts | 167 ++++++++++------- extensions/feishu/src/outbound.ts | 14 +- extensions/feishu/src/perm.ts | 18 +- extensions/feishu/src/policy.ts | 1 - extensions/feishu/src/probe.ts | 17 +- extensions/feishu/src/reactions.ts | 35 ++-- extensions/feishu/src/reply-dispatcher.ts | 39 ++-- extensions/feishu/src/runtime.ts | 1 - extensions/feishu/src/send.ts | 76 ++++---- extensions/feishu/src/targets.ts | 3 +- extensions/feishu/src/types.ts | 14 +- extensions/feishu/src/typing.ts | 20 ++- extensions/feishu/src/wiki.ts | 18 +- 22 files changed, 785 insertions(+), 369 deletions(-) diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index 510f7dc92f..4464a1597b 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -1,7 +1,81 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; -import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { + FeishuConfig, + FeishuAccountConfig, + FeishuDomain, + ResolvedFeishuAccount, +} from "./types.js"; +/** + * List all configured account IDs from the accounts field. + */ +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + return Object.keys(accounts).filter(Boolean); +} + +/** + * List all Feishu account IDs. + * If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility. + */ +export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + // Backward compatibility: no accounts configured, use default + return [DEFAULT_ACCOUNT_ID]; + } + return [...ids].toSorted((a, b) => a.localeCompare(b)); +} + +/** + * Resolve the default account ID. + */ +export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string { + const ids = listFeishuAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * Get the raw account-specific config. + */ +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): FeishuAccountConfig | undefined { + const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + return accounts[accountId]; +} + +/** + * Merge top-level config with account-specific config. + * Account-specific fields override top-level fields. + */ +function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + + // Extract base config (exclude accounts field to avoid recursion) + const { accounts: _ignored, ...base } = feishuCfg ?? {}; + + // Get account-specific overrides + const account = resolveAccountConfig(cfg, accountId) ?? {}; + + // Merge: account config overrides base config + return { ...base, ...account } as FeishuConfig; +} + +/** + * Resolve Feishu credentials from a config. + */ export function resolveFeishuCredentials(cfg?: FeishuConfig): { appId: string; appSecret: string; @@ -23,31 +97,46 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): { }; } +/** + * Resolve a complete Feishu account with merged config. + */ export function resolveFeishuAccount(params: { cfg: ClawdbotConfig; accountId?: string | null; }): ResolvedFeishuAccount { + const accountId = normalizeAccountId(params.accountId); const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; - const enabled = feishuCfg?.enabled !== false; - const creds = resolveFeishuCredentials(feishuCfg); + + // Base enabled state (top-level) + const baseEnabled = feishuCfg?.enabled !== false; + + // Merge configs + const merged = mergeFeishuAccountConfig(params.cfg, accountId); + + // Account-level enabled state + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + // Resolve credentials from merged config + const creds = resolveFeishuCredentials(merged); return { - accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID, + accountId, enabled, configured: Boolean(creds), + name: (merged as FeishuAccountConfig).name?.trim() || undefined, appId: creds?.appId, + appSecret: creds?.appSecret, + encryptKey: creds?.encryptKey, + verificationToken: creds?.verificationToken, domain: creds?.domain ?? "feishu", + config: merged, }; } -export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] { - return [DEFAULT_ACCOUNT_ID]; -} - -export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string { - return DEFAULT_ACCOUNT_ID; -} - +/** + * List all enabled and configured accounts. + */ export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] { return listFeishuAccountIds(cfg) .map((accountId) => resolveFeishuAccount({ cfg, accountId })) diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 5f0cfa18ab..f90b2d4d37 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -6,7 +6,8 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk"; -import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js"; +import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; @@ -79,12 +80,13 @@ type SenderNameResult = { }; async function resolveFeishuSenderName(params: { - feishuCfg?: FeishuConfig; + account: ResolvedFeishuAccount; senderOpenId: string; - log: (...args: unknown[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function + log: (...args: any[]) => void; }): Promise { - const { feishuCfg, senderOpenId, log } = params; - if (!feishuCfg) { + const { account, senderOpenId, log } = params; + if (!account.configured) { return {}; } if (!senderOpenId) { @@ -98,10 +100,11 @@ async function resolveFeishuSenderName(params: { } try { - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); // contact/v3/users/:user_id?user_id_type=open_id - const res = await client.contact.user.get({ + // 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" }, }); @@ -325,8 +328,9 @@ async function resolveFeishuMediaList(params: { content: string; maxBytes: number; log?: (msg: string) => void; + accountId?: string; }): Promise { - const { cfg, messageId, messageType, content, maxBytes, log } = params; + const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params; // Only process media message types (including post for embedded images) const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"]; @@ -354,6 +358,7 @@ async function resolveFeishuMediaList(params: { messageId, fileKey: imageKey, type: "image", + accountId, }); let contentType = result.contentType; @@ -407,6 +412,7 @@ async function resolveFeishuMediaList(params: { messageId, fileKey, type: resourceType, + accountId, }); buffer = result.buffer; contentType = result.contentType; @@ -506,9 +512,14 @@ export async function handleFeishuMessage(params: { botOpenId?: string; runtime?: RuntimeEnv; chatHistories?: Map; + accountId?: string; }): Promise { - const { cfg, event, botOpenId, runtime, chatHistories } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params; + + // Resolve account with merged config + const account = resolveFeishuAccount({ cfg, accountId }); + const feishuCfg = account.config; + const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; @@ -517,7 +528,7 @@ export async function handleFeishuMessage(params: { // Resolve sender display name (best-effort) so the agent can attribute messages correctly. const senderResult = await resolveFeishuSenderName({ - feishuCfg, + account, senderOpenId: ctx.senderOpenId, log, }); @@ -528,7 +539,7 @@ export async function handleFeishuMessage(params: { // Track permission error to inform agent later (with cooldown to avoid repetition) let permissionErrorForAgent: PermissionError | undefined; if (senderResult.permissionError) { - const appKey = feishuCfg?.appId ?? "default"; + const appKey = account.appId ?? "default"; const now = Date.now(); const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0; @@ -538,12 +549,14 @@ export async function handleFeishuMessage(params: { } } - log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`); + log( + `feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`, + ); // Log mention targets if detected if (ctx.mentionTargets && ctx.mentionTargets.length > 0) { const names = ctx.mentionTargets.map((t) => t.name).join(", "); - log(`feishu: detected @ forward request, targets: [${names}]`); + log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`); } const historyLimit = Math.max( @@ -554,6 +567,7 @@ export async function handleFeishuMessage(params: { if (isGroup) { const groupPolicy = feishuCfg?.groupPolicy ?? "open"; const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; + // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs) @@ -565,7 +579,7 @@ export async function handleFeishuMessage(params: { }); if (!groupAllowed) { - log(`feishu: group ${ctx.chatId} not in allowlist`); + log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`); return; } @@ -591,7 +605,9 @@ export async function handleFeishuMessage(params: { }); if (requireMention && !ctx.mentionedBot) { - log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`); + log( + `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`, + ); if (chatHistories) { recordPendingHistoryEntryIfEnabled({ historyMap: chatHistories, @@ -617,7 +633,7 @@ export async function handleFeishuMessage(params: { senderId: ctx.senderOpenId, }); if (!match.allowed) { - log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`); + log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`); return; } } @@ -634,6 +650,7 @@ export async function handleFeishuMessage(params: { const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "feishu", + accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: isGroup ? ctx.chatId : ctx.senderOpenId, @@ -642,8 +659,8 @@ export async function handleFeishuMessage(params: { const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160); const inboundLabel = isGroup - ? `Feishu message in group ${ctx.chatId}` - : `Feishu DM from ${ctx.senderOpenId}`; + ? `Feishu[${account.accountId}] message in group ${ctx.chatId}` + : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`; core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { sessionKey: route.sessionKey, @@ -659,6 +676,7 @@ export async function handleFeishuMessage(params: { content: event.message.content, maxBytes: mediaMaxBytes, log, + accountId: account.accountId, }); const mediaPayload = buildFeishuMediaPayload(mediaList); @@ -666,13 +684,19 @@ export async function handleFeishuMessage(params: { let quotedContent: string | undefined; if (ctx.parentId) { try { - const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId }); + const quotedMsg = await getMessageFeishu({ + cfg, + messageId: ctx.parentId, + accountId: account.accountId, + }); if (quotedMsg) { quotedContent = quotedMsg.content; - log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`); + log( + `feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`, + ); } } catch (err) { - log(`feishu: failed to fetch quoted message: ${String(err)}`); + log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`); } } @@ -742,9 +766,10 @@ export async function handleFeishuMessage(params: { runtime: runtime as RuntimeEnv, chatId: ctx.chatId, replyToMessageId: ctx.messageId, + accountId: account.accountId, }); - log(`feishu: dispatching permission error notification to agent`); + log(`feishu[${account.accountId}]: dispatching permission error notification to agent`); await core.channel.reply.dispatchReplyFromConfig({ ctx: permissionCtx, @@ -815,9 +840,10 @@ export async function handleFeishuMessage(params: { chatId: ctx.chatId, replyToMessageId: ctx.messageId, mentionTargets: ctx.mentionTargets, + accountId: account.accountId, }); - log(`feishu: dispatching to agent (session=${route.sessionKey})`); + log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ ctx: ctxPayload, @@ -836,8 +862,10 @@ export async function handleFeishuMessage(params: { }); } - log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`); + log( + `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`, + ); } catch (err) { - error(`feishu: failed to dispatch message: ${String(err)}`); + error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`); } } diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 59bbac52cd..40b76722a7 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,7 +1,11 @@ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; -import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js"; +import { + resolveFeishuAccount, + listFeishuAccountIds, + resolveDefaultFeishuAccountId, +} from "./accounts.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups, @@ -34,11 +38,12 @@ export const feishuPlugin: ChannelPlugin = { pairing: { idLabel: "feishuUserId", normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), - notifyApproval: async ({ cfg, id }) => { + notifyApproval: async ({ cfg, id, accountId }) => { await sendMessageFeishu({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE, + accountId, }); }, }, @@ -94,43 +99,111 @@ export const feishuPlugin: ChannelPlugin = { chunkMode: { type: "string", enum: ["length", "newline"] }, mediaMaxMb: { type: "number", minimum: 0 }, renderMode: { type: "string", enum: ["auto", "raw", "card"] }, + accounts: { + type: "object", + additionalProperties: { + type: "object", + properties: { + enabled: { type: "boolean" }, + name: { type: "string" }, + appId: { type: "string" }, + appSecret: { type: "string" }, + encryptKey: { type: "string" }, + verificationToken: { type: "string" }, + domain: { type: "string", enum: ["feishu", "lark"] }, + connectionMode: { type: "string", enum: ["websocket", "webhook"] }, + }, + }, + }, }, }, }, config: { - listAccountIds: () => [DEFAULT_ACCOUNT_ID], - resolveAccount: (cfg) => resolveFeishuAccount({ cfg }), - defaultAccountId: () => DEFAULT_ACCOUNT_ID, - setAccountEnabled: ({ cfg, enabled }) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled, - }, - }, - }), - deleteAccount: ({ cfg }) => { - const next = { ...cfg } as ClawdbotConfig; - const nextChannels = { ...cfg.channels }; - delete (nextChannels as Record).feishu; - if (Object.keys(nextChannels).length > 0) { - next.channels = nextChannels; - } else { - delete next.channels; + listAccountIds: (cfg) => listFeishuAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const _account = resolveFeishuAccount({ cfg, accountId }); + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + + if (isDefault) { + // For default account, set top-level enabled + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled, + }, + }, + }; } - return next; + + // For named accounts, set enabled in accounts[accountId] + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; }, - isConfigured: (_account, cfg) => - Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)), + deleteAccount: ({ cfg, accountId }) => { + const isDefault = accountId === DEFAULT_ACCOUNT_ID; + + if (isDefault) { + // Delete entire feishu config + const next = { ...cfg } as ClawdbotConfig; + const nextChannels = { ...cfg.channels }; + delete (nextChannels as Record).feishu; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + } + + // Delete specific account from accounts + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const accounts = { ...feishuCfg?.accounts }; + delete accounts[accountId]; + + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: Object.keys(accounts).length > 0 ? accounts : undefined, + }, + }, + }; + }, + isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, + name: account.name, + appId: account.appId, + domain: account.domain, }), - resolveAllowFrom: ({ cfg }) => - (cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [], + resolveAllowFrom: ({ cfg, accountId }) => { + const account = resolveFeishuAccount({ cfg, accountId }); + return account.config?.allowFrom ?? []; + }, formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) @@ -138,8 +211,9 @@ export const feishuPlugin: ChannelPlugin = { .map((entry) => entry.toLowerCase()), }, security: { - collectWarnings: ({ cfg }) => { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + collectWarnings: ({ cfg, accountId }) => { + const account = resolveFeishuAccount({ cfg, accountId }); + const feishuCfg = account.config; const defaultGroupPolicy = ( cfg.channels as Record | undefined )?.defaults?.groupPolicy; @@ -148,22 +222,46 @@ export const feishuPlugin: ChannelPlugin = { return []; } return [ - `- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, ]; }, }, setup: { resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; + } + + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled: true, + }, + }, + }, }, - }, - }), + }; + }, }, onboarding: feishuOnboardingAdapter, messaging: { @@ -175,12 +273,14 @@ export const feishuPlugin: ChannelPlugin = { }, directory: { self: async () => null, - listPeers: async ({ cfg, query, limit }) => listFeishuDirectoryPeers({ cfg, query, limit }), - listGroups: async ({ cfg, query, limit }) => listFeishuDirectoryGroups({ cfg, query, limit }), - listPeersLive: async ({ cfg, query, limit }) => - listFeishuDirectoryPeersLive({ cfg, query, limit }), - listGroupsLive: async ({ cfg, query, limit }) => - listFeishuDirectoryGroupsLive({ cfg, query, limit }), + listPeers: async ({ cfg, query, limit, accountId }) => + listFeishuDirectoryPeers({ cfg, query, limit, accountId }), + listGroups: async ({ cfg, query, limit, accountId }) => + listFeishuDirectoryGroups({ cfg, query, limit, accountId }), + listPeersLive: async ({ cfg, query, limit, accountId }) => + listFeishuDirectoryPeersLive({ cfg, query, limit, accountId }), + listGroupsLive: async ({ cfg, query, limit, accountId }) => + listFeishuDirectoryGroupsLive({ cfg, query, limit, accountId }), }, outbound: feishuOutbound, status: { @@ -202,12 +302,17 @@ export const feishuPlugin: ChannelPlugin = { probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - probeAccount: async ({ cfg }) => - await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined), + probeAccount: async ({ cfg, accountId }) => { + const account = resolveFeishuAccount({ cfg, accountId }); + return await probeFeishu(account); + }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, + name: account.name, + appId: account.appId, + domain: account.domain, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, @@ -219,10 +324,12 @@ export const feishuPlugin: ChannelPlugin = { gateway: { startAccount: async (ctx) => { const { monitorFeishuProvider } = await import("./monitor.js"); - const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined; - const port = feishuCfg?.webhookPort ?? null; + const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId }); + const port = account.config?.webhookPort ?? null; ctx.setStatus({ accountId: ctx.accountId, port }); - ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`); + ctx.log?.info( + `starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`, + ); return monitorFeishuProvider({ config: ctx.cfg, runtime: ctx.runtime, diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index c5641c4fc7..3c30890741 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -1,72 +1,118 @@ import * as Lark from "@larksuiteoapi/node-sdk"; -import type { FeishuConfig, FeishuDomain } from "./types.js"; -import { resolveFeishuCredentials } from "./accounts.js"; +import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js"; -let cachedClient: Lark.Client | null = null; -let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null; +// Multi-account client cache +const clientCache = new Map< + string, + { + client: Lark.Client; + config: { appId: string; appSecret: string; domain?: FeishuDomain }; + } +>(); -function resolveDomain(domain: FeishuDomain): Lark.Domain | string { +function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { if (domain === "lark") { return Lark.Domain.Lark; } - if (domain === "feishu") { + if (domain === "feishu" || !domain) { return Lark.Domain.Feishu; } - return domain.replace(/\/+$/, ""); // Custom URL, remove trailing slashes + return domain.replace(/\/+$/, ""); // Custom URL for private deployment } -export function createFeishuClient(cfg: FeishuConfig): Lark.Client { - const creds = resolveFeishuCredentials(cfg); - if (!creds) { - throw new Error("Feishu credentials not configured (appId, appSecret required)"); +/** + * Credentials needed to create a Feishu client. + * Both FeishuConfig and ResolvedFeishuAccount satisfy this interface. + */ +export type FeishuClientCredentials = { + accountId?: string; + appId?: string; + appSecret?: string; + domain?: FeishuDomain; +}; + +/** + * Create or get a cached Feishu client for an account. + * Accepts any object with appId, appSecret, and optional domain/accountId. + */ +export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client { + const { accountId = "default", appId, appSecret, domain } = creds; + + if (!appId || !appSecret) { + throw new Error(`Feishu credentials not configured for account "${accountId}"`); } + // Check cache + const cached = clientCache.get(accountId); if ( - cachedClient && - cachedConfig && - cachedConfig.appId === creds.appId && - cachedConfig.appSecret === creds.appSecret && - cachedConfig.domain === creds.domain + cached && + cached.config.appId === appId && + cached.config.appSecret === appSecret && + cached.config.domain === domain ) { - return cachedClient; + return cached.client; } + // Create new client const client = new Lark.Client({ - appId: creds.appId, - appSecret: creds.appSecret, + appId, + appSecret, appType: Lark.AppType.SelfBuild, - domain: resolveDomain(creds.domain), + domain: resolveDomain(domain), }); - cachedClient = client; - cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain }; + // Cache it + clientCache.set(accountId, { + client, + config: { appId, appSecret, domain }, + }); return client; } -export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient { - const creds = resolveFeishuCredentials(cfg); - if (!creds) { - throw new Error("Feishu credentials not configured (appId, appSecret required)"); +/** + * Create a Feishu WebSocket client for an account. + * Note: WSClient is not cached since each call creates a new connection. + */ +export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient { + const { accountId, appId, appSecret, domain } = account; + + if (!appId || !appSecret) { + throw new Error(`Feishu credentials not configured for account "${accountId}"`); } return new Lark.WSClient({ - appId: creds.appId, - appSecret: creds.appSecret, - domain: resolveDomain(creds.domain), + appId, + appSecret, + domain: resolveDomain(domain), loggerLevel: Lark.LoggerLevel.info, }); } -export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher { - const creds = resolveFeishuCredentials(cfg); +/** + * Create an event dispatcher for an account. + */ +export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher { return new Lark.EventDispatcher({ - encryptKey: creds?.encryptKey, - verificationToken: creds?.verificationToken, + encryptKey: account.encryptKey, + verificationToken: account.verificationToken, }); } -export function clearClientCache() { - cachedClient = null; - cachedConfig = null; +/** + * Get a cached client for an account (if exists). + */ +export function getFeishuClient(accountId: string): Lark.Client | null { + return clientCache.get(accountId)?.client ?? null; +} + +/** + * Clear client cache for a specific account or all accounts. + */ +export function clearClientCache(accountId?: string): void { + if (accountId) { + clientCache.delete(accountId); + } else { + clientCache.clear(); + } } diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index a05a7163b2..b97b67150d 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -83,9 +83,48 @@ export const FeishuGroupSchema = z }) .strict(); +/** + * Per-account configuration. + * All fields are optional - missing fields inherit from top-level config. + */ +export const FeishuAccountConfigSchema = z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), // Display name for this account + appId: z.string().optional(), + appSecret: z.string().optional(), + encryptKey: z.string().optional(), + verificationToken: z.string().optional(), + domain: FeishuDomainSchema.optional(), + connectionMode: FeishuConnectionModeSchema.optional(), + webhookPath: z.string().optional(), + webhookPort: z.number().int().positive().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + configWrites: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema, + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + renderMode: RenderModeSchema, + tools: FeishuToolsConfigSchema, + }) + .strict(); + export const FeishuConfigSchema = z .object({ enabled: z.boolean().optional(), + // Top-level credentials (backward compatible for single-account mode) appId: z.string().optional(), appSecret: z.string().optional(), encryptKey: z.string().optional(), @@ -113,6 +152,8 @@ export const FeishuConfigSchema = z heartbeat: ChannelHeartbeatVisibilitySchema, renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown tools: FeishuToolsConfigSchema, + // Multi-account configuration + accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index c840cffe5d..c87c23513d 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -1,5 +1,5 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk"; -import type { FeishuConfig } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { normalizeFeishuTarget } from "./targets.js"; @@ -19,8 +19,10 @@ export async function listFeishuDirectoryPeers(params: { cfg: ClawdbotConfig; query?: string; limit?: number; + accountId?: string; }): Promise { - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const feishuCfg = account.config; const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); @@ -51,8 +53,10 @@ export async function listFeishuDirectoryGroups(params: { cfg: ClawdbotConfig; query?: string; limit?: number; + accountId?: string; }): Promise { - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const feishuCfg = account.config; const q = params.query?.trim().toLowerCase() || ""; const ids = new Set(); @@ -82,14 +86,15 @@ export async function listFeishuDirectoryPeersLive(params: { cfg: ClawdbotConfig; query?: string; limit?: number; + accountId?: string; }): Promise { - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { return listFeishuDirectoryPeers(params); } try { - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const peers: FeishuDirectoryPeer[] = []; const limit = params.limit ?? 50; @@ -128,14 +133,15 @@ export async function listFeishuDirectoryGroupsLive(params: { cfg: ClawdbotConfig; query?: string; limit?: number; + accountId?: string; }): Promise { - const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { return listFeishuDirectoryGroups(params); } try { - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const groups: FeishuDirectoryGroup[] = []; const limit = params.limit ?? 50; diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index b9cbb25ad3..97475c26e7 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -2,7 +2,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { Type } from "@sinclair/typebox"; import { Readable } from "stream"; -import type { FeishuConfig } from "./types.js"; +import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js"; import { resolveToolsConfig } from "./tools-config.js"; @@ -55,8 +55,8 @@ const BLOCK_TYPE_NAMES: Record = { const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]); /** Clean blocks for insertion (remove unsupported types and read-only fields) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type -function cleanBlocksForInsert(blocks: any[]): { cleaned: unknown[]; skipped: string[] } { +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types +function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } { const skipped: string[] = []; const cleaned = blocks .filter((block) => { @@ -92,13 +92,14 @@ async function convertMarkdown(client: Lark.Client, markdown: string) { }; } +/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */ async function insertBlocks( client: Lark.Client, docToken: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type blocks: any[], parentBlockId?: string, -): Promise<{ children: unknown[]; skipped: string[] }> { +): Promise<{ children: any[]; skipped: string[] }> { + /* eslint-enable @typescript-eslint/no-explicit-any */ const { cleaned, skipped } = cleanBlocksForInsert(blocks); const blockId = parentBlockId ?? docToken; @@ -154,7 +155,7 @@ async function uploadImageToDocx( parent_type: "docx_image", parent_node: blockId, size: imageBuffer.length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK expects stream + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type file: Readable.from(imageBuffer) as any, }, }); @@ -174,13 +175,14 @@ async function downloadImage(url: string): Promise { return Buffer.from(await response.arrayBuffer()); } +/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */ async function processImages( client: Lark.Client, docToken: string, markdown: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type insertedBlocks: any[], ): Promise { + /* eslint-enable @typescript-eslint/no-explicit-any */ const imageUrls = extractImageUrls(markdown); if (imageUrls.length === 0) { return 0; @@ -426,14 +428,24 @@ async function listAppScopes(client: Lark.Client) { // ============ Tool Registration ============ export function registerFeishuDocTools(api: OpenClawPluginApi) { - const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { - api.logger.debug?.("feishu_doc: Feishu credentials not configured, skipping doc tools"); + if (!api.config) { + api.logger.debug?.("feishu_doc: No config available, skipping doc tools"); return; } - const toolsCfg = resolveToolsConfig(feishuCfg.tools); - const getClient = () => createFeishuClient(feishuCfg); + // Check if any account is configured + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools"); + return; + } + + // Use first account's config for tools configuration + const firstAccount = accounts[0]; + const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + + // Helper to get client for the default account + const getClient = () => createFeishuClient(firstAccount); const registered: string[] = []; // Main document tool with action-based dispatch diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index fe30f7cb3f..beefceba35 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,6 +1,6 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { FeishuConfig } from "./types.js"; +import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { resolveToolsConfig } from "./tools-config.js"; @@ -169,19 +169,25 @@ async function deleteFile(client: Lark.Client, fileToken: string, type: string) // ============ Tool Registration ============ export function registerFeishuDriveTools(api: OpenClawPluginApi) { - const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { - api.logger.debug?.("feishu_drive: Feishu credentials not configured, skipping drive tools"); + if (!api.config) { + api.logger.debug?.("feishu_drive: No config available, skipping drive tools"); return; } - const toolsCfg = resolveToolsConfig(feishuCfg.tools); + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools"); + return; + } + + const firstAccount = accounts[0]; + const toolsCfg = resolveToolsConfig(firstAccount.config.tools); if (!toolsCfg.drive) { api.logger.debug?.("feishu_drive: drive tool disabled in config"); return; } - const getClient = () => createFeishuClient(feishuCfg); + const getClient = () => createFeishuClient(firstAccount); api.registerTool( { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 2d9d5b7a8f..c1a32fed7d 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -3,7 +3,7 @@ import fs from "fs"; import os from "os"; import path from "path"; import { Readable } from "stream"; -import type { FeishuConfig } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js"; @@ -25,20 +25,21 @@ export type DownloadMessageResourceResult = { export async function downloadImageFeishu(params: { cfg: ClawdbotConfig; imageKey: string; + accountId?: string; }): Promise { - const { cfg, imageKey } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, imageKey, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const response = await client.im.image.get({ path: { image_key: imageKey }, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type varies + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error( @@ -104,21 +105,22 @@ export async function downloadMessageResourceFeishu(params: { messageId: string; fileKey: string; type: "image" | "file"; + accountId?: string; }): Promise { - const { cfg, messageId, fileKey, type } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, fileKey, type, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const response = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type }, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type varies + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error( @@ -198,14 +200,15 @@ export async function uploadImageFeishu(params: { cfg: ClawdbotConfig; image: Buffer | string; // Buffer or file path imageType?: "message" | "avatar"; + accountId?: string; }): Promise { - const { cfg, image, imageType = "message" } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, image, imageType = "message", accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); // SDK expects a Readable stream, not a Buffer // Use type assertion since SDK actually accepts any Readable at runtime @@ -214,14 +217,14 @@ export async function uploadImageFeishu(params: { const response = await client.im.image.create({ data: { image_type: imageType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK expects stream + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type image: imageStream as any, }, }); // SDK v1.30+ returns data directly without code wrapper on success // On error, it throws or returns { code, msg } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type varies + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); @@ -245,14 +248,15 @@ export async function uploadFileFeishu(params: { fileName: string; fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; duration?: number; // Required for audio/video files, in milliseconds + accountId?: string; }): Promise { - const { cfg, file, fileName, fileType, duration } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, file, fileName, fileType, duration, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); // SDK expects a Readable stream, not a Buffer // Use type assertion since SDK actually accepts any Readable at runtime @@ -262,14 +266,14 @@ export async function uploadFileFeishu(params: { data: { file_type: fileType, file_name: fileName, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK expects stream + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type file: fileStream as any, ...(duration !== undefined && { duration }), }, }); // SDK v1.30+ returns data directly without code wrapper on success - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type varies + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type const responseAny = response as any; if (responseAny.code !== undefined && responseAny.code !== 0) { throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); @@ -291,14 +295,15 @@ export async function sendImageFeishu(params: { to: string; imageKey: string; replyToMessageId?: string; + accountId?: string; }): Promise { - const { cfg, to, imageKey, replyToMessageId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, to, imageKey, replyToMessageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); @@ -353,14 +358,15 @@ export async function sendFileFeishu(params: { to: string; fileKey: string; replyToMessageId?: string; + accountId?: string; }): Promise { - const { cfg, to, fileKey, replyToMessageId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, to, fileKey, replyToMessageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); @@ -465,8 +471,9 @@ export async function sendMediaFeishu(params: { mediaBuffer?: Buffer; fileName?: string; replyToMessageId?: string; + accountId?: string; }): Promise { - const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params; + const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params; let buffer: Buffer; let name: string; @@ -504,8 +511,8 @@ export async function sendMediaFeishu(params: { const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext); if (isImage) { - const { imageKey } = await uploadImageFeishu({ cfg, image: buffer }); - return sendImageFeishu({ cfg, to, imageKey, replyToMessageId }); + const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); + return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId }); } else { const fileType = detectFileType(name); const { fileKey } = await uploadFileFeishu({ @@ -513,7 +520,8 @@ export async function sendMediaFeishu(params: { file: buffer, fileName: name, fileType, + accountId, }); - return sendFileFeishu({ cfg, to, fileKey, replyToMessageId }); + return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId }); } } diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index a38a99f8e5..24ba1211c9 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -1,7 +1,7 @@ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"; import * as Lark from "@larksuiteoapi/node-sdk"; -import type { FeishuConfig } from "./types.js"; -import { resolveFeishuCredentials } from "./accounts.js"; +import type { ResolvedFeishuAccount } from "./types.js"; +import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; import { createFeishuWSClient, createEventDispatcher } from "./client.js"; import { probeFeishu } from "./probe.js"; @@ -13,71 +13,52 @@ export type MonitorFeishuOpts = { accountId?: string; }; -let currentWsClient: Lark.WSClient | null = null; -let botOpenId: string | undefined; +// Per-account WebSocket clients and bot info +const wsClients = new Map(); +const botOpenIds = new Map(); -async function fetchBotOpenId(cfg: FeishuConfig): Promise { +async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise { try { - const result = await probeFeishu(cfg); + const result = await probeFeishu(account); return result.ok ? result.botOpenId : undefined; } catch { return undefined; } } -export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { - const cfg = opts.config; - if (!cfg) { - throw new Error("Config is required for Feishu monitor"); - } - - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - const creds = resolveFeishuCredentials(feishuCfg); - if (!creds) { - throw new Error("Feishu credentials not configured (appId, appSecret required)"); - } - - const log = opts.runtime?.log ?? console.log; - - if (feishuCfg) { - botOpenId = await fetchBotOpenId(feishuCfg); - log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`); - } - - const connectionMode = feishuCfg?.connectionMode ?? "websocket"; - - if (connectionMode === "websocket") { - return monitorWebSocket({ - cfg, - feishuCfg: feishuCfg!, - runtime: opts.runtime, - abortSignal: opts.abortSignal, - }); - } - - throw new Error( - "feishu: webhook mode not implemented in monitor. Use websocket mode or configure an external HTTP server.", - ); -} - -async function monitorWebSocket(params: { +/** + * Monitor a single Feishu account. + */ +async function monitorSingleAccount(params: { cfg: ClawdbotConfig; - feishuCfg: FeishuConfig; + account: ResolvedFeishuAccount; runtime?: RuntimeEnv; abortSignal?: AbortSignal; }): Promise { - const { cfg, feishuCfg, runtime, abortSignal } = params; + const { cfg, account, runtime, abortSignal } = params; + const { accountId } = account; const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; - log("feishu: starting WebSocket connection..."); + // Fetch bot open_id + const botOpenId = await fetchBotOpenId(account); + botOpenIds.set(accountId, botOpenId ?? ""); + log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`); - const wsClient = createFeishuWSClient(feishuCfg); - currentWsClient = wsClient; + 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(feishuCfg); + const eventDispatcher = createEventDispatcher(account); eventDispatcher.register({ "im.message.receive_v1": async (data) => { @@ -86,12 +67,13 @@ async function monitorWebSocket(params: { await handleFeishuMessage({ cfg, event, - botOpenId, + botOpenId: botOpenIds.get(accountId), runtime, chatHistories, + accountId, }); } catch (err) { - error(`feishu: error handling message event: ${String(err)}`); + error(`feishu[${accountId}]: error handling message: ${String(err)}`); } }, "im.message.message_read_v1": async () => { @@ -100,30 +82,29 @@ async function monitorWebSocket(params: { "im.chat.member.bot.added_v1": async (data) => { try { const event = data as unknown as FeishuBotAddedEvent; - log(`feishu: bot added to chat ${event.chat_id}`); + log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`); } catch (err) { - error(`feishu: error handling bot added event: ${String(err)}`); + error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`); } }, "im.chat.member.bot.deleted_v1": async (data) => { try { const event = data as unknown as { chat_id: string }; - log(`feishu: bot removed from chat ${event.chat_id}`); + log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`); } catch (err) { - error(`feishu: error handling bot removed event: ${String(err)}`); + error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`); } }, }); return new Promise((resolve, reject) => { const cleanup = () => { - if (currentWsClient === wsClient) { - currentWsClient = null; - } + wsClients.delete(accountId); + botOpenIds.delete(accountId); }; const handleAbort = () => { - log("feishu: abort signal received, stopping WebSocket client"); + log(`feishu[${accountId}]: abort signal received, stopping`); cleanup(); resolve(); }; @@ -137,11 +118,8 @@ async function monitorWebSocket(params: { abortSignal?.addEventListener("abort", handleAbort, { once: true }); try { - void wsClient.start({ - eventDispatcher, - }); - - log("feishu: WebSocket client started"); + void wsClient.start({ eventDispatcher }); + log(`feishu[${accountId}]: WebSocket client started`); } catch (err) { cleanup(); abortSignal?.removeEventListener("abort", handleAbort); @@ -150,8 +128,63 @@ async function monitorWebSocket(params: { }); } -export function stopFeishuMonitor(): void { - if (currentWsClient) { - currentWsClient = null; +/** + * Main entry: start monitoring for all enabled accounts. + */ +export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { + const cfg = opts.config; + if (!cfg) { + throw new Error("Config is required for Feishu monitor"); + } + + const log = opts.runtime?.log ?? console.log; + + // If accountId is specified, only monitor that account + if (opts.accountId) { + const account = resolveFeishuAccount({ cfg, accountId: opts.accountId }); + if (!account.enabled || !account.configured) { + throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`); + } + return monitorSingleAccount({ + cfg, + account, + runtime: opts.runtime, + abortSignal: opts.abortSignal, + }); + } + + // Otherwise, start all enabled accounts + const accounts = listEnabledFeishuAccounts(cfg); + if (accounts.length === 0) { + throw new Error("No enabled Feishu accounts configured"); + } + + log( + `feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`, + ); + + // Start all accounts in parallel + await Promise.all( + accounts.map((account) => + monitorSingleAccount({ + cfg, + account, + runtime: opts.runtime, + abortSignal: opts.abortSignal, + }), + ), + ); +} + +/** + * Stop monitoring for a specific account or all accounts. + */ +export function stopFeishuMonitor(accountId?: string): void { + if (accountId) { + wsClients.delete(accountId); + botOpenIds.delete(accountId); + } else { + wsClients.clear(); + botOpenIds.clear(); } } diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index db80f1a0e0..31885d8e09 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -8,33 +8,33 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text }) => { - const result = await sendMessageFeishu({ cfg, to, text }); + sendText: async ({ cfg, to, text, accountId }) => { + const result = await sendMessageFeishu({ cfg, to, text, accountId }); return { channel: "feishu", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { // Send text first if provided if (text?.trim()) { - await sendMessageFeishu({ cfg, to, text }); + await sendMessageFeishu({ cfg, to, text, accountId }); } // Upload and send media if URL provided if (mediaUrl) { try { - const result = await sendMediaFeishu({ cfg, to, mediaUrl }); + const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId }); return { channel: "feishu", ...result }; } catch (err) { // Log the error for debugging console.error(`[feishu] sendMediaFeishu failed:`, err); // Fallback to URL link if upload fails const fallbackText = `📎 ${mediaUrl}`; - const result = await sendMessageFeishu({ cfg, to, text: fallbackText }); + const result = await sendMessageFeishu({ cfg, to, text: fallbackText, accountId }); return { channel: "feishu", ...result }; } } // No media URL, just return text result - const result = await sendMessageFeishu({ cfg, to, text: text ?? "" }); + const result = await sendMessageFeishu({ cfg, to, text: text ?? "", accountId }); return { channel: "feishu", ...result }; }, }; diff --git a/extensions/feishu/src/perm.ts b/extensions/feishu/src/perm.ts index 22184dbe9f..f11fb9882e 100644 --- a/extensions/feishu/src/perm.ts +++ b/extensions/feishu/src/perm.ts @@ -1,6 +1,6 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { FeishuConfig } from "./types.js"; +import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js"; import { resolveToolsConfig } from "./tools-config.js"; @@ -118,19 +118,25 @@ async function removeMember( // ============ Tool Registration ============ export function registerFeishuPermTools(api: OpenClawPluginApi) { - const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { - api.logger.debug?.("feishu_perm: Feishu credentials not configured, skipping perm tools"); + if (!api.config) { + api.logger.debug?.("feishu_perm: No config available, skipping perm tools"); return; } - const toolsCfg = resolveToolsConfig(feishuCfg.tools); + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools"); + return; + } + + const firstAccount = accounts[0]; + const toolsCfg = resolveToolsConfig(firstAccount.config.tools); if (!toolsCfg.perm) { api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)"); return; } - const getClient = () => createFeishuClient(feishuCfg); + const getClient = () => createFeishuClient(firstAccount); api.registerTool( { diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index dd8e5659b2..cd9eb90496 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -58,7 +58,6 @@ export function resolveFeishuGroupConfig(params: { export function resolveFeishuGroupToolPolicy( params: ChannelGroupContext, - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- type resolution issue with plugin-sdk ): GroupToolPolicyConfig | undefined { const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined; if (!cfg) { diff --git a/extensions/feishu/src/probe.ts b/extensions/feishu/src/probe.ts index f6de11de18..3de5bc55dc 100644 --- a/extensions/feishu/src/probe.ts +++ b/extensions/feishu/src/probe.ts @@ -1,10 +1,8 @@ -import type { FeishuConfig, FeishuProbeResult } from "./types.js"; -import { resolveFeishuCredentials } from "./accounts.js"; -import { createFeishuClient } from "./client.js"; +import type { FeishuProbeResult } from "./types.js"; +import { createFeishuClient, type FeishuClientCredentials } from "./client.js"; -export async function probeFeishu(cfg?: FeishuConfig): Promise { - const creds = resolveFeishuCredentials(cfg); - if (!creds) { +export async function probeFeishu(creds?: FeishuClientCredentials): Promise { + if (!creds?.appId || !creds?.appSecret) { return { ok: false, error: "missing credentials (appId, appSecret)", @@ -12,10 +10,9 @@ export async function probeFeishu(cfg?: FeishuConfig): Promise { - const { cfg, messageId, emojiType } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, emojiType, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const response = (await client.im.messageReaction.create({ path: { message_id: messageId }, @@ -59,14 +60,15 @@ export async function removeReactionFeishu(params: { cfg: ClawdbotConfig; messageId: string; reactionId: string; + accountId?: string; }): Promise { - const { cfg, messageId, reactionId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, reactionId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const response = (await client.im.messageReaction.delete({ path: { @@ -87,14 +89,15 @@ export async function listReactionsFeishu(params: { cfg: ClawdbotConfig; messageId: string; emojiType?: string; + accountId?: string; }): Promise { - const { cfg, messageId, emojiType } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, emojiType, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const response = (await client.im.messageReaction.list({ path: { message_id: messageId }, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 0c858a3e5a..f25ae45bf7 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -7,7 +7,7 @@ import { type ReplyPayload, } from "openclaw/plugin-sdk"; import type { MentionTarget } from "./mention.js"; -import type { FeishuConfig } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; @@ -36,11 +36,16 @@ export type CreateFeishuReplyDispatcherParams = { replyToMessageId?: string; /** Mention targets, will be auto-included in replies */ mentionTargets?: MentionTarget[]; + /** Account ID for multi-account support */ + accountId?: string; }; export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { const core = getFeishuRuntime(); - const { cfg, agentId, chatId, replyToMessageId, mentionTargets } = params; + const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params; + + // Resolve account for config access + const account = resolveFeishuAccount({ cfg, accountId }); const prefixContext = createReplyPrefixContext({ cfg, @@ -56,16 +61,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (!replyToMessageId) { return; } - typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId }); - params.runtime.log?.(`feishu: added typing indicator reaction`); + typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId }); + params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`); }, stop: async () => { if (!typingState) { return; } - await removeTypingIndicator({ cfg, state: typingState }); + await removeTypingIndicator({ cfg, state: typingState, accountId }); typingState = null; - params.runtime.log?.(`feishu: removed typing indicator reaction`); + params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`); }, onStartError: (err) => { logTypingFailure({ @@ -103,15 +108,17 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), onReplyStart: typingCallbacks.onReplyStart, deliver: async (payload: ReplyPayload) => { - params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`); + params.runtime.log?.( + `feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`, + ); const text = payload.text ?? ""; if (!text.trim()) { - params.runtime.log?.(`feishu deliver: empty text, skipping`); + params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`); return; } // Check render mode: auto (default), raw, or card - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const feishuCfg = account.config; const renderMode = feishuCfg?.renderMode ?? "auto"; // Determine if we should use card for this message @@ -123,7 +130,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (useCard) { // Card mode: send as interactive card with markdown rendering const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); - params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`); + params.runtime.log?.( + `feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`, + ); for (const chunk of chunks) { await sendMarkdownCardFeishu({ cfg, @@ -131,6 +140,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP text: chunk, replyToMessageId, mentions: isFirstChunk ? mentionTargets : undefined, + accountId, }); isFirstChunk = false; } @@ -138,7 +148,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP // Raw mode: send as plain text with table conversion const converted = core.channel.text.convertMarkdownTables(text, tableMode); const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode); - params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`); + params.runtime.log?.( + `feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`, + ); for (const chunk of chunks) { await sendMessageFeishu({ cfg, @@ -146,13 +158,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP text: chunk, replyToMessageId, mentions: isFirstChunk ? mentionTargets : undefined, + accountId, }); isFirstChunk = false; } } }, onError: (err, info) => { - params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`); + params.runtime.error?.( + `feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`, + ); typingCallbacks.onIdle?.(); }, onIdle: typingCallbacks.onIdle, diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index 9001c0af4b..f1148c5e7d 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -1,6 +1,5 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents let runtime: PluginRuntime | null = null; export function setFeishuRuntime(next: PluginRuntime) { diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index fb7fdd5d25..48f7453eba 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -1,6 +1,7 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk"; import type { MentionTarget } from "./mention.js"; -import type { FeishuConfig, FeishuSendResult } from "./types.js"; +import type { FeishuSendResult } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -23,14 +24,15 @@ export type FeishuMessageInfo = { export async function getMessageFeishu(params: { cfg: ClawdbotConfig; messageId: string; + accountId?: string; }): Promise { - const { cfg, messageId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); try { const response = (await client.im.message.get({ @@ -95,9 +97,11 @@ export type SendFeishuMessageParams = { replyToMessageId?: string; /** Mention target users */ mentions?: MentionTarget[]; + /** Account ID (optional, uses default if not specified) */ + accountId?: string; }; -function buildFeishuPostMessagePayload(params: { feishuCfg: FeishuConfig; messageText: string }): { +function buildFeishuPostMessagePayload(params: { messageText: string }): { content: string; msgType: string; } { @@ -122,13 +126,13 @@ function buildFeishuPostMessagePayload(params: { feishuCfg: FeishuConfig; messag export async function sendMessageFeishu( params: SendFeishuMessageParams, ): Promise { - const { cfg, to, text, replyToMessageId, mentions } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, to, text, replyToMessageId, mentions, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); @@ -147,10 +151,7 @@ export async function sendMessageFeishu( } const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode); - const { content, msgType } = buildFeishuPostMessagePayload({ - feishuCfg, - messageText, - }); + const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); if (replyToMessageId) { const response = await client.im.message.reply({ @@ -195,16 +196,17 @@ export type SendFeishuCardParams = { to: string; card: Record; replyToMessageId?: string; + accountId?: string; }; export async function sendCardFeishu(params: SendFeishuCardParams): Promise { - const { cfg, to, card, replyToMessageId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, to, card, replyToMessageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const receiveId = normalizeFeishuTarget(to); if (!receiveId) { throw new Error(`Invalid Feishu target: ${to}`); @@ -255,14 +257,15 @@ export async function updateCardFeishu(params: { cfg: ClawdbotConfig; messageId: string; card: Record; + accountId?: string; }): Promise { - const { cfg, messageId, card } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, card, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const content = JSON.stringify(card); const response = await client.im.message.patch({ @@ -304,15 +307,16 @@ export async function sendMarkdownCardFeishu(params: { replyToMessageId?: string; /** Mention target users */ mentions?: MentionTarget[]; + accountId?: string; }): Promise { - const { cfg, to, text, replyToMessageId, mentions } = params; + const { cfg, to, text, replyToMessageId, mentions, accountId } = params; // Build message content (with @mention support) let cardText = text; if (mentions && mentions.length > 0) { cardText = buildMentionedCardContent(mentions, text); } const card = buildMarkdownCard(cardText); - return sendCardFeishu({ cfg, to, card, replyToMessageId }); + return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId }); } /** @@ -323,24 +327,22 @@ export async function editMessageFeishu(params: { cfg: ClawdbotConfig; messageId: string; text: string; + accountId?: string; }): Promise { - const { cfg, messageId, text } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { - throw new Error("Feishu channel not configured"); + const { cfg, messageId, text, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu", }); const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); - const { content, msgType } = buildFeishuPostMessagePayload({ - feishuCfg, - messageText, - }); + const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); const response = await client.im.message.update({ path: { message_id: messageId }, diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts index 8c3eb56aec..94f46a9e48 100644 --- a/extensions/feishu/src/targets.ts +++ b/extensions/feishu/src/targets.ts @@ -57,8 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_ if (trimmed.startsWith(OPEN_ID_PREFIX)) { return "open_id"; } - // Default to user_id for other alphanumeric IDs (e.g., enterprise user IDs) - return "user_id"; + return "open_id"; } export function looksLikeFeishuId(raw: string): boolean { diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 1ab2d26129..9892e860a2 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -1,8 +1,14 @@ -import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js"; +import type { + FeishuConfigSchema, + FeishuGroupSchema, + FeishuAccountConfigSchema, + z, +} from "./config-schema.js"; import type { MentionTarget } from "./mention.js"; export type FeishuConfig = z.infer; export type FeishuGroupConfig = z.infer; +export type FeishuAccountConfig = z.infer; export type FeishuDomain = "feishu" | "lark" | (string & {}); export type FeishuConnectionMode = "websocket" | "webhook"; @@ -11,8 +17,14 @@ export type ResolvedFeishuAccount = { accountId: string; enabled: boolean; configured: boolean; + name?: string; appId?: string; + appSecret?: string; + encryptKey?: string; + verificationToken?: string; domain: FeishuDomain; + /** Merged config (top-level defaults + account-specific overrides) */ + config: FeishuConfig; }; export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id"; diff --git a/extensions/feishu/src/typing.ts b/extensions/feishu/src/typing.ts index 662db0afa2..af72d95f9f 100644 --- a/extensions/feishu/src/typing.ts +++ b/extensions/feishu/src/typing.ts @@ -1,5 +1,5 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk"; -import type { FeishuConfig } from "./types.js"; +import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; // Feishu emoji types for typing indicator @@ -18,14 +18,15 @@ export type TypingIndicatorState = { export async function addTypingIndicator(params: { cfg: ClawdbotConfig; messageId: string; + accountId?: string; }): Promise { - const { cfg, messageId } = params; - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { + const { cfg, messageId, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { return { messageId, reactionId: null }; } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); try { const response = await client.im.messageReaction.create({ @@ -51,18 +52,19 @@ export async function addTypingIndicator(params: { export async function removeTypingIndicator(params: { cfg: ClawdbotConfig; state: TypingIndicatorState; + accountId?: string; }): Promise { - const { cfg, state } = params; + const { cfg, state, accountId } = params; if (!state.reactionId) { return; } - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg) { + const account = resolveFeishuAccount({ cfg, accountId }); + if (!account.configured) { return; } - const client = createFeishuClient(feishuCfg); + const client = createFeishuClient(account); try { await client.im.messageReaction.delete({ diff --git a/extensions/feishu/src/wiki.ts b/extensions/feishu/src/wiki.ts index e6c782aed5..dc76bcc6d7 100644 --- a/extensions/feishu/src/wiki.ts +++ b/extensions/feishu/src/wiki.ts @@ -1,6 +1,6 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { FeishuConfig } from "./types.js"; +import { listEnabledFeishuAccounts } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { resolveToolsConfig } from "./tools-config.js"; import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js"; @@ -157,19 +157,25 @@ async function renameNode(client: Lark.Client, spaceId: string, nodeToken: strin // ============ Tool Registration ============ export function registerFeishuWikiTools(api: OpenClawPluginApi) { - const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined; - if (!feishuCfg?.appId || !feishuCfg?.appSecret) { - api.logger.debug?.("feishu_wiki: Feishu credentials not configured, skipping wiki tools"); + if (!api.config) { + api.logger.debug?.("feishu_wiki: No config available, skipping wiki tools"); return; } - const toolsCfg = resolveToolsConfig(feishuCfg.tools); + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools"); + return; + } + + const firstAccount = accounts[0]; + const toolsCfg = resolveToolsConfig(firstAccount.config.tools); if (!toolsCfg.wiki) { api.logger.debug?.("feishu_wiki: wiki tool disabled in config"); return; } - const getClient = () => createFeishuClient(feishuCfg); + const getClient = () => createFeishuClient(firstAccount); api.registerTool( {