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 <noreply@anthropic.com>
This commit is contained in:
Yifeng Wang
2026-02-05 19:18:25 +08:00
committed by cpojer
parent 7e005acd3c
commit 5f6e1c19bd
22 changed files with 785 additions and 369 deletions

View File

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

View File

@@ -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<SenderNameResult> {
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<FeishuMediaInfo[]> {
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<string, HistoryEntry[]>;
accountId?: string;
}): Promise<void> {
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)}`);
}
}

View File

@@ -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<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
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<string, unknown>).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<string, unknown>).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<ResolvedFeishuAccount> = {
.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<string, { groupPolicy?: string }> | undefined
)?.defaults?.groupPolicy;
@@ -148,22 +222,46 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
},
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<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
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,

View File

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

View File

@@ -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) => {

View File

@@ -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<FeishuDirectoryPeer[]> {
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<string>();
@@ -51,8 +53,10 @@ export async function listFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
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<string>();
@@ -82,14 +86,15 @@ export async function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
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<FeishuDirectoryGroup[]> {
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;

View File

@@ -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<number, string> = {
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<Buffer> {
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<number> {
/* 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

View File

@@ -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(
{

View File

@@ -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<DownloadImageResult> {
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<DownloadMessageResourceResult> {
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<UploadImageResult> {
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<UploadFileResult> {
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<SendMediaResult> {
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<SendMediaResult> {
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<SendMediaResult> {
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 });
}
}

View File

@@ -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<string, Lark.WSClient>();
const botOpenIds = new Map<string, string>();
async function fetchBotOpenId(cfg: FeishuConfig): Promise<string | undefined> {
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
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<void> {
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<void> {
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<string, HistoryEntry[]>();
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<void> {
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();
}
}

View File

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

View File

@@ -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(
{

View File

@@ -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) {

View File

@@ -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<FeishuProbeResult> {
const creds = resolveFeishuCredentials(cfg);
if (!creds) {
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
if (!creds?.appId || !creds?.appSecret) {
return {
ok: false,
error: "missing credentials (appId, appSecret)",
@@ -12,10 +10,9 @@ export async function probeFeishu(cfg?: FeishuConfig): Promise<FeishuProbeResult
}
try {
const client = createFeishuClient(cfg!);
// Use im.chat.list as a simple connectivity test
// The bot info API path varies by SDK version
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal SDK method
const client = createFeishuClient(creds);
// Use bot/v3/info API to get bot information
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
const response = await (client as any).request({
method: "GET",
url: "/open-apis/bot/v3/info",

View File

@@ -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";
export type FeishuReaction = {
@@ -18,14 +18,15 @@ export async function addReactionFeishu(params: {
cfg: ClawdbotConfig;
messageId: string;
emojiType: string;
accountId?: string;
}): Promise<{ reactionId: string }> {
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<void> {
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<FeishuReaction[]> {
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 },

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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<FeishuMessageInfo | null> {
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<FeishuSendResult> {
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<string, unknown>;
replyToMessageId?: string;
accountId?: string;
};
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
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<string, unknown>;
accountId?: string;
}): Promise<void> {
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<FeishuSendResult> {
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<void> {
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 },

View File

@@ -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 {

View File

@@ -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<typeof FeishuConfigSchema>;
export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
export type FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
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";

View File

@@ -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<TypingIndicatorState> {
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<void> {
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({

View File

@@ -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(
{