Skip to content

Commit 844b652

Browse files
committed
refactor(whatsapp): centralize account policy
1 parent ed6094b commit 844b652

7 files changed

Lines changed: 397 additions & 119 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveWhatsAppAccountPolicy,
4+
resolveWhatsAppDirectTargetAuthorization,
5+
} from "./account-policy.js";
6+
7+
describe("resolveWhatsAppAccountPolicy", () => {
8+
it("prefers explicit accountId over configured defaultAccount", () => {
9+
const policy = resolveWhatsAppAccountPolicy({
10+
cfg: {
11+
channels: {
12+
whatsapp: {
13+
defaultAccount: "work",
14+
accounts: {
15+
personal: { allowFrom: ["+15550000001"] },
16+
work: { allowFrom: ["+15550000002"] },
17+
},
18+
},
19+
},
20+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
21+
accountId: "personal",
22+
});
23+
24+
expect(policy.accountId).toBe("personal");
25+
expect(policy.configuredAllowFrom).toEqual(["+15550000001"]);
26+
});
27+
28+
it("uses configured defaultAccount when accountId is omitted", () => {
29+
const policy = resolveWhatsAppAccountPolicy({
30+
cfg: {
31+
channels: {
32+
whatsapp: {
33+
defaultAccount: "work",
34+
accounts: {
35+
work: { allowFrom: ["+15550000002"] },
36+
},
37+
},
38+
},
39+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
40+
});
41+
42+
expect(policy.accountId).toBe("work");
43+
expect(policy.configuredAllowFrom).toEqual(["+15550000002"]);
44+
});
45+
46+
it("falls back to the default account when no preferred account is configured", () => {
47+
const policy = resolveWhatsAppAccountPolicy({
48+
cfg: {
49+
channels: {
50+
whatsapp: {},
51+
},
52+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
53+
});
54+
55+
expect(policy.accountId).toBe("default");
56+
});
57+
58+
it("inherits shared defaults from accounts.default for named accounts", () => {
59+
const policy = resolveWhatsAppAccountPolicy({
60+
cfg: {
61+
channels: {
62+
whatsapp: {
63+
accounts: {
64+
default: {
65+
dmPolicy: "allowlist",
66+
allowFrom: [" +15550001111 "],
67+
groupPolicy: "open",
68+
groupAllowFrom: [" +15550002222 "],
69+
},
70+
work: {},
71+
},
72+
},
73+
},
74+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
75+
accountId: "work",
76+
});
77+
78+
expect(policy.dmPolicy).toBe("allowlist");
79+
expect(policy.groupPolicy).toBe("open");
80+
expect(policy.configuredAllowFrom).toEqual(["+15550001111"]);
81+
expect(policy.groupAllowFrom).toEqual(["+15550002222"]);
82+
});
83+
84+
it("does not inherit default-only authDir or selfChatMode for named accounts", () => {
85+
const policy = resolveWhatsAppAccountPolicy({
86+
cfg: {
87+
channels: {
88+
whatsapp: {
89+
accounts: {
90+
default: {
91+
authDir: "/tmp/default-auth",
92+
selfChatMode: true,
93+
},
94+
work: {},
95+
},
96+
},
97+
},
98+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
99+
accountId: "work",
100+
selfE164: "+15550009999",
101+
});
102+
103+
expect(policy.account.authDir).toMatch(/whatsapp[/\\]work$/);
104+
expect(policy.account.selfChatMode).toBeUndefined();
105+
expect(policy.isSelfChat).toBe(false);
106+
});
107+
108+
it("falls back groupAllowFrom to allowFrom when unset", () => {
109+
const policy = resolveWhatsAppAccountPolicy({
110+
cfg: {
111+
channels: {
112+
whatsapp: {
113+
allowFrom: [" +15550001111 ", "+15550002222"],
114+
},
115+
},
116+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
117+
});
118+
119+
expect(policy.configuredAllowFrom).toEqual(["+15550001111", "+15550002222"]);
120+
expect(policy.groupAllowFrom).toEqual(["+15550001111", "+15550002222"]);
121+
});
122+
123+
it("keeps self-chat classification inputs aligned with allowFrom and selfE164", () => {
124+
const policy = resolveWhatsAppAccountPolicy({
125+
cfg: {
126+
channels: {
127+
whatsapp: {
128+
allowFrom: ["+15550009999"],
129+
},
130+
},
131+
} as Parameters<typeof resolveWhatsAppAccountPolicy>[0]["cfg"],
132+
selfE164: "+15550009999",
133+
});
134+
135+
expect(policy.dmAllowFrom).toEqual(["+15550009999"]);
136+
expect(policy.isSamePhone("+15550009999")).toBe(true);
137+
expect(policy.isDmSenderAllowed(policy.dmAllowFrom, "+15550009999")).toBe(true);
138+
expect(policy.isSelfChat).toBe(true);
139+
});
140+
});
141+
142+
describe("resolveWhatsAppDirectTargetAuthorization", () => {
143+
it("allows wildcard direct targets", () => {
144+
const authorized = resolveWhatsAppDirectTargetAuthorization({
145+
cfg: {
146+
channels: {
147+
whatsapp: {
148+
allowFrom: ["*"],
149+
},
150+
},
151+
} as Parameters<typeof resolveWhatsAppDirectTargetAuthorization>[0]["cfg"],
152+
to: "+15550007777",
153+
});
154+
155+
expect(authorized.accountId).toBe("default");
156+
expect(authorized.resolution).toEqual({ ok: true, to: "+15550007777" });
157+
});
158+
159+
it("allows direct targets when allowFrom is empty", () => {
160+
const authorized = resolveWhatsAppDirectTargetAuthorization({
161+
cfg: {
162+
channels: {
163+
whatsapp: {},
164+
},
165+
} as Parameters<typeof resolveWhatsAppDirectTargetAuthorization>[0]["cfg"],
166+
to: "+15550007777",
167+
});
168+
169+
expect(authorized.resolution).toEqual({ ok: true, to: "+15550007777" });
170+
});
171+
172+
it("blocks non-allowlisted direct targets", () => {
173+
const authorized = resolveWhatsAppDirectTargetAuthorization({
174+
cfg: {
175+
channels: {
176+
whatsapp: {
177+
allowFrom: ["+15550001111"],
178+
},
179+
},
180+
} as Parameters<typeof resolveWhatsAppDirectTargetAuthorization>[0]["cfg"],
181+
to: "+15550007777",
182+
});
183+
184+
expect(authorized.resolution.ok).toBe(false);
185+
});
186+
187+
it("always allows group JIDs", () => {
188+
const authorized = resolveWhatsAppDirectTargetAuthorization({
189+
cfg: {
190+
channels: {
191+
whatsapp: {
192+
allowFrom: ["+15550001111"],
193+
},
194+
},
195+
} as Parameters<typeof resolveWhatsAppDirectTargetAuthorization>[0]["cfg"],
196+
197+
});
198+
199+
expect(authorized.resolution).toEqual({ ok: true, to: "[email protected]" });
200+
});
201+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
resolveDefaultGroupPolicy,
3+
type DmPolicy,
4+
type GroupPolicy,
5+
type OpenClawConfig,
6+
} from "openclaw/plugin-sdk/config-runtime";
7+
import { resolveEffectiveAllowFromLists } from "openclaw/plugin-sdk/security-runtime";
8+
import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
9+
import {
10+
resolveWhatsAppOutboundTarget,
11+
type WhatsAppOutboundTargetResolution,
12+
} from "./resolve-outbound-target.js";
13+
import { resolveWhatsAppRuntimeGroupPolicy } from "./runtime-group-policy.js";
14+
import { isSelfChatMode, normalizeE164 } from "./text-runtime.js";
15+
16+
export type ResolvedWhatsAppAccountPolicy = {
17+
account: ResolvedWhatsAppAccount;
18+
accountId: string;
19+
dmPolicy: DmPolicy;
20+
groupPolicy: GroupPolicy;
21+
configuredAllowFrom: string[];
22+
dmAllowFrom: string[];
23+
groupAllowFrom: string[];
24+
isSelfChat: boolean;
25+
providerMissingFallbackApplied: boolean;
26+
shouldReadStorePairingApprovals: boolean;
27+
isSamePhone: (value?: string | null) => boolean;
28+
isDmSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
29+
isGroupSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
30+
};
31+
32+
function normalizeConfiguredAllowEntries(entries?: Array<string | number> | null): string[] {
33+
return (entries ?? []).map((entry) => String(entry).trim()).filter(Boolean);
34+
}
35+
36+
function isNormalizedSenderAllowed(allowEntries: string[], sender?: string | null): boolean {
37+
if (allowEntries.includes("*")) {
38+
return true;
39+
}
40+
const normalizedSender = normalizeE164(sender ?? "");
41+
if (!normalizedSender) {
42+
return false;
43+
}
44+
const normalizedEntrySet = new Set(
45+
allowEntries
46+
.map((entry) => normalizeE164(entry))
47+
.filter((entry): entry is string => Boolean(entry)),
48+
);
49+
return normalizedEntrySet.has(normalizedSender);
50+
}
51+
52+
export function resolveWhatsAppAccountPolicy(params: {
53+
cfg: OpenClawConfig;
54+
accountId?: string | null;
55+
selfE164?: string | null;
56+
}): ResolvedWhatsAppAccountPolicy {
57+
const account = resolveWhatsAppAccount({
58+
cfg: params.cfg,
59+
accountId: params.accountId,
60+
});
61+
const configuredAllowFrom = normalizeConfiguredAllowEntries(account.allowFrom);
62+
const dmPolicy = account.dmPolicy ?? "pairing";
63+
const dmAllowFrom =
64+
configuredAllowFrom.length > 0 ? configuredAllowFrom : params.selfE164 ? [params.selfE164] : [];
65+
const configuredGroupAllowFrom = normalizeConfiguredAllowEntries(account.groupAllowFrom);
66+
const { effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
67+
allowFrom: configuredAllowFrom,
68+
groupAllowFrom: configuredGroupAllowFrom,
69+
});
70+
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
71+
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
72+
providerConfigPresent: params.cfg.channels?.whatsapp !== undefined,
73+
groupPolicy: account.groupPolicy,
74+
defaultGroupPolicy,
75+
});
76+
const isSamePhone = (value?: string | null) =>
77+
typeof value === "string" && typeof params.selfE164 === "string" && value === params.selfE164;
78+
return {
79+
account,
80+
accountId: account.accountId,
81+
dmPolicy,
82+
groupPolicy,
83+
configuredAllowFrom,
84+
dmAllowFrom,
85+
groupAllowFrom: effectiveGroupAllowFrom,
86+
isSelfChat: account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom),
87+
providerMissingFallbackApplied,
88+
shouldReadStorePairingApprovals: dmPolicy !== "allowlist",
89+
isSamePhone,
90+
isDmSenderAllowed: (allowEntries, sender) =>
91+
isSamePhone(sender) || isNormalizedSenderAllowed(allowEntries, sender),
92+
isGroupSenderAllowed: (allowEntries, sender) => isNormalizedSenderAllowed(allowEntries, sender),
93+
};
94+
}
95+
96+
export function resolveWhatsAppDirectTargetAuthorization(params: {
97+
cfg: OpenClawConfig;
98+
to: string;
99+
accountId?: string | null;
100+
mode?: string | null;
101+
}): {
102+
account: ResolvedWhatsAppAccount;
103+
accountId: string;
104+
resolution: WhatsAppOutboundTargetResolution;
105+
} {
106+
const policy = resolveWhatsAppAccountPolicy({
107+
cfg: params.cfg,
108+
accountId: params.accountId,
109+
});
110+
return {
111+
account: policy.account,
112+
accountId: policy.accountId,
113+
resolution: resolveWhatsAppOutboundTarget({
114+
to: params.to,
115+
allowFrom: policy.configuredAllowFrom,
116+
mode: params.mode ?? "implicit",
117+
}),
118+
};
119+
}
Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
11
import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";
22
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
3-
import { resolveWhatsAppAccount } from "./accounts.js";
4-
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
3+
import { resolveWhatsAppDirectTargetAuthorization } from "./account-policy.js";
54

65
export function resolveAuthorizedWhatsAppOutboundTarget(params: {
76
cfg: OpenClawConfig;
87
chatJid: string;
98
accountId?: string;
109
actionLabel: string;
1110
}): { to: string; accountId: string } {
12-
const account = resolveWhatsAppAccount({
11+
const authorized = resolveWhatsAppDirectTargetAuthorization({
1312
cfg: params.cfg,
14-
accountId: params.accountId,
15-
});
16-
const resolution = resolveWhatsAppOutboundTarget({
1713
to: params.chatJid,
18-
allowFrom: account.allowFrom ?? [],
14+
accountId: params.accountId,
1915
mode: "implicit",
2016
});
21-
if (!resolution.ok) {
17+
if (!authorized.resolution.ok) {
2218
throw new ToolAuthorizationError(
23-
`WhatsApp ${params.actionLabel} blocked: chatJid "${params.chatJid}" is not in the configured allowFrom list for account "${account.accountId}".`,
19+
`WhatsApp ${params.actionLabel} blocked: chatJid "${params.chatJid}" is not in the configured allowFrom list for account "${authorized.accountId}".`,
2420
);
2521
}
26-
return { to: resolution.to, accountId: account.accountId };
22+
return { to: authorized.resolution.to, accountId: authorized.accountId };
2723
}

extensions/whatsapp/src/action-runtime.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,40 @@ describe("handleWhatsAppAction", () => {
327327
}),
328328
);
329329
});
330+
331+
it("uses configured defaultAccount for action authorization when accountId is omitted", async () => {
332+
const cfg = {
333+
channels: {
334+
whatsapp: {
335+
defaultAccount: "work",
336+
actions: { reactions: true },
337+
allowFrom: ["[email protected]"],
338+
accounts: {
339+
work: {
340+
allowFrom: ["[email protected]"],
341+
},
342+
},
343+
},
344+
},
345+
} as OpenClawConfig;
346+
347+
await handleWhatsAppAction(
348+
{
349+
action: "react",
350+
chatJid: "[email protected]",
351+
messageId: "msg1",
352+
emoji: "✅",
353+
},
354+
cfg,
355+
);
356+
357+
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith(
358+
"+123",
359+
"msg1",
360+
"✅",
361+
expect.objectContaining({
362+
accountId: "work",
363+
}),
364+
);
365+
});
330366
});

0 commit comments

Comments
 (0)