From 80d11f1246659e29b77a217d4ccdfb27367f775e Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:22:57 -0300 Subject: [PATCH] refactor(whatsapp): use runtime context for connection controllers (#104949) * refactor(whatsapp): replace connection controller registry * fix(whatsapp): remove controller context import cycle --- .../whatsapp/src/active-listener.test.ts | 26 ++++---- extensions/whatsapp/src/active-listener.ts | 4 +- .../whatsapp/src/agent-tools-call.test.ts | 24 ++++--- extensions/whatsapp/src/agent-tools-call.ts | 6 +- .../connection-controller-registry.test.ts | 30 --------- .../src/connection-controller-registry.ts | 62 ------------------- .../connection-controller-runtime-context.ts | 26 ++++++++ .../src/connection-controller.test.ts | 33 +++++++++- .../whatsapp/src/connection-controller.ts | 27 ++++++-- extensions/whatsapp/src/inbound/monitor.ts | 4 +- ...x.streams-inbound-messages.test-support.ts | 41 +++++------- .../src/send.delivery-recovery.test.ts | 27 ++++---- extensions/whatsapp/src/send.test.ts | 8 +-- extensions/whatsapp/src/send.ts | 5 +- 14 files changed, 148 insertions(+), 175 deletions(-) delete mode 100644 extensions/whatsapp/src/connection-controller-registry.test.ts delete mode 100644 extensions/whatsapp/src/connection-controller-registry.ts create mode 100644 extensions/whatsapp/src/connection-controller-runtime-context.ts diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts index 0912a98fef05..aa34e0970080 100644 --- a/extensions/whatsapp/src/active-listener.test.ts +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -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(); }); diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 94906ffadfd2..5175d387b668 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -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; } diff --git a/extensions/whatsapp/src/agent-tools-call.test.ts b/extensions/whatsapp/src/agent-tools-call.test.ts index d4d32eafd4ab..34b51b092cd2 100644 --- a/extensions/whatsapp/src/agent-tools-call.test.ts +++ b/extensions/whatsapp/src/agent-tools-call.test.ts @@ -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(), +})); + +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"); } }); diff --git a/extensions/whatsapp/src/agent-tools-call.ts b/extensions/whatsapp/src/agent-tools-call.ts index 575b9d691e61..064964acf19b 100644 --- a/extensions/whatsapp/src/agent-tools-call.ts +++ b/extensions/whatsapp/src/agent-tools-call.ts @@ -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; }): Promise { - const controller = getRegisteredWhatsAppConnectionController(params.accountId); + const controller = getWhatsAppConnectionController(params.accountId); if (!controller) { return null; } diff --git a/extensions/whatsapp/src/connection-controller-registry.test.ts b/extensions/whatsapp/src/connection-controller-registry.test.ts deleted file mode 100644 index 58fb69d79020..000000000000 --- a/extensions/whatsapp/src/connection-controller-registry.test.ts +++ /dev/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 { - 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); - } - }); -}); diff --git a/extensions/whatsapp/src/connection-controller-registry.ts b/extensions/whatsapp/src/connection-controller-registry.ts deleted file mode 100644 index aa3467fad843..000000000000 --- a/extensions/whatsapp/src/connection-controller-registry.ts +++ /dev/null @@ -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; -}; - -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(), - }; - 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); - } -} diff --git a/extensions/whatsapp/src/connection-controller-runtime-context.ts b/extensions/whatsapp/src/connection-controller-runtime-context.ts new file mode 100644 index 000000000000..b36207c78257 --- /dev/null +++ b/extensions/whatsapp/src/connection-controller-runtime-context.ts @@ -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; +} diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 22dbf7e2037e..7e072a732367 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -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 () => { diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 169aaa11e170..8d32e42e6791 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -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 { this.stopDisconnectRetries(); await this.closeCurrentConnection(); - unregisterWhatsAppConnectionController(this.accountId, this); + this.runtimeContextLease?.dispose(); + this.runtimeContextLease = null; } private startTimers( diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 2d0263ce4ec0..77eb655acb9c 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -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; } diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts index 247662709e6e..ace55a03884e 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts @@ -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(), 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( "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(); } }); diff --git a/extensions/whatsapp/src/send.delivery-recovery.test.ts b/extensions/whatsapp/src/send.delivery-recovery.test.ts index 83c4ac491357..db48c248c44e 100644 --- a/extensions/whatsapp/src/send.delivery-recovery.test.ts +++ b/extensions/whatsapp/src/send.delivery-recovery.test.ts @@ -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(), +})); + +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); diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index 603fc3fe14cc..197201aba747 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -28,13 +28,13 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = { channels: { whatsapp: {} }, }; -vi.mock("./connection-controller-registry.js", async () => { - const actual = await vi.importActual( - "./connection-controller-registry.js", +vi.mock("./connection-controller-runtime-context.js", async () => { + const actual = await vi.importActual( + "./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 ? { diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 7d19ee1da039..fed03958d07b 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -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}`)}.`,