refactor: move feishu zalo zalouser to setup wizard

This commit is contained in:
Peter Steinberger
2026-03-15 18:22:57 -07:00
parent 71a69e5337
commit 40be12db96
14 changed files with 675 additions and 560 deletions

View File

@@ -25,6 +25,7 @@ import { FeishuConfigSchema } from "./config-schema.js";
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
@@ -43,44 +44,6 @@ async function loadFeishuChannelRuntime() {
return await import("./channel.runtime.js");
}
const feishuOnboarding = {
channel: "feishu",
getStatus: async (ctx) =>
(await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx),
configure: async (ctx) =>
(await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx),
dmPolicy: {
label: "Feishu",
channel: "feishu",
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => ({
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
dmPolicy: policy,
},
},
}),
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
(await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({
cfg,
prompter,
accountId,
}),
},
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
feishu: { ...cfg.channels?.feishu, enabled: false },
},
}),
} satisfies ChannelPlugin<ResolvedFeishuAccount>["onboarding"];
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@@ -429,28 +392,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
});
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
},
onboarding: feishuOnboarding,
setup: feishuSetupAdapter,
setupWizard: feishuSetupWizard,
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
targetResolver: {

View File

@@ -1,10 +1,16 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import { feishuOnboardingAdapter } from "./onboarding.js";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { feishuPlugin } from "./channel.js";
describe("feishu onboarding status", () => {
const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});
describe("feishu setup wizard status", () => {
it("treats SecretRef appSecret as configured when appId is present", async () => {
const status = await feishuOnboardingAdapter.getStatus({
const status = await feishuConfigureAdapter.getStatus({
cfg: {
channels: {
feishu: {

View File

@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
vi.mock("./probe.js", () => ({
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
}));
import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuPlugin } from "./channel.js";
const baseConfigureContext = {
runtime: {} as never,
@@ -42,7 +43,7 @@ async function withEnvVars(values: Record<string, string | undefined>, run: () =
}
async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
return await feishuOnboardingAdapter.getStatus({
return await feishuConfigureAdapter.getStatus({
cfg: {
channels: {
feishu: {
@@ -55,7 +56,12 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
});
}
describe("feishuOnboardingAdapter.configure", () => {
const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
plugin: feishuPlugin,
wizard: feishuPlugin.setupWizard!,
});
describe("feishu setup wizard", () => {
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
const text = vi
.fn()
@@ -73,7 +79,7 @@ describe("feishuOnboardingAdapter.configure", () => {
} as never;
await expect(
feishuOnboardingAdapter.configure({
feishuConfigureAdapter.configure({
cfg: {
channels: {
feishu: {
@@ -89,9 +95,9 @@ describe("feishuOnboardingAdapter.configure", () => {
});
});
describe("feishuOnboardingAdapter.getStatus", () => {
describe("feishu setup wizard status", () => {
it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
const status = await feishuOnboardingAdapter.getStatus({
const status = await feishuConfigureAdapter.getStatus({
cfg: {
channels: {
feishu: {

View File

@@ -1,24 +1,22 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
DmPolicy,
SecretInput,
WizardPrompter,
} from "openclaw/plugin-sdk/feishu";
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
buildSingleChannelSecretPromptState,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitOnboardingEntries,
} from "openclaw/plugin-sdk/feishu";
import { resolveFeishuCredentials } from "./accounts.js";
} from "../../../src/channels/plugins/onboarding/helpers.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import type { SecretInput } from "../../../src/config/types.secrets.js";
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
import type { FeishuConfig } from "./types.js";
@@ -32,26 +30,117 @@ function normalizeString(value: unknown): string | undefined {
return trimmed || undefined;
}
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel: "feishu",
dmPolicy,
}) as ClawdbotConfig;
function setFeishuNamedAccountEnabled(
cfg: OpenClawConfig,
accountId: string,
enabled: boolean,
): OpenClawConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
}
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
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: "feishu",
channel,
allowFrom,
}) as ClawdbotConfig;
}) 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<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 = 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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
});
return topLevelConfigured || accountConfigured;
}
async function promptFeishuAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
await params.prompter.note(
[
@@ -82,7 +171,9 @@ async function promptFeishuAllowFrom(params: {
}
}
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
async function noteFeishuCredentialHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
): Promise<void> {
await prompter.note(
[
"1) Go to Feishu Open Platform (open.feishu.cn)",
@@ -98,131 +189,82 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
}
async function promptFeishuAppId(params: {
prompter: WizardPrompter;
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
initialValue?: string;
}): Promise<string> {
const appId = String(
return String(
await params.prompter.text({
message: "Enter Feishu App ID",
initialValue: params.initialValue,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return appId;
}
function setFeishuGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return setTopLevelChannelGroupPolicy({
cfg,
channel: "feishu",
groupPolicy,
enabled: true,
}) as ClawdbotConfig;
}
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
const feishuDmPolicy: 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),
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy),
promptAllowFrom: promptFeishuAllowFrom,
};
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
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 = 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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && 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
}
export const feishuSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
enabled: true,
},
},
};
}
const statusLines: string[] = [];
if (!configured) {
statusLines.push("Feishu: needs app credentials");
} else if (probeResult?.ok) {
statusLines.push(
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
);
} else {
statusLines.push("Feishu: configured (connection not verified)");
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? "configured" : "needs app creds",
quickstartScore: configured ? 2 : 0,
};
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
};
configure: async ({ cfg, prompter }) => {
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,
@@ -252,6 +294,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
prompter,
providerHint: "feishu",
credentialLabel: "App Secret",
secretInputMode: options?.secretInputMode,
accountConfigured: appSecretPromptState.accountConfigured,
canUseEnv: appSecretPromptState.canUseEnv,
hasConfigToken: appSecretPromptState.hasConfigToken,
@@ -293,7 +336,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
// Test connection
try {
const probe = await probeFeishu({
appId,
@@ -340,19 +382,17 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
if (connectionMode === "webhook") {
const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
?.verificationToken;
const verificationTokenPromptState = buildSingleChannelSecretPromptState({
accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
allowEnv: false,
});
const verificationTokenResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "feishu-webhook",
credentialLabel: "verification token",
accountConfigured: verificationTokenPromptState.accountConfigured,
canUseEnv: verificationTokenPromptState.canUseEnv,
hasConfigToken: verificationTokenPromptState.hasConfigToken,
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",
@@ -370,20 +410,19 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
}
const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
const encryptKeyPromptState = buildSingleChannelSecretPromptState({
accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
allowEnv: false,
});
const encryptKeyResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "feishu-webhook",
credentialLabel: "encrypt key",
accountConfigured: encryptKeyPromptState.accountConfigured,
canUseEnv: encryptKeyPromptState.canUseEnv,
hasConfigToken: encryptKeyPromptState.hasConfigToken,
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",
@@ -401,6 +440,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
},
};
}
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
const webhookPath = String(
await prompter.text({
@@ -421,7 +461,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
};
}
// Domain selection
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
const domain = await prompter.select({
message: "Which Feishu domain?",
@@ -431,21 +470,18 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
],
initialValue: currentDomain,
});
if (domain) {
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain as "feishu" | "lark",
},
next = {
...next,
channels: {
...next.channels,
feishu: {
...next.channels?.feishu,
domain: domain as "feishu" | "lark",
},
};
}
},
};
// Group policy
const groupPolicy = await prompter.select({
const groupPolicy = (await prompter.select({
message: "Group chat policy",
options: [
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
@@ -453,12 +489,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
{ value: "disabled", label: "Disabled - don't respond in groups" },
],
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
});
if (groupPolicy) {
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
}
})) as "allowlist" | "open" | "disabled";
next = setFeishuGroupPolicy(next, groupPolicy);
// Group allowlist if needed
if (groupPolicy === "allowlist") {
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
const entry = await prompter.text({
@@ -474,11 +507,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
return { cfg: next };
},
dmPolicy,
dmPolicy: feishuDmPolicy,
disable: (cfg) => ({
...cfg,
channels: {