import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin, ClawdbotConfig, } from "clawdbot/plugin-sdk"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, migrateBaseNameToDefaultAccount, missingTargetError, normalizeAccountId, PAIRING_APPROVED_MESSAGE, setAccountEnabledInConfigSection, } from "clawdbot/plugin-sdk"; import { getBotIdentity, sendFeishuTextMessage } from "./api.js"; import { listFeishuAccountIds, resolveDefaultFeishuAccountId, resolveFeishuAccount, type ResolvedFeishuAccount, } from "./accounts.js"; import { FeishuConfigSchema } from "./config-schema.js"; import { feishuOnboardingAdapter } from "./onboarding.js"; import { probeFeishu } from "./probe.js"; import { resolveFeishuWebhookPath, startFeishuMonitor } from "./monitor.js"; import { looksLikeFeishuTargetId, normalizeFeishuMessagingTarget, parseFeishuMessagingTarget, } from "./targets.js"; import { getFeishuRuntime } from "./runtime.js"; const meta = { id: "feishu", label: "Feishu", selectionLabel: "Feishu (Bot API)", docsPath: "/channels/feishu", docsLabel: "feishu", blurb: "Feishu bot events via webhook or long connection.", aliases: ["fs"], order: 85, quickstartAllowFrom: true, } as const; const formatAllowFromEntry = (entry: string) => entry .trim() .replace(/^feishu:/i, "") .replace(/^fs:/i, "") .replace(/^user:/i, "") .replace(/^open_id:/i, "") .replace(/^openid:/i, "") .toLowerCase(); export const feishuDock: ChannelDock = { id: "feishu", capabilities: { chatTypes: ["direct", "group"], blockStreaming: true, }, outbound: { textChunkLimit: 4000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => ( resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? [] ).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry)) .filter(Boolean) .map(formatAllowFromEntry), }, groups: { resolveRequireMention: () => true, }, threading: { resolveReplyToMode: () => "off", }, }; function targetHint() { return ""; } export const feishuPlugin: ChannelPlugin = { id: "feishu", meta: { ...meta }, onboarding: feishuOnboardingAdapter, pairing: { idLabel: "feishuOpenId", normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), notifyApproval: async ({ cfg, id }) => { const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig }); if (account.credentialSource === "none") return; const openId = formatAllowFromEntry(id); await sendFeishuTextMessage({ account, target: { kind: "user", openId }, text: PAIRING_APPROVED_MESSAGE, }); }, }, capabilities: { chatTypes: ["direct", "group"], reactions: false, threads: false, media: false, polls: false, nativeCommands: false, blockStreaming: true, }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, reload: { configPrefixes: ["channels.feishu"] }, configSchema: buildChannelConfigSchema(FeishuConfigSchema), config: { listAccountIds: (cfg) => listFeishuAccountIds(cfg as ClawdbotConfig), resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }), defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg as ClawdbotConfig), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg: cfg as ClawdbotConfig, sectionKey: "feishu", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg: cfg as ClawdbotConfig, sectionKey: "feishu", accountId, clearBaseFields: [ "appId", "appSecret", "verificationToken", "encryptKey", "webhookPath", "webhookUrl", "name", ], }), isConfigured: (account) => account.credentialSource !== "none", describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.credentialSource !== "none", credentialSource: account.credentialSource, }), resolveAllowFrom: ({ cfg, accountId }) => ( resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? [] ).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry)) .filter(Boolean) .map(formatAllowFromEntry), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const useAccountPath = Boolean( (cfg as ClawdbotConfig).channels?.feishu?.accounts?.[resolvedAccountId], ); const basePath = useAccountPath ? `channels.feishu.accounts.${resolvedAccountId}.` : "channels.feishu."; return { policy: account.config.dm?.policy ?? "pairing", allowFrom: account.config.dm?.allowFrom ?? [], policyPath: `${basePath}dm.policy`, allowFromPath: `${basePath}dm.`, approveHint: formatPairingApproveHint("feishu"), normalizeEntry: (raw) => formatAllowFromEntry(raw), }; }, collectWarnings: ({ cfg, account }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy === "open") { warnings.push( `- Feishu groups: groupPolicy="open" allows any group not explicitly denied to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" and configure channels.feishu.groups.`, ); } return warnings; }, }, groups: { resolveRequireMention: () => true, resolveToolPolicy: () => ({ mode: "allow" }), }, threading: { resolveReplyToMode: () => "off", }, messaging: { normalizeTarget: normalizeFeishuMessagingTarget, targetResolver: { looksLikeId: looksLikeFeishuTargetId, hint: targetHint(), }, }, directory: { self: async () => null, listPeers: async ({ cfg, accountId, query, limit }) => { const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }); const q = query?.trim().toLowerCase() || ""; const ids = new Set(); for (const entry of account.config.dm?.allowFrom ?? []) { const trimmed = String(entry).trim(); if (trimmed && trimmed !== "*") ids.add(trimmed); } return Array.from(ids) .map(formatAllowFromEntry) .filter((id) => (q ? id.includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "user", id }) as const); }, listGroups: async ({ cfg, accountId, query, limit }) => { const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }); const q = query?.trim().toLowerCase() || ""; const groups = account.config.groups ?? {}; const ids = Object.keys(groups) .map((id) => id.trim()) .filter((id) => id && id !== "*") .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "group", id }) as const); return ids; }, }, resolver: { resolveTargets: async ({ inputs, kind }) => { const resolved = inputs.map((input) => { const normalized = normalizeFeishuMessagingTarget(input); if (!normalized) { return { input, resolved: false, note: "empty target" }; } if (kind === "user" && normalized.toLowerCase().startsWith("user:")) { return { input, resolved: true, id: normalized }; } if (kind === "group" && normalized.toLowerCase().startsWith("chat:")) { return { input, resolved: true, id: normalized }; } return { input, resolved: false, note: targetHint() }; }); return resolved; }, }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg: cfg as ClawdbotConfig, channelKey: "feishu", accountId, name, }), validateInput: ({ input }) => { if (input.useEnv) { return "Feishu does not support --use-env; set appId/appSecret in config."; } const appId = (input as { appId?: unknown }).appId; const appSecret = (input as { appSecret?: unknown }).appSecret; const modeRaw = (input as { mode?: unknown }).mode; const mode = typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined; if (mode && mode !== "http" && mode !== "ws") { return 'Feishu --mode must be "http" or "ws".'; } const verificationToken = (input as { verificationToken?: unknown }).verificationToken; const encryptKey = (input as { encryptKey?: unknown }).encryptKey; if (typeof appId !== "string" || !appId.trim()) { return "Feishu requires --app-id."; } if (typeof appSecret !== "string" || !appSecret.trim()) { return "Feishu requires --app-secret."; } if (mode !== "ws") { if ( (typeof verificationToken !== "string" || !verificationToken.trim()) && (typeof encryptKey !== "string" || !encryptKey.trim()) ) { return 'Feishu requires --verification-token or --encrypt-key when --mode="http".'; } } return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { const namedConfig = applyAccountNameToChannelSection({ cfg: cfg as ClawdbotConfig, channelKey: "feishu", accountId, name: input.name, }); const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({ cfg: namedConfig as ClawdbotConfig, channelKey: "feishu", }) : namedConfig; const appId = String((input as { appId?: unknown }).appId ?? "").trim(); const appSecret = String((input as { appSecret?: unknown }).appSecret ?? "").trim(); const modeRaw = (input as { mode?: unknown }).mode; const mode = typeof modeRaw === "string" && modeRaw.trim() ? modeRaw.trim().toLowerCase() : undefined; const normalizedMode = mode === "http" || mode === "ws" ? mode : undefined; const verificationTokenRaw = (input as { verificationToken?: unknown }).verificationToken; const encryptKeyRaw = (input as { encryptKey?: unknown }).encryptKey; const verificationToken = typeof verificationTokenRaw === "string" && verificationTokenRaw.trim() ? verificationTokenRaw.trim() : undefined; const encryptKey = typeof encryptKeyRaw === "string" && encryptKeyRaw.trim() ? encryptKeyRaw.trim() : undefined; const webhookPath = input.webhookPath?.trim() || undefined; const webhookUrl = input.webhookUrl?.trim() || undefined; const configPatch = { appId, appSecret, ...(normalizedMode ? { mode: normalizedMode } : {}), ...(verificationToken ? { verificationToken } : {}), ...(encryptKey ? { encryptKey } : {}), ...(webhookPath ? { webhookPath } : {}), ...(webhookUrl ? { webhookUrl } : {}), }; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...next, channels: { ...next.channels, feishu: { ...(next.channels?.feishu ?? {}), enabled: true, ...configPatch, }, }, } as ClawdbotConfig; } return { ...next, channels: { ...next.channels, feishu: { ...(next.channels?.feishu ?? {}), enabled: true, accounts: { ...(next.channels?.feishu?.accounts ?? {}), [accountId]: { ...(next.channels?.feishu?.accounts?.[accountId] ?? {}), enabled: true, ...configPatch, }, }, }, }, } as ClawdbotConfig; }, }, outbound: { deliveryMode: "direct", chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, resolveTarget: ({ to, allowFrom, mode }) => { const trimmed = to?.trim() ?? ""; const allowList = (allowFrom ?? []) .map((entry) => String(entry).trim()) .filter(Boolean) .filter((entry) => entry !== "*") .map((entry) => normalizeFeishuMessagingTarget(entry)) .filter((entry): entry is string => Boolean(entry)); if (trimmed) { const normalized = normalizeFeishuMessagingTarget(trimmed); if (!normalized) { if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) { return { ok: true, to: allowList[0] }; } return { ok: false, error: missingTargetError( "Feishu", `${targetHint()} or channels.feishu.dm.allowFrom[0]`, ), }; } return { ok: true, to: normalized }; } if (allowList.length > 0) { return { ok: true, to: allowList[0] }; } return { ok: false, error: missingTargetError("Feishu", `${targetHint()} or channels.feishu.dm.allowFrom[0]`), }; }, sendText: async ({ cfg, to, text, accountId, replyToId }) => { const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }); if (account.credentialSource === "none") { throw new Error( "Feishu not configured: missing appId/appSecret and webhook validation secret", ); } const target = parseFeishuMessagingTarget(to); if (!target) { throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`); } const bot = await getBotIdentity(account).catch(() => null); const replyToMessageId = replyToId?.trim() || undefined; const result = await sendFeishuTextMessage({ account, target, text, replyToMessageId, }); return { channel: "feishu", messageId: result.messageId, chatId: target.kind === "chat" ? target.chatId : undefined, meta: { target: target.kind === "chat" ? `chat:${target.chatId}` : `user:${target.openId}`, botOpenId: bot?.openId, }, }; }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { const caption = text?.trim() ? `${text.trim()}\n${mediaUrl}` : mediaUrl; const account = resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }); if (account.credentialSource === "none") { throw new Error( "Feishu not configured: missing appId/appSecret and webhook validation secret", ); } const target = parseFeishuMessagingTarget(to); if (!target) { throw new Error(`Invalid Feishu target: ${to}. Expected ${targetHint()}.`); } const result = await sendFeishuTextMessage({ account, target, text: caption, replyToMessageId: replyToId?.trim() || undefined, }); return { channel: "feishu", messageId: result.messageId, chatId: target.kind === "chat" ? target.chatId : undefined, meta: { warning: "media send is not supported yet; delivered as text URL", }, }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, mode: null, webhookPath: null, lastInboundAt: null, lastOutboundAt: null, }, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, mode: snapshot.mode ?? null, webhookPath: snapshot.webhookPath ?? null, lastInboundAt: snapshot.lastInboundAt ?? null, lastOutboundAt: snapshot.lastOutboundAt ?? null, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ account }) => await probeFeishu(account), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.credentialSource !== "none", credentialSource: account.credentialSource, mode: runtime?.mode ?? account.config.mode ?? "http", webhookPath: account.config.webhookPath, webhookUrl: account.config.webhookUrl, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, probe, }), }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const mode = account.config.mode ?? "http"; ctx.log?.info( `[${account.accountId}] starting Feishu ${mode === "ws" ? "long connection" : "webhook"}`, ); ctx.setStatus({ accountId: account.accountId, running: true, lastStartAt: Date.now(), mode, webhookPath: mode === "http" ? resolveFeishuWebhookPath({ account }) : null, }); await startFeishuMonitor({ account, config: ctx.cfg as ClawdbotConfig, runtime: ctx.runtime, abortSignal: ctx.abortSignal, webhookPath: account.config.webhookPath, webhookUrl: account.config.webhookUrl, statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), }); }, }, };