Files
clawdbot/src/commands/onboard-providers.ts
2026-01-08 01:55:59 +01:00

1489 lines
44 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../discord/accounts.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "../imessage/accounts.js";
import { loginWeb } from "../provider-web.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../signal/accounts.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../slack/accounts.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../telegram/accounts.js";
import { formatTerminalLink, normalizeE164 } from "../utils.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "../web/accounts.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js";
import { installSignalCli } from "./signal-install.js";
const DOCS_BASE = "https://docs.clawd.bot";
function docsLink(path: string, label?: string): string {
const cleanPath = path.startsWith("/") ? path : `/${path}`;
const url = `${DOCS_BASE}${cleanPath}`;
return formatTerminalLink(label ?? url, url, { fallback: url });
}
async function promptAccountId(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
label: string;
currentId?: string;
listAccountIds: (cfg: ClawdbotConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const existingIds = params.listAccountIds(params.cfg);
const initial =
params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
const choice = (await params.prompter.select({
message: `${params.label} account`,
options: [
...existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
{ value: "__new__", label: "Add a new account" },
],
initialValue: initial,
})) as string;
if (choice !== "__new__") return normalizeAccountId(choice);
const entered = await params.prompter.text({
message: `New ${params.label} account id`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const normalized = normalizeAccountId(String(entered));
if (String(entered).trim() !== normalized) {
await params.prompter.note(
`Normalized account id to "${normalized}".`,
`${params.label} account`,
);
}
return normalized;
}
function addWildcardAllowFrom(
allowFrom?: Array<string | number> | null,
): Array<string | number> {
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (!next.includes("*")) next.push("*");
return next;
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function detectWhatsAppLinked(
cfg: ClawdbotConfig,
accountId: string,
): Promise<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"DM security: default is pairing; unknown DMs get a pairing code.",
"Approve with: clawdbot pairing approve --provider <provider> <code>",
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
`Docs: ${docsLink("/start/pairing", "start/pairing")}`,
"",
"Telegram: simplest way to get started — register a bot with @BotFather and get going.",
"WhatsApp: works with your own number; recommend a separate phone + eSIM.",
"Discord: very well supported right now.",
"Slack: supported (Socket Mode).",
'Signal: signal-cli linked device; more setup (David Reagans: "Hop on Discord.").',
"iMessage: this is still a work in progress.",
].join("\n"),
"How providers work",
);
}
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Open Telegram and chat with @BotFather",
"2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
`Docs: ${docsLink("/telegram", "telegram")}`,
].join("\n"),
"Telegram bot token",
);
}
async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Discord Developer Portal → Applications → New Application",
"2) Bot → Add Bot → Reset Token → copy token",
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
"Tip: enable Message Content Intent if you need message text.",
`Docs: ${docsLink("/discord", "discord")}`,
].join("\n"),
"Discord bot token",
);
}
function buildSlackManifest(botName: string) {
const safeName = botName.trim() || "Clawdbot";
const manifest = {
display_information: {
name: safeName,
description: `${safeName} connector for Clawdbot`,
},
features: {
bot_user: {
display_name: safeName,
always_online: false,
},
app_home: {
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
slash_commands: [
{
command: "/clawd",
description: "Send a message to Clawdbot",
should_escape: false,
},
],
},
oauth_config: {
scopes: {
bot: [
"chat:write",
"channels:history",
"channels:read",
"groups:history",
"im:history",
"mpim:history",
"users:read",
"app_mentions:read",
"reactions:read",
"reactions:write",
"pins:read",
"pins:write",
"emoji:read",
"commands",
"files:read",
"files:write",
],
},
},
settings: {
socket_mode_enabled: true,
event_subscriptions: {
bot_events: [
"app_mention",
"message.channels",
"message.groups",
"message.im",
"message.mpim",
"reaction_added",
"reaction_removed",
"member_joined_channel",
"member_left_channel",
"channel_rename",
"pin_added",
"pin_removed",
],
},
},
};
return JSON.stringify(manifest, null, 2);
}
async function noteSlackTokenHelp(
prompter: WizardPrompter,
botName: string,
): Promise<void> {
const manifest = buildSlackManifest(botName);
await prompter.note(
[
"1) Slack API → Create App → From scratch",
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
"3) OAuth & Permissions → install app to workspace (xoxb- bot token)",
"4) Enable Event Subscriptions (socket) for message events",
"5) App Home → enable the Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${docsLink("/slack", "slack")}`,
"",
"Manifest (JSON):",
manifest,
].join("\n"),
"Slack socket mode tokens",
);
}
function setWhatsAppDmPolicy(
cfg: ClawdbotConfig,
dmPolicy?: DmPolicy,
): ClawdbotConfig {
return {
...cfg,
whatsapp: {
...cfg.whatsapp,
dmPolicy,
},
};
}
function setWhatsAppAllowFrom(
cfg: ClawdbotConfig,
allowFrom?: string[],
): ClawdbotConfig {
return {
...cfg,
whatsapp: {
...cfg.whatsapp,
allowFrom,
},
};
}
function setMessagesResponsePrefix(
cfg: ClawdbotConfig,
responsePrefix?: string,
): ClawdbotConfig {
return {
...cfg,
messages: {
...cfg.messages,
responsePrefix,
},
};
}
function setWhatsAppSelfChatMode(
cfg: ClawdbotConfig,
selfChatMode?: boolean,
): ClawdbotConfig {
return {
...cfg,
whatsapp: {
...cfg.whatsapp,
selfChatMode,
},
};
}
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.telegram?.allowFrom)
: undefined;
return {
...cfg,
telegram: {
...cfg.telegram,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
};
}
function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom)
: undefined;
return {
...cfg,
discord: {
...cfg.discord,
dm: {
...cfg.discord?.dm,
enabled: cfg.discord?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom)
: undefined;
return {
...cfg,
slack: {
...cfg.slack,
dm: {
...cfg.slack?.dm,
enabled: cfg.slack?.dm?.enabled ?? true,
policy: dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.signal?.allowFrom)
: undefined;
return {
...cfg,
signal: {
...cfg.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
};
}
function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.imessage?.allowFrom)
: undefined;
return {
...cfg,
imessage: {
...cfg.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
};
}
async function maybeConfigureDmPolicies(params: {
cfg: ClawdbotConfig;
selection: ProviderChoice[];
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const { selection, prompter } = params;
const supportsDmPolicy = selection.some((p) =>
["telegram", "discord", "slack", "signal", "imessage"].includes(p),
);
if (!supportsDmPolicy) 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 (params: {
label: string;
provider: ProviderChoice;
policyKey: string;
allowFromKey: string;
}) => {
await prompter.note(
[
"Default: pairing (unknown DMs get a pairing code).",
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
`Docs: ${docsLink("/start/pairing", "start/pairing")}`,
].join("\n"),
`${params.label} DM access`,
);
return (await prompter.select({
message: `${params.label} DM policy`,
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
],
})) as DmPolicy;
};
if (selection.includes("telegram")) {
const current = cfg.telegram?.dmPolicy ?? "pairing";
const policy = await selectPolicy({
label: "Telegram",
provider: "telegram",
policyKey: "telegram.dmPolicy",
allowFromKey: "telegram.allowFrom",
});
if (policy !== current) cfg = setTelegramDmPolicy(cfg, policy);
}
if (selection.includes("discord")) {
const current = cfg.discord?.dm?.policy ?? "pairing";
const policy = await selectPolicy({
label: "Discord",
provider: "discord",
policyKey: "discord.dm.policy",
allowFromKey: "discord.dm.allowFrom",
});
if (policy !== current) cfg = setDiscordDmPolicy(cfg, policy);
}
if (selection.includes("slack")) {
const current = cfg.slack?.dm?.policy ?? "pairing";
const policy = await selectPolicy({
label: "Slack",
provider: "slack",
policyKey: "slack.dm.policy",
allowFromKey: "slack.dm.allowFrom",
});
if (policy !== current) cfg = setSlackDmPolicy(cfg, policy);
}
if (selection.includes("signal")) {
const current = cfg.signal?.dmPolicy ?? "pairing";
const policy = await selectPolicy({
label: "Signal",
provider: "signal",
policyKey: "signal.dmPolicy",
allowFromKey: "signal.allowFrom",
});
if (policy !== current) cfg = setSignalDmPolicy(cfg, policy);
}
if (selection.includes("imessage")) {
const current = cfg.imessage?.dmPolicy ?? "pairing";
const policy = await selectPolicy({
label: "iMessage",
provider: "imessage",
policyKey: "imessage.dmPolicy",
allowFromKey: "imessage.allowFrom",
});
if (policy !== current) cfg = setIMessageDmPolicy(cfg, policy);
}
return cfg;
}
async function promptWhatsAppAllowFrom(
cfg: ClawdbotConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<ClawdbotConfig> {
const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const existingResponsePrefix = cfg.messages?.responsePrefix;
await prompter.note(
[
"WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.",
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
"- disabled: ignore WhatsApp DMs",
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp DM access",
);
const phoneMode = (await prompter.select({
message: "WhatsApp phone setup",
options: [
{ value: "personal", label: "This is my personal phone number" },
{ value: "separate", label: "Separate phone just for Clawdbot" },
],
})) as "personal" | "separate";
if (phoneMode === "personal") {
const entry = await prompter.text({
message: "Your WhatsApp number (E.164)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const normalized = normalizeE164(raw);
if (!normalized) return `Invalid number: ${raw}`;
return undefined;
},
});
const normalized = normalizeE164(String(entry).trim());
const merged = [
...existingAllowFrom
.filter((item) => item !== "*")
.map((item) => normalizeE164(item))
.filter(Boolean),
normalized,
];
const unique = [...new Set(merged.filter(Boolean))];
let next = setWhatsAppSelfChatMode(cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, unique);
if (existingResponsePrefix === undefined) {
next = setMessagesResponsePrefix(next, "[clawdbot]");
}
await prompter.note(
[
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
`- allowFrom includes ${normalized}`,
existingResponsePrefix === undefined
? "- responsePrefix set to [clawdbot]"
: "- responsePrefix left unchanged",
].join("\n"),
"WhatsApp personal phone",
);
return next;
}
const policy = (await prompter.select({
message: "WhatsApp DM policy",
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist only (block unknown senders)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore WhatsApp DMs)" },
],
})) as DmPolicy;
let next = setWhatsAppSelfChatMode(cfg, false);
next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
next = setWhatsAppAllowFrom(next, ["*"]);
}
if (policy === "disabled") return next;
const options =
existingAllowFrom.length > 0
? ([
{ value: "keep", label: "Keep current allowFrom" },
{
value: "unset",
label: "Unset allowFrom (use pairing approvals only)",
},
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const)
: ([
{ value: "unset", label: "Unset allowFrom (default)" },
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const);
const mode = (await prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)",
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
})) as (typeof options)[number]["value"];
if (mode === "keep") {
// Keep allowFrom as-is.
} else if (mode === "unset") {
next = setWhatsAppAllowFrom(next, undefined);
} else {
const allowRaw = await prompter.text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = raw
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) return "Required";
for (const part of parts) {
if (part === "*") continue;
const normalized = normalizeE164(part);
if (!normalized) return `Invalid number: ${part}`;
}
return undefined;
},
});
const parts = String(allowRaw)
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
const normalized = parts.map((part) =>
part === "*" ? "*" : normalizeE164(part),
);
const unique = [...new Set(normalized.filter(Boolean))];
next = setWhatsAppAllowFrom(next, unique);
}
return next;
}
type SetupProvidersOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ProviderChoice[]) => void;
accountIds?: Partial<Record<ProviderChoice, string>>;
onAccountId?: (provider: ProviderChoice, accountId: string) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void;
};
export async function setupProviders(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: SetupProvidersOptions,
): Promise<ClawdbotConfig> {
let whatsappAccountId =
options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId);
const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) =>
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
);
const discordConfigured = listDiscordAccountIds(cfg).some((accountId) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
);
const slackConfigured = listSlackAccountIds(cfg).some((accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
return Boolean(account.botToken && account.appToken);
});
const signalConfigured = listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
);
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
const imessageConfigured = listIMessageAccountIds(cfg).some((accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
return Boolean(
account.config.cliPath ||
account.config.dbPath ||
account.config.allowFrom ||
account.config.service ||
account.config.region,
);
});
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath);
const waAccountLabel =
whatsappAccountId === DEFAULT_ACCOUNT_ID ? "default" : whatsappAccountId;
await prompter.note(
[
`Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
`WhatsApp (${waAccountLabel}): ${whatsappLinked ? "linked" : "not linked"}`,
`Discord: ${discordConfigured ? "configured" : "needs token"}`,
`Slack: ${slackConfigured ? "configured" : "needs tokens"}`,
`Signal: ${signalConfigured ? "configured" : "needs setup"}`,
`iMessage: ${imessageConfigured ? "configured" : "needs setup"}`,
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
`imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`,
].join("\n"),
"Provider status",
);
const shouldConfigure = await prompter.confirm({
message: "Configure chat providers now?",
initialValue: true,
});
if (!shouldConfigure) return cfg;
await noteProviderPrimer(prompter);
const selection = (await prompter.multiselect({
message: "Select providers",
options: [
{
value: "telegram",
label: "Telegram (Bot API)",
hint: telegramConfigured
? "recommended · configured"
: "recommended · newcomer-friendly",
},
{
value: "whatsapp",
label: "WhatsApp (QR link)",
hint: whatsappLinked ? "linked" : "not linked",
},
{
value: "discord",
label: "Discord (Bot API)",
hint: discordConfigured ? "configured" : "needs token",
},
{
value: "slack",
label: "Slack (Socket Mode)",
hint: slackConfigured ? "configured" : "needs tokens",
},
{
value: "signal",
label: "Signal (signal-cli)",
hint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
},
{
value: "imessage",
label: "iMessage (imsg)",
hint: imessageCliDetected ? "imsg found" : "imsg missing",
},
],
})) as ProviderChoice[];
options?.onSelection?.(selection);
const accountOverrides: Partial<Record<ProviderChoice, string>> = {
...options?.accountIds,
};
if (options?.whatsappAccountId?.trim()) {
accountOverrides.whatsapp = options.whatsappAccountId.trim();
}
const recordAccount = (provider: ProviderChoice, accountId: string) => {
options?.onAccountId?.(provider, accountId);
if (provider === "whatsapp") {
options?.onWhatsAppAccountId?.(accountId);
}
};
const selectionNotes: Record<ProviderChoice, string> = {
telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "telegram")}`,
whatsapp: `WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: ${docsLink("/whatsapp", "whatsapp")}`,
discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`,
slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`,
signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`,
imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`,
};
const selectedLines = selection
.map((provider) => selectionNotes[provider])
.filter(Boolean);
if (selectedLines.length > 0) {
await prompter.note(selectedLines.join("\n"), "Selected providers");
}
const shouldPromptAccountIds = options?.promptAccountIds === true;
let next = cfg;
if (selection.includes("whatsapp")) {
const overrideId = accountOverrides.whatsapp?.trim();
if (overrideId) {
whatsappAccountId = normalizeAccountId(overrideId);
} else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
whatsappAccountId = await promptAccountId({
cfg: next,
prompter,
label: "WhatsApp",
currentId: whatsappAccountId,
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(next),
});
}
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[whatsappAccountId]: {
...next.whatsapp?.accounts?.[whatsappAccountId],
enabled:
next.whatsapp?.accounts?.[whatsappAccountId]?.enabled ?? true,
},
},
},
};
}
recordAccount("whatsapp", whatsappAccountId);
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
const { authDir } = resolveWhatsAppAuthDir({
cfg: next,
accountId: whatsappAccountId,
});
if (!whatsappLinked) {
await prompter.note(
[
"Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`,
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp linking",
);
}
const wantsLink = await prompter.confirm({
message: whatsappLinked
? "WhatsApp already linked. Re-link now?"
: "Link WhatsApp now (QR)?",
initialValue: !whatsappLinked,
});
if (wantsLink) {
try {
await loginWeb(false, "web", undefined, runtime, whatsappAccountId);
} catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note(
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
"WhatsApp help",
);
}
} else if (!whatsappLinked) {
await prompter.note(
"Run `clawdbot login` later to link WhatsApp.",
"WhatsApp",
);
}
next = await promptWhatsAppAllowFrom(next, runtime, prompter);
}
if (selection.includes("telegram")) {
const telegramOverride = accountOverrides.telegram?.trim();
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(next);
let telegramAccountId = telegramOverride
? normalizeAccountId(telegramOverride)
: defaultTelegramAccountId;
if (shouldPromptAccountIds && !telegramOverride) {
telegramAccountId = await promptAccountId({
cfg: next,
prompter,
label: "Telegram",
currentId: telegramAccountId,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
}
recordAccount("telegram", telegramAccountId);
const resolvedAccount = resolveTelegramAccount({
cfg: next,
accountId: telegramAccountId,
});
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && telegramEnv;
const hasConfigToken = Boolean(
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
);
let token: string | null = null;
if (!accountConfigured) {
await noteTelegramTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botToken) {
const keepEnv = await prompter.confirm({
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
},
};
} else {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Telegram token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Telegram bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
botToken: token,
},
};
} else {
next = {
...next,
telegram: {
...next.telegram,
enabled: true,
accounts: {
...next.telegram?.accounts,
[telegramAccountId]: {
...next.telegram?.accounts?.[telegramAccountId],
enabled:
next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true,
botToken: token,
},
},
},
};
}
}
}
if (selection.includes("discord")) {
const discordOverride = accountOverrides.discord?.trim();
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(next);
let discordAccountId = discordOverride
? normalizeAccountId(discordOverride)
: defaultDiscordAccountId;
if (shouldPromptAccountIds && !discordOverride) {
discordAccountId = await promptAccountId({
cfg: next,
prompter,
label: "Discord",
currentId: discordAccountId,
listAccountIds: listDiscordAccountIds,
defaultAccountId: defaultDiscordAccountId,
});
}
recordAccount("discord", discordAccountId);
const resolvedAccount = resolveDiscordAccount({
cfg: next,
accountId: discordAccountId,
});
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && discordEnv;
const hasConfigToken = Boolean(resolvedAccount.config.token);
let token: string | null = null;
if (!accountConfigured) {
await noteDiscordTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.token) {
const keepEnv = await prompter.confirm({
message: "DISCORD_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
discord: {
...next.discord,
enabled: true,
},
};
} else {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Discord token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Discord bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
discord: {
...next.discord,
enabled: true,
token,
},
};
} else {
next = {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[discordAccountId]: {
...next.discord?.accounts?.[discordAccountId],
enabled:
next.discord?.accounts?.[discordAccountId]?.enabled ?? true,
token,
},
},
},
};
}
}
}
if (selection.includes("slack")) {
const slackOverride = accountOverrides.slack?.trim();
const defaultSlackAccountId = resolveDefaultSlackAccountId(next);
let slackAccountId = slackOverride
? normalizeAccountId(slackOverride)
: defaultSlackAccountId;
if (shouldPromptAccountIds && !slackOverride) {
slackAccountId = await promptAccountId({
cfg: next,
prompter,
label: "Slack",
currentId: slackAccountId,
listAccountIds: listSlackAccountIds,
defaultAccountId: defaultSlackAccountId,
});
}
recordAccount("slack", slackAccountId);
const resolvedAccount = resolveSlackAccount({
cfg: next,
accountId: slackAccountId,
});
const accountConfigured = Boolean(
resolvedAccount.botToken && resolvedAccount.appToken,
);
const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && slackBotEnv && slackAppEnv;
const hasConfigTokens = Boolean(
resolvedAccount.config.botToken && resolvedAccount.config.appToken,
);
let botToken: string | null = null;
let appToken: string | null = null;
const slackBotName = String(
await prompter.text({
message: "Slack bot display name (used for manifest)",
initialValue: "Clawdbot",
}),
).trim();
if (!accountConfigured) {
await noteSlackTokenHelp(prompter, slackBotName);
}
if (
canUseEnv &&
(!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
) {
const keepEnv = await prompter.confirm({
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
slack: {
...next.slack,
enabled: true,
},
};
} else {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigTokens) {
const keep = await prompter.confirm({
message: "Slack tokens already configured. Keep them?",
initialValue: true,
});
if (!keep) {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
botToken = String(
await prompter.text({
message: "Enter Slack bot token (xoxb-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appToken = String(
await prompter.text({
message: "Enter Slack app token (xapp-...)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (botToken && appToken) {
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
slack: {
...next.slack,
enabled: true,
botToken,
appToken,
},
};
} else {
next = {
...next,
slack: {
...next.slack,
enabled: true,
accounts: {
...next.slack?.accounts,
[slackAccountId]: {
...next.slack?.accounts?.[slackAccountId],
enabled:
next.slack?.accounts?.[slackAccountId]?.enabled ?? true,
botToken,
appToken,
},
},
},
};
}
}
}
if (selection.includes("signal")) {
const signalOverride = accountOverrides.signal?.trim();
const defaultSignalAccountId = resolveDefaultSignalAccountId(next);
let signalAccountId = signalOverride
? normalizeAccountId(signalOverride)
: defaultSignalAccountId;
if (shouldPromptAccountIds && !signalOverride) {
signalAccountId = await promptAccountId({
cfg: next,
prompter,
label: "Signal",
currentId: signalAccountId,
listAccountIds: listSignalAccountIds,
defaultAccountId: defaultSignalAccountId,
});
}
recordAccount("signal", signalAccountId);
const resolvedAccount = resolveSignalAccount({
cfg: next,
accountId: signalAccountId,
});
const accountConfig = resolvedAccount.config;
let resolvedCliPath = accountConfig.cliPath ?? signalCliPath;
let cliDetected = await detectBinary(resolvedCliPath);
if (options?.allowSignalInstall) {
const wantsInstall = await prompter.confirm({
message: cliDetected
? "signal-cli detected. Reinstall/update now?"
: "signal-cli not found. Install now?",
initialValue: !cliDetected,
});
if (wantsInstall) {
try {
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
cliDetected = true;
resolvedCliPath = result.cliPath;
await prompter.note(
`Installed signal-cli at ${result.cliPath}`,
"Signal",
);
} else if (!result.ok) {
await prompter.note(
result.error ?? "signal-cli install failed.",
"Signal",
);
}
} catch (err) {
await prompter.note(
`signal-cli install failed: ${String(err)}`,
"Signal",
);
}
}
}
if (!cliDetected) {
await prompter.note(
"signal-cli not found. Install it, then rerun this step or set signal.cliPath.",
"Signal",
);
}
let account = accountConfig.account ?? "";
if (account) {
const keep = await prompter.confirm({
message: `Signal account set (${account}). Keep it?`,
initialValue: true,
});
if (!keep) account = "";
}
if (!account) {
account = String(
await prompter.text({
message: "Signal bot number (E.164)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (account) {
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
signal: {
...next.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
};
} else {
next = {
...next,
signal: {
...next.signal,
enabled: true,
accounts: {
...next.signal?.accounts,
[signalAccountId]: {
...next.signal?.accounts?.[signalAccountId],
enabled:
next.signal?.accounts?.[signalAccountId]?.enabled ?? true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
},
};
}
}
await prompter.note(
[
'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
`Docs: ${docsLink("/signal", "signal")}`,
].join("\n"),
"Signal next steps",
);
}
if (selection.includes("imessage")) {
const imessageOverride = accountOverrides.imessage?.trim();
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(next);
let imessageAccountId = imessageOverride
? normalizeAccountId(imessageOverride)
: defaultIMessageAccountId;
if (shouldPromptAccountIds && !imessageOverride) {
imessageAccountId = await promptAccountId({
cfg: next,
prompter,
label: "iMessage",
currentId: imessageAccountId,
listAccountIds: listIMessageAccountIds,
defaultAccountId: defaultIMessageAccountId,
});
}
recordAccount("imessage", imessageAccountId);
const resolvedAccount = resolveIMessageAccount({
cfg: next,
accountId: imessageAccountId,
});
let resolvedCliPath = resolvedAccount.config.cliPath ?? imessageCliPath;
const cliDetected = await detectBinary(resolvedCliPath);
if (!cliDetected) {
const entered = await prompter.text({
message: "imsg CLI path",
initialValue: resolvedCliPath,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
resolvedCliPath = String(entered).trim();
if (!resolvedCliPath) {
await prompter.note(
"imsg CLI path required to enable iMessage.",
"iMessage",
);
}
}
if (resolvedCliPath) {
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
imessage: {
...next.imessage,
enabled: true,
cliPath: resolvedCliPath,
},
};
} else {
next = {
...next,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[imessageAccountId]: {
...next.imessage?.accounts?.[imessageAccountId],
enabled:
next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true,
cliPath: resolvedCliPath,
},
},
},
};
}
}
await prompter.note(
[
"This is still a work in progress.",
"Ensure Clawdbot has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20",
`Docs: ${docsLink("/imessage", "imessage")}`,
].join("\n"),
"iMessage next steps",
);
}
next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter });
if (options?.allowDisable) {
if (!selection.includes("telegram") && telegramConfigured) {
const disable = await prompter.confirm({
message: "Disable Telegram provider?",
initialValue: false,
});
if (disable) {
next = {
...next,
telegram: { ...next.telegram, enabled: false },
};
}
}
if (!selection.includes("discord") && discordConfigured) {
const disable = await prompter.confirm({
message: "Disable Discord provider?",
initialValue: false,
});
if (disable) {
next = {
...next,
discord: { ...next.discord, enabled: false },
};
}
}
if (!selection.includes("slack") && slackConfigured) {
const disable = await prompter.confirm({
message: "Disable Slack provider?",
initialValue: false,
});
if (disable) {
next = {
...next,
slack: { ...next.slack, enabled: false },
};
}
}
if (!selection.includes("signal") && signalConfigured) {
const disable = await prompter.confirm({
message: "Disable Signal provider?",
initialValue: false,
});
if (disable) {
next = {
...next,
signal: { ...next.signal, enabled: false },
};
}
}
if (!selection.includes("imessage") && imessageConfigured) {
const disable = await prompter.confirm({
message: "Disable iMessage provider?",
initialValue: false,
});
if (disable) {
next = {
...next,
imessage: { ...next.imessage, enabled: false },
};
}
}
}
return next;
}