From f705f69fc2a5709fb6d807d8ee551fb37da6e608 Mon Sep 17 00:00:00 2001 From: Marcus Castro <7562095+mcaxtr@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:18:11 -0300 Subject: [PATCH] fix(whatsapp): preserve live connections across reloads (#110762) --- .../whatsapp/src/active-listener.test.ts | 2 +- .../connection-controller-runtime-context.ts | 6 ++-- .../src/connection-controller.test.ts | 2 +- .../whatsapp/src/connection-controller.ts | 6 ++-- extensions/whatsapp/src/runtime.test.ts | 23 +++++++++++++ extensions/whatsapp/src/runtime.ts | 34 +++++++++++++++---- 6 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 extensions/whatsapp/src/runtime.test.ts diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts index aa34e0970080..e7b852dbfd77 100644 --- a/extensions/whatsapp/src/active-listener.test.ts +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -12,7 +12,7 @@ vi.mock("openclaw/plugin-sdk/channel-runtime-context", () => ({ })); vi.mock("./runtime.js", () => ({ - getOptionalWhatsAppRuntime: () => ({ channel: runtimeContextMocks.channelRuntime }), + getOptionalWhatsAppChannelRuntime: () => runtimeContextMocks.channelRuntime, })); const WHATSAPP_ACTIVE_LISTENER_TEST_CFG = { diff --git a/extensions/whatsapp/src/connection-controller-runtime-context.ts b/extensions/whatsapp/src/connection-controller-runtime-context.ts index bc135146b93d..0ea3132cf78a 100644 --- a/extensions/whatsapp/src/connection-controller-runtime-context.ts +++ b/extensions/whatsapp/src/connection-controller-runtime-context.ts @@ -3,7 +3,7 @@ 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"; +import { getOptionalWhatsAppChannelRuntime } from "./runtime.js"; export const WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY = "connection-controller"; export const WHATSAPP_CONNECTION_OWNER_PENDING_CAPABILITY = "connection-owner-pending"; @@ -18,7 +18,7 @@ export function getWhatsAppConnectionController( accountId: string, ): WhatsAppConnectionControllerHandle | null { const context = getChannelRuntimeContext({ - channelRuntime: getOptionalWhatsAppRuntime()?.channel, + channelRuntime: getOptionalWhatsAppChannelRuntime() ?? undefined, channelId: "whatsapp", accountId, capability: WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY, @@ -29,7 +29,7 @@ export function getWhatsAppConnectionController( export function hasPendingWhatsAppConnectionOwner(accountId: string): boolean { return Boolean( getChannelRuntimeContext({ - channelRuntime: getOptionalWhatsAppRuntime()?.channel, + channelRuntime: getOptionalWhatsAppChannelRuntime() ?? undefined, channelId: "whatsapp", accountId, capability: WHATSAPP_CONNECTION_OWNER_PENDING_CAPABILITY, diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 71ddf8efeecb..3493c3d636ec 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -51,7 +51,7 @@ vi.mock("openclaw/plugin-sdk/channel-runtime-context", () => { }); vi.mock("./runtime.js", () => ({ - getWhatsAppRuntime: () => ({ channel: runtimeContextMocks.channelRuntime }), + getWhatsAppChannelRuntime: () => runtimeContextMocks.channelRuntime, })); vi.mock("./connection-owner.js", () => ({ diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index fc94be0a2152..fb8598646e2d 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -14,7 +14,7 @@ import { 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 { getWhatsAppChannelRuntime } from "./runtime.js"; import { createWaSocket, formatError, @@ -692,7 +692,7 @@ export class WhatsAppConnectionController { // Publish only after the listener is ready. Lease tokens make disposal of this // controller's previous registration unable to remove a newer replacement. this.runtimeContextLease = registerChannelRuntimeContext({ - channelRuntime: getWhatsAppRuntime().channel, + channelRuntime: getWhatsAppChannelRuntime(), channelId: "whatsapp", accountId: this.accountId, capability: WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY, @@ -1011,7 +1011,7 @@ export class WhatsAppConnectionController { // Keep a pending marker separate from the ready controller capability. This // blocks a second socket without replacing a still-working controller on reload. this.pendingOwnerContextLease = registerChannelRuntimeContext({ - channelRuntime: getWhatsAppRuntime().channel, + channelRuntime: getWhatsAppChannelRuntime(), channelId: "whatsapp", accountId: this.accountId, capability: WHATSAPP_CONNECTION_OWNER_PENDING_CAPABILITY, diff --git a/extensions/whatsapp/src/runtime.test.ts b/extensions/whatsapp/src/runtime.test.ts new file mode 100644 index 000000000000..99d08cb654e3 --- /dev/null +++ b/extensions/whatsapp/src/runtime.test.ts @@ -0,0 +1,23 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +// Whatsapp tests cover runtime injection across plugin registry reloads. +import { describe, expect, it } from "vitest"; +import { + getOptionalWhatsAppChannelRuntime, + getWhatsAppChannelRuntime, + getWhatsAppRuntime, + setWhatsAppRuntime, +} from "./runtime.js"; + +describe("WhatsApp runtime", () => { + it("preserves the channel context owner when the injected runtime changes", () => { + const originalChannelRuntime = getOptionalWhatsAppChannelRuntime(); + const first = { channel: { runtimeContexts: { id: "first" } } } as unknown as PluginRuntime; + const second = { channel: { runtimeContexts: { id: "second" } } } as unknown as PluginRuntime; + + setWhatsAppRuntime(first); + setWhatsAppRuntime(second); + + expect(getWhatsAppRuntime()).toBe(second); + expect(getWhatsAppChannelRuntime()).toBe(originalChannelRuntime ?? first.channel); + }); +}); diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 217534972a31..af20e855bd89 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -2,12 +2,34 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -const { - setRuntime: setWhatsAppRuntime, - getRuntime: getWhatsAppRuntime, - tryGetRuntime: getOptionalWhatsAppRuntime, -} = createPluginRuntimeStore({ +const runtimeStore = createPluginRuntimeStore({ pluginId: "whatsapp", errorMessage: "WhatsApp runtime not initialized", }); -export { getOptionalWhatsAppRuntime, getWhatsAppRuntime, setWhatsAppRuntime }; +const channelRuntimeStore = createPluginRuntimeStore({ + key: "plugin-runtime:whatsapp:channel-context-owner", + errorMessage: "WhatsApp channel runtime not initialized", +}); + +/** Injects current helpers while preserving the process-lifetime channel context owner. */ +function setWhatsAppRuntime(next: PluginRuntime): void { + // Plugin registry reloads create fresh runtime objects. Live connection leases must remain + // readable by outbound sends until their account task explicitly disposes them. + if (!channelRuntimeStore.tryGetRuntime()) { + channelRuntimeStore.setRuntime(next.channel); + } + runtimeStore.setRuntime(next); +} + +const getWhatsAppRuntime = runtimeStore.getRuntime; +const getOptionalWhatsAppRuntime = runtimeStore.tryGetRuntime; +const getWhatsAppChannelRuntime = channelRuntimeStore.getRuntime; +const getOptionalWhatsAppChannelRuntime = channelRuntimeStore.tryGetRuntime; + +export { + getOptionalWhatsAppChannelRuntime, + getOptionalWhatsAppRuntime, + getWhatsAppChannelRuntime, + getWhatsAppRuntime, + setWhatsAppRuntime, +};