Files
openclaw/extensions/whatsapp/src/channel.ts
Peter Steinberger 75dbe52e3e refactor: one code path behind doctor legacy-state migrations (#120716)
* refactor(doctor): prefer manifest route-state owners

* refactor(doctor): unify config repair declarations

* refactor(doctor): unify legacy state migrations

* fix(doctor): satisfy migration pipeline guards

* fix(plugin-sdk): keep doctor adapter inside boundary
2026-08-08 18:23:15 -07:00

375 lines
15 KiB
TypeScript

// 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<string, unknown> }) {
const chatJid = params.args.chatJid;
return typeof chatJid === "string" ? normalizeWhatsAppMessagingTarget(chatJid) : undefined;
}
export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
createChatChannelPlugin<ResolvedWhatsAppAccount>({
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: "<E.164|group JID|newsletter JID>",
},
},
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<ResolvedWhatsAppAccount>({
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 };
},
},
},
});