Files
openclaw/src/flows/channel-setup.ts
2026-03-27 01:17:39 +00:00

603 lines
19 KiB
TypeScript

import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelSetupPlugin,
listChannelSetupPlugins,
} from "../channels/plugins/setup-registry.js";
import type { ChannelSetupPlugin } from "../channels/plugins/setup-wizard-types.js";
import { listChatChannels } from "../channels/registry.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveChannelSetupEntries } 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 type {
ChannelSetupConfiguredResult,
ChannelSetupResult,
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/config.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
maybeConfigureDmPolicies,
promptConfiguredAction,
promptRemovalAccountId,
formatAccountLabel,
} from "./channel-setup.prompts.js";
import {
collectChannelStatus,
noteChannelPrimer,
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 = err instanceof Error ? err.message : String(err);
params.runtime.error(
`Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`,
);
}
}
}
// 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 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 getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined =>
scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel);
const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => {
const merged = new Map<string, ChannelSetupPlugin>();
for (const plugin of listChannelSetupPlugins()) {
merged.set(plugin.id, plugin);
}
for (const plugin of scopedPluginsById.values()) {
merged.set(plugin.id, plugin);
}
return Array.from(merged.values());
};
const loadScopedChannelPlugin = async (
channel: ChannelChoice,
pluginId?: string,
): Promise<ChannelSetupPlugin | undefined> => {
const existing = getVisibleChannelPlugin(channel);
if (existing) {
return existing;
}
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
cfg: next,
runtime,
channel,
...(pluginId ? { pluginId } : {}),
workspaceDir: resolveWorkspaceDir(),
});
const plugin =
snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin ??
snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin;
if (plugin) {
rememberScopedPlugin(plugin);
return plugin;
}
return undefined;
};
const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => {
const scopedPlugin = scopedPluginsById.get(channel);
if (scopedPlugin) {
return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin);
}
return resolveChannelSetupWizardAdapterForPlugin(getChannelSetupPlugin(channel));
};
const preloadConfiguredExternalPlugins = () => {
// Keep setup memory bounded by snapshot-loading only configured external plugins.
const workspaceDir = resolveWorkspaceDir();
for (const entry of listChannelPluginCatalogEntries({ 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;
}
void loadScopedChannelPlugin(channel, entry.pluginId);
}
};
if (options?.whatsappAccountId?.trim()) {
accountOverrides.whatsapp = options.whatsappAccountId.trim();
}
preloadConfiguredExternalPlugins();
const {
installedPlugins,
catalogEntries,
installedCatalogEntries,
statusByChannel,
statusLines,
} = await collectChannelStatus({
cfg: next,
options,
accountOverrides,
installedPlugins: listVisibleInstalledPlugins(),
resolveAdapter: getVisibleSetupFlowAdapter,
});
if (!options?.skipStatusNote && statusLines.length > 0) {
await prompter.note(statusLines.join("\n"), "Channel status");
}
const shouldConfigure = options?.skipConfirm
? true
: await prompter.confirm({
message: "Configure chat channels now?",
initialValue: true,
});
if (!shouldConfigure) {
return cfg;
}
const corePrimer = listChatChannels().map((meta) => ({
id: meta.id,
label: meta.label,
blurb: meta.blurb,
}));
const coreIds = new Set(corePrimer.map((entry) => entry.id));
const primerChannels = [
...corePrimer,
...installedPlugins
.filter((plugin) => !coreIds.has(plugin.id))
.map((plugin) => ({
id: plugin.id,
label: plugin.meta.label,
blurb: plugin.meta.blurb,
})),
...installedCatalogEntries
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
.map((entry) => ({
id: entry.id as ChannelChoice,
label: entry.meta.label,
blurb: entry.meta.blurb,
})),
...catalogEntries
.filter((entry) => !coreIds.has(entry.id as ChannelChoice))
.map((entry) => ({
id: entry.id as ChannelChoice,
label: entry.meta.label,
blurb: entry.meta.blurb,
})),
];
await noteChannelPrimer(prompter, primerChannels);
const quickstartDefault =
options?.initialSelection?.[0] ?? 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 resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
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;
}
const plugin = getVisibleChannelPlugin(channel);
if (!plugin) {
if (next.plugins?.entries?.[channel]?.enabled === false) {
return "plugin disabled";
}
if (next.plugins?.enabled === false) {
return "plugins disabled";
}
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 = resolveChannelSetupEntries({
cfg: next,
installedPlugins: listVisibleInstalledPlugins(),
workspaceDir: resolveWorkspaceDir(),
});
return {
entries: resolved.entries,
catalogById: resolved.installableCatalogById,
installedCatalogById: resolved.installedCatalogById,
};
};
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 result = enablePluginInConfig(next, channel);
next = result.config;
if (!result.enabled) {
await prompter.note(
`Cannot enable ${channel}: ${result.reason ?? "plugin disabled"}.`,
"Channel setup",
);
return false;
}
const plugin = await loadScopedChannelPlugin(channel);
const adapter = getVisibleSetupFlowAdapter(channel);
if (!plugin) {
if (adapter) {
await prompter.note(
`${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand(
"openclaw plugins list",
)}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`,
"Channel setup",
);
await refreshStatus(channel);
return true;
}
await prompter.note(`${channel} plugin not available.`, "Channel setup");
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);
if (adapter?.afterConfigWritten) {
options?.onPostWriteHook?.({
channel,
accountId: result.accountId,
run: async ({ cfg, runtime }) =>
await adapter.afterConfigWritten?.({
previousCfg,
cfg,
accountId: result.accountId!,
runtime,
}),
});
}
}
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) => {
const adapter = getVisibleSetupFlowAdapter(channel);
if (!adapter) {
await prompter.note(`${channel} does not support guided setup yet.`, "Channel setup");
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(`${label} does not support deleting config entries.`, "Remove channel");
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: `Delete ${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) => {
const { catalogById, installedCatalogById } = getChannelEntries();
const catalogEntry = catalogById.get(channel);
const installedCatalogEntry = installedCatalogById.get(channel);
if (catalogEntry) {
const workspaceDir = resolveWorkspaceDir();
const result = await ensureChannelSetupPluginInstalled({
cfg: next,
entry: catalogEntry,
prompter,
runtime,
workspaceDir,
});
next = result.cfg;
if (!result.installed) {
return;
}
await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
await refreshStatus(channel);
} else if (installedCatalogEntry) {
const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId);
if (!plugin) {
await prompter.note(`${channel} plugin not available.`, "Channel setup");
return;
}
await refreshStatus(channel);
} else {
const enabled = await enableBundledPluginForSetup(channel);
if (!enabled) {
return;
}
}
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;
}
return;
}
if (configured) {
await handleConfiguredChannel(channel, label);
return;
}
await configureChannel(channel);
};
if (options?.quickstartDefaults) {
const { entries } = getChannelEntries();
const choice = (await prompter.select({
message: "Select channel (QuickStart)",
options: [
...resolveChannelSetupSelectionContributions({
entries,
statusByChannel,
resolveDisabledHint,
}).map((contribution) => contribution.option),
{
value: "__skip__",
label: "Skip for now",
hint: `You can add channels later via \`${formatCliCommand("openclaw channels add")}\``,
},
],
initialValue: quickstartDefault,
})) as ChannelChoice | "__skip__";
if (choice !== "__skip__") {
await handleChannelChoice(choice);
}
} else {
const doneValue = "__done__" as const;
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
while (true) {
const { entries } = getChannelEntries();
const choice = (await prompter.select({
message: "Select a channel",
options: [
...resolveChannelSetupSelectionContributions({
entries,
statusByChannel,
resolveDisabledHint,
}).map((contribution) => contribution.option),
{
value: doneValue,
label: "Finished",
hint: selection.length > 0 ? "Done" : "Skip for now",
},
],
initialValue,
})) as ChannelChoice | typeof doneValue;
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"), "Selected channels");
}
if (!options?.skipDmPolicyPrompt) {
next = await maybeConfigureDmPolicies({
cfg: next,
selection,
prompter,
accountIdsByChannel,
resolveAdapter: getVisibleSetupFlowAdapter,
});
}
return next;
}