fix(whatsapp): resolve configured default account in single-arg setActiveWebListener overload (#53918)

Merged via squash.

Prepared head SHA: ad9be63835
Co-authored-by: yhyatt <10474956+yhyatt@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
Yonatan
2026-04-11 05:25:16 +02:00
committed by GitHub
parent 959b1472dc
commit 38cd7f72b6
3 changed files with 105 additions and 9 deletions

View File

@@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- WhatsApp: honor the configured default account when the active listener helper is used without an explicit account id, so named default accounts do not get registered under `default`. (#53918) Thanks @yhyatt.
## 2026.4.10
### Changes

View File

@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock loadConfig so the single-arg setActiveWebListener overload resolves
// the configured default account as "work" (matching the regression test).
// All other tests pass explicit accountIds and are unaffected by this mock.
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
loadConfig: () => ({
channels: { whatsapp: { accounts: { work: { enabled: true } }, defaultAccount: "work" } },
}),
}));
type ActiveListenerModule = typeof import("./active-listener.js");
const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href;
@@ -12,18 +21,28 @@ afterEach(async () => {
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
mod.setActiveWebListener(null);
mod.setActiveWebListener("work", null);
mod.setActiveWebListener("default", null);
});
/** Minimal listener stub */
function makeListener() {
return {
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
sendReaction: vi.fn(async () => {}),
sendComposingTo: vi.fn(async () => {}),
};
}
describe("active WhatsApp listener singleton", () => {
it("shares listeners across duplicate module instances", async () => {
it("shares listeners across duplicate module instances (bundle-fragmentation fix)", async () => {
// Simulates the scenario where two bundled copies of active-listener.ts are loaded
// (e.g. channel-web-*.js calls setActiveWebListener, outbound-*.js calls
// requireActiveWebListener). Without resolveGlobalSingleton they would each hold
// their own Map and the listener would never be found by the outbound path.
const first = await importActiveListenerModule(`first-${Date.now()}`);
const second = await importActiveListenerModule(`second-${Date.now()}`);
const listener = {
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
sendReaction: vi.fn(async () => {}),
sendComposingTo: vi.fn(async () => {}),
};
const listener = makeListener();
first.setActiveWebListener("work", listener);
@@ -33,4 +52,74 @@ describe("active WhatsApp listener singleton", () => {
listener,
});
});
it("single-arg overload registers under configured default account, not always 'default'", async () => {
// Regression: setActiveWebListener(listener) used DEFAULT_ACCOUNT_ID ("default")
// even when the configured default account is named "work". This caused
// requireActiveWebListener("work") to throw while the listener was silently
// registered under the wrong key.
const mod = await importActiveListenerModule(`named-account-${Date.now()}`);
const listener = makeListener();
// Single-arg call — should resolve accountId from loadConfig() default, which
// vitest config maps to "work" (see mock below).
mod.setActiveWebListener(listener);
// "work" must be resolvable — previously this threw
expect(mod.requireActiveWebListener("work")).toEqual({
accountId: "work",
listener,
});
});
it("single-arg overload still works when default account is 'default'", async () => {
// Backward-compat: configs that rely on the "default" account name must
// continue to work after the fix. Use single-arg overload with a temporary
// spy that returns "default" as the configured default account.
const configRuntime = await import("openclaw/plugin-sdk/config-runtime");
const spy = vi.spyOn(configRuntime, "loadConfig").mockReturnValue({
channels: {
whatsapp: { accounts: { default: { enabled: true } }, defaultAccount: "default" },
},
} as ReturnType<typeof configRuntime.loadConfig>);
try {
const mod = await importActiveListenerModule(`default-account-${Date.now()}`);
const listener = makeListener();
// Single-arg call — should resolve to "default" via the spy
mod.setActiveWebListener(listener);
expect(mod.requireActiveWebListener("default")).toEqual({
accountId: "default",
listener,
});
// The legacy no-arg lookup (undefined → "default") must also work
expect(mod.requireActiveWebListener()).toEqual({
accountId: "default",
listener,
});
} finally {
spy.mockRestore();
}
});
it("requireActiveWebListener throws a clear error when listener is missing", async () => {
const mod = await importActiveListenerModule(`missing-${Date.now()}`);
expect(() => mod.requireActiveWebListener("work")).toThrowError(
/No active WhatsApp Web listener \(account: work\)/,
);
});
it("setActiveWebListener with null removes the listener", async () => {
const mod = await importActiveListenerModule(`remove-${Date.now()}`);
const listener = makeListener();
mod.setActiveWebListener("work", listener);
expect(mod.getActiveWebListener("work")).toBe(listener);
mod.setActiveWebListener("work", null);
expect(mod.getActiveWebListener("work")).toBeNull();
});
});

View File

@@ -1,6 +1,8 @@
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import type { PollInput } from "openclaw/plugin-sdk/media-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { resolveDefaultWhatsAppAccountId } from "./accounts.js";
export type ActiveWebSendOptions = {
gifPlayback?: boolean;
@@ -53,7 +55,7 @@ function setCurrentListener(listener: ActiveWebListener | null): void {
}
export function resolveWebAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
return (accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(loadConfig());
}
export function requireActiveWebListener(accountId?: string | null): {
@@ -83,7 +85,10 @@ export function setActiveWebListener(
typeof accountIdOrListener === "string"
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
: {
accountId: DEFAULT_ACCOUNT_ID,
// Resolve the configured default account name so that callers using the
// single-arg overload register under the right key (e.g. "work"), not
// always under DEFAULT_ACCOUNT_ID ("default").
accountId: resolveDefaultWhatsAppAccountId(loadConfig()),
listener: accountIdOrListener ?? null,
};