From 30a94dfd3b7c2a06ec1d15e5edbb47ba37cc8300 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Mar 2026 03:12:16 +0000 Subject: [PATCH] refactor: untangle whatsapp runtime boundary --- extensions/whatsapp/light-runtime-api.ts | 12 + extensions/whatsapp/package.json | 3 + extensions/whatsapp/runtime-api.ts | 1 + extensions/whatsapp/src/agent-tools-login.ts | 2 +- extensions/whatsapp/src/channel.runtime.ts | 8 +- extensions/whatsapp/src/channel.ts | 4 +- extensions/whatsapp/src/runtime-api.ts | 4 +- extensions/whatsapp/src/session-errors.ts | 123 +++++++ extensions/whatsapp/src/session.ts | 127 +------ package.json | 4 + pnpm-lock.yaml | 6 +- scripts/lib/plugin-sdk-entrypoints.json | 1 + src/channel-web.ts | 58 ++- src/cli/deps.ts | 2 +- src/cli/send-runtime/whatsapp.ts | 4 +- src/commands/health.snapshot.test.ts | 10 + src/library.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 2 + src/plugin-sdk/web-media.ts | 2 +- src/plugin-sdk/whatsapp-action-runtime.ts | 2 +- src/plugin-sdk/whatsapp-login-qr.ts | 5 +- src/plugin-sdk/whatsapp-shared.ts | 9 + src/plugin-sdk/whatsapp.ts | 57 ++- src/plugins/bundled-runtime-deps.test.ts | 27 +- src/plugins/loader.ts | 186 +--------- .../runtime/runtime-whatsapp-boundary.ts | 339 ++++++++++++++++++ .../runtime/runtime-whatsapp-login-tool.ts | 2 +- .../runtime/runtime-whatsapp-login.runtime.ts | 2 +- .../runtime-whatsapp-outbound.runtime.ts | 2 +- src/plugins/runtime/runtime-whatsapp.ts | 103 +----- src/plugins/runtime/types-channel.ts | 26 +- src/plugins/runtime/types-core.ts | 2 +- src/plugins/sdk-alias.ts | 185 ++++++++++ 33 files changed, 848 insertions(+), 474 deletions(-) create mode 100644 extensions/whatsapp/light-runtime-api.ts create mode 100644 extensions/whatsapp/src/session-errors.ts create mode 100644 src/plugin-sdk/whatsapp-shared.ts create mode 100644 src/plugins/runtime/runtime-whatsapp-boundary.ts create mode 100644 src/plugins/sdk-alias.ts diff --git a/extensions/whatsapp/light-runtime-api.ts b/extensions/whatsapp/light-runtime-api.ts new file mode 100644 index 00000000000..6101a4404ad --- /dev/null +++ b/extensions/whatsapp/light-runtime-api.ts @@ -0,0 +1,12 @@ +export { getActiveWebListener } from "./src/active-listener.js"; +export { + getWebAuthAgeMs, + logWebSelfId, + logoutWeb, + pickWebChannel, + readWebSelfId, + WA_WEB_AUTH_DIR, + webAuthExists, +} from "./src/auth-store.js"; +export { createWhatsAppLoginTool } from "./src/agent-tools-login.js"; +export { formatError, getStatusCode } from "./src/session-errors.js"; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 3a2be87dca9..ab0be9a6513 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -4,6 +4,9 @@ "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", + "dependencies": { + "@whiskeysockets/baileys": "7.0.0-rc.9" + }, "openclaw": { "extensions": [ "./index.ts" diff --git a/extensions/whatsapp/runtime-api.ts b/extensions/whatsapp/runtime-api.ts index 531cee4b524..d55b02ab5db 100644 --- a/extensions/whatsapp/runtime-api.ts +++ b/extensions/whatsapp/runtime-api.ts @@ -5,6 +5,7 @@ export * from "./src/auth-store.js"; export * from "./src/auto-reply.js"; export * from "./src/inbound.js"; export * from "./src/login.js"; +export * from "./src/login-qr.js"; export * from "./src/media.js"; export * from "./src/send.js"; export * from "./src/session.js"; diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index 9343e83d21a..d53f5105ca2 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -1,5 +1,6 @@ import { Type } from "@sinclair/typebox"; import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime"; +import { startWebLoginWithQr, waitForWebLogin } from "openclaw/plugin-sdk/whatsapp-login-qr"; export function createWhatsAppLoginTool(): ChannelAgentTool { return { @@ -18,7 +19,6 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { force: Type.Optional(Type.Boolean()), }), execute: async (_toolCallId, args) => { - const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js"); const action = (args as { action?: string })?.action ?? "start"; if (action === "wait") { const result = await waitForWebLogin({ diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index 4aa4951616a..9278dff2358 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -6,8 +6,8 @@ import { readWebSelfId as readWebSelfIdImpl, webAuthExists as webAuthExistsImpl, } from "./auth-store.js"; +import { monitorWebChannel as monitorWebChannelImpl } from "./auto-reply/monitor.js"; import { loginWeb as loginWebImpl } from "./login.js"; -import { monitorWebChannel as monitorWebChannelImpl } from "./runtime-api.js"; import { whatsappSetupWizard as whatsappSetupWizardImpl } from "./setup-surface.js"; type GetActiveWebListener = typeof import("./active-listener.js").getActiveWebListener; @@ -20,7 +20,7 @@ type LoginWeb = typeof import("./login.js").loginWeb; type StartWebLoginWithQr = typeof import("./login-qr.js").startWebLoginWithQr; type WaitForWebLogin = typeof import("./login-qr.js").waitForWebLogin; type WhatsAppSetupWizard = typeof import("./setup-surface.js").whatsappSetupWizard; -type MonitorWebChannel = typeof import("./runtime-api.js").monitorWebChannel; +type MonitorWebChannel = typeof import("./auto-reply/monitor.js").monitorWebChannel; let loginQrPromise: Promise | null = null; @@ -75,8 +75,8 @@ export async function waitForWebLogin( export const whatsappSetupWizard: WhatsAppSetupWizard = { ...whatsappSetupWizardImpl }; -export async function monitorWebChannel( +export function monitorWebChannel( ...args: Parameters ): ReturnType { - return await monitorWebChannelImpl(...args); + return monitorWebChannelImpl(...args); } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 151cfc60b40..d85ee4984e8 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,6 +1,7 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; +import type { WebChannelStatus } from "./auto-reply/types.js"; import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, @@ -282,7 +283,8 @@ export const whatsappPlugin: ChannelPlugin = { ctx.runtime, ctx.abortSignal, { - statusSink: (next) => ctx.setStatus({ accountId: ctx.accountId, ...next }), + statusSink: (next: WebChannelStatus) => + ctx.setStatus({ accountId: ctx.accountId, ...next }), accountId: account.accountId, }, ); diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index a0f07404a91..515040ffb42 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -26,6 +26,6 @@ export { type DmPolicy, type GroupPolicy, type WhatsAppAccountConfig, -} from "openclaw/plugin-sdk/whatsapp"; +} from "openclaw/plugin-sdk/whatsapp-shared"; -export { monitorWebChannel } from "openclaw/plugin-sdk/whatsapp"; +export { monitorWebChannel } from "./channel.runtime.js"; diff --git a/extensions/whatsapp/src/session-errors.ts b/extensions/whatsapp/src/session-errors.ts new file mode 100644 index 00000000000..1aca21a107d --- /dev/null +++ b/extensions/whatsapp/src/session-errors.ts @@ -0,0 +1,123 @@ +function safeStringify(value: unknown, limit = 800): string { + try { + const seen = new WeakSet(); + const raw = JSON.stringify( + value, + (_key, v) => { + if (typeof v === "bigint") { + return v.toString(); + } + if (typeof v === "function") { + const maybeName = (v as { name?: unknown }).name; + const name = + typeof maybeName === "string" && maybeName.length > 0 ? maybeName : "anonymous"; + return `[Function ${name}]`; + } + if (typeof v === "object" && v) { + if (seen.has(v)) { + return "[Circular]"; + } + seen.add(v); + } + return v; + }, + 2, + ); + if (!raw) { + return String(value); + } + return raw.length > limit ? `${raw.slice(0, limit)}…` : raw; + } catch { + return String(value); + } +} + +function extractBoomDetails(err: unknown): { + statusCode?: number; + error?: string; + message?: string; +} | null { + if (!err || typeof err !== "object") { + return null; + } + const output = (err as { output?: unknown })?.output as + | { statusCode?: unknown; payload?: unknown } + | undefined; + if (!output || typeof output !== "object") { + return null; + } + const payload = (output as { payload?: unknown }).payload as + | { error?: unknown; message?: unknown; statusCode?: unknown } + | undefined; + const statusCode = + typeof (output as { statusCode?: unknown }).statusCode === "number" + ? ((output as { statusCode?: unknown }).statusCode as number) + : typeof payload?.statusCode === "number" + ? payload.statusCode + : undefined; + const error = typeof payload?.error === "string" ? payload.error : undefined; + const message = typeof payload?.message === "string" ? payload.message : undefined; + if (!statusCode && !error && !message) { + return null; + } + return { statusCode, error, message }; +} + +export function getStatusCode(err: unknown) { + return ( + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status ?? + (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode + ); +} + +export function formatError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + if (!err || typeof err !== "object") { + return String(err); + } + + const boom = + extractBoomDetails(err) ?? + extractBoomDetails((err as { error?: unknown })?.error) ?? + extractBoomDetails((err as { lastDisconnect?: { error?: unknown } })?.lastDisconnect?.error); + + const status = boom?.statusCode ?? getStatusCode(err); + const code = (err as { code?: unknown })?.code; + const codeText = typeof code === "string" || typeof code === "number" ? String(code) : undefined; + + const messageCandidates = [ + boom?.message, + typeof (err as { message?: unknown })?.message === "string" + ? ((err as { message?: unknown }).message as string) + : undefined, + typeof (err as { error?: { message?: unknown } })?.error?.message === "string" + ? ((err as { error?: { message?: unknown } }).error?.message as string) + : undefined, + ].filter((value): value is string => Boolean(value && value.trim().length > 0)); + const message = messageCandidates[0]; + + const pieces: string[] = []; + if (typeof status === "number") { + pieces.push(`status=${status}`); + } + if (boom?.error) { + pieces.push(boom.error); + } + if (message) { + pieces.push(message); + } + if (codeText) { + pieces.push(`code=${codeText}`); + } + + if (pieces.length > 0) { + return pieces.join(" "); + } + return safeStringify(err); +} diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 80690b110eb..3c9c7f74c1f 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -20,6 +20,8 @@ import { resolveWebCredsBackupPath, resolveWebCredsPath, } from "./auth-store.js"; +import { formatError, getStatusCode } from "./session-errors.js"; +export { formatError, getStatusCode } from "./session-errors.js"; export { getWebAuthAgeMs, @@ -190,14 +192,6 @@ export async function waitForWaConnection(sock: ReturnType) }); } -export function getStatusCode(err: unknown) { - return ( - (err as { output?: { statusCode?: number } })?.output?.statusCode ?? - (err as { status?: number })?.status ?? - (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode - ); -} - /** Await pending credential saves — scoped to one authDir, or all if omitted. */ export function waitForCredsSaveQueue(authDir?: string): Promise { if (authDir) { @@ -224,123 +218,6 @@ export async function waitForCredsSaveQueueWithTimeout( }); } -function safeStringify(value: unknown, limit = 800): string { - try { - const seen = new WeakSet(); - const raw = JSON.stringify( - value, - (_key, v) => { - if (typeof v === "bigint") { - return v.toString(); - } - if (typeof v === "function") { - const maybeName = (v as { name?: unknown }).name; - const name = - typeof maybeName === "string" && maybeName.length > 0 ? maybeName : "anonymous"; - return `[Function ${name}]`; - } - if (typeof v === "object" && v) { - if (seen.has(v)) { - return "[Circular]"; - } - seen.add(v); - } - return v; - }, - 2, - ); - if (!raw) { - return String(value); - } - return raw.length > limit ? `${raw.slice(0, limit)}…` : raw; - } catch { - return String(value); - } -} - -function extractBoomDetails(err: unknown): { - statusCode?: number; - error?: string; - message?: string; -} | null { - if (!err || typeof err !== "object") { - return null; - } - const output = (err as { output?: unknown })?.output as - | { statusCode?: unknown; payload?: unknown } - | undefined; - if (!output || typeof output !== "object") { - return null; - } - const payload = (output as { payload?: unknown }).payload as - | { error?: unknown; message?: unknown; statusCode?: unknown } - | undefined; - const statusCode = - typeof (output as { statusCode?: unknown }).statusCode === "number" - ? ((output as { statusCode?: unknown }).statusCode as number) - : typeof payload?.statusCode === "number" - ? payload.statusCode - : undefined; - const error = typeof payload?.error === "string" ? payload.error : undefined; - const message = typeof payload?.message === "string" ? payload.message : undefined; - if (!statusCode && !error && !message) { - return null; - } - return { statusCode, error, message }; -} - -export function formatError(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - if (typeof err === "string") { - return err; - } - if (!err || typeof err !== "object") { - return String(err); - } - - // Baileys frequently wraps errors under `error` with a Boom-like shape. - const boom = - extractBoomDetails(err) ?? - extractBoomDetails((err as { error?: unknown })?.error) ?? - extractBoomDetails((err as { lastDisconnect?: { error?: unknown } })?.lastDisconnect?.error); - - const status = boom?.statusCode ?? getStatusCode(err); - const code = (err as { code?: unknown })?.code; - const codeText = typeof code === "string" || typeof code === "number" ? String(code) : undefined; - - const messageCandidates = [ - boom?.message, - typeof (err as { message?: unknown })?.message === "string" - ? ((err as { message?: unknown }).message as string) - : undefined, - typeof (err as { error?: { message?: unknown } })?.error?.message === "string" - ? ((err as { error?: { message?: unknown } }).error?.message as string) - : undefined, - ].filter((v): v is string => Boolean(v && v.trim().length > 0)); - const message = messageCandidates[0]; - - const pieces: string[] = []; - if (typeof status === "number") { - pieces.push(`status=${status}`); - } - if (boom?.error) { - pieces.push(boom.error); - } - if (message) { - pieces.push(message); - } - if (codeText) { - pieces.push(`code=${codeText}`); - } - - if (pieces.length > 0) { - return pieces.join(" "); - } - return safeStringify(err); -} - export function newConnectionId() { return randomUUID(); } diff --git a/package.json b/package.json index 1ecf252da04..797c8b484b3 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,10 @@ "types": "./dist/plugin-sdk/whatsapp.d.ts", "default": "./dist/plugin-sdk/whatsapp.js" }, + "./plugin-sdk/whatsapp-shared": { + "types": "./dist/plugin-sdk/whatsapp-shared.d.ts", + "default": "./dist/plugin-sdk/whatsapp-shared.js" + }, "./plugin-sdk/whatsapp-action-runtime": { "types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts", "default": "./dist/plugin-sdk/whatsapp-action-runtime.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ce1e135cec..82c9c597d68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,7 +590,11 @@ importers: extensions/volcengine: {} - extensions/whatsapp: {} + extensions/whatsapp: + dependencies: + '@whiskeysockets/baileys': + specifier: 7.0.0-rc.9 + version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.0)(sharp@0.34.5) extensions/xai: {} diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index da2395758c5..6373432652b 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -56,6 +56,7 @@ "qwen-portal-auth", "signal", "whatsapp", + "whatsapp-shared", "whatsapp-action-runtime", "whatsapp-login-qr", "whatsapp-core", diff --git a/src/channel-web.ts b/src/channel-web.ts index 3566cee4790..749398ab9fe 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -1,29 +1,51 @@ // Barrel exports for the web channel pieces. Splitting the original 900+ line // module keeps responsibilities small and testable. -export { - DEFAULT_WEB_MEDIA_BYTES, - HEARTBEAT_PROMPT, - HEARTBEAT_TOKEN, - monitorWebChannel, - resolveHeartbeatRecipients, - runWebHeartbeatOnce, -} from "openclaw/plugin-sdk/whatsapp"; -export { - extractMediaPlaceholder, - extractText, - monitorWebInbox, -} from "openclaw/plugin-sdk/whatsapp"; -export { loginWeb } from "openclaw/plugin-sdk/whatsapp"; +import { resolveWaWebAuthDir } from "./plugins/runtime/runtime-whatsapp-boundary.js"; + +export { HEARTBEAT_PROMPT } from "./auto-reply/heartbeat.js"; +export { HEARTBEAT_TOKEN } from "./auto-reply/tokens.js"; export { loadWebMedia, optimizeImageToJpeg } from "./media/web-media.js"; -export { sendMessageWhatsApp } from "openclaw/plugin-sdk/whatsapp"; export { createWaSocket, + extractMediaPlaceholder, + extractText, formatError, getStatusCode, - logoutWeb, logWebSelfId, + loginWeb, + logoutWeb, + monitorWebChannel, + monitorWebInbox, pickWebChannel, - WA_WEB_AUTH_DIR, + resolveHeartbeatRecipients, + runWebHeartbeatOnce, + sendMessageWhatsApp, + sendReactionWhatsApp, waitForWaConnection, webAuthExists, -} from "openclaw/plugin-sdk/whatsapp"; +} from "./plugins/runtime/runtime-whatsapp-boundary.js"; + +// Keep the historic constant surface available, but resolve it through the +// plugin boundary only when a caller actually coerces the value to string. +class LazyWhatsAppAuthDir { + #value: string | null = null; + + #read(): string { + this.#value ??= resolveWaWebAuthDir(); + return this.#value; + } + + toString(): string { + return this.#read(); + } + + valueOf(): string { + return this.#read(); + } + + [Symbol.toPrimitive](): string { + return this.#read(); + } +} + +export const WA_WEB_AUTH_DIR = new LazyWhatsAppAuthDir() as unknown as string; diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 23d2d9af399..67c890d5b53 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "openclaw/plugin-sdk/whatsapp"; +export { logWebSelfId } from "../plugins/runtime/runtime-whatsapp-boundary.js"; diff --git a/src/cli/send-runtime/whatsapp.ts b/src/cli/send-runtime/whatsapp.ts index b1e731e7c44..1a7d4996773 100644 --- a/src/cli/send-runtime/whatsapp.ts +++ b/src/cli/send-runtime/whatsapp.ts @@ -1,7 +1,7 @@ -import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "openclaw/plugin-sdk/whatsapp"; +import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "../../plugins/runtime/runtime-whatsapp-boundary.js"; type RuntimeSend = { - sendMessage: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; + sendMessage: typeof import("../../plugins/runtime/runtime-whatsapp-boundary.js").sendMessageWhatsApp; }; export const runtimeSend = { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 47d6a10f623..03055c8eb17 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -21,12 +21,22 @@ vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/sessions.js", () => ({ resolveStorePath: () => "/tmp/sessions.json", + resolveSessionFilePath: vi.fn(() => "/tmp/sessions.json"), loadSessionStore: () => testStore, + saveSessionStore: vi.fn().mockResolvedValue(undefined), readSessionUpdatedAt: vi.fn(() => undefined), recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), updateLastRoute: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../extensions/telegram/src/fetch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveTelegramFetch: () => fetch, + }; +}); + vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 1234), diff --git a/src/library.ts b/src/library.ts index faaf7ea5998..889d7b36039 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,6 +1,5 @@ import { getReplyFromConfig } from "./auto-reply/reply.js"; import { applyTemplate } from "./auto-reply/templating.js"; -import { monitorWebChannel } from "./channel-web.js"; import { createDefaultDeps } from "./cli/deps.js"; import { promptYesNo } from "./cli/prompt.js"; import { waitForever } from "./cli/wait.js"; @@ -19,6 +18,7 @@ import { handlePortError, PortInUseError, } from "./infra/ports.js"; +import { monitorWebChannel } from "./plugins/runtime/runtime-whatsapp-boundary.js"; import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index aebc29071f5..069a0be8067 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -248,6 +248,8 @@ describe("plugin-sdk subpath exports", () => { expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object"); expect(typeof whatsappSdk.resolveWhatsAppOutboundTarget).toBe("function"); expect(typeof whatsappSdk.resolveWhatsAppMentionStripRegexes).toBe("function"); + expect(typeof whatsappSdk.sendMessageWhatsApp).toBe("function"); + expect(typeof whatsappSdk.loadWebMedia).toBe("function"); }); it("exports WhatsApp QR login helpers from the dedicated subpath", () => { diff --git a/src/plugin-sdk/web-media.ts b/src/plugin-sdk/web-media.ts index ce734a295bb..a21e98d0ac1 100644 --- a/src/plugin-sdk/web-media.ts +++ b/src/plugin-sdk/web-media.ts @@ -3,4 +3,4 @@ export { loadWebMedia, loadWebMediaRaw, type WebMediaResult, -} from "../../extensions/whatsapp/runtime-api.js"; +} from "../media/web-media.js"; diff --git a/src/plugin-sdk/whatsapp-action-runtime.ts b/src/plugin-sdk/whatsapp-action-runtime.ts index 87e7a29e437..6bef2336fe7 100644 --- a/src/plugin-sdk/whatsapp-action-runtime.ts +++ b/src/plugin-sdk/whatsapp-action-runtime.ts @@ -1 +1 @@ -export { handleWhatsAppAction } from "../../extensions/whatsapp/action-runtime-api.js"; +export { handleWhatsAppAction } from "../plugins/runtime/runtime-whatsapp-boundary.js"; diff --git a/src/plugin-sdk/whatsapp-login-qr.ts b/src/plugin-sdk/whatsapp-login-qr.ts index bde71742811..2981d66991f 100644 --- a/src/plugin-sdk/whatsapp-login-qr.ts +++ b/src/plugin-sdk/whatsapp-login-qr.ts @@ -1 +1,4 @@ -export { startWebLoginWithQr, waitForWebLogin } from "../../extensions/whatsapp/login-qr-api.js"; +export { + startWebLoginWithQr, + waitForWebLogin, +} from "../plugins/runtime/runtime-whatsapp-boundary.js"; diff --git a/src/plugin-sdk/whatsapp-shared.ts b/src/plugin-sdk/whatsapp-shared.ts new file mode 100644 index 00000000000..d1794898bc3 --- /dev/null +++ b/src/plugin-sdk/whatsapp-shared.ts @@ -0,0 +1,9 @@ +export type { ChannelMessageActionName } from "../channels/plugins/types.js"; +export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; +export { + createWhatsAppOutboundBase, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppMentionStripRegexes, +} from "../channels/plugins/whatsapp-shared.js"; +export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; +export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index d5182f9004c..b156c5e856a 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,11 +1,14 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; -export type { WebChannelStatus, WebMonitorTuning } from "../../extensions/whatsapp/runtime-api.js"; +export type { + WebChannelStatus, + WebMonitorTuning, +} from "../../extensions/whatsapp/src/auto-reply/types.js"; export type { WebInboundMessage, WebListenerCloseReason, -} from "../../extensions/whatsapp/runtime-api.js"; +} from "../../extensions/whatsapp/src/inbound/types.js"; export type { ChannelMessageActionContext, ChannelPlugin, @@ -71,44 +74,40 @@ export { resolveWhatsAppAccount, } from "../../extensions/whatsapp/api.js"; export { - getActiveWebListener, - getWebAuthAgeMs, - WA_WEB_AUTH_DIR, - logWebSelfId, - logoutWeb, - pickWebChannel, - readWebSelfId, - webAuthExists, -} from "../../extensions/whatsapp/runtime-api.js"; -export { - DEFAULT_WEB_MEDIA_BYTES, HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, + WA_WEB_AUTH_DIR, + createWaSocket, + formatError, + loginWeb, + logWebSelfId, + logoutWeb, monitorWebChannel, + pickWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "../../extensions/whatsapp/runtime-api.js"; + sendMessageWhatsApp, + sendReactionWhatsApp, + waitForWaConnection, + webAuthExists, +} from "../channel-web.js"; export { extractMediaPlaceholder, extractText, + getActiveWebListener, + getWebAuthAgeMs, monitorWebInbox, -} from "../../extensions/whatsapp/runtime-api.js"; -export { loginWeb } from "../../extensions/whatsapp/runtime-api.js"; + readWebSelfId, + sendPollWhatsApp, + startWebLoginWithQr, + waitForWebLogin, +} from "../plugins/runtime/runtime-whatsapp-boundary.js"; +export { DEFAULT_WEB_MEDIA_BYTES } from "../../extensions/whatsapp/src/auto-reply/constants.js"; export { getDefaultLocalRoots, loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg, -} from "../../extensions/whatsapp/runtime-api.js"; -export { - sendMessageWhatsApp, - sendPollWhatsApp, - sendReactionWhatsApp, -} from "../../extensions/whatsapp/runtime-api.js"; -export { - createWaSocket, - formatError, - getStatusCode, - waitForWaConnection, -} from "../../extensions/whatsapp/runtime-api.js"; -export { createWhatsAppLoginTool } from "../../extensions/whatsapp/runtime-api.js"; +} from "../media/web-media.js"; +export { getStatusCode } from "../plugins/runtime/runtime-whatsapp-boundary.js"; +export { createRuntimeWhatsAppLoginTool as createWhatsAppLoginTool } from "../plugins/runtime/runtime-whatsapp-boundary.js"; diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index a97e9451ad7..866dd305124 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -22,18 +22,22 @@ describe("bundled plugin runtime dependencies", () => { expect(rootSpec).toBeUndefined(); } + function expectRootMirrorsPluginRuntimeDep(pluginPath: string, dependencyName: string) { + const rootManifest = readJson("package.json"); + const pluginManifest = readJson(pluginPath); + const pluginSpec = pluginManifest.dependencies?.[dependencyName]; + const rootSpec = rootManifest.dependencies?.[dependencyName]; + + expect(pluginSpec).toBeTruthy(); + expect(rootSpec).toBe(pluginSpec); + } + it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => { expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk"); }); - it("keeps bundled memory-lancedb runtime deps available from the root package while its native runtime stays bundled", () => { - const rootManifest = readJson("package.json"); - const memoryManifest = readJson("extensions/memory-lancedb/package.json"); - const memorySpec = memoryManifest.dependencies?.["@lancedb/lancedb"]; - const rootSpec = rootManifest.dependencies?.["@lancedb/lancedb"]; - - expect(memorySpec).toBeTruthy(); - expect(rootSpec).toBe(memorySpec); + it("keeps bundled memory-lancedb runtime deps mirrored in the root package while its native runtime is still packaged that way", () => { + expectRootMirrorsPluginRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb"); }); it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => { @@ -48,6 +52,13 @@ describe("bundled plugin runtime dependencies", () => { expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy"); }); + it("keeps bundled WhatsApp runtime deps mirrored in the root package while its heavy runtime still uses the legacy bundle path", () => { + expectRootMirrorsPluginRuntimeDep( + "extensions/whatsapp/package.json", + "@whiskeysockets/baileys", + ); + }); + it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => { expectPluginOwnsRuntimeDep("extensions/discord/package.json", "https-proxy-agent"); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 10cd4b52e27..71fc1bd6f1f 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -8,7 +8,6 @@ import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; @@ -31,6 +30,17 @@ import { setActivePluginRegistry } from "./runtime.js"; import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; +import { + buildPluginLoaderJitiOptions, + listPluginSdkAliasCandidates, + listPluginSdkExportedSubpaths, + resolveLoaderPackageRoot, + resolvePluginSdkAliasCandidateOrder, + resolvePluginSdkAliasFile, + resolvePluginSdkScopedAliasMap, + shouldPreferNativeJiti, + type LoaderModuleResolveParams, +} from "./sdk-alias.js"; import type { OpenClawPluginDefinition, OpenClawPluginModule, @@ -90,130 +100,13 @@ export function clearPluginLoaderCache(): void { const defaultLogger = () => createSubsystemLogger("plugins"); -type PluginSdkAliasCandidateKind = "dist" | "src"; - -type LoaderModuleResolveParams = { - modulePath?: string; - argv1?: string; - cwd?: string; - moduleUrl?: string; -}; - function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string { return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url); } -function resolveLoaderPackageRoot( - params: LoaderModuleResolveParams & { modulePath: string }, -): string | null { - const cwd = params.cwd ?? path.dirname(params.modulePath); - const fromModulePath = resolveOpenClawPackageRootSync({ cwd }); - if (fromModulePath) { - return fromModulePath; - } - const argv1 = params.argv1 ?? process.argv[1]; - const moduleUrl = params.moduleUrl ?? (params.modulePath ? undefined : import.meta.url); - return resolveOpenClawPackageRootSync({ - cwd, - ...(argv1 ? { argv1 } : {}), - ...(moduleUrl ? { moduleUrl } : {}), - }); -} - -function resolvePluginSdkAliasCandidateOrder(params: { - modulePath: string; - isProduction: boolean; -}): PluginSdkAliasCandidateKind[] { - const normalizedModulePath = params.modulePath.replace(/\\/g, "/"); - const isDistRuntime = normalizedModulePath.includes("/dist/"); - return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"]; -} - -function listPluginSdkAliasCandidates(params: { - srcFile: string; - distFile: string; - modulePath: string; - argv1?: string; - cwd?: string; - moduleUrl?: string; -}) { - const orderedKinds = resolvePluginSdkAliasCandidateOrder({ - modulePath: params.modulePath, - isProduction: process.env.NODE_ENV === "production", - }); - const packageRoot = resolveLoaderPackageRoot(params); - if (packageRoot) { - const candidateMap = { - src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile), - dist: path.join(packageRoot, "dist", "plugin-sdk", params.distFile), - } as const; - return orderedKinds.map((kind) => candidateMap[kind]); - } - let cursor = path.dirname(params.modulePath); - const candidates: string[] = []; - for (let i = 0; i < 6; i += 1) { - const candidateMap = { - src: path.join(cursor, "src", "plugin-sdk", params.srcFile), - dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), - } as const; - for (const kind of orderedKinds) { - candidates.push(candidateMap[kind]); - } - const parent = path.dirname(cursor); - if (parent === cursor) { - break; - } - cursor = parent; - } - return candidates; -} - -const resolvePluginSdkAliasFile = (params: { - srcFile: string; - distFile: string; - modulePath?: string; - argv1?: string; - cwd?: string; - moduleUrl?: string; -}): string | null => { - try { - const modulePath = resolveLoaderModulePath(params); - for (const candidate of listPluginSdkAliasCandidates({ - srcFile: params.srcFile, - distFile: params.distFile, - modulePath, - argv1: params.argv1, - cwd: params.cwd, - moduleUrl: params.moduleUrl, - })) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - } catch { - // ignore - } - return null; -}; - const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); -function buildPluginLoaderJitiOptions(aliasMap: Record) { - return { - interopDefault: true, - // Prefer Node's native sync ESM loader for built dist/*.js modules so - // bundled plugins and plugin-sdk subpaths stay on the canonical module graph. - tryNative: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }; -} - function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null { try { const modulePath = resolveLoaderModulePath(params); @@ -243,63 +136,6 @@ function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): return null; } -const cachedPluginSdkExportedSubpaths = new Map(); - -function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { - const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveOpenClawPackageRootSync({ - cwd: path.dirname(modulePath), - }); - if (!packageRoot) { - return []; - } - const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); - if (cached) { - return cached; - } - try { - const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); - const pkg = JSON.parse(pkgRaw) as { - exports?: Record; - }; - const subpaths = Object.keys(pkg.exports ?? {}) - .filter((key) => key.startsWith("./plugin-sdk/")) - .map((key) => key.slice("./plugin-sdk/".length)) - .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) - .toSorted(); - cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); - return subpaths; - } catch { - return []; - } -} - -const resolvePluginSdkScopedAliasMap = (): Record => { - const aliasMap: Record = {}; - for (const subpath of listPluginSdkExportedSubpaths()) { - const resolved = resolvePluginSdkAliasFile({ - srcFile: `${subpath}.ts`, - distFile: `${subpath}.js`, - }); - if (resolved) { - aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; - } - } - return aliasMap; -}; - -function shouldPreferNativeJiti(modulePath: string): boolean { - switch (path.extname(modulePath).toLowerCase()) { - case ".js": - case ".mjs": - case ".cjs": - case ".json": - return true; - default: - return false; - } -} - export const __testing = { buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, diff --git a/src/plugins/runtime/runtime-whatsapp-boundary.ts b/src/plugins/runtime/runtime-whatsapp-boundary.ts new file mode 100644 index 00000000000..b44856b799a --- /dev/null +++ b/src/plugins/runtime/runtime-whatsapp-boundary.ts @@ -0,0 +1,339 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js"; +import { loadConfig } from "../../config/config.js"; +import { + getDefaultLocalRoots as getDefaultLocalRootsImpl, + loadWebMedia as loadWebMediaImpl, + loadWebMediaRaw as loadWebMediaRawImpl, + optimizeImageToJpeg as optimizeImageToJpegImpl, +} from "../../media/web-media.js"; +import { loadPluginManifestRegistry } from "../manifest-registry.js"; +import { + buildPluginLoaderJitiOptions, + resolvePluginSdkAliasFile, + resolvePluginSdkScopedAliasMap, + shouldPreferNativeJiti, +} from "../sdk-alias.js"; + +const WHATSAPP_PLUGIN_ID = "whatsapp"; + +type WhatsAppLightModule = typeof import("../../../extensions/whatsapp/light-runtime-api.js"); +type WhatsAppHeavyModule = typeof import("../../../extensions/whatsapp/runtime-api.js"); + +type WhatsAppPluginRecord = { + origin: string; + rootDir?: string; + source: string; +}; + +let cachedHeavyModulePath: string | null = null; +let cachedHeavyModule: WhatsAppHeavyModule | null = null; +let cachedLightModulePath: string | null = null; +let cachedLightModule: WhatsAppLightModule | null = null; + +const jitiLoaders = new Map>(); + +function readConfigSafely() { + try { + return loadConfig(); + } catch { + return {}; + } +} + +function resolveWhatsAppPluginRecord(): WhatsAppPluginRecord { + const manifestRegistry = loadPluginManifestRegistry({ + config: readConfigSafely(), + cache: true, + }); + const record = manifestRegistry.plugins.find((plugin) => plugin.id === WHATSAPP_PLUGIN_ID); + if (!record?.source) { + throw new Error( + `WhatsApp plugin runtime is unavailable: missing plugin '${WHATSAPP_PLUGIN_ID}'`, + ); + } + return { + origin: record.origin, + rootDir: record.rootDir, + source: record.source, + }; +} + +function resolveWhatsAppRuntimeModulePath( + record: WhatsAppPluginRecord, + entryBaseName: "light-runtime-api" | "runtime-api", +): string { + const candidates = [ + path.join(path.dirname(record.source), `${entryBaseName}.js`), + path.join(path.dirname(record.source), `${entryBaseName}.ts`), + ...(record.rootDir + ? [ + path.join(record.rootDir, `${entryBaseName}.js`), + path.join(record.rootDir, `${entryBaseName}.ts`), + ] + : []), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + throw new Error( + `WhatsApp plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WHATSAPP_PLUGIN_ID}'`, + ); +} + +function getJiti(modulePath: string) { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = jitiLoaders.get(tryNative); + if (cached) { + return cached; + } + const pluginSdkAlias = resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath: modulePath, + }); + const aliasMap = { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap({ modulePath }), + }; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + jitiLoaders.set(tryNative, loader); + return loader; +} + +function loadWithJiti(modulePath: string): TModule { + return getJiti(modulePath)(modulePath) as TModule; +} + +function loadCurrentHeavyModuleSync(): WhatsAppHeavyModule { + const modulePath = resolveWhatsAppRuntimeModulePath(resolveWhatsAppPluginRecord(), "runtime-api"); + return loadWithJiti(modulePath); +} + +function loadWhatsAppLightModule(): WhatsAppLightModule { + const modulePath = resolveWhatsAppRuntimeModulePath( + resolveWhatsAppPluginRecord(), + "light-runtime-api", + ); + if (cachedLightModule && cachedLightModulePath === modulePath) { + return cachedLightModule; + } + const loaded = loadWithJiti(modulePath); + cachedLightModulePath = modulePath; + cachedLightModule = loaded; + return loaded; +} + +async function loadWhatsAppHeavyModule(): Promise { + const record = resolveWhatsAppPluginRecord(); + const modulePath = resolveWhatsAppRuntimeModulePath(record, "runtime-api"); + if (cachedHeavyModule && cachedHeavyModulePath === modulePath) { + return cachedHeavyModule; + } + const loaded = loadWithJiti(modulePath); + cachedHeavyModulePath = modulePath; + cachedHeavyModule = loaded; + return loaded; +} + +function getLightExport( + exportName: K, +): NonNullable { + const loaded = loadWhatsAppLightModule(); + const value = loaded[exportName]; + if (value == null) { + throw new Error(`WhatsApp plugin runtime is missing export '${String(exportName)}'`); + } + return value as NonNullable; +} + +async function getHeavyExport( + exportName: K, +): Promise> { + const loaded = await loadWhatsAppHeavyModule(); + const value = loaded[exportName]; + if (value == null) { + throw new Error(`WhatsApp plugin runtime is missing export '${String(exportName)}'`); + } + return value as NonNullable; +} + +export function getActiveWebListener( + ...args: Parameters +): ReturnType { + return getLightExport("getActiveWebListener")(...args); +} + +export function getWebAuthAgeMs( + ...args: Parameters +): ReturnType { + return getLightExport("getWebAuthAgeMs")(...args); +} + +export function logWebSelfId( + ...args: Parameters +): ReturnType { + return getLightExport("logWebSelfId")(...args); +} + +export function loginWeb( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.loginWeb(...args)); +} + +export function logoutWeb( + ...args: Parameters +): ReturnType { + return getLightExport("logoutWeb")(...args); +} + +export function readWebSelfId( + ...args: Parameters +): ReturnType { + return getLightExport("readWebSelfId")(...args); +} + +export function webAuthExists( + ...args: Parameters +): ReturnType { + return getLightExport("webAuthExists")(...args); +} + +export function sendMessageWhatsApp( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.sendMessageWhatsApp(...args)); +} + +export function sendPollWhatsApp( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.sendPollWhatsApp(...args)); +} + +export function sendReactionWhatsApp( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.sendReactionWhatsApp(...args)); +} + +export function createRuntimeWhatsAppLoginTool( + ...args: Parameters +): ReturnType { + return getLightExport("createWhatsAppLoginTool")(...args); +} + +export function createWaSocket( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.createWaSocket(...args)); +} + +export function formatError( + ...args: Parameters +): ReturnType { + return getLightExport("formatError")(...args); +} + +export function getStatusCode( + ...args: Parameters +): ReturnType { + return getLightExport("getStatusCode")(...args); +} + +export function pickWebChannel( + ...args: Parameters +): ReturnType { + return getLightExport("pickWebChannel")(...args); +} + +export function resolveWaWebAuthDir(): WhatsAppLightModule["WA_WEB_AUTH_DIR"] { + return getLightExport("WA_WEB_AUTH_DIR"); +} + +export async function handleWhatsAppAction( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("handleWhatsAppAction"))(...args); +} + +export async function loadWebMedia( + ...args: Parameters +): ReturnType { + return await loadWebMediaImpl(...args); +} + +export async function loadWebMediaRaw( + ...args: Parameters +): ReturnType { + return await loadWebMediaRawImpl(...args); +} + +export function monitorWebChannel( + ...args: Parameters +): ReturnType { + return loadWhatsAppHeavyModule().then((loaded) => loaded.monitorWebChannel(...args)); +} + +export async function monitorWebInbox( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("monitorWebInbox"))(...args); +} + +export async function optimizeImageToJpeg( + ...args: Parameters +): ReturnType { + return await optimizeImageToJpegImpl(...args); +} + +export async function runWebHeartbeatOnce( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("runWebHeartbeatOnce"))(...args); +} + +export async function startWebLoginWithQr( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("startWebLoginWithQr"))(...args); +} + +export async function waitForWaConnection( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("waitForWaConnection"))(...args); +} + +export async function waitForWebLogin( + ...args: Parameters +): ReturnType { + return (await getHeavyExport("waitForWebLogin"))(...args); +} + +export const extractMediaPlaceholder = ( + ...args: Parameters +) => loadCurrentHeavyModuleSync().extractMediaPlaceholder(...args); + +export const extractText = (...args: Parameters) => + loadCurrentHeavyModuleSync().extractText(...args); + +export function getDefaultLocalRoots( + ...args: Parameters +): ReturnType { + return getDefaultLocalRootsImpl(...args); +} + +export function resolveHeartbeatRecipients( + ...args: Parameters +): ReturnType { + return resolveWhatsAppHeartbeatRecipients(...args); +} diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 33c2355cda1..577bf3aeb27 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1 +1 @@ -export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "openclaw/plugin-sdk/whatsapp"; +export { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-boundary.js"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index c0e89600bde..bb60f57d624 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1,4 +1,4 @@ -import { loginWeb as loginWebImpl } from "openclaw/plugin-sdk/whatsapp"; +import { loginWeb as loginWebImpl } from "./runtime-whatsapp-boundary.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppLogin = Pick; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index c213afe141e..7f3f3b07c05 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1,7 +1,7 @@ import { sendMessageWhatsApp as sendMessageWhatsAppImpl, sendPollWhatsApp as sendPollWhatsAppImpl, -} from "openclaw/plugin-sdk/whatsapp"; +} from "./runtime-whatsapp-boundary.js"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppOutbound = Pick< diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index ca266581d21..b49e7c4f14a 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,90 +1,21 @@ -import { getActiveWebListener } from "openclaw/plugin-sdk/whatsapp"; import { + createRuntimeWhatsAppLoginTool, + getActiveWebListener, getWebAuthAgeMs, + handleWhatsAppAction, logWebSelfId, + loginWeb, logoutWeb, + monitorWebChannel, readWebSelfId, + sendMessageWhatsApp, + sendPollWhatsApp, + startWebLoginWithQr, + waitForWebLogin, webAuthExists, -} from "openclaw/plugin-sdk/whatsapp"; -import { - createLazyRuntimeMethodBinder, - createLazyRuntimeSurface, -} from "../../shared/lazy-runtime.js"; -import { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-login-tool.js"; +} from "./runtime-whatsapp-boundary.js"; import type { PluginRuntime } from "./types.js"; -const loadWebOutbound = createLazyRuntimeSurface( - () => import("./runtime-whatsapp-outbound.runtime.js"), - ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, -); - -const loadWebLogin = createLazyRuntimeSurface( - () => import("./runtime-whatsapp-login.runtime.js"), - ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, -); - -const bindWhatsAppOutboundMethod = createLazyRuntimeMethodBinder(loadWebOutbound); -const bindWhatsAppLoginMethod = createLazyRuntimeMethodBinder(loadWebLogin); - -const sendMessageWhatsAppLazy = bindWhatsAppOutboundMethod( - (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp, -); -const sendPollWhatsAppLazy = bindWhatsAppOutboundMethod( - (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp, -); -const loginWebLazy = bindWhatsAppLoginMethod( - (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb, -); - -const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( - ...args -) => { - const { startWebLoginWithQr } = await loadWebLoginQr(); - return startWebLoginWithQr(...args); -}; - -const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async ( - ...args -) => { - const { waitForWebLogin } = await loadWebLoginQr(); - return waitForWebLogin(...args); -}; - -const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async ( - ...args -) => { - const { monitorWebChannel } = await loadWebChannel(); - return monitorWebChannel(...args); -}; - -const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] = - async (...args) => { - const { handleWhatsAppAction } = await loadWhatsAppActions(); - return handleWhatsAppAction(...args); - }; - -let webLoginQrPromise: Promise | null = - null; -let webChannelPromise: Promise | null = null; -let whatsappActionsPromise: Promise< - typeof import("openclaw/plugin-sdk/whatsapp-action-runtime") -> | null = null; - -function loadWebLoginQr() { - webLoginQrPromise ??= import("openclaw/plugin-sdk/whatsapp-login-qr"); - return webLoginQrPromise; -} - -function loadWebChannel() { - webChannelPromise ??= import("../../channels/web/index.js"); - return webChannelPromise; -} - -function loadWhatsAppActions() { - whatsappActionsPromise ??= import("openclaw/plugin-sdk/whatsapp-action-runtime"); - return whatsappActionsPromise; -} - export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] { return { getActiveWebListener, @@ -93,13 +24,13 @@ export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] { logWebSelfId, readWebSelfId, webAuthExists, - sendMessageWhatsApp: sendMessageWhatsAppLazy, - sendPollWhatsApp: sendPollWhatsAppLazy, - loginWeb: loginWebLazy, - startWebLoginWithQr: startWebLoginWithQrLazy, - waitForWebLogin: waitForWebLoginLazy, - monitorWebChannel: monitorWebChannelLazy, - handleWhatsAppAction: handleWhatsAppActionLazy, + sendMessageWhatsApp, + sendPollWhatsApp, + loginWeb, + startWebLoginWithQr, + waitForWebLogin, + monitorWebChannel, + handleWhatsAppAction, createLoginTool: createRuntimeWhatsAppLoginTool, }; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index b5f9a8e8e7a..a0fe9a1d9bc 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -205,19 +205,19 @@ export type PluginRuntimeChannel = { sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("openclaw/plugin-sdk/whatsapp").getActiveWebListener; - getWebAuthAgeMs: typeof import("openclaw/plugin-sdk/whatsapp").getWebAuthAgeMs; - logoutWeb: typeof import("openclaw/plugin-sdk/whatsapp").logoutWeb; - logWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").logWebSelfId; - readWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").readWebSelfId; - webAuthExists: typeof import("openclaw/plugin-sdk/whatsapp").webAuthExists; - sendMessageWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendPollWhatsApp; - loginWeb: typeof import("openclaw/plugin-sdk/whatsapp").loginWeb; - startWebLoginWithQr: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").startWebLoginWithQr; - waitForWebLogin: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").waitForWebLogin; - monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("openclaw/plugin-sdk/whatsapp-action-runtime").handleWhatsAppAction; + getActiveWebListener: typeof import("./runtime-whatsapp-boundary.js").getActiveWebListener; + getWebAuthAgeMs: typeof import("./runtime-whatsapp-boundary.js").getWebAuthAgeMs; + logoutWeb: typeof import("./runtime-whatsapp-boundary.js").logoutWeb; + logWebSelfId: typeof import("./runtime-whatsapp-boundary.js").logWebSelfId; + readWebSelfId: typeof import("./runtime-whatsapp-boundary.js").readWebSelfId; + webAuthExists: typeof import("./runtime-whatsapp-boundary.js").webAuthExists; + sendMessageWhatsApp: typeof import("./runtime-whatsapp-boundary.js").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("./runtime-whatsapp-boundary.js").sendPollWhatsApp; + loginWeb: typeof import("./runtime-whatsapp-boundary.js").loginWeb; + startWebLoginWithQr: typeof import("./runtime-whatsapp-boundary.js").startWebLoginWithQr; + waitForWebLogin: typeof import("./runtime-whatsapp-boundary.js").waitForWebLogin; + monitorWebChannel: typeof import("./runtime-whatsapp-boundary.js").monitorWebChannel; + handleWhatsAppAction: typeof import("./runtime-whatsapp-boundary.js").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 2ca6f6c035a..35d5d52c2a6 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -39,7 +39,7 @@ export type PluginRuntimeCore = { formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint; }; media: { - loadWebMedia: typeof import("../../../extensions/whatsapp/runtime-api.js").loadWebMedia; + loadWebMedia: typeof import("../../media/web-media.js").loadWebMedia; detectMime: typeof import("../../media/mime.js").detectMime; mediaKindFromMime: typeof import("../../media/constants.js").mediaKindFromMime; isVoiceCompatibleAudio: typeof import("../../media/audio.js").isVoiceCompatibleAudio; diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts new file mode 100644 index 00000000000..7f172b8d3dd --- /dev/null +++ b/src/plugins/sdk-alias.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; + +type PluginSdkAliasCandidateKind = "dist" | "src"; + +export type LoaderModuleResolveParams = { + modulePath?: string; + argv1?: string; + cwd?: string; + moduleUrl?: string; +}; + +function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string { + return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url); +} + +export function resolveLoaderPackageRoot( + params: LoaderModuleResolveParams & { modulePath: string }, +): string | null { + const cwd = params.cwd ?? path.dirname(params.modulePath); + const fromModulePath = resolveOpenClawPackageRootSync({ cwd }); + if (fromModulePath) { + return fromModulePath; + } + const argv1 = params.argv1 ?? process.argv[1]; + const moduleUrl = params.moduleUrl ?? (params.modulePath ? undefined : import.meta.url); + return resolveOpenClawPackageRootSync({ + cwd, + ...(argv1 ? { argv1 } : {}), + ...(moduleUrl ? { moduleUrl } : {}), + }); +} + +export function resolvePluginSdkAliasCandidateOrder(params: { + modulePath: string; + isProduction: boolean; +}): PluginSdkAliasCandidateKind[] { + const normalizedModulePath = params.modulePath.replace(/\\/g, "/"); + const isDistRuntime = normalizedModulePath.includes("/dist/"); + return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"]; +} + +export function listPluginSdkAliasCandidates(params: { + srcFile: string; + distFile: string; + modulePath: string; + argv1?: string; + cwd?: string; + moduleUrl?: string; +}) { + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath: params.modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const packageRoot = resolveLoaderPackageRoot(params); + if (packageRoot) { + const candidateMap = { + src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile), + dist: path.join(packageRoot, "dist", "plugin-sdk", params.distFile), + } as const; + return orderedKinds.map((kind) => candidateMap[kind]); + } + let cursor = path.dirname(params.modulePath); + const candidates: string[] = []; + for (let i = 0; i < 6; i += 1) { + const candidateMap = { + src: path.join(cursor, "src", "plugin-sdk", params.srcFile), + dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), + } as const; + for (const kind of orderedKinds) { + candidates.push(candidateMap[kind]); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return candidates; +} + +export function resolvePluginSdkAliasFile(params: { + srcFile: string; + distFile: string; + modulePath?: string; + argv1?: string; + cwd?: string; + moduleUrl?: string; +}): string | null { + try { + const modulePath = resolveLoaderModulePath(params); + for (const candidate of listPluginSdkAliasCandidates({ + srcFile: params.srcFile, + distFile: params.distFile, + modulePath, + argv1: params.argv1, + cwd: params.cwd, + moduleUrl: params.moduleUrl, + })) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +} + +const cachedPluginSdkExportedSubpaths = new Map(); + +export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: path.dirname(modulePath), + }); + if (!packageRoot) { + return []; + } + const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); + if (cached) { + return cached; + } + try { + const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); + const pkg = JSON.parse(pkgRaw) as { + exports?: Record; + }; + const subpaths = Object.keys(pkg.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)) + .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) + .toSorted(); + cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); + return subpaths; + } catch { + return []; + } +} + +export function resolvePluginSdkScopedAliasMap( + params: { modulePath?: string } = {}, +): Record { + const aliasMap: Record = {}; + for (const subpath of listPluginSdkExportedSubpaths(params)) { + const resolved = resolvePluginSdkAliasFile({ + srcFile: `${subpath}.ts`, + distFile: `${subpath}.js`, + modulePath: params.modulePath, + }); + if (resolved) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; + } + } + return aliasMap; +} + +export function buildPluginLoaderJitiOptions(aliasMap: Record) { + return { + interopDefault: true, + // Prefer Node's native sync ESM loader for built dist/*.js modules so + // bundled plugins and plugin-sdk subpaths stay on the canonical module graph. + tryNative: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 + ? { + alias: aliasMap, + } + : {}), + }; +} + +export function shouldPreferNativeJiti(modulePath: string): boolean { + switch (path.extname(modulePath).toLowerCase()) { + case ".js": + case ".mjs": + case ".cjs": + case ".json": + return true; + default: + return false; + } +}