From c454fe0fb372eeaaf933cebe16c037d2fcef0241 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 23:00:32 +0000 Subject: [PATCH] refactor: adopt chat plugin builder in whatsapp --- extensions/whatsapp/src/channel.ts | 535 +++++++++++++++-------------- 1 file changed, 272 insertions(+), 263 deletions(-) diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index db858d9741c..529e6f8b44f 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,5 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; // 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"; @@ -52,269 +53,277 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } -export const whatsappPlugin: ChannelPlugin = { - ...createWhatsAppPluginBase({ - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, +export const whatsappPlugin: ChannelPlugin = + createChatChannelPlugin({ + pairing: { + idLabel: "whatsappSenderId", }, - setupWizard: whatsappSetupWizardProxy, - setup: whatsappSetupAdapter, - isConfigured: async (account) => - await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), - }), - agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], - pairing: { - idLabel: "whatsappSenderId", - }, - allowlist: buildDmGroupAccountAllowlistAdapter({ - channelId: "whatsapp", - resolveAccount: resolveWhatsAppAccount, - normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), - resolveDmAllowFrom: (account) => account.allowFrom, - resolveGroupAllowFrom: (account) => account.groupAllowFrom, - resolveDmPolicy: (account) => account.dmPolicy, - resolveGroupPolicy: (account) => account.groupPolicy, - }), - mentions: { - stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), - }, - commands: { - enforceOwnerForCommands: true, - skipWhenConfigEmpty: true, - }, - messaging: { - normalizeTarget: normalizeWhatsAppMessagingTarget, - resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params), - parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw), - inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType, - targetResolver: { - looksLikeId: looksLikeWhatsAppTargetId, - hint: "", - }, - }, - directory: { - self: async ({ cfg, accountId }) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); - const id = e164 ?? jid; - if (!id) { - return null; - } - return { - kind: "user", - id, - name: account.name, - raw: { e164, jid }, - }; - }, - listPeers: async (params) => listWhatsAppDirectoryPeersFromConfig(params), - listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params), - }, - actions: { - describeMessageTool: ({ cfg }) => { - if (!cfg.channels?.whatsapp) { - return null; - } - const gate = createActionGate(cfg.channels.whatsapp.actions); - const actions = new Set(); - if (gate("reactions")) { - actions.add("react"); - } - if (gate("polls")) { - actions.add("poll"); - } - return { actions: Array.from(actions) }; - }, - supportsAction: ({ action }) => action === "react", - handleAction: async ({ action, params, cfg, accountId }) => { - if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); - } - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction( - { - action: "react", - chatJid: - readStringParam(params, "chatJid") ?? readStringParam(params, "to", { required: true }), - messageId, - emoji, - remove, - participant: readStringParam(params, "participant"), - accountId: accountId ?? undefined, - fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, - }, - cfg, - ); - }, - }, - outbound: { - ...createWhatsAppOutboundBase({ - chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), - sendMessageWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), - sendPollWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), - shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), - resolveTarget: ({ to, allowFrom, mode }) => - resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - }), - normalizePayload: ({ payload }) => ({ - ...payload, - text: normalizeWhatsAppPayloadText(payload.text), - }), - }, - auth: { - login: async ({ cfg, accountId, runtime, verbose }) => { - const resolvedAccountId = - accountId?.trim() || whatsappPlugin.config.defaultAccountId?.(cfg) || DEFAULT_ACCOUNT_ID; - await ( - await loadWhatsAppChannelRuntime() - ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); - }, - }, - heartbeat: { - checkReady: async ({ cfg, accountId, deps }) => { - if (cfg.web?.enabled === false) { - return { ok: false, reason: "whatsapp-disabled" }; - } - const account = resolveWhatsAppAccount({ cfg, accountId }); - const authExists = await ( - deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists - )(account.authDir); - if (!authExists) { - return { ok: false, reason: "whatsapp-not-linked" }; - } - const listenerActive = deps?.hasActiveWebListener - ? deps.hasActiveWebListener() - : Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener()); - if (!listenerActive) { - return { ok: false, reason: "whatsapp-not-running" }; - } - return { ok: true, reason: "ok" }; - }, - resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts), - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastInboundAt: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, - healthState: "stopped", - }, - collectStatusIssues: collectWhatsAppStatusIssues, - buildChannelSummary: async ({ account, snapshot }) => { - const authDir = account.authDir; - const linked = - typeof snapshot.linked === "boolean" - ? snapshot.linked - : authDir - ? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir) - : false; - const authAgeMs = - linked && authDir ? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir) : null; - const self = - linked && authDir - ? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir) - : { e164: null, jid: null }; - return { - configured: linked, - linked, - authAgeMs, - self, - running: snapshot.running ?? false, - connected: snapshot.connected ?? false, - lastConnectedAt: snapshot.lastConnectedAt ?? null, - lastDisconnect: snapshot.lastDisconnect ?? null, - reconnectAttempts: snapshot.reconnectAttempts, - lastInboundAt: snapshot.lastInboundAt ?? snapshot.lastMessageAt ?? null, - lastMessageAt: snapshot.lastMessageAt ?? null, - lastEventAt: snapshot.lastEventAt ?? null, - lastError: snapshot.lastError ?? null, - healthState: snapshot.healthState ?? undefined, - }; - }, - buildAccountSnapshot: async ({ account, runtime }) => { - const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: true, - linked, - running: runtime?.running ?? false, - connected: runtime?.connected ?? false, - reconnectAttempts: runtime?.reconnectAttempts, - lastConnectedAt: runtime?.lastConnectedAt ?? null, - lastDisconnect: runtime?.lastDisconnect ?? null, - lastInboundAt: runtime?.lastInboundAt ?? runtime?.lastMessageAt ?? null, - lastMessageAt: runtime?.lastMessageAt ?? null, - lastEventAt: runtime?.lastEventAt ?? null, - lastError: runtime?.lastError ?? null, - healthState: runtime?.healthState ?? undefined, - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }; - }, - resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), - logSelfId: ({ account, runtime, includeChannelPrefix }) => { - void loadWhatsAppChannelRuntime().then((runtimeExports) => - runtimeExports.logWebSelfId(account.authDir, runtime, includeChannelPrefix), - ); - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); - return (await loadWhatsAppChannelRuntime()).monitorWebChannel( - getWhatsAppRuntime().logging.shouldLogVerbose(), - undefined, - true, - undefined, - ctx.runtime, - ctx.abortSignal, - { - statusSink: (next: WebChannelStatus) => - ctx.setStatus({ accountId: ctx.accountId, ...next }), - accountId: account.accountId, - }, - ); - }, - loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => - await ( - await loadWhatsAppChannelRuntime() - ).startWebLoginWithQr({ - accountId, - force, - timeoutMs, - verbose, + outbound: { + ...createWhatsAppOutboundBase({ + chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), + sendMessageWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), + sendPollWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), + shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), + resolveTarget: ({ to, allowFrom, mode }) => + resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), + }), + normalizePayload: ({ payload }) => ({ + ...payload, + text: normalizeWhatsAppPayloadText(payload.text), }), - loginWithQrWait: async ({ accountId, timeoutMs }) => - await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }), - logoutAccount: async ({ account, runtime }) => { - const cleared = await ( - await loadWhatsAppChannelRuntime() - ).logoutWeb({ - authDir: account.authDir, - isLegacyAuthDir: account.isLegacyAuthDir, - runtime, - }); - return { cleared, loggedOut: cleared }; }, - }, -}; + base: { + ...createWhatsAppPluginBase({ + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), + }), + agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "whatsapp", + resolveAccount: resolveWhatsAppAccount, + normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.allowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + }), + mentions: { + stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), + }, + commands: { + enforceOwnerForCommands: true, + skipWhenConfigEmpty: true, + }, + messaging: { + normalizeTarget: normalizeWhatsAppMessagingTarget, + resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params), + parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType, + targetResolver: { + looksLikeId: looksLikeWhatsAppTargetId, + hint: "", + }, + }, + directory: { + self: async ({ cfg, accountId }) => { + const account = resolveWhatsAppAccount({ cfg, accountId }); + const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); + const id = e164 ?? jid; + if (!id) { + return null; + } + return { + kind: "user", + id, + name: account.name, + raw: { e164, jid }, + }; + }, + listPeers: async (params) => listWhatsAppDirectoryPeersFromConfig(params), + listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params), + }, + actions: { + describeMessageTool: ({ cfg }) => { + if (!cfg.channels?.whatsapp) { + return null; + } + const gate = createActionGate(cfg.channels.whatsapp.actions); + const actions = new Set(); + if (gate("reactions")) { + actions.add("react"); + } + if (gate("polls")) { + actions.add("poll"); + } + return { actions: Array.from(actions) }; + }, + supportsAction: ({ action }) => action === "react", + handleAction: async ({ action, params, cfg, accountId }) => { + if (action !== "react") { + throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); + } + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = typeof params.remove === "boolean" ? params.remove : undefined; + return await getWhatsAppRuntime().channel.whatsapp.handleWhatsAppAction( + { + action: "react", + chatJid: + readStringParam(params, "chatJid") ?? + readStringParam(params, "to", { required: true }), + messageId, + emoji, + remove, + participant: readStringParam(params, "participant"), + accountId: accountId ?? undefined, + fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, + }, + cfg, + ); + }, + }, + auth: { + login: async ({ cfg, accountId, runtime, verbose }) => { + const resolvedAccountId = + accountId?.trim() || + whatsappPlugin.config.defaultAccountId?.(cfg) || + DEFAULT_ACCOUNT_ID; + await ( + await loadWhatsAppChannelRuntime() + ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); + }, + }, + heartbeat: { + checkReady: async ({ cfg, accountId, deps }) => { + if (cfg.web?.enabled === false) { + return { ok: false, reason: "whatsapp-disabled" }; + } + const account = resolveWhatsAppAccount({ cfg, accountId }); + const authExists = await ( + deps?.webAuthExists ?? (await loadWhatsAppChannelRuntime()).webAuthExists + )(account.authDir); + if (!authExists) { + return { ok: false, reason: "whatsapp-not-linked" }; + } + const listenerActive = deps?.hasActiveWebListener + ? deps.hasActiveWebListener() + : Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener()); + if (!listenerActive) { + return { ok: false, reason: "whatsapp-not-running" }; + } + return { ok: true, reason: "ok" }; + }, + resolveRecipients: ({ cfg, opts }) => resolveWhatsAppHeartbeatRecipients(cfg, opts), + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastInboundAt: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + healthState: "stopped", + }, + collectStatusIssues: collectWhatsAppStatusIssues, + buildChannelSummary: async ({ account, snapshot }) => { + const authDir = account.authDir; + const linked = + typeof snapshot.linked === "boolean" + ? snapshot.linked + : authDir + ? await (await loadWhatsAppChannelRuntime()).webAuthExists(authDir) + : false; + const authAgeMs = + linked && authDir + ? (await loadWhatsAppChannelRuntime()).getWebAuthAgeMs(authDir) + : null; + const self = + linked && authDir + ? (await loadWhatsAppChannelRuntime()).readWebSelfId(authDir) + : { e164: null, jid: null }; + return { + configured: linked, + linked, + authAgeMs, + self, + running: snapshot.running ?? false, + connected: snapshot.connected ?? false, + lastConnectedAt: snapshot.lastConnectedAt ?? null, + lastDisconnect: snapshot.lastDisconnect ?? null, + reconnectAttempts: snapshot.reconnectAttempts, + lastInboundAt: snapshot.lastInboundAt ?? snapshot.lastMessageAt ?? null, + lastMessageAt: snapshot.lastMessageAt ?? null, + lastEventAt: snapshot.lastEventAt ?? null, + lastError: snapshot.lastError ?? null, + healthState: snapshot.healthState ?? undefined, + }; + }, + buildAccountSnapshot: async ({ account, runtime }) => { + const linked = await (await loadWhatsAppChannelRuntime()).webAuthExists(account.authDir); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: true, + linked, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + reconnectAttempts: runtime?.reconnectAttempts, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastDisconnect: runtime?.lastDisconnect ?? null, + lastInboundAt: runtime?.lastInboundAt ?? runtime?.lastMessageAt ?? null, + lastMessageAt: runtime?.lastMessageAt ?? null, + lastEventAt: runtime?.lastEventAt ?? null, + lastError: runtime?.lastError ?? null, + healthState: runtime?.healthState ?? undefined, + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }; + }, + resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), + logSelfId: ({ account, runtime, includeChannelPrefix }) => { + void loadWhatsAppChannelRuntime().then((runtimeExports) => + runtimeExports.logWebSelfId(account.authDir, runtime, includeChannelPrefix), + ); + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const { e164, jid } = (await loadWhatsAppChannelRuntime()).readWebSelfId(account.authDir); + const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; + ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); + return (await loadWhatsAppChannelRuntime()).monitorWebChannel( + getWhatsAppRuntime().logging.shouldLogVerbose(), + undefined, + true, + undefined, + ctx.runtime, + ctx.abortSignal, + { + statusSink: (next: WebChannelStatus) => + ctx.setStatus({ accountId: ctx.accountId, ...next }), + accountId: account.accountId, + }, + ); + }, + loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => + await ( + await loadWhatsAppChannelRuntime() + ).startWebLoginWithQr({ + accountId, + force, + timeoutMs, + verbose, + }), + loginWithQrWait: async ({ accountId, timeoutMs }) => + await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }), + logoutAccount: async ({ account, runtime }) => { + const cleared = await ( + await loadWhatsAppChannelRuntime() + ).logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime, + }); + return { cleared, loggedOut: cleared }; + }, + }, + }, + });