mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 23:20:44 +00:00
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
import {
|
|
DEFAULT_ACCOUNT_ID,
|
|
formatDocsLink,
|
|
hasConfiguredSecretInput,
|
|
mergeAllowFromEntries,
|
|
patchTopLevelChannelConfigSection,
|
|
promptSingleChannelSecretInput,
|
|
splitSetupEntries,
|
|
type ChannelSetupDmPolicy,
|
|
type ChannelSetupWizard,
|
|
type DmPolicy,
|
|
type OpenClawConfig,
|
|
type SecretInput,
|
|
} from "openclaw/plugin-sdk/setup";
|
|
import { inspectFeishuCredentials, resolveDefaultFeishuAccountId } from "./accounts.js";
|
|
import type { AppRegistrationResult } from "./app-registration.js";
|
|
import type { FeishuConfig, FeishuDomain } from "./types.js";
|
|
|
|
const channel = "feishu" as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function normalizeString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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 =
|
|
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<string, unknown>).appId)
|
|
: isAppIdConfigured(feishuCfg?.appId);
|
|
const accountSecretConfigured = hasOwnAppSecret
|
|
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
|
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
|
return accountAppIdConfigured && accountSecretConfigured;
|
|
});
|
|
|
|
return topLevelConfigured || accountConfigured;
|
|
}
|
|
|
|
/**
|
|
* Patch feishu config at the correct location based on accountId.
|
|
* - DEFAULT_ACCOUNT_ID → writes to top-level channels.feishu
|
|
* - named account → writes to channels.feishu.accounts[accountId]
|
|
*/
|
|
function patchFeishuConfig(
|
|
cfg: OpenClawConfig,
|
|
accountId: string,
|
|
patch: Record<string, unknown>,
|
|
): OpenClawConfig {
|
|
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return patchTopLevelChannelConfigSection({
|
|
cfg,
|
|
channel,
|
|
enabled: true,
|
|
patch,
|
|
});
|
|
}
|
|
const nextAccountPatch = {
|
|
...(feishuCfg?.accounts?.[accountId] as Record<string, unknown> | undefined),
|
|
enabled: true,
|
|
...patch,
|
|
};
|
|
return patchTopLevelChannelConfigSection({
|
|
cfg,
|
|
channel,
|
|
enabled: true,
|
|
patch: {
|
|
accounts: {
|
|
...feishuCfg?.accounts,
|
|
[accountId]: nextAccountPatch,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function promptFeishuAllowFrom(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId?: string;
|
|
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
|
}): Promise<OpenClawConfig> {
|
|
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
|
const resolvedAccountId = params.accountId ?? resolveDefaultFeishuAccountId(params.cfg);
|
|
const account =
|
|
resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
|
? (feishuCfg?.accounts?.[resolvedAccountId] as Record<string, unknown> | undefined)
|
|
: undefined;
|
|
const existingAllowFrom = (account?.allowFrom ?? feishuCfg?.allowFrom ?? []) as Array<
|
|
string | number
|
|
>;
|
|
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",
|
|
);
|
|
const entry = await params.prompter.text({
|
|
message: "Feishu allowFrom (user open_ids)",
|
|
placeholder: "ou_xxxxx, ou_yyyyy",
|
|
initialValue:
|
|
existingAllowFrom.length > 0 ? existingAllowFrom.map(String).join(", ") : undefined,
|
|
});
|
|
const mergedAllowFrom = mergeAllowFromEntries(existingAllowFrom, splitSetupEntries(entry));
|
|
return patchFeishuConfig(params.cfg, resolvedAccountId, { allowFrom: mergedAllowFrom });
|
|
}
|
|
|
|
async function noteFeishuCredentialHelp(
|
|
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
|
|
): Promise<void> {
|
|
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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
|
|
initialValue?: string;
|
|
}): Promise<string> {
|
|
return (
|
|
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",
|
|
resolveConfigKeys: (_cfg, accountId) => {
|
|
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(_cfg);
|
|
return resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
|
? {
|
|
policyKey: `channels.feishu.accounts.${resolvedAccountId}.dmPolicy`,
|
|
allowFromKey: `channels.feishu.accounts.${resolvedAccountId}.allowFrom`,
|
|
}
|
|
: {
|
|
policyKey: "channels.feishu.dmPolicy",
|
|
allowFromKey: "channels.feishu.allowFrom",
|
|
};
|
|
},
|
|
getCurrent: (cfg, accountId) => {
|
|
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
|
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
|
|
const account = feishuCfg?.accounts?.[resolvedAccountId] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (account?.dmPolicy) {
|
|
return account.dmPolicy as DmPolicy;
|
|
}
|
|
}
|
|
return (feishuCfg?.dmPolicy as DmPolicy | undefined) ?? "pairing";
|
|
},
|
|
setPolicy: (cfg, policy, accountId) => {
|
|
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
|
return patchFeishuConfig(cfg, resolvedAccountId, {
|
|
dmPolicy: policy,
|
|
...(policy === "open" ? { allowFrom: mergeAllowFromEntries([], ["*"]) } : {}),
|
|
});
|
|
},
|
|
promptAllowFrom: promptFeishuAllowFrom,
|
|
};
|
|
|
|
type WizardPrompter = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Security policy helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function applyNewAppSecurityPolicy(
|
|
cfg: OpenClawConfig,
|
|
accountId: string,
|
|
openId: string | undefined,
|
|
groupPolicy: "allowlist" | "open" | "disabled",
|
|
): OpenClawConfig {
|
|
let next = cfg;
|
|
|
|
if (openId) {
|
|
// dmPolicy=allowlist, allowFrom=[openId]
|
|
next = patchFeishuConfig(next, accountId, { dmPolicy: "allowlist", allowFrom: [openId] });
|
|
}
|
|
|
|
// Apply group policy.
|
|
const groupPatch: Record<string, unknown> = { groupPolicy };
|
|
if (groupPolicy === "open") {
|
|
groupPatch.requireMention = true;
|
|
}
|
|
next = patchFeishuConfig(next, accountId, groupPatch);
|
|
|
|
return next;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scan-to-create flow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runScanToCreate(prompter: WizardPrompter): Promise<AppRegistrationResult | null> {
|
|
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
|
|
await import("./app-registration.js");
|
|
try {
|
|
await initAppRegistration("feishu");
|
|
} catch {
|
|
await prompter.note(
|
|
"Scan-to-create is not available in this environment. Falling back to manual input.",
|
|
"Feishu setup",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const begin = await beginAppRegistration("feishu");
|
|
|
|
await prompter.note("Scan the QR with Lark/Feishu on your phone.", "Feishu scan-to-create");
|
|
await printQrCode(begin.qrUrl);
|
|
|
|
const progress = prompter.progress("Fetching configuration results...");
|
|
|
|
const outcome = await pollAppRegistration({
|
|
deviceCode: begin.deviceCode,
|
|
interval: begin.interval,
|
|
expireIn: begin.expireIn,
|
|
initialDomain: "feishu",
|
|
tp: "ob_app",
|
|
});
|
|
|
|
switch (outcome.status) {
|
|
case "success":
|
|
progress.stop("Scan completed.");
|
|
return outcome.result;
|
|
case "access_denied":
|
|
progress.stop("User denied authorization. Falling back to manual input.");
|
|
return null;
|
|
case "expired":
|
|
progress.stop("Session expired. Falling back to manual input.");
|
|
return null;
|
|
case "timeout":
|
|
progress.stop("Scan timed out. Falling back to manual input.");
|
|
return null;
|
|
case "error":
|
|
progress.stop(`Registration error: ${outcome.message}. Falling back to manual input.`);
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New app configuration flow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runNewAppFlow(params: {
|
|
cfg: OpenClawConfig;
|
|
prompter: WizardPrompter;
|
|
options: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["options"];
|
|
}): Promise<{ cfg: OpenClawConfig }> {
|
|
const { prompter, options } = params;
|
|
let next = params.cfg;
|
|
|
|
// Resolve target account: defaultAccount > first account key > top-level.
|
|
const targetAccountId = resolveDefaultFeishuAccountId(next);
|
|
|
|
// ----- QR scan flow -----
|
|
let appId: string | null = null;
|
|
let appSecret: SecretInput | null = null;
|
|
let appSecretProbeValue: string | null = null;
|
|
let scanDomain: FeishuDomain | undefined;
|
|
let scanOpenId: string | undefined;
|
|
|
|
const scanResult = await runScanToCreate(prompter);
|
|
if (scanResult) {
|
|
appId = scanResult.appId;
|
|
appSecret = scanResult.appSecret;
|
|
appSecretProbeValue = scanResult.appSecret;
|
|
scanDomain = scanResult.domain;
|
|
scanOpenId = scanResult.openId;
|
|
} else {
|
|
// Fallback to manual input: collect domain, appId, appSecret.
|
|
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
|
|
await noteFeishuCredentialHelp(prompter);
|
|
|
|
// Domain selection first (needed for API calls).
|
|
const currentDomain = feishuCfg?.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,
|
|
})) as FeishuDomain;
|
|
scanDomain = domain;
|
|
|
|
appId = await promptFeishuAppId({
|
|
prompter,
|
|
initialValue: normalizeString(process.env.FEISHU_APP_ID),
|
|
});
|
|
|
|
const appSecretResult = await promptSingleChannelSecretInput({
|
|
cfg: next,
|
|
prompter,
|
|
providerHint: "feishu",
|
|
credentialLabel: "App Secret",
|
|
secretInputMode: options?.secretInputMode,
|
|
accountConfigured: false,
|
|
canUseEnv: false,
|
|
hasConfigToken: false,
|
|
envPrompt: "",
|
|
keepPrompt: "Feishu App Secret already configured. Keep it?",
|
|
inputPrompt: "Enter Feishu App Secret",
|
|
preferredEnvVar: "FEISHU_APP_SECRET",
|
|
});
|
|
if (appSecretResult.action === "set") {
|
|
appSecret = appSecretResult.value;
|
|
appSecretProbeValue = appSecretResult.resolvedValue;
|
|
}
|
|
|
|
// Fetch openId via API for manual flow.
|
|
if (appId && appSecretProbeValue) {
|
|
const { getAppOwnerOpenId } = await import("./app-registration.js");
|
|
scanOpenId = await getAppOwnerOpenId({
|
|
appId,
|
|
appSecret: appSecretProbeValue,
|
|
domain: scanDomain,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ----- Group chat policy -----
|
|
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: "allowlist",
|
|
})) as "allowlist" | "open" | "disabled";
|
|
|
|
// ----- Apply credentials & security policy -----
|
|
const configProgress = prompter.progress("Configuring...");
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
if (appId && appSecret) {
|
|
next = patchFeishuConfig(next, targetAccountId, {
|
|
appId,
|
|
appSecret,
|
|
connectionMode: "websocket",
|
|
...(scanDomain ? { domain: scanDomain } : {}),
|
|
});
|
|
} else if (scanDomain) {
|
|
next = patchFeishuConfig(next, targetAccountId, { domain: scanDomain });
|
|
}
|
|
|
|
next = applyNewAppSecurityPolicy(next, targetAccountId, scanOpenId, groupPolicy);
|
|
|
|
configProgress.stop("Bot configured.");
|
|
|
|
return { cfg: next };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edit configuration flow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function runEditFlow(params: {
|
|
cfg: OpenClawConfig;
|
|
prompter: WizardPrompter;
|
|
options: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["options"];
|
|
}): Promise<{ cfg: OpenClawConfig } | null> {
|
|
const { prompter, options } = params;
|
|
const next = params.cfg;
|
|
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
|
|
|
|
// Check existing appId (top-level or first configured account).
|
|
// Supports both plain string and SecretRef (env-backed) appId values.
|
|
const resolveAppIdLabel = (value: unknown): string | undefined => {
|
|
const asString = normalizeString(value);
|
|
if (asString) {
|
|
return asString;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const rec = value as Record<string, unknown>;
|
|
if (normalizeString(rec.source) && normalizeString(rec.id)) {
|
|
const envValue = normalizeString(process.env[rec.id as string]);
|
|
return envValue ?? `env:${String(rec.id)}`;
|
|
}
|
|
if (hasConfiguredSecretInput(value)) {
|
|
return "(configured)";
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const existingAppId =
|
|
resolveAppIdLabel(feishuCfg?.appId) ??
|
|
Object.values(feishuCfg?.accounts ?? {}).reduce<string | undefined>((found, account) => {
|
|
if (found) {
|
|
return found;
|
|
}
|
|
if (account && typeof account === "object") {
|
|
return resolveAppIdLabel((account as Record<string, unknown>).appId);
|
|
}
|
|
return undefined;
|
|
}, undefined);
|
|
if (existingAppId) {
|
|
const useExisting = await prompter.confirm({
|
|
message: `We found an existing bot (App ID: ${existingAppId}). Use it for this setup?`,
|
|
initialValue: true,
|
|
});
|
|
|
|
if (!useExisting) {
|
|
// User wants a new bot — run new app flow.
|
|
return runNewAppFlow({ cfg: next, prompter, options });
|
|
}
|
|
} else {
|
|
// No existing appId — run new app flow.
|
|
return runNewAppFlow({ cfg: next, prompter, options });
|
|
}
|
|
|
|
await prompter.note("Bot configured.", "");
|
|
|
|
return { cfg: next };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone login entry point (for `channels login --channel feishu`)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function runFeishuLogin(params: {
|
|
cfg: OpenClawConfig;
|
|
prompter: WizardPrompter;
|
|
}): Promise<OpenClawConfig> {
|
|
const { cfg, prompter } = params;
|
|
const options = {};
|
|
const alreadyConfigured = isFeishuConfigured(cfg);
|
|
|
|
if (alreadyConfigured) {
|
|
const result = await runEditFlow({ cfg, prompter, options });
|
|
if (result === null) {
|
|
return cfg;
|
|
}
|
|
return result.cfg;
|
|
}
|
|
|
|
const result = await runNewAppFlow({ cfg, prompter, options });
|
|
return result.cfg;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exported wizard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export { feishuSetupAdapter } from "./setup-core.js";
|
|
|
|
export const feishuSetupWizard: ChannelSetupWizard = {
|
|
channel,
|
|
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId, cfg }) =>
|
|
(typeof accountOverride === "string" && accountOverride.trim()
|
|
? accountOverride.trim()
|
|
: undefined) ??
|
|
resolveDefaultFeishuAccountId(cfg) ??
|
|
defaultAccountId,
|
|
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 = inspectFeishuCredentials(feishuCfg);
|
|
let probeResult = null;
|
|
if (configured && resolvedCredentials) {
|
|
try {
|
|
const { probeFeishu } = await import("./probe.js");
|
|
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)"];
|
|
},
|
|
},
|
|
|
|
// -------------------------------------------------------------------------
|
|
// prepare: determine flow based on existing configuration
|
|
// -------------------------------------------------------------------------
|
|
prepare: async ({ cfg, credentialValues }) => {
|
|
const alreadyConfigured = isFeishuConfigured(cfg);
|
|
|
|
if (alreadyConfigured) {
|
|
return {
|
|
credentialValues: { ...credentialValues, _flow: "edit" },
|
|
};
|
|
}
|
|
|
|
return {
|
|
credentialValues: { ...credentialValues, _flow: "new" },
|
|
};
|
|
},
|
|
|
|
credentials: [],
|
|
|
|
// -------------------------------------------------------------------------
|
|
// finalize: run the appropriate flow
|
|
// -------------------------------------------------------------------------
|
|
finalize: async ({ cfg, prompter, options, credentialValues }) => {
|
|
const flow = credentialValues._flow ?? "new";
|
|
|
|
if (flow === "edit") {
|
|
const result = await runEditFlow({ cfg, prompter, options });
|
|
if (result === null) {
|
|
return { cfg };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return runNewAppFlow({ cfg, prompter, options });
|
|
},
|
|
|
|
dmPolicy: feishuDmPolicy,
|
|
disable: (cfg) =>
|
|
patchTopLevelChannelConfigSection({
|
|
cfg,
|
|
channel,
|
|
patch: { enabled: false },
|
|
}),
|
|
};
|