diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d652a9bf6..598c4f2409d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored. +- Channels/WhatsApp: isolate inbound direct-message sessions by account and contact (`agent::whatsapp::direct:`) instead of collapsing all contacts into the agent main session, preventing shared transcripts and context across WhatsApp senders. Fixes #76263. Thanks @matirossi93 and @chinar-amrutkar. - Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier. - Control UI/Skills: fix skill detail modal silently failing to open in all browsers by deferring `showModal()` until the dialog element is connected to the DOM; the Lit `ref` callback fired before connection causing a `DOMException: HTMLDialogElement.showModal: Dialog element is not connected` on every skill click. Thanks @nickmopen. - Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 948157ae858..3e408fccef6 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -156,7 +156,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - Group sends attach native mention metadata for `@+` and `@` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups. - Status and broadcast chats are ignored (`@status`, `@broadcast`). - The reconnect watchdog follows WhatsApp Web transport activity, not only inbound app-message volume: quiet linked-device sessions stay up while transport frames continue, but a transport stall forces reconnect well before the later remote disconnect path. -- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). +- Direct chats use account-aware per-contact sessions (`agent::whatsapp::direct:`), so distinct contacts and separate WhatsApp accounts do not share session files or model context. - Group sessions are isolated (`agent::whatsapp:group:`). - WhatsApp Channels/Newsletters can be explicit outbound targets with their native `@newsletter` JID. Outbound newsletter sends use channel session metadata (`agent::whatsapp:channel:`) rather than DM session semantics. - WhatsApp Web transport honors standard proxy environment variables on the gateway host (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` / lowercase variants). Prefer host-level proxy config over channel-specific WhatsApp proxy settings. diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index 90f9cf31b22..3ea95f599e3 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -181,4 +181,96 @@ describe("web auto-reply last-route", () => { await store.cleanup(); }); + + it("uses account-aware session keys for different direct chat contacts", async () => { + const now = Date.now(); + const store = await makeSessionStore({}); + + const { handler, backgroundTasks } = createLastRouteHarness(store.storePath); + + await handler( + buildInboundMessage({ + id: "m1", + from: "+15551112222", + conversationId: "+15551112222", + chatType: "direct", + chatId: "direct:+15551112222", + accountId: "biz", + timestamp: now, + senderE164: "+15551112222", + }), + ); + + await awaitBackgroundTasks(backgroundTasks); + const firstSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey; + + updateLastRouteInBackgroundMock.mockClear(); + + await handler( + buildInboundMessage({ + id: "m2", + from: "+15553334444", + conversationId: "+15553334444", + chatType: "direct", + chatId: "direct:+15553334444", + accountId: "biz", + timestamp: now + 1, + senderE164: "+15553334444", + }), + ); + + await awaitBackgroundTasks(backgroundTasks); + const secondSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey; + + expect(firstSessionKey).toBe("agent:main:whatsapp:biz:direct:+15551112222"); + expect(secondSessionKey).toBe("agent:main:whatsapp:biz:direct:+15553334444"); + + await store.cleanup(); + }); + + it("keeps the same direct contact isolated across WhatsApp accounts", async () => { + const now = Date.now(); + const store = await makeSessionStore({}); + + const { handler, backgroundTasks } = createLastRouteHarness(store.storePath); + + await handler( + buildInboundMessage({ + id: "m1", + from: "+15551112222", + conversationId: "+15551112222", + chatType: "direct", + chatId: "direct:+15551112222", + accountId: "personal", + timestamp: now, + senderE164: "+15551112222", + }), + ); + + await awaitBackgroundTasks(backgroundTasks); + const firstSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey; + + updateLastRouteInBackgroundMock.mockClear(); + + await handler( + buildInboundMessage({ + id: "m2", + from: "+15551112222", + conversationId: "+15551112222", + chatType: "direct", + chatId: "direct:+15551112222", + accountId: "biz", + timestamp: now + 1, + senderE164: "+15551112222", + }), + ); + + await awaitBackgroundTasks(backgroundTasks); + const secondSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey; + + expect(firstSessionKey).toBe("agent:main:whatsapp:personal:direct:+15551112222"); + expect(secondSessionKey).toBe("agent:main:whatsapp:biz:direct:+15551112222"); + + await store.cleanup(); + }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 74a1954b90c..ddb47c518aa 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -816,7 +816,7 @@ describe("whatsapp inbound dispatch", () => { expect(updateLastRoute).toHaveBeenCalledTimes(1); }); - it("does not update main last route for isolated DM scope sessions", () => { + it("updates isolated DM last route for scoped session keys", () => { const updateLastRoute = vi.fn(); updateWhatsAppMainLastRoute({ @@ -826,14 +826,23 @@ describe("whatsapp inbound dispatch", () => { dmRouteTarget: "+3000", pinnedMainDmRecipient: null, route: makeRoute({ - sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", - mainSessionKey: "agent:main:whatsapp:direct:+1000", + sessionKey: "agent:main:whatsapp:biz:direct:+3000", + mainSessionKey: "agent:main:main", + lastRoutePolicy: "session", + accountId: "biz", }), updateLastRoute, warn: () => {}, }); - expect(updateLastRoute).not.toHaveBeenCalled(); + expect(updateLastRoute).toHaveBeenCalledTimes(1); + expect(updateLastRoute).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:whatsapp:biz:direct:+3000", + accountId: "biz", + to: "+3000", + }), + ); }); it("does not update main last route for non-owner sender when main DM scope is pinned", () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 6bfde4c3fea..ef5f3423f39 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -256,6 +256,25 @@ export function updateWhatsAppMainLastRoute(params: { return; } + if ( + params.dmRouteTarget && + params.route.sessionKey !== params.route.mainSessionKey && + inboundLastRouteSessionKey === params.route.sessionKey + ) { + params.updateLastRoute({ + cfg: params.cfg, + backgroundTasks: params.backgroundTasks, + storeAgentId: params.route.agentId, + sessionKey: params.route.sessionKey, + channel: "whatsapp", + to: params.dmRouteTarget, + accountId: params.route.accountId, + ctx: params.ctx, + warn: params.warn, + }); + return; + } + if ( params.dmRouteTarget && inboundLastRouteSessionKey === params.route.mainSessionKey && diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index 64eea5bf425..aac5232f31f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -94,6 +94,9 @@ export function createWebOnMessageHandler(params: { kind: msg.chatType === "group" ? "group" : "direct", id: peerId, }, + ...(msg.chatType === "direct" + ? { dmScopeOverride: "per-account-channel-peer" as const } + : {}), }); const route = msg.chatType === "group" ? resolveWhatsAppGroupSessionRoute(baseRoute) : baseRoute; diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index b0c2e6f2e1a..4cc6eaa0974 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -138,6 +138,24 @@ describe("resolveAgentRoute", () => { }); }); + test("dmScopeOverride can force account-aware direct-message isolation", () => { + const route = resolveAgentRoute({ + cfg: { session: { dmScope: "main" } }, + channel: "whatsapp", + accountId: "biz", + peer: { kind: "direct", id: "+15551234567" }, + dmScopeOverride: "per-account-channel-peer", + }); + + expectResolvedRoute(route, { + agentId: "main", + accountId: "biz", + sessionKey: "agent:main:whatsapp:biz:direct:+15551234567", + lastRoutePolicy: "session", + matchedBy: "default", + }); + }); + test("route binding session dmScope isolates selected direct peers without changing agent", () => { const cfg: OpenClawConfig = { session: { dmScope: "main" }, diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 14131fcdf75..d7e4e236ac4 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -41,6 +41,11 @@ export type ResolveAgentRouteInput = { teamId?: string | null; /** Discord member role IDs — used for role-based agent routing. */ memberRoleIds?: string[]; + /** + * Override cfg.session.dmScope for this route resolution only. + * Channel plugins use this when their privacy contract needs stricter DM isolation. + */ + dmScopeOverride?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; }; export type ResolvedAgentRoute = { @@ -620,7 +625,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; const memberRoleIdSet = new Set(memberRoleIds); - const dmScope = input.cfg.session?.dmScope ?? "main"; + const dmScope = input.dmScopeOverride ?? input.cfg.session?.dmScope ?? "main"; const identityLinks = input.cfg.session?.identityLinks; const shouldLogDebug = shouldLogVerbose(); const parentPeer = input.parentPeer