Files
clawdbot/src/commands/onboard-channels.ts
2026-01-16 03:30:56 +00:00

551 lines
18 KiB
TypeScript

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<typeof listChannelPlugins>;
catalogEntries: ReturnType<typeof listChannelPluginCatalogEntries>;
statusByChannel: Map<ChannelChoice, ChannelOnboardingStatus>;
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<ConfiguredChannelAction> {
const { prompter, label, supportsDisable, supportsDelete } = params;
const updateOption: WizardSelectOption<ConfiguredChannelAction> = {
value: "update",
label: "Modify settings",
};
const disableOption: WizardSelectOption<ConfiguredChannelAction> = {
value: "disable",
label: "Disable (keeps config)",
};
const deleteOption: WizardSelectOption<ConfiguredChannelAction> = {
value: "delete",
label: "Delete config",
};
const skipOption: WizardSelectOption<ConfiguredChannelAction> = {
value: "skip",
label: "Skip (leave as-is)",
};
const options: Array<WizardSelectOption<ConfiguredChannelAction>> = [
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<string> {
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<Record<ChannelChoice, string>>;
}): Promise<ChannelStatusSummary> {
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<Record<ChannelChoice, string>>;
}): Promise<void> {
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<void> {
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 <channel> <code>",
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
"",
...channelLines,
].join("\n"),
"How channels work",
);
}
function resolveQuickstartDefault(
statusByChannel: Map<ChannelChoice, { quickstartScore?: number }>,
): 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<ClawdbotConfig> {
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} <code>`,
`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<ClawdbotConfig> {
let next = cfg;
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...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<string, { enabled?: boolean }> | undefined)?.[channel]
?.enabled === "boolean"
) {
enabled = (next.channels as Record<string, { enabled?: boolean }>)[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<string, string>();
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;
}