From ab0b2c21f3a8075ce1cd63f3f16fdacc37cb44c9 Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Mon, 2 Mar 2026 13:35:27 -0600 Subject: [PATCH] WhatsApp: guard main DM last-route to single owner --- .../process-message.inbound-contract.test.ts | 72 +++++++++++++++++++ src/web/auto-reply/monitor/process-message.ts | 42 ++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 945b1c239..8b3676400 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -344,4 +344,76 @@ describe("web processMessage inbound contract", () => { expect(updateLastRouteMock).not.toHaveBeenCalled(); }); + + it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:main", + groupHistoryKey: "+3000", + cfg: { + channels: { + whatsapp: { + allowFrom: ["+1000"], + }, + }, + messages: {}, + session: { store: sessionStorePath, dmScope: "main" }, + } as unknown as ReturnType, + msg: { + id: "msg-last-route-3", + from: "+3000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+3000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:main", + mainSessionKey: "agent:main:main", + }; + + await processMessage(args); + + expect(updateLastRouteMock).not.toHaveBeenCalled(); + }); + + it("updates main last route for owner sender when main DM scope is pinned", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:main", + groupHistoryKey: "+1000", + cfg: { + channels: { + whatsapp: { + allowFrom: ["+1000"], + }, + }, + messages: {}, + session: { store: sessionStorePath, dmScope: "main" }, + } as unknown as ReturnType, + msg: { + id: "msg-last-route-4", + from: "+1000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+1000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:main", + mainSessionKey: "agent:main:main", + }; + + await processMessage(args); + + expect(updateLastRouteMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 93a12ff07..aa0b597d7 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -107,6 +107,28 @@ async function resolveWhatsAppCommandAuthorized(params: { return access.commandAuthorized; } +function resolvePinnedMainDmRecipient(params: { + cfg: ReturnType; + msg: WebInboundMsg; +}): string | null { + if ((params.cfg.session?.dmScope ?? "main") !== "main") { + return null; + } + const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId }); + const rawAllowFrom = account.allowFrom ?? []; + if (rawAllowFrom.includes("*")) { + return null; + } + const normalizedOwners = Array.from( + new Set( + rawAllowFrom + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)), + ), + ); + return normalizedOwners.length === 1 ? normalizedOwners[0] : null; +} + export async function processMessage(params: { cfg: ReturnType; msg: WebInboundMsg; @@ -320,7 +342,17 @@ export async function processMessage(params: { // Only update main session's lastRoute when DM actually IS the main session. // When dmScope="per-channel-peer", the DM uses an isolated sessionKey, // and updating mainSessionKey would corrupt routing for the session owner. - if (dmRouteTarget && params.route.sessionKey === params.route.mainSessionKey) { + const pinnedMainDmRecipient = resolvePinnedMainDmRecipient({ + cfg: params.cfg, + msg: params.msg, + }); + const shouldUpdateMainLastRoute = + !pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget; + if ( + dmRouteTarget && + params.route.sessionKey === params.route.mainSessionKey && + shouldUpdateMainLastRoute + ) { updateLastRouteInBackground({ cfg: params.cfg, backgroundTasks: params.backgroundTasks, @@ -332,6 +364,14 @@ export async function processMessage(params: { ctx: ctxPayload, warn: params.replyLogger.warn.bind(params.replyLogger), }); + } else if ( + dmRouteTarget && + params.route.sessionKey === params.route.mainSessionKey && + pinnedMainDmRecipient + ) { + logVerbose( + `Skipping main-session last route update for ${dmRouteTarget} (pinned owner ${pinnedMainDmRecipient})`, + ); } const metaTask = recordSessionMetaFromInbound({