470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
|
import type { WizardPrompter } from "../../wizard/prompts.js";
|
|
import type {
|
|
ChannelOnboardingAdapter,
|
|
ChannelOnboardingDmPolicy,
|
|
ChannelOnboardingStatus,
|
|
ChannelOnboardingStatusContext,
|
|
} from "./onboarding-types.js";
|
|
import { configureChannelAccessWithAllowlist } from "./onboarding/channel-access-configure.js";
|
|
import type { ChannelAccessPolicy } from "./onboarding/channel-access.js";
|
|
import {
|
|
promptResolvedAllowFrom,
|
|
resolveAccountIdForConfigure,
|
|
runSingleChannelSecretStep,
|
|
splitOnboardingEntries,
|
|
} from "./onboarding/helpers.js";
|
|
import type { ChannelSetupInput } from "./types.core.js";
|
|
import type { ChannelPlugin } from "./types.js";
|
|
|
|
export type ChannelSetupWizardStatus = {
|
|
configuredLabel: string;
|
|
unconfiguredLabel: string;
|
|
configuredHint?: string;
|
|
unconfiguredHint?: string;
|
|
configuredScore?: number;
|
|
unconfiguredScore?: number;
|
|
resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise<boolean>;
|
|
};
|
|
|
|
export type ChannelSetupWizardCredentialState = {
|
|
accountConfigured: boolean;
|
|
hasConfiguredValue: boolean;
|
|
resolvedValue?: string;
|
|
envValue?: string;
|
|
};
|
|
|
|
type ChannelSetupWizardCredentialValues = Partial<Record<keyof ChannelSetupInput, string>>;
|
|
|
|
export type ChannelSetupWizardNote = {
|
|
title: string;
|
|
lines: string[];
|
|
shouldShow?: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
credentialValues: ChannelSetupWizardCredentialValues;
|
|
}) => boolean | Promise<boolean>;
|
|
};
|
|
|
|
export type ChannelSetupWizardEnvShortcut = {
|
|
prompt: string;
|
|
preferredEnvVar?: string;
|
|
isAvailable: (params: { cfg: OpenClawConfig; accountId: string }) => boolean;
|
|
apply: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
|
};
|
|
|
|
export type ChannelSetupWizardCredential = {
|
|
inputKey: keyof ChannelSetupInput;
|
|
providerHint: string;
|
|
credentialLabel: string;
|
|
preferredEnvVar?: string;
|
|
helpTitle?: string;
|
|
helpLines?: string[];
|
|
envPrompt: string;
|
|
keepPrompt: string;
|
|
inputPrompt: string;
|
|
allowEnv?: (params: { cfg: OpenClawConfig; accountId: string }) => boolean;
|
|
inspect: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
}) => ChannelSetupWizardCredentialState;
|
|
applyUseEnv?: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
|
applySet?: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
value: unknown;
|
|
resolvedValue: string;
|
|
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
|
};
|
|
|
|
export type ChannelSetupWizardAllowFromEntry = {
|
|
input: string;
|
|
resolved: boolean;
|
|
id: string | null;
|
|
};
|
|
|
|
export type ChannelSetupWizardAllowFrom = {
|
|
helpTitle?: string;
|
|
helpLines?: string[];
|
|
credentialInputKey?: keyof ChannelSetupInput;
|
|
message: string;
|
|
placeholder: string;
|
|
invalidWithoutCredentialNote: string;
|
|
parseInputs?: (raw: string) => string[];
|
|
parseId: (raw: string) => string | null;
|
|
resolveEntries: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
credentialValues: ChannelSetupWizardCredentialValues;
|
|
entries: string[];
|
|
}) => Promise<ChannelSetupWizardAllowFromEntry[]>;
|
|
apply: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
allowFrom: string[];
|
|
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
|
};
|
|
|
|
export type ChannelSetupWizardGroupAccess = {
|
|
label: string;
|
|
placeholder: string;
|
|
helpTitle?: string;
|
|
helpLines?: string[];
|
|
currentPolicy: (params: { cfg: OpenClawConfig; accountId: string }) => ChannelAccessPolicy;
|
|
currentEntries: (params: { cfg: OpenClawConfig; accountId: string }) => string[];
|
|
updatePrompt: (params: { cfg: OpenClawConfig; accountId: string }) => boolean;
|
|
setPolicy: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
policy: ChannelAccessPolicy;
|
|
}) => OpenClawConfig;
|
|
resolveAllowlist: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
credentialValues: ChannelSetupWizardCredentialValues;
|
|
entries: string[];
|
|
prompter: Pick<WizardPrompter, "note">;
|
|
}) => Promise<unknown>;
|
|
applyAllowlist: (params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
resolved: unknown;
|
|
}) => OpenClawConfig;
|
|
};
|
|
|
|
export type ChannelSetupWizard = {
|
|
channel: string;
|
|
status: ChannelSetupWizardStatus;
|
|
introNote?: ChannelSetupWizardNote;
|
|
envShortcut?: ChannelSetupWizardEnvShortcut;
|
|
credentials: ChannelSetupWizardCredential[];
|
|
dmPolicy?: ChannelOnboardingDmPolicy;
|
|
allowFrom?: ChannelSetupWizardAllowFrom;
|
|
groupAccess?: ChannelSetupWizardGroupAccess;
|
|
disable?: (cfg: OpenClawConfig) => OpenClawConfig;
|
|
onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"];
|
|
};
|
|
|
|
type ChannelSetupWizardPlugin = Pick<ChannelPlugin, "id" | "meta" | "config" | "setup">;
|
|
|
|
async function buildStatus(
|
|
plugin: ChannelSetupWizardPlugin,
|
|
wizard: ChannelSetupWizard,
|
|
ctx: ChannelOnboardingStatusContext,
|
|
): Promise<ChannelOnboardingStatus> {
|
|
const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg });
|
|
return {
|
|
channel: plugin.id,
|
|
configured,
|
|
statusLines: [
|
|
`${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`,
|
|
],
|
|
selectionHint: configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint,
|
|
quickstartScore: configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore,
|
|
};
|
|
}
|
|
|
|
function applySetupInput(params: {
|
|
plugin: ChannelSetupWizardPlugin;
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
input: ChannelSetupInput;
|
|
}) {
|
|
const setup = params.plugin.setup;
|
|
if (!setup?.applyAccountConfig) {
|
|
throw new Error(`${params.plugin.id} does not support setup`);
|
|
}
|
|
const resolvedAccountId =
|
|
setup.resolveAccountId?.({
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
input: params.input,
|
|
}) ?? params.accountId;
|
|
const validationError = setup.validateInput?.({
|
|
cfg: params.cfg,
|
|
accountId: resolvedAccountId,
|
|
input: params.input,
|
|
});
|
|
if (validationError) {
|
|
throw new Error(validationError);
|
|
}
|
|
let next = setup.applyAccountConfig({
|
|
cfg: params.cfg,
|
|
accountId: resolvedAccountId,
|
|
input: params.input,
|
|
});
|
|
if (params.input.name?.trim() && setup.applyAccountName) {
|
|
next = setup.applyAccountName({
|
|
cfg: next,
|
|
accountId: resolvedAccountId,
|
|
name: params.input.name,
|
|
});
|
|
}
|
|
return {
|
|
cfg: next,
|
|
accountId: resolvedAccountId,
|
|
};
|
|
}
|
|
|
|
function trimResolvedValue(value?: string): string | undefined {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function collectCredentialValues(params: {
|
|
wizard: ChannelSetupWizard;
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
}): ChannelSetupWizardCredentialValues {
|
|
const values: ChannelSetupWizardCredentialValues = {};
|
|
for (const credential of params.wizard.credentials) {
|
|
const resolvedValue = trimResolvedValue(
|
|
credential.inspect({
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
}).resolvedValue,
|
|
);
|
|
if (resolvedValue) {
|
|
values[credential.inputKey] = resolvedValue;
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
export function buildChannelOnboardingAdapterFromSetupWizard(params: {
|
|
plugin: ChannelSetupWizardPlugin;
|
|
wizard: ChannelSetupWizard;
|
|
}): ChannelOnboardingAdapter {
|
|
const { plugin, wizard } = params;
|
|
return {
|
|
channel: plugin.id,
|
|
getStatus: async (ctx) => buildStatus(plugin, wizard, ctx),
|
|
configure: async ({
|
|
cfg,
|
|
prompter,
|
|
options,
|
|
accountOverrides,
|
|
shouldPromptAccountIds,
|
|
forceAllowFrom,
|
|
}) => {
|
|
const defaultAccountId =
|
|
plugin.config.defaultAccountId?.(cfg) ??
|
|
plugin.config.listAccountIds(cfg)[0] ??
|
|
DEFAULT_ACCOUNT_ID;
|
|
const accountId = await resolveAccountIdForConfigure({
|
|
cfg,
|
|
prompter,
|
|
label: plugin.meta.label,
|
|
accountOverride: accountOverrides[plugin.id],
|
|
shouldPromptAccountIds,
|
|
listAccountIds: plugin.config.listAccountIds,
|
|
defaultAccountId,
|
|
});
|
|
|
|
let next = cfg;
|
|
let credentialValues = collectCredentialValues({
|
|
wizard,
|
|
cfg: next,
|
|
accountId,
|
|
});
|
|
let usedEnvShortcut = false;
|
|
|
|
if (wizard.envShortcut?.isAvailable({ cfg: next, accountId })) {
|
|
const useEnvShortcut = await prompter.confirm({
|
|
message: wizard.envShortcut.prompt,
|
|
initialValue: true,
|
|
});
|
|
if (useEnvShortcut) {
|
|
next = await wizard.envShortcut.apply({ cfg: next, accountId });
|
|
credentialValues = collectCredentialValues({
|
|
wizard,
|
|
cfg: next,
|
|
accountId,
|
|
});
|
|
usedEnvShortcut = true;
|
|
}
|
|
}
|
|
|
|
const shouldShowIntro =
|
|
!usedEnvShortcut &&
|
|
(wizard.introNote?.shouldShow
|
|
? await wizard.introNote.shouldShow({
|
|
cfg: next,
|
|
accountId,
|
|
credentialValues,
|
|
})
|
|
: Boolean(wizard.introNote));
|
|
if (shouldShowIntro && wizard.introNote) {
|
|
await prompter.note(wizard.introNote.lines.join("\n"), wizard.introNote.title);
|
|
}
|
|
|
|
if (!usedEnvShortcut) {
|
|
for (const credential of wizard.credentials) {
|
|
let credentialState = credential.inspect({ cfg: next, accountId });
|
|
let resolvedCredentialValue = trimResolvedValue(credentialState.resolvedValue);
|
|
const allowEnv = credential.allowEnv?.({ cfg: next, accountId }) ?? false;
|
|
|
|
const credentialResult = await runSingleChannelSecretStep({
|
|
cfg: next,
|
|
prompter,
|
|
providerHint: credential.providerHint,
|
|
credentialLabel: credential.credentialLabel,
|
|
secretInputMode: options?.secretInputMode,
|
|
accountConfigured: credentialState.accountConfigured,
|
|
hasConfigToken: credentialState.hasConfiguredValue,
|
|
allowEnv,
|
|
envValue: credentialState.envValue,
|
|
envPrompt: credential.envPrompt,
|
|
keepPrompt: credential.keepPrompt,
|
|
inputPrompt: credential.inputPrompt,
|
|
preferredEnvVar: credential.preferredEnvVar,
|
|
onMissingConfigured:
|
|
credential.helpLines && credential.helpLines.length > 0
|
|
? async () => {
|
|
await prompter.note(
|
|
credential.helpLines!.join("\n"),
|
|
credential.helpTitle ?? credential.credentialLabel,
|
|
);
|
|
}
|
|
: undefined,
|
|
applyUseEnv: async (currentCfg) =>
|
|
credential.applyUseEnv
|
|
? await credential.applyUseEnv({
|
|
cfg: currentCfg,
|
|
accountId,
|
|
})
|
|
: applySetupInput({
|
|
plugin,
|
|
cfg: currentCfg,
|
|
accountId,
|
|
input: {
|
|
[credential.inputKey]: undefined,
|
|
useEnv: true,
|
|
},
|
|
}).cfg,
|
|
applySet: async (currentCfg, value, resolvedValue) => {
|
|
resolvedCredentialValue = resolvedValue;
|
|
return credential.applySet
|
|
? await credential.applySet({
|
|
cfg: currentCfg,
|
|
accountId,
|
|
value,
|
|
resolvedValue,
|
|
})
|
|
: applySetupInput({
|
|
plugin,
|
|
cfg: currentCfg,
|
|
accountId,
|
|
input: {
|
|
[credential.inputKey]: value,
|
|
useEnv: false,
|
|
},
|
|
}).cfg;
|
|
},
|
|
});
|
|
|
|
next = credentialResult.cfg;
|
|
credentialState = credential.inspect({ cfg: next, accountId });
|
|
resolvedCredentialValue =
|
|
trimResolvedValue(credentialResult.resolvedValue) ||
|
|
trimResolvedValue(credentialState.resolvedValue);
|
|
if (resolvedCredentialValue) {
|
|
credentialValues[credential.inputKey] = resolvedCredentialValue;
|
|
} else {
|
|
delete credentialValues[credential.inputKey];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (wizard.groupAccess) {
|
|
const access = wizard.groupAccess;
|
|
if (access.helpLines && access.helpLines.length > 0) {
|
|
await prompter.note(access.helpLines.join("\n"), access.helpTitle ?? access.label);
|
|
}
|
|
next = await configureChannelAccessWithAllowlist({
|
|
cfg: next,
|
|
prompter,
|
|
label: access.label,
|
|
currentPolicy: access.currentPolicy({ cfg: next, accountId }),
|
|
currentEntries: access.currentEntries({ cfg: next, accountId }),
|
|
placeholder: access.placeholder,
|
|
updatePrompt: access.updatePrompt({ cfg: next, accountId }),
|
|
setPolicy: (currentCfg, policy) =>
|
|
access.setPolicy({
|
|
cfg: currentCfg,
|
|
accountId,
|
|
policy,
|
|
}),
|
|
resolveAllowlist: async ({ cfg: currentCfg, entries }) =>
|
|
await access.resolveAllowlist({
|
|
cfg: currentCfg,
|
|
accountId,
|
|
credentialValues,
|
|
entries,
|
|
prompter,
|
|
}),
|
|
applyAllowlist: ({ cfg: currentCfg, resolved }) =>
|
|
access.applyAllowlist({
|
|
cfg: currentCfg,
|
|
accountId,
|
|
resolved,
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (forceAllowFrom && wizard.allowFrom) {
|
|
const allowFrom = wizard.allowFrom;
|
|
const allowFromCredentialValue = trimResolvedValue(
|
|
credentialValues[allowFrom.credentialInputKey ?? wizard.credentials[0]?.inputKey],
|
|
);
|
|
if (allowFrom.helpLines && allowFrom.helpLines.length > 0) {
|
|
await prompter.note(
|
|
allowFrom.helpLines.join("\n"),
|
|
allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`,
|
|
);
|
|
}
|
|
const existingAllowFrom =
|
|
plugin.config.resolveAllowFrom?.({
|
|
cfg: next,
|
|
accountId,
|
|
}) ?? [];
|
|
const unique = await promptResolvedAllowFrom({
|
|
prompter,
|
|
existing: existingAllowFrom,
|
|
token: allowFromCredentialValue,
|
|
message: allowFrom.message,
|
|
placeholder: allowFrom.placeholder,
|
|
label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`,
|
|
parseInputs: allowFrom.parseInputs ?? splitOnboardingEntries,
|
|
parseId: allowFrom.parseId,
|
|
invalidWithoutTokenNote: allowFrom.invalidWithoutCredentialNote,
|
|
resolveEntries: async ({ entries }) =>
|
|
allowFrom.resolveEntries({
|
|
cfg: next,
|
|
accountId,
|
|
credentialValues,
|
|
entries,
|
|
}),
|
|
});
|
|
next = await allowFrom.apply({
|
|
cfg: next,
|
|
accountId,
|
|
allowFrom: unique,
|
|
});
|
|
}
|
|
|
|
return { cfg: next, accountId };
|
|
},
|
|
dmPolicy: wizard.dmPolicy,
|
|
disable: wizard.disable,
|
|
onAccountRecorded: wizard.onAccountRecorded,
|
|
};
|
|
}
|