mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 20:36:52 +00:00
825 lines
28 KiB
TypeScript
825 lines
28 KiB
TypeScript
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js";
|
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
|
import { listActiveChannelSetupPlugins } from "../channels/plugins/setup-registry.js";
|
|
import type {
|
|
ChannelSetupPlugin,
|
|
ChannelSetupWizardAdapter,
|
|
} from "../channels/plugins/setup-wizard-types.js";
|
|
import { formatCliCommand } from "../cli/command-format.js";
|
|
import {
|
|
resolveChannelSetupEntries,
|
|
shouldShowChannelInSetup,
|
|
} from "../commands/channel-setup/discovery.js";
|
|
import {
|
|
ensureChannelSetupPluginInstalled,
|
|
loadChannelSetupPluginRegistrySnapshotForChannel,
|
|
} from "../commands/channel-setup/plugin-install.js";
|
|
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
|
|
import {
|
|
getTrustedChannelPluginCatalogEntry,
|
|
listTrustedChannelPluginCatalogEntries,
|
|
} from "../commands/channel-setup/trusted-catalog.js";
|
|
import type {
|
|
ChannelSetupConfiguredResult,
|
|
ChannelSetupResult,
|
|
ChannelSetupStatus,
|
|
ChannelOnboardingPostWriteHook,
|
|
SetupChannelsOptions,
|
|
} from "../commands/channel-setup/types.js";
|
|
import type { ChannelChoice } from "../commands/onboard-types.js";
|
|
import { isChannelConfigured } from "../config/channel-configured.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { resolveBundledPluginSources } from "../plugins/bundled-sources.js";
|
|
import { enablePluginInConfig } from "../plugins/enable.js";
|
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { t } from "../wizard/i18n/index.js";
|
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
|
import {
|
|
maybeConfigureDmPolicies,
|
|
promptConfiguredAction,
|
|
promptRemovalAccountId,
|
|
formatAccountLabel,
|
|
} from "./channel-setup.prompts.js";
|
|
import {
|
|
collectChannelStatus,
|
|
findBundledSourceForCatalogChannel,
|
|
noteChannelPrimer,
|
|
resolveCatalogChannelSelectionHint,
|
|
resolveChannelSelectionNoteLines,
|
|
resolveChannelSetupSelectionContributions,
|
|
resolveQuickstartDefault,
|
|
} from "./channel-setup.status.js";
|
|
export { noteChannelStatus } from "./channel-setup.status.js";
|
|
|
|
export function createChannelOnboardingPostWriteHookCollector() {
|
|
const hooks = new Map<string, ChannelOnboardingPostWriteHook>();
|
|
return {
|
|
collect(hook: ChannelOnboardingPostWriteHook) {
|
|
hooks.set(`${hook.channel}:${hook.accountId}`, hook);
|
|
},
|
|
drain(): ChannelOnboardingPostWriteHook[] {
|
|
const next = [...hooks.values()];
|
|
hooks.clear();
|
|
return next;
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function runCollectedChannelOnboardingPostWriteHooks(params: {
|
|
hooks: ChannelOnboardingPostWriteHook[];
|
|
cfg: OpenClawConfig;
|
|
runtime: RuntimeEnv;
|
|
}): Promise<void> {
|
|
for (const hook of params.hooks) {
|
|
try {
|
|
await hook.run({ cfg: params.cfg, runtime: params.runtime });
|
|
} catch (err) {
|
|
const message = formatErrorMessage(err);
|
|
params.runtime.error(
|
|
`Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createChannelOnboardingPostWriteHook(params: {
|
|
accountId?: string;
|
|
adapter?: Pick<ChannelSetupWizardAdapter, "afterConfigWritten">;
|
|
channel: ChannelChoice;
|
|
previousCfg: OpenClawConfig;
|
|
}): ChannelOnboardingPostWriteHook | undefined {
|
|
if (!params.accountId || !params.adapter?.afterConfigWritten) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
channel: params.channel,
|
|
accountId: params.accountId,
|
|
run: async ({ cfg, runtime }) =>
|
|
await params.adapter?.afterConfigWritten?.({
|
|
previousCfg: params.previousCfg,
|
|
cfg,
|
|
accountId: params.accountId!,
|
|
runtime,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// Channel-specific prompts moved into setup flow adapters.
|
|
|
|
export async function setupChannels(
|
|
cfg: OpenClawConfig,
|
|
runtime: RuntimeEnv,
|
|
prompter: WizardPrompter,
|
|
options?: SetupChannelsOptions,
|
|
): Promise<OpenClawConfig> {
|
|
let next = cfg;
|
|
const deferStatusUntilSelection = options?.deferStatusUntilSelection === true;
|
|
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
|
|
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
|
|
...options?.accountIds,
|
|
};
|
|
const scopedPluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
|
|
const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
|
const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => {
|
|
const channel = plugin.id;
|
|
scopedPluginsById.set(channel, plugin);
|
|
options?.onResolvedPlugin?.(channel, plugin);
|
|
};
|
|
const activePluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
|
|
const rememberActivePlugin = (plugin: ChannelSetupPlugin) => {
|
|
activePluginsById.set(plugin.id, plugin);
|
|
return plugin;
|
|
};
|
|
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined =>
|
|
scopedPluginsById.get(channel) ?? activePluginsById.get(channel);
|
|
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
|
|
const merged = new Map<string, ChannelSetupPlugin>();
|
|
const registryPlugins = listActiveChannelSetupPlugins().map(rememberActivePlugin);
|
|
for (const plugin of registryPlugins) {
|
|
if (shouldShowChannelInSetup(plugin.meta)) {
|
|
merged.set(plugin.id, plugin);
|
|
}
|
|
}
|
|
for (const plugin of scopedPluginsById.values()) {
|
|
if (shouldShowChannelInSetup(plugin.meta)) {
|
|
merged.set(plugin.id, plugin);
|
|
}
|
|
}
|
|
return Array.from(merged.values());
|
|
};
|
|
const resolveVisibleChannelEntries = () =>
|
|
resolveChannelSetupEntries({
|
|
cfg: next,
|
|
installedPlugins: listVisibleInstalledPlugins(),
|
|
workspaceDir: resolveWorkspaceDir(),
|
|
});
|
|
const loadScopedChannelPlugin = async (
|
|
channel: ChannelChoice,
|
|
pluginId?: string,
|
|
setup?: {
|
|
forceReload?: boolean;
|
|
forceSetupOnlyChannelPlugins?: boolean;
|
|
},
|
|
): Promise<ChannelSetupPlugin | undefined> => {
|
|
const existing = getVisibleChannelPlugin(channel);
|
|
if (existing && setup?.forceReload !== true) {
|
|
return existing;
|
|
}
|
|
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
|
|
cfg: next,
|
|
runtime,
|
|
channel,
|
|
...(pluginId ? { pluginId } : {}),
|
|
workspaceDir: resolveWorkspaceDir(),
|
|
forceSetupOnlyChannelPlugins: setup?.forceSetupOnlyChannelPlugins ?? true,
|
|
});
|
|
const plugin =
|
|
snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin ??
|
|
snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin;
|
|
if (plugin) {
|
|
rememberScopedPlugin(plugin);
|
|
return plugin;
|
|
}
|
|
const bundledPlugin = getBundledChannelSetupPlugin(channel);
|
|
if (bundledPlugin) {
|
|
rememberScopedPlugin(bundledPlugin);
|
|
return bundledPlugin;
|
|
}
|
|
return undefined;
|
|
};
|
|
const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => {
|
|
const scopedPlugin = scopedPluginsById.get(channel);
|
|
if (scopedPlugin) {
|
|
return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin);
|
|
}
|
|
return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel));
|
|
};
|
|
const preloadConfiguredExternalPlugins = async () => {
|
|
// Keep setup memory bounded by snapshot-loading only configured external plugins.
|
|
listVisibleInstalledPlugins();
|
|
const workspaceDir = resolveWorkspaceDir();
|
|
const preloadTasks: Promise<unknown>[] = [];
|
|
// Security: keep trusted workspace overrides eligible during setup while
|
|
// falling back from untrusted workspace shadows to the non-workspace entry.
|
|
for (const entry of listTrustedChannelPluginCatalogEntries({ cfg: next, workspaceDir })) {
|
|
const channel = entry.id as ChannelChoice;
|
|
if (getVisibleChannelPlugin(channel)) {
|
|
continue;
|
|
}
|
|
const explicitlyEnabled =
|
|
next.plugins?.entries?.[entry.pluginId ?? channel]?.enabled === true;
|
|
if (!explicitlyEnabled && !isChannelConfigured(next, channel)) {
|
|
continue;
|
|
}
|
|
preloadTasks.push(loadScopedChannelPlugin(channel, entry.pluginId));
|
|
}
|
|
await Promise.all(preloadTasks);
|
|
};
|
|
if (!deferStatusUntilSelection) {
|
|
await preloadConfiguredExternalPlugins();
|
|
}
|
|
|
|
const statusSummary = deferStatusUntilSelection
|
|
? { statusByChannel: new Map<ChannelChoice, ChannelSetupStatus>(), statusLines: [] }
|
|
: await collectChannelStatus({
|
|
cfg: next,
|
|
options,
|
|
accountOverrides,
|
|
installedPlugins: listVisibleInstalledPlugins(),
|
|
resolveAdapter: getVisibleSetupFlowAdapter,
|
|
});
|
|
const { statusByChannel, statusLines } = statusSummary;
|
|
if (!options?.skipStatusNote && statusLines.length > 0) {
|
|
await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle"));
|
|
}
|
|
|
|
const shouldConfigure = options?.skipConfirm
|
|
? true
|
|
: await prompter.confirm({
|
|
message: t("wizard.channels.setupConfirm"),
|
|
initialValue: true,
|
|
});
|
|
if (!shouldConfigure) {
|
|
return cfg;
|
|
}
|
|
|
|
const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({
|
|
id: entry.id,
|
|
label: entry.meta.label,
|
|
blurb: entry.meta.blurb,
|
|
}));
|
|
await noteChannelPrimer(prompter, primerChannels);
|
|
|
|
const quickstartDefault =
|
|
options?.initialSelection?.[0] ??
|
|
(deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel));
|
|
|
|
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
|
const accountIdsByChannel = new Map<ChannelChoice, string>();
|
|
const recordAccount = (channel: ChannelChoice, accountId: string) => {
|
|
options?.onAccountId?.(channel, accountId);
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
adapter?.onAccountRecorded?.(accountId, options);
|
|
accountIdsByChannel.set(channel, accountId);
|
|
};
|
|
|
|
const selection: ChannelChoice[] = [];
|
|
const addSelection = (channel: ChannelChoice) => {
|
|
if (!selection.includes(channel)) {
|
|
selection.push(channel);
|
|
}
|
|
};
|
|
|
|
const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => {
|
|
if (next.plugins?.enabled === false) {
|
|
return "plugins disabled";
|
|
}
|
|
if (next.plugins?.entries?.[channel]?.enabled === false) {
|
|
return "plugin disabled";
|
|
}
|
|
if (
|
|
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
|
|
?.enabled === "boolean"
|
|
) {
|
|
return (next.channels as Record<string, { enabled?: boolean }>)[channel]?.enabled === false
|
|
? "disabled"
|
|
: undefined;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
|
const configDisabledHint = resolveConfigDisabledHint(channel);
|
|
if (configDisabledHint || deferStatusUntilSelection) {
|
|
return configDisabledHint;
|
|
}
|
|
const plugin = getVisibleChannelPlugin(channel);
|
|
if (!plugin) {
|
|
return undefined;
|
|
}
|
|
const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next });
|
|
const account = plugin.config.resolveAccount(next, accountId);
|
|
let enabled: boolean | undefined;
|
|
if (plugin.config.isEnabled) {
|
|
enabled = plugin.config.isEnabled(account, next);
|
|
} else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") {
|
|
enabled = (account as { enabled?: boolean }).enabled;
|
|
}
|
|
return enabled === false ? "disabled" : undefined;
|
|
};
|
|
|
|
const getChannelEntries = () => {
|
|
const resolved = resolveVisibleChannelEntries();
|
|
return {
|
|
entries: resolved.entries,
|
|
catalogById: resolved.installableCatalogById,
|
|
installedCatalogById: resolved.installedCatalogById,
|
|
};
|
|
};
|
|
|
|
// Decorates the runtime status map with synthetic `selectionHint` entries for
|
|
// installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we
|
|
// run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty
|
|
// until the user picks a channel — without this overlay the selection menu
|
|
// would render those options without any "download from <npm-spec>" hint.
|
|
//
|
|
// Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path
|
|
// too whenever their plugin is not yet enabled, because they share the same
|
|
// "installable catalog" bucket. For those we must NOT show "download from
|
|
// <npm-spec>" — the plugin already lives under `extensions/<id>` and the
|
|
// hint would mislead users into thinking the plugin is missing.
|
|
const buildStatusByChannelForSelection = (
|
|
catalogById: ReturnType<typeof getChannelEntries>["catalogById"],
|
|
): Map<ChannelChoice, ChannelSetupStatus> => {
|
|
const decorated = new Map(statusByChannel);
|
|
if (catalogById.size === 0) {
|
|
return decorated;
|
|
}
|
|
const bundledSources = resolveBundledPluginSources({
|
|
workspaceDir: resolveWorkspaceDir(),
|
|
});
|
|
for (const [channel, entry] of catalogById) {
|
|
if (decorated.has(channel)) {
|
|
continue;
|
|
}
|
|
const bundledLocalPath =
|
|
findBundledSourceForCatalogChannel({ bundled: bundledSources, entry })?.localPath ?? null;
|
|
decorated.set(channel, {
|
|
channel,
|
|
configured: false,
|
|
statusLines: [],
|
|
selectionHint: resolveCatalogChannelSelectionHint(entry, { bundledLocalPath }),
|
|
});
|
|
}
|
|
return decorated;
|
|
};
|
|
|
|
const refreshStatus = async (channel: ChannelChoice) => {
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
if (!adapter) {
|
|
return;
|
|
}
|
|
const status = await adapter.getStatus({ cfg: next, options, accountOverrides });
|
|
statusByChannel.set(channel, status);
|
|
};
|
|
|
|
const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise<boolean> => {
|
|
if (getVisibleChannelPlugin(channel)) {
|
|
await refreshStatus(channel);
|
|
return true;
|
|
}
|
|
const disabledHint = resolveConfigDisabledHint(channel);
|
|
if (disabledHint) {
|
|
await prompter.note(
|
|
t("wizard.channels.disabledDuringSetup", {
|
|
channel,
|
|
hint: disabledHint,
|
|
command: formatCliCommand("openclaw channels add"),
|
|
}),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return false;
|
|
}
|
|
const result = enablePluginInConfig(next, channel);
|
|
next = result.config;
|
|
if (!result.enabled) {
|
|
await prompter.note(
|
|
t("wizard.channels.pluginEnableFailed", {
|
|
channel,
|
|
reason: result.reason ?? "plugin disabled",
|
|
command: formatCliCommand("openclaw plugins list"),
|
|
}),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return false;
|
|
}
|
|
const plugin = await loadScopedChannelPlugin(channel);
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
if (!plugin) {
|
|
if (adapter) {
|
|
await prompter.note(
|
|
t("wizard.channels.pluginMissingRecoverable", {
|
|
channel,
|
|
listCommand: formatCliCommand("openclaw plugins list"),
|
|
enableCommand: formatCliCommand("openclaw plugins enable " + channel),
|
|
}),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
await refreshStatus(channel);
|
|
return true;
|
|
}
|
|
await prompter.note(
|
|
t("wizard.channels.pluginNotAvailable", { channel }),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return false;
|
|
}
|
|
await refreshStatus(channel);
|
|
return true;
|
|
};
|
|
|
|
const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => {
|
|
const previousCfg = next;
|
|
next = result.cfg;
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
if (result.accountId) {
|
|
recordAccount(channel, result.accountId);
|
|
const postWriteHook = createChannelOnboardingPostWriteHook({
|
|
accountId: result.accountId,
|
|
adapter,
|
|
channel,
|
|
previousCfg,
|
|
});
|
|
if (postWriteHook) {
|
|
options?.onPostWriteHook?.(postWriteHook);
|
|
}
|
|
}
|
|
addSelection(channel);
|
|
await refreshStatus(channel);
|
|
};
|
|
|
|
const applyCustomSetupResult = async (
|
|
channel: ChannelChoice,
|
|
result: ChannelSetupConfiguredResult,
|
|
) => {
|
|
if (result === "skip") {
|
|
return false;
|
|
}
|
|
await applySetupResult(channel, result);
|
|
return true;
|
|
};
|
|
|
|
const configureChannel = async (channel: ChannelChoice) => {
|
|
if (scopedPluginsById.has(channel)) {
|
|
await loadScopedChannelPlugin(channel, undefined, {
|
|
forceReload: true,
|
|
forceSetupOnlyChannelPlugins: true,
|
|
});
|
|
}
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
if (!adapter) {
|
|
await prompter.note(
|
|
t("wizard.channels.noInteractiveSetup", {
|
|
channel,
|
|
command: formatCliCommand(`openclaw channels add --channel ${channel} --help`),
|
|
}),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return;
|
|
}
|
|
const result = await adapter.configure({
|
|
cfg: next,
|
|
runtime,
|
|
prompter,
|
|
options,
|
|
accountOverrides,
|
|
shouldPromptAccountIds,
|
|
forceAllowFrom: forceAllowFromChannels.has(channel),
|
|
});
|
|
await applySetupResult(channel, result);
|
|
};
|
|
|
|
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
|
|
const plugin = getVisibleChannelPlugin(channel);
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
if (adapter?.configureWhenConfigured) {
|
|
const custom = await adapter.configureWhenConfigured({
|
|
cfg: next,
|
|
runtime,
|
|
prompter,
|
|
options,
|
|
accountOverrides,
|
|
shouldPromptAccountIds,
|
|
forceAllowFrom: forceAllowFromChannels.has(channel),
|
|
configured: true,
|
|
label,
|
|
});
|
|
if (!(await applyCustomSetupResult(channel, custom))) {
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
const supportsDisable = Boolean(
|
|
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
|
|
);
|
|
const supportsDelete = Boolean(options?.allowDisable && plugin?.config.deleteAccount);
|
|
const action = await promptConfiguredAction({
|
|
prompter,
|
|
label,
|
|
supportsDisable,
|
|
supportsDelete,
|
|
});
|
|
|
|
if (action === "skip") {
|
|
return;
|
|
}
|
|
if (action === "update") {
|
|
await configureChannel(channel);
|
|
return;
|
|
}
|
|
if (!options?.allowDisable) {
|
|
return;
|
|
}
|
|
|
|
if (action === "delete" && !supportsDelete) {
|
|
await prompter.note(
|
|
t("wizard.channels.configuredDeleteUnsupported", { label }),
|
|
t("wizard.channels.removeTitle"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const shouldPromptAccount =
|
|
action === "delete"
|
|
? Boolean(plugin?.config.deleteAccount)
|
|
: Boolean(plugin?.config.setAccountEnabled);
|
|
const accountId = shouldPromptAccount
|
|
? await promptRemovalAccountId({
|
|
cfg: next,
|
|
prompter,
|
|
label,
|
|
channel,
|
|
plugin,
|
|
})
|
|
: DEFAULT_ACCOUNT_ID;
|
|
const resolvedAccountId =
|
|
normalizeAccountId(accountId) ??
|
|
(plugin ? resolveChannelDefaultAccountId({ plugin, cfg: next }) : DEFAULT_ACCOUNT_ID);
|
|
const accountLabel = formatAccountLabel(resolvedAccountId);
|
|
|
|
if (action === "delete") {
|
|
const confirmed = await prompter.confirm({
|
|
message: t("wizard.channels.deleteAccount", { label, account: accountLabel }),
|
|
initialValue: false,
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
if (plugin?.config.deleteAccount) {
|
|
next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId });
|
|
}
|
|
await refreshStatus(channel);
|
|
return;
|
|
}
|
|
|
|
if (plugin?.config.setAccountEnabled) {
|
|
next = plugin.config.setAccountEnabled({
|
|
cfg: next,
|
|
accountId: resolvedAccountId,
|
|
enabled: false,
|
|
});
|
|
} else if (adapter?.disable) {
|
|
next = adapter.disable(next);
|
|
}
|
|
await refreshStatus(channel);
|
|
};
|
|
|
|
const handleChannelChoice = async (
|
|
channel: ChannelChoice,
|
|
): Promise<"done" | "retry_selection"> => {
|
|
const { catalogById, installedCatalogById } = getChannelEntries();
|
|
const catalogEntry = catalogById.get(channel);
|
|
const installedCatalogEntry = installedCatalogById.get(channel);
|
|
const deferredDisabledHint = deferStatusUntilSelection
|
|
? resolveConfigDisabledHint(channel)
|
|
: undefined;
|
|
if (deferredDisabledHint) {
|
|
await prompter.note(
|
|
t("wizard.channels.disabledBeforeSetup", {
|
|
channel,
|
|
hint: deferredDisabledHint,
|
|
}),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return "done";
|
|
}
|
|
if (catalogEntry) {
|
|
const workspaceDir = resolveWorkspaceDir();
|
|
const result = await ensureChannelSetupPluginInstalled({
|
|
cfg: next,
|
|
entry: catalogEntry,
|
|
prompter,
|
|
runtime,
|
|
workspaceDir,
|
|
autoConfirmSingleSource: true,
|
|
});
|
|
next = result.cfg;
|
|
if (!result.installed) {
|
|
return "retry_selection";
|
|
}
|
|
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
|
|
await refreshStatus(channel);
|
|
} else if (installedCatalogEntry) {
|
|
let plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
|
|
if (!plugin && installedCatalogEntry.install?.npmSpec) {
|
|
// The channel is recorded in the user's config (e.g. a stale
|
|
// `channels.<id>` entry left over from a previous install) but the
|
|
// plugin runtime cannot be loaded from disk — typically because the
|
|
// externalized npm package was uninstalled or pruned during an
|
|
// upgrade. Rather than dead-ending with "plugin not available", fall
|
|
// back to the catalog-driven install flow so onboard can recover by
|
|
// reinstalling the official external plugin.
|
|
//
|
|
// Preserve the same disabled-config guard used by
|
|
// `enableBundledPluginForSetup` so an operator-disabled channel
|
|
// cannot be silently reinstalled/re-enabled through this path.
|
|
const disabledHint = resolveConfigDisabledHint(channel);
|
|
if (disabledHint) {
|
|
await prompter.note(
|
|
t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return "done";
|
|
}
|
|
const workspaceDir = resolveWorkspaceDir();
|
|
const result = await ensureChannelSetupPluginInstalled({
|
|
cfg: next,
|
|
entry: installedCatalogEntry,
|
|
prompter,
|
|
runtime,
|
|
workspaceDir,
|
|
autoConfirmSingleSource: true,
|
|
});
|
|
next = result.cfg;
|
|
if (!result.installed) {
|
|
return "retry_selection";
|
|
}
|
|
plugin = await loadScopedChannelPlugin(
|
|
channel,
|
|
result.pluginId ?? installedCatalogEntry.pluginId,
|
|
);
|
|
}
|
|
if (!plugin) {
|
|
await prompter.note(
|
|
t("wizard.channels.pluginNotAvailable", { channel }),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return "done";
|
|
}
|
|
await refreshStatus(channel);
|
|
} else {
|
|
// Neither discovery bucket yielded an entry for this channel. This can
|
|
// happen when `channels.<id>` in user config carries stale fields (e.g.
|
|
// `appId`, tokens) left over from a previous install: `isStatically-
|
|
// ChannelConfigured` returns true, which removes the channel from the
|
|
// `installableCatalogEntries` bucket, while a missing/pruned plugin on
|
|
// disk keeps it out of `installedCatalogEntries`. Before falling back
|
|
// to the bundled-plugin enable path, consult the catalog directly so
|
|
// users with a stale config entry for an externalized channel (qqbot,
|
|
// imessage, discord, whatsapp, ...) still get auto-install instead
|
|
// of a dead-end "plugin not available" note.
|
|
const fallbackCatalogEntry = getTrustedChannelPluginCatalogEntry(channel, {
|
|
cfg: next,
|
|
workspaceDir: resolveWorkspaceDir(),
|
|
});
|
|
if (fallbackCatalogEntry?.install?.npmSpec) {
|
|
// Preserve the same disabled-config guard used by
|
|
// `enableBundledPluginForSetup` so an operator-disabled channel
|
|
// cannot be silently reinstalled/re-enabled through this path. This
|
|
// mirrors the guard that was previously enforced inside the
|
|
// bundled-enable fallback.
|
|
const disabledHint = resolveConfigDisabledHint(channel);
|
|
if (disabledHint) {
|
|
await prompter.note(
|
|
t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }),
|
|
t("wizard.channels.setupTitle"),
|
|
);
|
|
return "done";
|
|
}
|
|
const workspaceDir = resolveWorkspaceDir();
|
|
const result = await ensureChannelSetupPluginInstalled({
|
|
cfg: next,
|
|
entry: fallbackCatalogEntry,
|
|
prompter,
|
|
runtime,
|
|
workspaceDir,
|
|
autoConfirmSingleSource: true,
|
|
});
|
|
next = result.cfg;
|
|
if (!result.installed) {
|
|
return "retry_selection";
|
|
}
|
|
await loadScopedChannelPlugin(channel, result.pluginId ?? fallbackCatalogEntry.pluginId);
|
|
await refreshStatus(channel);
|
|
} else {
|
|
const enabled = await enableBundledPluginForSetup(channel);
|
|
if (!enabled) {
|
|
return "done";
|
|
}
|
|
}
|
|
}
|
|
|
|
const plugin = getVisibleChannelPlugin(channel);
|
|
const adapter = getVisibleSetupFlowAdapter(channel);
|
|
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
|
|
const status = statusByChannel.get(channel);
|
|
const configured = status?.configured ?? false;
|
|
if (adapter?.configureInteractive) {
|
|
const custom = await adapter.configureInteractive({
|
|
cfg: next,
|
|
runtime,
|
|
prompter,
|
|
options,
|
|
accountOverrides,
|
|
shouldPromptAccountIds,
|
|
forceAllowFrom: forceAllowFromChannels.has(channel),
|
|
configured,
|
|
label,
|
|
});
|
|
if (!(await applyCustomSetupResult(channel, custom))) {
|
|
return "done";
|
|
}
|
|
return "done";
|
|
}
|
|
if (configured) {
|
|
await handleConfiguredChannel(channel, label);
|
|
return "done";
|
|
}
|
|
await configureChannel(channel);
|
|
return "done";
|
|
};
|
|
|
|
if (options?.quickstartDefaults) {
|
|
while (true) {
|
|
const { entries, catalogById } = getChannelEntries();
|
|
const choice = await prompter.select({
|
|
message: t("wizard.channels.selectQuickstart"),
|
|
options: [
|
|
...resolveChannelSetupSelectionContributions({
|
|
entries,
|
|
statusByChannel: buildStatusByChannelForSelection(catalogById),
|
|
resolveDisabledHint,
|
|
}).map((contribution) => contribution.option),
|
|
{
|
|
value: "__skip__",
|
|
label: t("common.skipForNow"),
|
|
hint: t("wizard.channels.skipLaterHint", {
|
|
command: formatCliCommand("openclaw channels add"),
|
|
}),
|
|
},
|
|
],
|
|
initialValue: quickstartDefault,
|
|
searchable: true,
|
|
});
|
|
if (choice === "__skip__") {
|
|
break;
|
|
}
|
|
if ((await handleChannelChoice(choice)) === "done") {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
const doneValue = "__done__" as const;
|
|
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
|
|
while (true) {
|
|
const { entries, catalogById } = getChannelEntries();
|
|
const choice = await prompter.select({
|
|
message: t("wizard.channels.select"),
|
|
options: [
|
|
...resolveChannelSetupSelectionContributions({
|
|
entries,
|
|
statusByChannel: buildStatusByChannelForSelection(catalogById),
|
|
resolveDisabledHint,
|
|
}).map((contribution) => contribution.option),
|
|
{
|
|
value: doneValue,
|
|
label: t("common.finished"),
|
|
hint: selection.length > 0 ? t("wizard.channels.doneHint") : t("common.skipForNow"),
|
|
},
|
|
],
|
|
initialValue,
|
|
});
|
|
if (choice === doneValue) {
|
|
break;
|
|
}
|
|
await handleChannelChoice(choice);
|
|
}
|
|
}
|
|
|
|
options?.onSelection?.(selection);
|
|
|
|
const selectedLines = resolveChannelSelectionNoteLines({
|
|
cfg: next,
|
|
installedPlugins: listVisibleInstalledPlugins(),
|
|
selection,
|
|
});
|
|
if (selectedLines.length > 0) {
|
|
await prompter.note(selectedLines.join("\n"), t("wizard.channels.selectedTitle"));
|
|
}
|
|
|
|
if (!options?.skipDmPolicyPrompt) {
|
|
next = await maybeConfigureDmPolicies({
|
|
cfg: next,
|
|
selection,
|
|
prompter,
|
|
accountIdsByChannel,
|
|
resolveAdapter: getVisibleSetupFlowAdapter,
|
|
});
|
|
}
|
|
|
|
return next;
|
|
}
|