Skip to content

Commit d9e4602

Browse files
authored
fix(cron/whatsapp): route implicit delivery to allowlisted recipients (#21533) thanks @Takhoffman
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Tak Hoffman <[email protected]>
1 parent a87b5fb commit d9e4602

6 files changed

Lines changed: 194 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.
15+
- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
1516
- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet.
1617
- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
1718
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../config/sessions.js", () => ({
4+
loadSessionStore: vi.fn(),
5+
resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"),
6+
}));
7+
8+
vi.mock("../../pairing/pairing-store.js", () => ({
9+
readChannelAllowFromStoreSync: vi.fn(() => []),
10+
}));
11+
12+
import type { OpenClawConfig } from "../../config/config.js";
13+
import { loadSessionStore } from "../../config/sessions.js";
14+
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
15+
import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js";
16+
17+
function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
18+
return {
19+
bindings: [],
20+
channels: {},
21+
...overrides,
22+
} as OpenClawConfig;
23+
}
24+
25+
describe("resolveWhatsAppHeartbeatRecipients", () => {
26+
beforeEach(() => {
27+
vi.mocked(loadSessionStore).mockReset();
28+
vi.mocked(readChannelAllowFromStoreSync).mockReset();
29+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue([]);
30+
});
31+
32+
it("uses allowFrom store recipients when session recipients are ambiguous", () => {
33+
vi.mocked(loadSessionStore).mockReturnValue({
34+
a: { lastChannel: "whatsapp", lastTo: "+15550000001", updatedAt: 2, sessionId: "a" },
35+
b: { lastChannel: "whatsapp", lastTo: "+15550000002", updatedAt: 1, sessionId: "b" },
36+
});
37+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]);
38+
39+
const cfg = makeCfg();
40+
const result = resolveWhatsAppHeartbeatRecipients(cfg);
41+
42+
expect(result).toEqual({ recipients: ["+15550000001"], source: "session-single" });
43+
});
44+
45+
it("falls back to allowFrom when no session recipient is authorized", () => {
46+
vi.mocked(loadSessionStore).mockReturnValue({
47+
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
48+
});
49+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]);
50+
51+
const cfg = makeCfg();
52+
const result = resolveWhatsAppHeartbeatRecipients(cfg);
53+
54+
expect(result).toEqual({ recipients: ["+15550000001"], source: "allowFrom" });
55+
});
56+
57+
it("includes both session and allowFrom recipients when --all is set", () => {
58+
vi.mocked(loadSessionStore).mockReturnValue({
59+
a: { lastChannel: "whatsapp", lastTo: "+15550000099", updatedAt: 2, sessionId: "a" },
60+
});
61+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]);
62+
63+
const cfg = makeCfg();
64+
const result = resolveWhatsAppHeartbeatRecipients(cfg, { all: true });
65+
66+
expect(result).toEqual({
67+
recipients: ["+15550000099", "+15550000001"],
68+
source: "all",
69+
});
70+
});
71+
});

src/channels/plugins/whatsapp-heartbeat.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig } from "../../config/config.js";
22
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
3+
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
34
import { normalizeE164 } from "../../utils.js";
45
import { normalizeChatChannelId } from "../registry.js";
56

@@ -51,18 +52,34 @@ export function resolveWhatsAppHeartbeatRecipients(
5152
}
5253

5354
const sessionRecipients = getSessionRecipients(cfg);
54-
const allowFrom =
55+
const configuredAllowFrom =
5556
Array.isArray(cfg.channels?.whatsapp?.allowFrom) && cfg.channels.whatsapp.allowFrom.length > 0
5657
? cfg.channels.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
5758
: [];
59+
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp").map(normalizeE164);
5860

5961
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
62+
const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]);
6063

6164
if (opts.all) {
6265
const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]);
6366
return { recipients: all, source: "all" };
6467
}
6568

69+
if (allowFrom.length > 0) {
70+
const allowSet = new Set(allowFrom);
71+
const authorizedSessionRecipients = sessionRecipients
72+
.map((entry) => entry.to)
73+
.filter((recipient) => allowSet.has(recipient));
74+
if (authorizedSessionRecipients.length === 1) {
75+
return { recipients: [authorizedSessionRecipients[0]], source: "session-single" };
76+
}
77+
if (authorizedSessionRecipients.length > 1) {
78+
return { recipients: authorizedSessionRecipients, source: "session-ambiguous" };
79+
}
80+
return { recipients: allowFrom, source: "allowFrom" };
81+
}
82+
6683
if (sessionRecipients.length === 1) {
6784
return { recipients: [sessionRecipients[0].to], source: "session-single" };
6885
}

src/cron/isolated-agent/delivery-target.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,18 @@ vi.mock("../../infra/outbound/channel-selection.js", () => ({
1212
resolveMessageChannelSelection: vi.fn().mockResolvedValue({ channel: "telegram" }),
1313
}));
1414

15+
vi.mock("../../pairing/pairing-store.js", () => ({
16+
readChannelAllowFromStoreSync: vi.fn(() => []),
17+
}));
18+
19+
vi.mock("../../web/accounts.js", () => ({
20+
resolveWhatsAppAccount: vi.fn(() => ({ allowFrom: [] })),
21+
}));
22+
1523
import { loadSessionStore } from "../../config/sessions.js";
1624
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
25+
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
26+
import { resolveWhatsAppAccount } from "../../web/accounts.js";
1727
import { resolveDeliveryTarget } from "./delivery-target.js";
1828

1929
function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
@@ -50,6 +60,46 @@ async function resolveForAgent(params: {
5060
}
5161

5262
describe("resolveDeliveryTarget", () => {
63+
it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => {
64+
setMainSessionEntry({
65+
sessionId: "sess-w1",
66+
updatedAt: 1000,
67+
lastChannel: "whatsapp",
68+
lastTo: "+15550000099",
69+
});
70+
vi.mocked(resolveWhatsAppAccount).mockReturnValue({
71+
allowFrom: [],
72+
} as unknown as ReturnType<typeof resolveWhatsAppAccount>);
73+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]);
74+
75+
const cfg = makeCfg({ bindings: [] });
76+
const result = await resolveDeliveryTarget(cfg, AGENT_ID, { channel: "last", to: undefined });
77+
78+
expect(result.channel).toBe("whatsapp");
79+
expect(result.to).toBe("+15550000001");
80+
});
81+
82+
it("keeps explicit whatsapp target unchanged", async () => {
83+
setMainSessionEntry({
84+
sessionId: "sess-w2",
85+
updatedAt: 1000,
86+
lastChannel: "whatsapp",
87+
lastTo: "+15550000099",
88+
});
89+
vi.mocked(resolveWhatsAppAccount).mockReturnValue({
90+
allowFrom: [],
91+
} as unknown as ReturnType<typeof resolveWhatsAppAccount>);
92+
vi.mocked(readChannelAllowFromStoreSync).mockReturnValue(["+15550000001"]);
93+
94+
const cfg = makeCfg({ bindings: [] });
95+
const result = await resolveDeliveryTarget(cfg, AGENT_ID, {
96+
channel: "whatsapp",
97+
to: "+15550000099",
98+
});
99+
100+
expect(result.to).toBe("+15550000099");
101+
});
102+
53103
it("falls back to bound accountId when session has no lastAccountId", async () => {
54104
setMainSessionEntry(undefined);
55105

src/cron/isolated-agent/delivery-target.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
resolveOutboundTarget,
1313
resolveSessionDeliveryTarget,
1414
} from "../../infra/outbound/targets.js";
15+
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
1516
import { buildChannelAccountBindings } from "../../routing/bindings.js";
1617
import { normalizeAgentId } from "../../routing/session-key.js";
18+
import { resolveWhatsAppAccount } from "../../web/accounts.js";
19+
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
1720

1821
export async function resolveDeliveryTarget(
1922
cfg: OpenClawConfig,
@@ -76,7 +79,7 @@ export async function resolveDeliveryTarget(
7679

7780
const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL;
7881
const mode = resolved.mode as "explicit" | "implicit";
79-
const toCandidate = resolved.to;
82+
let toCandidate = resolved.to;
8083

8184
// When the session has no lastAccountId (e.g. first-run isolated cron
8285
// session), fall back to the agent's bound account from bindings config.
@@ -112,12 +115,34 @@ export async function resolveDeliveryTarget(
112115
};
113116
}
114117

118+
let allowFromOverride: string[] | undefined;
119+
if (channel === "whatsapp") {
120+
const configuredAllowFromRaw = resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [];
121+
const configuredAllowFrom = configuredAllowFromRaw
122+
.map((entry) => String(entry).trim())
123+
.filter((entry) => entry && entry !== "*")
124+
.map((entry) => normalizeWhatsAppTarget(entry))
125+
.filter((entry): entry is string => Boolean(entry));
126+
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp", process.env, accountId)
127+
.map((entry) => normalizeWhatsAppTarget(entry))
128+
.filter((entry): entry is string => Boolean(entry));
129+
allowFromOverride = [...new Set([...configuredAllowFrom, ...storeAllowFrom])];
130+
131+
if (mode === "implicit" && allowFromOverride.length > 0) {
132+
const normalizedCurrentTarget = normalizeWhatsAppTarget(toCandidate);
133+
if (!normalizedCurrentTarget || !allowFromOverride.includes(normalizedCurrentTarget)) {
134+
toCandidate = allowFromOverride[0];
135+
}
136+
}
137+
}
138+
115139
const docked = resolveOutboundTarget({
116140
channel,
117141
to: toCandidate,
118142
cfg,
119143
accountId,
120144
mode,
145+
allowFrom: allowFromOverride,
121146
});
122147
return {
123148
channel,

src/pairing/pairing-store.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,16 @@ async function readAllowFromStateForPath(
269269
return normalizeAllowFromList(channel, value);
270270
}
271271

272+
function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] {
273+
try {
274+
const raw = fs.readFileSync(filePath, "utf8");
275+
const parsed = JSON.parse(raw) as AllowFromStore;
276+
return normalizeAllowFromList(channel, parsed);
277+
} catch {
278+
return [];
279+
}
280+
}
281+
272282
async function readAllowFromState(params: {
273283
channel: PairingChannel;
274284
entry: string | number;
@@ -341,6 +351,24 @@ export async function readChannelAllowFromStore(
341351
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
342352
}
343353

354+
export function readChannelAllowFromStoreSync(
355+
channel: PairingChannel,
356+
env: NodeJS.ProcessEnv = process.env,
357+
accountId?: string,
358+
): string[] {
359+
const normalizedAccountId = accountId?.trim().toLowerCase() ?? "";
360+
if (!normalizedAccountId) {
361+
const filePath = resolveAllowFromPath(channel, env);
362+
return readAllowFromStateForPathSync(channel, filePath);
363+
}
364+
365+
const scopedPath = resolveAllowFromPath(channel, env, accountId);
366+
const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath);
367+
const legacyPath = resolveAllowFromPath(channel, env);
368+
const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath);
369+
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
370+
}
371+
344372
type AllowFromStoreEntryUpdateParams = {
345373
channel: PairingChannel;
346374
entry: string | number;

0 commit comments

Comments
 (0)