import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, ClawdbotConfig, DmPolicy, SecretInput, WizardPrompter, } from "openclaw/plugin-sdk"; import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink, hasConfiguredSecretInput, promptSingleChannelSecretInput, } from "openclaw/plugin-sdk"; import { resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry)) : undefined; return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, dmPolicy, ...(allowFrom ? { allowFrom } : {}), }, }, }; } function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, allowFrom, }, }, }; } function parseAllowFromInput(raw: string): string[] { return raw .split(/[\n,;]+/g) .map((entry) => entry.trim()) .filter(Boolean); } async function promptFeishuAllowFrom(params: { cfg: ClawdbotConfig; prompter: WizardPrompter; }): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( [ "通过 open_id 或 user_id 将 Feishu 私信列入白名单。", "您可以在 Feishu 管理控制台或通过 API 找到用户的 open_id。", "Examples:", "- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ].join("\n"), "Feishu allowlist", ); while (true) { const entry = await params.prompter.text({ message: "Feishu 允许列表 (用户 open_id)", placeholder: "ou_xxxxx, ou_yyyyy", initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); const parts = parseAllowFromInput(String(entry)); if (parts.length === 0) { await params.prompter.note("至少输入一个用户。", "Feishu allowlist"); continue; } const unique = [ ...new Set([ ...existing.map((v: string | number) => String(v).trim()).filter(Boolean), ...parts, ]), ]; return setFeishuAllowFrom(params.cfg, unique); } } async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { await prompter.note( [ "1) 访问飞书开放平台 (open.feishu.cn)", "2) 创建一个自建应用", "3) 从凭证页面获取 App ID 和 App Secret", "4) 启用必要权限: im:message, im:chat, contact:user.base:readonly", "5) 发布应用或将其添加到测试组", "提示: 您也可以设置 FEISHU_APP_ID / FEISHU_APP_SECRET 环境变量。", `Docs: ${formatDocsLink("/channels/feishu", "feishu")}`, ].join("\n"), "飞书凭证", ); } async function promptFeishuAppId(params: { prompter: WizardPrompter; initialValue?: string; }): Promise { const appId = String( await params.prompter.text({ message: "输入飞书 App ID", initialValue: params.initialValue, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); return appId; } function setFeishuGroupPolicy( cfg: ClawdbotConfig, groupPolicy: "open" | "allowlist" | "disabled", ): ClawdbotConfig { return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, enabled: true, groupPolicy, }, }, }; } function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { return { ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, groupAllowFrom, }, }, }; } const dmPolicy: ChannelOnboardingDmPolicy = { 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, policy), promptAllowFrom: promptFeishuAllowFrom, }; export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const topLevelConfigured = Boolean( feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret), ); const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { if (!account || typeof account !== "object") { return false; } const accountAppId = typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim(); const accountSecretConfigured = hasConfiguredSecretInput(account.appSecret) || hasConfiguredSecretInput(feishuCfg?.appSecret); return Boolean(accountAppId && accountSecretConfigured); }); const configured = topLevelConfigured || accountConfigured; const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, }); // Try to probe if configured let probeResult = null; if (configured && resolvedCredentials) { try { probeResult = await probeFeishu(resolvedCredentials); } catch { // Ignore probe errors } } const statusLines: string[] = []; if (!configured) { statusLines.push("飞书: 需要应用凭证"); } else if (probeResult?.ok) { statusLines.push( `飞书: 已连接为 ${probeResult.botName ?? probeResult.botOpenId ?? "机器人"}`, ); } else { statusLines.push("飞书: 已配置 (连接未验证)"); } return { channel, configured, statusLines, selectionHint: configured ? "configured" : "需要应用凭证", quickstartScore: configured ? 2 : 0, }; }, configure: async ({ cfg, prompter }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolved = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, }); const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret); const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret); const canUseEnv = Boolean( !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(), ); 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", accountConfigured: Boolean(resolved), canUseEnv, hasConfigToken: hasConfigSecret, envPrompt: "检测到 FEISHU_APP_ID + FEISHU_APP_SECRET。使用环境变量?", keepPrompt: "飞书 App Secret 已配置。保留它?", inputPrompt: "输入飞书 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: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(), }); } if (appId && appSecret) { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, enabled: true, appId, appSecret, }, }, }; // Test connection try { const probe = await probeFeishu({ appId, appSecret: appSecretProbeValue ?? undefined, domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain, }); if (probe.ok) { await prompter.note( `已连接为 ${probe.botName ?? probe.botOpenId ?? "机器人"}`, "Feishu connection test", ); } else { await prompter.note( `连接失败: ${probe.error ?? "未知错误"}`, "Feishu connection test", ); } } catch (err) { await prompter.note(`连接测试失败: ${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", accountConfigured: hasConfiguredSecretInput(currentVerificationToken), canUseEnv: false, hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), 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 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, }, }, }; } // Domain selection const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; const domain = await prompter.select({ message: "选择哪个飞书域名?", options: [ { value: "feishu", label: "飞书 (feishu.cn) - 中国" }, { value: "lark", label: "Lark (larksuite.com) - 国际" }, ], initialValue: currentDomain, }); if (domain) { next = { ...next, channels: { ...next.channels, feishu: { ...next.channels?.feishu, domain: domain as "feishu" | "lark", }, }, }; } // Group policy const groupPolicy = await prompter.select({ message: "群聊策略", options: [ { value: "allowlist", label: "白名单 - 仅在特定群组响应" }, { value: "open", label: "开放 - 在所有群组响应 (需要提及)" }, { value: "disabled", label: "禁用 - 不在群组响应" }, ], initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", }); if (groupPolicy) { next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); } // Group allowlist if needed if (groupPolicy === "allowlist") { const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; const entry = await prompter.text({ message: "群聊白名单 (chat_id)", placeholder: "oc_xxxxx, oc_yyyyy", initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, }); if (entry) { const parts = parseAllowFromInput(String(entry)); if (parts.length > 0) { next = setFeishuGroupAllowFrom(next, parts); } } } return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, dmPolicy, disable: (cfg) => ({ ...cfg, channels: { ...cfg.channels, feishu: { ...cfg.channels?.feishu, enabled: false }, }, }), };