import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js"; import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy } from "../config/types.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import type { ChannelChoice } from "./onboard-types.js"; import { getChannelOnboardingAdapter, listChannelOnboardingAdapters, } from "./onboarding/registry.js"; import { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; import type { ChannelOnboardingDmPolicy, ChannelOnboardingStatus, SetupChannelsOptions, } from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; statusByChannel: Map; statusLines: string[]; }; function formatAccountLabel(accountId: string): string { return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId; } async function promptConfiguredAction(params: { prompter: WizardPrompter; label: string; supportsDisable: boolean; supportsDelete: boolean; }): Promise { const { prompter, label, supportsDisable, supportsDelete } = params; const updateOption: WizardSelectOption = { value: "update", label: "Modify settings", }; const disableOption: WizardSelectOption = { value: "disable", label: "Disable (keeps config)", }; const deleteOption: WizardSelectOption = { value: "delete", label: "Delete config", }; const skipOption: WizardSelectOption = { value: "skip", label: "Skip (leave as-is)", }; const options: Array> = [ updateOption, ...(supportsDisable ? [disableOption] : []), ...(supportsDelete ? [deleteOption] : []), skipOption, ]; return (await prompter.select({ message: `${label} already configured. What do you want to do?`, options, initialValue: "update", })) as ConfiguredChannelAction; } async function promptRemovalAccountId(params: { cfg: ClawdbotConfig; prompter: WizardPrompter; label: string; channel: ChannelChoice; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = getChannelPlugin(channel); if (!plugin) return DEFAULT_ACCOUNT_ID; const accountIds = plugin.config.listAccountIds(cfg).filter(Boolean); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds }); if (accountIds.length <= 1) return defaultAccountId; const selected = (await prompter.select({ message: `${label} account`, options: accountIds.map((accountId) => ({ value: accountId, label: formatAccountLabel(accountId), })), initialValue: defaultAccountId, })) as string; return normalizeAccountId(selected) ?? defaultAccountId; } async function collectChannelStatus(params: { cfg: ClawdbotConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; }): Promise { const installedPlugins = listChannelPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const catalogEntries = listChannelPluginCatalogEntries().filter( (entry) => !installedIds.has(entry.id), ); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ cfg: params.cfg, options: params.options, accountOverrides: params.accountOverrides, }), ), ); const catalogStatuses = catalogEntries.map((entry) => ({ channel: entry.id, configured: false, statusLines: [`${entry.meta.label}: install plugin to enable`], selectionHint: "plugin · install", quickstartScore: 0, })); const combinedStatuses = [...statusEntries, ...catalogStatuses]; const statusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { installedPlugins, catalogEntries, statusByChannel, statusLines, }; } export async function noteChannelStatus(params: { cfg: ClawdbotConfig; prompter: WizardPrompter; options?: SetupChannelsOptions; accountOverrides?: Partial>; }): Promise { const { statusLines } = await collectChannelStatus({ cfg: params.cfg, options: params.options, accountOverrides: params.accountOverrides ?? {}, }); if (statusLines.length > 0) { await params.prompter.note(statusLines.join("\n"), "Channel status"); } } async function noteChannelPrimer( prompter: WizardPrompter, channels: Array<{ id: ChannelChoice; blurb: string; label: string }>, ): Promise { const channelLines = channels.map((channel) => formatChannelPrimerLine({ id: channel.id, label: channel.label, selectionLabel: channel.label, docsPath: "/", blurb: channel.blurb, }), ); await prompter.note( [ "DM security: default is pairing; unknown DMs get a pairing code.", "Approve with: clawdbot pairing approve ", 'Public DMs require dmPolicy="open" + allowFrom=["*"].', `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, "", ...channelLines, ].join("\n"), "How channels work", ); } function resolveQuickstartDefault( statusByChannel: Map, ): ChannelChoice | undefined { let best: { channel: ChannelChoice; score: number } | null = null; for (const [channel, status] of statusByChannel) { if (status.quickstartScore == null) continue; if (!best || status.quickstartScore > best.score) { best = { channel, score: status.quickstartScore }; } } return best?.channel; } async function maybeConfigureDmPolicies(params: { cfg: ClawdbotConfig; selection: ChannelChoice[]; prompter: WizardPrompter; }): Promise { const { selection, prompter } = params; const dmPolicies = selection .map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; if (dmPolicies.length === 0) return params.cfg; const wants = await prompter.confirm({ message: "Configure DM access policies now? (default: pairing)", initialValue: false, }); if (!wants) return params.cfg; let cfg = params.cfg; const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => { await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", `Approve: clawdbot pairing approve ${policy.channel} `, `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, ].join("\n"), `${policy.label} DM access`, ); return (await prompter.select({ message: `${policy.label} DM policy`, options: [ { value: "pairing", label: "Pairing (recommended)" }, { value: "open", label: "Open (public inbound DMs)" }, { value: "disabled", label: "Disabled (ignore DMs)" }, ], })) as DmPolicy; }; for (const policy of dmPolicies) { const current = policy.getCurrent(cfg); const nextPolicy = await selectPolicy(policy); if (nextPolicy !== current) { cfg = policy.setPolicy(cfg, nextPolicy); } } return cfg; } // Channel-specific prompts moved into onboarding adapters. export async function setupChannels( cfg: ClawdbotConfig, runtime: RuntimeEnv, prompter: WizardPrompter, options?: SetupChannelsOptions, ): Promise { let next = cfg; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, }; if (options?.whatsappAccountId?.trim()) { accountOverrides.whatsapp = options.whatsappAccountId.trim(); } const { installedPlugins, catalogEntries, statusByChannel, statusLines } = await collectChannelStatus({ cfg: next, options, accountOverrides }); 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 primerChannels = [ ...installedPlugins.map((plugin) => ({ id: plugin.id as ChannelChoice, label: plugin.meta.label, blurb: plugin.meta.blurb, })), ...catalogEntries.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 recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); const adapter = getChannelOnboardingAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); }; const selection: ChannelChoice[] = []; const addSelection = (channel: ChannelChoice) => { if (!selection.includes(channel)) selection.push(channel); }; const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { const plugin = getChannelPlugin(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; } else if ( typeof (next.channels as Record | undefined)?.[channel] ?.enabled === "boolean" ) { enabled = (next.channels as Record)[channel]?.enabled; } return enabled === false ? "disabled" : undefined; }; const buildSelectionOptions = ( entries: Array<{ id: ChannelChoice; meta: { id: string; label: string; selectionLabel?: string }; }>, ) => entries.map((entry) => { const status = statusByChannel.get(entry.id); const disabledHint = resolveDisabledHint(entry.id); const hint = [status?.selectionHint, disabledHint].filter(Boolean).join(" · ") || undefined; return { value: entry.meta.id, label: entry.meta.selectionLabel ?? entry.meta.label, ...(hint ? { hint } : {}), }; }); const getChannelEntries = () => { const installed = listChannelPlugins(); const installedIds = new Set(installed.map((plugin) => plugin.id)); const catalog = listChannelPluginCatalogEntries().filter( (entry) => !installedIds.has(entry.id), ); const entries = [ ...installed.map((plugin) => ({ id: plugin.id as ChannelChoice, meta: plugin.meta, })), ...catalog.map((entry) => ({ id: entry.id as ChannelChoice, meta: entry.meta, })), ]; return { entries, catalog, catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])), }; }; const refreshStatus = async (channel: ChannelChoice) => { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) return; const status = await adapter.getStatus({ cfg: next, options, accountOverrides }); statusByChannel.set(channel, status); }; const configureChannel = async (channel: ChannelChoice) => { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) { await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup"); return; } const result = await adapter.configure({ cfg: next, runtime, prompter, options, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); next = result.cfg; if (result.accountId) { recordAccount(channel, result.accountId); } addSelection(channel); await refreshStatus(channel); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getChannelPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); 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, }) : 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 } = getChannelEntries(); const catalogEntry = catalogById.get(channel); if (catalogEntry) { const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); const result = await ensureOnboardingPluginInstalled({ cfg: next, entry: catalogEntry, prompter, runtime, workspaceDir, }); next = result.cfg; if (!result.installed) return; reloadOnboardingPluginRegistry({ cfg: next, runtime, workspaceDir, }); await refreshStatus(channel); } const plugin = getChannelPlugin(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; 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: [ ...buildSelectionOptions(entries), { value: "__skip__", label: "Skip for now", hint: "You can add channels later via `clawdbot 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: [ ...buildSelectionOptions(entries), { 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 selectionNotes = new Map(); for (const plugin of listChannelPlugins()) { selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink)); } for (const entry of listChannelPluginCatalogEntries()) { selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink)); } const selectedLines = selection .map((channel) => selectionNotes.get(channel)) .filter((line): line is string => Boolean(line)); if (selectedLines.length > 0) { await prompter.note(selectedLines.join("\n"), "Selected channels"); } if (!options?.skipDmPolicyPrompt) { next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter }); } return next; }