Skip to content

Commit 458a526

Browse files
authored
fix(whatsapp): isolate multi-account inbound state and align shared defaults (#65700)
* refactor(whatsapp): centralize inbound policy resolution * fix(whatsapp): scope named-account group session keys * fix(whatsapp): preserve legacy group activation during scoped-key migration * fix(whatsapp): wire shared defaults through accounts.default * fix(whatsapp): align schema, helpers, and monitor behavior * fix(whatsapp): restore verbose inbound diagnostics * chore(config): refresh whatsapp changelog and baseline hashes
1 parent 996eb9a commit 458a526

47 files changed

Lines changed: 2159 additions & 390 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

1313
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
14+
- WhatsApp/multi-account: centralize named-account inbound policy, isolate per-account group activation and scoped session keys, preserve legacy activation backfill, and keep `accounts.default` shared defaults aligned across runtime, setup, and compat migration paths. Thanks @mcaxtr.
1415
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
1516
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
1617
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.

extensions/whatsapp/src/account-config.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
DEFAULT_ACCOUNT_ID,
3+
mergeAccountConfig,
34
resolveAccountEntry,
45
resolveMergedAccountConfig,
56
type OpenClawConfig,
@@ -10,25 +11,63 @@ import {
1011
} from "openclaw/plugin-sdk/channel-streaming";
1112
import type { WhatsAppAccountConfig } from "./account-types.js";
1213

14+
function resolveWhatsAppDefaultAccountSharedConfig(
15+
cfg: OpenClawConfig,
16+
): Partial<WhatsAppAccountConfig> | undefined {
17+
const defaultAccount = resolveAccountEntry(cfg.channels?.whatsapp?.accounts, DEFAULT_ACCOUNT_ID);
18+
if (!defaultAccount) {
19+
return undefined;
20+
}
21+
const {
22+
enabled: _ignoredEnabled,
23+
name: _ignoredName,
24+
authDir: _ignoredAuthDir,
25+
selfChatMode: _ignoredSelfChatMode,
26+
...sharedDefaults
27+
} = defaultAccount;
28+
return sharedDefaults;
29+
}
30+
1331
function _resolveWhatsAppAccountConfig(
1432
cfg: OpenClawConfig,
1533
accountId: string,
1634
): WhatsAppAccountConfig | undefined {
1735
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
1836
}
1937

38+
function resolveMergedNamedWhatsAppAccountConfig(params: {
39+
cfg: OpenClawConfig;
40+
accountId: string;
41+
}): WhatsAppAccountConfig {
42+
const rootCfg = params.cfg.channels?.whatsapp;
43+
const accountConfig = _resolveWhatsAppAccountConfig(params.cfg, params.accountId);
44+
return {
45+
...mergeAccountConfig<WhatsAppAccountConfig>({
46+
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
47+
accountConfig: undefined,
48+
omitKeys: ["defaultAccount"],
49+
}),
50+
...resolveWhatsAppDefaultAccountSharedConfig(params.cfg),
51+
...accountConfig,
52+
};
53+
}
54+
2055
export function resolveMergedWhatsAppAccountConfig(params: {
2156
cfg: OpenClawConfig;
2257
accountId?: string | null;
2358
}): WhatsAppAccountConfig & { accountId: string } {
2459
const rootCfg = params.cfg.channels?.whatsapp;
2560
const accountId = params.accountId?.trim() || rootCfg?.defaultAccount || DEFAULT_ACCOUNT_ID;
26-
const merged = resolveMergedAccountConfig<WhatsAppAccountConfig>({
61+
const base = resolveMergedAccountConfig<WhatsAppAccountConfig>({
2762
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
2863
accounts: rootCfg?.accounts as Record<string, Partial<WhatsAppAccountConfig>> | undefined,
2964
accountId,
3065
omitKeys: ["defaultAccount"],
3166
});
67+
const merged =
68+
accountId === DEFAULT_ACCOUNT_ID
69+
? base
70+
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
3271
return {
3372
accountId,
3473
...merged,

extensions/whatsapp/src/accounts.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,112 @@ describe("resolveWhatsAppAuthDir", () => {
7171
expect(resolved.messagePrefix).toBe("[root]");
7272
expect(resolved.debounceMs).toBe(250);
7373
});
74+
75+
it("inherits shared defaults from accounts.default for named accounts", () => {
76+
const resolved = resolveWhatsAppAccount({
77+
cfg: {
78+
channels: {
79+
whatsapp: {
80+
accounts: {
81+
default: {
82+
dmPolicy: "allowlist",
83+
allowFrom: ["+15550001111"],
84+
groupPolicy: "open",
85+
groupAllowFrom: ["+15550002222"],
86+
defaultTo: "+15550003333",
87+
reactionLevel: "extensive",
88+
historyLimit: 42,
89+
mediaMaxMb: 12,
90+
},
91+
work: {
92+
authDir: "/tmp/work",
93+
},
94+
},
95+
},
96+
},
97+
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
98+
accountId: "work",
99+
});
100+
101+
expect(resolved.dmPolicy).toBe("allowlist");
102+
expect(resolved.allowFrom).toEqual(["+15550001111"]);
103+
expect(resolved.groupPolicy).toBe("open");
104+
expect(resolved.groupAllowFrom).toEqual(["+15550002222"]);
105+
expect(resolved.defaultTo).toBe("+15550003333");
106+
expect(resolved.reactionLevel).toBe("extensive");
107+
expect(resolved.historyLimit).toBe(42);
108+
expect(resolved.mediaMaxMb).toBe(12);
109+
});
110+
111+
it("prefers account overrides and accounts.default over root defaults", () => {
112+
const resolved = resolveWhatsAppAccount({
113+
cfg: {
114+
channels: {
115+
whatsapp: {
116+
dmPolicy: "open",
117+
allowFrom: ["*"],
118+
groupPolicy: "disabled",
119+
accounts: {
120+
default: {
121+
dmPolicy: "allowlist",
122+
allowFrom: ["+15550001111"],
123+
groupPolicy: "open",
124+
},
125+
work: {
126+
authDir: "/tmp/work",
127+
dmPolicy: "pairing",
128+
},
129+
},
130+
},
131+
},
132+
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
133+
accountId: "work",
134+
});
135+
136+
expect(resolved.dmPolicy).toBe("pairing");
137+
expect(resolved.allowFrom).toEqual(["+15550001111"]);
138+
expect(resolved.groupPolicy).toBe("open");
139+
});
140+
141+
it("does not inherit default-account authDir for named accounts", () => {
142+
const resolved = resolveWhatsAppAccount({
143+
cfg: {
144+
channels: {
145+
whatsapp: {
146+
accounts: {
147+
default: {
148+
authDir: "/tmp/default-auth",
149+
name: "Personal",
150+
},
151+
work: {},
152+
},
153+
},
154+
},
155+
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
156+
accountId: "work",
157+
});
158+
159+
expect(resolved.authDir).toMatch(/whatsapp[/\\]work$/);
160+
expect(resolved.name).toBeUndefined();
161+
});
162+
163+
it("does not inherit default-account selfChatMode for named accounts", () => {
164+
const resolved = resolveWhatsAppAccount({
165+
cfg: {
166+
channels: {
167+
whatsapp: {
168+
accounts: {
169+
default: {
170+
selfChatMode: true,
171+
},
172+
work: {},
173+
},
174+
},
175+
},
176+
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
177+
accountId: "work",
178+
});
179+
180+
expect(resolved.selfChatMode).toBeUndefined();
181+
});
74182
});

extensions/whatsapp/src/accounts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type ResolvedWhatsAppAccount = {
2828
groupAllowFrom?: string[];
2929
groupPolicy?: GroupPolicy;
3030
dmPolicy?: DmPolicy;
31+
historyLimit?: number;
3132
textChunkLimit?: number;
3233
chunkMode?: "length" | "newline";
3334
mediaMaxMb?: number;
@@ -141,6 +142,7 @@ export function resolveWhatsAppAccount(params: {
141142
allowFrom: merged.allowFrom,
142143
groupAllowFrom: merged.groupAllowFrom,
143144
groupPolicy: merged.groupPolicy,
145+
historyLimit: merged.historyLimit,
144146
textChunkLimit: merged.textChunkLimit,
145147
chunkMode: merged.chunkMode,
146148
mediaMaxMb: merged.mediaMaxMb,

extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,57 @@ describe("broadcast groups", () => {
138138
resetLoadConfigMock();
139139
});
140140

141+
it("keeps named-account group broadcast routes on the scoped session key", async () => {
142+
setLoadConfigMock({
143+
channels: {
144+
whatsapp: {
145+
allowFrom: ["*"],
146+
accounts: {
147+
work: {
148+
allowFrom: ["*"],
149+
},
150+
},
151+
},
152+
},
153+
agents: {
154+
defaults: { maxConcurrent: 10 },
155+
list: [{ id: "alfred" }, { id: "baerbel" }],
156+
},
157+
broadcast: {
158+
strategy: "sequential",
159+
"123@g.us": ["alfred", "baerbel"],
160+
},
161+
} satisfies OpenClawConfig);
162+
163+
const seen: string[] = [];
164+
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
165+
seen.push(String(ctx.SessionKey));
166+
return { text: "ok" };
167+
});
168+
169+
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
170+
171+
await sendWebGroupInboundMessage({
172+
onMessage,
173+
spies,
174+
body: "@bot ping",
175+
id: "g-work-1",
176+
senderE164: "+111",
177+
senderName: "Alice",
178+
mentionedJids: ["999@s.whatsapp.net"],
179+
selfE164: "+999",
180+
selfJid: "999@s.whatsapp.net",
181+
accountId: "work",
182+
});
183+
184+
expect(resolver).toHaveBeenCalledTimes(2);
185+
expect(seen).toEqual([
186+
"agent:alfred:whatsapp:group:123@g.us:thread:whatsapp-account-work",
187+
"agent:baerbel:whatsapp:group:123@g.us:thread:whatsapp-account-work",
188+
]);
189+
resetLoadConfigMock();
190+
});
191+
141192
it("broadcasts in parallel by default", async () => {
142193
setLoadConfigMock({
143194
channels: { whatsapp: { allowFrom: ["*"] } },

extensions/whatsapp/src/auto-reply.test-harness.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
resetLoadConfigMock as _resetLoadConfigMock,
1313
} from "./test-helpers.js";
1414

15-
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
15+
export {
16+
resetBaileysMocks,
17+
resetLoadConfigMock,
18+
setLoadConfigMock,
19+
setRuntimeConfigSourceSnapshotMock,
20+
} from "./test-helpers.js";
1621

1722
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
1823
type AnyExport = any;
@@ -179,16 +184,27 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
179184

180185
export function createWebListenerFactoryCapture(): AnyExport {
181186
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
187+
let capturedOptions:
188+
| {
189+
onMessage: (msg: WebInboundMessage) => Promise<void>;
190+
debounceMs?: number;
191+
selfChatMode?: boolean;
192+
}
193+
| undefined;
182194
const listenerFactory = async (opts: {
183195
onMessage: (msg: WebInboundMessage) => Promise<void>;
196+
debounceMs?: number;
197+
selfChatMode?: boolean;
184198
}) => {
185199
capturedOnMessage = opts.onMessage;
200+
capturedOptions = opts;
186201
return { close: vi.fn() };
187202
};
188203

189204
return {
190205
listenerFactory,
191206
getOnMessage: () => capturedOnMessage,
207+
getLastOptions: () => capturedOptions,
192208
};
193209
}
194210

0 commit comments

Comments
 (0)