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 } 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, SetupChannelsOptions } from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; 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 options = [ { value: "update", label: "Modify settings" }, ...(supportsDisable ? [{ value: "disable", label: "Disable (keeps config)" }] : []), ...(supportsDelete ? [{ value: "delete", label: "Delete config" }] : []), { value: "skip", label: "Skip (leave as-is)" }, ] as const; 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 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 = listChannelPlugins(); const catalogEntries = listChannelPluginCatalogEntries().filter( (entry) => !installedPlugins.some((plugin) => plugin.id === entry.id), ); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ cfg, options, 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); if (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 selectionOptions = [ ...installedPlugins.map((plugin) => ({ id: plugin.id as ChannelChoice, meta: plugin.meta, })), ...catalogEntries.map((entry) => ({ id: entry.id as ChannelChoice, meta: entry.meta, })), ].map((entry) => { const meta = entry.meta; const status = statusByChannel.get(entry.id); return { value: meta.id, label: meta.selectionLabel ?? meta.label, ...(status?.selectionHint ? { hint: status.selectionHint } : {}), }; }); const quickstartDefault = options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel); let selection: ChannelChoice[]; if (options?.quickstartDefaults) { const choice = (await prompter.select({ message: "Select channel (QuickStart)", options: [ ...selectionOptions, { value: "__skip__", label: "Skip for now", hint: "You can add channels later via `clawdbot channels add`", }, ], initialValue: quickstartDefault, })) as ChannelChoice | "__skip__"; selection = choice === "__skip__" ? [] : [choice]; } else { const initialSelection = options?.initialSelection ?? []; const selectionSet = new Set(initialSelection); const doneValue = "__done__" as const; const buildOptions = () => [ ...selectionOptions.map((opt) => ({ value: opt.value, label: `${selectionSet.has(opt.value as ChannelChoice) ? "[x]" : "[ ]"} ${opt.label}`, ...(opt.hint ? { hint: opt.hint } : {}), })), { value: doneValue, label: "Finished", hint: selectionSet.size > 0 ? "Continue with selected channels" : "Skip channels for now", }, ]; while (true) { const choice = (await prompter.select({ message: "Select channels (Enter to toggle, choose Finished to continue)", options: buildOptions(), })) as ChannelChoice | typeof doneValue; if (choice === doneValue) break; if (selectionSet.has(choice)) { selectionSet.delete(choice); } else { selectionSet.add(choice); } } selection = Array.from(selectionSet); } const catalogById = new Map(catalogEntries.map((entry) => [entry.id as ChannelChoice, entry])); if (selection.some((channel) => catalogById.has(channel))) { const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); for (const channel of selection) { const entry = catalogById.get(channel); if (!entry) continue; const result = await ensureOnboardingPluginInstalled({ cfg: next, entry, prompter, runtime, workspaceDir, }); next = result.cfg; if (!result.installed) { selection = selection.filter((id) => id !== channel); continue; } reloadOnboardingPluginRegistry({ cfg: next, runtime, workspaceDir, }); } } const updatedSelection: ChannelChoice[] = []; for (const channel of selection) { const status = statusByChannel.get(channel); if (!status?.configured) { updatedSelection.push(channel); continue; } const plugin = getChannelPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); const label = plugin?.meta.label ?? channel; const supportsDisable = Boolean(plugin?.config.setAccountEnabled || adapter?.disable); const supportsDelete = Boolean(plugin?.config.deleteAccount); const action = await promptConfiguredAction({ prompter, label, supportsDisable, supportsDelete, }); if (action === "skip") { continue; } if (action === "update") { updatedSelection.push(channel); continue; } if (action === "delete" && !supportsDelete) { await prompter.note(`${label} does not support deleting config entries.`, "Remove channel"); continue; } 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) { continue; } if (plugin?.config.deleteAccount) { next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId }); } continue; } if (plugin?.config.setAccountEnabled) { next = plugin.config.setAccountEnabled({ cfg: next, accountId: resolvedAccountId, enabled: false, }); } else if (adapter?.disable) { next = adapter.disable(next); } } selection = updatedSelection; options?.onSelection?.(selection); const selectionNotes = new Map(); for (const plugin of installedPlugins) { selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink)); } for (const entry of catalogEntries) { 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"); } const shouldPromptAccountIds = options?.promptAccountIds === true; const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); const adapter = getChannelOnboardingAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); }; for (const channel of selection) { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) continue; 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); } } if (!options?.skipDmPolicyPrompt) { next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter }); } if (options?.allowDisable) { for (const [channelId, status] of statusByChannel) { if (selection.includes(channelId)) continue; if (!status.configured) continue; const adapter = getChannelOnboardingAdapter(channelId); if (!adapter?.disable) continue; const meta = getChannelPlugin(channelId)?.meta; const disable = await prompter.confirm({ message: `Disable ${meta?.label ?? channelId} channel?`, initialValue: false, }); if (disable) { next = adapter.disable(next); } } } return next; }