diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a376f35bc..3dab0842940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai - Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant. - Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant. - WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67. +- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo. ### Breaking diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts new file mode 100644 index 00000000000..a1d037f788a --- /dev/null +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type ActiveListenerModule = typeof import("./active-listener.js"); + +const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href; + +async function importActiveListenerModule(cacheBust: string): Promise { + return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule; +} + +afterEach(async () => { + const mod = await importActiveListenerModule(`cleanup-${Date.now()}`); + mod.setActiveWebListener(null); + mod.setActiveWebListener("work", null); +}); + +describe("active WhatsApp listener singleton", () => { + it("shares listeners across duplicate module instances", async () => { + 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 () => {}), + }; + + first.setActiveWebListener("work", listener); + + expect(second.getActiveWebListener("work")).toBe(listener); + expect(second.requireActiveWebListener("work")).toEqual({ + accountId: "work", + listener, + }); + }); +}); diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 3315a5775ec..8b62d15ff1f 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -28,27 +28,22 @@ export type ActiveWebListener = { close?: () => Promise; }; -// Use a process-level singleton to survive bundler code-splitting. -// Rolldown duplicates this module across multiple output chunks, each with its -// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's -// Map via setActiveWebListener(), but the outbound send path reads from a -// different chunk's Map via requireActiveWebListener() — so the listener is -// never found. Pinning the Map to globalThis ensures all chunks share one -// instance. See: https://github.com/openclaw/openclaw/issues/14406 -const GLOBAL_KEY = "__openclaw_wa_listeners" as const; -const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const; +// Use process-global symbol keys to survive bundler code-splitting and loader +// cache splits without depending on fragile string property names. +const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners"); +const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener"); type GlobalWithListeners = typeof globalThis & { - [GLOBAL_KEY]?: Map; + [GLOBAL_LISTENERS_KEY]?: Map; [GLOBAL_CURRENT_KEY]?: ActiveWebListener | null; }; const _global = globalThis as GlobalWithListeners; -_global[GLOBAL_KEY] ??= new Map(); +_global[GLOBAL_LISTENERS_KEY] ??= new Map(); _global[GLOBAL_CURRENT_KEY] ??= null; -const listeners = _global[GLOBAL_KEY]; +const listeners = _global[GLOBAL_LISTENERS_KEY]; function getCurrentListener(): ActiveWebListener | null { return _global[GLOBAL_CURRENT_KEY] ?? null; diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index c1c482e2bd2..9f10ae7fe81 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -12,6 +12,14 @@ import { } from "./commands.js"; import { setActivePluginRegistry } from "./runtime.js"; +type CommandsModule = typeof import("./commands.js"); + +const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href; + +async function importCommandsModule(cacheBust: string): Promise { + return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule; +} + beforeEach(() => { setActivePluginRegistry( createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), @@ -108,6 +116,40 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("shares plugin commands across duplicate module instances", async () => { + const first = await importCommandsModule(`first-${Date.now()}`); + const second = await importCommandsModule(`second-${Date.now()}`); + + first.clearPluginCommands(); + + expect( + first.registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + telegram: "voice", + }, + description: "Voice command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect(second.getPluginCommandSpecs("telegram")).toEqual([ + { + name: "voice", + description: "Voice command", + acceptsArgs: false, + }, + ]); + expect(second.matchPluginCommand("/voice")).toMatchObject({ + command: expect.objectContaining({ + name: "voice", + pluginId: "demo-plugin", + }), + }); + + second.clearPluginCommands(); + }); + it("matches provider-specific native aliases back to the canonical command", () => { const result = registerPluginCommand("demo-plugin", { name: "voice", diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index a44cbc26e7e..8137ebbed1b 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -8,6 +8,7 @@ import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, @@ -25,11 +26,19 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginRoot?: string; }; -// Registry of plugin commands -const pluginCommands: Map = new Map(); +type PluginCommandState = { + pluginCommands: Map; + registryLocked: boolean; +}; -// Lock to prevent modifications during command execution -let registryLocked = false; +const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); + +const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ + pluginCommands: new Map(), + registryLocked: false, +})); + +const pluginCommands = state.pluginCommands; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; @@ -172,7 +181,7 @@ export function registerPluginCommand( opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (registryLocked) { + if (state.registryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -451,7 +460,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - registryLocked = true; + state.registryLocked = true; try { const result = await command.handler(ctx); logVerbose( @@ -464,7 +473,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - registryLocked = false; + state.registryLocked = false; } }