refactor: untangle whatsapp runtime boundary

This commit is contained in:
Peter Steinberger
2026-03-19 03:12:16 +00:00
parent 510f4276b5
commit 30a94dfd3b
33 changed files with 848 additions and 474 deletions

View File

@@ -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";

View File

@@ -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"

View File

@@ -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";

View File

@@ -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({

View File

@@ -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<typeof import("./login-qr.js")> | null = null;
@@ -75,8 +75,8 @@ export async function waitForWebLogin(
export const whatsappSetupWizard: WhatsAppSetupWizard = { ...whatsappSetupWizardImpl };
export async function monitorWebChannel(
export function monitorWebChannel(
...args: Parameters<MonitorWebChannel>
): ReturnType<MonitorWebChannel> {
return await monitorWebChannelImpl(...args);
return monitorWebChannelImpl(...args);
}

View File

@@ -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<ResolvedWhatsAppAccount> = {
ctx.runtime,
ctx.abortSignal,
{
statusSink: (next) => ctx.setStatus({ accountId: ctx.accountId, ...next }),
statusSink: (next: WebChannelStatus) =>
ctx.setStatus({ accountId: ctx.accountId, ...next }),
accountId: account.accountId,
},
);

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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<typeof makeWASocket>)
});
}
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<void> {
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();
}