Skip to content

Commit 6523eb7

Browse files
committed
fix(whatsapp): isolate direct sessions by account
1 parent 0949f4f commit 6523eb7

8 files changed

Lines changed: 153 additions & 6 deletions

File tree

CHANGELOG.md

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

2727
- 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.
28+
- Channels/WhatsApp: isolate inbound direct-message sessions by account and contact (`agent:<agentId>:whatsapp:<accountId>:direct:<peerId>`) 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.
2829
- 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.
2930
- 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.
3031
- 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.

docs/channels/whatsapp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
156156
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
157157
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
158158
- 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.
159-
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
159+
- Direct chats use account-aware per-contact sessions (`agent:<agentId>:whatsapp:<accountId>:direct:<peerId>`), so distinct contacts and separate WhatsApp accounts do not share session files or model context.
160160
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
161161
- WhatsApp Channels/Newsletters can be explicit outbound targets with their native `@newsletter` JID. Outbound newsletter sends use channel session metadata (`agent:<agentId>:whatsapp:channel:<jid>`) rather than DM session semantics.
162162
- 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.

extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,96 @@ describe("web auto-reply last-route", () => {
181181

182182
await store.cleanup();
183183
});
184+
185+
it("uses account-aware session keys for different direct chat contacts", async () => {
186+
const now = Date.now();
187+
const store = await makeSessionStore({});
188+
189+
const { handler, backgroundTasks } = createLastRouteHarness(store.storePath);
190+
191+
await handler(
192+
buildInboundMessage({
193+
id: "m1",
194+
from: "+15551112222",
195+
conversationId: "+15551112222",
196+
chatType: "direct",
197+
chatId: "direct:+15551112222",
198+
accountId: "biz",
199+
timestamp: now,
200+
senderE164: "+15551112222",
201+
}),
202+
);
203+
204+
await awaitBackgroundTasks(backgroundTasks);
205+
const firstSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey;
206+
207+
updateLastRouteInBackgroundMock.mockClear();
208+
209+
await handler(
210+
buildInboundMessage({
211+
id: "m2",
212+
from: "+15553334444",
213+
conversationId: "+15553334444",
214+
chatType: "direct",
215+
chatId: "direct:+15553334444",
216+
accountId: "biz",
217+
timestamp: now + 1,
218+
senderE164: "+15553334444",
219+
}),
220+
);
221+
222+
await awaitBackgroundTasks(backgroundTasks);
223+
const secondSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey;
224+
225+
expect(firstSessionKey).toBe("agent:main:whatsapp:biz:direct:+15551112222");
226+
expect(secondSessionKey).toBe("agent:main:whatsapp:biz:direct:+15553334444");
227+
228+
await store.cleanup();
229+
});
230+
231+
it("keeps the same direct contact isolated across WhatsApp accounts", async () => {
232+
const now = Date.now();
233+
const store = await makeSessionStore({});
234+
235+
const { handler, backgroundTasks } = createLastRouteHarness(store.storePath);
236+
237+
await handler(
238+
buildInboundMessage({
239+
id: "m1",
240+
from: "+15551112222",
241+
conversationId: "+15551112222",
242+
chatType: "direct",
243+
chatId: "direct:+15551112222",
244+
accountId: "personal",
245+
timestamp: now,
246+
senderE164: "+15551112222",
247+
}),
248+
);
249+
250+
await awaitBackgroundTasks(backgroundTasks);
251+
const firstSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey;
252+
253+
updateLastRouteInBackgroundMock.mockClear();
254+
255+
await handler(
256+
buildInboundMessage({
257+
id: "m2",
258+
from: "+15551112222",
259+
conversationId: "+15551112222",
260+
chatType: "direct",
261+
chatId: "direct:+15551112222",
262+
accountId: "biz",
263+
timestamp: now + 1,
264+
senderE164: "+15551112222",
265+
}),
266+
);
267+
268+
await awaitBackgroundTasks(backgroundTasks);
269+
const secondSessionKey = updateLastRouteInBackgroundMock.mock.calls[0]?.[0]?.sessionKey;
270+
271+
expect(firstSessionKey).toBe("agent:main:whatsapp:personal:direct:+15551112222");
272+
expect(secondSessionKey).toBe("agent:main:whatsapp:biz:direct:+15551112222");
273+
274+
await store.cleanup();
275+
});
184276
});

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ describe("whatsapp inbound dispatch", () => {
816816
expect(updateLastRoute).toHaveBeenCalledTimes(1);
817817
});
818818

819-
it("does not update main last route for isolated DM scope sessions", () => {
819+
it("updates isolated DM last route for scoped session keys", () => {
820820
const updateLastRoute = vi.fn();
821821

822822
updateWhatsAppMainLastRoute({
@@ -826,14 +826,23 @@ describe("whatsapp inbound dispatch", () => {
826826
dmRouteTarget: "+3000",
827827
pinnedMainDmRecipient: null,
828828
route: makeRoute({
829-
sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000",
830-
mainSessionKey: "agent:main:whatsapp:direct:+1000",
829+
sessionKey: "agent:main:whatsapp:biz:direct:+3000",
830+
mainSessionKey: "agent:main:main",
831+
lastRoutePolicy: "session",
832+
accountId: "biz",
831833
}),
832834
updateLastRoute,
833835
warn: () => {},
834836
});
835837

836-
expect(updateLastRoute).not.toHaveBeenCalled();
838+
expect(updateLastRoute).toHaveBeenCalledTimes(1);
839+
expect(updateLastRoute).toHaveBeenCalledWith(
840+
expect.objectContaining({
841+
sessionKey: "agent:main:whatsapp:biz:direct:+3000",
842+
accountId: "biz",
843+
to: "+3000",
844+
}),
845+
);
837846
});
838847

839848
it("does not update main last route for non-owner sender when main DM scope is pinned", () => {

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,25 @@ export function updateWhatsAppMainLastRoute(params: {
256256
return;
257257
}
258258

259+
if (
260+
params.dmRouteTarget &&
261+
params.route.sessionKey !== params.route.mainSessionKey &&
262+
inboundLastRouteSessionKey === params.route.sessionKey
263+
) {
264+
params.updateLastRoute({
265+
cfg: params.cfg,
266+
backgroundTasks: params.backgroundTasks,
267+
storeAgentId: params.route.agentId,
268+
sessionKey: params.route.sessionKey,
269+
channel: "whatsapp",
270+
to: params.dmRouteTarget,
271+
accountId: params.route.accountId,
272+
ctx: params.ctx,
273+
warn: params.warn,
274+
});
275+
return;
276+
}
277+
259278
if (
260279
params.dmRouteTarget &&
261280
inboundLastRouteSessionKey === params.route.mainSessionKey &&

extensions/whatsapp/src/auto-reply/monitor/on-message.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export function createWebOnMessageHandler(params: {
9494
kind: msg.chatType === "group" ? "group" : "direct",
9595
id: peerId,
9696
},
97+
...(msg.chatType === "direct"
98+
? { dmScopeOverride: "per-account-channel-peer" as const }
99+
: {}),
97100
});
98101
const route =
99102
msg.chatType === "group" ? resolveWhatsAppGroupSessionRoute(baseRoute) : baseRoute;

src/routing/resolve-route.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,24 @@ describe("resolveAgentRoute", () => {
138138
});
139139
});
140140

141+
test("dmScopeOverride can force account-aware direct-message isolation", () => {
142+
const route = resolveAgentRoute({
143+
cfg: { session: { dmScope: "main" } },
144+
channel: "whatsapp",
145+
accountId: "biz",
146+
peer: { kind: "direct", id: "+15551234567" },
147+
dmScopeOverride: "per-account-channel-peer",
148+
});
149+
150+
expectResolvedRoute(route, {
151+
agentId: "main",
152+
accountId: "biz",
153+
sessionKey: "agent:main:whatsapp:biz:direct:+15551234567",
154+
lastRoutePolicy: "session",
155+
matchedBy: "default",
156+
});
157+
});
158+
141159
test("route binding session dmScope isolates selected direct peers without changing agent", () => {
142160
const cfg: OpenClawConfig = {
143161
session: { dmScope: "main" },

src/routing/resolve-route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export type ResolveAgentRouteInput = {
4141
teamId?: string | null;
4242
/** Discord member role IDs — used for role-based agent routing. */
4343
memberRoleIds?: string[];
44+
/**
45+
* Override cfg.session.dmScope for this route resolution only.
46+
* Channel plugins use this when their privacy contract needs stricter DM isolation.
47+
*/
48+
dmScopeOverride?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
4449
};
4550

4651
export type ResolvedAgentRoute = {
@@ -620,7 +625,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
620625
const teamId = normalizeId(input.teamId);
621626
const memberRoleIds = input.memberRoleIds ?? [];
622627
const memberRoleIdSet = new Set(memberRoleIds);
623-
const dmScope = input.cfg.session?.dmScope ?? "main";
628+
const dmScope = input.dmScopeOverride ?? input.cfg.session?.dmScope ?? "main";
624629
const identityLinks = input.cfg.session?.identityLinks;
625630
const shouldLogDebug = shouldLogVerbose();
626631
const parentPeer = input.parentPeer

0 commit comments

Comments
 (0)