fix: honor whatsapp setup dm policy accounts

This commit is contained in:
Tak Hoffman
2026-04-03 10:49:08 -05:00
parent bd4f745833
commit d1883470e7
2 changed files with 116 additions and 23 deletions

View File

@@ -48,11 +48,12 @@ async function runFinalizeWithHarness(params: {
cfg?: Parameters<NonNullable<typeof whatsappSetupWizard.finalize>>[0]["cfg"];
runtime?: RuntimeEnv;
forceAllowFrom?: boolean;
accountId?: string;
}) {
return await runSetupWizardFinalize({
finalize: whatsappSetupWizard.finalize,
cfg: params.cfg ?? {},
accountId: DEFAULT_ACCOUNT_ID,
accountId: params.accountId ?? DEFAULT_ACCOUNT_ID,
runtime: params.runtime ?? createRuntime(),
prompter: params.harness.prompter,
forceAllowFrom: params.forceAllowFrom ?? false,
@@ -135,6 +136,47 @@ describe("whatsapp setup wizard", () => {
expect(harness.text).not.toHaveBeenCalled();
});
it("writes named-account DM policy and allowFrom instead of the channel root", async () => {
hoisted.pathExists.mockResolvedValue(true);
const harness = createSeparatePhoneHarness({
selectValues: ["separate", "open"],
});
const named = expectFinalizeResult(
await runFinalizeWithHarness({
harness,
accountId: "work",
cfg: {
channels: {
whatsapp: {
dmPolicy: "disabled",
allowFrom: ["+15555550123"],
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
},
}),
);
expect(named.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(named.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(named.cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open");
expect(named.cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual([
"*",
"+15555550123",
]);
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining(
"`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`",
),
"WhatsApp DM access",
);
});
it("normalizes allowFrom entries when list mode is selected", async () => {
const { result } = await runSeparatePhoneFlow({
selectValues: ["separate", "allowlist", "list"],

View File

@@ -12,7 +12,7 @@ import {
} 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 { listWhatsAppAccountIds, resolveWhatsAppAccount, resolveWhatsAppAuthDir } from "./accounts.js";
import { loginWeb } from "./login.js";
import { whatsappSetupAdapter } from "./setup-core.js";
@@ -20,38 +20,74 @@ const channel = "whatsapp" as const;
function mergeWhatsAppConfig(
cfg: OpenClawConfig,
accountId: string,
patch: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>>,
options?: { unsetOnUndefined?: string[] },
): OpenClawConfig {
const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record<string, unknown>;
const channelConfig = { ...(cfg.channels?.whatsapp ?? {}) } as Record<string, unknown>;
if (accountId === DEFAULT_ACCOUNT_ID) {
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
if (options?.unsetOnUndefined?.includes(key)) {
delete channelConfig[key];
}
continue;
}
channelConfig[key] = value;
}
return {
...cfg,
channels: {
...cfg.channels,
whatsapp: channelConfig,
},
};
}
const accounts = {
...((channelConfig.accounts as Record<string, Record<string, unknown> | undefined> | undefined) ?? {}),
};
const nextAccount = { ...(accounts[accountId] ?? {}) };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
if (options?.unsetOnUndefined?.includes(key)) {
delete base[key];
delete nextAccount[key];
}
continue;
}
base[key] = value;
nextAccount[key] = value;
}
accounts[accountId] = nextAccount;
return {
...cfg,
channels: {
...cfg.channels,
whatsapp: base,
whatsapp: {
...channelConfig,
accounts,
},
},
};
}
function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { dmPolicy });
function setWhatsAppDmPolicy(cfg: OpenClawConfig, accountId: string, dmPolicy: DmPolicy): OpenClawConfig {
return mergeWhatsAppConfig(cfg, accountId, { dmPolicy });
}
function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] });
function setWhatsAppAllowFrom(
cfg: OpenClawConfig,
accountId: string,
allowFrom?: string[],
): OpenClawConfig {
return mergeWhatsAppConfig(cfg, accountId, { allowFrom }, { unsetOnUndefined: ["allowFrom"] });
}
function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { selfChatMode });
function setWhatsAppSelfChatMode(
cfg: OpenClawConfig,
accountId: string,
selfChatMode: boolean,
): OpenClawConfig {
return mergeWhatsAppConfig(cfg, accountId, { selfChatMode });
}
async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise<boolean> {
@@ -100,6 +136,7 @@ async function promptWhatsAppOwnerAllowFrom(params: {
async function applyWhatsAppOwnerAllowlist(params: {
cfg: OpenClawConfig;
accountId: string;
existingAllowFrom: string[];
messageLines: string[];
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
@@ -109,9 +146,9 @@ async function applyWhatsAppOwnerAllowlist(params: {
prompter: params.prompter,
existingAllowFrom: params.existingAllowFrom,
});
let next = setWhatsAppSelfChatMode(params.cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, allowFrom);
let next = setWhatsAppSelfChatMode(params.cfg, params.accountId, true);
next = setWhatsAppDmPolicy(next, params.accountId, "allowlist");
next = setWhatsAppAllowFrom(next, params.accountId, allowFrom);
await params.prompter.note(
[...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"),
params.title,
@@ -141,16 +178,28 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali
async function promptWhatsAppDmAccess(params: {
cfg: OpenClawConfig;
accountId: string;
forceAllowFrom: boolean;
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const existingPolicy = params.cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = params.cfg.channels?.whatsapp?.allowFrom ?? [];
const accountId = normalizeAccountId(params.accountId);
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
const existingPolicy = account.dmPolicy ?? "pairing";
const existingAllowFrom = account.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const policyKey =
accountId === DEFAULT_ACCOUNT_ID
? "channels.whatsapp.dmPolicy"
: `channels.whatsapp.accounts.${accountId}.dmPolicy`;
const allowFromKey =
accountId === DEFAULT_ACCOUNT_ID
? "channels.whatsapp.allowFrom"
: `channels.whatsapp.accounts.${accountId}.allowFrom`;
if (params.forceAllowFrom) {
return await applyWhatsAppOwnerAllowlist({
cfg: params.cfg,
accountId,
prompter: params.prompter,
existingAllowFrom,
title: "WhatsApp allowlist",
@@ -160,7 +209,7 @@ async function promptWhatsAppDmAccess(params: {
await params.prompter.note(
[
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
`WhatsApp direct chats are gated by \`${policyKey}\` + \`${allowFromKey}\`.`,
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
@@ -183,6 +232,7 @@ async function promptWhatsAppDmAccess(params: {
if (phoneMode === "personal") {
return await applyWhatsAppOwnerAllowlist({
cfg: params.cfg,
accountId,
prompter: params.prompter,
existingAllowFrom,
title: "WhatsApp personal phone",
@@ -203,11 +253,11 @@ async function promptWhatsAppDmAccess(params: {
],
})) as DmPolicy;
let next = setWhatsAppSelfChatMode(params.cfg, false);
next = setWhatsAppDmPolicy(next, policy);
let next = setWhatsAppSelfChatMode(params.cfg, accountId, false);
next = setWhatsAppDmPolicy(next, accountId, policy);
if (policy === "open") {
const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164);
next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]);
next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]);
return next;
}
if (policy === "disabled") {
@@ -241,7 +291,7 @@ async function promptWhatsAppDmAccess(params: {
return next;
}
if (mode === "unset") {
return setWhatsAppAllowFrom(next, undefined);
return setWhatsAppAllowFrom(next, accountId, undefined);
}
const allowRaw = await params.prompter.text({
@@ -264,7 +314,7 @@ async function promptWhatsAppDmAccess(params: {
});
const parsed = parseWhatsAppAllowFromEntries(String(allowRaw));
return setWhatsAppAllowFrom(next, parsed.entries);
return setWhatsAppAllowFrom(next, accountId, parsed.entries);
}
export const whatsappSetupWizard: ChannelSetupWizard = {
@@ -349,6 +399,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
next = await promptWhatsAppDmAccess({
cfg: next,
accountId,
forceAllowFrom,
prompter,
});