Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
612
extensions/feishu/src/setup-surface.ts
Normal file
612
extensions/feishu/src/setup-surface.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Feishu plugin module implements setup surface behavior.
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
mergeAllowFromEntries,
|
||||
patchTopLevelChannelConfigSection,
|
||||
promptSingleChannelSecretInput,
|
||||
splitSetupEntries,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js";
|
||||
import type { AppRegistrationResult } from "./app-registration.js";
|
||||
import type { FeishuConfig, FeishuDomain } from "./types.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "feishu" as const;
|
||||
const SCAN_TO_CREATE_TP = "ob_cli_app";
|
||||
const FEISHU_SETUP_FLOW_KEY = "_flow";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.hasOwn(account, "appId");
|
||||
const hasOwnAppSecret = Object.hasOwn(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;
|
||||
}
|
||||
|
||||
function formatFeishuStatusLine(status: "configured-unverified" | "needs-credentials"): string {
|
||||
if (status === "needs-credentials") {
|
||||
return `Feishu: ${t("wizard.channels.statusNeedsAppCredentials")}`;
|
||||
}
|
||||
return `Feishu: ${t("wizard.channels.statusConfiguredConnectionNotVerified")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
[
|
||||
t("wizard.feishu.allowlistIntro"),
|
||||
t("wizard.feishu.allowlistFindUser"),
|
||||
t("wizard.feishu.examples"),
|
||||
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
].join("\n"),
|
||||
t("wizard.feishu.allowlistTitle"),
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: t("wizard.feishu.allowFromPrompt"),
|
||||
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(
|
||||
[
|
||||
t("wizard.feishu.credentialsStepOpenPlatform"),
|
||||
t("wizard.feishu.credentialsStepCreateApp"),
|
||||
t("wizard.feishu.credentialsStepGetCredentials"),
|
||||
t("wizard.feishu.credentialsStepPermissions"),
|
||||
t("wizard.feishu.credentialsStepPublish"),
|
||||
t("wizard.feishu.credentialsEnvTip"),
|
||||
t("wizard.channels.docs", { link: formatDocsLink("/channels/feishu", "feishu") }),
|
||||
].join("\n"),
|
||||
t("wizard.feishu.credentialsTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
async function promptFeishuAppId(params: {
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
|
||||
initialValue?: string;
|
||||
}): Promise<string> {
|
||||
return (
|
||||
await params.prompter.text({
|
||||
message: t("wizard.feishu.appIdPrompt"),
|
||||
initialValue: params.initialValue,
|
||||
validate: (value) => (value?.trim() ? undefined : t("common.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"];
|
||||
type FeishuSetupMethod = "manual" | "scan";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
const loadAppRegistrationModule = createLazyRuntimeModule(() => import("./app-registration.js"));
|
||||
|
||||
async function promptFeishuDomain(params: {
|
||||
prompter: WizardPrompter;
|
||||
initialValue?: FeishuDomain;
|
||||
}): Promise<FeishuDomain> {
|
||||
return (await params.prompter.select({
|
||||
message: t("wizard.feishu.domainPrompt"),
|
||||
options: [
|
||||
{ value: "feishu", label: t("wizard.feishu.domainFeishu") },
|
||||
{ value: "lark", label: t("wizard.feishu.domainLark") },
|
||||
],
|
||||
initialValue: params.initialValue ?? "feishu",
|
||||
})) as FeishuDomain;
|
||||
}
|
||||
|
||||
async function promptFeishuSetupMethod(prompter: WizardPrompter): Promise<FeishuSetupMethod> {
|
||||
return (await prompter.select({
|
||||
message: t("wizard.feishu.setupMethodPrompt"),
|
||||
options: [
|
||||
{ value: "manual", label: t("wizard.feishu.setupMethodManual") },
|
||||
{ value: "scan", label: t("wizard.feishu.setupMethodScan") },
|
||||
],
|
||||
initialValue: "manual",
|
||||
})) as FeishuSetupMethod;
|
||||
}
|
||||
|
||||
async function runScanToCreate(
|
||||
prompter: WizardPrompter,
|
||||
domain: FeishuDomain,
|
||||
): Promise<AppRegistrationResult | null> {
|
||||
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
|
||||
await loadAppRegistrationModule();
|
||||
try {
|
||||
await initAppRegistration(domain);
|
||||
} catch {
|
||||
await prompter.note(t("wizard.feishu.scanUnavailable"), t("wizard.feishu.setupTitle"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const begin = await beginAppRegistration(domain);
|
||||
|
||||
await prompter.note(t("wizard.feishu.scanQr"), t("wizard.feishu.scanTitle"));
|
||||
await printQrCode(begin.qrUrl);
|
||||
|
||||
const progress = prompter.progress(t("wizard.feishu.fetchingConfig"));
|
||||
|
||||
const outcome = await pollAppRegistration({
|
||||
deviceCode: begin.deviceCode,
|
||||
interval: begin.interval,
|
||||
expireIn: begin.expireIn,
|
||||
initialDomain: domain,
|
||||
tp: SCAN_TO_CREATE_TP,
|
||||
});
|
||||
|
||||
switch (outcome.status) {
|
||||
case "success":
|
||||
progress.stop(t("wizard.feishu.scanCompleted"));
|
||||
return outcome.result;
|
||||
case "access_denied":
|
||||
progress.stop(t("wizard.feishu.scanDenied"));
|
||||
return null;
|
||||
case "expired":
|
||||
progress.stop(t("wizard.feishu.scanExpired"));
|
||||
return null;
|
||||
case "timeout":
|
||||
progress.stop(t("wizard.feishu.scanTimedOut"));
|
||||
return null;
|
||||
case "error":
|
||||
progress.stop(t("wizard.feishu.scanError", { error: outcome.message }));
|
||||
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;
|
||||
let appSecret: SecretInput | null = null;
|
||||
let appSecretProbeValue: string | null = null;
|
||||
let scanDomain: FeishuDomain | undefined;
|
||||
let scanOpenId: string | undefined;
|
||||
const feishuCfg = next.channels?.feishu as FeishuConfig | undefined;
|
||||
const currentDomain = feishuCfg?.domain ?? "feishu";
|
||||
const setupMethod = await promptFeishuSetupMethod(prompter);
|
||||
const selectedDomain = await promptFeishuDomain({
|
||||
prompter,
|
||||
initialValue: currentDomain,
|
||||
});
|
||||
scanDomain = selectedDomain;
|
||||
|
||||
const scanResult =
|
||||
setupMethod === "scan" ? await runScanToCreate(prompter, selectedDomain) : null;
|
||||
if (scanResult) {
|
||||
appId = scanResult.appId;
|
||||
appSecret = scanResult.appSecret;
|
||||
scanDomain = scanResult.domain;
|
||||
scanOpenId = scanResult.openId;
|
||||
} else {
|
||||
// Fallback to manual input: collect domain, appId, appSecret.
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
|
||||
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: t("wizard.feishu.appSecretKeep"),
|
||||
inputPrompt: t("wizard.feishu.appSecretPrompt"),
|
||||
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 loadAppRegistrationModule();
|
||||
scanOpenId = await getAppOwnerOpenId({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue,
|
||||
domain: selectedDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Group chat policy -----
|
||||
const groupPolicy = (await prompter.select({
|
||||
message: t("wizard.feishu.groupPolicyPrompt"),
|
||||
options: [
|
||||
{ value: "allowlist", label: t("wizard.feishu.groupPolicyAllowlist") },
|
||||
{ value: "open", label: t("wizard.feishu.groupPolicyOpen") },
|
||||
{ value: "disabled", label: t("wizard.feishu.groupPolicyDisabled") },
|
||||
],
|
||||
initialValue: "allowlist",
|
||||
})) as "allowlist" | "open" | "disabled";
|
||||
|
||||
// ----- Apply credentials & security policy -----
|
||||
const configProgress = prompter.progress(t("wizard.feishu.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(t("wizard.feishu.botConfigured"));
|
||||
|
||||
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: t("wizard.feishu.existingBotPrompt", { appId: existingAppId }),
|
||||
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(t("wizard.feishu.botConfigured"), "");
|
||||
|
||||
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 const feishuSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId, cfg }) =>
|
||||
(typeof accountOverride === "string" && accountOverride.trim()
|
||||
? accountOverride.trim()
|
||||
: undefined) ??
|
||||
resolveDefaultFeishuAccountId(cfg) ??
|
||||
defaultAccountId,
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: {
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsAppCredentials"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsAppCreds"),
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg),
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
let probeResult = null;
|
||||
if (configured && account.configured) {
|
||||
try {
|
||||
const { probeFeishu } = await import("./probe.js");
|
||||
probeResult = await probeFeishu(account);
|
||||
} catch {}
|
||||
}
|
||||
if (!configured) {
|
||||
return [formatFeishuStatusLine("needs-credentials")];
|
||||
}
|
||||
if (probeResult?.ok) {
|
||||
return [
|
||||
`Feishu: ${t("wizard.channels.statusConnectedAs", {
|
||||
name: probeResult.botName ?? probeResult.botOpenId ?? "bot",
|
||||
})}`,
|
||||
];
|
||||
}
|
||||
return [formatFeishuStatusLine("configured-unverified")];
|
||||
},
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// prepare: determine flow based on existing configuration
|
||||
// -------------------------------------------------------------------------
|
||||
prepare: async ({ cfg, credentialValues }) => {
|
||||
const alreadyConfigured = isFeishuConfigured(cfg);
|
||||
|
||||
if (alreadyConfigured) {
|
||||
return {
|
||||
credentialValues: { ...credentialValues, [FEISHU_SETUP_FLOW_KEY]: "edit" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
credentialValues: { ...credentialValues, [FEISHU_SETUP_FLOW_KEY]: "new" },
|
||||
};
|
||||
},
|
||||
|
||||
credentials: [],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// finalize: run the appropriate flow
|
||||
// -------------------------------------------------------------------------
|
||||
finalize: async ({ cfg, prompter, options, credentialValues }) => {
|
||||
const flow = credentialValues[FEISHU_SETUP_FLOW_KEY] ?? "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 },
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user