fix(whatsapp): isolate direct sessions by account

This commit is contained in:
Peter Steinberger
2026-05-03 15:28:34 +01:00
parent 0949f4fe51
commit 6523eb7618
8 changed files with 153 additions and 6 deletions

View File

@@ -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:<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.
- 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.

View File

@@ -156,7 +156,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
- 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.
- 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:<agentId>:whatsapp:<accountId>:direct:<peerId>`), so distinct contacts and separate WhatsApp accounts do not share session files or model context.
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
- 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.
- 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.

View File

@@ -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();
});
});

View File

@@ -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", () => {

View File

@@ -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 &&

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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