Files
openclaw/src/channels/plugins/onboarding/whatsapp.ts
2026-02-09 18:59:42 -08:00

349 lines
11 KiB
TypeScript

import path from "node:path";
import type { OpenClawConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import type { RuntimeEnv } from "../../../runtime.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import { loginWeb } from "../../../channel-web.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164, pathExists } from "../../../utils.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "../../../web/accounts.js";
import { promptAccountId } from "./helpers.js";
const channel = "whatsapp" as const;
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<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function promptWhatsAppAllowFrom(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<OpenClawConfig> {
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
if (options?.forceAllowlist) {
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());
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);
await prompter.note(
["Allowlist mode enabled.", `- allowFrom includes ${normalized}`].join("\n"),
"WhatsApp allowlist",
);
return next;
}
await 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 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") {
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());
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);
await prompter.note(
[
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
`- allowFrom includes ${normalized}`,
].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 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 prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)",
options: allowOptions.map((opt) => ({
value: opt.value,
label: opt.label,
})),
});
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;
}
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const overrideId = accountOverrides.whatsapp?.trim();
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
const accountId = overrideId ? normalizeAccountId(overrideId) : defaultAccountId;
const linked = await detectWhatsAppLinked(cfg, accountId);
const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
channel,
configured: linked,
statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`],
selectionHint: linked ? "linked" : "not linked",
quickstartScore: linked ? 5 : 4,
};
},
configure: async ({
cfg,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const overrideId = accountOverrides.whatsapp?.trim();
let accountId = overrideId
? normalizeAccountId(overrideId)
: resolveDefaultWhatsAppAccountId(cfg);
if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
if (!overrideId) {
accountId = await promptAccountId({
cfg,
prompter,
label: "WhatsApp",
currentId: accountId,
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
}
}
let next = cfg;
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: {
...next.channels?.whatsapp?.accounts?.[accountId],
enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true,
},
},
},
},
};
}
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 (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
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 promptWhatsAppAllowFrom(next, runtime, prompter, {
forceAllowlist: forceAllowFrom,
});
return { cfg: next, accountId };
},
onAccountRecorded: (accountId, options) => {
options?.onWhatsAppAccountId?.(accountId);
},
};