* refactor: move WhatsApp channel from src/web/ to extensions/whatsapp/ Move all WhatsApp implementation code (77 source/test files + 9 channel plugin files) from src/web/ and src/channels/plugins/*/whatsapp* to extensions/whatsapp/src/. - Leave thin re-export shims at all original locations so cross-cutting imports continue to resolve - Update plugin-sdk/whatsapp.ts to only re-export generic framework utilities; channel-specific functions imported locally by the extension - Update vi.mock paths in 15 cross-cutting test files - Rename outbound.ts -> send.ts to match extension naming conventions and avoid false positive in cfg-threading guard test - Widen tsconfig.plugin-sdk.dts.json rootDir to support shim->extension cross-directory references Part of the core-channels-to-extensions migration (PR 6/10). * style: format WhatsApp extension files * fix: correct stale import paths in WhatsApp extension tests Fix vi.importActual, test mock, and hardcoded source paths that weren't updated during the file move: - media.test.ts: vi.importActual path - onboarding.test.ts: vi.importActual path - test-helpers.ts: test/mocks/baileys.js path - monitor-inbox.test-harness.ts: incomplete media/store mock - login.test.ts: hardcoded source file path - message-action-runner.media.test.ts: vi.mock/importActual path
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import path from "node:path";
|
|
import { loginWeb } from "../../../src/channel-web.js";
|
|
import type { ChannelOnboardingAdapter } from "../../../src/channels/plugins/onboarding-types.js";
|
|
import {
|
|
normalizeAllowFromEntries,
|
|
resolveAccountIdForConfigure,
|
|
resolveOnboardingAccountId,
|
|
splitOnboardingEntries,
|
|
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
|
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js";
|
|
import type { DmPolicy } from "../../../src/config/types.js";
|
|
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
|
import type { RuntimeEnv } from "../../../src/runtime.js";
|
|
import { formatDocsLink } from "../../../src/terminal/links.js";
|
|
import { normalizeE164, pathExists } from "../../../src/utils.js";
|
|
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
|
import {
|
|
listWhatsAppAccountIds,
|
|
resolveDefaultWhatsAppAccountId,
|
|
resolveWhatsAppAuthDir,
|
|
} from "./accounts.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 promptWhatsAppOwnerAllowFrom(params: {
|
|
prompter: WizardPrompter;
|
|
existingAllowFrom: string[];
|
|
}): 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;
|
|
prompter: WizardPrompter;
|
|
existingAllowFrom: string[];
|
|
title: string;
|
|
messageLines: string[];
|
|
}): Promise<OpenClawConfig> {
|
|
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 = splitOnboardingEntries(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 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) {
|
|
return await applyWhatsAppOwnerAllowlist({
|
|
cfg,
|
|
prompter,
|
|
existingAllowFrom,
|
|
title: "WhatsApp allowlist",
|
|
messageLines: ["Allowlist mode enabled."],
|
|
});
|
|
}
|
|
|
|
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") {
|
|
return await applyWhatsAppOwnerAllowlist({
|
|
cfg,
|
|
prompter,
|
|
existingAllowFrom,
|
|
title: "WhatsApp personal phone",
|
|
messageLines: [
|
|
"Personal phone mode enabled.",
|
|
"- dmPolicy set to allowlist (pairing skipped)",
|
|
],
|
|
});
|
|
}
|
|
|
|
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") {
|
|
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 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 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));
|
|
next = setWhatsAppAllowFrom(next, parsed.entries);
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
channel,
|
|
getStatus: async ({ cfg, accountOverrides }) => {
|
|
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
|
|
const accountId = resolveOnboardingAccountId({
|
|
accountId: accountOverrides.whatsapp,
|
|
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 accountId = await resolveAccountIdForConfigure({
|
|
cfg,
|
|
prompter,
|
|
label: "WhatsApp",
|
|
accountOverride: accountOverrides.whatsapp,
|
|
shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId),
|
|
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);
|
|
},
|
|
};
|