// Whatsapp plugin module implements channel behavior. import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { createWhatsAppLoginTool } from "./agent-tools-login.js"; import { whatsappApprovalCapability } from "./approval-native.js"; import type { WebChannelStatus } from "./auto-reply/types.js"; import { describeWhatsAppMessageActions, resolveWhatsAppAgentReactionGuidance, } from "./channel-actions.js"; import { whatsappChannelOutbound, whatsappMessageAdapter } from "./channel-outbound.js"; import { loadWhatsAppChannelRuntime, readWhatsAppAccountLinkState, } from "./channel-runtime-loader.js"; import { whatsappCommandPolicy } from "./command-policy.js"; import { formatWhatsAppConfigAllowFromEntries } from "./config-accessors.js"; import { resolveWhatsAppMentionStripRegexes } from "./group-intro.js"; import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./group-policy.js"; import { checkWhatsAppHeartbeatReady } from "./heartbeat.js"; import { isWhatsAppGroupJid, isWhatsAppNewsletterJid, looksLikeWhatsAppTargetId, normalizeWhatsAppAllowFromEntry, normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, } from "./normalize.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { sendTypingWhatsApp } from "./send.js"; import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js"; import { whatsappSetupContract } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const loadWhatsAppDirectoryConfig = createLazyRuntimeModule(() => import("./directory-config.js")); const loadWhatsAppChannelReactAction = createLazyRuntimeModule( () => import("./channel-react-action.js"), ); function resolveWhatsAppTargetInfo(raw: string) { const normalized = normalizeWhatsAppTarget(raw); if (!normalized) { return null; } return { to: normalized, chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : isWhatsAppNewsletterJid(normalized) ? ("channel" as const) : ("direct" as const), }; } function resolveWhatsAppMessageActionTarget(params: { args: Record }) { const chatJid = params.args.chatJid; return typeof chatJid === "string" ? normalizeWhatsAppMessagingTarget(chatJid) : undefined; } export const whatsappPlugin: ChannelPlugin = createChatChannelPlugin({ pairing: { idLabel: "whatsappSenderId", normalizeAllowEntry: (entry) => normalizeWhatsAppAllowFromEntry(entry) ?? "", }, outbound: whatsappChannelOutbound, threading: { scopedAccountReplyToMode: { resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), resolveReplyToMode: (account) => account.replyToMode, }, }, base: { ...createWhatsAppPluginBase({ groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, }, setupWizard: whatsappSetupWizardProxy, setupContract: whatsappSetupContract, isConfigured: (account) => Boolean(account.authDir), isLinked: async (account) => await readWhatsAppAccountLinkState(account.authDir), }), agentTools: () => [createWhatsAppLoginTool()], 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: whatsappCommandPolicy, bindings: { compileConfiguredBinding: ({ conversationId }) => { const normalized = normalizeWhatsAppTarget(conversationId); return normalized ? { conversationId: normalized } : null; }, matchInboundConversation: ({ compiledBinding, conversationId }) => { const normalizedConversationId = normalizeWhatsAppTarget(conversationId); if (normalizedConversationId === compiledBinding.conversationId) { return { conversationId: compiledBinding.conversationId, matchPriority: 2 }; } return null; }, }, agentPrompt: { reactionGuidance: ({ cfg, accountId }) => { const level = resolveWhatsAppAgentReactionGuidance({ cfg, accountId: accountId ?? undefined, }); return level ? { level, channelLabel: "WhatsApp" } : undefined; }, }, messaging: { targetPrefixes: ["whatsapp"], normalizeTarget: normalizeWhatsAppMessagingTarget, resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params), inferTargetChatType: ({ to }) => resolveWhatsAppTargetInfo(to)?.chatType, targetResolver: { looksLikeId: looksLikeWhatsAppTargetId, hint: "", }, }, message: whatsappMessageAdapter, 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) => (await loadWhatsAppDirectoryConfig()).listWhatsAppDirectoryPeersFromConfig(params), listGroups: async (params) => (await loadWhatsAppDirectoryConfig()).listWhatsAppDirectoryGroupsFromConfig(params), listGroupsLive: async (params) => (await loadWhatsAppDirectoryConfig()).listWhatsAppDirectoryGroupsLive(params), }, actions: { messageActionTargetAliases: { react: { aliases: ["chatJid", "messageId"], deliveryTargetAliases: ["chatJid"], resolveDeliveryTarget: resolveWhatsAppMessageActionTarget, }, }, describeMessageTool: ({ cfg, accountId }) => describeWhatsAppMessageActions({ cfg, accountId }), supportsAction: ({ action }) => action === "react" || action === "upload-file", resolveExecutionMode: ({ action }) => action === "react" || action === "upload-file" ? "gateway" : "local", handleAction: async ({ action, params, cfg, accountId, requesterSenderId, mediaAccess, mediaLocalRoots, mediaReadFile, toolContext, }) => await ( await loadWhatsAppChannelReactAction() ).handleWhatsAppMessageAction({ action, params, cfg, accountId, requesterSenderId, mediaAccess, mediaLocalRoots, mediaReadFile, toolContext, }), }, approvalCapability: whatsappApprovalCapability, 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 }) => await checkWhatsAppHeartbeatReady({ cfg, accountId: accountId ?? undefined, deps }), sendTyping: async ({ cfg, to, accountId }) => { await sendTypingWhatsApp(to, { cfg, ...(accountId ? { accountId } : {}), }); }, }, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { connected: false, reconnectAttempts: 0, lastConnectedAt: null, lastDisconnect: null, lastInboundAt: null, lastMessageAt: null, lastEventAt: null, busy: false, lastRunActivityAt: null, healthState: "stopped", lifecycle: "stopped" as const, }), collectStatusIssues: collectWhatsAppStatusIssues, buildChannelSummary: async ({ account, snapshot }) => { const channelRuntime = await loadWhatsAppChannelRuntime(); const authDir = account.authDir; const auth = authDir ? await channelRuntime.readWebAuthSnapshot(authDir) : { state: "not-linked" as const, authAgeMs: null, selfId: { e164: null, jid: null, lid: null }, }; const linked = snapshot.healthState === "logged-out" ? false : typeof snapshot.linked === "boolean" ? snapshot.linked : auth.state === "unstable" ? undefined : auth.state === "linked"; const summaryAuthState = auth.state === "unstable" ? auth.state : linked === true ? "linked" : linked === false ? "not-linked" : undefined; const statusState = summaryAuthState === undefined ? undefined : summaryAuthState; const authAgeMs = typeof linked === "boolean" && linked ? auth.authAgeMs : null; const self = typeof linked === "boolean" && linked ? auth.selfId : { e164: null, jid: null, lid: null }; return { configured: Boolean(account.authDir), ...(statusState ? { statusState } : {}), ...(typeof linked === "boolean" ? { 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, busy: snapshot.busy ?? false, lastRunActivityAt: snapshot.lastRunActivityAt ?? null, lastError: snapshot.lastError ?? null, healthState: snapshot.healthState ?? undefined, lifecycle: snapshot.lifecycle ?? undefined, ...(snapshot.terminalDisconnect ? { terminalDisconnect: snapshot.terminalDisconnect } : {}), }; }, resolveAccountSnapshot: ({ account, runtime }) => { const locallyRevoked = runtime?.healthState === "logged-out"; return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.authDir), extra: { ...(locallyRevoked ? { statusState: "not-linked", linked: 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, busy: runtime?.busy ?? false, lastRunActivityAt: runtime?.lastRunActivityAt ?? null, healthState: runtime?.healthState ?? undefined, ...(runtime?.terminalDisconnect ? { terminalDisconnect: runtime.terminalDisconnect } : {}), dmPolicy: account.dmPolicy, allowFrom: account.allowFrom, }, }; }, 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, channelRuntime: ctx.channelRuntime, }, ); }, loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => await ( await loadWhatsAppChannelRuntime() ).startWebLoginWithQr({ accountId, force, timeoutMs, verbose, }), loginWithQrWait: async ({ accountId, timeoutMs, currentQrDataUrl }) => await ( await loadWhatsAppChannelRuntime() ).waitForWebLogin({ accountId, timeoutMs, currentQrDataUrl }), logoutAccount: async ({ account, runtime }) => { const cleared = await ( await loadWhatsAppChannelRuntime() ).logoutWeb({ authDir: account.authDir, isLegacyAuthDir: account.isLegacyAuthDir, runtime, }); return { cleared, loggedOut: cleared }; }, }, }, });