mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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}`)}.`,
|
||||
|
||||
Reference in New Issue
Block a user