import path from "node:path"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeAllowFromEntries, normalizeE164, pathExists, splitSetupEntries, setSetupChannelEnabled, type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; const channel = "whatsapp" as const; function mergeWhatsAppConfig( cfg: OpenClawConfig, patch: Partial["whatsapp"]>>, options?: { unsetOnUndefined?: string[] }, ): OpenClawConfig { const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record; for (const [key, value] of Object.entries(patch)) { if (value === undefined) { if (options?.unsetOnUndefined?.includes(key)) { delete base[key]; } continue; } base[key] = value; } return { ...cfg, channels: { ...cfg.channels, whatsapp: base, }, }; } function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return mergeWhatsAppConfig(cfg, { dmPolicy }); } function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig { return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); } function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig { return mergeWhatsAppConfig(cfg, { selfChatMode }); } async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); const credsPath = path.join(authDir, "creds.json"); return await pathExists(credsPath); } async function promptWhatsAppOwnerAllowFrom(params: { existingAllowFrom: string[]; prompter: Parameters>[0]["prompter"]; }): Promise<{ normalized: string; allowFrom: string[] }> { const { prompter, existingAllowFrom } = params; await prompter.note( "We need the sender/owner number so OpenClaw can allowlist you.", "WhatsApp number", ); const entry = await prompter.text({ message: "Your personal WhatsApp number (the phone you will message from)", 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()); if (!normalized) { throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); } const allowFrom = normalizeAllowFromEntries( [...existingAllowFrom.filter((item) => item !== "*"), normalized], normalizeE164, ); return { normalized, allowFrom }; } async function applyWhatsAppOwnerAllowlist(params: { cfg: OpenClawConfig; existingAllowFrom: string[]; messageLines: string[]; prompter: Parameters>[0]["prompter"]; title: string; }): Promise { const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ prompter: params.prompter, existingAllowFrom: params.existingAllowFrom, }); let next = setWhatsAppSelfChatMode(params.cfg, true); next = setWhatsAppDmPolicy(next, "allowlist"); next = setWhatsAppAllowFrom(next, allowFrom); await params.prompter.note( [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), params.title, ); return next; } function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { const parts = splitSetupEntries(raw); if (parts.length === 0) { return { entries: [] }; } const entries: string[] = []; for (const part of parts) { if (part === "*") { entries.push("*"); continue; } const normalized = normalizeE164(part); if (!normalized) { return { entries: [], invalidEntry: part }; } entries.push(normalized); } return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; } async function promptWhatsAppDmAccess(params: { cfg: OpenClawConfig; forceAllowFrom: boolean; prompter: Parameters>[0]["prompter"]; }): Promise { const existingPolicy = params.cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; const existingAllowFrom = params.cfg.channels?.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; if (params.forceAllowFrom) { return await applyWhatsAppOwnerAllowlist({ cfg: params.cfg, prompter: params.prompter, existingAllowFrom, title: "WhatsApp allowlist", messageLines: ["Allowlist mode enabled."], }); } await params.prompter.note( [ "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.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: ${formatDocsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp DM access", ); const phoneMode = await params.prompter.select({ message: "WhatsApp phone setup", options: [ { value: "personal", label: "This is my personal phone number" }, { value: "separate", label: "Separate phone just for OpenClaw" }, ], }); if (phoneMode === "personal") { return await applyWhatsAppOwnerAllowlist({ cfg: params.cfg, prompter: params.prompter, existingAllowFrom, title: "WhatsApp personal phone", messageLines: [ "Personal phone mode enabled.", "- dmPolicy set to allowlist (pairing skipped)", ], }); } const policy = (await params.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(params.cfg, false); next = setWhatsAppDmPolicy(next, policy); if (policy === "open") { const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]); return next; } if (policy === "disabled") { return next; } const allowOptions = 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 params.prompter.select({ message: "WhatsApp allowFrom (optional pre-allowlist)", options: allowOptions.map((opt) => ({ value: opt.value, label: opt.label, })), }); if (mode === "keep") { return next; } if (mode === "unset") { return setWhatsAppAllowFrom(next, undefined); } const allowRaw = await params.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 parsed = parseWhatsAppAllowFromEntries(raw); if (parsed.entries.length === 0 && !parsed.invalidEntry) { return "Required"; } if (parsed.invalidEntry) { return `Invalid number: ${parsed.invalidEntry}`; } return undefined; }, }); const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); return setWhatsAppAllowFrom(next, parsed.entries); } export const whatsappSetupWizard: ChannelSetupWizard = { channel, status: { configuredLabel: "linked", unconfiguredLabel: "not linked", configuredHint: "linked", unconfiguredHint: "not linked", configuredScore: 5, unconfiguredScore: 4, resolveConfigured: async ({ cfg }) => { for (const accountId of listWhatsAppAccountIds(cfg)) { if (await detectWhatsAppLinked(cfg, accountId)) { return true; } } return false; }, resolveStatusLines: async ({ cfg, configured }) => { const linkedAccountId = ( await Promise.all( listWhatsAppAccountIds(cfg).map(async (accountId) => ({ accountId, linked: await detectWhatsAppLinked(cfg, accountId), })), ) ).find((entry) => entry.linked)?.accountId; const label = linkedAccountId ? `WhatsApp (${linkedAccountId === DEFAULT_ACCOUNT_ID ? "default" : linkedAccountId})` : "WhatsApp"; return [`${label}: ${configured ? "linked" : "not linked"}`]; }, }, resolveShouldPromptAccountIds: ({ options, shouldPromptAccountIds }) => Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), credentials: [], finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => { let next = accountId === DEFAULT_ACCOUNT_ID ? cfg : whatsappSetupAdapter.applyAccountConfig({ cfg, accountId, input: {}, }); const linked = await detectWhatsAppLinked(next, accountId); const { authDir } = resolveWhatsAppAuthDir({ cfg: next, accountId, }); if (!linked) { await prompter.note( [ "Scan the QR with WhatsApp on your phone.", `Credentials are stored under ${authDir}/ for future runs.`, `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp linking", ); } const wantsLink = await prompter.confirm({ message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", initialValue: !linked, }); if (wantsLink) { try { await loginWeb(false, undefined, runtime, accountId); } catch (error) { runtime.error(`WhatsApp login failed: ${String(error)}`); await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); } } else if (!linked) { await prompter.note( `Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`, "WhatsApp", ); } next = await promptWhatsAppDmAccess({ cfg: next, forceAllowFrom, prompter, }); return { cfg: next }; }, disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, };