import { buildSingleChannelSecretPromptState, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, type DmPolicy, type OpenClawConfig, type SecretInput, } from "openclaw/plugin-sdk/setup"; import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import { feishuSetupAdapter } from "./setup-core.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; function normalizeString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed || undefined; } function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, channel, dmPolicy, }) as OpenClawConfig; } function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, channel, allowFrom, }) as OpenClawConfig; } function setFeishuGroupPolicy( cfg: OpenClawConfig, groupPolicy: "open" | "allowlist" | "disabled", ): OpenClawConfig { return setTopLevelChannelGroupPolicy({ cfg, channel, groupPolicy, enabled: true, }) as OpenClawConfig; } function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, groupAllowFrom, }, }, }; } function isFeishuConfigured(cfg: OpenClawConfig): boolean { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const isAppIdConfigured = (value: unknown): boolean => { const asString = normalizeString(value); if (asString) { return true; } if (!value || typeof value !== "object") { return false; } const rec = value as Record; const source = normalizeString(rec.source)?.toLowerCase(); const id = normalizeString(rec.id); if (source === "env" && id) { return Boolean(normalizeString(process.env[id])); } return hasConfiguredSecretInput(value); }; const topLevelConfigured = Boolean( isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), ); const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { if (!account || typeof account !== "object") { return false; } const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); const accountAppIdConfigured = hasOwnAppId ? isAppIdConfigured((account as Record).appId) : isAppIdConfigured(feishuCfg?.appId); const accountSecretConfigured = hasOwnAppSecret ? hasConfiguredSecretInput((account as Record).appSecret) : hasConfiguredSecretInput(feishuCfg?.appSecret); return Boolean(accountAppIdConfigured && accountSecretConfigured); }); return topLevelConfigured || accountConfigured; } async function promptFeishuAllowFrom(params: { cfg: OpenClawConfig; prompter: Parameters>[0]["prompter"]; }): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( [ "Allowlist Feishu DMs by open_id or user_id.", "You can find user open_id in Feishu admin console or via API.", "Examples:", "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ].join("\n"), "Feishu allowlist", ); while (true) { const entry = await params.prompter.text({ message: "Feishu allowFrom (user open_ids)", placeholder: "ou_xxxxx, ou_yyyyy", initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); const parts = splitSetupEntries(String(entry)); if (parts.length === 0) { await params.prompter.note("Enter at least one user.", "Feishu allowlist"); continue; } const unique = mergeAllowFromEntries(existing, parts); return setFeishuAllowFrom(params.cfg, unique); } } async function noteFeishuCredentialHelp( prompter: Parameters>[0]["prompter"], ): Promise { await prompter.note( [ "1) Go to Feishu Open Platform (open.feishu.cn)", "2) Create a self-built app", "3) Get App ID and App Secret from Credentials page", "4) Enable required permissions: im:message, im:chat, contact:user.base:readonly", "5) Publish the app or add it to a test group", "Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.", `Docs: ${formatDocsLink("/channels/feishu", "feishu")}`, ].join("\n"), "Feishu credentials", ); } async function promptFeishuAppId(params: { prompter: Parameters>[0]["prompter"]; initialValue?: string; }): Promise { return String( await params.prompter.text({ message: "Enter Feishu App ID", initialValue: params.initialValue, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); } const feishuDmPolicy: ChannelSetupDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, }; export { feishuSetupAdapter } from "./setup-core.js"; export const feishuSetupWizard: ChannelSetupWizard = { channel, resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", unconfiguredLabel: "needs app credentials", configuredHint: "configured", unconfiguredHint: "needs app creds", configuredScore: 2, unconfiguredScore: 0, resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg), resolveStatusLines: async ({ cfg, configured }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, }); let probeResult = null; if (configured && resolvedCredentials) { try { probeResult = await probeFeishu(resolvedCredentials); } catch {} } if (!configured) { return ["Feishu: needs app credentials"]; } if (probeResult?.ok) { return [`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`]; } return ["Feishu: configured (connection not verified)"]; }, }, credentials: [], finalize: async ({ cfg, prompter, options }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolved = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, }); const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret); const hasConfigCreds = Boolean( typeof feishuCfg?.appId === "string" && feishuCfg.appId.trim() && hasConfigSecret, ); const appSecretPromptState = buildSingleChannelSecretPromptState({ accountConfigured: Boolean(resolved), hasConfigToken: hasConfigSecret, allowEnv: !hasConfigCreds && Boolean(process.env.FEISHU_APP_ID?.trim()), envValue: process.env.FEISHU_APP_SECRET, }); let next = cfg; let appId: string | null = null; let appSecret: SecretInput | null = null; let appSecretProbeValue: string | null = null; if (!resolved) { await noteFeishuCredentialHelp(prompter); } const appSecretResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu", credentialLabel: "App Secret", secretInputMode: options?.secretInputMode, accountConfigured: appSecretPromptState.accountConfigured, canUseEnv: appSecretPromptState.canUseEnv, hasConfigToken: appSecretPromptState.hasConfigToken, envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?", keepPrompt: "Feishu App Secret already configured. Keep it?", inputPrompt: "Enter Feishu App Secret", preferredEnvVar: "FEISHU_APP_SECRET", }); if (appSecretResult.action === "use-env") { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, enabled: true }, }, }; } else if (appSecretResult.action === "set") { appSecret = appSecretResult.value; appSecretProbeValue = appSecretResult.resolvedValue; appId = await promptFeishuAppId({ prompter, initialValue: normalizeString(feishuCfg?.appId) ?? normalizeString(process.env.FEISHU_APP_ID), }); } if (appId && appSecret) { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, enabled: true, appId, appSecret, }, }, }; try { const probe = await probeFeishu({ appId, appSecret: appSecretProbeValue ?? undefined, domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain, }); if (probe.ok) { await prompter.note( `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`, "Feishu connection test", ); } else { await prompter.note( `Connection failed: ${probe.error ?? "unknown error"}`, "Feishu connection test", ); } } catch (err) { await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test"); } } const currentMode = (next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket"; const connectionMode = (await prompter.select({ message: "Feishu connection mode", options: [ { value: "websocket", label: "WebSocket (default)" }, { value: "webhook", label: "Webhook" }, ], initialValue: currentMode, })) as "websocket" | "webhook"; next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, connectionMode, }, }, }; if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) ?.verificationToken; const verificationTokenResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "verification token", secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: hasConfiguredSecretInput(currentVerificationToken), hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), allowEnv: false, }), envPrompt: "", keepPrompt: "Feishu verification token already configured. Keep it?", inputPrompt: "Enter Feishu verification token", preferredEnvVar: "FEISHU_VERIFICATION_TOKEN", }); if (verificationTokenResult.action === "set") { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, verificationToken: verificationTokenResult.value, }, }, }; } const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; const encryptKeyResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "encrypt key", secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: hasConfiguredSecretInput(currentEncryptKey), hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), allowEnv: false, }), envPrompt: "", keepPrompt: "Feishu encrypt key already configured. Keep it?", inputPrompt: "Enter Feishu encrypt key", preferredEnvVar: "FEISHU_ENCRYPT_KEY", }); if (encryptKeyResult.action === "set") { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, encryptKey: encryptKeyResult.value, }, }, }; } const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; const webhookPath = String( await prompter.text({ message: "Feishu webhook path", initialValue: currentWebhookPath ?? "/feishu/events", validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }), ).trim(); next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, webhookPath, }, }, }; } const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; const domain = await prompter.select({ message: "Which Feishu domain?", options: [ { value: "feishu", label: "Feishu (feishu.cn) - China" }, { value: "lark", label: "Lark (larksuite.com) - International" }, ], initialValue: currentDomain, }); next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, domain: domain as "feishu" | "lark", }, }, }; const groupPolicy = (await prompter.select({ message: "Group chat policy", options: [ { value: "allowlist", label: "Allowlist - only respond in specific groups" }, { value: "open", label: "Open - respond in all groups (requires mention)" }, { value: "disabled", label: "Disabled - don't respond in groups" }, ], initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", })) as "allowlist" | "open" | "disabled"; next = setFeishuGroupPolicy(next, groupPolicy); if (groupPolicy === "allowlist") { const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; const entry = await prompter.text({ message: "Group chat allowlist (chat_ids)", placeholder: "oc_xxxxx, oc_yyyyy", initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, }); if (entry) { const parts = splitSetupEntries(String(entry)); if (parts.length > 0) { next = setFeishuGroupAllowFrom(next, parts); } } } return { cfg: next }; }, dmPolicy: feishuDmPolicy, disable: (cfg) => ({ ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, enabled: false }, }, }), };