Skip to content

Commit aa023e4

Browse files
mcaxtrvincentkoc
andauthored
refactor(whatsapp): centralize account connection lifecycle (#65427)
* refactor(whatsapp): centralize account connection lifecycle * fix(whatsapp): harden controller open failure cleanup * refactor(whatsapp): remove active listener fallback path * fix(whatsapp): isolate controller registry state * debug(whatsapp): trace typing presence updates * docs(changelog): add whatsapp lifecycle fix note * debug(whatsapp): log global presence mode * chore(whatsapp): remove debug presence logs --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent c37e49f commit aa023e4

19 files changed

Lines changed: 1353 additions & 729 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
5151
- Memory/QMD: allow channel sessions in the shipped default QMD scope, while still denying groups.
5252
- Memory/QMD: stop registering the legacy lowercase root memory file as a separate default collection, so QMD now prefers `MEMORY.md` and the `memory/` tree without duplicate collection-add warnings.
5353
- Memory/memory-core: watch the `memory` directory directly and ignore non-markdown churn so nested note changes still sync on macOS + Node 25 environments where recursive `memory/**/*.md` glob watching fails. (#64711) Thanks @jasonxargs-boop and @vincentkoc.
54+
- WhatsApp: centralize per-account connection ownership so reconnects, login recovery, and outbound readiness stay attached to the live socket instead of drifting across monitor and login paths. (#65290) Thanks @mcaxtr and @vincentkoc.
5455

5556
## 2026.4.11
5657

Lines changed: 35 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22

3-
// Mock loadConfig so the single-arg setActiveWebListener overload resolves
4-
// the configured default account as "work" (matching the regression test).
5-
// All other tests pass explicit accountIds and are unaffected by this mock.
63
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
74
loadConfig: () => ({
85
channels: { whatsapp: { accounts: { work: { enabled: true } }, defaultAccount: "work" } },
@@ -17,14 +14,6 @@ async function importActiveListenerModule(cacheBust: string): Promise<ActiveList
1714
return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule;
1815
}
1916

20-
afterEach(async () => {
21-
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
22-
mod.setActiveWebListener(null);
23-
mod.setActiveWebListener("work", null);
24-
mod.setActiveWebListener("default", null);
25-
});
26-
27-
/** Minimal listener stub */
2817
function makeListener() {
2918
return {
3019
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
@@ -34,92 +23,53 @@ function makeListener() {
3423
};
3524
}
3625

37-
describe("active WhatsApp listener singleton", () => {
38-
it("shares listeners across duplicate module instances (bundle-fragmentation fix)", async () => {
39-
// Simulates the scenario where two bundled copies of active-listener.ts are loaded
40-
// (e.g. channel-web-*.js calls setActiveWebListener, outbound-*.js calls
41-
// requireActiveWebListener). Without resolveGlobalSingleton they would each hold
42-
// their own Map and the listener would never be found by the outbound path.
43-
const first = await importActiveListenerModule(`first-${Date.now()}`);
44-
const second = await importActiveListenerModule(`second-${Date.now()}`);
26+
afterEach(() => {
27+
vi.doUnmock("./connection-controller-registry.js");
28+
});
29+
30+
describe("active WhatsApp listener view", () => {
31+
it("reads controller-backed state across duplicate module instances", async () => {
4532
const listener = makeListener();
33+
vi.doMock("./connection-controller-registry.js", () => ({
34+
getRegisteredWhatsAppConnectionController: (accountId: string) =>
35+
accountId === "work"
36+
? {
37+
getActiveListener: () => listener,
38+
}
39+
: null,
40+
}));
4641

47-
first.setActiveWebListener("work", listener);
42+
const first = await importActiveListenerModule(`first-${Date.now()}`);
43+
const second = await importActiveListenerModule(`second-${Date.now()}`);
4844

45+
expect(first.getActiveWebListener("work")).toBe(listener);
4946
expect(second.getActiveWebListener("work")).toBe(listener);
50-
expect(second.requireActiveWebListener("work")).toEqual({
51-
accountId: "work",
52-
listener,
53-
});
5447
});
5548

56-
it("single-arg overload registers under configured default account, not always 'default'", async () => {
57-
// Regression: setActiveWebListener(listener) used DEFAULT_ACCOUNT_ID ("default")
58-
// even when the configured default account is named "work". This caused
59-
// requireActiveWebListener("work") to throw while the listener was silently
60-
// registered under the wrong key.
61-
const mod = await importActiveListenerModule(`named-account-${Date.now()}`);
49+
it("resolves the configured default account when accountId is omitted", async () => {
6250
const listener = makeListener();
63-
64-
// Single-arg call — should resolve accountId from loadConfig() default, which
65-
// vitest config maps to "work" (see mock below).
66-
mod.setActiveWebListener(listener);
67-
68-
// "work" must be resolvable — previously this threw
69-
expect(mod.requireActiveWebListener("work")).toEqual({
70-
accountId: "work",
71-
listener,
72-
});
51+
vi.doMock("./connection-controller-registry.js", () => ({
52+
getRegisteredWhatsAppConnectionController: (accountId: string) =>
53+
accountId === "work"
54+
? {
55+
getActiveListener: () => listener,
56+
}
57+
: null,
58+
}));
59+
60+
const mod = await importActiveListenerModule(`default-${Date.now()}`);
61+
62+
expect(mod.resolveWebAccountId()).toBe("work");
63+
expect(mod.getActiveWebListener()).toBe(listener);
7364
});
7465

75-
it("single-arg overload still works when default account is 'default'", async () => {
76-
// Backward-compat: configs that rely on the "default" account name must
77-
// continue to work after the fix. Use single-arg overload with a temporary
78-
// spy that returns "default" as the configured default account.
79-
const configRuntime = await import("openclaw/plugin-sdk/config-runtime");
80-
const spy = vi.spyOn(configRuntime, "loadConfig").mockReturnValue({
81-
channels: {
82-
whatsapp: { accounts: { default: { enabled: true } }, defaultAccount: "default" },
83-
},
84-
} as ReturnType<typeof configRuntime.loadConfig>);
66+
it("returns null when the controller has no active listener for the account", async () => {
67+
vi.doMock("./connection-controller-registry.js", () => ({
68+
getRegisteredWhatsAppConnectionController: () => null,
69+
}));
8570

86-
try {
87-
const mod = await importActiveListenerModule(`default-account-${Date.now()}`);
88-
const listener = makeListener();
89-
90-
// Single-arg call — should resolve to "default" via the spy
91-
mod.setActiveWebListener(listener);
92-
93-
expect(mod.requireActiveWebListener("default")).toEqual({
94-
accountId: "default",
95-
listener,
96-
});
97-
// The legacy no-arg lookup (undefined → "default") must also work
98-
expect(mod.requireActiveWebListener()).toEqual({
99-
accountId: "default",
100-
listener,
101-
});
102-
} finally {
103-
spy.mockRestore();
104-
}
105-
});
106-
107-
it("requireActiveWebListener throws a clear error when listener is missing", async () => {
10871
const mod = await importActiveListenerModule(`missing-${Date.now()}`);
10972

110-
expect(() => mod.requireActiveWebListener("work")).toThrowError(
111-
/No active WhatsApp Web listener \(account: work\)/,
112-
);
113-
});
114-
115-
it("setActiveWebListener with null removes the listener", async () => {
116-
const mod = await importActiveListenerModule(`remove-${Date.now()}`);
117-
const listener = makeListener();
118-
119-
mod.setActiveWebListener("work", listener);
120-
expect(mod.getActiveWebListener("work")).toBe(listener);
121-
122-
mod.setActiveWebListener("work", null);
12373
expect(mod.getActiveWebListener("work")).toBeNull();
12474
});
12575
});
Lines changed: 4 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,15 @@
1-
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
21
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
3-
import type { PollInput } from "openclaw/plugin-sdk/media-runtime";
4-
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
52
import { resolveDefaultWhatsAppAccountId } from "./accounts.js";
3+
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
4+
import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
65

7-
export type ActiveWebSendOptions = {
8-
gifPlayback?: boolean;
9-
accountId?: string;
10-
fileName?: string;
11-
};
12-
13-
export type ActiveWebListener = {
14-
sendMessage: (
15-
to: string,
16-
text: string,
17-
mediaBuffer?: Buffer,
18-
mediaType?: string,
19-
options?: ActiveWebSendOptions,
20-
) => Promise<{ messageId: string }>;
21-
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
22-
sendReaction: (
23-
chatJid: string,
24-
messageId: string,
25-
emoji: string,
26-
fromMe: boolean,
27-
participant?: string,
28-
) => Promise<void>;
29-
sendComposingTo: (to: string) => Promise<void>;
30-
close?: () => Promise<void>;
31-
};
32-
33-
// WhatsApp shares a live Baileys socket between inbound and outbound runtime
34-
// chunks. Keep this on a direct globalThis symbol lookup; the generic
35-
// singleton helper was previously inlined during code-splitting and split the
36-
// listener state back into per-chunk Maps.
37-
const WHATSAPP_ACTIVE_LISTENER_STATE_KEY = Symbol.for("openclaw.whatsapp.activeListenerState");
38-
39-
type ActiveListenerState = {
40-
listeners: Map<string, ActiveWebListener>;
41-
current: ActiveWebListener | null;
42-
};
43-
44-
const g = globalThis as unknown as Record<symbol, ActiveListenerState | undefined>;
45-
if (!g[WHATSAPP_ACTIVE_LISTENER_STATE_KEY]) {
46-
g[WHATSAPP_ACTIVE_LISTENER_STATE_KEY] = {
47-
listeners: new Map<string, ActiveWebListener>(),
48-
current: null,
49-
};
50-
}
51-
const state = g[WHATSAPP_ACTIVE_LISTENER_STATE_KEY];
52-
53-
function setCurrentListener(listener: ActiveWebListener | null): void {
54-
state.current = listener;
55-
}
6+
export type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
567

578
export function resolveWebAccountId(accountId?: string | null): string {
589
return (accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(loadConfig());
5910
}
6011

61-
export function requireActiveWebListener(accountId?: string | null): {
62-
accountId: string;
63-
listener: ActiveWebListener;
64-
} {
65-
const id = resolveWebAccountId(accountId);
66-
const listener = state.listeners.get(id) ?? null;
67-
if (!listener) {
68-
throw new Error(
69-
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`openclaw channels login --channel whatsapp --account ${id}`)}.`,
70-
);
71-
}
72-
return { accountId: id, listener };
73-
}
74-
75-
export function setActiveWebListener(listener: ActiveWebListener | null): void;
76-
export function setActiveWebListener(
77-
accountId: string | null | undefined,
78-
listener: ActiveWebListener | null,
79-
): void;
80-
export function setActiveWebListener(
81-
accountIdOrListener: string | ActiveWebListener | null | undefined,
82-
maybeListener?: ActiveWebListener | null,
83-
): void {
84-
const { accountId, listener } =
85-
typeof accountIdOrListener === "string"
86-
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
87-
: {
88-
// Resolve the configured default account name so that callers using the
89-
// single-arg overload register under the right key (e.g. "work"), not
90-
// always under DEFAULT_ACCOUNT_ID ("default").
91-
accountId: resolveDefaultWhatsAppAccountId(loadConfig()),
92-
listener: accountIdOrListener ?? null,
93-
};
94-
95-
const id = resolveWebAccountId(accountId);
96-
if (!listener) {
97-
state.listeners.delete(id);
98-
} else {
99-
state.listeners.set(id, listener);
100-
}
101-
if (id === DEFAULT_ACCOUNT_ID) {
102-
setCurrentListener(listener);
103-
}
104-
}
105-
10612
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
10713
const id = resolveWebAccountId(accountId);
108-
return state.listeners.get(id) ?? null;
14+
return getRegisteredWhatsAppConnectionController(id)?.getActiveListener() ?? null;
10915
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ type WebAutoReplyMonitorHarness = {
4040

4141
export const TEST_NET_IP = "93.184.216.34";
4242

43+
vi.mock("./session.js", async () => {
44+
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
45+
return {
46+
...actual,
47+
createWaSocket: vi.fn(async () => ({
48+
ev: {
49+
on: vi.fn(),
50+
off: vi.fn(),
51+
},
52+
ws: { close: vi.fn() },
53+
user: { id: "[email protected]" },
54+
})),
55+
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
56+
};
57+
});
58+
4359
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
4460
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
4561
appendCronStyleCurrentTimeLine: (text: string) => text,

extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ async function startWatchdogScenario(params: {
3636
watchdogCheckMs: 5,
3737
});
3838

39-
await Promise.resolve();
40-
expect(scripted.getListenerCount()).toBe(1);
39+
await vi.waitFor(
40+
() => {
41+
expect(scripted.getListenerCount()).toBe(1);
42+
},
43+
{ timeout: 250, interval: 2 },
44+
);
4145
await vi.waitFor(
4246
() => {
4347
expect(scripted.getOnMessage()).toBeTypeOf("function");
@@ -95,8 +99,12 @@ describe("web auto-reply connection", () => {
9599
reconnect: scenario.reconnect,
96100
});
97101

98-
await Promise.resolve();
99-
expect(scripted.getListenerCount()).toBe(1);
102+
await vi.waitFor(
103+
() => {
104+
expect(scripted.getListenerCount()).toBe(1);
105+
},
106+
{ timeout: 250, interval: 2 },
107+
);
100108

101109
scripted.resolveClose(0);
102110
await vi.waitFor(
@@ -130,8 +138,12 @@ describe("web auto-reply connection", () => {
130138
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
131139
});
132140

133-
await Promise.resolve();
134-
expect(scripted.getListenerCount()).toBe(1);
141+
await vi.waitFor(
142+
() => {
143+
expect(scripted.getListenerCount()).toBe(1);
144+
},
145+
{ timeout: 250, interval: 2 },
146+
);
135147
scripted.resolveClose(0, {
136148
status: 440,
137149
isLoggedOut: false,

0 commit comments

Comments
 (0)