fix: honor feishu setup account writes

This commit is contained in:
Tak Hoffman 2026-04-03 12:22:45 -05:00
parent 53612fa128
commit 17060ca124
No known key found for this signature in database
2 changed files with 206 additions and 100 deletions

View File

@ -90,6 +90,56 @@ describe("feishu setup wizard", () => {
}),
).resolves.toBeTruthy();
});
it("writes selected-account credentials instead of overwriting the channel root", async () => {
const prompter = createTestWizardPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enter Feishu App Secret") {
return "work-secret"; // pragma: allowlist secret
}
if (message === "Enter Feishu App ID") {
return "work-app";
}
if (message === "Group chat allowlist (chat_ids)") {
return "";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
select: vi.fn(
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "websocket",
) as never,
});
const result = await runSetupWizardConfigure({
configure: feishuConfigure,
cfg: {
channels: {
feishu: {
appId: "top-level-app",
appSecret: "top-level-secret", // pragma: allowlist secret
accounts: {
work: {
appId: "",
},
},
},
},
} as never,
prompter,
accountOverrides: {
feishu: "work",
},
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
});
expect(result.cfg.channels?.feishu?.appId).toBe("top-level-app");
expect(result.cfg.channels?.feishu?.appSecret).toBe("top-level-secret");
expect(result.cfg.channels?.feishu?.accounts?.work).toMatchObject({
enabled: true,
appId: "work-app",
appSecret: "work-secret",
});
});
});
describe("feishu setup wizard status", () => {

View File

@ -1,13 +1,10 @@
import {
buildSingleChannelSecretPromptState,
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
createTopLevelChannelParsedAllowFromPrompt,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
patchChannelConfigForAccount,
patchTopLevelChannelConfigSection,
promptSingleChannelSecretInput,
splitSetupEntries,
@ -22,13 +19,6 @@ import { feishuSetupAdapter } from "./setup-core.js";
import type { FeishuConfig } from "./types.js";
const channel = "feishu" as const;
const setFeishuAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
const setFeishuGroupPolicy = createTopLevelChannelGroupPolicySetter({
channel,
enabled: true,
});
function normalizeString(value: unknown): string | undefined {
if (typeof value !== "string") {
@ -38,17 +28,49 @@ function normalizeString(value: unknown): string | undefined {
return trimmed || undefined;
}
function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...cfg.channels?.feishu,
groupAllowFrom,
},
},
};
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): FeishuConfig {
const feishuCfg = (cfg.channels?.feishu as FeishuConfig | undefined) ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return feishuCfg;
}
return (feishuCfg.accounts?.[accountId] as FeishuConfig | undefined) ?? {};
}
function patchFeishuConfig(
cfg: OpenClawConfig,
accountId: string,
patch: Record<string, unknown>,
): OpenClawConfig {
return patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch,
});
}
function setFeishuAllowFrom(
cfg: OpenClawConfig,
accountId: string,
allowFrom: string[],
): OpenClawConfig {
return patchFeishuConfig(cfg, accountId, { allowFrom });
}
function setFeishuGroupPolicy(
cfg: OpenClawConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
return patchFeishuConfig(cfg, accountId, { groupPolicy });
}
function setFeishuGroupAllowFrom(
cfg: OpenClawConfig,
accountId: string,
groupAllowFrom: string[],
): OpenClawConfig {
return patchFeishuConfig(cfg, accountId, { groupAllowFrom });
}
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
@ -93,22 +115,34 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
return topLevelConfigured || accountConfigured;
}
const promptFeishuAllowFrom = createTopLevelChannelParsedAllowFromPrompt({
channel,
defaultAccountId: DEFAULT_ACCOUNT_ID,
noteTitle: "Feishu allowlist",
noteLines: [
"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",
],
message: "Feishu allowFrom (user open_ids)",
placeholder: "ou_xxxxx, ou_yyyyy",
parseEntries: (raw) => ({ entries: splitSetupEntries(raw) }),
mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries(existing, parsed),
});
async function promptFeishuAllowFrom(params: {
cfg: OpenClawConfig;
accountId: string;
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const existingAllowFrom =
resolveFeishuAccount({
cfg: params.cfg,
accountId: params.accountId,
}).config.allowFrom ?? [];
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(String(entry)));
return setFeishuAllowFrom(params.cfg, params.accountId, mergedAllowFrom);
}
async function noteFeishuCredentialHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
@ -140,20 +174,51 @@ async function promptFeishuAppId(params: {
).trim();
}
const feishuDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({
const feishuDmPolicy: ChannelSetupDmPolicy = {
label: "Feishu",
channel,
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
promptAllowFrom: promptFeishuAllowFrom,
});
resolveConfigKeys: (_cfg, accountId) =>
accountId && accountId !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.feishu.accounts.${accountId}.dmPolicy`,
allowFromKey: `channels.feishu.accounts.${accountId}.allowFrom`,
}
: {
policyKey: "channels.feishu.dmPolicy",
allowFromKey: "channels.feishu.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveFeishuAccount({
cfg,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const currentAllowFrom = resolveFeishuAccount({
cfg,
accountId: resolvedAccountId,
}).config.allowFrom;
return patchFeishuConfig(cfg, resolvedAccountId, {
dmPolicy: policy,
...(policy === "open" ? { allowFrom: mergeAllowFromEntries(currentAllowFrom, ["*"]) } : {}),
});
},
promptAllowFrom: async ({ cfg, accountId, prompter }) =>
await promptFeishuAllowFrom({
cfg,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
prompter,
}),
};
export { feishuSetupAdapter } from "./setup-core.js";
export const feishuSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveAccountIdForConfigure: ({ accountOverride, defaultAccountId }) =>
normalizeString(accountOverride) ?? defaultAccountId,
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
@ -195,12 +260,23 @@ export const feishuSetupWizard: ChannelSetupWizard = {
},
},
credentials: [],
finalize: async ({ cfg, prompter, options }) => {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const resolved = inspectFeishuCredentials(feishuCfg);
const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
finalize: async ({ cfg, accountId, prompter, options }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId);
const resolved =
resolvedAccount.configured && resolvedAccount.appId && resolvedAccount.appSecret
? {
appId: resolvedAccount.appId,
appSecret: resolvedAccount.appSecret,
encryptKey: resolvedAccount.encryptKey,
verificationToken: resolvedAccount.verificationToken,
domain: resolvedAccount.domain,
}
: null;
const hasConfigSecret = hasConfiguredSecretInput(scopedConfig.appSecret);
const hasConfigCreds = Boolean(
typeof feishuCfg?.appId === "string" && feishuCfg.appId.trim() && hasConfigSecret,
typeof scopedConfig.appId === "string" && scopedConfig.appId.trim() && hasConfigSecret,
);
const appSecretPromptState = buildSingleChannelSecretPromptState({
accountConfigured: Boolean(resolved),
@ -234,38 +310,28 @@ export const feishuSetupWizard: ChannelSetupWizard = {
});
if (appSecretResult.action === "use-env") {
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
enabled: true,
patch: {},
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, {});
} else if (appSecretResult.action === "set") {
appSecret = appSecretResult.value;
appSecretProbeValue = appSecretResult.resolvedValue;
appId = await promptFeishuAppId({
prompter,
initialValue:
normalizeString(feishuCfg?.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
normalizeString(scopedConfig.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
});
}
if (appId && appSecret) {
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
enabled: true,
patch: {
appId,
appSecret,
},
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, {
appId,
appSecret,
});
try {
const probe = await probeFeishu({
appId,
appSecret: appSecretProbeValue ?? undefined,
domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain,
domain: resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain,
});
if (probe.ok) {
await prompter.note(
@ -284,7 +350,8 @@ export const feishuSetupWizard: ChannelSetupWizard = {
}
const currentMode =
(next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket";
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.connectionMode ??
"websocket";
const connectionMode = (await prompter.select({
message: "Feishu connection mode",
options: [
@ -293,15 +360,10 @@ export const feishuSetupWizard: ChannelSetupWizard = {
],
initialValue: currentMode,
})) as "websocket" | "webhook";
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { connectionMode },
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, { connectionMode });
if (connectionMode === "webhook") {
const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
?.verificationToken;
const currentVerificationToken = getScopedFeishuConfig(next, resolvedAccountId).verificationToken;
const verificationTokenResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
@ -319,14 +381,12 @@ export const feishuSetupWizard: ChannelSetupWizard = {
preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
});
if (verificationTokenResult.action === "set") {
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { verificationToken: verificationTokenResult.value },
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, {
verificationToken: verificationTokenResult.value,
});
}
const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
const currentEncryptKey = getScopedFeishuConfig(next, resolvedAccountId).encryptKey;
const encryptKeyResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
@ -344,14 +404,12 @@ export const feishuSetupWizard: ChannelSetupWizard = {
preferredEnvVar: "FEISHU_ENCRYPT_KEY",
});
if (encryptKeyResult.action === "set") {
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { encryptKey: encryptKeyResult.value },
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, {
encryptKey: encryptKeyResult.value,
});
}
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
const currentWebhookPath = getScopedFeishuConfig(next, resolvedAccountId).webhookPath;
const webhookPath = String(
await prompter.text({
message: "Feishu webhook path",
@ -359,14 +417,10 @@ export const feishuSetupWizard: ChannelSetupWizard = {
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { webhookPath },
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, { webhookPath });
}
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
const currentDomain = resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).domain;
const domain = await prompter.select({
message: "Which Feishu domain?",
options: [
@ -375,11 +429,9 @@ export const feishuSetupWizard: ChannelSetupWizard = {
],
initialValue: currentDomain,
});
next = patchTopLevelChannelConfigSection({
cfg: next,
channel,
patch: { domain: domain as "feishu" | "lark" },
}) as OpenClawConfig;
next = patchFeishuConfig(next, resolvedAccountId, {
domain: domain as "feishu" | "lark",
});
const groupPolicy = (await prompter.select({
message: "Group chat policy",
@ -388,12 +440,16 @@ export const feishuSetupWizard: ChannelSetupWizard = {
{ value: "open", label: "Open - respond in all groups (requires mention)" },
{ value: "disabled", label: "Disabled - don't respond in groups" },
],
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
initialValue:
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupPolicy ??
"allowlist",
})) as "allowlist" | "open" | "disabled";
next = setFeishuGroupPolicy(next, groupPolicy);
next = setFeishuGroupPolicy(next, resolvedAccountId, groupPolicy);
if (groupPolicy === "allowlist") {
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
const existing =
resolveFeishuAccount({ cfg: next, accountId: resolvedAccountId }).config.groupAllowFrom ??
[];
const entry = await prompter.text({
message: "Group chat allowlist (chat_ids)",
placeholder: "oc_xxxxx, oc_yyyyy",
@ -402,7 +458,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
if (entry) {
const parts = splitSetupEntries(String(entry));
if (parts.length > 0) {
next = setFeishuGroupAllowFrom(next, parts);
next = setFeishuGroupAllowFrom(next, resolvedAccountId, parts);
}
}
}