mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:40:43 +00:00
Add QR-based app registration, improve Feishu onboarding flows, support direct login entry, add group chat policy setup, reduce log noise, and update docs.
588 lines
19 KiB
TypeScript
588 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 {
|
|
beginAppRegistration,
|
|
getAppOwnerOpenId,
|
|
initAppRegistration,
|
|
pollAppRegistration,
|
|
printQrCode,
|
|
type AppRegistrationResult,
|
|
} from "./app-registration.js";
|
|
import { probeFeishu } from "./probe.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> {
|
|
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) {
|
|
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 {
|
|
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 },
|
|
}),
|
|
};
|