refactor(whatsapp): use runtime context for connection controllers (#104949)

* refactor(whatsapp): replace connection controller registry

* fix(whatsapp): remove controller context import cycle
This commit is contained in:
Marcus Castro
2026-07-12 14:22:57 -03:00
committed by GitHub
parent 7c5b66a1e4
commit 80d11f1246
14 changed files with 148 additions and 175 deletions
+15 -11
View File
@@ -2,13 +2,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getActiveWebListener, resolveWebAccountId } from "./active-listener.js";
const registryMocks = vi.hoisted(() => ({
getRegisteredWhatsAppConnectionController: vi.fn(),
const runtimeContextMocks = vi.hoisted(() => ({
channelRuntime: { runtimeContexts: {} },
getChannelRuntimeContext: vi.fn(),
}));
vi.mock("./connection-controller-registry.js", () => ({
getRegisteredWhatsAppConnectionController:
registryMocks.getRegisteredWhatsAppConnectionController,
vi.mock("openclaw/plugin-sdk/channel-runtime-context", () => ({
getChannelRuntimeContext: runtimeContextMocks.getChannelRuntimeContext,
}));
vi.mock("./runtime.js", () => ({
getOptionalWhatsAppRuntime: () => ({ channel: runtimeContextMocks.channelRuntime }),
}));
const WHATSAPP_ACTIVE_LISTENER_TEST_CFG = {
@@ -25,14 +29,14 @@ function makeListener() {
}
beforeEach(() => {
registryMocks.getRegisteredWhatsAppConnectionController.mockReset();
runtimeContextMocks.getChannelRuntimeContext.mockReset();
});
describe("active WhatsApp listener view", () => {
it("reads controller-backed state", () => {
const listener = makeListener();
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
(accountId: string) =>
runtimeContextMocks.getChannelRuntimeContext.mockImplementation(
({ accountId }: { accountId?: string }) =>
accountId === "work"
? {
getActiveListener: () => listener,
@@ -45,8 +49,8 @@ describe("active WhatsApp listener view", () => {
it("resolves the configured default account when accountId is omitted", () => {
const listener = makeListener();
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
(accountId: string) =>
runtimeContextMocks.getChannelRuntimeContext.mockImplementation(
({ accountId }: { accountId?: string }) =>
accountId === "work"
? {
getActiveListener: () => listener,
@@ -59,7 +63,7 @@ describe("active WhatsApp listener view", () => {
});
it("returns null when the controller has no active listener for the account", () => {
registryMocks.getRegisteredWhatsAppConnectionController.mockReturnValue(null);
runtimeContextMocks.getChannelRuntimeContext.mockReturnValue(undefined);
expect(getActiveWebListener("work")).toBeNull();
});
+2 -2
View File
@@ -1,7 +1,7 @@
// Whatsapp plugin module implements active listener behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveDefaultWhatsAppAccountId } from "./account-ids.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { getWhatsAppConnectionController } from "./connection-controller-runtime-context.js";
import type { ActiveWebListener } from "./inbound/types.js";
export type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
@@ -14,5 +14,5 @@ export function resolveWebAccountId(params: {
}
export function getActiveWebListener(accountId: string): ActiveWebListener | null {
return getRegisteredWhatsAppConnectionController(accountId)?.getActiveListener() ?? null;
return getWhatsAppConnectionController(accountId)?.getActiveListener() ?? null;
}
@@ -5,11 +5,15 @@ import path from "node:path";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWhatsAppCallTool, testing } from "./agent-tools-call.js";
import {
getRegisteredWhatsAppConnectionController,
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
const runtimeContextMocks = vi.hoisted(() => ({
controllers: new Map<string, unknown>(),
}));
vi.mock("./connection-controller-runtime-context.js", () => ({
getWhatsAppConnectionController: (accountId: string) =>
runtimeContextMocks.controllers.get(accountId) ?? null,
}));
function createApi(params?: {
speech?: Partial<
@@ -62,6 +66,7 @@ describe("WhatsApp call tool", () => {
let stateDir: string;
beforeEach(async () => {
runtimeContextMocks.controllers.clear();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-call-test-"));
});
@@ -176,7 +181,7 @@ describe("WhatsApp call tool", () => {
}) as never,
getSelfIdentity: () => null,
};
registerWhatsAppConnectionController("default", controller);
runtimeContextMocks.controllers.set("default", controller);
try {
await expect(
testing.resolveRequesterE164({
@@ -185,9 +190,8 @@ describe("WhatsApp call tool", () => {
requesterSenderId: "123456789@lid",
}),
).resolves.toBe("+15551234567");
expect(getRegisteredWhatsAppConnectionController("default")).toBe(controller);
} finally {
unregisterWhatsAppConnectionController("default", controller);
runtimeContextMocks.controllers.delete("default");
}
});
@@ -198,7 +202,7 @@ describe("WhatsApp call tool", () => {
getCurrentSock: () => null,
getSelfIdentity: () => ({ e164: "+15551234567" }),
};
registerWhatsAppConnectionController("default", controller);
runtimeContextMocks.controllers.set("default", controller);
try {
const tool = testing.createWhatsAppCallToolWithDependencies(createApi(), createContext(), {
detectMeowCaller: async () => true,
@@ -208,7 +212,7 @@ describe("WhatsApp call tool", () => {
tool?.execute("call-self", { action: "call", message: "Hello" }),
).rejects.toThrow("WhatsApp cannot call the linked account itself");
} finally {
unregisterWhatsAppConnectionController("default", controller);
runtimeContextMocks.controllers.delete("default");
}
});
+3 -3
View File
@@ -15,7 +15,7 @@ import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { Type } from "typebox";
import { resolveWhatsAppAccount } from "./accounts.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { getWhatsAppConnectionController } from "./connection-controller-runtime-context.js";
import { resolveJidToE164 } from "./targets-runtime.js";
const MEOWCALLER_COMMAND = "meowcaller";
@@ -162,7 +162,7 @@ async function resolveRequesterE164(params: {
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const lidLookup = getRegisteredWhatsAppConnectionController(params.accountId)?.getCurrentSock()
const lidLookup = getWhatsAppConnectionController(params.accountId)?.getCurrentSock()
?.signalRepository.lidMapping;
return await resolveJidToE164(senderId, { authDir: account.authDir, lidLookup });
}
@@ -171,7 +171,7 @@ async function resolveLinkedWhatsAppSelfE164(params: {
accountId: string;
cfg: NonNullable<OpenClawPluginToolContext["config"]>;
}): Promise<string | null> {
const controller = getRegisteredWhatsAppConnectionController(params.accountId);
const controller = getWhatsAppConnectionController(params.accountId);
if (!controller) {
return null;
}
@@ -1,30 +0,0 @@
// Whatsapp tests cover connection controller registry plugin behavior.
import { describe, expect, it, vi } from "vitest";
type RegistryModule = typeof import("./connection-controller-registry.js");
const registryModuleUrl = new URL("./connection-controller-registry.ts", import.meta.url).href;
async function importRegistryModule(cacheBust: string): Promise<RegistryModule> {
return (await import(`${registryModuleUrl}?t=${cacheBust}`)) as RegistryModule;
}
describe("WhatsApp connection controller registry", () => {
it("shares registered controllers across duplicate module instances", async () => {
const first = await importRegistryModule(`first-${Date.now()}`);
const second = await importRegistryModule(`second-${Date.now()}`);
const controller = {
getActiveListener: vi.fn(() => null),
getCurrentSock: vi.fn(() => null),
getSelfIdentity: vi.fn(() => null),
};
first.registerWhatsAppConnectionController("work", controller);
try {
expect(second.getRegisteredWhatsAppConnectionController("work")).toBe(controller);
} finally {
first.unregisterWhatsAppConnectionController("work", controller);
}
});
});
@@ -1,62 +0,0 @@
// Whatsapp plugin module implements connection controller registry behavior.
import type { WASocket } from "baileys";
import type { WhatsAppSelfIdentity } from "./identity.js";
import type { ActiveWebListener } from "./inbound/types.js";
type WhatsAppConnectionControllerHandle = {
getActiveListener(): ActiveWebListener | null;
getCurrentSock(): WASocket | null;
/**
* The self identity (jid + lid) of the controller's currently-authenticated
* socket, or `null` if the socket is not connected or not authenticated yet.
* Used as the session-identity guard for outbound socket fallback so an
* in-place relink to a different phone number is not silently accepted.
* Compared via `identitiesOverlap()` so JID-vs-LID and device-scoped JID
* differences between the two controllers' user records are normalized away.
*/
getSelfIdentity(): WhatsAppSelfIdentity | null;
};
type ConnectionRegistryState = {
controllers: Map<string, WhatsAppConnectionControllerHandle>;
};
const CONNECTION_REGISTRY_KEY = Symbol.for("openclaw.whatsapp.connectionControllerRegistry");
function getConnectionRegistryState(): ConnectionRegistryState {
const globalState = globalThis as typeof globalThis & {
[CONNECTION_REGISTRY_KEY]?: ConnectionRegistryState;
};
const existing = globalState[CONNECTION_REGISTRY_KEY];
if (existing) {
return existing;
}
const created: ConnectionRegistryState = {
controllers: new Map<string, WhatsAppConnectionControllerHandle>(),
};
globalState[CONNECTION_REGISTRY_KEY] = created;
return created;
}
export function getRegisteredWhatsAppConnectionController(
accountId: string,
): WhatsAppConnectionControllerHandle | null {
return getConnectionRegistryState().controllers.get(accountId) ?? null;
}
export function registerWhatsAppConnectionController(
accountId: string,
controller: WhatsAppConnectionControllerHandle,
): void {
getConnectionRegistryState().controllers.set(accountId, controller);
}
export function unregisterWhatsAppConnectionController(
accountId: string,
controller: WhatsAppConnectionControllerHandle,
): void {
const controllers = getConnectionRegistryState().controllers;
if (controllers.get(accountId) === controller) {
controllers.delete(accountId);
}
}
@@ -0,0 +1,26 @@
// Whatsapp plugin module exposes live connection controllers through the channel runtime.
import type { WASocket } from "baileys";
import { getChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import type { WhatsAppSelfIdentity } from "./identity.js";
import type { ActiveWebListener } from "./inbound/types.js";
import { getOptionalWhatsAppRuntime } from "./runtime.js";
export const WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY = "connection-controller";
export type WhatsAppConnectionControllerHandle = {
getActiveListener(): ActiveWebListener | null;
getCurrentSock(): WASocket | null;
getSelfIdentity(): WhatsAppSelfIdentity | null;
};
export function getWhatsAppConnectionController(
accountId: string,
): WhatsAppConnectionControllerHandle | null {
const context = getChannelRuntimeContext({
channelRuntime: getOptionalWhatsAppRuntime()?.channel,
channelId: "whatsapp",
accountId,
capability: WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY,
});
return (context as WhatsAppConnectionControllerHandle | undefined) ?? null;
}
@@ -5,7 +5,6 @@ import os from "node:os";
import path from "node:path";
import { DisconnectReason } from "baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import {
closeWaSocket,
waitForWhatsAppLoginResult,
@@ -32,10 +31,27 @@ vi.mock("./session.js", async () => {
};
});
const runtimeContextMocks = vi.hoisted(() => ({
channelRuntime: { runtimeContexts: {} },
register: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/channel-runtime-context", () => {
return {
getChannelRuntimeContext: vi.fn(),
registerChannelRuntimeContext: runtimeContextMocks.register,
};
});
vi.mock("./runtime.js", () => ({
getWhatsAppRuntime: () => ({ channel: runtimeContextMocks.channelRuntime }),
}));
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const logoutWebMock = vi.mocked(logoutWeb);
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
const registerChannelRuntimeContextMock = runtimeContextMocks.register;
function createListenerStub(messageId = "ok") {
return {
@@ -126,6 +142,7 @@ describe("WhatsAppConnectionController", () => {
beforeEach(() => {
vi.clearAllMocks();
registerChannelRuntimeContextMock.mockReturnValue({ dispose: vi.fn() });
logoutWebMock.mockResolvedValue(true);
readWebAuthExistsForDecisionMock
.mockReset()
@@ -580,6 +597,8 @@ describe("WhatsAppConnectionController", () => {
});
it("keeps the previous registered controller until a replacement listener is ready", async () => {
const disposeRuntimeContext = vi.fn();
registerChannelRuntimeContextMock.mockReturnValueOnce({ dispose: disposeRuntimeContext });
const liveController = new WhatsAppConnectionController({
accountId: "work",
authDir: "/tmp/wa-auth",
@@ -605,7 +624,14 @@ describe("WhatsAppConnectionController", () => {
createListener: async () => liveListener,
});
expect(getRegisteredWhatsAppConnectionController("work")).toBe(liveController);
expect(registerChannelRuntimeContextMock).toHaveBeenCalledWith({
channelRuntime: runtimeContextMocks.channelRuntime,
channelId: "whatsapp",
accountId: "work",
capability: "connection-controller",
context: liveController,
abortSignal: undefined,
});
const replacement = new WhatsAppConnectionController({
accountId: "work",
@@ -636,11 +662,12 @@ describe("WhatsAppConnectionController", () => {
}),
).rejects.toThrow("replacement failed");
expect(getRegisteredWhatsAppConnectionController("work")).toBe(liveController);
expect(registerChannelRuntimeContextMock).toHaveBeenCalledTimes(1);
} finally {
await replacement.shutdown();
await liveController.shutdown();
}
expect(disposeRuntimeContext).toHaveBeenCalledOnce();
});
it("tracks real websocket frame activity in the connection snapshot", async () => {
@@ -1,14 +1,13 @@
// Whatsapp plugin module implements connection controller behavior.
import type { GroupMetadata, WASocket, WAMessageKey, proto } from "baileys";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { info } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
import { WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY } from "./connection-controller-runtime-context.js";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
import type { ActiveWebListener, WebListenerCloseReason } from "./inbound/types.js";
import { computeBackoff, sleepWithAbort, type ReconnectPolicy } from "./reconnect.js";
import { getWhatsAppRuntime } from "./runtime.js";
import {
createWaSocket,
formatError,
@@ -426,6 +425,7 @@ export class WhatsAppConnectionController {
private readonly disconnectRetryController = new AbortController();
private current: WhatsAppLiveConnection | null = null;
private runtimeContextLease: { dispose: () => void } | null = null;
private reconnectAttempts = 0;
private lastHandledInboundAt: number | null = null;
@@ -592,6 +592,7 @@ export class WhatsAppConnectionController {
...(params.cachedGroupMetadata ? { cachedGroupMetadata: params.cachedGroupMetadata } : {}),
});
await waitForWaConnection(sock, { timeoutMs: this.socketTiming.connectTimeoutMs });
const channelRuntime = getWhatsAppRuntime().channel;
this.socketRef.current = sock;
const placeholderListener = {} as ManagedWhatsAppListener;
@@ -605,7 +606,20 @@ export class WhatsAppConnectionController {
connection.listener = listener;
this.current = connection;
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
registerWhatsAppConnectionController(this.accountId, this);
const previousRuntimeContextLease = this.runtimeContextLease;
// Outbound adapters and agent tools read this context outside the gateway account task,
// so the plugin-injected runtime is the shared owner for this internal capability.
this.runtimeContextLease = registerChannelRuntimeContext({
channelRuntime,
channelId: "whatsapp",
accountId: this.accountId,
capability: WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY,
context: this,
abortSignal: this.abortSignal,
});
// Publish the ready replacement before releasing the old lease. Runtime-context
// lease tokens keep stale disposal from unregistering the replacement.
previousRuntimeContextLease?.dispose();
this.startTimers(connection, {
onHeartbeat: params.onHeartbeat,
onWatchdogTimeout: params.onWatchdogTimeout,
@@ -791,7 +805,8 @@ export class WhatsAppConnectionController {
async shutdown(): Promise<void> {
this.stopDisconnectRetries();
await this.closeCurrentConnection();
unregisterWhatsAppConnectionController(this.accountId, this);
this.runtimeContextLease?.dispose();
this.runtimeContextLease = null;
}
private startTimers(
+2 -2
View File
@@ -27,7 +27,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { maybeResolveWhatsAppApprovalReaction } from "../approval-reactions.js";
import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js";
import { getRegisteredWhatsAppConnectionController } from "../connection-controller-registry.js";
import { getWhatsAppConnectionController } from "../connection-controller-runtime-context.js";
import { getPrimaryIdentityId, identitiesOverlap, resolveComparableIdentity } from "../identity.js";
import { addWhatsAppImagePreviewFields } from "../image-preview.js";
import { cacheInboundMessageMeta } from "../quoted-message.js";
@@ -445,7 +445,7 @@ export async function attachWebInboxToSocket(
if (!self.e164 && !self.jid && !self.lid) {
return null;
}
const successor = getRegisteredWhatsAppConnectionController(options.accountId);
const successor = getWhatsAppConnectionController(options.accountId);
if (!successor) {
return null;
}
@@ -5,10 +5,6 @@ import type { GroupMetadata, WAMessageKey } from "baileys";
import "./monitor-inbox.test-harness.js";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
import { WhatsAppRetryableInboundError } from "./inbound/dedupe.js";
import {
readWhatsAppBaileysCacheEntry,
@@ -35,7 +31,8 @@ import type { InboxOnMessage } from "./monitor-inbox.test-harness.js";
import { lookupInboundMessageMeta } from "./quoted-message.js";
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
const { imageOps, sleepWithAbortMock } = vi.hoisted(() => ({
const { controllerContexts, imageOps, sleepWithAbortMock } = vi.hoisted(() => ({
controllerContexts: new Map<string, unknown>(),
imageOps: {
getImageMetadata: vi.fn(),
resizeToJpeg: vi.fn(),
@@ -43,6 +40,11 @@ const { imageOps, sleepWithAbortMock } = vi.hoisted(() => ({
sleepWithAbortMock: vi.fn(async (_ms: number, _signal?: AbortSignal) => undefined),
}));
vi.mock("./connection-controller-runtime-context.js", () => ({
WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY: "connection-controller",
getWhatsAppConnectionController: (accountId: string) => controllerContexts.get(accountId) ?? null,
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
"openclaw/plugin-sdk/media-runtime",
@@ -200,6 +202,7 @@ describe("web monitor inbox", () => {
installWebMonitorInboxUnitTestHooks();
beforeEach(() => {
controllerContexts.clear();
imageOps.getImageMetadata.mockReset();
imageOps.getImageMetadata.mockResolvedValue(null);
imageOps.resizeToJpeg.mockReset();
@@ -1560,22 +1563,12 @@ describe("web monitor inbox", () => {
await waitForMessageCalls(onMessage, 1);
const inbound = inboundMessage(onMessage);
// The mock harness socket exposes user.id = "123@s.whatsapp.net"; the
// successor handle must report a self identity that overlaps that JID
// so the session-safety guard accepts the fallback.
const handleA = {
getActiveListener: () => null,
getCurrentSock: () => null,
getSelfIdentity: () => null,
} as never;
registerWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleA);
// === Simulate health-monitor-driven shutdown of controller A ===
socketRefA.current = null;
aShouldRetryDisconnect = false;
unregisterWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleA);
// === Successor controller B comes up with its OWN socket and registers ===
// The mock harness socket exposes user.id = "123@s.whatsapp.net"; the
// successor must report an overlapping identity for the handoff to succeed.
const sockB = {
sendMessage: vi.fn(async () => ({ key: { id: "post-restart-msg-id" } })),
};
@@ -1584,13 +1577,13 @@ describe("web monitor inbox", () => {
getCurrentSock: () => sockB as never,
getSelfIdentity: () => ({ jid: "123@s.whatsapp.net", lid: null }),
} as never;
registerWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleB);
controllerContexts.set(DEFAULT_ACCOUNT_ID, handleB);
try {
await inbound.reply("pong");
await inbound.sendMedia({ text: "media after restart" });
// Captured A reply routed through B via the registry handle.
// Captured A reply routed through B via the runtime context.
expect(sockB.sendMessage).toHaveBeenCalledTimes(2);
expect(sockB.sendMessage).toHaveBeenNthCalledWith(1, "999@s.whatsapp.net", {
text: "pong",
@@ -1599,7 +1592,7 @@ describe("web monitor inbox", () => {
text: "media after restart",
});
} finally {
unregisterWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleB);
controllerContexts.delete(DEFAULT_ACCOUNT_ID);
await listenerA.close();
}
});
@@ -1653,14 +1646,14 @@ describe("web monitor inbox", () => {
getCurrentSock: () => sockB as never,
getSelfIdentity: () => ({ jid: null, lid: "12300:1@lid", e164: sharedE164 }),
} as never;
registerWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleB);
controllerContexts.set(DEFAULT_ACCOUNT_ID, handleB);
try {
await inbound.reply("pong");
expect(sockB.sendMessage).toHaveBeenCalledTimes(1);
expect(sockB.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: "pong" });
} finally {
unregisterWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleB);
controllerContexts.delete(DEFAULT_ACCOUNT_ID);
await listenerA.close();
}
});
@@ -1708,7 +1701,7 @@ describe("web monitor inbox", () => {
getCurrentSock: () => sockB as never,
getSelfIdentity: () => ({ jid: "456@s.whatsapp.net", lid: null }),
} as never;
registerWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleBMismatch);
controllerContexts.set(DEFAULT_ACCOUNT_ID, handleBMismatch);
try {
await expect(inbound.reply("pong")).rejects.toThrow(
@@ -1718,7 +1711,7 @@ describe("web monitor inbox", () => {
// The mismatched successor's socket was never used.
expect(sockB.sendMessage).not.toHaveBeenCalled();
} finally {
unregisterWhatsAppConnectionController(DEFAULT_ACCOUNT_ID, handleBMismatch);
controllerContexts.delete(DEFAULT_ACCOUNT_ID);
await listenerA.close();
}
});
@@ -13,24 +13,21 @@ import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-run
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { whatsappChannelOutbound } from "./channel-outbound.js";
import {
getRegisteredWhatsAppConnectionController,
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
import type { ActiveWebListener } from "./inbound/types.js";
const runtimeContextMocks = vi.hoisted(() => ({
controllers: new Map<string, unknown>(),
}));
vi.mock("./connection-controller-runtime-context.js", () => ({
getWhatsAppConnectionController: (accountId: string) =>
runtimeContextMocks.controllers.get(accountId) ?? null,
}));
const cfg = { channels: { whatsapp: {} } } as OpenClawConfig;
const accountId = "default";
function clearDefaultController(): void {
const controller = getRegisteredWhatsAppConnectionController(accountId);
if (controller) {
unregisterWhatsAppConnectionController(accountId, controller);
}
}
async function drainDefaultWhatsAppDeliveries(stateDir: string) {
const log = {
info: vi.fn(),
@@ -56,7 +53,7 @@ async function drainDefaultWhatsAppDeliveries(stateDir: string) {
describe("WhatsApp delivery recovery", () => {
beforeEach(() => {
clearDefaultController();
runtimeContextMocks.controllers.clear();
setActivePluginRegistry(
createTestRegistry([
{
@@ -72,7 +69,7 @@ describe("WhatsApp delivery recovery", () => {
});
afterEach(() => {
clearDefaultController();
runtimeContextMocks.controllers.clear();
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(createEmptyPluginRegistry());
});
@@ -109,7 +106,7 @@ describe("WhatsApp delivery recovery", () => {
getCurrentSock: () => null,
getSelfIdentity: () => null,
};
registerWhatsAppConnectionController(accountId, controller);
runtimeContextMocks.controllers.set(accountId, controller);
await drainDefaultWhatsAppDeliveries(stateDir);
await drainDefaultWhatsAppDeliveries(stateDir);
+4 -4
View File
@@ -28,13 +28,13 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
channels: { whatsapp: {} },
};
vi.mock("./connection-controller-registry.js", async () => {
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
"./connection-controller-registry.js",
vi.mock("./connection-controller-runtime-context.js", async () => {
const actual = await vi.importActual<typeof import("./connection-controller-runtime-context.js")>(
"./connection-controller-runtime-context.js",
);
return {
...actual,
getRegisteredWhatsAppConnectionController: vi.fn((accountId: string) => {
getWhatsAppConnectionController: vi.fn((accountId: string) => {
const listener = hoisted.controllerListeners.get(accountId) ?? null;
return listener
? {
+2 -3
View File
@@ -16,7 +16,7 @@ import {
resolveWhatsAppAccount,
resolveWhatsAppMediaMaxBytes,
} from "./accounts.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { getWhatsAppConnectionController } from "./connection-controller-runtime-context.js";
import { resolveWhatsAppDocumentFileName } from "./document-filename.js";
import type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
import { isWhatsAppNewsletterJid } from "./normalize.js";
@@ -94,8 +94,7 @@ function requireOutboundActiveWebListener(params: { cfg: OpenClawConfig; account
} {
const accountId = resolveOutboundWhatsAppAccountId(params);
const resolvedAccountId = accountId ?? resolveDefaultWhatsAppAccountId(params.cfg);
const listener =
getRegisteredWhatsAppConnectionController(resolvedAccountId)?.getActiveListener() ?? null;
const listener = getWhatsAppConnectionController(resolvedAccountId)?.getActiveListener() ?? null;
if (!listener) {
const cause = new Error(
`No active WhatsApp Web listener (account: ${resolvedAccountId}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`openclaw channels login --channel whatsapp --account ${resolvedAccountId}`)}.`,