Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw WhatsApp
Official OpenClaw channel plugin for WhatsApp Web chats.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/whatsapp
```
Link a WhatsApp account through the plugin's setup flow, then configure which chats OpenClaw agents should monitor and reply to.

View File

@@ -0,0 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
export { handleWhatsAppAction } from "./src/action-runtime.js";

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements action runtime behavior.
export { handleWhatsAppAction } from "./src/action-runtime.js";

View File

@@ -0,0 +1,72 @@
// Whatsapp API module exposes the plugin public contract.
export { whatsappPlugin } from "./src/channel.js";
export { whatsappSetupPlugin } from "./src/channel.setup.js";
export {
DEFAULT_WHATSAPP_MEDIA_MAX_MB,
hasAnyWhatsAppAuth,
listEnabledWhatsAppAccounts,
listWhatsAppAccountIds,
listWhatsAppAuthDirs,
resolveDefaultWhatsAppAccountId,
type ResolvedWhatsAppAccount,
resolveWhatsAppAccount,
resolveWhatsAppAuthDir,
resolveWhatsAppMediaMaxBytes,
} from "./src/accounts.js";
export { DEFAULT_WEB_MEDIA_BYTES } from "./src/auto-reply/constants.js";
export { whatsappCommandPolicy } from "./src/command-policy.js";
export {
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupToolPolicy,
} from "./src/group-policy.js";
export { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
export {
assertWebChannel,
isSelfChatMode,
jidToE164,
markdownToWhatsApp,
normalizeE164,
resolveJidToE164,
resolveUserPath,
toWhatsappJid,
toWhatsappJidWithLid,
type JidToE164Options,
type WebChannel,
} from "./src/text-runtime.js";
export {
type WebChannelHealthState,
type WebChannelStatus,
type WebInboundMsg,
type WebMonitorTuning,
} from "./src/auto-reply/types.js";
export {
type ActiveWebListener,
type ActiveWebSendOptions,
type LegacyFlatWebInboundMessage,
type WebInboundCallbackMessage,
type WebInboundMessage,
type WebInboundMessageInput,
type WebListenerCloseReason,
type WhatsAppStructuredContactContext,
} from "./src/inbound/types.js";
export type { WhatsAppInboundAdmission } from "./src/inbound/admission.js";
export {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export { resolveWhatsAppOutboundTarget } from "./src/resolve-outbound-target.js";
export {
isWhatsAppGroupJid,
normalizeWhatsAppAllowFromEntries,
isWhatsAppUserTarget,
looksLikeWhatsAppTargetId,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
} from "./src/normalize-target.js";
export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js";
export { testing as whatsappAccessControlTesting } from "./src/inbound/access-control.js";
export {
startWhatsAppQaDriverSession,
type WhatsAppQaDriverObservedMessage,
type WhatsAppQaDriverSession,
} from "./src/qa-driver.runtime.js";

View File

@@ -0,0 +1,81 @@
// Whatsapp plugin module implements auth presence behavior.
import fs from "node:fs";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveUserPath } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { hasWebCredsSync } from "./src/creds-files.js";
type WhatsAppAuthPresenceParams =
| {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}
| OpenClawConfig;
function addAccountAuthDirs(
authDirs: Set<string>,
accountId: string,
authDir: string | undefined,
accountsRoot: string,
env: NodeJS.ProcessEnv,
): void {
authDirs.add(path.join(accountsRoot, normalizeAccountId(accountId)));
const configuredAuthDir = authDir?.trim();
if (configuredAuthDir) {
authDirs.add(resolveUserPath(configuredAuthDir, env));
}
}
function listWhatsAppAuthDirs(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): readonly string[] {
const oauthDir = resolveOAuthDir(env);
const accountsRoot = path.join(oauthDir, "whatsapp");
const channel = cfg.channels?.whatsapp;
const authDirs = new Set<string>([oauthDir, path.join(accountsRoot, DEFAULT_ACCOUNT_ID)]);
addAccountAuthDirs(authDirs, DEFAULT_ACCOUNT_ID, undefined, accountsRoot, env);
if (channel?.defaultAccount?.trim()) {
addAccountAuthDirs(
authDirs,
channel.defaultAccount,
channel.accounts?.[channel.defaultAccount]?.authDir,
accountsRoot,
env,
);
}
const accounts = channel?.accounts;
if (accounts) {
for (const [accountId, account] of Object.entries(accounts)) {
addAccountAuthDirs(authDirs, accountId, account?.authDir, accountsRoot, env);
}
}
try {
const entries = fs.readdirSync(accountsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
authDirs.add(path.join(accountsRoot, entry.name));
}
}
} catch {
// Missing directories mean no auth state.
}
return [...authDirs];
}
export function hasAnyWhatsAppAuth(
params: WhatsAppAuthPresenceParams,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const cfg = params && typeof params === "object" && "cfg" in params ? params.cfg : params;
const resolvedEnv =
params && typeof params === "object" && "cfg" in params ? (params.env ?? env) : env;
return listWhatsAppAuthDirs(cfg, resolvedEnv).some((authDir) => hasWebCredsSync(authDir));
}

View File

@@ -0,0 +1,2 @@
// WhatsApp call tool facade keeps the bundled entrypoint light during discovery.
export { registerWhatsAppCallTool } from "./src/agent-tools-call.js";

View File

@@ -0,0 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
export { WhatsAppChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,3 @@
// Keep bundled channel bootstrap loads narrow so lightweight channel entry
// loads do not import setup-only surfaces.
export { whatsappPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,5 @@
// Whatsapp API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
WhatsAppConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements constants behavior.
export { DEFAULT_WEB_MEDIA_BYTES } from "./src/auto-reply/constants.js";

View File

@@ -0,0 +1,22 @@
// Whatsapp API module exposes the plugin public contract.
import { whatsappCommandPolicy as whatsappCommandPolicyImpl } from "./src/command-policy.js";
import { resolveLegacyGroupSessionKey as resolveLegacyGroupSessionKeyImpl } from "./src/group-session-contract.js";
import { testing as whatsappAccessControlTestingImpl } from "./src/inbound/access-control.js";
import {
isWhatsAppGroupJid as isWhatsAppGroupJidImpl,
normalizeWhatsAppTarget as normalizeWhatsAppTargetImpl,
} from "./src/normalize-target.js";
import { resolveWhatsAppRuntimeGroupPolicy as resolveWhatsAppRuntimeGroupPolicyImpl } from "./src/runtime-group-policy.js";
import {
canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl,
isLegacyGroupSessionKey as isLegacyGroupSessionKeyImpl,
} from "./src/session-contract.js";
export const canonicalizeLegacySessionKey = canonicalizeLegacySessionKeyImpl;
export const isLegacyGroupSessionKey = isLegacyGroupSessionKeyImpl;
export const isWhatsAppGroupJid = isWhatsAppGroupJidImpl;
export const normalizeWhatsAppTarget = normalizeWhatsAppTargetImpl;
export const resolveLegacyGroupSessionKey = resolveLegacyGroupSessionKeyImpl;
export const resolveWhatsAppRuntimeGroupPolicy = resolveWhatsAppRuntimeGroupPolicyImpl;
export const whatsappAccessControlTesting = whatsappAccessControlTestingImpl;
export const whatsappCommandPolicy = whatsappCommandPolicyImpl;

View File

@@ -0,0 +1,15 @@
// Whatsapp API module exposes the plugin public contract.
import {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig };
export const whatsappDirectoryContractPlugin = {
id: "whatsapp",
directory: {
listPeers: listWhatsAppDirectoryPeersFromConfig,
listGroups: listWhatsAppDirectoryGroupsFromConfig,
},
};

View File

@@ -0,0 +1,9 @@
// Whatsapp API module exposes the plugin public contract.
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
export { normalizeCompatibilityConfig } from "./src/doctor-contract.js";
// WhatsApp currently exposes doctor compatibility fixes without extra legacy
// rule scans. Keep that empty answer on a lightweight contract surface so
// config validation stays off the broad contract-api import path.
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [];

View File

@@ -0,0 +1,22 @@
// Whatsapp tests cover index plugin behavior.
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, expect, it } from "vitest";
import { whatsappPlugin } from "./channel-plugin-api.js";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("whatsapp bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "whatsapp",
expectedName: "WhatsApp",
setupEntry,
});
it("declares account config as channel-restart reload metadata", () => {
expect(whatsappPlugin.reload).toEqual({
configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"],
noopPrefixes: ["channels.whatsapp"],
});
});
});

View File

@@ -0,0 +1,33 @@
// Whatsapp plugin entrypoint registers its OpenClaw integration.
import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
} from "openclaw/plugin-sdk/channel-entry-contract";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
function registerWhatsAppCallTool(api: OpenClawPluginApi): void {
const registerTool = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(
import.meta.url,
{
specifier: "./call-tool-api.js",
exportName: "registerWhatsAppCallTool",
},
);
registerTool(api);
}
export default defineBundledChannelEntry({
id: "whatsapp",
name: "WhatsApp",
description: "WhatsApp channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "whatsappPlugin",
},
runtime: {
specifier: "./runtime-setter-api.js",
exportName: "setWhatsAppRuntime",
},
registerFull: registerWhatsAppCallTool,
});

View File

@@ -0,0 +1,7 @@
// Whatsapp API module exposes the plugin public contract.
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
export const whatsappLegacySessionSurface = {
isLegacyGroupSessionKey,
canonicalizeLegacySessionKey,
};

View File

@@ -0,0 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";

View File

@@ -0,0 +1,14 @@
// Whatsapp API module exposes the plugin public contract.
export { getActiveWebListener } from "./src/active-listener.js";
export {
getWebAuthAgeMs,
logWebSelfId,
logoutWeb,
pickWebChannel,
readWebSelfId,
resolveDefaultWebAuthDir,
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

@@ -0,0 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
export { startWebLoginWithQr, waitForWebLogin } from "./login-qr-runtime.js";

View File

@@ -0,0 +1,20 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Whatsapp plugin module implements login qr runtime behavior.
type StartWebLoginWithQr = typeof import("./src/login-qr.js").startWebLoginWithQr;
type WaitForWebLogin = typeof import("./src/login-qr.js").waitForWebLogin;
const loadLoginQrModule = createLazyRuntimeModule(() => import("./src/login-qr.js"));
export async function startWebLoginWithQr(
...args: Parameters<StartWebLoginWithQr>
): ReturnType<StartWebLoginWithQr> {
const { startWebLoginWithQr: startWebLoginWithQrLocal } = await loadLoginQrModule();
return await startWebLoginWithQrLocal(...args);
}
export async function waitForWebLogin(
...args: Parameters<WaitForWebLogin>
): ReturnType<WaitForWebLogin> {
const { waitForWebLogin: waitForWebLoginLocal } = await loadLoginQrModule();
return await waitForWebLoginLocal(...args);
}

958
extensions/whatsapp/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,958 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/whatsapp",
"version": "2026.6.11",
"dependencies": {
"audio-decode": "2.2.3",
"baileys": "7.0.0-rc13",
"typebox": "1.3.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
"integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.5.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/node-cache": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
"integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
"license": "MIT",
"dependencies": {
"cacheable": "^2.3.1",
"hookified": "^1.14.0",
"keyv": "^5.5.5"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@cacheable/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
"license": "MIT",
"dependencies": {
"hashery": "^1.5.1",
"keyv": "^5.6.0"
}
},
"node_modules/@eshaz/web-worker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@eshaz/web-worker/-/web-worker-1.2.2.tgz",
"integrity": "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==",
"license": "Apache-2.0"
},
"node_modules/@hapi/boom": {
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
"integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "9.x.x"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
"license": "BSD-3-Clause"
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"license": "MIT",
"dependencies": {
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.6.0"
}
},
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@thi.ng/bitstream": {
"version": "2.4.53",
"resolved": "https://registry.npmjs.org/@thi.ng/bitstream/-/bitstream-2.4.53.tgz",
"integrity": "sha512-nhSs378SbSVrHNGb2mRDMVeHn+j2Ss78lK4+G/8IYAVBh461sBcSpj80FkBhDyTAr/yAa53ZFlyrKjkj7Volvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/postspectacular"
},
{
"type": "patreon",
"url": "https://patreon.com/thing_umbrella"
},
{
"type": "liberapay",
"url": "https://liberapay.com/thi.ng"
}
],
"license": "Apache-2.0",
"dependencies": {
"@thi.ng/errors": "^2.6.15"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@thi.ng/errors": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@thi.ng/errors/-/errors-2.6.15.tgz",
"integrity": "sha512-kkd42XRB+D1PVI9IRyEVK/URF0fgTE7SscoehHyuuZ6UHn/4N60ODiTwOONJHRHxLQVQLJ7EwM0t6AuxBufRUA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/postspectacular"
},
{
"type": "patreon",
"url": "https://patreon.com/thing_umbrella"
},
{
"type": "liberapay",
"url": "https://liberapay.com/thi.ng"
}
],
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@wasm-audio-decoders/common": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.7.tgz",
"integrity": "sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==",
"license": "MIT",
"dependencies": {
"@eshaz/web-worker": "1.2.2",
"simple-yenc": "^1.0.4"
}
},
"node_modules/@wasm-audio-decoders/flac": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/flac/-/flac-0.2.10.tgz",
"integrity": "sha512-YfcyoD2rYRBa6ffawZKNi5qvV5HArJmNmuMVUPoutuZ2hhGi6WNSWIzgvbROGmPbFivLL764Am7xxJENWJDhjw==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7",
"codec-parser": "2.5.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/@wasm-audio-decoders/ogg-vorbis": {
"version": "0.1.20",
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/ogg-vorbis/-/ogg-vorbis-0.1.20.tgz",
"integrity": "sha512-zaQPasU5usRjUDXtXOHYED5tfkR4QMXd+EH3Nrz1+4+M5pCsdD+s9YxJqb0oqnTyRu/KUujOmu5Z/m/NT47vwg==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7",
"codec-parser": "2.5.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/@wasm-audio-decoders/opus-ml": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/opus-ml/-/opus-ml-0.0.2.tgz",
"integrity": "sha512-58rWEqDGg+CKCyEeKm2KoxxSwTWtHh/NLTW9ObR4K8CGF6VwuuGudEI1CtniS/oSRmL1nJq/eh8MKARiluw4DQ==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/audio-buffer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/audio-buffer/-/audio-buffer-5.0.0.tgz",
"integrity": "sha512-gsDyj1wwUp8u7NBB+eW6yhLb9ICf+0eBmDX8NGaAS00w8/fLqFdxUlL5Ge/U8kB64DlQhdonxYC59dXy1J7H/w==",
"license": "MIT"
},
"node_modules/audio-decode": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/audio-decode/-/audio-decode-2.2.3.tgz",
"integrity": "sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/flac": "^0.2.4",
"@wasm-audio-decoders/ogg-vorbis": "^0.1.15",
"audio-buffer": "^5.0.0",
"audio-type": "^2.2.1",
"mpg123-decoder": "^1.0.0",
"node-wav": "^0.0.2",
"ogg-opus-decoder": "^1.6.12",
"qoa-format": "^1.0.1"
}
},
"node_modules/audio-type": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/audio-type/-/audio-type-2.4.1.tgz",
"integrity": "sha512-dK9Z/P83C/rBfTrXXgPD3jZ+aXxx2o/P4rq8+H1JqxbXklitEeJw4CrcwMC5CkON3CX3yy2gaWnIEVYejYh0zQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/baileys": {
"version": "7.0.0-rc13",
"resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc13.tgz",
"integrity": "sha512-v8k74K8B5R7WNYGa26MyJAYEu3Wc4BSuK01QaK8lr30lhE8Nga31nWNu8KN0NDDt+Fsvkq4SQFFI8Q13ghjKmA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@cacheable/node-cache": "^1.4.0",
"@hapi/boom": "^9.1.3",
"async-mutex": "^0.5.0",
"libsignal": "^6.0.0",
"lru-cache": "^11.1.0",
"music-metadata": "^11.12.3",
"p-queue": "^9.0.0",
"pino": "^9.6",
"protobufjs": "^7.5.6",
"whatsapp-rust-bridge": "0.5.4",
"ws": "^8.13.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"audio-decode": "^2.1.3",
"jimp": "^1.6.1",
"link-preview-js": "^3.0.0",
"sharp": "*"
},
"peerDependenciesMeta": {
"audio-decode": {
"optional": true
},
"jimp": {
"optional": true
},
"link-preview-js": {
"optional": true
},
"sharp": {
"optional": true
}
}
},
"node_modules/cacheable": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
"integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.2.0",
"@cacheable/utils": "^2.5.0",
"hookified": "^1.15.0",
"keyv": "^5.6.0",
"qified": "^0.10.1"
}
},
"node_modules/codec-parser": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/codec-parser/-/codec-parser-2.5.0.tgz",
"integrity": "sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ==",
"license": "LGPL-3.0-or-later"
},
"node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/curve25519-js": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
"integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/file-type": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz",
"integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/hashery": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
"license": "MIT",
"dependencies": {
"hookified": "^1.15.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/libsignal": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz",
"integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==",
"license": "GPL-3.0",
"dependencies": {
"curve25519-js": "^0.0.4",
"protobufjs": "^7.5.5"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/media-typer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mpg123-decoder": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/mpg123-decoder/-/mpg123-decoder-1.0.3.tgz",
"integrity": "sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/music-metadata": {
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz",
"integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Borewit"
},
{
"type": "buymeacoffee",
"url": "https://buymeacoffee.com/borewit"
}
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.2",
"@tokenizer/token": "^0.3.0",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"file-type": "^21.3.4",
"media-typer": "^2.0.0",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/node-wav": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/node-wav/-/node-wav-0.0.2.tgz",
"integrity": "sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==",
"license": "MIT",
"engines": {
"node": ">=4.4.0"
}
},
"node_modules/ogg-opus-decoder": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/ogg-opus-decoder/-/ogg-opus-decoder-1.7.3.tgz",
"integrity": "sha512-w47tiZpkLgdkpa+34VzYD8mHUj8I9kfWVZa82mBbNwDvB1byfLXSSzW/HxA4fI3e9kVlICSpXGFwMLV1LPdjwg==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7",
"@wasm-audio-decoders/opus-ml": "0.0.2",
"codec-parser": "2.5.0",
"opus-decoder": "0.7.11"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/opus-decoder": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/opus-decoder/-/opus-decoder-0.7.11.tgz",
"integrity": "sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==",
"license": "MIT",
"dependencies": {
"@wasm-audio-decoders/common": "9.0.7"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/p-queue": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/qified": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
"license": "MIT",
"dependencies": {
"hookified": "^2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qified/node_modules/hookified": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"license": "MIT"
},
"node_modules/qoa-format": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/qoa-format/-/qoa-format-1.0.1.tgz",
"integrity": "sha512-dMB0Z6XQjdpz/Cw4Rf6RiBpQvUSPCfYlQMWvmuWlWkAT7nDQD29cVZ1SwDUB6DYJSitHENwbt90lqfI+7bvMcw==",
"license": "MIT",
"dependencies": {
"@thi.ng/bitstream": "^2.2.12"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/simple-yenc": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/simple-yenc/-/simple-yenc-1.0.4.tgz",
"integrity": "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/whatsapp-rust-bridge": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz",
"integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==",
"license": "MIT"
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
{
"id": "whatsapp",
"name": "WhatsApp",
"description": "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
"icon": "https://cdn.simpleicons.org/whatsapp",
"skills": ["./skills"],
"activation": {
"onStartup": false
},
"contracts": {
"tools": ["whatsapp_call"]
},
"channels": ["whatsapp"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"pluginHooks": {
"type": "object",
"additionalProperties": false,
"properties": {
"messageReceived": {
"type": "boolean",
"description": "Opt in to broadcasting inbound WhatsApp message_received hook payloads to loaded plugins."
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
export { whatsappOutbound } from "./src/outbound-adapter.js";

View File

@@ -0,0 +1,73 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.6.11",
"description": "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"audio-decode": "2.2.3",
"baileys": "7.0.0-rc13",
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true,
"legacySessionSurfaces": true
},
"channel": {
"id": "whatsapp",
"label": "WhatsApp",
"selectionLabel": "WhatsApp (QR link)",
"detailLabel": "WhatsApp Web",
"docsPath": "/channels/whatsapp",
"docsLabel": "whatsapp",
"blurb": "works with your own number; recommend a separate phone + eSIM.",
"systemImage": "message",
"persistedAuthState": {
"specifier": "./auth-presence",
"exportName": "hasAnyWhatsAppAuth"
},
"cliAddOptions": [
{
"flags": "--auth-dir <path>",
"description": "WhatsApp auth directory override"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/whatsapp",
"npmSpec": "@openclaw/whatsapp",
"defaultChoice": "clawhub",
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,89 @@
// Whatsapp API module exposes the plugin public contract.
export {
getActiveWebListener,
resolveWebAccountId,
type ActiveWebListener,
type ActiveWebSendOptions,
} from "./src/active-listener.js";
export { handleWhatsAppAction, whatsAppActionRuntime } from "./src/action-runtime.js";
export { createWhatsAppLoginTool } from "./src/agent-tools-login.js";
export {
formatWhatsAppWebAuthStatusState,
getWebAuthAgeMs,
hasWebCredsSync,
logWebSelfId,
logoutWeb,
pickWebChannel,
readCredsJsonRaw,
readWebAuthExistsBestEffort,
readWebAuthExistsForDecision,
readWebAuthSnapshot,
readWebAuthSnapshotBestEffort,
readWebAuthState,
readWebSelfId,
readWebSelfIdentity,
readWebSelfIdentityForDecision,
resolveDefaultWebAuthDir,
resolveWebCredsBackupPath,
resolveWebCredsPath,
restoreCredsFromBackupIfNeeded,
webAuthExists,
WA_WEB_AUTH_DIR,
WHATSAPP_AUTH_UNSTABLE_CODE,
WhatsAppAuthUnstableError,
type WhatsAppWebAuthState,
} from "./src/auth-store.js";
export {
DEFAULT_WEB_MEDIA_BYTES,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
monitorWebChannel,
SILENT_REPLY_TOKEN,
stripHeartbeatToken,
type WebChannelStatus,
type WebMonitorTuning,
} from "./src/auto-reply.js";
export {
extractContactContext,
extractLocationData,
extractMediaPlaceholder,
extractText,
monitorWebInbox,
resetWebInboundDedupe,
type LegacyFlatWebInboundMessage,
type WebInboundCallbackMessage,
type WebInboundMessage,
type WebInboundMessageInput,
type WebListenerCloseReason,
type WhatsAppInboundAdmission,
} from "./src/inbound.js";
export { loginWeb } from "./src/login.js";
export {
getDefaultLocalRoots,
loadWebMedia,
loadWebMediaRaw,
LocalMediaAccessError,
optimizeImageToJpeg,
optimizeImageToPng,
type LocalMediaAccessErrorCode,
type WebMediaResult,
} from "./src/media.js";
export {
sendMessageWhatsApp,
sendPollWhatsApp,
sendReactionWhatsApp,
sendTypingWhatsApp,
} from "./src/send.js";
export {
createWaSocket,
formatError,
getStatusCode,
newConnectionId,
waitForCredsSaveQueue,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
writeCredsJsonAtomically,
type CredsQueueWaitResult,
} from "./src/session.js";
export { setWhatsAppRuntime } from "./src/runtime.js";
export { startWebLoginWithQr, waitForWebLogin } from "./login-qr-runtime.js";

View File

@@ -0,0 +1,3 @@
// Keep bundled registration fast: the runtime setter is needed during plugin
// bootstrap, but the broad runtime-api barrel pulls in WhatsApp runtime modules.
export { setWhatsAppRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,4 @@
// WhatsApp does not expose secret-contract surfaces.
export const secretTargetRegistryEntries: readonly [] = [];
export function collectRuntimeConfigAssignments(): void {}

View File

@@ -0,0 +1,5 @@
// Whatsapp API module exposes the plugin public contract.
export {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./src/security-contract.js";

View File

@@ -0,0 +1,76 @@
// Whatsapp tests cover setup entry plugin behavior.
import { describe, expect, it, vi } from "vitest";
import * as legacySessionSurfaceApi from "./legacy-session-surface-api.js";
import * as legacyStateMigrationsApi from "./legacy-state-migrations-api.js";
import setupEntry from "./setup-entry.js";
import * as setupPluginApi from "./setup-plugin-api.js";
vi.mock("baileys", () => {
throw new Error("setup plugin load must not load Baileys");
});
vi.mock("./src/setup-finalize.js", () => {
throw new Error("setup status load must not load finalize");
});
const setupEntryLoadOptions = {
createLoaderForTest: (() => (specifier: string) => {
if (/[\\/]setup-plugin-api\.[jt]s$/u.test(specifier)) {
return setupPluginApi;
}
if (/[\\/]legacy-state-migrations-api\.[jt]s$/u.test(specifier)) {
return legacyStateMigrationsApi;
}
if (/[\\/]legacy-session-surface-api\.[jt]s$/u.test(specifier)) {
return legacySessionSurfaceApi;
}
throw new Error(`unexpected setup entry module load: ${specifier}`);
}) as never,
};
describe("whatsapp setup entry", () => {
it("loads setup entry metadata without importing runtime dependencies", () => {
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupEntry.features).toEqual({
legacySessionSurfaces: true,
legacyStateMigrations: true,
});
});
it("loads the setup plugin without installing runtime dependencies", () => {
const whatsappSetupPlugin = setupEntry.loadSetupPlugin(setupEntryLoadOptions);
expect(whatsappSetupPlugin.id).toBe("whatsapp");
});
it("loads legacy setup helpers without importing runtime dependencies", () => {
const detectLegacyStateMigrations =
setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions);
if (!detectLegacyStateMigrations) {
throw new Error("expected WhatsApp legacy state migration detector");
}
expect(
detectLegacyStateMigrations({
cfg: {},
env: {},
oauthDir: "/tmp/openclaw-whatsapp-empty",
stateDir: "/tmp/openclaw-state",
}),
).toStrictEqual([]);
const legacySessionSurface = setupEntry.loadLegacySessionSurface?.(setupEntryLoadOptions);
if (!legacySessionSurface) {
throw new Error("expected WhatsApp legacy session surface");
}
expect(Object.keys(legacySessionSurface).toSorted()).toEqual([
"canonicalizeLegacySessionKey",
"isLegacyGroupSessionKey",
]);
expect(legacySessionSurface.canonicalizeLegacySessionKey).toBeTypeOf("function");
expect(legacySessionSurface.isLegacyGroupSessionKey).toBeTypeOf("function");
});
it("loads the delegated setup wizard without importing runtime dependencies", async () => {
const { whatsappSetupWizard } = await import("./src/setup-surface.js");
expect(whatsappSetupWizard.channel).toBe("whatsapp");
});
});

View File

@@ -0,0 +1,22 @@
// Whatsapp plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
features: {
legacyStateMigrations: true,
legacySessionSurfaces: true,
},
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "whatsappSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectWhatsAppLegacyStateMigrations",
},
legacySessionSurface: {
specifier: "./legacy-session-surface-api.js",
exportName: "whatsappLegacySessionSurface",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader WhatsApp channel plugin surface.
export { whatsappSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,72 @@
---
name: wacli
description: "Send third-party WhatsApp messages or sync/search WhatsApp history via wacli, not normal active chats."
homepage: https://wacli.sh
metadata:
{
"openclaw":
{
"emoji": "📱",
"requires": { "bins": ["wacli"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "steipete/tap/wacli",
"bins": ["wacli"],
"label": "Install wacli (brew)",
},
{
"id": "go",
"kind": "go",
"module": "github.com/steipete/wacli/cmd/wacli@latest",
"bins": ["wacli"],
"label": "Install wacli (go)",
},
],
},
}
---
# wacli
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
Do NOT use `wacli` for normal user chats; OpenClaw routes WhatsApp conversations automatically.
If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
Safety
- Require explicit recipient + message text.
- Confirm recipient + message before sending.
- If anything is ambiguous, ask a clarifying question.
Auth + sync
- `wacli auth` (QR login + initial sync)
- `wacli sync --follow` (continuous sync)
- `wacli doctor`
Find chats + messages
- `wacli chats list --limit 20 --query "name or number"`
- `wacli messages search "query" --limit 20 --chat <jid>`
- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31`
History backfill
- `wacli history backfill --chat <jid> --requests 2 --count 50`
Send
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
- Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."`
- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"`
Notes
- Store dir: `~/.wacli` (override with `--store`).
- Use `--json` for machine-readable output when parsing.
- Backfill requires your phone online; results are best-effort.
- WhatsApp CLI is not needed for routine user chats; it's for messaging other people.
- JIDs: direct chats look like `<number>@s.whatsapp.net`; groups look like `<id>@g.us` (use `wacli chats list` to find).

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

View File

@@ -0,0 +1,78 @@
// Whatsapp helper module supports account config behavior.
import {
DEFAULT_ACCOUNT_ID,
mergeAccountConfig,
resolveAccountEntry,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
} from "openclaw/plugin-sdk/channel-outbound";
import type { WhatsAppAccountConfig } from "./account-types.js";
function resolveWhatsAppDefaultAccountSharedConfig(
cfg: OpenClawConfig,
): Partial<WhatsAppAccountConfig> | undefined {
const defaultAccount = resolveAccountEntry(cfg.channels?.whatsapp?.accounts, DEFAULT_ACCOUNT_ID);
if (!defaultAccount) {
return undefined;
}
const {
enabled: _ignoredEnabled,
name: _ignoredName,
authDir: _ignoredAuthDir,
selfChatMode: _ignoredSelfChatMode,
...sharedDefaults
} = defaultAccount;
return sharedDefaults;
}
function resolveWhatsAppAccountConfigForTest(
cfg: OpenClawConfig,
accountId: string,
): WhatsAppAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
}
function resolveMergedNamedWhatsAppAccountConfig(params: {
cfg: OpenClawConfig;
accountId: string;
}): WhatsAppAccountConfig {
const rootCfg = params.cfg.channels?.whatsapp;
const accountConfig = resolveWhatsAppAccountConfigForTest(params.cfg, params.accountId);
return {
...mergeAccountConfig<WhatsAppAccountConfig>({
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
accountConfig: undefined,
omitKeys: ["defaultAccount"],
}),
...resolveWhatsAppDefaultAccountSharedConfig(params.cfg),
...accountConfig,
};
}
export function resolveMergedWhatsAppAccountConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): WhatsAppAccountConfig & { accountId: string } {
const rootCfg = params.cfg.channels?.whatsapp;
const accountId = params.accountId?.trim() || rootCfg?.defaultAccount || DEFAULT_ACCOUNT_ID;
const base = resolveMergedAccountConfig<WhatsAppAccountConfig>({
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
accounts: rootCfg?.accounts as Record<string, Partial<WhatsAppAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
});
const merged =
accountId === DEFAULT_ACCOUNT_ID
? base
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
return {
accountId,
...merged,
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
};
}

View File

@@ -0,0 +1,18 @@
// Whatsapp plugin module implements account ids behavior.
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-core";
const {
listConfiguredAccountIds,
listAccountIds,
resolveDefaultAccountId: resolveDefaultWhatsAppAccountId,
} = createAccountListHelpers("whatsapp", {
implicitDefaultAccount: {
channelKeys: ["authDir"],
},
});
export {
listConfiguredAccountIds,
listAccountIds as listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
};

View File

@@ -0,0 +1,6 @@
// Whatsapp plugin module implements account types behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type WhatsAppAccountConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["accounts"]
>[string];

View File

@@ -0,0 +1,205 @@
// Whatsapp tests cover accounts plugin behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
resolveWhatsAppAuthDir,
} from "./accounts.js";
describe("resolveWhatsAppAuthDir", () => {
const stubCfg = { channels: { whatsapp: { accounts: {} } } } as Parameters<
typeof resolveWhatsAppAuthDir
>[0]["cfg"];
it("sanitizes path traversal sequences in accountId", () => {
const { authDir } = resolveWhatsAppAuthDir({
cfg: stubCfg,
accountId: "../../../etc/passwd",
});
// Sanitized accountId must not escape the whatsapp auth directory.
expect(authDir).not.toContain("..");
expect(path.basename(authDir)).not.toContain("/");
});
it("sanitizes special characters in accountId", () => {
const { authDir } = resolveWhatsAppAuthDir({
cfg: stubCfg,
accountId: "foo/bar\\baz",
});
// Sprawdzaj sanityzacje na segmencie accountId, nie na calej sciezce
// (Windows uzywa backslash jako separator katalogow).
const segment = path.basename(authDir);
expect(segment).not.toContain("/");
expect(segment).not.toContain("\\");
});
it("returns default directory for empty accountId", () => {
const { authDir } = resolveWhatsAppAuthDir({
cfg: stubCfg,
accountId: "",
});
expect(authDir).toMatch(/whatsapp[/\\]default$/);
});
it("preserves top-level default account when named accounts are configured", () => {
const cfg = {
channels: {
whatsapp: {
authDir: "~/.openclaw/whatsapp-default",
accounts: {
work: { enabled: false },
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
expect(listWhatsAppAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultWhatsAppAccountId(cfg)).toBe("default");
expect(resolveWhatsAppAccount({ cfg }).authDir).toMatch(/whatsapp-default$/);
});
it("preserves valid accountId unchanged", () => {
const { authDir } = resolveWhatsAppAuthDir({
cfg: stubCfg,
accountId: "my-account-1",
});
expect(authDir).toMatch(/whatsapp[/\\]my-account-1$/);
});
it("merges top-level and account-specific config through shared helpers", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
messages: {
messagePrefix: "[global]",
},
channels: {
whatsapp: {
sendReadReceipts: false,
messagePrefix: "[root]",
debounceMs: 100,
accounts: {
work: {
debounceMs: 250,
},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.sendReadReceipts).toBe(false);
expect(resolved.messagePrefix).toBe("[root]");
expect(resolved.debounceMs).toBe(250);
});
it("inherits shared defaults from accounts.default for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: ["+15550002222"],
defaultTo: "+15550003333",
reactionLevel: "extensive",
historyLimit: 42,
mediaMaxMb: 12,
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.dmPolicy).toBe("allowlist");
expect(resolved.allowFrom).toEqual(["+15550001111"]);
expect(resolved.groupPolicy).toBe("open");
expect(resolved.groupAllowFrom).toEqual(["+15550002222"]);
expect(resolved.defaultTo).toBe("+15550003333");
expect(resolved.reactionLevel).toBe("extensive");
expect(resolved.historyLimit).toBe(42);
expect(resolved.mediaMaxMb).toBe(12);
});
it("prefers account overrides and accounts.default over root defaults", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "disabled",
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
},
work: {
authDir: "/tmp/work",
dmPolicy: "pairing",
},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.dmPolicy).toBe("pairing");
expect(resolved.allowFrom).toEqual(["+15550001111"]);
expect(resolved.groupPolicy).toBe("open");
});
it("does not inherit default-account authDir for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
authDir: "/tmp/default-auth",
name: "Personal",
},
work: {},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.authDir).toMatch(/whatsapp[/\\]work$/);
expect(resolved.name).toBeUndefined();
});
it("does not inherit default-account selfChatMode for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
selfChatMode: true,
},
work: {},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.selfChatMode).toBeUndefined();
});
});

View File

@@ -0,0 +1,175 @@
// Whatsapp plugin module implements accounts behavior.
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveUserPath,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
import type { DmPolicy, GroupPolicy, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveMergedWhatsAppAccountConfig } from "./account-config.js";
import {
listConfiguredAccountIds,
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
} from "./account-ids.js";
import type { WhatsAppAccountConfig } from "./account-types.js";
import { hasWebCredsRegularFileSync, hasWebCredsSync } from "./creds-files.js";
export { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId } from "./account-ids.js";
export type ResolvedWhatsAppAccount = {
accountId: string;
name?: string;
enabled: boolean;
sendReadReceipts: boolean;
messagePrefix?: string;
defaultTo?: string;
authDir: string;
isLegacyAuthDir: boolean;
selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
mentionPatterns?: WhatsAppAccountConfig["mentionPatterns"];
dmPolicy?: DmPolicy;
historyLimit?: number;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
groups?: WhatsAppAccountConfig["groups"];
direct?: WhatsAppAccountConfig["direct"];
debounceMs?: number;
replyToMode?: ReplyToMode;
};
export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50;
export function listWhatsAppAuthDirs(cfg: OpenClawConfig): string[] {
const oauthDir = resolveOAuthDir();
const whatsappDir = path.join(oauthDir, "whatsapp");
const authDirs = new Set<string>([oauthDir, path.join(whatsappDir, DEFAULT_ACCOUNT_ID)]);
const accountIds = listConfiguredAccountIds(cfg);
for (const accountId of accountIds) {
authDirs.add(resolveWhatsAppAuthDir({ cfg, accountId }).authDir);
}
try {
const entries = fs.readdirSync(whatsappDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
authDirs.add(path.join(whatsappDir, entry.name));
}
} catch {
// ignore missing dirs
}
return Array.from(authDirs);
}
export function hasAnyWhatsAppAuth(cfg: OpenClawConfig): boolean {
return listWhatsAppAuthDirs(cfg).some((authDir) => hasWebCredsSync(authDir));
}
function resolveDefaultAuthDir(accountId: string): string {
return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId));
}
function resolveLegacyAuthDir(): string {
// Legacy Baileys creds lived in the same directory as OAuth tokens.
return resolveOAuthDir();
}
function legacyAuthExists(authDir: string): boolean {
return hasWebCredsRegularFileSync(authDir);
}
export function resolveWhatsAppAuthDir(params: { cfg: OpenClawConfig; accountId: string }): {
authDir: string;
isLegacy: boolean;
} {
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
const account = resolveMergedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
const configured = account?.authDir?.trim();
if (configured) {
return { authDir: resolveUserPath(configured), isLegacy: false };
}
const defaultDir = resolveDefaultAuthDir(accountId);
if (accountId === DEFAULT_ACCOUNT_ID) {
const legacyDir = resolveLegacyAuthDir();
if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) {
return { authDir: legacyDir, isLegacy: true };
}
}
return { authDir: defaultDir, isLegacy: false };
}
export function resolveWhatsAppAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedWhatsAppAccount {
const merged = resolveMergedWhatsAppAccountConfig({
cfg: params.cfg,
accountId: params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg),
});
const accountId = merged.accountId;
const enabled = merged.enabled !== false;
const { authDir, isLegacy } = resolveWhatsAppAuthDir({
cfg: params.cfg,
accountId,
});
return {
accountId,
name: normalizeOptionalString(merged.name),
enabled,
sendReadReceipts: merged.sendReadReceipts ?? true,
messagePrefix: merged.messagePrefix ?? params.cfg.messages?.messagePrefix,
defaultTo: merged.defaultTo,
authDir,
isLegacyAuthDir: isLegacy,
selfChatMode: merged.selfChatMode,
dmPolicy: merged.dmPolicy,
allowFrom: merged.allowFrom,
groupAllowFrom: merged.groupAllowFrom,
groupPolicy: merged.groupPolicy,
mentionPatterns: merged.mentionPatterns,
historyLimit: merged.historyLimit,
textChunkLimit: merged.textChunkLimit,
chunkMode: merged.chunkMode,
mediaMaxMb: merged.mediaMaxMb,
blockStreaming: merged.blockStreaming,
ackReaction: merged.ackReaction,
reactionLevel: merged.reactionLevel,
groups: merged.groups,
direct: merged.direct,
debounceMs: merged.debounceMs,
replyToMode: merged.replyToMode,
};
}
export function resolveWhatsAppMediaMaxBytes(
account: Pick<ResolvedWhatsAppAccount, "mediaMaxMb">,
): number {
const mediaMaxMb =
typeof account.mediaMaxMb === "number" && account.mediaMaxMb > 0
? account.mediaMaxMb
: DEFAULT_WHATSAPP_MEDIA_MAX_MB;
return Math.floor(mediaMaxMb * 1024 * 1024);
}
export function listEnabledWhatsAppAccounts(cfg: OpenClawConfig): ResolvedWhatsAppAccount[] {
return listWhatsAppAccountIds(cfg)
.map((accountId) => resolveWhatsAppAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,94 @@
// Whatsapp tests cover accounts.whatsapp auth plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { captureEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs, resolveWhatsAppAuthDir } from "./accounts.js";
describe("hasAnyWhatsAppAuth", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let tempOauthDir: string | undefined;
const writeCreds = (dir: string) => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "creds.json"), JSON.stringify({ me: {} }));
};
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_OAUTH_DIR"]);
tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-"));
process.env.OPENCLAW_OAUTH_DIR = tempOauthDir;
});
afterEach(() => {
envSnapshot.restore();
if (tempOauthDir) {
fs.rmSync(tempOauthDir, { recursive: true, force: true });
tempOauthDir = undefined;
}
});
it("returns false when no auth exists", () => {
expect(hasAnyWhatsAppAuth({})).toBe(false);
});
it("returns true when legacy auth exists", () => {
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), JSON.stringify({ me: {} }));
expect(hasAnyWhatsAppAuth({})).toBe(true);
});
it.runIf(process.platform !== "win32")("ignores symlinked legacy creds", () => {
const targetPath = path.join(tempOauthDir ?? "", "target-creds.json");
const credsPath = path.join(tempOauthDir ?? "", "creds.json");
fs.writeFileSync(targetPath, JSON.stringify({ me: {} }));
fs.symlinkSync(targetPath, credsPath);
expect(hasAnyWhatsAppAuth({})).toBe(false);
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
authDir: path.join(tempOauthDir ?? "", "whatsapp", "default"),
isLegacy: false,
});
});
it("selects legacy auth when legacy creds are truncated so backup recovery can run", () => {
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), "{");
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
authDir: tempOauthDir,
isLegacy: true,
});
});
it("does not fall back to legacy auth when default creds are truncated", () => {
const defaultAuthDir = path.join(tempOauthDir ?? "", "whatsapp", "default");
fs.mkdirSync(defaultAuthDir, { recursive: true });
fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), JSON.stringify({ me: {} }));
fs.writeFileSync(path.join(defaultAuthDir, "creds.json"), "{");
expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({
authDir: defaultAuthDir,
isLegacy: false,
});
});
it("returns true when non-default auth exists", () => {
writeCreds(path.join(tempOauthDir ?? "", "whatsapp", "work"));
expect(hasAnyWhatsAppAuth({})).toBe(true);
});
it("includes authDir overrides", () => {
const customDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-wa-auth-"));
try {
writeCreds(customDir);
const cfg = {
channels: { whatsapp: { accounts: { work: { authDir: customDir } } } },
};
expect(listWhatsAppAuthDirs(cfg)).toContain(customDir);
expect(hasAnyWhatsAppAuth(cfg)).toBe(true);
} finally {
fs.rmSync(customDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,28 @@
// Whatsapp plugin module implements action runtime target auth behavior.
import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveWhatsAppAccount } from "./accounts.js";
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
export function resolveAuthorizedWhatsAppOutboundTarget(params: {
cfg: OpenClawConfig;
chatJid: string;
accountId?: string;
actionLabel: string;
}): { to: string; accountId: string } {
const account = resolveWhatsAppAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const resolution = resolveWhatsAppOutboundTarget({
to: params.chatJid,
allowFrom: account.allowFrom ?? [],
mode: "implicit",
});
if (!resolution.ok) {
throw new ToolAuthorizationError(
`WhatsApp ${params.actionLabel} blocked: chatJid "${params.chatJid}" is not in the configured allowFrom list for account "${account.accountId}".`,
);
}
return { to: resolution.to, accountId: account.accountId };
}

View File

@@ -0,0 +1,323 @@
// Whatsapp tests cover action runtime plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleWhatsAppAction, whatsAppActionRuntime } from "./action-runtime.js";
const originalWhatsAppActionRuntime = { ...whatsAppActionRuntime };
const sendReactionWhatsApp = vi.fn(async () => undefined);
const enabledConfig = {
channels: { whatsapp: { actions: { reactions: true } } },
} as OpenClawConfig;
describe("handleWhatsAppAction", () => {
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
return {
channels: { whatsapp: { actions: { reactions: true }, reactionLevel } },
} as OpenClawConfig;
}
function expectLastReactionSend(expected: {
chat: string;
messageId: string;
emoji: string;
accountId: string;
fromMe?: boolean;
participant?: string;
}) {
const calls = sendReactionWhatsApp.mock.calls as unknown[][];
const call = calls.at(-1);
if (!call) {
throw new Error("expected WhatsApp reaction send");
}
expect(call[0]).toBe(expected.chat);
expect(call[1]).toBe(expected.messageId);
expect(call[2]).toBe(expected.emoji);
const options = call[3] as {
verbose?: unknown;
fromMe?: unknown;
participant?: unknown;
accountId?: unknown;
};
expect(options.verbose).toBe(false);
expect(options.fromMe).toBe(expected.fromMe);
expect(options.participant).toBe(expected.participant);
expect(options.accountId).toBe(expected.accountId);
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, {
sendReactionWhatsApp,
});
});
it("adds reactions", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
enabledConfig,
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "✅",
accountId: DEFAULT_ACCOUNT_ID,
});
});
it("adds reactions when reactionLevel is minimal", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
reactionConfig("minimal"),
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "✅",
accountId: DEFAULT_ACCOUNT_ID,
});
});
it("adds reactions when reactionLevel is extensive", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
reactionConfig("extensive"),
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "✅",
accountId: DEFAULT_ACCOUNT_ID,
});
});
it("removes reactions on empty emoji", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "",
},
enabledConfig,
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "",
accountId: DEFAULT_ACCOUNT_ID,
});
});
it("removes reactions when remove flag set", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
remove: true,
},
enabledConfig,
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "",
accountId: DEFAULT_ACCOUNT_ID,
});
});
it("passes account scope and sender flags", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "🎉",
accountId: "work",
fromMe: true,
participant: "999@s.whatsapp.net",
},
enabledConfig,
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "🎉",
accountId: "work",
fromMe: true,
participant: "999@s.whatsapp.net",
});
});
it("preserves LID participant ids when forwarding reactions", async () => {
await handleWhatsAppAction(
{
action: "react",
chatJid: "12345@g.us",
messageId: "msg1",
emoji: "🎉",
participant: "123@lid",
},
enabledConfig,
);
expectLastReactionSend({
chat: "12345@g.us",
messageId: "msg1",
emoji: "🎉",
accountId: DEFAULT_ACCOUNT_ID,
participant: "123@lid",
});
});
it("respects reaction gating", async () => {
const cfg = {
channels: { whatsapp: { actions: { reactions: false } } },
} as OpenClawConfig;
await expect(
handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/WhatsApp reactions are disabled/);
});
it("disables reactions when WhatsApp is not configured", async () => {
await expect(
handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
{} as OpenClawConfig,
),
).rejects.toThrow(/WhatsApp reactions are disabled/);
});
it("prefers the action gate error when both actions.reactions and reactionLevel disable reactions", async () => {
const cfg = {
channels: { whatsapp: { actions: { reactions: false }, reactionLevel: "ack" } },
} as OpenClawConfig;
await expect(
handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/WhatsApp reactions are disabled/);
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
});
it.each(["off", "ack"] as const)(
"blocks agent reactions when reactionLevel is %s",
async (reactionLevel) => {
await expect(
handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
reactionConfig(reactionLevel),
),
).rejects.toThrow(
new RegExp(`WhatsApp agent reactions disabled \\(reactionLevel="${reactionLevel}"\\)`),
);
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
},
);
it("applies default account allowFrom when accountId is omitted", async () => {
const cfg = {
channels: {
whatsapp: {
actions: { reactions: true },
allowFrom: ["111@s.whatsapp.net"],
accounts: {
[DEFAULT_ACCOUNT_ID]: {
allowFrom: ["222@s.whatsapp.net"],
},
},
},
},
} as OpenClawConfig;
try {
await handleWhatsAppAction(
{
action: "react",
chatJid: "111@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
cfg,
);
throw new Error("expected WhatsApp action authorization error");
} catch (error) {
expect((error as { name?: unknown }).name).toBe("ToolAuthorizationError");
expect((error as { status?: unknown }).status).toBe(403);
}
});
it("routes to resolved default account when no accountId is provided", async () => {
const cfg = {
channels: {
whatsapp: {
actions: { reactions: true },
accounts: {
work: {
allowFrom: ["123@s.whatsapp.net"],
},
},
},
},
} as OpenClawConfig;
await handleWhatsAppAction(
{
action: "react",
chatJid: "123@s.whatsapp.net",
messageId: "msg1",
emoji: "✅",
},
cfg,
);
expectLastReactionSend({
chat: "+123",
messageId: "msg1",
emoji: "✅",
accountId: "work",
});
});
});

View File

@@ -0,0 +1,77 @@
// Whatsapp plugin module implements action runtime behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
createActionGate,
jsonResult,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js";
import { resolveWhatsAppReactionLevel } from "./reaction-level.js";
import { sendReactionWhatsApp } from "./send.js";
export const whatsAppActionRuntime = {
resolveAuthorizedWhatsAppOutboundTarget,
sendReactionWhatsApp,
};
export async function handleWhatsAppAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const whatsAppConfig = cfg.channels?.whatsapp;
const isActionEnabled = createActionGate(whatsAppConfig?.actions);
if (action === "react") {
const accountId = readStringParam(params, "accountId");
if (!whatsAppConfig) {
throw new Error("WhatsApp reactions are disabled.");
}
if (!isActionEnabled("reactions")) {
throw new Error("WhatsApp reactions are disabled.");
}
const reactionLevelInfo = resolveWhatsAppReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`WhatsApp agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.whatsapp.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
const chatJid = readStringParam(params, "chatJid", { required: true });
const messageId = readStringParam(params, "messageId", { required: true });
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a WhatsApp reaction.",
});
const participant = readStringParam(params, "participant");
const fromMeRaw = params.fromMe;
const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined;
// Resolve account + allowFrom via shared account logic so auth and routing stay aligned.
const resolved = whatsAppActionRuntime.resolveAuthorizedWhatsAppOutboundTarget({
cfg,
chatJid,
accountId,
actionLabel: "reaction",
});
const resolvedEmoji = remove ? "" : emoji;
await whatsAppActionRuntime.sendReactionWhatsApp(resolved.to, messageId, resolvedEmoji, {
verbose: false,
fromMe,
participant: participant ?? undefined,
accountId: resolved.accountId,
cfg,
});
if (!remove && !isEmpty) {
return jsonResult({ ok: true, added: emoji });
}
return jsonResult({ ok: true, removed: true });
}
throw new Error(`Unsupported WhatsApp action: ${action}`);
}

View File

@@ -0,0 +1,66 @@
// Whatsapp tests cover active listener plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getActiveWebListener, resolveWebAccountId } from "./active-listener.js";
const registryMocks = vi.hoisted(() => ({
getRegisteredWhatsAppConnectionController: vi.fn(),
}));
vi.mock("./connection-controller-registry.js", () => ({
getRegisteredWhatsAppConnectionController:
registryMocks.getRegisteredWhatsAppConnectionController,
}));
const WHATSAPP_ACTIVE_LISTENER_TEST_CFG = {
channels: { whatsapp: { accounts: { work: { enabled: true } }, defaultAccount: "work" } },
};
function makeListener() {
return {
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
sendReaction: vi.fn(async () => {}),
sendComposingTo: vi.fn(async () => {}),
};
}
beforeEach(() => {
registryMocks.getRegisteredWhatsAppConnectionController.mockReset();
});
describe("active WhatsApp listener view", () => {
it("reads controller-backed state", () => {
const listener = makeListener();
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
(accountId: string) =>
accountId === "work"
? {
getActiveListener: () => listener,
}
: null,
);
expect(getActiveWebListener("work")).toBe(listener);
});
it("resolves the configured default account when accountId is omitted", () => {
const listener = makeListener();
registryMocks.getRegisteredWhatsAppConnectionController.mockImplementation(
(accountId: string) =>
accountId === "work"
? {
getActiveListener: () => listener,
}
: null,
);
expect(resolveWebAccountId({ cfg: WHATSAPP_ACTIVE_LISTENER_TEST_CFG })).toBe("work");
expect(getActiveWebListener("work")).toBe(listener);
});
it("returns null when the controller has no active listener for the account", () => {
registryMocks.getRegisteredWhatsAppConnectionController.mockReturnValue(null);
expect(getActiveWebListener("work")).toBeNull();
});
});

View File

@@ -0,0 +1,18 @@
// Whatsapp plugin module implements active listener behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveDefaultWhatsAppAccountId } from "./account-ids.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import type { ActiveWebListener } from "./inbound/types.js";
export type { ActiveWebListener, ActiveWebSendOptions } from "./inbound/types.js";
export function resolveWebAccountId(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string {
return (params.accountId ?? "").trim() || resolveDefaultWhatsAppAccountId(params.cfg);
}
export function getActiveWebListener(accountId: string): ActiveWebListener | null {
return getRegisteredWhatsAppConnectionController(accountId)?.getActiveListener() ?? null;
}

View File

@@ -0,0 +1,308 @@
// WhatsApp call tool tests cover requester binding, audio framing, and process cleanup.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWhatsAppCallTool, testing } from "./agent-tools-call.js";
import {
getRegisteredWhatsAppConnectionController,
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
function createApi(params?: {
speech?: Partial<
Awaited<ReturnType<OpenClawPluginApi["runtime"]["tts"]["textToSpeechTelephony"]>>
>;
runCommand?: OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
}): OpenClawPluginApi {
return {
config: {},
runtime: {
tts: {
textToSpeechTelephony: vi.fn(async () => ({
success: true,
audioBuffer: Buffer.alloc(48_000, 1),
outputFormat: "pcm",
sampleRate: 24_000,
provider: "openai",
...params?.speech,
})),
},
system: {
runCommandWithTimeout:
params?.runCommand ??
vi.fn(async () => ({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit" as const,
})),
},
},
} as unknown as OpenClawPluginApi;
}
function createContext(
overrides: Partial<OpenClawPluginToolContext> = {},
): OpenClawPluginToolContext {
return {
config: { channels: { whatsapp: { actions: { calls: true } } } },
messageChannel: "whatsapp",
agentAccountId: "default",
requesterSenderId: "+15551234567",
...overrides,
};
}
describe("WhatsApp call tool", () => {
let stateDir: string;
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-call-test-"));
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("is opt-in and available only for a trusted WhatsApp requester", () => {
const api = createApi();
expect(createWhatsAppCallTool(api, createContext({ config: {} }))).toBeNull();
expect(
createWhatsAppCallTool(
api,
createContext({
config: { channels: { whatsapp: { actions: { calls: false } } } },
}),
),
).toBeNull();
expect(createWhatsAppCallTool(api, createContext({ messageChannel: "telegram" }))).toBeNull();
expect(createWhatsAppCallTool(api, createContext({ requesterSenderId: undefined }))).toBeNull();
expect(createWhatsAppCallTool(api, createContext())?.name).toBe("whatsapp_call");
});
it("reports the separate companion setup without exposing a recipient argument", async () => {
const tool = testing.createWhatsAppCallToolWithDependencies(createApi(), createContext(), {
detectMeowCaller: async () => false,
resolveStateDir: () => stateDir,
});
const result = await tool?.execute("call-1", { action: "status" });
expect(result?.details).toMatchObject({
binaryFound: false,
sessionStoreFound: false,
accountId: "default",
stateDir,
});
expect(result?.details).toMatchObject({
setupCommand: expect.stringContaining("meowcaller pair --store"),
});
expect(JSON.stringify(tool?.parameters)).not.toContain('"to"');
});
it("synthesizes a private WAV and calls only the current requester", async () => {
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
let audioPath: string | undefined;
const runCommand = vi.fn(async (argv: string[]) => {
const commandAudioPath = argv.at(-1);
if (!commandAudioPath) {
throw new Error("missing audio path");
}
audioPath = commandAudioPath;
const wav = await fs.readFile(commandAudioPath);
expect(wav.toString("ascii", 0, 4)).toBe("RIFF");
expect(wav.toString("ascii", 8, 12)).toBe("WAVE");
expect(wav.readUInt32LE(24)).toBe(24_000);
expect(wav.readUInt32LE(40)).toBe(48_000);
expect(wav.subarray(44)).toEqual(Buffer.alloc(48_000, 1));
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit" as const,
};
}) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
const api = createApi({ runCommand });
const tool = testing.createWhatsAppCallToolWithDependencies(api, createContext(), {
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
});
const result = await tool?.execute("call-2", {
action: "call",
message: "The build finished successfully.",
});
expect(runCommand).toHaveBeenCalledOnce();
expect(vi.mocked(runCommand).mock.calls[0]?.[0]).toEqual([
"meowcaller",
"notify",
"--store",
path.join(stateDir, "wa-voip.db"),
"--answer-timeout",
"45s",
"--max-duration",
"65s",
"+15551234567",
audioPath,
]);
expect(result?.details).toMatchObject({
completed: true,
recipient: "current WhatsApp requester",
callWindowSeconds: 116,
ttsProvider: "openai",
});
expect(audioPath).toBeDefined();
await expect(fs.stat(path.dirname(audioPath ?? ""))).rejects.toThrow();
});
it("resolves a requester LID through the active WhatsApp account", async () => {
const controller = {
getActiveListener: () => null,
getCurrentSock: () =>
({
signalRepository: {
lidMapping: {
getPNForLID: vi.fn(async () => "15551234567@s.whatsapp.net"),
},
},
}) as never,
getSelfIdentity: () => null,
};
registerWhatsAppConnectionController("default", controller);
try {
await expect(
testing.resolveRequesterE164({
accountId: "default",
cfg: {},
requesterSenderId: "123456789@lid",
}),
).resolves.toBe("+15551234567");
expect(getRegisteredWhatsAppConnectionController("default")).toBe(controller);
} finally {
unregisterWhatsAppConnectionController("default", controller);
}
});
it("rejects calling the linked WhatsApp identity itself", async () => {
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
const controller = {
getActiveListener: () => null,
getCurrentSock: () => null,
getSelfIdentity: () => ({ e164: "+15551234567" }),
};
registerWhatsAppConnectionController("default", controller);
try {
const tool = testing.createWhatsAppCallToolWithDependencies(createApi(), createContext(), {
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
});
await expect(
tool?.execute("call-self", { action: "call", message: "Hello" }),
).rejects.toThrow("WhatsApp cannot call the linked account itself");
} finally {
unregisterWhatsAppConnectionController("default", controller);
}
});
it("rejects an early MeowCaller failure and removes the temporary audio", async () => {
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
let audioPath: string | undefined;
const runCommand = vi.fn(async (argv: string[]) => {
audioPath = argv.at(-1);
return {
stdout: "",
stderr: "sensitive upstream diagnostics",
code: 1,
signal: null,
killed: false,
termination: "exit" as const,
};
}) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
const tool = testing.createWhatsAppCallToolWithDependencies(
createApi({ runCommand }),
createContext(),
{
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
},
);
await expect(tool?.execute("call-3", { action: "call", message: "Hello" })).rejects.toThrow(
"MeowCaller did not complete the call (code 1)",
);
expect(audioPath).toBeDefined();
await expect(fs.stat(path.dirname(audioPath ?? ""))).rejects.toThrow();
});
it("does not report success when MeowCaller times out", async () => {
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
const runCommand = vi.fn(async () => ({
stdout: "",
stderr: "",
code: 124,
signal: "SIGTERM" as const,
killed: true,
termination: "timeout" as const,
})) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
const tool = testing.createWhatsAppCallToolWithDependencies(
createApi({ runCommand }),
createContext(),
{
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
},
);
await expect(
tool?.execute("call-unpaired", { action: "call", message: "Hello" }),
).rejects.toThrow("MeowCaller exceeded the bounded WhatsApp call window");
});
it.each(["ulaw_8000", "raw-8khz-8bit-mono-mulaw"])(
"decodes %s telephony audio to PCM",
(outputFormat) => {
const pcm = testing.normalizeTelephonyPcm(Buffer.from([0xff, 0x7f]), outputFormat);
expect(pcm.length).toBe(4);
expect(pcm.readInt16LE(0)).toBe(0);
},
);
it("writes valid PCM headers and enforces the call window", () => {
const wav = testing.wrapPcm16MonoInWav(Buffer.alloc(4), 16_000);
expect(wav.readUInt32LE(4)).toBe(40);
expect(wav.readUInt16LE(22)).toBe(1);
expect(wav.readUInt16LE(34)).toBe(16);
expect(() => testing.wrapPcm16MonoInWav(Buffer.alloc(3), 16_000)).toThrow("invalid 16-bit PCM");
expect(() => testing.normalizeTelephonyPcm(Buffer.alloc(2), "mp3")).toThrow(
"unsupported telephony format",
);
expect(testing.resolveCallWindowMs(0, 24_000)).toBe(115_000);
expect(testing.resolveCallWindowMs(24_000 * 2 * 60, 24_000)).toBe(175_000);
expect(() => testing.resolveCallWindowMs(24_000 * 2 * 61, 24_000)).toThrow(
"60-second WhatsApp call limit",
);
});
it("shell-quotes the pairing command", () => {
expect(
testing.resolveSetupCommand("/tmp/call dir/$HOME's", "/tmp/call dir/$HOME's/wa-voip.db"),
).toBe(
`mkdir -p '/tmp/call dir/$HOME'"'"'s' && chmod 700 '/tmp/call dir/$HOME'"'"'s' && meowcaller pair --store '/tmp/call dir/$HOME'"'"'s/wa-voip.db'`,
);
expect(
testing.resolveSetupCommand(
String.raw`C:\Users\Peter O'Neil\calls`,
String.raw`C:\Users\Peter O'Neil\calls\wa-voip.db`,
"win32",
),
).toBe(String.raw`meowcaller pair --store 'C:\Users\Peter O''Neil\calls\wa-voip.db'`);
});
});

View File

@@ -0,0 +1,362 @@
// WhatsApp plugin tool places requester-bound calls through the MeowCaller companion CLI.
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import { createActionGate, stringEnum } from "openclaw/plugin-sdk/channel-actions";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginToolContext,
} from "openclaw/plugin-sdk/core";
import { mulawToPcm } from "openclaw/plugin-sdk/realtime-voice";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { Type } from "typebox";
import { resolveWhatsAppAccount } from "./accounts.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { resolveJidToE164 } from "./targets-runtime.js";
const MEOWCALLER_COMMAND = "meowcaller";
const SESSION_DATABASE = "wa-voip.db";
const MEOWCALLER_CONNECT_TIMEOUT_MS = 60_000;
const MEOWCALLER_ANSWER_TIMEOUT_MS = 45_000;
const CALL_SHUTDOWN_GRACE_MS = 10_000;
const MAX_AUDIO_DURATION_MS = 60_000;
const MIN_CALL_WINDOW_MS =
MEOWCALLER_CONNECT_TIMEOUT_MS + MEOWCALLER_ANSWER_TIMEOUT_MS + CALL_SHUTDOWN_GRACE_MS;
const MAX_CALL_WINDOW_MS = MIN_CALL_WINDOW_MS + MAX_AUDIO_DURATION_MS;
const MAX_MESSAGE_LENGTH = 4_000;
const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
const MEOWCALLER_ANSWER_TIMEOUT = "45s";
const MEOWCALLER_MAX_DURATION = "65s";
// One whatsmeow session database must not be driven by concurrent companion clients.
// Reject overlap so model retries cannot duplicate calls or contend on auth state.
const activeCallAccounts = new Set<string>();
const WhatsAppCallToolSchema = Type.Object(
{
action: stringEnum(["status", "call"] as const, {
description: "Check MeowCaller setup or call the current WhatsApp requester",
}),
message: Type.Optional(
Type.String({
description: "Spoken message to play after the requester answers (maximum 60 seconds)",
maxLength: MAX_MESSAGE_LENGTH,
}),
),
},
{ additionalProperties: false },
);
type WhatsAppCallToolParams = {
action: "status" | "call";
message?: string;
};
type WhatsAppCallToolDependencies = {
detectMeowCaller: () => Promise<boolean>;
resolveStateDir: (accountId: string) => string;
};
const defaultDependencies: WhatsAppCallToolDependencies = {
detectMeowCaller: () => detectBinary(MEOWCALLER_COMMAND),
resolveStateDir: (accountId) =>
path.join(resolveOAuthDir(), "whatsapp-calls", normalizeAccountId(accountId)),
};
function jsonResult(payload: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
details: payload,
};
}
async function isRegularFile(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
}
function quotePosixShellArg(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShellArg(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function resolveSetupCommand(
stateDir: string,
sessionStorePath: string,
platform: NodeJS.Platform = process.platform,
): string {
if (platform === "win32") {
return `meowcaller pair --store ${quotePowerShellArg(sessionStorePath)}`;
}
const quotedStateDir = quotePosixShellArg(stateDir);
const quotedStorePath = quotePosixShellArg(sessionStorePath);
return `mkdir -p ${quotedStateDir} && chmod 700 ${quotedStateDir} && meowcaller pair --store ${quotedStorePath}`;
}
function wrapPcm16MonoInWav(pcm: Buffer, sampleRate: number): Buffer {
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new Error("TTS returned an invalid sample rate");
}
if (pcm.length === 0 || pcm.length % 2 !== 0) {
throw new Error("TTS returned invalid 16-bit PCM audio");
}
const header = Buffer.alloc(44);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8, "ascii");
header.write("fmt ", 12, "ascii");
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36, "ascii");
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function normalizeTelephonyPcm(audio: Buffer, outputFormat: string | undefined): Buffer {
const normalizedFormat = outputFormat?.trim().toLowerCase();
if (normalizedFormat?.startsWith("pcm")) {
return audio;
}
if (normalizedFormat === "ulaw_8000" || normalizedFormat === "raw-8khz-8bit-mono-mulaw") {
return mulawToPcm(audio);
}
throw new Error(`TTS returned unsupported telephony format: ${outputFormat ?? "unknown"}`);
}
function resolveCallWindowMs(pcmBytes: number, sampleRate: number): number {
const audioDurationMs = (pcmBytes / 2 / sampleRate) * 1_000;
if (audioDurationMs > MAX_AUDIO_DURATION_MS) {
throw new Error("TTS audio exceeds the 60-second WhatsApp call limit");
}
return Math.min(MAX_CALL_WINDOW_MS, Math.ceil(audioDurationMs + MIN_CALL_WINDOW_MS));
}
async function resolveRequesterE164(params: {
accountId: string;
cfg: NonNullable<OpenClawPluginToolContext["config"]>;
requesterSenderId: string;
}): Promise<string | null> {
const senderId = params.requesterSenderId.trim();
if (!senderId.includes("@")) {
try {
return normalizeE164(senderId.replace(/^whatsapp:/i, ""));
} catch {
return null;
}
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const lidLookup = getRegisteredWhatsAppConnectionController(params.accountId)?.getCurrentSock()
?.signalRepository.lidMapping;
return await resolveJidToE164(senderId, { authDir: account.authDir, lidLookup });
}
async function resolveLinkedWhatsAppSelfE164(params: {
accountId: string;
cfg: NonNullable<OpenClawPluginToolContext["config"]>;
}): Promise<string | null> {
const controller = getRegisteredWhatsAppConnectionController(params.accountId);
if (!controller) {
return null;
}
const identity = controller.getSelfIdentity();
if (!identity) {
return null;
}
if (identity.e164) {
return normalizeE164(identity.e164);
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const lidLookup = controller.getCurrentSock()?.signalRepository.lidMapping;
return await resolveJidToE164(identity.jid ?? identity.lid, {
authDir: account.authDir,
lidLookup,
});
}
function resolveRuntimeConfig(api: OpenClawPluginApi, context: OpenClawPluginToolContext) {
return context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config ?? api.config;
}
function createWhatsAppCallToolWithDependencies(
api: OpenClawPluginApi,
context: OpenClawPluginToolContext,
dependencies: WhatsAppCallToolDependencies,
): AnyAgentTool | null {
const cfg = resolveRuntimeConfig(api, context);
const isActionEnabled = createActionGate(cfg.channels?.whatsapp?.actions);
const requesterSenderId = context.requesterSenderId?.trim();
if (
!isActionEnabled("calls", false) ||
context.messageChannel !== "whatsapp" ||
!requesterSenderId
) {
return null;
}
const accountId = normalizeAccountId(context.agentAccountId);
const stateDir = dependencies.resolveStateDir(accountId);
const sessionStorePath = path.join(stateDir, SESSION_DATABASE);
return {
name: "whatsapp_call",
label: "WhatsApp Call",
description:
"Call the current WhatsApp requester and play a synthesized spoken message. This tool cannot call arbitrary phone numbers.",
parameters: WhatsAppCallToolSchema,
async execute(_toolCallId, rawParams, signal) {
const params = rawParams as WhatsAppCallToolParams;
const binaryFound = await dependencies.detectMeowCaller();
const sessionStoreFound = await isRegularFile(sessionStorePath);
if (params.action === "status") {
return jsonResult({
binaryFound,
sessionStoreFound,
accountId,
stateDir,
setupCommand: resolveSetupCommand(stateDir, sessionStorePath),
setupShell: process.platform === "win32" ? "PowerShell" : "POSIX shell",
requiredCommand:
"meowcaller notify --store <path> --answer-timeout 45s --max-duration 65s <target> <file>",
note: "MeowCaller uses a separate WhatsApp linked-device session; it cannot reuse OpenClaw's Baileys credentials.",
});
}
const message = params.message?.trim();
if (!message) {
throw new Error("message required for call action");
}
if (message.length > MAX_MESSAGE_LENGTH) {
throw new Error(`message must be at most ${MAX_MESSAGE_LENGTH} characters`);
}
if (!binaryFound) {
throw new Error("MeowCaller is not installed; run whatsapp_call with action=status");
}
if (!sessionStoreFound) {
throw new Error(
"MeowCaller has no session store; run whatsapp_call with action=status, then run its setupCommand in an interactive terminal and scan the QR as a linked device",
);
}
const target = await resolveRequesterE164({
accountId,
cfg,
requesterSenderId,
});
if (!target) {
throw new Error("Could not resolve the current WhatsApp requester to a phone number");
}
const linkedSelf = await resolveLinkedWhatsAppSelfE164({ accountId, cfg });
if (linkedSelf === target) {
throw new Error(
"WhatsApp cannot call the linked account itself; use a dedicated OpenClaw WhatsApp number",
);
}
if (activeCallAccounts.has(accountId)) {
throw new Error("A WhatsApp call is already active for this account");
}
activeCallAccounts.add(accountId);
try {
const speech = await api.runtime.tts.textToSpeechTelephony({ text: message, cfg });
if (!speech.success || !speech.audioBuffer || !speech.sampleRate) {
throw new Error(speech.error ?? "TTS synthesis failed");
}
const pcm = normalizeTelephonyPcm(speech.audioBuffer, speech.outputFormat);
const callWindowMs = resolveCallWindowMs(pcm.length, speech.sampleRate);
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-whatsapp-call-"),
);
const audioPath = path.join(tempDir, "message.wav");
try {
await fs.writeFile(audioPath, wrapPcm16MonoInWav(pcm, speech.sampleRate), {
mode: 0o600,
});
const result = await api.runtime.system.runCommandWithTimeout(
[
MEOWCALLER_COMMAND,
"notify",
"--store",
sessionStorePath,
"--answer-timeout",
MEOWCALLER_ANSWER_TIMEOUT,
"--max-duration",
MEOWCALLER_MAX_DURATION,
target,
audioPath,
],
{
cwd: stateDir,
env: { MEOW_LOG_LEVEL: "warn" },
timeoutMs: callWindowMs,
signal,
killProcessTree: true,
maxOutputBytes: MAX_COMMAND_OUTPUT_BYTES,
},
);
if (result.termination === "signal") {
throw new Error("WhatsApp call cancelled");
}
if (result.termination === "timeout") {
throw new Error("MeowCaller exceeded the bounded WhatsApp call window");
}
if (result.termination !== "exit" || result.code !== 0) {
throw new Error(
`MeowCaller did not complete the call (code ${result.code ?? "unknown"})`,
);
}
return jsonResult({
completed: true,
recipient: "current WhatsApp requester",
callWindowSeconds: Math.ceil(callWindowMs / 1_000),
ttsProvider: speech.provider,
note: "MeowCaller completed answer, playback, and hangup for the requester-bound call.",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
} finally {
activeCallAccounts.delete(accountId);
}
},
};
}
export function createWhatsAppCallTool(
api: OpenClawPluginApi,
context: OpenClawPluginToolContext,
): AnyAgentTool | null {
return createWhatsAppCallToolWithDependencies(api, context, defaultDependencies);
}
export function registerWhatsAppCallTool(api: OpenClawPluginApi): void {
api.registerTool((context) => createWhatsAppCallTool(api, context), {
name: "whatsapp_call",
});
}
export const testing = {
createWhatsAppCallToolWithDependencies,
normalizeTelephonyPcm,
resolveCallWindowMs,
resolveLinkedWhatsAppSelfE164,
resolveRequesterE164,
resolveSetupCommand,
wrapPcm16MonoInWav,
};

View File

@@ -0,0 +1,115 @@
// Whatsapp tests cover agent tools login plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
import { createWhatsAppLoginTool } from "./agent-tools-login.js";
vi.mock("../login-qr-api.js", () => ({
startWebLoginWithQr: vi.fn(),
waitForWebLogin: vi.fn(),
}));
const startWebLoginWithQrMock = vi.mocked(startWebLoginWithQr);
const waitForWebLoginMock = vi.mocked(waitForWebLogin);
describe("createWhatsAppLoginTool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes the caller's current QR back into wait actions", async () => {
const accountId = "account-1";
waitForWebLoginMock.mockResolvedValueOnce({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,next-qr",
});
const tool = createWhatsAppLoginTool();
const result = await tool.execute("tool-call-1", {
action: "wait",
timeoutMs: "5000",
accountId,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(waitForWebLoginMock).toHaveBeenCalledWith({
accountId,
timeoutMs: 5000,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(result).toEqual({
content: [
{
type: "text",
text: [
"QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
"",
"Open WhatsApp → Linked Devices and scan:",
"",
"![whatsapp-qr](data:image/png;base64,next-qr)",
].join("\n"),
},
],
details: {
connected: false,
qr: true,
},
});
});
it("passes string timeoutMs through to start actions", async () => {
startWebLoginWithQrMock.mockResolvedValueOnce({
connected: false,
message: "Scan this QR in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,current-qr",
});
const tool = createWhatsAppLoginTool();
await tool.execute("tool-call-start", {
action: "start",
timeoutMs: "6000",
accountId: "account-3",
});
expect(startWebLoginWithQrMock).toHaveBeenCalledWith({
accountId: "account-3",
timeoutMs: 6000,
force: false,
});
});
it("rejects fractional timeoutMs before login actions", async () => {
const tool = createWhatsAppLoginTool();
await expect(
tool.execute("tool-call-start", {
action: "start",
timeoutMs: "6000.5",
}),
).rejects.toThrow("timeoutMs must be a positive integer");
expect(startWebLoginWithQrMock).not.toHaveBeenCalled();
});
it("does not retain QR state across tool actions", async () => {
const accountId = "account-2";
startWebLoginWithQrMock.mockResolvedValueOnce({
connected: false,
message: "Scan this QR in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,current-qr",
});
waitForWebLoginMock.mockResolvedValueOnce({
connected: true,
message: "✅ Linked! WhatsApp is ready.",
});
const tool = createWhatsAppLoginTool();
await tool.execute("tool-call-start", { action: "start", accountId });
await tool.execute("tool-call-wait", { action: "wait", timeoutMs: 5000, accountId });
expect(waitForWebLoginMock).toHaveBeenCalledWith({
accountId,
timeoutMs: 5000,
currentQrDataUrl: undefined,
});
});
});

View File

@@ -0,0 +1,112 @@
// Whatsapp plugin module implements agent tools login behavior.
import {
optionalPositiveIntegerSchema,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/channel-actions";
import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
import { Type } from "typebox";
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
const QR_DATA_URL_MAX_LENGTH = 16_384;
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
parameters: Type.Object({
action: Type.Unsafe<"start" | "wait">({
type: "string",
enum: ["start", "wait"],
}),
timeoutMs: optionalPositiveIntegerSchema(),
force: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String()),
currentQrDataUrl: Type.Optional(
Type.String({
maxLength: QR_DATA_URL_MAX_LENGTH,
pattern: "^data:image/png;base64,",
}),
),
}),
execute: async (_toolCallId, args) => {
const renderQrReply = (params: {
message: string;
qrDataUrl: string;
connected?: boolean;
}) => {
const text = [
params.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${params.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text" as const, text }],
details: {
connected: params.connected ?? false,
qr: true,
},
};
};
const action = (args as { action?: string })?.action ?? "start";
const accountId = readOptionalString((args as { accountId?: unknown }).accountId);
const timeoutMs = readPositiveIntegerParam(args as Record<string, unknown>, "timeoutMs");
if (action === "wait") {
const result = await waitForWebLogin({
accountId,
timeoutMs,
currentQrDataUrl: readOptionalString(
(args as { currentQrDataUrl?: unknown }).currentQrDataUrl,
),
});
if (result.qrDataUrl) {
return renderQrReply({
message: result.message,
qrDataUrl: result.qrDataUrl,
connected: result.connected,
});
}
return {
content: [{ type: "text", text: result.message }],
details: { connected: result.connected },
};
}
const result = await startWebLoginWithQr({
accountId,
timeoutMs,
force:
typeof (args as { force?: unknown }).force === "boolean"
? (args as { force?: boolean }).force
: false,
});
if (!result.qrDataUrl) {
return {
content: [
{
type: "text",
text: result.message,
},
],
details: { qr: false },
};
}
return renderQrReply({
message: result.message,
qrDataUrl: result.qrDataUrl,
connected: result.connected,
});
},
};
}

View File

@@ -0,0 +1,62 @@
// Whatsapp tests cover approval auth plugin behavior.
import { describe, expect, it } from "vitest";
import { getWhatsAppApprovalApprovers, whatsappApprovalAuth } from "./approval-auth.js";
describe("whatsappApprovalAuth", () => {
it("authorizes direct WhatsApp recipients and ignores group entries", () => {
expect(
whatsappApprovalAuth.authorizeActorAction({
cfg: { channels: { whatsapp: { allowFrom: ["+1 (555) 123-0000"] } } },
senderId: "15551230000@s.whatsapp.net",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
getWhatsAppApprovalApprovers({
cfg: { channels: { whatsapp: { allowFrom: ["12345-67890@g.us"] } } },
}),
).toEqual([]);
expect(
whatsappApprovalAuth.authorizeActorAction({
cfg: { channels: { whatsapp: { allowFrom: ["+15551230000"] } } },
senderId: "+15551239999",
action: "approve",
approvalKind: "exec",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve exec requests on WhatsApp.",
});
});
it("does not treat defaultTo as an explicit approval approver", () => {
expect(
getWhatsAppApprovalApprovers({
cfg: { channels: { whatsapp: { allowFrom: [], defaultTo: "+15551230000" } } },
}),
).toEqual([]);
expect(
whatsappApprovalAuth.authorizeActorAction({
cfg: { channels: { whatsapp: { allowFrom: [], defaultTo: "+15551230000" } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
it("supports explicit wildcard approval approvers", () => {
expect(
whatsappApprovalAuth.authorizeActorAction({
cfg: { channels: { whatsapp: { allowFrom: ["*"] } } },
senderId: "+15551230000",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
});
});

View File

@@ -0,0 +1,64 @@
// Whatsapp plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { resolveWhatsAppAccount } from "./accounts.js";
import { normalizeWhatsAppTarget } from "./normalize.js";
type ApprovalKind = "exec" | "plugin";
export function normalizeWhatsAppApproverId(value: string | number): string | undefined {
const normalized = normalizeWhatsAppTarget(String(value));
if (!normalized || normalized.endsWith("@g.us")) {
return undefined;
}
return normalized;
}
function normalizeWhatsAppApproverEntry(value: string | number): string | undefined {
return String(value).trim() === "*" ? "*" : normalizeWhatsAppApproverId(value);
}
export function getWhatsAppApprovalApprovers(params: {
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
accountId?: string | null;
}): string[] {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
normalizeApprover: normalizeWhatsAppApproverEntry,
});
}
const whatsappResolvedApproverAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "WhatsApp",
resolveApprovers: ({ cfg, accountId }) => getWhatsAppApprovalApprovers({ cfg, accountId }),
normalizeSenderId: (value) => normalizeWhatsAppApproverId(value),
});
export const whatsappApprovalAuth = {
authorizeActorAction({
cfg,
accountId,
senderId,
approvalKind,
}: {
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
accountId?: string | null;
senderId?: string | null;
action: "approve";
approvalKind: ApprovalKind;
}) {
if (getWhatsAppApprovalApprovers({ cfg, accountId }).includes("*")) {
return { authorized: true } as const;
}
return whatsappResolvedApproverAuth.authorizeActorAction({
cfg,
accountId,
senderId,
action: "approve",
approvalKind,
});
},
};

View File

@@ -0,0 +1,160 @@
// Whatsapp tests cover approval handler plugin behavior.
import { describe, expect, it } from "vitest";
import { whatsappApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("whatsappApprovalNativeRuntime", () => {
it("renders allowed thumbs-only reactions in pending exec approvals", async () => {
const payload = await whatsappApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
request: {
id: "exec-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve exec-1 allow-once",
style: "success",
},
{
decision: "deny",
label: "Deny",
command: "/approve exec-1 deny",
style: "danger",
},
],
} as never,
});
expect(payload.reactionPayload.text).toContain("👍 Allow Once");
expect(payload.reactionPayload.text).toContain("👎 Deny");
expect(payload.reactionPayload.text).not.toContain("1⃣ Allow Once");
expect(payload.reactionPayload.text).not.toContain("2⃣ Allow Always");
expect(payload.reactionPayload.text).not.toContain("3⃣ Deny");
expect(payload.reactionPayload.allowedDecisions).toEqual(["allow-once", "deny"]);
});
it("renders allowed thumbs-only reactions in pending plugin approvals", async () => {
const payload = await whatsappApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: { accountId: "default" },
request: {
id: "plugin:abc",
request: {
title: "Allow Codex to use 1Password?",
description: "Allow Codex to use 1Password?",
pluginId: "openclaw-codex-app-server",
toolName: "codex_mcp_tool_approval",
severity: "warning",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
approvalId: "plugin:abc",
title: "Plugin approval required",
severity: "warning",
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve plugin:abc allow-once",
style: "success",
},
{
decision: "allow-always",
label: "Allow Always",
command: "/approve plugin:abc allow-always",
style: "primary",
},
{
decision: "deny",
label: "Deny",
command: "/approve plugin:abc deny",
style: "danger",
},
],
} as never,
});
expect(payload.reactionPayload.text).toContain("Plugin approval required");
expect(payload.reactionPayload.text).toContain(
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
);
expect(payload.reactionPayload.text).toContain("👍 Allow Once");
expect(payload.reactionPayload.text).toContain("👎 Deny");
expect(payload.reactionPayload.text).not.toContain("/approve <id>");
expect(payload.reactionPayload.text).not.toContain("1⃣ Allow Once");
expect(payload.reactionPayload.text).not.toContain("2⃣ Allow Always");
expect(payload.reactionPayload.text).not.toContain("3⃣ Deny");
expect(payload.reactionPayload.allowedDecisions).toEqual([
"allow-once",
"allow-always",
"deny",
]);
});
it("normalizes WhatsApp targets and carries account ids into prepared delivery", async () => {
await expect(
whatsappApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "ops",
context: { accountId: "ops" },
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "15551230000@s.whatsapp.net",
},
},
request: {
id: "exec-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
view: {
approvalKind: "exec",
approvalId: "exec-1",
commandText: "echo hi",
actions: [],
} as never,
pendingPayload: {
manualFallbackPayload: { text: "pending" },
reactionPayload: {
text: "pending",
allowedDecisions: ["allow-once"],
reactionBindings: [],
},
},
}),
).resolves.toEqual({
dedupeKey: expect.any(String),
target: {
to: "+15551230000",
accountId: "ops",
},
});
});
});

View File

@@ -0,0 +1,170 @@
// Whatsapp plugin module implements approval handler behavior.
import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type PendingApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildApprovalReactionPendingContent,
type ApprovalReactionPendingContent,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
registerWhatsAppApprovalReactionTarget,
unregisterWhatsAppApprovalReactionTarget,
} from "./approval-reactions.js";
import { normalizeWhatsAppMessagingTarget } from "./normalize.js";
import { getWhatsAppRuntime } from "./runtime.js";
import { sendMessageWhatsApp, sendTypingWhatsApp } from "./send.js";
const log = createSubsystemLogger("whatsapp/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type WhatsAppPendingDelivery = ApprovalReactionPendingContent;
type PreparedWhatsAppApprovalTarget = {
to: string;
accountId?: string;
};
type PendingWhatsAppApprovalEntry = {
accountId?: string;
to: string;
remoteJid: string;
messageId: string;
};
type WhatsAppFinalPayload = {
text: string;
};
function buildPendingPayload(params: {
request: ApprovalRequest;
view: PendingApprovalView;
nowMs: number;
}): WhatsAppPendingDelivery {
return buildApprovalReactionPendingContent(params);
}
export const whatsappApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
WhatsAppPendingDelivery,
PreparedWhatsAppApprovalTarget,
PendingWhatsAppApprovalEntry,
true,
WhatsAppFinalPayload
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ context }) => Boolean(context),
shouldHandle: ({ context }) => Boolean(context),
},
presentation: {
buildPendingPayload: ({ request, nowMs, view }) =>
buildPendingPayload({ request, view, nowMs }),
buildResolvedResult: ({ request, resolved, view }) => ({
kind: "update",
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
prepareTarget: ({ plannedTarget, accountId }) => {
const to = normalizeWhatsAppMessagingTarget(plannedTarget.target.to);
if (!to) {
return null;
}
const prepared: PreparedWhatsAppApprovalTarget = {
to,
accountId: resolvePreparedApprovalAccountId({
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
contextAccountId: accountId,
}),
};
return {
dedupeKey: `${prepared.accountId ?? ""}:${buildChannelApprovalNativeTargetKey({
to: prepared.to,
})}`,
target: prepared,
};
},
deliverPending: async ({ cfg, preparedTarget, pendingPayload }) => {
const verbose = getWhatsAppRuntime().logging.shouldLogVerbose();
await sendTypingWhatsApp(preparedTarget.to, {
cfg,
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
}).catch(() => {});
const result = await sendMessageWhatsApp(
preparedTarget.to,
pendingPayload.reactionPayload.text ?? "",
{
cfg,
verbose,
preserveLeadingWhitespace: true,
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
},
);
if (!result.messageId) {
return null;
}
return {
accountId: preparedTarget.accountId,
to: preparedTarget.to,
remoteJid: result.toJid,
messageId: result.messageId,
};
},
updateEntry: async ({ cfg, entry, payload }) => {
const verbose = getWhatsAppRuntime().logging.shouldLogVerbose();
await sendMessageWhatsApp(entry.to, payload.text, {
cfg,
verbose,
preserveLeadingWhitespace: true,
...(entry.accountId ? { accountId: entry.accountId } : {}),
quotedMessageKey: {
id: entry.messageId,
remoteJid: entry.remoteJid,
fromMe: true,
},
});
},
},
interactions: {
bindPending: ({ entry, request, view, pendingPayload }) =>
registerWhatsAppApprovalReactionTarget({
accountId: entry.accountId ?? "",
remoteJid: entry.remoteJid,
messageId: entry.messageId,
approvalId: request.id,
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
ttlMs: Math.max(1, view.expiresAtMs - Date.now()),
})
? true
: null,
unbindPending: ({ entry }) => {
unregisterWhatsAppApprovalReactionTarget({
accountId: entry.accountId ?? "",
remoteJid: entry.remoteJid,
messageId: entry.messageId,
});
},
cancelDelivered: ({ entry }) => {
unregisterWhatsAppApprovalReactionTarget({
accountId: entry.accountId ?? "",
remoteJid: entry.remoteJid,
messageId: entry.messageId,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
log.error(`whatsapp approvals: failed to send request ${request.id}: ${String(error)}`);
},
},
});

View File

@@ -0,0 +1,483 @@
// Whatsapp tests cover approval native plugin behavior.
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { whatsappApprovalCapability } from "./approval-native.js";
type WhatsAppConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>;
function buildConfig(
params: {
whatsapp?: Partial<WhatsAppConfig>;
approvals?: OpenClawConfig["approvals"];
} = {},
): OpenClawConfig {
return {
channels: {
whatsapp: {
enabled: true,
...params.whatsapp,
},
},
approvals: params.approvals,
} as OpenClawConfig;
}
function buildExecRequest(
turnSourceTo: string,
overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
return {
id: "exec-1",
request: {
command: "echo hi",
agentId: "main",
turnSourceChannel: "whatsapp",
turnSourceTo,
turnSourceAccountId: "default",
sessionKey: `agent:main:whatsapp:${turnSourceTo}`,
...overrides,
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
function buildPluginRequest(
turnSourceTo: string,
overrides: Partial<PluginApprovalRequest["request"]> = {},
): PluginApprovalRequest {
return {
id: "plugin:approval-1",
request: {
title: "Plugin approval",
description: "Allow plugin action",
agentId: "main",
turnSourceChannel: "whatsapp",
turnSourceTo,
turnSourceAccountId: "default",
sessionKey: `agent:main:whatsapp:${turnSourceTo}`,
...overrides,
},
createdAtMs: 0,
expiresAtMs: 1000,
};
}
function nativeShouldHandle(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest | PluginApprovalRequest;
accountId?: string | null;
}) {
return whatsappApprovalCapability.nativeRuntime?.availability.shouldHandle({
cfg: params.cfg,
accountId: params.accountId ?? "default",
context: {},
request: params.request,
});
}
describe("whatsapp approval capability", () => {
it("does not enable exec or plugin native approvals from WhatsApp account readiness alone", () => {
const cfg = buildConfig();
const execRequest = buildExecRequest("+15551230000");
const pluginRequest = buildPluginRequest("+15551230000");
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ kind: "disabled" });
expect(
whatsappApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request: execRequest,
}).enabled,
).toBe(false);
expect(nativeShouldHandle({ cfg, request: execRequest })).toBe(false);
expect(nativeShouldHandle({ cfg, request: pluginRequest })).toBe(false);
});
it("allows session-mode exec delivery for matching WhatsApp origins", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("+15551230000");
expect(
whatsappApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}),
).toEqual({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: true,
});
expect(nativeShouldHandle({ cfg, request })).toBe(true);
});
it("keeps exec and plugin forwarding gates independent", () => {
const execOnly = buildConfig({ approvals: { exec: { enabled: true } } });
const pluginOnly = buildConfig({ approvals: { plugin: { enabled: true } } });
expect(nativeShouldHandle({ cfg: execOnly, request: buildPluginRequest("+15551230000") })).toBe(
false,
);
expect(nativeShouldHandle({ cfg: pluginOnly, request: buildExecRequest("+15551230000") })).toBe(
false,
);
expect(
nativeShouldHandle({ cfg: pluginOnly, request: buildPluginRequest("+15551230000") }),
).toBe(true);
});
it("does not use session mode for non-WhatsApp-origin requests", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("", {
turnSourceChannel: "slack",
turnSourceTo: "C123",
sessionKey: "agent:main:slack:channel:c123",
});
expect(nativeShouldHandle({ cfg, request })).toBe(false);
expect(
whatsappApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}).enabled,
).toBe(false);
});
it("uses target-mode config for requestless availability without native runtime handling", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15551230000" }],
},
},
});
const request = buildExecRequest("+15551230000");
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "enabled" });
expect(
whatsappApprovalCapability.nativeRuntime?.availability.isConfigured({
cfg,
accountId: "default",
context: {},
}),
).toBe(false);
expect(nativeShouldHandle({ cfg, request })).toBe(false);
expect(
whatsappApprovalCapability.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}).enabled,
).toBe(false);
});
it("renders target-mode exec prompts with concrete thumbs-only reaction choices", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15551230000" }],
},
},
});
const request = buildExecRequest("+15551230000", {
ask: "always",
cwd: "/tmp/work",
host: "gateway",
});
const payload = whatsappApprovalCapability.render?.exec?.buildPendingPayload?.({
cfg,
request,
target: { channel: "whatsapp", to: "+15551230000", source: "target" },
nowMs: 0,
});
const text = payload?.text ?? "";
expect(text).toContain("/approve exec-1 allow-once");
expect(text).toContain("React with:");
expect(text).toContain("👍 Allow Once");
expect(text).toContain("👎 Deny");
expect(text).not.toContain("<id>");
expect(text).not.toContain("1⃣ Allow Once");
expect(text).not.toContain("2⃣ Allow Always");
expect(text).not.toContain("3⃣ Deny");
expect(text.indexOf("React with:")).toBeLessThan(text.indexOf("/approve exec-1 allow-once"));
});
it("renders target-mode plugin prompts with concrete thumbs-only reaction choices", () => {
const cfg = buildConfig({
approvals: {
plugin: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15551230000" }],
},
},
});
const request = buildPluginRequest("+15551230000", {
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const payload = whatsappApprovalCapability.render?.plugin?.buildPendingPayload?.({
cfg,
request,
target: { channel: "whatsapp", to: "+15551230000", source: "target" },
nowMs: 0,
});
expect(payload?.text).toContain("/approve plugin:approval-1 allow-once");
expect(payload?.text).toContain(
"Reply with: /approve plugin:approval-1 allow-once|allow-always|deny",
);
expect(payload?.text).toContain("React with:");
expect(payload?.text).toContain("👍 Allow Once");
expect(payload?.text).toContain("👎 Deny");
expect(payload?.text).not.toContain("1⃣ Allow Once");
expect(payload?.text).not.toContain("2⃣ Allow Always");
expect(payload?.text).not.toContain("3⃣ Deny");
expect(payload?.text).not.toContain("<id>");
});
it("does not report target-mode availability when no WhatsApp target matches", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "slack", to: "C123" }],
},
},
});
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
});
it("applies agent and session filters to native handling", () => {
const request = buildExecRequest("+15551230000", {
agentId: "main",
sessionKey: "agent:main:whatsapp:+15551230000",
});
const blockedByAgent = buildConfig({
approvals: { exec: { enabled: true, agentFilter: ["other"] } },
});
const blockedBySession = buildConfig({
approvals: { exec: { enabled: true, sessionFilter: ["telegram"] } },
});
expect(nativeShouldHandle({ cfg: blockedByAgent, request })).toBe(false);
expect(nativeShouldHandle({ cfg: blockedBySession, request })).toBe(false);
});
it("matches account-scoped top-level WhatsApp targets only for that account", () => {
const cfg = buildConfig({
whatsapp: {
accounts: {
work: { enabled: true },
},
} as Partial<WhatsAppConfig>,
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15551230000", accountId: "work" }],
},
},
});
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "disabled" });
expect(
whatsappApprovalCapability?.getActionAvailabilityState?.({
cfg,
accountId: "work",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ kind: "enabled" });
});
it("suppresses forwarding fallback only when the exact session-origin native target matches", () => {
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
const request = buildExecRequest("+15551230000");
const shouldSuppress = whatsappApprovalCapability.delivery?.shouldSuppressForwardingFallback;
expect(
shouldSuppress?.({
cfg,
approvalKind: "exec",
target: {
channel: "whatsapp",
to: "+15551230000",
accountId: "default",
source: "session",
},
request,
}),
).toBe(true);
expect(
shouldSuppress?.({
cfg,
approvalKind: "exec",
target: {
channel: "whatsapp",
to: "+15550000000",
accountId: "default",
source: "session",
},
request,
}),
).toBe(false);
});
it("does not suppress target-only forwarding when native delivery cannot bind that target", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "whatsapp", to: "+15550000000" }],
},
},
});
expect(
whatsappApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "whatsapp", to: "+15550000000", source: "target" },
request: buildExecRequest("+15551230000"),
}),
).toBe(false);
});
it("suppresses both-mode explicit targets that omit the origin account id", () => {
const cfg = buildConfig({
approvals: {
exec: {
enabled: true,
mode: "both",
targets: [{ channel: "whatsapp", to: "+15551230000" }],
},
},
});
expect(
whatsappApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "whatsapp", to: "+15551230000", source: "target" },
request: buildExecRequest("+15551230000"),
}),
).toBe(true);
});
it("suppresses both-mode unscoped targets through the configured default WhatsApp account", () => {
const cfg = buildConfig({
whatsapp: {
defaultAccount: "work",
accounts: {
default: { enabled: true },
work: { enabled: true },
},
} as Partial<WhatsAppConfig>,
approvals: {
exec: {
enabled: true,
mode: "both",
targets: [{ channel: "whatsapp", to: "+15551230000" }],
},
},
});
expect(
whatsappApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
cfg,
approvalKind: "exec",
target: { channel: "whatsapp", to: "+15551230000", source: "target" },
request: buildExecRequest("+15551230000", {
turnSourceAccountId: "work",
}),
}),
).toBe(true);
});
it("allows group-origin emoji approvals only after exec forwarding and approvers are configured", () => {
const request = buildExecRequest("120363401234567890@g.us");
const withoutApprovers = buildConfig({ approvals: { exec: { enabled: true } } });
const withApprovers = buildConfig({
whatsapp: { allowFrom: ["+15551230000"] },
approvals: { exec: { enabled: true } },
});
expect(
whatsappApprovalCapability.native?.resolveOriginTarget?.({
cfg: withoutApprovers,
accountId: "default",
approvalKind: "exec",
request,
}),
).toBeNull();
expect(
whatsappApprovalCapability.native?.resolveOriginTarget?.({
cfg: withApprovers,
accountId: "default",
approvalKind: "exec",
request,
}),
).toEqual({
to: "120363401234567890@g.us",
accountId: "default",
});
});
});

View File

@@ -0,0 +1,274 @@
// Whatsapp plugin module implements approval native behavior.
import { createChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createNativeApprovalChannelRouteGates,
createNativeApprovalForwardingFallbackSuppressor,
} from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalReactionPromptPayloadForRequest } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
} from "./accounts.js";
import { getWhatsAppApprovalApprovers, whatsappApprovalAuth } from "./approval-auth.js";
import { isWhatsAppGroupJid, normalizeWhatsAppMessagingTarget } from "./normalize.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type ChannelApprovalForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
>
>[0]["target"];
type WhatsAppApprovalTarget = {
to: string;
accountId?: string | null;
threadId?: string | number | null;
};
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
function isWhatsAppApprovalTransportEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
}
function normalizeWhatsAppForwardTarget(
target: Pick<ChannelApprovalForwardTarget, "channel" | "to" | "accountId" | "threadId">,
): WhatsAppApprovalTarget | null {
if (normalizeLowercaseStringOrEmpty(target.channel) !== "whatsapp") {
return null;
}
const to = normalizeWhatsAppMessagingTarget(target.to);
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(target.accountId),
threadId: target.threadId ?? null,
};
}
function resolveTurnSourceWhatsAppOriginTarget(
request: ApprovalRequest,
): WhatsAppApprovalTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
if (turnSourceChannel !== "whatsapp") {
return null;
}
const to = normalizeWhatsAppMessagingTarget(request.request.turnSourceTo ?? "");
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(request.request.turnSourceAccountId),
};
}
function resolveSessionWhatsAppOriginTarget(sessionTarget: {
to: string;
accountId?: string | null;
}): WhatsAppApprovalTarget | null {
const to = normalizeWhatsAppMessagingTarget(sessionTarget.to);
return to ? { to, accountId: normalizeOptionalString(sessionTarget.accountId) } : null;
}
const whatsappApprovalRouteGates = createNativeApprovalChannelRouteGates({
channel: "whatsapp",
defaultForwardingMode: DEFAULT_APPROVAL_FORWARDING_MODE,
isTransportEnabled: isWhatsAppApprovalTransportEnabled,
listAccountIds: listWhatsAppAccountIds,
resolveDefaultAccountId: resolveDefaultWhatsAppAccountId,
normalizeForwardTarget: normalizeWhatsAppForwardTarget,
resolveTurnSourceTarget: resolveTurnSourceWhatsAppOriginTarget,
});
const {
canApprovalPotentiallyRouteToChannel: canApprovalPotentiallyRouteToWhatsApp,
canAnyApprovalPotentiallyRouteToChannel: canAnyApprovalPotentiallyRouteToWhatsApp,
isSessionApprovalEligible: isWhatsAppSessionApprovalEligible,
isExplicitTargetEligible: isWhatsAppExplicitTargetEligible,
shouldHandleApprovalRequest: shouldHandleWhatsAppApprovalRequest,
} = whatsappApprovalRouteGates;
const resolveWhatsAppOriginTargetBase = createChannelNativeOriginTargetResolver({
channel: "whatsapp",
shouldHandleRequest: shouldHandleWhatsAppApprovalRequest,
resolveTurnSourceTarget: resolveTurnSourceWhatsAppOriginTarget,
resolveSessionTarget: resolveSessionWhatsAppOriginTarget,
normalizeTarget: (target) => {
const to = normalizeWhatsAppMessagingTarget(target.to);
return to ? { ...target, to } : null;
},
});
function resolveWhatsAppOriginTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind?: "exec" | "plugin";
request: ApprovalRequest;
}): WhatsAppApprovalTarget | null {
const target = resolveWhatsAppOriginTargetBase(params);
if (!target) {
return null;
}
if (
isWhatsAppGroupJid(target.to) &&
getWhatsAppApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0
) {
return null;
}
return target;
}
const resolveWhatsAppApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: shouldHandleWhatsAppApprovalRequest,
resolveApprovers: getWhatsAppApprovalApprovers,
mapApprover: (approver, params) => {
const to = normalizeWhatsAppMessagingTarget(approver);
if (!to) {
return null;
}
return {
to,
accountId: normalizeOptionalString(params.accountId),
};
},
});
const shouldSuppressWhatsAppForwardingFallback =
createNativeApprovalForwardingFallbackSuppressor<WhatsAppApprovalTarget>({
channel: "whatsapp",
normalizeForwardTarget: normalizeWhatsAppForwardTarget,
resolveAccountId: ({ forwardingTarget, request }) =>
forwardingTarget.accountId ?? normalizeOptionalString(request.request.turnSourceAccountId),
resolveForwardingTargetForMatch: ({ forwardingTarget, accountId }) => ({
...forwardingTarget,
accountId,
}),
isSessionRouteEligible: isWhatsAppSessionApprovalEligible,
isExplicitTargetEligible: isWhatsAppExplicitTargetEligible,
resolveOriginTarget: resolveWhatsAppOriginTarget,
resolveApproverDmTargets: resolveWhatsAppApproverDmTargets,
});
function buildWhatsAppExecPendingPayload(params: { request: ExecApprovalRequest; nowMs: number }) {
return buildApprovalReactionPromptPayloadForRequest(params);
}
function buildWhatsAppPluginPendingPayload(params: {
request: PluginApprovalRequest;
nowMs: number;
}) {
return buildApprovalReactionPromptPayloadForRequest(params);
}
export const whatsappApprovalCapability: ChannelApprovalCapability =
createChannelApprovalCapability({
...whatsappApprovalAuth,
getActionAvailabilityState: ({ cfg, accountId, approvalKind }) =>
(
approvalKind
? canApprovalPotentiallyRouteToWhatsApp({ cfg, accountId, approvalKind })
: canAnyApprovalPotentiallyRouteToWhatsApp({ cfg, accountId })
)
? ({ kind: "enabled" } as const)
: ({ kind: "disabled" } as const),
getExecInitiatingSurfaceState: ({ cfg, accountId }) =>
canApprovalPotentiallyRouteToWhatsApp({ cfg, accountId, approvalKind: "exec" })
? ({ kind: "enabled" } as const)
: ({ kind: "disabled" } as const),
describeExecApprovalSetup: ({ accountId }) => {
const prefix =
accountId && accountId !== "default"
? `channels.whatsapp.accounts.${accountId}`
: "channels.whatsapp";
return `WhatsApp supports native exec approvals for this account when \`approvals.exec.enabled\` is true and the route allows WhatsApp. Link WhatsApp and keep the gateway running; configure \`${prefix}.allowFrom\` to restrict approvers.`;
},
delivery: {
hasConfiguredDmRoute: ({ cfg }) =>
listWhatsAppAccountIds(cfg).some((accountId) => {
if (
!canAnyApprovalPotentiallyRouteToWhatsApp({
cfg,
accountId,
nativeSessionOnly: true,
})
) {
return false;
}
return getWhatsAppApprovalApprovers({ cfg, accountId }).length > 0;
}),
shouldSuppressForwardingFallback: shouldSuppressWhatsAppForwardingFallback,
},
render: {
exec: {
buildPendingPayload: ({ request, nowMs }) =>
buildWhatsAppExecPendingPayload({ request, nowMs }),
},
plugin: {
buildPendingPayload: ({ request, nowMs }) =>
buildWhatsAppPluginPendingPayload({ request, nowMs }),
},
},
native: {
describeDeliveryCapabilities: ({ cfg, accountId, approvalKind, request }) => {
const originTarget = resolveWhatsAppOriginTarget({
cfg,
accountId,
approvalKind,
request,
});
const approverTargets = resolveWhatsAppApproverDmTargets({
cfg,
accountId,
approvalKind,
request,
});
const enabled = Boolean(originTarget) || approverTargets.length > 0;
return {
enabled,
preferredSurface: originTarget ? "origin" : "approver-dm",
supportsOriginSurface: Boolean(originTarget),
supportsApproverDmSurface: approverTargets.length > 0,
notifyOriginWhenDmOnly: true,
};
},
resolveOriginTarget: resolveWhatsAppOriginTarget,
resolveApproverDmTargets: resolveWhatsAppApproverDmTargets,
},
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId, context }) =>
Boolean(context) &&
canAnyApprovalPotentiallyRouteToWhatsApp({
cfg,
accountId,
nativeSessionOnly: true,
}),
shouldHandle: ({ cfg, accountId, context, request }) =>
Boolean(context) && shouldHandleWhatsAppApprovalRequest({ cfg, accountId, request }),
load: async () =>
(await import("./approval-handler.runtime.js"))
.whatsappApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});

View File

@@ -0,0 +1,454 @@
// Whatsapp tests cover approval reactions plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearWhatsAppApprovalReactionTargetsForTest,
extractWhatsAppApprovalPromptBinding,
maybeResolveWhatsAppApprovalReaction,
registerWhatsAppApprovalReactionTarget,
registerWhatsAppApprovalReactionTargetForOutboundMessage,
resolveWhatsAppApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
import { resolveEquivalentWhatsAppDirectChatJids, type LidLookup } from "./text-runtime.js";
const resolverMocks = vi.hoisted(() => ({
resolveWhatsAppApproval: vi.fn(),
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveWhatsAppApproval: resolverMocks.resolveWhatsAppApproval,
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
function approvalConfig(allowFrom: string[]) {
return {
channels: {
whatsapp: {
allowFrom,
},
},
};
}
function registerExecApprovalTarget(params: { remoteJid: string; approvalId?: string }): void {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: params.remoteJid,
messageId: "approval-message",
approvalId: params.approvalId ?? "exec-direct",
allowedDecisions: ["allow-once", "deny"],
});
}
function buildReactionMessage(params: {
remoteJid: string;
reactionRemoteJid?: string;
participant?: string;
fromMe?: boolean;
reactionFromMe?: boolean;
}) {
return {
key: {
id: "reaction-message",
remoteJid: params.remoteJid,
...(params?.participant ? { participant: params.participant } : {}),
fromMe: params.fromMe ?? false,
},
message: {
reactionMessage: {
text: "👍",
key: {
remoteJid: params.reactionRemoteJid ?? params.remoteJid,
id: "approval-message",
...(params.reactionFromMe === undefined ? {} : { fromMe: params.reactionFromMe }),
},
},
},
} as never;
}
describe("WhatsApp approval reactions", () => {
beforeEach(() => {
clearWhatsAppApprovalReactionTargetsForTest();
resolverMocks.resolveWhatsAppApproval.mockReset();
resolverMocks.resolveWhatsAppApproval.mockResolvedValue(undefined);
resolverMocks.isApprovalNotFoundError.mockReset();
resolverMocks.isApprovalNotFoundError.mockReturnValue(false);
});
it("registers reaction state when only allow-always is available", async () => {
expect(
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "msg-allow-always",
approvalId: "exec-allow-always",
allowedDecisions: ["allow-always"],
}),
).toEqual({
approvalId: "exec-allow-always",
approvalKind: "exec",
allowedDecisions: ["allow-always"],
});
await expect(
resolveWhatsAppApprovalReactionTargetWithPersistence({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "msg-allow-always",
reactionKey: "♾",
}),
).resolves.toEqual({
approvalId: "exec-allow-always",
decision: "allow-always",
});
});
it("resolves a registered reaction target", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "msg-1",
approvalId: "exec-1",
allowedDecisions: ["allow-once", "deny"],
});
await expect(
resolveWhatsAppApprovalReactionTargetWithPersistence({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "msg-1",
reactionKey: "👎",
}),
).resolves.toEqual({
approvalId: "exec-1",
decision: "deny",
});
});
it("extracts approval bindings only from canonical approval prompts", () => {
expect(
extractWhatsAppApprovalPromptBinding(
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|allow-always|deny",
),
).toEqual({
approvalId: "plugin:abc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
expect(
extractWhatsAppApprovalPromptBinding("Run /approve task-7 allow-once when you're ready."),
).toBeNull();
});
it("registers outbound target-mode approval prompts for reactions", async () => {
expect(
registerWhatsAppApprovalReactionTargetForOutboundMessage({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "approval-message",
text:
"Plugin approval required\n" +
"ID: plugin:abc\n\n" +
"React with:\n\n" +
"👍 Allow Once\n" +
"♾️ Allow Always\n" +
"👎 Deny\n\n" +
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
}),
).toBe(true);
await expect(
resolveWhatsAppApprovalReactionTargetWithPersistence({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "approval-message",
reactionKey: "👍",
}),
).resolves.toEqual({
approvalId: "plugin:abc",
decision: "allow-once",
});
});
it("authorizes group reactions using the participant, not the group chat", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "120363401234567890@g.us",
messageId: "approval-message",
approvalId: "plugin:abc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230000"]),
accountId: "default",
msg: buildReactionMessage({
remoteJid: "120363401234567890@g.us",
participant: "15551230000@s.whatsapp.net",
}),
resolveInboundJid: async (jid) =>
jid === "15551230000@s.whatsapp.net" ? "+15551230000" : null,
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({
cfg: approvalConfig(["+15551230000"]),
approvalId: "plugin:abc",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
});
});
it("authorizes direct self-chat reactions from the account owner", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "276853659042038@lid",
messageId: "approval-message",
approvalId: "exec-self",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230001"]),
accountId: "default",
msg: buildReactionMessage({
remoteJid: "276853659042038@lid",
fromMe: true,
reactionFromMe: true,
}),
selfLid: "276853659042038@lid",
resolveInboundJid: async (jid) => (jid === "276853659042038@lid" ? "+15551230001" : null),
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({
cfg: approvalConfig(["+15551230001"]),
approvalId: "exec-self",
decision: "allow-once",
senderId: "+15551230001",
gatewayUrl: undefined,
});
});
it.each([
{
name: "stored PN target from outer chat JID",
storedRemoteJid: "15551230001@s.whatsapp.net",
eventRemoteJid: "15551230001@s.whatsapp.net",
reactionRemoteJid: "276853659042038@lid",
actorId: "+15551230001",
},
{
name: "stored LID target from PN event",
storedRemoteJid: "276853659042038@lid",
eventRemoteJid: "15551230001@s.whatsapp.net",
actorId: "+15551230001",
lidForPn: "276853659042038@lid",
},
{
name: "stored PN target from LID event",
storedRemoteJid: "15551230001@s.whatsapp.net",
eventRemoteJid: "276853659042038@lid",
actorId: "+15551230001",
pnForLid: "15551230001:0@s.whatsapp.net",
},
{
name: "stored PN target from device-qualified PN event",
storedRemoteJid: "15551230001@s.whatsapp.net",
eventRemoteJid: "15551230001:0@s.whatsapp.net",
actorId: "+15551230001",
},
{
name: "stored LID target from device-qualified LID event",
storedRemoteJid: "276853659042038@lid",
eventRemoteJid: "276853659042038:1@lid",
actorId: "+15551230001",
},
])("resolves direct approval reactions across PN/LID target drift: $name", async (testCase) => {
registerExecApprovalTarget({ remoteJid: testCase.storedRemoteJid });
const lidLookup: LidLookup = {
getLIDForPN: vi.fn().mockResolvedValue(testCase.lidForPn ?? null),
getPNForLID: vi.fn().mockResolvedValue(testCase.pnForLid ?? null),
};
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig([testCase.actorId]),
accountId: "default",
msg: buildReactionMessage({
remoteJid: testCase.eventRemoteJid,
reactionRemoteJid: testCase.reactionRemoteJid,
}),
resolveInboundJid: async (jid) => (jid === testCase.eventRemoteJid ? testCase.actorId : null),
resolveReactionTargetJids: async (jid) =>
resolveEquivalentWhatsAppDirectChatJids(jid, { lidLookup }),
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).toHaveBeenCalledWith({
cfg: approvalConfig([testCase.actorId]),
approvalId: "exec-direct",
decision: "allow-once",
senderId: testCase.actorId,
gatewayUrl: undefined,
});
});
it("does not use a group reaction actor as a direct-chat target candidate", async () => {
registerExecApprovalTarget({ remoteJid: "15551230000@s.whatsapp.net" });
const lidLookup: LidLookup = {
getLIDForPN: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net"),
getPNForLID: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net"),
};
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230000"]),
accountId: "default",
msg: buildReactionMessage({
remoteJid: "120363401234567890@g.us",
participant: "15551230000@s.whatsapp.net",
}),
resolveInboundJid: async () => "+15551230000",
resolveReactionTargetJids: async (jid) =>
resolveEquivalentWhatsAppDirectChatJids(jid, { lidLookup }),
});
expect(handled).toBe(false);
expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled();
});
it("unregisters the matched target candidate when an approval expired", async () => {
registerExecApprovalTarget({
remoteJid: "15551230000@s.whatsapp.net",
approvalId: "exec-expired",
});
resolverMocks.resolveWhatsAppApproval.mockRejectedValueOnce(
new Error("unknown or expired approval id"),
);
resolverMocks.isApprovalNotFoundError.mockReturnValue(true);
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230000"]),
accountId: "default",
msg: buildReactionMessage({ remoteJid: "276853659042038@lid" }),
resolveInboundJid: async () => "+15551230000",
resolveReactionTargetJids: async (jid) =>
resolveEquivalentWhatsAppDirectChatJids(jid, {
lidLookup: { getPNForLID: vi.fn().mockResolvedValue("15551230000@s.whatsapp.net") },
}),
});
expect(handled).toBe(true);
await expect(
resolveWhatsAppApprovalReactionTargetWithPersistence({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "approval-message",
reactionKey: "👍",
}),
).resolves.toBeNull();
});
it("does not attribute a peer DM fromMe reaction to the peer", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "approval-message",
approvalId: "exec-peer",
allowedDecisions: ["allow-once", "deny"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230000"]),
accountId: "default",
msg: buildReactionMessage({
remoteJid: "15551230000@s.whatsapp.net",
fromMe: true,
reactionFromMe: true,
}),
selfLid: "276853659042038@lid",
resolveInboundJid: async (jid) => {
if (jid === "15551230000@s.whatsapp.net") {
return "+15551230000";
}
if (jid === "276853659042038@lid") {
return "+15551230001";
}
return null;
},
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled();
});
it("fails closed when a group reaction is missing actor identity", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "120363401234567890@g.us",
messageId: "approval-message",
approvalId: "exec-1",
allowedDecisions: ["allow-once"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: approvalConfig(["+15551230000"]),
accountId: "default",
msg: buildReactionMessage({ remoteJid: "120363401234567890@g.us" }),
resolveInboundJid: async () => null,
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled();
});
it("requires explicit approvers for direct approval reactions", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "15551230000@s.whatsapp.net",
messageId: "approval-message",
approvalId: "exec-1",
allowedDecisions: ["allow-once"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: {
channels: {
whatsapp: {},
},
},
accountId: "default",
msg: buildReactionMessage({ remoteJid: "15551230000@s.whatsapp.net" }),
resolveInboundJid: async () => "+15551230000",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled();
});
it("requires explicit approvers for group approval reactions", async () => {
registerWhatsAppApprovalReactionTarget({
accountId: "default",
remoteJid: "120363401234567890@g.us",
messageId: "approval-message",
approvalId: "exec-1",
allowedDecisions: ["allow-once"],
});
const handled = await maybeResolveWhatsAppApprovalReaction({
cfg: {
channels: {
whatsapp: {},
},
},
accountId: "default",
msg: buildReactionMessage({
remoteJid: "120363401234567890@g.us",
participant: "15551230000@s.whatsapp.net",
}),
resolveInboundJid: async () => "+15551230000",
});
expect(handled).toBe(true);
expect(resolverMocks.resolveWhatsAppApproval).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,400 @@
// Whatsapp plugin module implements approval reactions behavior.
import type { WAMessage } from "baileys";
import {
createApprovalReactionTargetStore,
listApprovalReactionBindings,
resolveApprovalReactionTarget,
type ApprovalReactionDecisionBinding,
type ApprovalReactionTargetRecord,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { getWhatsAppApprovalApprovers, whatsappApprovalAuth } from "./approval-auth.js";
import { getOptionalWhatsAppRuntime } from "./runtime.js";
const PERSISTENT_NAMESPACE = "whatsapp.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type WhatsAppApprovalReactionBinding = ApprovalReactionDecisionBinding;
type WhatsAppApprovalReactionResolution = {
approvalId: string;
decision: ExecApprovalReplyDecision;
};
type WhatsAppApprovalReactionTarget = ApprovalReactionTargetRecord;
type WhatsAppApprovalReactionEvent = {
remoteJids: string[];
messageId: string;
actorJid: string;
reactionKey: string;
};
type ResolvedWhatsAppApprovalReactionTarget = WhatsAppApprovalReactionResolution & {
remoteJid: string;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const whatsappApprovalReactionTargets =
createApprovalReactionTargetStore<WhatsAppApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
openStore: (storeParams) => getOptionalWhatsAppRuntime()?.state.openKeyedStore(storeParams),
logPersistentError: reportPersistentApprovalReactionError,
readPersistedTarget,
});
const loadApprovalResolver = resolverRuntimeLoader;
function buildReactionTargetKey(params: {
accountId: string;
remoteJid: string;
messageId: string;
}) {
const accountId = params.accountId.trim();
const remoteJid = params.remoteJid.trim();
const messageId = params.messageId.trim();
if (!accountId || !remoteJid || !messageId) {
return null;
}
return `${accountId}:${remoteJid}:${messageId}`;
}
function addCandidateRemoteJid(target: string[], value: string | null | undefined): void {
const remoteJid = value?.trim();
if (remoteJid && !target.includes(remoteJid)) {
target.push(remoteJid);
}
}
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalWhatsAppRuntime()
?.logging.getChildLogger({ plugin: "whatsapp", feature: "approval-reaction-state" })
.warn("WhatsApp persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break WhatsApp reactions.
}
}
function readPersistedTarget(target: unknown): WhatsAppApprovalReactionTarget | null {
const value = target as Partial<WhatsAppApprovalReactionTarget> | null | undefined;
if (!value || typeof value.approvalId !== "string" || !Array.isArray(value.allowedDecisions)) {
return null;
}
return {
approvalId: value.approvalId,
...(value.approvalKind === "exec" || value.approvalKind === "plugin"
? { approvalKind: value.approvalKind }
: {}),
allowedDecisions: value.allowedDecisions,
};
}
export function listWhatsAppApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): WhatsAppApprovalReactionBinding[] {
return listApprovalReactionBindings({ allowedDecisions });
}
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
const normalized = value.trim().toLowerCase();
if (normalized === "always") {
return "allow-always";
}
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
return normalized;
}
return null;
}
const APPROVAL_ID_LINE_RE = /^\s*ID:\s*([A-Za-z0-9][A-Za-z0-9._:-]*)\s*$/i;
const APPROVE_COMMAND_LINE_RE = /\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i;
export function extractWhatsAppApprovalPromptBinding(text: string): {
approvalId: string;
allowedDecisions: ExecApprovalReplyDecision[];
} | null {
const lines = text.split(/\r?\n/);
const idHeaderMatch = lines
.map((line) => line.match(APPROVAL_ID_LINE_RE))
.find((match): match is RegExpMatchArray => Boolean(match));
if (!idHeaderMatch) {
return null;
}
const approvalId = idHeaderMatch[1];
const allowedDecisions: ExecApprovalReplyDecision[] = [];
for (const line of lines) {
const match = line.match(APPROVE_COMMAND_LINE_RE);
if (!match || match[1] !== approvalId) {
continue;
}
for (const decisionText of match[2].split(/[\s|,]+/)) {
const decision = normalizeApprovalDecision(decisionText);
if (decision && !allowedDecisions.includes(decision)) {
allowedDecisions.push(decision);
}
}
}
return allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
}
export function registerWhatsAppApprovalReactionTarget(params: {
accountId: string;
remoteJid: string;
messageId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
ttlMs?: number;
}): WhatsAppApprovalReactionTarget | null {
const key = buildReactionTargetKey(params);
const approvalId = params.approvalId.trim();
const allowedDecisions = listWhatsAppApprovalReactionBindings(params.allowedDecisions).map(
(binding) => binding.decision,
);
if (!key || !approvalId || allowedDecisions.length === 0) {
return null;
}
const target: WhatsAppApprovalReactionTarget = {
approvalId,
approvalKind: approvalId.startsWith("plugin:") ? "plugin" : "exec",
allowedDecisions,
};
whatsappApprovalReactionTargets.register(key, target, { ttlMs: params.ttlMs });
return target;
}
export function registerWhatsAppApprovalReactionTargetForOutboundMessage(params: {
accountId: string;
remoteJid: string;
messageId: string;
text: string;
ttlMs?: number;
}): boolean {
const binding = extractWhatsAppApprovalPromptBinding(params.text);
if (!binding) {
return false;
}
return Boolean(
registerWhatsAppApprovalReactionTarget({
accountId: params.accountId,
remoteJid: params.remoteJid,
messageId: params.messageId,
approvalId: binding.approvalId,
allowedDecisions: binding.allowedDecisions,
ttlMs: params.ttlMs,
}),
);
}
export function unregisterWhatsAppApprovalReactionTarget(params: {
accountId: string;
remoteJid: string;
messageId: string;
}): void {
const key = buildReactionTargetKey(params);
if (!key) {
return;
}
whatsappApprovalReactionTargets.delete(key);
}
function resolveTarget(params: {
target: WhatsAppApprovalReactionTarget | null | undefined;
reactionKey: string;
}): WhatsAppApprovalReactionResolution | null {
const resolved = resolveApprovalReactionTarget({
target: params.target,
reactionKey: params.reactionKey,
});
return resolved
? {
approvalId: resolved.approvalId,
decision: resolved.decision,
}
: null;
}
export async function resolveWhatsAppApprovalReactionTargetWithPersistence(params: {
accountId: string;
remoteJid: string;
messageId: string;
reactionKey: string;
}): Promise<WhatsAppApprovalReactionResolution | null> {
const key = buildReactionTargetKey(params);
if (!key) {
return null;
}
return resolveTarget({
target: await whatsappApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
});
}
async function resolveWhatsAppApprovalReactionTargetFromCandidates(params: {
accountId: string;
observedRemoteJids: readonly string[];
messageId: string;
reactionKey: string;
resolveReactionTargetJids?: (jid: string) => Promise<readonly string[]>;
logVerboseMessage?: (message: string) => void;
}): Promise<ResolvedWhatsAppApprovalReactionTarget | null> {
const candidateRemoteJids: string[] = [];
for (const observedRemoteJid of params.observedRemoteJids) {
addCandidateRemoteJid(candidateRemoteJids, observedRemoteJid);
try {
for (const candidate of (await params.resolveReactionTargetJids?.(observedRemoteJid)) ?? []) {
addCandidateRemoteJid(candidateRemoteJids, candidate);
}
} catch (error) {
params.logVerboseMessage?.(
`whatsapp: approval reaction target JID mapping failed for ${observedRemoteJid}: ${String(error)}`,
);
}
}
for (const remoteJid of candidateRemoteJids) {
const target = await resolveWhatsAppApprovalReactionTargetWithPersistence({
accountId: params.accountId,
remoteJid,
messageId: params.messageId,
reactionKey: params.reactionKey,
});
if (target) {
return { ...target, remoteJid };
}
}
return null;
}
function readWhatsAppApprovalReactionEvent(params: {
msg: WAMessage;
selfJid?: string | null;
selfLid?: string | null;
}): WhatsAppApprovalReactionEvent | null {
const msg = params.msg;
const reaction = msg.message?.reactionMessage;
const reactionKey = reaction?.text?.trim() ?? "";
const messageId = reaction?.key?.id?.trim() ?? "";
const remoteJids: string[] = [];
addCandidateRemoteJid(remoteJids, reaction?.key?.remoteJid);
addCandidateRemoteJid(remoteJids, msg.key?.remoteJid);
const actorJid =
msg.key?.participant?.trim() ||
(msg.key?.fromMe
? (params.selfLid?.trim() ?? params.selfJid?.trim() ?? "")
: (msg.key?.remoteJid?.trim() ?? ""));
if (!reactionKey || !messageId || remoteJids.length === 0 || !actorJid) {
return null;
}
return {
remoteJids,
messageId,
actorJid,
reactionKey,
};
}
export async function maybeResolveWhatsAppApprovalReaction(params: {
cfg: OpenClawConfig;
accountId: string;
msg: WAMessage;
gatewayUrl?: string;
selfJid?: string | null;
selfLid?: string | null;
resolveInboundJid: (jid: string | null | undefined) => Promise<string | null>;
resolveReactionTargetJids?: (jid: string) => Promise<readonly string[]>;
logVerboseMessage?: (message: string) => void;
}): Promise<boolean> {
const event = readWhatsAppApprovalReactionEvent({
msg: params.msg,
selfJid: params.selfJid,
selfLid: params.selfLid,
});
if (!event) {
return false;
}
const target = await resolveWhatsAppApprovalReactionTargetFromCandidates({
accountId: params.accountId,
observedRemoteJids: event.remoteJids,
messageId: event.messageId,
reactionKey: event.reactionKey,
resolveReactionTargetJids: params.resolveReactionTargetJids,
logVerboseMessage: params.logVerboseMessage,
});
if (!target) {
return false;
}
const actorId = await params.resolveInboundJid(event.actorJid);
if (!actorId) {
params.logVerboseMessage?.(
`whatsapp: approval reaction ignored for ${target.approvalId}; missing actor identity`,
);
return true;
}
const approvalKind = target.approvalId.startsWith("plugin:") ? "plugin" : "exec";
const approvers = getWhatsAppApprovalApprovers({ cfg: params.cfg, accountId: params.accountId });
if (approvers.length === 0) {
params.logVerboseMessage?.(
`whatsapp: approval reaction denied id=${target.approvalId}; reactions require explicit approvers`,
);
return true;
}
const auth = whatsappApprovalAuth.authorizeActorAction({
cfg: params.cfg,
accountId: params.accountId,
senderId: actorId,
action: "approve",
approvalKind,
});
if (!auth.authorized) {
params.logVerboseMessage?.(
`whatsapp: approval reaction denied id=${target.approvalId} sender=${actorId}`,
);
return true;
}
const { isApprovalNotFoundError, resolveWhatsAppApproval } = await loadApprovalResolver();
try {
await resolveWhatsAppApproval({
cfg: params.cfg,
approvalId: target.approvalId,
decision: target.decision,
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
params.logVerboseMessage?.(
`whatsapp: approval reaction resolved id=${target.approvalId} sender=${actorId} decision=${target.decision}`,
);
return true;
} catch (error) {
if (isApprovalNotFoundError(error)) {
unregisterWhatsAppApprovalReactionTarget({
accountId: params.accountId,
remoteJid: target.remoteJid,
messageId: event.messageId,
});
params.logVerboseMessage?.(
`whatsapp: approval reaction ignored for expired approval id=${target.approvalId} sender=${actorId}`,
);
return true;
}
params.logVerboseMessage?.(
`whatsapp: approval reaction failed id=${target.approvalId} sender=${actorId}: ${String(error)}`,
);
return true;
}
}
export function clearWhatsAppApprovalReactionTargetsForTest(): void {
whatsappApprovalReactionTargets.clearForTest();
resolverRuntimeLoader.clear();
}

View File

@@ -0,0 +1,24 @@
// Whatsapp plugin module implements approval resolver behavior.
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveWhatsAppApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `WhatsApp approval (${params.senderId?.trim() || "unknown"})`,
});
}

View File

@@ -0,0 +1,57 @@
// Whatsapp tests cover auth store.lazy dir plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { captureEnv } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("WhatsApp auth dir profile resolution", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let tempStateDir: string | undefined;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_OAUTH_DIR"]);
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-wa-profile-"));
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_OAUTH_DIR;
vi.resetModules();
});
afterEach(() => {
envSnapshot.restore();
vi.resetModules();
if (tempStateDir) {
fs.rmSync(tempStateDir, { recursive: true, force: true });
tempStateDir = undefined;
}
});
it("resolves the default web auth dir from OPENCLAW_STATE_DIR at call time", async () => {
const authStore = await import("./auth-store.js");
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const expected = path.join(tempStateDir ?? "", "credentials", "whatsapp", DEFAULT_ACCOUNT_ID);
expect(authStore.resolveDefaultWebAuthDir()).toBe(expected);
});
it("exports the legacy default auth dir as a primitive string", async () => {
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const authStore = await import("./auth-store.js");
const expected = path.join(tempStateDir ?? "", "credentials", "whatsapp", DEFAULT_ACCOUNT_ID);
expect(authStore.WA_WEB_AUTH_DIR).toBe(expected);
expect(typeof authStore.WA_WEB_AUTH_DIR).toBe("string");
});
it("lists WhatsApp auth dirs under the active profile state dir", async () => {
const accounts = await import("./accounts.js");
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const dirs = accounts.listWhatsAppAuthDirs({});
expect(dirs).toContain(path.join(tempStateDir ?? "", "credentials"));
expect(dirs).toContain(
path.join(tempStateDir ?? "", "credentials", "whatsapp", DEFAULT_ACCOUNT_ID),
);
});
});

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements auth store behavior.
export { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";

View File

@@ -0,0 +1,430 @@
// Whatsapp tests cover auth store plugin behavior.
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getWebAuthAgeMs,
hasWebCredsSync,
logoutWeb,
pickWebChannel,
readCredsJsonRaw,
readWebAuthSnapshot,
readWebAuthState,
readWebSelfId,
readWebSelfIdentity,
restoreCredsFromBackupIfNeeded,
webAuthExists,
WhatsAppAuthUnstableError,
WHATSAPP_AUTH_UNSTABLE_CODE,
} from "./auth-store.js";
import type { CredsQueueWaitResult } from "./creds-persistence.js";
const hoisted = vi.hoisted(() => ({
waitForCredsSaveQueueWithTimeout: vi.fn<() => Promise<CredsQueueWaitResult>>(
async () => "drained",
),
oauthDir: "/tmp/openclaw-wa-auth-store-test-oauth",
}));
vi.mock("./creds-persistence.js", async () => {
const actual =
await vi.importActual<typeof import("./creds-persistence.js")>("./creds-persistence.js");
return {
...actual,
waitForCredsSaveQueueWithTimeout: hoisted.waitForCredsSaveQueueWithTimeout,
};
});
vi.mock("./auth-store.runtime.js", () => ({
resolveOAuthDir: () => hoisted.oauthDir,
}));
function createTempAuthDir(prefix: string) {
return fsSync.mkdtempSync(
path.join((process.env.TMPDIR ?? "/tmp").replace(/\/+$/, ""), `${prefix}-`),
);
}
function withOwnedOAuthAuthDir<T>(
prefix: string,
run: (authDir: string) => Promise<T>,
): Promise<T> {
const previousOAuthDir = hoisted.oauthDir;
const oauthDir = createTempAuthDir(`${prefix}-oauth`);
const authDir = path.join(oauthDir, "whatsapp", "default");
fsSync.mkdirSync(authDir, { recursive: true });
hoisted.oauthDir = oauthDir;
return run(authDir).finally(() => {
hoisted.oauthDir = previousOAuthDir;
fsSync.rmSync(oauthDir, { recursive: true, force: true });
});
}
describe("auth-store", () => {
beforeEach(() => {
hoisted.waitForCredsSaveQueueWithTimeout.mockReset().mockResolvedValue("drained");
});
it("does not restore creds from backup on ordinary reads", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-read");
const credsPath = path.join(authDir, "creds.json");
const backupPath = path.join(authDir, "creds.json.bak");
fsSync.writeFileSync(backupPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
await expect(webAuthExists(authDir)).resolves.toBe(false);
expect(fsSync.existsSync(credsPath)).toBe(false);
});
it("restores creds from a regular backup file", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-restore");
const credsPath = path.join(authDir, "creds.json");
fsSync.writeFileSync(credsPath, "{", "utf-8");
fsSync.writeFileSync(
path.join(authDir, "creds.json.bak"),
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
"utf-8",
);
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(true);
expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual({
me: { id: "123@s.whatsapp.net" },
});
});
it("preserves valid large creds instead of treating them as corrupt", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-large-creds");
const credsPath = path.join(authDir, "creds.json");
const largeCreds = JSON.stringify({
me: { id: "15551234567@s.whatsapp.net" },
additionalData: "x".repeat(1024 * 1024 + 512),
});
fsSync.writeFileSync(credsPath, largeCreds, "utf-8");
fsSync.writeFileSync(
path.join(authDir, "creds.json.bak"),
JSON.stringify({ me: { id: "19990000000@s.whatsapp.net" } }),
"utf-8",
);
await expect(webAuthExists(authDir)).resolves.toBe(true);
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe(largeCreds);
expect(readWebSelfId(authDir)).toMatchObject({
e164: "+15551234567",
jid: "15551234567@s.whatsapp.net",
});
});
it("refuses to restore creds from a symlinked backup path", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink");
const targetPath = path.join(authDir, "backup-target.json");
const backupPath = path.join(authDir, "creds.json.bak");
const credsPath = path.join(authDir, "creds.json");
fsSync.writeFileSync(targetPath, JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), "utf-8");
fsSync.symlinkSync(targetPath, backupPath);
fsSync.writeFileSync(credsPath, "{", "utf-8");
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{");
});
it.runIf(process.platform !== "win32")(
"does not restore backup over a symlinked creds path",
async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-restore-target-symlink");
const targetPath = path.join(authDir, "target-creds.json");
const credsPath = path.join(authDir, "creds.json");
const backupPath = path.join(authDir, "creds.json.bak");
fsSync.writeFileSync(targetPath, "{", "utf-8");
fsSync.symlinkSync(targetPath, credsPath);
fsSync.writeFileSync(
backupPath,
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
"utf-8",
);
await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false);
expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true);
expect(fsSync.readFileSync(targetPath, "utf-8")).toBe("{");
},
);
it("reports linked auth state and snapshot from the shared read helper", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-linked");
fsSync.writeFileSync(
path.join(authDir, "creds.json"),
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
await expect(readWebAuthState(authDir)).resolves.toBe("linked");
const snapshot = await readWebAuthSnapshot(authDir);
expect(snapshot.authAgeMs).toBeTypeOf("number");
expect(snapshot.authAgeMs).toBeGreaterThanOrEqual(-1);
expect(snapshot).toEqual({
state: "linked",
authAgeMs: snapshot.authAgeMs,
selfId: {
e164: "+15551234567",
jid: "15551234567@s.whatsapp.net",
lid: null,
},
});
});
it.runIf(process.platform !== "win32")(
"treats symlinked creds as missing across auth readers",
async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-symlink-read");
const targetPath = path.join(authDir, "target-creds.json");
const credsPath = path.join(authDir, "creds.json");
fsSync.writeFileSync(
targetPath,
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
fsSync.symlinkSync(targetPath, credsPath);
expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true);
expect(fsSync.statSync(credsPath).isFile()).toBe(true);
expect(hasWebCredsSync(authDir)).toBe(false);
expect(readCredsJsonRaw(credsPath)).toBeNull();
expect(getWebAuthAgeMs(authDir)).toBeNull();
expect(readWebSelfId(authDir)).toEqual({ e164: null, jid: null, lid: null });
await expect(readWebSelfIdentity(authDir)).resolves.toEqual({
e164: null,
jid: null,
lid: null,
});
await expect(webAuthExists(authDir)).resolves.toBe(false);
await expect(readWebAuthState(authDir)).resolves.toBe("not-linked");
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
state: "not-linked",
authAgeMs: null,
selfId: { e164: null, jid: null, lid: null },
});
},
);
it.runIf(process.platform !== "win32")(
"treats creds under a symlinked auth directory as missing",
async () => {
const rootDir = createTempAuthDir("openclaw-wa-auth-symlink-parent");
const targetAuthDir = path.join(rootDir, "target-auth");
const authDir = path.join(rootDir, "linked-auth");
fsSync.mkdirSync(targetAuthDir);
fsSync.writeFileSync(
path.join(targetAuthDir, "creds.json"),
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
fsSync.symlinkSync(targetAuthDir, authDir, "dir");
const credsPath = path.join(authDir, "creds.json");
expect(fsSync.lstatSync(authDir).isSymbolicLink()).toBe(true);
expect(fsSync.lstatSync(credsPath).isFile()).toBe(true);
expect(hasWebCredsSync(authDir)).toBe(false);
expect(readCredsJsonRaw(credsPath)).toBeNull();
await expect(webAuthExists(authDir)).resolves.toBe(false);
await expect(readWebAuthState(authDir)).resolves.toBe("not-linked");
},
);
it("reports unstable auth state when the shared barrier read times out", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
fsSync.writeFileSync(
path.join(authDir, "creds.json"),
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
"utf-8",
);
hoisted.waitForCredsSaveQueueWithTimeout
.mockResolvedValueOnce("timed_out")
.mockResolvedValueOnce("timed_out");
await expect(readWebAuthState(authDir)).resolves.toBe("unstable");
await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({
state: "unstable",
authAgeMs: null,
selfId: { e164: null, jid: null, lid: null },
});
});
it("clears unreadable auth state on explicit logout", async () => {
await withOwnedOAuthAuthDir("openclaw-wa-auth-logout", async (authDir) => {
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8");
fsSync.writeFileSync(
path.join(authDir, "creds.json.bak"),
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
"utf-8",
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
expect(fsSync.existsSync(authDir)).toBe(false);
});
});
it("does not delete the whole legacy auth root when targeted cleanup fails", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-legacy-failure");
const previousOAuthDir = hoisted.oauthDir;
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
fsSync.writeFileSync(path.join(authDir, "oauth.json"), '{"token":true}', "utf-8");
fsSync.writeFileSync(path.join(authDir, "session-abc.json"), "{}", "utf-8");
hoisted.oauthDir = authDir;
const originalRm = fs.rm;
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
if (String(target).endsWith("creds.json")) {
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
}
return await originalRm.call(fs, target, options as never);
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
try {
await expect(
logoutWeb({ authDir, isLegacyAuthDir: true, runtime: runtime as never }),
).rejects.toThrow("EACCES");
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(authDir, "oauth.json"))).toBe(true);
} finally {
hoisted.oauthDir = previousOAuthDir;
rmSpy.mockRestore();
fsSync.rmSync(authDir, { recursive: true, force: true });
}
});
it("clears auth state even when directory enumeration fails", async () => {
await withOwnedOAuthAuthDir("openclaw-wa-auth-readdir", async (authDir) => {
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
const readdirSpy = vi
.spyOn(fs, "readdir")
.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" }));
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(true);
expect(fsSync.existsSync(authDir)).toBe(false);
readdirSpy.mockRestore();
});
});
it("does not delete custom auth directories outside the OpenClaw auth root", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-custom");
const nestedDir = path.join(authDir, "nested");
fsSync.mkdirSync(nestedDir);
fsSync.writeFileSync(path.join(authDir, "creds.json"), "{}", "utf-8");
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8");
fsSync.writeFileSync(path.join(nestedDir, "session-abc.json"), "keep me", "utf-8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(authDir, "creds.json"))).toBe(true);
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
expect(fsSync.existsSync(path.join(nestedDir, "session-abc.json"))).toBe(true);
});
it("does not clear auth files through a symlinked owned auth directory", async () => {
const previousOAuthDir = hoisted.oauthDir;
const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-oauth");
const externalDir = createTempAuthDir("openclaw-wa-auth-symlink-target");
const authDir = path.join(oauthDir, "whatsapp", "default");
try {
fsSync.mkdirSync(path.dirname(authDir), { recursive: true });
fsSync.writeFileSync(path.join(externalDir, "creds.json"), "{}", "utf-8");
fsSync.writeFileSync(path.join(externalDir, "notes.txt"), "keep me", "utf-8");
fsSync.symlinkSync(externalDir, authDir, "dir");
hoisted.oauthDir = oauthDir;
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(externalDir, "creds.json"))).toBe(true);
expect(fsSync.existsSync(path.join(externalDir, "notes.txt"))).toBe(true);
} finally {
hoisted.oauthDir = previousOAuthDir;
fsSync.rmSync(oauthDir, { recursive: true, force: true });
fsSync.rmSync(externalDir, { recursive: true, force: true });
}
});
it("does not clear auth files through an intermediate symlink in the owned auth tree", async () => {
const previousOAuthDir = hoisted.oauthDir;
const oauthDir = createTempAuthDir("openclaw-wa-auth-symlink-parent-oauth");
const externalRoot = createTempAuthDir("openclaw-wa-auth-symlink-parent-target");
const externalAuthDir = path.join(externalRoot, "default");
const linkedParent = path.join(oauthDir, "whatsapp", "linked");
const authDir = path.join(linkedParent, "default");
try {
fsSync.mkdirSync(path.dirname(linkedParent), { recursive: true });
fsSync.mkdirSync(externalAuthDir, { recursive: true });
fsSync.writeFileSync(path.join(externalAuthDir, "creds.json"), "{}", "utf-8");
fsSync.writeFileSync(path.join(externalAuthDir, "notes.txt"), "keep me", "utf-8");
fsSync.symlinkSync(externalRoot, linkedParent, "dir");
hoisted.oauthDir = oauthDir;
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(externalAuthDir, "creds.json"))).toBe(true);
expect(fsSync.existsSync(path.join(externalAuthDir, "notes.txt"))).toBe(true);
} finally {
hoisted.oauthDir = previousOAuthDir;
fsSync.rmSync(oauthDir, { recursive: true, force: true });
fsSync.rmSync(externalRoot, { recursive: true, force: true });
}
});
it("does not delete unrelated non-empty directories on logout", async () => {
const authDir = createTempAuthDir("openclaw-wa-auth-unrelated");
fsSync.writeFileSync(path.join(authDir, "notes.txt"), "keep me", "utf-8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(logoutWeb({ authDir, runtime: runtime as never })).resolves.toBe(false);
expect(fsSync.existsSync(authDir)).toBe(true);
expect(fsSync.existsSync(path.join(authDir, "notes.txt"))).toBe(true);
});
it("throws a typed unstable-auth error when channel selection times out", async () => {
hoisted.waitForCredsSaveQueueWithTimeout.mockResolvedValueOnce("timed_out");
const error = await pickWebChannel("auto", "/tmp/openclaw-wa-auth-unstable").catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(WhatsAppAuthUnstableError);
expect(error).toEqual(
Object.assign(new WhatsAppAuthUnstableError(), {
code: WHATSAPP_AUTH_UNSTABLE_CODE,
name: WhatsAppAuthUnstableError.name,
}),
);
});
});

View File

@@ -0,0 +1,483 @@
// Whatsapp plugin module implements auth store behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { info, success } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveOAuthDir } from "./auth-store.runtime.js";
import {
assertWebCredsPathRegularFileOrMissing,
hasWebCredsSync,
readWebCredsJsonRaw,
readWebCredsJsonRawSync,
resolveWebCredsBackupPath,
resolveWebCredsPath,
statWebCredsFileSync,
} from "./creds-files.js";
import {
waitForCredsSaveQueueWithTimeout,
writeWebCredsRawAtomically,
type CredsQueueWaitResult,
} from "./creds-persistence.js";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
import { resolveUserPath, type WebChannel } from "./text-runtime.js";
export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath };
export const WHATSAPP_AUTH_UNSTABLE_CODE = "whatsapp-auth-unstable";
const authStoreLogger = getChildLogger({ module: "web-auth-store" });
const emptyWebSelfId = () => ({ e164: null, jid: null, lid: null }) as const;
export type WhatsAppWebAuthState = "linked" | "not-linked" | "unstable";
export class WhatsAppAuthUnstableError extends Error {
readonly code = WHATSAPP_AUTH_UNSTABLE_CODE;
constructor(message = "WhatsApp auth state is still stabilizing; retry shortly.") {
super(message);
this.name = "WhatsAppAuthUnstableError";
}
}
export function resolveDefaultWebAuthDir(): string {
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
}
export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir();
export function readCredsJsonRaw(filePath: string): string | null {
return readWebCredsJsonRawSync(filePath);
}
async function waitForWebAuthBarrier(
authDir: string,
context: string,
): Promise<CredsQueueWaitResult> {
const result = await waitForCredsSaveQueueWithTimeout(authDir);
if (result === "timed_out") {
authStoreLogger.warn(
{
authDir,
context,
},
"timed out waiting for queued WhatsApp creds save before auth read",
);
}
return result;
}
export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<boolean> {
const logger = getChildLogger({ module: "web-session" });
try {
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
try {
await assertWebCredsPathRegularFileOrMissing(credsPath);
} catch {
return false;
}
const raw = readCredsJsonRaw(credsPath);
if (raw) {
// Validate that creds.json is parseable.
JSON.parse(raw);
return false;
}
const backupRaw = readCredsJsonRaw(backupPath);
if (!backupRaw) {
return false;
}
// Ensure backup is parseable before restoring.
JSON.parse(backupRaw);
await writeWebCredsRawAtomically({
filePath: credsPath,
content: backupRaw,
tempPrefix: ".creds.restore",
});
logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup");
return true;
} catch {
// ignore
}
return false;
}
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
const resolvedAuthDir = resolveUserPath(authDir);
const credsPath = resolveWebCredsPath(resolvedAuthDir);
const raw = await readWebCredsJsonRaw(credsPath);
if (!raw) {
return false;
}
try {
JSON.parse(raw);
return true;
} catch {
return false;
}
}
function resolveWebAuthState(params: {
linked: boolean;
barrierResult: CredsQueueWaitResult;
}): WhatsAppWebAuthState {
if (params.barrierResult === "timed_out") {
return "unstable";
}
return params.linked ? "linked" : "not-linked";
}
async function readWebAuthStateCore(
authDir: string,
context: string,
): Promise<{ authDir: string; linked: boolean; state: WhatsAppWebAuthState }> {
const resolvedAuthDir = resolveUserPath(authDir);
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, context);
const linked = await webAuthExists(resolvedAuthDir);
return {
authDir: resolvedAuthDir,
linked,
state: resolveWebAuthState({ linked, barrierResult }),
};
}
export function formatWhatsAppWebAuthStatusState(state: WhatsAppWebAuthState): string {
switch (state) {
case "linked":
return "linked";
case "not-linked":
return "not linked";
case "unstable":
return "auth stabilizing";
}
const exhaustive: never = state;
return exhaustive;
}
export async function readWebAuthState(
authDir: string = resolveDefaultWebAuthDir(),
): Promise<WhatsAppWebAuthState> {
return (await readWebAuthStateCore(authDir, "readWebAuthState")).state;
}
export async function readWebAuthSnapshot(authDir: string = resolveDefaultWebAuthDir()) {
const auth = await readWebAuthStateCore(authDir, "readWebAuthSnapshot");
return {
state: auth.state,
authAgeMs: auth.state === "linked" ? getWebAuthAgeMs(auth.authDir) : null,
selfId: auth.state === "linked" ? readWebSelfId(auth.authDir) : emptyWebSelfId(),
} as const;
}
export async function readWebAuthExistsBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
const state = await readWebAuthState(authDir);
return {
exists: state === "linked",
timedOut: state === "unstable",
} as const;
}
export async function readWebAuthExistsForDecision(
authDir: string = resolveDefaultWebAuthDir(),
): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> {
const state = await readWebAuthState(authDir);
if (state === "unstable") {
return { outcome: "unstable" };
}
return {
outcome: "stable",
exists: state === "linked",
};
}
export async function readWebAuthSnapshotBestEffort(authDir: string = resolveDefaultWebAuthDir()) {
const snapshot = await readWebAuthSnapshot(authDir);
return {
linked: snapshot.state === "linked",
timedOut: snapshot.state === "unstable",
authAgeMs: snapshot.authAgeMs,
selfId: snapshot.selfId,
} as const;
}
function isBaileysAuthFileName(name: string): boolean {
if (name === "oauth.json") {
return false;
}
if (name === "creds.json" || name === "creds.json.bak") {
return true;
}
if (!name.endsWith(".json")) {
return false;
}
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
async function clearBaileysAuthFiles(authDir: string) {
const rootStats = await fs.lstat(authDir).catch(() => null);
if (!rootStats?.isDirectory() || rootStats.isSymbolicLink()) {
return;
}
const entries = await fs.readdir(authDir, { withFileTypes: true });
await Promise.all(
entries.map(async (entry) => {
if (!entry.isFile()) {
return;
}
if (!isBaileysAuthFileName(entry.name)) {
return;
}
await fs.rm(path.join(authDir, entry.name), { force: true });
}),
);
}
async function shouldClearOnLogout(authDir: string, isLegacyAuthDir: boolean): Promise<boolean> {
try {
const stats = await fs.lstat(authDir);
if (!stats.isDirectory() || stats.isSymbolicLink()) {
return false;
}
if (isLegacyAuthDir) {
const entries = await fs.readdir(authDir, { withFileTypes: true });
return entries.some((entry) => {
if (!entry.isFile()) {
return false;
}
return isBaileysAuthFileName(entry.name);
});
}
const credsStats = await fs.lstat(resolveWebCredsPath(authDir)).catch(() => null);
if (credsStats?.isFile()) {
return true;
}
const backupStats = await fs.lstat(resolveWebCredsBackupPath(authDir)).catch(() => null);
return backupStats?.isFile() === true;
} catch (error) {
const codeValue =
error && typeof error === "object" && "code" in error
? (error as { code?: unknown }).code
: undefined;
const code = typeof codeValue === "string" ? codeValue : "";
return code !== "ENOENT";
}
}
function isPathInsideDirectory(baseDir: string, targetPath: string): boolean {
const relativePath = path.relative(baseDir, targetPath);
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
async function pathHasSymlinkComponent(baseDir: string, targetPath: string): Promise<boolean> {
const relativePath = path.relative(baseDir, targetPath);
let currentPath = baseDir;
for (const segment of relativePath.split(path.sep)) {
currentPath = path.join(currentPath, segment);
const stats = await fs.lstat(currentPath).catch(() => null);
if (!stats || stats.isSymbolicLink()) {
return true;
}
}
return false;
}
type WebAuthDirOwnership =
| { kind: "owned"; authDir: string }
| { kind: "unsafe-owned" }
| { kind: "external" };
async function isLegacyWebAuthDir(authDir: string): Promise<boolean> {
const legacyAuthDir = path.resolve(resolveOAuthDir());
const resolvedAuthDir = path.resolve(authDir);
if (resolvedAuthDir !== legacyAuthDir) {
return false;
}
const stats = await fs.lstat(resolvedAuthDir).catch(() => null);
return stats?.isDirectory() === true && !stats.isSymbolicLink();
}
async function classifyWebAuthDirOwnership(authDir: string): Promise<WebAuthDirOwnership> {
const whatsappAuthBase = path.resolve(resolveOAuthDir(), "whatsapp");
const resolvedAuthDir = path.resolve(authDir);
if (!isPathInsideDirectory(whatsappAuthBase, resolvedAuthDir)) {
return { kind: "external" };
}
const [baseRealPath, authDirRealPath] = await Promise.all([
fs.realpath(whatsappAuthBase).catch(() => null),
fs.realpath(resolvedAuthDir).catch(() => null),
]);
if (!baseRealPath || !authDirRealPath) {
return { kind: "unsafe-owned" };
}
if (!isPathInsideDirectory(baseRealPath, authDirRealPath)) {
return { kind: "unsafe-owned" };
}
if (await pathHasSymlinkComponent(whatsappAuthBase, resolvedAuthDir)) {
return { kind: "unsafe-owned" };
}
return { kind: "owned", authDir: resolvedAuthDir };
}
export async function logoutWeb(params: {
authDir?: string;
isLegacyAuthDir?: boolean;
runtime?: RuntimeEnv;
}) {
const runtime = params.runtime ?? defaultRuntime;
const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
const barrierResult = await waitForWebAuthBarrier(resolvedAuthDir, "logoutWeb");
if (barrierResult === "timed_out") {
runtime.log(
info("WhatsApp auth state is still stabilizing; clearing cached credentials anyway."),
);
}
if (!(await shouldClearOnLogout(resolvedAuthDir, Boolean(params.isLegacyAuthDir)))) {
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false;
}
if (params.isLegacyAuthDir) {
if (!(await isLegacyWebAuthDir(resolvedAuthDir))) {
runtime.log(
info("Skipped WhatsApp Web credential cleanup outside the managed legacy auth directory."),
);
return false;
}
await clearBaileysAuthFiles(resolvedAuthDir);
} else {
const ownership = await classifyWebAuthDirOwnership(resolvedAuthDir);
if (ownership.kind === "owned") {
await fs.rm(ownership.authDir, { recursive: true, force: true });
} else if (ownership.kind === "unsafe-owned") {
runtime.log(
info(
"Skipped WhatsApp Web credential cleanup because the auth directory crosses a symlink boundary.",
),
);
return false;
} else {
runtime.log(
info("Skipped WhatsApp Web credential cleanup outside the managed auth directory."),
);
return false;
}
}
runtime.log(success("Cleared WhatsApp Web credentials."));
return true;
}
export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try {
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
const raw = readCredsJsonRaw(credsPath);
if (!raw) {
return emptyWebSelfId();
}
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
const identity = resolveComparableIdentity(
{
jid: parsed?.me?.id ?? null,
lid: parsed?.me?.lid ?? null,
},
authDir,
);
return {
e164: identity.e164 ?? null,
jid: identity.jid ?? null,
lid: identity.lid ?? null,
} as const;
} catch {
return emptyWebSelfId();
}
}
export async function readWebSelfIdentity(
authDir: string = resolveDefaultWebAuthDir(),
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<WhatsAppSelfIdentity> {
const resolvedAuthDir = resolveUserPath(authDir);
const raw = await readWebCredsJsonRaw(resolveWebCredsPath(resolvedAuthDir));
if (raw) {
try {
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
return resolveComparableIdentity(
{
jid: parsed?.me?.id ?? null,
lid: parsed?.me?.lid ?? null,
},
resolvedAuthDir,
);
} catch {
// Fall through to the live message identity below when cached creds are corrupt.
}
}
return resolveComparableIdentity(
{
jid: fallback?.id ?? null,
lid: fallback?.lid ?? null,
},
resolvedAuthDir,
);
}
export async function readWebSelfIdentityForDecision(
authDir: string = resolveDefaultWebAuthDir(),
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<{ outcome: "stable"; identity: WhatsAppSelfIdentity } | { outcome: "unstable" }> {
const resolvedAuthDir = resolveUserPath(authDir);
const result = await waitForWebAuthBarrier(resolvedAuthDir, "readWebSelfIdentityForDecision");
if (result === "timed_out") {
return { outcome: "unstable" };
}
return {
outcome: "stable",
identity: await readWebSelfIdentity(resolvedAuthDir, fallback),
};
}
/**
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
* Helpful for heartbeats/observability to spot stale credentials.
*/
export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null {
const stats = statWebCredsFileSync(resolveWebCredsPath(resolveUserPath(authDir)));
return stats ? Math.max(0, Date.now() - stats.mtimeMs) : null;
}
export function logWebSelfId(
authDir: string = resolveDefaultWebAuthDir(),
runtime: RuntimeEnv = defaultRuntime,
includeChannelPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid, lid } = readWebSelfId(authDir);
const parts = [jid ? `jid ${jid}` : null, lid ? `lid ${lid}` : null].filter(
(value): value is string => Boolean(value),
);
const details =
e164 || parts.length > 0
? `${e164 ?? "unknown"}${parts.length > 0 ? ` (${parts.join(", ")})` : ""}`
: "unknown";
const prefix = includeChannelPrefix ? "Web Channel: " : "";
runtime.log(info(`${prefix}${details}`));
}
export async function pickWebChannel(
pref: WebChannel | "auto",
authDir: string = resolveDefaultWebAuthDir(),
): Promise<WebChannel> {
const choice: WebChannel = pref === "auto" ? "web" : pref;
const auth = await readWebAuthExistsForDecision(authDir);
if (auth.outcome === "unstable") {
throw new WhatsAppAuthUnstableError();
}
if (!auth.exists) {
throw new Error(
`No WhatsApp Web session found. Run \`${formatCliCommand("openclaw channels login --channel whatsapp --verbose")}\` to link.`,
);
}
return choice;
}

View File

@@ -0,0 +1,257 @@
// Whatsapp tests cover auto reply.broadcast groups.combined plugin behavior.
import "./test-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import {
monitorWebChannelWithCapture,
sendWebDirectInboundAndCollectSessionKeys,
} from "./auto-reply.broadcast-groups.test-harness.js";
import {
createWebInboundDeliverySpies,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
sendWebGroupInboundMessage,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
installWebAutoReplyTestHomeHooks();
describe("broadcast groups", () => {
installWebAutoReplyUnitTestHooks();
it("skips unknown broadcast agent ids when agents.list is present", async () => {
setLoadConfigMock({
channels: { whatsapp: { allowFrom: ["*"] } },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }],
},
broadcast: {
"+1000": ["alfred", "missing"],
},
} satisfies OpenClawConfig);
const { seen, resolver } = await sendWebDirectInboundAndCollectSessionKeys();
expect(resolver).toHaveBeenCalledTimes(1);
expect(seen[0]).toContain("agent:alfred:");
resetLoadConfigMock();
});
it("broadcasts sequentially in configured order", async () => {
setLoadConfigMock({
channels: { whatsapp: { allowFrom: ["*"] } },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "sequential",
"+1000": ["alfred", "baerbel"],
},
} satisfies OpenClawConfig);
const { seen, resolver } = await sendWebDirectInboundAndCollectSessionKeys();
expect(resolver).toHaveBeenCalledTimes(2);
expect(seen[0]).toContain("agent:alfred:");
expect(seen[1]).toContain("agent:baerbel:");
resetLoadConfigMock();
});
it("shares group history across broadcast agents and clears after replying", async () => {
setLoadConfigMock({
channels: { whatsapp: { allowFrom: ["*"] } },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "sequential",
"123@g.us": ["alfred", "baerbel"],
},
} satisfies OpenClawConfig);
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebGroupInboundMessage({
onMessage,
spies,
body: "hello group",
id: "g1",
senderE164: "+111",
senderName: "Alice",
selfE164: "+999",
});
expect(resolver).not.toHaveBeenCalled();
await sendWebGroupInboundMessage({
onMessage,
spies,
body: "@bot ping",
id: "g2",
senderE164: "+222",
senderName: "Bob",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expect(resolver).toHaveBeenCalledTimes(2);
for (const call of resolver.mock.calls.slice(0, 2)) {
const payload = call[0] as {
Body: string;
SenderName?: string;
SenderE164?: string;
SenderId?: string;
};
expect(payload.Body).toContain("Chat messages since your last reply");
expect(payload.Body).toContain("Alice (+111): hello group");
expect(payload.Body).not.toContain("[message_id:");
expect(payload.Body).toContain("@bot ping");
expect(payload.SenderName).toBe("Bob");
expect(payload.SenderE164).toBe("+222");
expect(payload.SenderId).toBe("+222");
}
await sendWebGroupInboundMessage({
onMessage,
spies,
body: "@bot ping 2",
id: "g3",
senderE164: "+333",
senderName: "Clara",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expect(resolver).toHaveBeenCalledTimes(4);
for (const call of resolver.mock.calls.slice(2, 4)) {
const payload = call[0] as { Body: string };
expect(payload.Body).not.toContain("Alice (+111): hello group");
expect(payload.Body).not.toContain("Chat messages since your last reply");
}
resetLoadConfigMock();
});
it("keeps named-account group broadcast routes on the scoped session key", async () => {
setLoadConfigMock({
channels: {
whatsapp: {
allowFrom: ["*"],
accounts: {
work: {
allowFrom: ["*"],
},
},
},
},
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "sequential",
"123@g.us": ["alfred", "baerbel"],
},
} satisfies OpenClawConfig);
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebGroupInboundMessage({
onMessage,
spies,
body: "@bot ping",
id: "g-work-1",
senderE164: "+111",
senderName: "Alice",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
accountId: "work",
});
expect(resolver).toHaveBeenCalledTimes(2);
expect(seen).toEqual([
"agent:alfred:whatsapp:group:123@g.us:thread:whatsapp-account-work",
"agent:baerbel:whatsapp:group:123@g.us:thread:whatsapp-account-work",
]);
resetLoadConfigMock();
});
it("broadcasts in parallel by default", async () => {
setLoadConfigMock({
channels: { whatsapp: { allowFrom: ["*"] } },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "parallel",
"+1000": ["alfred", "baerbel"],
},
} satisfies OpenClawConfig);
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
let started = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const resolver = vi.fn(async () => {
started += 1;
if (started < 2) {
await gate;
} else {
release?.();
}
return { text: "ok" };
});
const { onMessage: capturedOnMessage } = await monitorWebChannelWithCapture(resolver);
await capturedOnMessage(
createTestWebInboundMessage({
event: {
id: "m1",
timestamp: Date.now(),
},
payload: {
body: "hello",
},
platform: {
chatJid: "direct:+1000",
recipientJid: "+2000",
sendComposing,
reply,
sendMedia,
},
admission: {
accountId: "default",
conversation: {
kind: "direct",
id: "+1000",
},
},
}),
);
expect(resolver).toHaveBeenCalledTimes(2);
resetLoadConfigMock();
});
});

View File

@@ -0,0 +1,48 @@
// Whatsapp plugin module implements auto reply.broadcast groups harness behavior.
import { vi } from "vitest";
import {
createWebInboundDeliverySpies,
createWebListenerFactoryCapture,
sendWebDirectInboundMessage,
} from "./auto-reply.test-harness.js";
import { monitorWebChannel } from "./auto-reply/monitor.js";
import type { WebInboundMessageInput } from "./inbound.js";
export async function monitorWebChannelWithCapture(resolver: unknown): Promise<{
spies: ReturnType<typeof createWebInboundDeliverySpies>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
}> {
const spies = createWebInboundDeliverySpies();
const { listenerFactory, getOnMessage } = createWebListenerFactoryCapture();
await monitorWebChannel(false, listenerFactory, false, resolver as never);
const onMessage = getOnMessage();
if (!onMessage) {
throw new Error("Missing onMessage handler");
}
return { spies, onMessage };
}
export async function sendWebDirectInboundAndCollectSessionKeys(): Promise<{
seen: string[];
resolver: ReturnType<typeof vi.fn>;
}> {
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebDirectInboundMessage({
onMessage,
spies,
id: "m1",
from: "+1000",
to: "+2000",
body: "hello",
});
return { seen, resolver };
}

View File

@@ -0,0 +1,7 @@
// Whatsapp plugin module implements auto reply.impl behavior.
export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "openclaw/plugin-sdk/reply-runtime";
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-runtime";
export { DEFAULT_WEB_MEDIA_BYTES } from "./auto-reply/constants.js";
export { monitorWebChannel } from "./auto-reply/monitor.js";
export type { WebChannelStatus, WebMonitorTuning } from "./auto-reply/types.js";

View File

@@ -0,0 +1,469 @@
// Whatsapp plugin module implements auto reply harness behavior.
import "./test-helpers.js";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
import type { WebChannelStatus } from "./auto-reply/types.js";
import type { WebInboundMessageInput, WebListenerCloseReason } from "./inbound.js";
import type { WhatsAppSendResult } from "./inbound/send-result.js";
import { createAcceptedWhatsAppSendResult as createAcceptedWhatsAppSendResultForHarness } from "./inbound/send-result.test-helper.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
import {
resetBaileysMocks as _resetBaileysMocks,
resetLoadConfigMock as _resetLoadConfigMock,
} from "./test-helpers.js";
export { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
export {
resetLoadConfigMock,
setLoadConfigMock,
setRuntimeConfigSourceSnapshotMock,
} from "./test-helpers.js";
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyExport = any;
type MockWebListener = {
close: () => Promise<void>;
onClose: Promise<WebListenerCloseReason>;
signalClose: () => void;
sendMessage: () => Promise<WhatsAppSendResult>;
sendPoll: () => Promise<WhatsAppSendResult>;
sendContact: () => Promise<WhatsAppSendResult>;
sendLocation: () => Promise<WhatsAppSendResult>;
sendSticker: () => Promise<WhatsAppSendResult>;
sendReaction: () => Promise<WhatsAppSendResult>;
sendComposingTo: () => Promise<void>;
};
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
type WebAutoReplyRuntime = {
log: UnknownMock;
error: UnknownMock;
exit: UnknownMock;
};
type WebAutoReplyMonitorHarness = {
runtime: WebAutoReplyRuntime;
controller: AbortController;
run: Promise<unknown>;
};
type MockSessionSocket = {
ev: {
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
};
ws: EventEmitter & {
close: ReturnType<typeof vi.fn>;
};
user: { id: string };
};
const TEST_NET_IP = "93.184.216.34";
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
function getSessionSockets(): MockSessionSocket[] {
const store = globalThis as Record<PropertyKey, unknown>;
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
}
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
}
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn(async () => {
const ws = new EventEmitter() as MockSessionSocket["ws"];
ws.close = vi.fn();
const socket: MockSessionSocket = {
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws,
user: { id: "123@s.whatsapp.net" },
};
getSessionSockets().push(socket);
return socket;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};
});
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
const last = getSessionSockets().at(-1);
if (!last) {
throw new Error("No WhatsApp Web auto-reply test socket created");
}
return last;
}
function resetWebAutoReplySessionSockets() {
getSessionSockets().length = 0;
}
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
abortEmbeddedAgentRun: vi.fn().mockReturnValue(false),
appendCronStyleCurrentTimeLine: (text: string) => text,
isEmbeddedAgentRunActive: vi.fn().mockReturnValue(false),
isEmbeddedAgentRunStreaming: vi.fn().mockReturnValue(false),
queueEmbeddedAgentMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
resolveAgentIdentity: (
cfg: { agents?: { list?: Array<{ id: string; identity?: unknown }> } },
agentId: string,
) =>
cfg.agents?.list?.find(
(entry) => entry.id.trim().toLowerCase() === agentId.trim().toLowerCase(),
)?.identity,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix,
resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) =>
cfg.messages?.messagePrefix,
runEmbeddedAgent: vi.fn(),
}));
async function rmDirWithRetries(
dir: string,
opts?: { attempts?: number; delayMs?: number },
): Promise<void> {
const attempts = opts?.attempts ?? 10;
const delayMs = opts?.delayMs ?? 5;
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
// Let Node handle retries (faster than re-walking the tree in JS on each retry).
try {
await fs.rm(dir, {
recursive: true,
force: true,
maxRetries: attempts,
retryDelay: delayMs,
});
} catch {
// Fall back for older Node implementations (or unexpected retry behavior).
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
await fs.rm(dir, { recursive: true, force: true });
return;
} catch (retryErr) {
const code =
retryErr && typeof retryErr === "object" && "code" in retryErr
? String((retryErr as { code?: unknown }).code)
: null;
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
continue;
}
throw retryErr;
}
}
await fs.rm(dir, { recursive: true, force: true });
}
}
let previousHome: string | undefined;
let tempHome: string | undefined;
let tempHomeRoot: string | undefined;
let tempHomeId = 0;
export function installWebAutoReplyTestHomeHooks() {
beforeAll(async () => {
tempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-suite-"));
});
beforeEach(async () => {
resetInboundDedupe();
previousHome = process.env.HOME;
tempHome = path.join(tempHomeRoot ?? os.tmpdir(), `case-${++tempHomeId}`);
await fs.mkdir(tempHome, { recursive: true });
process.env.HOME = tempHome;
});
afterEach(async () => {
process.env.HOME = previousHome;
tempHome = undefined;
});
afterAll(async () => {
if (tempHomeRoot) {
await rmDirWithRetries(tempHomeRoot);
tempHomeRoot = undefined;
}
tempHomeId = 0;
});
}
export async function makeSessionStore(
entries: Record<string, unknown> = {},
): Promise<{ storePath: string; cleanup: () => Promise<void> }> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, JSON.stringify(entries));
const cleanup = async () => {
await rmDirWithRetries(dir);
};
return {
storePath,
cleanup,
};
}
export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
let resolvePinnedHostnameSpy: { mockRestore: () => unknown } | undefined;
beforeEach(async () => {
vi.clearAllMocks();
resetWebAutoReplySessionSockets();
_resetBaileysMocks();
_resetLoadConfigMock();
if (opts?.pinDns) {
resolvePinnedHostnameSpy = mockPinnedHostnameResolution([TEST_NET_IP]);
}
});
afterEach(() => {
resolvePinnedHostnameSpy?.mockRestore();
resolvePinnedHostnameSpy = undefined;
resetLogger();
setLoggerOverride(null);
vi.useRealTimers();
});
}
export function createWebListenerFactoryCapture(): AnyExport {
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
let capturedOptions:
| {
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
debounceMs?: number;
selfChatMode?: boolean;
}
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
debounceMs?: number;
selfChatMode?: boolean;
}) => {
capturedOnMessage = opts.onMessage;
capturedOptions = opts;
return { close: vi.fn() };
};
return {
listenerFactory,
getOnMessage: () => capturedOnMessage,
getLastOptions: () => capturedOptions,
};
}
export function createMockWebListener(): MockWebListener {
return {
close: vi.fn(async () => undefined),
onClose: new Promise<WebListenerCloseReason>(() => {}),
signalClose: vi.fn(),
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("text", "msg-1")),
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("poll", "poll-1")),
sendContact: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("contact", "contact-1"),
),
sendLocation: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("location", "location-1"),
),
sendSticker: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("sticker", "sticker-1"),
),
sendReaction: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("reaction", "reaction-1"),
),
sendComposingTo: vi.fn(async () => undefined),
};
}
export function createScriptedWebListenerFactory(): AnyExport {
const onMessages: Array<(msg: WebInboundMessageInput) => Promise<void>> = [];
const closeResolvers: Array<(reason: unknown) => void> = [];
const listeners: MockWebListener[] = [];
const listenerFactory = vi.fn(
async (opts: { onMessage: (msg: WebInboundMessageInput) => Promise<void> }) => {
onMessages.push(opts.onMessage);
let resolveClose: (reason: unknown) => void = () => {};
const onClose = new Promise<WebListenerCloseReason>((res) => {
resolveClose = res as (reason: unknown) => void;
closeResolvers.push(resolveClose);
});
const listener: MockWebListener = {
...createMockWebListener(),
onClose,
signalClose: vi.fn((reason?: unknown) => resolveClose(reason)),
};
listeners.push(listener);
return listener;
},
);
return {
listenerFactory,
listeners,
getOnMessage: (index = onMessages.length - 1) => onMessages[index],
resolveClose: (index: number, reason?: unknown) => closeResolvers[index]?.(reason),
getListenerCount: () => listenerFactory.mock.calls.length,
};
}
export function createWebInboundDeliverySpies(): AnyExport {
return {
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("media", "m1")),
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("text", "r1")),
sendComposing: vi.fn(),
};
}
function createWebAutoReplyRuntime(): WebAutoReplyRuntime {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
export function startWebAutoReplyMonitor(params: {
monitorWebChannelFn: (...args: unknown[]) => Promise<unknown>;
listenerFactory: unknown;
sleep: UnknownMock | AsyncUnknownMock;
signal?: AbortSignal;
heartbeatSeconds?: number;
transportTimeoutMs?: number;
messageTimeoutMs?: number;
watchdogCheckMs?: number;
reconnect?: { initialMs: number; maxMs: number; maxAttempts: number; factor: number };
accountId?: string;
statusSink?: (status: WebChannelStatus) => void;
}): WebAutoReplyMonitorHarness {
const runtime = createWebAutoReplyRuntime();
const controller = new AbortController();
const run = params.monitorWebChannelFn(
false,
params.listenerFactory as never,
true,
async () => ({ text: "ok" }),
runtime as never,
params.signal ?? controller.signal,
{
heartbeatSeconds: params.heartbeatSeconds ?? 1,
transportTimeoutMs: params.transportTimeoutMs,
messageTimeoutMs: params.messageTimeoutMs,
watchdogCheckMs: params.watchdogCheckMs,
reconnect: params.reconnect ?? { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep: params.sleep,
accountId: params.accountId,
statusSink: params.statusSink,
},
);
return { runtime, controller, run };
}
export async function sendWebGroupInboundMessage(params: {
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
body: string;
id: string;
senderE164: string;
senderName: string;
mentionedJids?: string[];
selfE164?: string;
selfJid?: string;
spies: ReturnType<typeof createWebInboundDeliverySpies>;
conversationId?: string;
accountId?: string;
}) {
const conversationId = params.conversationId ?? "123@g.us";
const accountId = params.accountId ?? "default";
await params.onMessage(
createTestWebInboundMessage({
event: { id: params.id },
payload: { body: params.body },
platform: {
chatJid: conversationId,
recipientJid: "+2",
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
selfJid: params.selfJid,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
},
admission: {
accountId,
conversation: {
kind: "group",
id: conversationId,
},
sender: {
id: params.senderE164,
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
group: params.mentionedJids?.length
? {
mentions: {
jids: params.mentionedJids,
},
}
: undefined,
}),
);
}
export async function sendWebDirectInboundMessage(params: {
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
body: string;
id: string;
from: string;
to: string;
spies: ReturnType<typeof createWebInboundDeliverySpies>;
accountId?: string;
timestamp?: number;
}) {
const accountId = params.accountId ?? "default";
await params.onMessage(
createTestWebInboundMessage({
event: {
id: params.id,
timestamp: params.timestamp ?? Date.now(),
},
payload: {
body: params.body,
},
platform: {
chatJid: `direct:${params.from}`,
recipientJid: params.to,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
},
admission: {
accountId,
conversation: {
kind: "direct",
id: params.from,
},
sender: {
id: params.from,
},
},
}),
);
}

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements auto reply behavior.
export * from "./auto-reply.impl.js";

View File

@@ -0,0 +1,382 @@
// Whatsapp tests cover auto reply.web auto reply.compresses common formats jpeg cap plugin behavior.
import fs from "node:fs/promises";
import { createNoisyPngBuffer, createSolidPngBuffer } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
createMockWebListener,
createWebInboundDeliverySpies,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
import type { WebInboundCallbackMessage, WebInboundMessageInput } from "./inbound.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
installWebAutoReplyTestHomeHooks();
let monitorWebChannel: typeof import("./auto-reply/monitor.js").monitorWebChannel;
describe("web auto-reply", () => {
installWebAutoReplyUnitTestHooks({ pinDns: true });
type ListenerFactory = NonNullable<Parameters<typeof monitorWebChannel>[1]>;
type WebInboundPlatform = WebInboundCallbackMessage["platform"];
type ReplyMock = ReturnType<typeof vi.fn<WebInboundPlatform["reply"]>>;
type SendMediaMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendMedia"]>>;
type SendComposingMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendComposing"]>>;
const SMALL_MEDIA_CAP_MB = 0.1;
const SMALL_MEDIA_CAP_BYTES = Math.floor(SMALL_MEDIA_CAP_MB * 1024 * 1024);
beforeAll(async () => {
({ monitorWebChannel } = await import("./auto-reply/monitor.js"));
});
async function setupSingleInboundMessage(params: {
resolverValue: { text: string; mediaUrl: string };
sendMedia?: SendMediaMock;
reply?: ReplyMock;
}) {
const spies = createWebInboundDeliverySpies() as {
sendMedia: SendMediaMock;
reply: ReplyMock;
sendComposing: SendComposingMock;
};
const reply = params.reply ?? spies.reply;
const sendMedia = params.sendMedia ?? spies.sendMedia;
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
const listenerFactory: ListenerFactory = async ({ onMessage }) => {
capturedOnMessage = onMessage;
return createMockWebListener();
};
await monitorWebChannel(false, listenerFactory, false, resolver);
if (!capturedOnMessage) {
throw new Error("expected WhatsApp web message handler");
}
const onMessage = capturedOnMessage;
return {
reply,
sendMedia,
dispatch: async (
id = "msg1",
overrides?: Partial<{
from: string;
conversationId: string;
accountId: string;
recipientJid: string;
chatJid: string;
}>,
) => {
const from = overrides?.from ?? "+1";
const conversationId = overrides?.conversationId ?? from;
const chatJid = overrides?.chatJid ?? from;
await onMessage(
createTestWebInboundMessage({
event: {
id,
},
payload: {
body: "hello",
},
platform: {
chatJid,
recipientJid: overrides?.recipientJid ?? "+2",
sendComposing: spies.sendComposing,
reply,
sendMedia,
},
admission: {
accountId: overrides?.accountId ?? "default",
conversation: {
kind: "direct",
id: conversationId,
},
sender: {
id: from,
},
},
}),
);
},
};
}
function getSingleImagePayload(sendMedia: ReturnType<typeof vi.fn>) {
expect(sendMedia).toHaveBeenCalledTimes(1);
return imagePayloadAt(sendMedia, 0);
}
function imagePayloadAt(sendMedia: ReturnType<typeof vi.fn>, callIndex: number) {
const call = sendMedia.mock.calls.at(callIndex);
if (!call) {
throw new Error(`Expected sendMedia call ${callIndex}`);
}
return call[0] as {
image: Buffer;
caption?: string;
mimetype?: string;
};
}
function replyText(reply: ReturnType<typeof vi.fn>): string {
const call = reply.mock.calls.at(0);
if (!call || typeof call[0] !== "string") {
throw new Error("Expected text reply call");
}
return call[0];
}
async function withMediaCap<T>(mediaMaxMb: number, run: () => Promise<T>): Promise<T> {
setLoadConfigMock(() => ({
channels: {
whatsapp: {
allowFrom: ["*"],
mediaMaxMb,
},
},
}));
try {
return await run();
} finally {
resetLoadConfigMock();
}
}
function fetchResponse(body: Buffer | null, mime: string, status = 200): Response {
return {
ok: status < 400,
body: body ? true : null,
arrayBuffer: async () =>
body
? body.buffer.slice(body.byteOffset, body.byteOffset + body.length)
: new ArrayBuffer(0),
headers: new Headers({ "content-type": mime }),
status,
} as unknown as Response;
}
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue(fetchResponse(buffer, mime));
}
async function expectCompressedImageWithinCap(params: {
mediaUrl: string;
mime: string;
image: Buffer;
messageId: string;
mediaMaxMb?: number;
}) {
await withMediaCap(params.mediaMaxMb ?? 1, async () => {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: params.mediaUrl },
});
const fetchMock = mockFetchMediaBuffer(params.image, params.mime);
await dispatch(params.messageId);
const payload = getSingleImagePayload(sendMedia);
expect(payload.image.length).toBeLessThanOrEqual((params.mediaMaxMb ?? 1) * 1024 * 1024);
expect(payload.mimetype).toBe("image/jpeg");
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
});
}
it("sends common in-limit image formats without re-encoding", async () => {
const jpeg = await fs.readFile("docs/assets/showcase/roof-camera-sky.jpg");
const webp = await fs.readFile("extensions/whatsapp/src/__fixtures__/large-noisy.webp");
const formats = [
{
name: "png",
mime: "image/png",
image: createSolidPngBuffer(64, 64, { r: 80, g: 120, b: 200 }),
},
{
name: "jpeg",
mime: "image/jpeg",
image: jpeg,
},
{
name: "webp",
mime: "image/webp",
image: webp,
},
] as const;
await withMediaCap(1, async () => {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/big.image",
},
});
let fetchIndex = 0;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
const matched = formats[Math.min(fetchIndex, formats.length - 1)] ?? formats[0];
fetchIndex += 1;
const { image, mime } = matched;
return fetchResponse(image, mime);
});
try {
for (const [index, fmt] of formats.entries()) {
const beforeCalls = sendMedia.mock.calls.length;
await dispatch(`msg-${fmt.name}-${index}`, {
from: `+1${index}`,
conversationId: `conv-${index}`,
chatJid: `conv-${index}`,
});
expect(sendMedia).toHaveBeenCalledTimes(beforeCalls + 1);
const payload = imagePayloadAt(sendMedia, beforeCalls);
expect(payload.image.length).toBeGreaterThan(0);
expect(payload.image.length).toBeLessThanOrEqual(1024 * 1024);
expect(payload.mimetype).toBe(fmt.mime);
}
expect(sendMedia).toHaveBeenCalledTimes(formats.length);
expect(reply).not.toHaveBeenCalled();
} finally {
fetchMock.mockRestore();
}
});
});
it("honors channels.whatsapp.mediaMaxMb for outbound auto-replies", async () => {
const bigPng = createNoisyPngBuffer(256, 256);
expect(bigPng.length).toBeGreaterThan(SMALL_MEDIA_CAP_BYTES);
await expectCompressedImageWithinCap({
mediaUrl: "https://example.com/big.png",
mime: "image/png",
image: bigPng,
messageId: "msg1",
mediaMaxMb: SMALL_MEDIA_CAP_MB,
});
});
it("prefers per-account WhatsApp media caps for outbound auto-replies", async () => {
const bigPng = createNoisyPngBuffer(256, 256);
expect(bigPng.length).toBeGreaterThan(SMALL_MEDIA_CAP_BYTES);
setLoadConfigMock(() => ({
channels: {
whatsapp: {
allowFrom: ["*"],
mediaMaxMb: 1,
accounts: {
work: {
mediaMaxMb: SMALL_MEDIA_CAP_MB,
},
},
},
},
}));
try {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: "https://example.com/account-big.png" },
});
const fetchMock = mockFetchMediaBuffer(bigPng, "image/png");
await dispatch("msg-account-cap", { accountId: "work" });
const payload = getSingleImagePayload(sendMedia);
expect(payload.image.length).toBeLessThanOrEqual(SMALL_MEDIA_CAP_BYTES);
expect(payload.mimetype).toBe("image/jpeg");
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
} finally {
resetLoadConfigMock();
}
});
it("sends PDF media as a document", async () => {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" },
});
const fetchMock = mockFetchMediaBuffer(Buffer.from("%PDF-1.4"), "application/pdf");
await dispatch("msg-pdf");
expect(sendMedia).toHaveBeenCalledTimes(1);
const payload = imagePayloadAt(sendMedia, 0) as {
document?: Buffer;
caption?: string;
fileName?: string;
};
expect(payload.document).toBeInstanceOf(Buffer);
expect(payload.fileName).toBe("file.pdf");
expect(payload.caption).toBe("hi");
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
});
it("falls back to text when media send fails", async () => {
const sendMedia = vi.fn<WebInboundPlatform["sendMedia"]>().mockRejectedValue(new Error("boom"));
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/img.png",
},
sendMedia,
});
const smallPng = createSolidPngBuffer(64, 64, { r: 0, g: 255, b: 0 });
const fetchMock = mockFetchMediaBuffer(smallPng, "image/png");
await dispatch("msg1");
expect(sendMedia).toHaveBeenCalledTimes(1);
const fallback = replyText(reply);
expect(fallback).toContain("hi");
expect(fallback).toContain("Media failed");
fetchMock.mockRestore();
});
it("returns a warning when remote media fetch 404s", async () => {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "caption",
mediaUrl: "https://example.com/missing.jpg",
},
});
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(fetchResponse(null, "text/plain", 404));
await dispatch("msg1");
expect(sendMedia).not.toHaveBeenCalled();
const fallback = replyText(reply);
expect(fallback).toContain("caption");
expect(fallback).toContain("Media failed");
expect(fallback).not.toContain("404");
fetchMock.mockRestore();
});
it("sends media with a caption when delivery succeeds", async () => {
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/img.png",
},
});
const png = createSolidPngBuffer(64, 64, { r: 0, g: 0, b: 255 });
const fetchMock = mockFetchMediaBuffer(png, "image/png");
await dispatch("msg1");
const payload = getSingleImagePayload(sendMedia);
expect(payload.caption).toBe("hi");
expect(payload.image.length).toBeGreaterThan(0);
// Should not fall back to separate text reply because caption is used.
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
});
});

View File

@@ -0,0 +1,257 @@
// Whatsapp tests cover auto reply.web auto reply.last route plugin behavior.
import "./test-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js";
import { buildMentionConfig } from "./auto-reply/mentions.js";
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
vi.mock("./auto-reply/monitor/last-route.js", async () => {
const actual = await vi.importActual<typeof import("./auto-reply/monitor/last-route.js")>(
"./auto-reply/monitor/last-route.js",
);
return {
...actual,
updateLastRouteInBackground: (...args: unknown[]) => updateLastRouteInBackgroundMock(...args),
};
});
function makeCfg(storePath: string): OpenClawConfig {
return {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
}
function makeReplyLogger() {
return {
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as Parameters<typeof createWebOnMessageHandler>[0]["replyLogger"];
}
function createHandlerForTest(opts: { cfg: OpenClawConfig; replyResolver: unknown }) {
const backgroundTasks = new Set<Promise<unknown>>();
const replyLogger = makeReplyLogger();
const handler = createWebOnMessageHandler({
cfg: opts.cfg,
verbose: false,
connectionId: "test",
maxMediaBytes: 1024,
groupHistoryLimit: 3,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: createEchoTracker({ maxItems: 10 }),
backgroundTasks,
replyResolver: opts.replyResolver as Parameters<
typeof createWebOnMessageHandler
>[0]["replyResolver"],
replyLogger,
baseMentionConfig: buildMentionConfig(opts.cfg),
account: {},
});
return { handler, backgroundTasks };
}
function buildInboundMessage(params: {
id: string;
from: string;
conversationId: string;
chatType: "direct" | "group";
chatId: string;
timestamp: number;
body?: string;
to?: string;
accountId?: string;
senderE164?: string;
senderName?: string;
selfE164?: string;
}) {
return createTestWebInboundMessage({
event: {
id: params.id,
timestamp: params.timestamp,
},
payload: {
body: params.body ?? "hello",
},
platform: {
chatJid: params.chatId,
recipientJid: params.to ?? "+2000",
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
},
admission: {
accountId: params.accountId ?? "default",
conversation: {
kind: params.chatType,
id: params.conversationId,
},
sender: {
id: params.senderE164 ?? params.from,
},
},
});
}
describe("web auto-reply last-route", () => {
installWebAutoReplyUnitTestHooks();
beforeEach(() => {
updateLastRouteInBackgroundMock.mockClear();
});
it("updates last-route for direct chats without senderE164", async () => {
const now = Date.now();
const mainSessionKey = "agent:main:main";
const store = await makeSessionStore({
[mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const cfg = makeCfg(store.storePath);
const { handler, backgroundTasks } = createHandlerForTest({
cfg,
replyResolver: vi.fn().mockResolvedValue(undefined),
});
await handler(
buildInboundMessage({
id: "m1",
from: "+1000",
conversationId: "+1000",
chatType: "direct",
chatId: "direct:+1000",
timestamp: now,
}),
);
await Promise.allSettled(backgroundTasks);
backgroundTasks.clear();
expect(updateLastRouteInBackgroundMock).toHaveBeenCalledTimes(1);
const updateParams = updateLastRouteInBackgroundMock.mock.calls.at(0)?.[0] as
| Record<string, unknown>
| undefined;
expect(updateParams?.cfg).toBe(cfg);
expect(updateParams?.backgroundTasks).toBe(backgroundTasks);
expect(updateParams?.warn).toBeTypeOf("function");
const {
cfg: _cfg,
backgroundTasks: _backgroundTasks,
warn: _warn,
ctx,
...routeParams
} = updateParams ?? {};
expect(routeParams).toEqual({
storeAgentId: "main",
sessionKey: mainSessionKey,
channel: "whatsapp",
to: "+1000",
accountId: "default",
});
expect(ctx).toMatchObject({
From: "+1000",
To: "+2000",
SessionKey: mainSessionKey,
AccountId: "default",
ChatType: "direct",
ConversationLabel: "+1000",
GroupMembers: "+1000",
MessageSid: "m1",
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "+1000",
SenderE164: "+1000",
SenderId: "+1000",
RawBody: "hello",
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/u),
BodyForAgent: "hello",
CommandBody: "hello",
Timestamp: now,
});
await store.cleanup();
});
it("updates last-route for group chats with account id", async () => {
const now = Date.now();
const groupSessionKey = "agent:main:whatsapp:group:123@g.us";
const store = await makeSessionStore({
[groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const cfg = makeCfg(store.storePath);
const { handler, backgroundTasks } = createHandlerForTest({
cfg,
replyResolver: vi.fn().mockResolvedValue(undefined),
});
await handler(
buildInboundMessage({
id: "g1",
from: "123@g.us",
conversationId: "123@g.us",
chatType: "group",
chatId: "123@g.us",
body: "hello +2000",
timestamp: now,
accountId: "work",
senderE164: "+1000",
senderName: "Alice",
selfE164: "+2000",
}),
);
await Promise.allSettled(backgroundTasks);
backgroundTasks.clear();
expect(updateLastRouteInBackgroundMock).toHaveBeenCalledTimes(1);
const updateParams = updateLastRouteInBackgroundMock.mock.calls.at(0)?.[0] as
| Record<string, unknown>
| undefined;
expect(updateParams?.cfg).toBe(cfg);
expect(updateParams?.backgroundTasks).toBe(backgroundTasks);
expect(updateParams?.warn).toBeTypeOf("function");
const {
cfg: _cfg,
backgroundTasks: _backgroundTasks,
warn: _warn,
ctx,
...routeParams
} = updateParams ?? {};
expect(routeParams).toEqual({
storeAgentId: "main",
sessionKey: `${groupSessionKey}:thread:whatsapp-account-work`,
channel: "whatsapp",
to: "123@g.us",
accountId: "work",
});
expect(ctx).toEqual({
From: "123@g.us",
To: "+2000",
SessionKey: `${groupSessionKey}:thread:whatsapp-account-work`,
AccountId: "work",
ChatType: "group",
ConversationLabel: "123@g.us",
GroupSubject: undefined,
SenderName: "Alice",
SenderId: "+1000",
SenderE164: "+1000",
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "123@g.us",
});
await store.cleanup();
});
});

View File

@@ -0,0 +1,17 @@
// Whatsapp helper module supports config behavior.
export {
evaluateSessionFreshness,
loadSessionStore,
resolveSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveStorePath,
resolveThreadFlag,
resolveChannelResetConfig,
updateLastRoute,
} from "openclaw/plugin-sdk/session-store-runtime";
export {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements constants behavior.
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
// Whatsapp plugin module implements deliver reply behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-outbound";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-chunking";
import {
isReasoningReplyPayload,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { requireWhatsAppInboundAdmission } from "../inbound/admission.js";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { listWhatsAppSendResultMessageIds } from "../inbound/send-result.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { loadWebMedia } from "../media.js";
import {
type DeliverableWhatsAppOutboundPayload,
normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation,
prepareWhatsAppOutboundMedia,
sendWhatsAppOutboundWithRetry,
} from "../outbound-media-contract.js";
import { buildQuotedMessageOptions, lookupInboundMessageMeta } from "../quoted-message.js";
import { newConnectionId } from "../reconnect.js";
import { formatError } from "../session.js";
import { convertMarkdownTables } from "../text-runtime.js";
import { markdownToWhatsApp } from "../text-runtime.js";
import { whatsappOutboundLog } from "./loggers.js";
import { elide, markWhatsAppVisibleDeliveryError } from "./util.js";
export type WhatsAppReplyDeliveryResult = {
results: WhatsAppSendResult[];
receipt: MessageReceipt;
providerAccepted: boolean;
};
function resolveWhatsAppReceiptKind(
results: readonly WhatsAppSendResult[],
): Parameters<typeof createMessageReceiptFromOutboundResults>[0]["kind"] {
if (results.length > 0 && results.every((result) => result.kind === "text")) {
return "text";
}
if (results.length > 0 && results.every((result) => result.kind === "media")) {
return "media";
}
return "unknown";
}
function createWhatsAppReplyDeliveryReceipt(
results: readonly WhatsAppSendResult[],
): MessageReceipt {
const receiptResultsById = new Map<string, MessageReceiptSourceResult>();
for (const result of results) {
if (result.receipt?.parts.length) {
for (const part of result.receipt.parts) {
receiptResultsById.set(part.platformMessageId, {
...(part.raw ?? { channel: "whatsapp", messageId: part.platformMessageId }),
meta: {
...part.raw?.meta,
kind: result.kind,
providerAccepted: result.providerAccepted,
},
});
}
continue;
}
for (const messageId of listWhatsAppSendResultMessageIds(result)) {
receiptResultsById.set(messageId, {
channel: "whatsapp",
messageId,
meta: {
kind: result.kind,
providerAccepted: result.providerAccepted,
},
});
}
}
return createMessageReceiptFromOutboundResults({
results: [...receiptResultsById.values()],
kind: resolveWhatsAppReceiptKind(results),
});
}
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
mediaLocalRoots?: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ChunkMode;
replyLogger: {
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
connectionId?: string;
skipLog?: boolean;
tableMode?: MarkdownTableMode;
}): Promise<WhatsAppReplyDeliveryResult> {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const admission = requireWhatsAppInboundAdmission(msg);
const conversationId = admission.conversation.id;
const isGroupConversation = admission.conversation.kind === "group";
const replyStarted = Date.now();
const sendResults: WhatsAppSendResult[] = [];
const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
if (result) {
sendResults.push(result);
}
};
const finishDelivery = (): WhatsAppReplyDeliveryResult => {
const receipt = createWhatsAppReplyDeliveryReceipt(sendResults);
return {
results: sendResults,
receipt,
providerAccepted: sendResults.some((result) => result.providerAccepted),
};
};
if (isReasoningReplyPayload(replyResult)) {
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${conversationId}`);
return finishDelivery();
}
const tableMode = params.tableMode ?? "code";
const chunkMode = params.chunkMode ?? "length";
const normalizedReply =
params.normalizedReplyResult ??
normalizeWhatsAppOutboundPayload(replyResult, {
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
});
const convertedText = markdownToWhatsApp(
convertMarkdownTables(normalizedReply.text ?? "", tableMode),
);
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = normalizedReply.mediaUrls ?? [];
const getQuote = () => {
if (!replyResult.replyToId) {
return undefined;
}
// Use replyToId (not msg.event.id) so batched payloads quote the correct
// per-message target. Look up cached metadata for the specific
// message being quoted — msg.payload.body may be a combined batch body.
const cached = lookupInboundMessageMeta(
admission.accountId,
msg.platform.chatJid,
replyResult.replyToId,
);
return buildQuotedMessageOptions({
messageId: replyResult.replyToId,
remoteJid: msg.platform.chatJid,
fromMe: cached?.fromMe ?? false,
participant:
cached?.participant ?? (isGroupConversation ? msg.platform.senderJid : undefined),
messageText: cached?.body ?? "",
});
};
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
try {
return await sendWhatsAppOutboundWithRetry({
send: fn,
maxAttempts,
onRetry: ({ attempt, maxAttempts: retryMaxAttempts, backoffMs, errorText }) => {
logVerbose(
`Retrying ${label} to ${conversationId} after failure (${attempt}/${retryMaxAttempts - 1}) in ${backoffMs}ms: ${errorText}`,
);
},
});
} catch (error: unknown) {
if (sendResults.some((result) => result.providerAccepted)) {
throw markWhatsAppVisibleDeliveryError(error);
}
throw error;
}
};
// Text-only replies
if (mediaList.length === 0 && textChunks.length) {
const totalChunks = textChunks.length;
for (const [index, chunk] of textChunks.entries()) {
const chunkStarted = Date.now();
const quote = getQuote();
rememberSendResult(await sendWithRetry(() => msg.platform.reply(chunk, quote), "text"));
if (!skipLog) {
const durationMs = Date.now() - chunkStarted;
whatsappOutboundLog.debug(
`Sent chunk ${index + 1}/${totalChunks} to ${conversationId} (${durationMs.toFixed(0)}ms)`,
);
}
}
const delivery = finishDelivery();
const logPayload = {
correlationId: msg.event.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
text: elide(replyResult.text, 240),
mediaUrl: null,
mediaSizeBytes: null,
mediaKind: null,
durationMs: Date.now() - replyStarted,
};
if (delivery.providerAccepted) {
replyLogger.info(logPayload, "auto-reply sent (text)");
} else {
replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
}
return delivery;
}
const remainingText = [...textChunks];
// Media (with optional caption on first item)
const leadingCaption = remainingText.shift() || "";
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: leadingCaption,
send: async ({ mediaUrl, caption }) => {
const media = await prepareWhatsAppOutboundMedia(
await loadWebMedia(mediaUrl, {
maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots,
}),
mediaUrl,
);
if (shouldLogVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
logVerbose(`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`);
}
if (media.kind === "image") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
image: media.buffer,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:image",
),
);
} else if (media.kind === "audio") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
audio: media.buffer,
ptt: true,
mimetype: media.mimetype,
},
quote,
),
"media:audio",
),
);
if (caption) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(caption, quote), "media:audio-text"),
);
}
} else if (media.kind === "video") {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
video: media.buffer,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:video",
),
);
} else {
const quote = getQuote();
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
{
document: media.buffer,
fileName: media.fileName,
caption,
mimetype: media.mimetype,
},
quote,
),
"media:document",
),
);
}
whatsappOutboundLog.info(
`Sent media reply to ${conversationId} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
);
replyLogger.info(
{
correlationId: msg.event.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
text: caption ?? null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (media)",
);
},
onError: async ({ error, mediaUrl, caption, isFirst }) => {
whatsappOutboundLog.error(
`Failed sending web media to ${conversationId}: ${formatError(error)}`,
);
replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
if (!isFirst) {
// Non-first media failures were silently dropped before. Notify the user
// so they know a trailing attachment did not arrive.
whatsappOutboundLog.warn(`Trailing media failed; sent warning to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply("⚠️ Media unavailable.", getQuote()),
"media:fallback-unavailable",
),
);
return;
}
const warning = "⚠️ Media failed.";
const fallbackTextParts = [caption ?? "", warning].filter(Boolean);
const fallbackText = fallbackTextParts.join("\n");
if (!fallbackText) {
return;
}
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply(fallbackText, getQuote()),
"media:fallback-text",
),
);
},
});
// Remaining text chunks after media
for (const chunk of remainingText) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(chunk, getQuote()), "media:text"),
);
}
return finishDelivery();
}

View File

@@ -0,0 +1,7 @@
// Whatsapp plugin module implements loggers behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp");
export const whatsappInboundLog = whatsappLog.child("inbound");
export const whatsappOutboundLog = whatsappLog.child("outbound");
export const whatsappHeartbeatLog = whatsappLog.child("heartbeat");

View File

@@ -0,0 +1,141 @@
// Whatsapp plugin module implements mentions behavior.
import {
buildMentionRegexes,
normalizeMentionText,
} from "openclaw/plugin-sdk/channel-mention-gating";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
getComparableIdentityValues,
getMentionIdentities,
getSelfIdentity,
identitiesOverlap,
type WhatsAppIdentity,
} from "../identity.js";
import { requireWhatsAppInboundAdmission } from "../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
export type MentionConfig = {
mentionRegexes: RegExp[];
allowFrom?: Array<string | number>;
isSelfChat?: boolean;
};
export type MentionTargets = {
normalizedMentions: WhatsAppIdentity[];
self: WhatsAppIdentity;
};
export function buildMentionConfig(
cfg: OpenClawConfig,
agentId?: string,
options?: Parameters<typeof buildMentionRegexes>[2],
): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg, agentId, options);
return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom };
}
export function resolveMentionTargets(
msg: AdmittedWebInboundMessage,
authDir?: string,
): MentionTargets {
const normalizedMentions = getMentionIdentities(msg, authDir);
const self = getSelfIdentity(msg, authDir);
return { normalizedMentions, self };
}
export function isBotMentionedFromTargets(
msg: AdmittedWebInboundMessage,
mentionCfg: MentionConfig,
targets: MentionTargets,
): boolean {
const clean = (text: string) =>
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const explicitSelfChatOverride = typeof mentionCfg.isSelfChat === "boolean";
// `isSelfChatMode` is a config-shaped check ("is the bot's own E.164 in
// allowFrom?"), not a conversation-shaped check, so it returns true even
// for group conversations whenever the operator put their own number in
// allowFrom — which is the common config. The original mention-skip path
// was designed to prevent owner-mentioning-self in a true 1:1 self DM
// from falsely triggering the bot, so when we derive the flag implicitly
// from `allowFrom`, confine the suppression to non-group conversations
// and let real group @mentions go through the identity-overlap check
// (#49317). Explicit `mentionCfg.isSelfChat` overrides from the caller
// are honored as-is so multi-account / precomputed paths keep working.
const admission = requireWhatsAppInboundAdmission(msg);
const isGroupConversation = admission.conversation.kind === "group";
const isSelfChat = explicitSelfChatOverride
? Boolean(mentionCfg.isSelfChat)
: isSelfChatMode(targets.self.e164, mentionCfg.allowFrom) && !isGroupConversation;
const hasMentions = targets.normalizedMentions.length > 0;
if (hasMentions && !isSelfChat) {
for (const mention of targets.normalizedMentions) {
if (identitiesOverlap(targets.self, mention)) {
return true;
}
}
// If the message explicitly mentions someone else, do not fall back to regex matches.
return false;
} else if (hasMentions && isSelfChat) {
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in self-chat triggers the bot.
}
const bodyClean = clean(msg.payload.body);
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) {
return true;
}
// Fallback: detect body containing our own number (with or without +, spacing)
if (targets.self.e164) {
const selfDigits = targets.self.e164.replace(/\D/g, "");
if (selfDigits) {
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
if (bodyDigits.includes(selfDigits)) {
return true;
}
const bodyNoSpace = msg.payload.body.replace(/[\s-]/g, "");
const pattern = new RegExp(`\\+?${selfDigits}`, "i");
if (pattern.test(bodyNoSpace)) {
return true;
}
}
}
return false;
}
export function debugMention(
msg: AdmittedWebInboundMessage,
mentionCfg: MentionConfig,
authDir?: string,
): { wasMentioned: boolean; details: Record<string, unknown> } {
const mentionTargets = resolveMentionTargets(msg, authDir);
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
const admission = requireWhatsAppInboundAdmission(msg);
const details = {
from: admission.conversation.id,
body: msg.payload.body,
bodyClean: normalizeMentionText(msg.payload.body),
mentionedJids: msg.group?.mentions?.jids ?? null,
normalizedMentionedJids: mentionTargets.normalizedMentions.length
? mentionTargets.normalizedMentions.map((identity) => getComparableIdentityValues(identity))
: null,
selfJid: msg.platform.self?.jid ?? msg.platform.selfJid ?? null,
selfLid: msg.platform.self?.lid ?? msg.platform.selfLid ?? null,
selfE164: msg.platform.self?.e164 ?? msg.platform.selfE164 ?? null,
resolvedSelf: mentionTargets.self,
};
return { wasMentioned: result, details };
}
export function resolveOwnerList(mentionCfg: MentionConfig, selfE164?: string | null) {
const allowFrom = mentionCfg.allowFrom;
const raw =
Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : selfE164 ? [selfE164] : [];
return raw
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
}

View File

@@ -0,0 +1,136 @@
// Whatsapp tests cover monitor state plugin behavior.
import { describe, expect, it } from "vitest";
import { createWebChannelStatusController } from "./monitor-state.js";
describe("createWebChannelStatusController", () => {
it("sets lastTransportActivityAt on noteConnected", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.lastTransportActivityAt).toBe(1000);
});
it("updates lastTransportActivityAt on noteInbound", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteInbound(2000);
const last = patches.at(-1)!;
expect(last.lastTransportActivityAt).toBe(2000);
});
it("updates lastTransportActivityAt from explicit transport activity", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteTransportActivity(3000);
const last = patches.at(-1)!;
expect(last.lastTransportActivityAt).toBe(3000);
});
it("publishes busy state for pending inbound work", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteBusy(true, 2000);
controller.noteBusy(false, 3000);
const busy = patches.at(-2)!;
expect(busy.busy).toBe(true);
expect(busy.lastRunActivityAt).toBe(2000);
expect(busy.healthState).toBe("healthy");
const idle = patches.at(-1)!;
expect(idle.busy).toBe(false);
expect(idle.lastRunActivityAt).toBe(3000);
});
it("does not set lastTransportActivityAt on noteWatchdogStale", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteWatchdogStale(5000);
const last = patches.at(-1)!;
// Watchdog staleness should not refresh transport activity — it means
// the check loop is running but the socket itself is idle/stale.
expect(last.lastTransportActivityAt).toBe(1000);
});
it("produces snapshots that enable stale-socket health detection", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
const last = patches.at(-1)!;
// The gateway health policy checks `connected === true && lastTransportActivityAt != null`
// to decide whether to run stale-socket detection. Both must be present.
expect(last.connected).toBe(true);
expect(last.lastTransportActivityAt).toBe(1000);
});
it("clears watchdog recovery history once the socket is healthy again", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteClose({
at: 2000,
statusCode: 499,
error: "status=499",
reconnectAttempts: 1,
healthState: "reconnecting",
watchdogRecovery: true,
});
expect(patches.at(-1)!.lastDisconnect).toEqual({
at: 2000,
status: 499,
error: "status=499",
loggedOut: false,
});
controller.noteConnected(3000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.healthState).toBe("healthy");
expect(last.reconnectAttempts).toBe(0);
expect(last.lastDisconnect).toBeNull();
});
it("keeps non-watchdog reconnect history after the socket reconnects", () => {
const patches: Record<string, unknown>[] = [];
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
controller.noteConnected(1000);
controller.noteClose({
at: 2000,
statusCode: 408,
error: "status=408",
reconnectAttempts: 1,
healthState: "reconnecting",
});
controller.noteConnected(3000);
const last = patches.at(-1)!;
expect(last.connected).toBe(true);
expect(last.healthState).toBe("healthy");
expect(last.reconnectAttempts).toBe(1);
expect(last.lastDisconnect).toEqual({
at: 2000,
status: 408,
error: "status=408",
loggedOut: false,
});
});
});

View File

@@ -0,0 +1,128 @@
// Whatsapp plugin module implements monitor state behavior.
import {
createConnectedChannelStatusPatch,
createTransportActivityStatusPatch,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { WebChannelHealthState, WebChannelStatus } from "./types.js";
function cloneStatus(status: WebChannelStatus): WebChannelStatus {
return {
...status,
lastDisconnect: status.lastDisconnect ? { ...status.lastDisconnect } : null,
};
}
function isTerminalHealthState(healthState: WebChannelHealthState | undefined): boolean {
return healthState === "conflict" || healthState === "logged-out" || healthState === "stopped";
}
export function createWebChannelStatusController(statusSink?: (status: WebChannelStatus) => void) {
let lastDisconnectWasWatchdogRecovery = false;
const status: WebChannelStatus = {
running: true,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastInboundAt: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
busy: false,
lastRunActivityAt: null,
healthState: "starting",
};
const emit = () => {
statusSink?.(cloneStatus(status));
};
return {
emit,
snapshot: () => status,
noteConnected(at = Date.now()) {
Object.assign(status, createConnectedChannelStatusPatch(at));
Object.assign(status, createTransportActivityStatusPatch(at));
if (lastDisconnectWasWatchdogRecovery) {
status.lastDisconnect = null;
status.reconnectAttempts = 0;
lastDisconnectWasWatchdogRecovery = false;
}
status.lastError = null;
status.healthState = "healthy";
emit();
},
noteInbound(at = Date.now()) {
status.lastInboundAt = at;
status.lastMessageAt = at;
status.lastEventAt = at;
Object.assign(status, createTransportActivityStatusPatch(at));
if (status.connected) {
status.healthState = "healthy";
}
emit();
},
noteTransportActivity(at = Date.now()) {
if (status.lastTransportActivityAt === at) {
return;
}
Object.assign(status, createTransportActivityStatusPatch(at));
emit();
},
noteBusy(busy: boolean, at = Date.now()) {
if (status.busy === busy && status.lastRunActivityAt === at) {
return;
}
status.busy = busy;
status.lastRunActivityAt = at;
if (status.connected && busy) {
status.healthState = "healthy";
}
emit();
},
noteWatchdogStale(at = Date.now()) {
status.lastEventAt = at;
if (status.connected) {
status.healthState = "stale";
}
emit();
},
noteReconnectAttempts(reconnectAttempts: number) {
status.reconnectAttempts = reconnectAttempts;
emit();
},
noteClose(params: {
at?: number;
statusCode?: number;
loggedOut?: boolean;
error?: string;
reconnectAttempts: number;
healthState: WebChannelHealthState;
watchdogRecovery?: boolean;
}) {
const at = params.at ?? Date.now();
lastDisconnectWasWatchdogRecovery = params.watchdogRecovery === true;
status.connected = false;
status.lastEventAt = at;
status.lastDisconnect = {
at,
status: params.statusCode,
error: params.error,
loggedOut: Boolean(params.loggedOut),
};
status.lastError = params.error ?? null;
status.reconnectAttempts = params.reconnectAttempts;
status.healthState = params.healthState;
emit();
},
markStopped(at = Date.now()) {
status.running = false;
status.connected = false;
status.lastEventAt = at;
if (!isTerminalHealthState(status.healthState)) {
status.healthState = "stopped";
}
emit();
},
};
}

View File

@@ -0,0 +1,714 @@
// Whatsapp plugin module implements monitor behavior.
import type { WAMessageKey } from "baileys";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound-debounce";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import {
defaultRuntime,
formatDurationPrecise,
warn,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "../accounts.js";
import { WHATSAPP_AUTH_UNSTABLE_CODE, WhatsAppAuthUnstableError } from "../auth-store.js";
import {
WhatsAppConnectionController,
WHATSAPP_WATCHDOG_TIMEOUT_ERROR,
type ManagedWhatsAppListener,
} from "../connection-controller.js";
import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
import { normalizeWebInboundMessage } from "../inbound/message-aliases.js";
import {
attachWebInboxToSocket,
readWhatsAppBaileysCacheEntry,
type WhatsAppBaileysGroupMetadataCache,
type WhatsAppBaileysMessageCache,
type WhatsAppGroupMetadataCache,
} from "../inbound/monitor.js";
import type { WebInboundMessageInput } from "../inbound/types.js";
import {
newConnectionId,
resolveHeartbeatSeconds,
resolveReconnectPolicy,
sleepWithAbort,
} from "../reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
import { buildMentionConfig } from "./mentions.js";
import { createWebChannelStatusController } from "./monitor-state.js";
import { createEchoTracker } from "./monitor/echo.js";
import { formatWhatsAppInboundListeningLog } from "./monitor/listener-log.js";
import { createWebOnMessageHandler } from "./monitor/on-message.js";
import type { WebMonitorTuning } from "./types.js";
import { isLikelyWhatsAppCryptoError } from "./util.js";
function isNonRetryableWebCloseStatus(statusCode: unknown): boolean {
// WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)").
// This is persistent until the operator resolves the conflicting session.
// Baileys 428 = DisconnectReason.connectionClosed, a generic WebSocket close
// that is often transient and must stay on the reconnect path.
return statusCode === 440;
}
type ReplyResolver = typeof import("./reply-resolver.runtime.js").getReplyFromConfig;
type WhatsAppRuntimeConfig = ReturnType<typeof getRuntimeConfig>;
const loadReplyResolverRuntime = createLazyRuntimeModule(
() => import("./reply-resolver.runtime.js"),
);
function resolveWebMonitorConfigSnapshot(params: {
cfg: WhatsAppRuntimeConfig;
accountId?: string | null;
}): {
cfg: WhatsAppRuntimeConfig;
account: ReturnType<typeof resolveWhatsAppAccount>;
} {
const account = resolveWhatsAppAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const cfg = {
...params.cfg,
channels: {
...params.cfg.channels,
whatsapp: {
...params.cfg.channels?.whatsapp,
ackReaction: account.ackReaction,
messagePrefix: account.messagePrefix,
allowFrom: account.allowFrom,
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
chunkMode: account.chunkMode,
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,
},
},
} satisfies WhatsAppRuntimeConfig;
return { cfg, account };
}
function normalizeReconnectAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || "default";
}
function isNoListenerReconnectError(lastError?: string): boolean {
return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError);
}
function resolveExplicitWhatsAppDebounceOverride(params: {
cfg: ReturnType<typeof getRuntimeConfig>;
sourceCfg?: ReturnType<typeof getRuntimeConfig> | null;
accountId: string;
}): number | undefined {
const channel = params.sourceCfg?.channels?.whatsapp;
if (!channel) {
return undefined;
}
const accountId = normalizeReconnectAccountId(params.accountId);
const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs;
if (accountDebounce !== undefined) {
return accountDebounce;
}
if (accountId !== "default") {
const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs;
if (defaultAccountDebounce !== undefined) {
return defaultAccountDebounce;
}
}
return channel.debounceMs;
}
function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError {
return (
error instanceof WhatsAppAuthUnstableError ||
(typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === WHATSAPP_AUTH_UNSTABLE_CODE)
);
}
const DEFAULT_TRANSPORT_TIMEOUT_MS = 5 * 60 * 1000;
export async function monitorWebChannel(
verbose: boolean,
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
keepAlive = true,
replyResolver?: ReplyResolver,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
tuning: WebMonitorTuning = {},
) {
const activeReplyResolver =
replyResolver ?? (await loadReplyResolverRuntime()).getReplyFromConfig;
const runId = newConnectionId();
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const baseCfg = getRuntimeConfig();
const sourceCfg = getRuntimeConfigSourceSnapshot();
const { cfg, account } = resolveWebMonitorConfigSnapshot({
cfg: baseCfg,
accountId: tuning.accountId,
});
const loadCurrentMonitorConfig = () =>
resolveWebMonitorConfigSnapshot({
cfg: getRuntimeConfig(),
accountId: account.accountId,
}).cfg;
const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, tuning.heartbeatSeconds);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const socketTiming = resolveWhatsAppSocketTiming(cfg, tuning.socketTiming);
const baseMentionConfig = buildMentionConfig(cfg);
const groupHistoryLimit =
account.historyLimit ??
cfg.channels?.whatsapp?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
string,
Array<{
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
}>
>();
const groupMemberNames = new Map<string, Map<string, string>>();
const groupMetadataCache: WhatsAppGroupMetadataCache = new Map();
const recentMessageKeys: WhatsAppBaileysMessageCache = new Map();
const baileysGroupMetaCache: WhatsAppBaileysGroupMetadataCache = new Map();
const echoTracker = createEchoTracker({ maxItems: 100, logVerbose });
const sleep =
tuning.sleep ??
((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal ?? abortSignal));
const stopRequested = () => abortSignal?.aborted === true;
// Avoid noisy MaxListenersExceeded warnings in test environments where
// multiple gateway instances may be constructed.
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
if (process.setMaxListeners && currentMaxListeners < 50) {
process.setMaxListeners(50);
}
let sigintStop = false;
const handleSigint = () => {
sigintStop = true;
};
process.once("SIGINT", handleSigint);
const transportTimeoutMs = tuning.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS;
const messageTimeoutMs = tuning.messageTimeoutMs ?? 30 * 60 * 1000;
const watchdogCheckMs = tuning.watchdogCheckMs ?? 60 * 1000;
const controller = new WhatsAppConnectionController({
accountId: account.accountId,
authDir: account.authDir,
verbose,
keepAlive,
heartbeatSeconds,
transportTimeoutMs,
messageTimeoutMs,
watchdogCheckMs,
reconnectPolicy,
socketTiming,
abortSignal,
sleep,
isNonRetryableStatus: isNonRetryableWebCloseStatus,
});
const statusController = createWebChannelStatusController(tuning.statusSink);
statusController.emit();
try {
while (true) {
if (stopRequested()) {
break;
}
const connectionId = newConnectionId();
const inboundDebounceMs = resolveInboundDebounceMs({
cfg,
channel: "whatsapp",
overrideMs: resolveExplicitWhatsAppDebounceOverride({
cfg,
sourceCfg,
accountId: account.accountId,
}),
});
const shouldDebounce = (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
if (normalized.payload.media?.path || normalized.payload.media?.type) {
return false;
}
if (normalized.payload.location) {
return false;
}
if (normalized.quote?.id || normalized.quote?.body) {
return false;
}
return !isControlCommandMessage(
normalized.payload.commandBody ?? normalized.payload.body,
cfg,
);
};
let connection;
try {
connection = await controller.openConnection({
connectionId,
getMessage: async (key: WAMessageKey) =>
key.id && key.remoteJid
? readWhatsAppBaileysCacheEntry(recentMessageKeys, `${key.remoteJid}:${key.id}`)
: undefined,
cachedGroupMetadata: async (jid: string) => {
const meta = readWhatsAppBaileysCacheEntry(baileysGroupMetaCache, jid);
return meta?.participants?.length ? meta : undefined;
},
createListener: async ({ sock, connection: connectionLocal }) => {
const onMessage = createWebOnMessageHandler({
cfg,
loadConfig: loadCurrentMonitorConfig,
verbose,
connectionId,
maxMediaBytes,
groupHistoryLimit,
groupHistories,
groupMemberNames,
echoTracker,
backgroundTasks: connectionLocal.backgroundTasks,
replyResolver: activeReplyResolver,
replyLogger,
baseMentionConfig,
account,
});
return (await (listenerFactory ?? attachWebInboxToSocket)({
cfg,
loadConfig: loadCurrentMonitorConfig,
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
selfChatMode: account.selfChatMode,
sendReadReceipts: account.sendReadReceipts,
socketTiming,
debounceMs: inboundDebounceMs,
shouldDebounce,
socketRef: controller.socketRef,
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
disconnectRetryPolicy: reconnectPolicy,
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
groupMetadataCache,
recentMessageKeys,
baileysGroupMetaCache,
onMessage: async (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
const inboundAt = Date.now();
controller.noteInbound(inboundAt);
statusController.noteInbound(inboundAt);
await onMessage(normalized);
},
onPendingWorkChanged: (pendingWorkCount, at) => {
statusController.noteBusy(pendingWorkCount > 0, at);
},
sock,
})) as ManagedWhatsAppListener;
},
onHeartbeat: (snapshot) => {
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = snapshot.lastInboundAt
? Math.floor((Date.now() - snapshot.lastInboundAt) / 60000)
: null;
const logData = {
connectionId: snapshot.connectionId,
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
lastTransportActivityAt: snapshot.lastTransportActivityAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
? { minutesSinceLastMessage }
: {}),
};
statusController.noteTransportActivity(snapshot.lastTransportActivityAt);
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
heartbeatLogger.warn(
logData,
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
);
} else {
heartbeatLogger.info(logData, "web gateway heartbeat");
}
},
onWatchdogTimeout: (snapshot) => {
const now = Date.now();
const transportSilentMs = now - snapshot.lastTransportActivityAt;
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
const watchdogReason =
transportSilentMs > transportTimeoutMs ? "transport-inactive" : "app-silent";
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
watchdogReason,
minutesSinceTransportActivity,
minutesSinceAppActivity,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
messagesHandled: snapshot.handledMessages,
},
"WhatsApp watchdog timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
);
},
});
} catch (error) {
const setupDecision = controller.resolveSetupErrorDecision(error);
if (setupDecision === "aborted") {
await controller.shutdown();
break;
}
if (setupDecision) {
statusController.noteReconnectAttempts(setupDecision.reconnectAttempts);
statusController.noteClose({
statusCode: setupDecision.normalized.statusCode,
error: formatError(error),
reconnectAttempts: setupDecision.reconnectAttempts,
healthState: setupDecision.healthState,
});
if (setupDecision.action === "stop") {
reconnectLogger.warn(
{
connectionId,
status: setupDecision.normalized.statusLabel,
reconnectAttempts: setupDecision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: setup status error; max attempts reached",
);
if (setupDecision.healthState === "logged-out") {
runtime.error(
`WhatsApp session logged out during setup. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (setupDecision.healthState === "conflict") {
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}) after ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` if the issue persists.`,
);
}
await controller.shutdown();
break;
}
reconnectLogger.info(
{
connectionId,
status: setupDecision.normalized.statusLabel,
reconnectAttempts: setupDecision.reconnectAttempts,
delayMs: setupDecision.delayMs,
},
"web reconnect: setup status error; retrying",
);
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}). Retry ${setupDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(setupDecision.delayMs ?? 0)}.`,
);
try {
await controller.waitBeforeRetry(setupDecision.delayMs ?? 0);
} catch {
break;
}
continue;
}
if (!isRetryableAuthUnstableError(error)) {
throw error;
}
const retryDecision = controller.consumeReconnectAttempt();
statusController.noteReconnectAttempts(retryDecision.reconnectAttempts);
statusController.noteClose({
error: error.message,
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
});
if (retryDecision.action === "stop") {
reconnectLogger.warn(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: auth state stayed unstable; max attempts reached",
);
runtime.error(
`WhatsApp auth state is still stabilizing after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Stopping web monitoring.`,
);
await controller.shutdown();
break;
}
reconnectLogger.info(
{
connectionId,
reconnectAttempts: retryDecision.reconnectAttempts,
delayMs: retryDecision.delayMs,
},
"web reconnect: auth state still stabilizing during inbox attach; retrying",
);
runtime.error(
`WhatsApp auth state is still stabilizing. Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} for inbox attach in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`,
);
try {
await controller.waitBeforeRetry(retryDecision.delayMs ?? 0);
} catch {
break;
}
continue;
}
statusController.noteConnected();
const approvalContextLease = registerChannelRuntimeContext({
channelRuntime: tuning.channelRuntime,
channelId: "whatsapp",
accountId: account.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: { accountId: account.accountId },
abortSignal,
});
controller.setUnhandledRejectionCleanup(
registerUnhandledRejectionHandler((reason) => {
if (!isLikelyWhatsAppCryptoError(reason)) {
return false;
}
const errorStr = formatError(reason);
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: errorStr },
"web reconnect: unhandled rejection from WhatsApp socket; forcing reconnect",
);
controller.forceClose({
status: 499,
isLoggedOut: false,
error: reason,
});
return true;
}),
);
const { e164: selfE164 } = readWebSelfId(account.authDir);
const connectRoute = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: account.accountId,
});
enqueueSystemEvent(`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`, {
sessionKey: connectRoute.sessionKey,
});
const normalizedAccountId = normalizeReconnectAccountId(account.accountId);
void drainPendingDeliveries({
drainKey: `whatsapp:${normalizedAccountId}`,
logLabel: "WhatsApp reconnect drain",
cfg,
log: reconnectLogger,
selectEntry: (entry) => ({
match:
entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
bypassBackoff: isNoListenerReconnectError(entry.lastError),
}),
}).catch((err: unknown) => {
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: String(err) },
"reconnect drain failed",
);
});
const periodicDrainInterval = setInterval(() => {
void drainPendingDeliveries({
drainKey: `whatsapp:${normalizedAccountId}`,
logLabel: "WhatsApp periodic drain",
cfg,
log: reconnectLogger,
selectEntry: (entry) => ({
match:
entry.channel === "whatsapp" &&
normalizeReconnectAccountId(entry.accountId) === normalizedAccountId,
bypassBackoff: false,
}),
}).catch((err: unknown) => {
reconnectLogger.warn(
{ connectionId: connection.connectionId, error: String(err) },
"periodic drain failed",
);
});
}, 30_000);
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg,
accountId: account.accountId,
selfE164: selfE164 ?? null,
});
whatsappLog.info(
formatWhatsAppInboundListeningLog({
groups: inboundPolicy.account.groups,
groupPolicy: inboundPolicy.groupPolicy,
hasGroupAllowFrom: inboundPolicy.groupAllowFrom.length > 0,
}),
);
if (process.stdout.isTTY || process.stderr.isTTY) {
whatsappLog.raw("Ctrl+C to stop.");
}
if (!keepAlive) {
clearInterval(periodicDrainInterval);
approvalContextLease?.dispose();
await controller.shutdown();
return;
}
const reason = await controller.waitForClose().finally(() => {
clearInterval(periodicDrainInterval);
approvalContextLease?.dispose();
});
if (stopRequested() || sigintStop || reason === "aborted") {
await controller.shutdown();
break;
}
const decision = controller.resolveCloseDecision(reason);
if (decision === "aborted") {
await controller.shutdown();
break;
}
statusController.noteReconnectAttempts(controller.getReconnectAttempts());
reconnectLogger.info(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
loggedOut: decision.normalized.isLoggedOut,
reconnectAttempts: decision.reconnectAttempts,
error: decision.normalized.errorText,
},
"web reconnect: connection closed",
);
enqueueSystemEvent(
`WhatsApp gateway disconnected (status ${decision.normalized.statusLabel})`,
{
sessionKey: connectRoute.sessionKey,
},
);
if (decision.action === "stop") {
await controller.closeCurrentConnection();
statusController.noteClose({
statusCode: decision.normalized.statusCode,
loggedOut: decision.normalized.isLoggedOut,
error: decision.normalized.errorText,
reconnectAttempts: decision.reconnectAttempts,
healthState: decision.healthState,
});
if (decision.healthState === "logged-out") {
runtime.error(
`WhatsApp session logged out. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (decision.healthState === "conflict") {
reconnectLogger.warn(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
error: decision.normalized.errorText,
},
"web reconnect: non-retryable close status; stopping monitor",
);
runtime.error(
`WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
reconnectLogger.warn(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
reconnectAttempts: decision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts,
},
"web reconnect: max attempts reached; continuing in degraded mode",
);
runtime.error(
`WhatsApp Web reconnect: max attempts reached (${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts}). Stopping web monitoring.`,
);
}
await controller.shutdown();
break;
}
const isWatchdogRecoveryReconnect =
decision.normalized.error === WHATSAPP_WATCHDOG_TIMEOUT_ERROR;
statusController.noteClose({
statusCode: decision.normalized.statusCode,
error: decision.normalized.errorText,
reconnectAttempts: decision.reconnectAttempts,
healthState: decision.healthState,
watchdogRecovery: isWatchdogRecoveryReconnect,
});
reconnectLogger.info(
{
connectionId: connection.connectionId,
status: decision.normalized.statusLabel,
reconnectAttempts: decision.reconnectAttempts,
maxAttempts: reconnectPolicy.maxAttempts || "unlimited",
delayMs: decision.delayMs,
},
"web reconnect: scheduling retry",
);
const reconnectMessage = isWatchdogRecoveryReconnect
? `WhatsApp Web watchdog is recovering a stale connection (status ${decision.normalized.statusLabel}). Retry ${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(decision.delayMs ?? 0)}.`
: `WhatsApp Web connection closed (status ${decision.normalized.statusLabel}). Retry ${decision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(decision.delayMs ?? 0)}… (${decision.normalized.errorText})`;
if (isWatchdogRecoveryReconnect) {
runtime.log(warn(reconnectMessage));
} else {
runtime.error(reconnectMessage);
}
await controller.closeCurrentConnection();
try {
await controller.waitBeforeRetry(decision.delayMs ?? 0);
} catch {
break;
}
}
} finally {
statusController.markStopped();
process.removeListener("SIGINT", handleSigint);
await controller.shutdown();
}
}

View File

@@ -0,0 +1,110 @@
// Whatsapp tests cover ack emoji plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
function createConfig(
ackReaction?: NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>,
): OpenClawConfig {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {},
},
} as OpenClawConfig;
if (ackReaction !== undefined) {
cfg.channels!.whatsapp!.ackReaction = ackReaction;
}
return cfg;
}
describe("resolveWhatsAppAckEmoji", () => {
it("keeps missing ackReaction config disabled", () => {
expect(
resolveWhatsAppAckEmoji({
cfg: createConfig(),
agentId: "agent",
ackConfig: undefined,
}),
).toBe("");
});
it("uses the configured WhatsApp emoji when present", () => {
const cfg = createConfig({ emoji: " 👀 ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
it("keeps an explicit empty emoji disabled", () => {
const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("");
});
it("falls back to the routed agent identity emoji when the ack object has no emoji", () => {
const cfg = createConfig({ direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses normalized agent ids for the identity fallback", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "Agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses the default ack emoji when configured without an emoji or agent identity", () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
});

View File

@@ -0,0 +1,28 @@
// Whatsapp plugin module implements ack emoji behavior.
import { resolveAgentIdentity } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
const DEFAULT_WHATSAPP_ACK_REACTION = "👀";
type WhatsAppAckReactionConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>;
export function resolveWhatsAppAckEmoji(params: {
cfg: OpenClawConfig;
agentId: string;
ackConfig: WhatsAppAckReactionConfig | undefined;
}): string {
if (!params.ackConfig) {
return "";
}
if (params.ackConfig.emoji !== undefined) {
return params.ackConfig.emoji.trim();
}
return resolveAgentIdentityEmoji(params.cfg, params.agentId) ?? DEFAULT_WHATSAPP_ACK_REACTION;
}
function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId: string): string | undefined {
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || undefined;
}

View File

@@ -0,0 +1,205 @@
// Whatsapp tests cover ack reaction plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
},
admission: {
accountId: "default",
conversation: {
kind: "direct",
id: "15551234567",
},
sender: {
id: "15551234567",
},
},
...overrides,
});
}
function createConfig(
reactionLevel: "off" | "ack" | "minimal" | "extensive",
extras?: Partial<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>,
): OpenClawConfig {
return {
channels: {
whatsapp: {
reactionLevel,
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions",
},
...extras,
},
},
} as OpenClawConfig;
}
type AckReactionParams = Parameters<typeof maybeSendAckReaction>[0];
const runAckReaction = (overrides: Partial<AckReactionParams> = {}) =>
maybeSendAckReaction({
cfg: createConfig("ack"),
msg: createMessage(),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
info: vi.fn(),
warn: vi.fn(),
...overrides,
});
const expectAckReactionSent = (accountId: string, cfg: OpenClawConfig = createConfig("ack")) => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
accountId,
cfg,
},
);
};
describe("maybeSendAckReaction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["ack", "minimal", "extensive"] as const)(
"sends ack reactions when reactionLevel is %s",
async (reactionLevel) => {
const cfg = createConfig(reactionLevel);
const ackReaction = await runAckReaction({
cfg,
});
expect(ackReaction?.ackReactionValue).toBe("👀");
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
expectAckReactionSent("default", cfg);
},
);
it("suppresses ack reactions when reactionLevel is off", async () => {
const ackReaction = await runAckReaction({
cfg: createConfig("off"),
});
expect(ackReaction).toBeNull();
expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled();
});
it("uses the active account reactionLevel override for ack gating", async () => {
const cfg = createConfig("off", {
accounts: {
work: {
reactionLevel: "ack",
},
},
});
const ackReaction = await runAckReaction({
cfg,
msg: createMessage({
admission: {
accountId: "work",
},
}),
sessionKey: "whatsapp:work:15551234567",
});
expect(ackReaction?.ackReactionValue).toBe("👀");
expectAckReactionSent("work", cfg);
});
it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
reactionLevel: "ack",
ackReaction: {
direct: true,
group: "mentions",
},
},
},
} as OpenClawConfig;
const ackReaction = await runAckReaction({ cfg });
expect(ackReaction?.ackReactionValue).toBe("🔥");
await expect(ackReaction?.ackReactionPromise).resolves.toBe(true);
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
it("returns a handle that removes the ack with an empty reaction", async () => {
const cfg = createConfig("ack");
const ackReaction = await runAckReaction({ cfg });
await ackReaction?.remove();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
it("records ack send failures on the handle", async () => {
const cfg = createConfig("ack");
const warn = vi.fn();
hoisted.sendReactionWhatsApp.mockRejectedValueOnce(new Error("session down"));
const ackReaction = await runAckReaction({ cfg, warn });
await expect(ackReaction?.ackReactionPromise).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(
{
error: "session down",
chatId: "15551234567@s.whatsapp.net",
messageId: "msg-1",
},
"failed to send ack reaction",
);
});
});

View File

@@ -0,0 +1,115 @@
// Whatsapp plugin module implements ack reaction behavior.
import {
createAckReactionHandle,
shouldAckReactionForWhatsApp,
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSenderIdentity } from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
import { sendReactionWhatsApp } from "../../send.js";
import { formatError } from "../../session.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export async function maybeSendAckReaction(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
agentId: string;
sessionKey: string;
verbose: boolean;
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
}): Promise<AckReactionHandle | null> {
if (!params.msg.event.id) {
return null;
}
const admission = requireWhatsAppInboundAdmission(params.msg);
const accountId = admission.accountId;
// Keep ackReaction as the emoji/scope control, while letting reactionLevel
// suppress all automatic reactions when it is explicitly set to "off".
const reactionLevel = resolveWhatsAppReactionLevel({
cfg: params.cfg,
accountId,
});
if (reactionLevel.level === "off") {
return null;
}
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
const emoji = resolveWhatsAppAckEmoji({
cfg: params.cfg,
agentId: params.agentId,
ackConfig,
});
const directEnabled = ackConfig?.direct ?? true;
const groupMode = ackConfig?.group ?? "mentions";
const isGroup = admission.conversation.kind === "group";
const conversationIdForCheck = admission.conversation.id;
const activation = isGroup
? await resolveGroupActivationFor({
cfg: params.cfg,
accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
})
: null;
const shouldSendReaction = () =>
shouldAckReactionForWhatsApp({
emoji,
isDirect: admission.conversation.kind === "direct",
isGroup,
directEnabled,
groupMode,
wasMentioned: (params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned) === true,
groupActivated: activation === "always",
});
if (!shouldSendReaction()) {
return null;
}
params.info(
{ chatId: params.msg.platform.chatJid, messageId: params.msg.event.id, emoji },
"sending ack reaction",
);
const sender = getSenderIdentity(params.msg);
const reactionOptions = {
verbose: params.verbose,
fromMe: false,
...(sender.jid ? { participant: sender.jid } : {}),
accountId,
cfg: params.cfg,
};
return createAckReactionHandle({
ackReactionValue: emoji,
send: () =>
sendReactionWhatsApp(
params.msg.platform.chatJid,
params.msg.event.id!,
emoji,
reactionOptions,
),
remove: () =>
sendReactionWhatsApp(params.msg.platform.chatJid, params.msg.event.id!, "", reactionOptions),
onSendError: (err) => {
params.warn(
{
error: formatError(err),
chatId: params.msg.platform.chatJid,
messageId: params.msg.event.id,
},
"failed to send ack reaction",
);
logVerbose(
`WhatsApp ack reaction failed for chat ${params.msg.platform.chatJid}: ${formatError(err)}`,
);
},
});
}

View File

@@ -0,0 +1,10 @@
// Whatsapp plugin module implements audio preflight behavior.
import { transcribeFirstAudio as transcribeFirstAudioImpl } from "openclaw/plugin-sdk/media-runtime";
type TranscribeFirstAudio = typeof import("openclaw/plugin-sdk/media-runtime").transcribeFirstAudio;
export async function transcribeFirstAudio(
...args: Parameters<TranscribeFirstAudio>
): ReturnType<TranscribeFirstAudio> {
return await transcribeFirstAudioImpl(...args);
}

View File

@@ -0,0 +1,156 @@
// Whatsapp plugin module implements broadcast behavior.
import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
import {
buildAgentMainSessionKey,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "openclaw/plugin-sdk/routing";
import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { formatError } from "../../session.js";
import { whatsappInboundLog } from "../loggers.js";
import type { GroupHistoryEntry } from "./inbound-context.js";
function buildBroadcastRouteKeys(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
route: ReturnType<typeof resolveAgentRoute>;
peerId: string;
agentId: string;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
const sessionKey = buildAgentSessionKey({
agentId: params.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
peer: {
kind: admission.conversation.kind,
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
});
const mainSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
});
return {
sessionKey,
mainSessionKey,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey,
mainSessionKey,
}),
};
}
export async function maybeBroadcastMessage(params: {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
peerId: string;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
processMessage: (
msg: AdmittedWebInboundMessage,
route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
opts?: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear?: boolean;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
},
) => Promise<boolean>;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
}) {
const broadcastAgents = params.cfg.broadcast?.[params.peerId];
if (!broadcastAgents || !Array.isArray(broadcastAgents)) {
return false;
}
if (broadcastAgents.length === 0) {
return false;
}
const strategy = params.cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info(`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`);
const agentIds = params.cfg.agents?.list?.map((agent) => normalizeAgentId(agent.id));
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const admission = requireWhatsAppInboundAdmission(params.msg);
const isGroupConversation = admission.conversation.kind === "group";
const groupHistorySnapshot = isGroupConversation
? (params.groupHistories.get(params.groupHistoryKey) ?? [])
: undefined;
const processForAgent = async (agentId: string): Promise<boolean> => {
const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`);
return false;
}
const routeKeys = buildBroadcastRouteKeys({
cfg: params.cfg,
msg: params.msg,
route: params.route,
peerId: params.peerId,
agentId: normalizedAgentId,
});
const baseAgentRoute = {
...params.route,
agentId: normalizedAgentId,
...routeKeys,
};
const agentRoute = isGroupConversation
? resolveWhatsAppGroupSessionRoute(baseAgentRoute)
: baseAgentRoute;
try {
const opts: {
groupHistory?: GroupHistoryEntry[];
suppressGroupHistoryClear: true;
preflightAudioTranscript?: string | null;
ackAlreadySent?: boolean;
ackReaction?: AckReactionHandle | null;
} = {
groupHistory: groupHistorySnapshot,
suppressGroupHistoryClear: true,
};
if (params.preflightAudioTranscript !== undefined) {
opts.preflightAudioTranscript = params.preflightAudioTranscript;
}
if (params.ackAlreadySent === true) {
opts.ackAlreadySent = true;
}
if (params.ackReaction !== undefined) {
opts.ackReaction = params.ackReaction;
}
return await params.processMessage(params.msg, agentRoute, params.groupHistoryKey, opts);
} catch (err) {
whatsappInboundLog.error(`Broadcast agent ${agentId} failed: ${formatError(err)}`);
return false;
}
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
await processForAgent(agentId);
}
} else {
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
if (isGroupConversation) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return true;
}

View File

@@ -0,0 +1,20 @@
// Whatsapp plugin module implements commands behavior.
export function stripMentionsForCommand(
text: string,
mentionRegexes: RegExp[],
selfE164?: string | null,
) {
let result = text;
for (const re of mentionRegexes) {
result = result.replace(re, " ");
}
if (selfE164) {
// `selfE164` is usually like "+1234"; strip down to digits so we can match "+?1234" safely.
const digits = selfE164.replace(/\D/g, "");
if (digits) {
const pattern = new RegExp(`\\+?${digits}`, "g");
result = result.replace(pattern, " ");
}
}
return result.replace(/\s+/g, " ").trim();
}

View File

@@ -0,0 +1,65 @@
// Whatsapp plugin module implements echo behavior.
export type EchoTracker = {
rememberText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
has: (key: string) => boolean;
forget: (key: string) => void;
buildCombinedKey: (params: { sessionKey: string; combinedBody: string }) => string;
};
export function createEchoTracker(params: {
maxItems?: number;
logVerbose?: (msg: string) => void;
}): EchoTracker {
const recentlySent = new Set<string>();
const maxItems = Math.max(1, params.maxItems ?? 100);
const buildCombinedKey = (p: { sessionKey: string; combinedBody: string }) =>
`combined:${p.sessionKey}:${p.combinedBody}`;
const trim = () => {
while (recentlySent.size > maxItems) {
const firstKey = recentlySent.values().next().value;
if (!firstKey) {
break;
}
recentlySent.delete(firstKey);
}
};
const rememberText: EchoTracker["rememberText"] = (text, opts) => {
if (!text) {
return;
}
recentlySent.add(text);
if (opts.combinedBody && opts.combinedBodySessionKey) {
recentlySent.add(
buildCombinedKey({
sessionKey: opts.combinedBodySessionKey,
combinedBody: opts.combinedBody,
}),
);
}
if (opts.logVerboseMessage) {
params.logVerbose?.(
`Added to echo detection set (size now: ${recentlySent.size}): ${text.slice(0, 50)}...`,
);
}
trim();
};
return {
rememberText,
has: (key) => recentlySent.has(key),
forget: (key) => {
recentlySent.delete(key);
},
buildCombinedKey,
};
}

View File

@@ -0,0 +1,2 @@
// Whatsapp plugin module implements group activation behavior.
export { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";

View File

@@ -0,0 +1,193 @@
// Whatsapp tests cover group activation plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { saveSessionStore, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadSessionStore } from "../config.runtime.js";
import { resolveGroupActivationFor } from "./group-activation.js";
const GROUP_CONVERSATION_ID = "123@g.us";
const LEGACY_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us";
const WORK_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
type SessionStoreEntry = {
groupActivation?: unknown;
sessionId?: unknown;
updatedAt?: unknown;
};
async function makeSessionStore(
entries: Record<string, unknown> = {},
): Promise<{ storePath: string; cleanup: () => Promise<void> }> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-"));
const storePath = path.join(dir, "sessions.json");
await saveSessionStore(storePath, entries as Record<string, SessionEntry>, {
skipMaintenance: true,
});
return {
storePath,
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const resolveWorkGroupActivation = (storePath: string) =>
resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "work",
agentId: "main",
sessionKey: WORK_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
const expectWorkGroupActivationEntry = async (
storePath: string,
assertEntry?: (entry: SessionStoreEntry | undefined) => void,
) => {
await vi.waitFor(() => {
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[WORK_GROUP_SESSION_KEY];
expect(scopedEntry?.groupActivation).toBe("always");
assertEntry?.(scopedEntry);
});
};
const expectResolvedWorkGroupActivation = async (
storePath: string,
assertEntry?: (entry: SessionStoreEntry | undefined) => void,
) => {
const activation = await resolveWorkGroupActivation(storePath);
expect(activation).toBe("always");
await expectWorkGroupActivationEntry(storePath, assertEntry);
};
describe("resolveGroupActivationFor", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
it("reads legacy named-account group activation and backfills the scoped key", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
sessionId: "legacy-session",
updatedAt: 123,
},
});
cleanups.push(cleanup);
await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
expect(scopedEntry?.sessionId).toBeUndefined();
expect(scopedEntry?.updatedAt).toBeUndefined();
});
});
it("preserves legacy group activation when the scoped entry already exists without activation", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
[WORK_GROUP_SESSION_KEY]: {
sessionId: "scoped-session",
},
});
cleanups.push(cleanup);
await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
expect(scopedEntry?.sessionId).toBe("scoped-session");
});
});
it("does not wake the default account from an activation-only legacy group entry in multi-account setups", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const cfg = {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never;
const workActivation = await resolveGroupActivationFor({
cfg,
accountId: "work",
agentId: "main",
sessionKey: WORK_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(workActivation).toBe("always");
const defaultActivation = await resolveGroupActivationFor({
cfg,
accountId: "default",
agentId: "main",
sessionKey: LEGACY_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(defaultActivation).toBe("mention");
await expectWorkGroupActivationEntry(storePath);
});
it("does not treat mixed-case default account keys as named accounts", async () => {
const { storePath, cleanup } = await makeSessionStore({
[LEGACY_GROUP_SESSION_KEY]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const activation = await resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
Default: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "default",
agentId: "main",
sessionKey: LEGACY_GROUP_SESSION_KEY,
conversationId: GROUP_CONVERSATION_ID,
});
expect(activation).toBe("always");
});
});

View File

@@ -0,0 +1,88 @@
// Whatsapp plugin module implements group activation behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
getSessionEntry,
patchSessionEntry,
resolveStorePath,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import { normalizeGroupActivation } from "./group-activation.runtime.js";
function hasNamedWhatsAppAccounts(cfg: OpenClawConfig) {
const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID);
}
function isActivationOnlyEntry(
entry:
| {
groupActivation?: unknown;
sessionId?: unknown;
updatedAt?: unknown;
}
| undefined,
) {
return (
entry?.groupActivation !== undefined &&
typeof entry?.sessionId !== "string" &&
typeof entry?.updatedAt !== "number"
);
}
/** Resolves group activation for a WhatsApp conversation and backfills scoped session metadata. */
export async function resolveGroupActivationFor(params: {
cfg: OpenClawConfig;
accountId?: string | null;
agentId: string;
sessionKey: string;
conversationId: string;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
const sessionScope = { storePath, agentId: params.agentId };
const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({
sessionKey: params.sessionKey,
accountId: params.accountId,
});
const legacyEntry = legacySessionKey
? getSessionEntry({ ...sessionScope, sessionKey: legacySessionKey })
: undefined;
const scopedEntry = getSessionEntry({ ...sessionScope, sessionKey: params.sessionKey });
const normalizedAccountId = normalizeAccountId(params.accountId);
const ignoreScopedActivation =
normalizedAccountId === DEFAULT_ACCOUNT_ID &&
hasNamedWhatsAppAccounts(params.cfg) &&
isActivationOnlyEntry(scopedEntry);
const activation =
(ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ??
legacyEntry?.groupActivation;
if (activation !== undefined && scopedEntry?.groupActivation === undefined) {
// Activation-only backfills must not synthesize session ids or activity.
// replaceEntry preserves existing scoped metadata while keeping fallback writes sparse.
await patchSessionEntry({
...sessionScope,
sessionKey: params.sessionKey,
fallbackEntry: {} as SessionEntry,
replaceEntry: true,
update: (entry) => {
if (entry.groupActivation !== undefined) {
return null;
}
return {
...entry,
groupActivation: activation,
};
},
});
}
const requireMention = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.accountId,
}).resolveConversationRequireMention(params.conversationId);
const defaultActivation = !requireMention ? "always" : "mention";
return normalizeGroupActivation(activation) ?? defaultActivation;
}

View File

@@ -0,0 +1,228 @@
// Whatsapp tests cover group gating.allowlist warn plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import {
resetGroupDropWarningsForTests,
applyGroupGating,
type GroupHistoryEntry,
} from "./group-gating.js";
function makeUnregisteredGroupMsg(
conversationId: string,
accountId = "default",
): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: {
id: `msg-${conversationId}`,
timestamp: 1700000000,
},
payload: {
body: "@openclaw hello",
},
platform: {
chatJid: conversationId,
recipientJid: "+15550000001",
sender: { e164: "+15550000002", name: "Alice" },
},
admission: {
accountId,
conversation: {
kind: "group",
id: conversationId,
},
sender: {
id: "+15550000002",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
});
}
type WarnLogger = (obj: unknown, msg: string) => void;
type ApplyGroupGatingParams = Parameters<typeof applyGroupGating>[0];
function makeParams(
msg: AdmittedWebInboundMessage,
warn: WarnLogger,
cfg: ApplyGroupGatingParams["cfg"] = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as never,
) {
const admission = msg.admission;
if (!admission) {
throw new Error("Expected admitted WhatsApp test message");
}
return {
cfg,
msg,
groupHistoryKey: `whatsapp:group:${admission.conversation.id}`,
agentId: "main",
sessionKey: `agent:main:whatsapp:group:${admission.conversation.id}`,
baseMentionConfig: { mentionRegexes: [/\bopenclaw\b/i] } satisfies MentionConfig,
groupHistories: new Map<string, GroupHistoryEntry[]>(),
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn(), warn },
};
}
describe("applyGroupGating allowlist drop warning", () => {
beforeEach(() => {
resetGroupDropWarningsForTests();
});
it("emits a warn log naming the root groups path for the default account", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us");
const params = makeParams(msg, warn);
const result = await applyGroupGating(params);
expect(result).toEqual({ shouldProcess: false });
expect(warn).toHaveBeenCalledTimes(1);
expect(params.logVerbose).toHaveBeenCalledWith(
'Dropping message from unregistered WhatsApp group unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "default",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("unregistered@g.us");
expect(message).toContain("channels.whatsapp.groups");
});
it("names the account-scoped groups path for non-default accounts", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
await applyGroupGating(makeParams(msg, warn));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.accounts.work.groups",
});
expect(message).toContain("channels.whatsapp.accounts.work.groups");
});
it("names the root groups path for non-default accounts inheriting root groups", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
const cfg = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as ApplyGroupGatingParams["cfg"];
await applyGroupGating(makeParams(msg, warn, cfg));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("channels.whatsapp.groups");
});
it("warns once but keeps verbose diagnostics per dropped message", async () => {
const warn = vi.fn<WarnLogger>();
const first = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const second = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const third = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
await applyGroupGating(first);
await applyGroupGating(second);
await applyGroupGating(third);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[1]).toContain("loud@g.us");
expect(first.logVerbose).toHaveBeenCalledTimes(1);
expect(second.logVerbose).toHaveBeenCalledTimes(1);
expect(third.logVerbose).toHaveBeenCalledTimes(1);
});
it("warns separately for distinct conversations", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("a@g.us"), warn));
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("b@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0]?.[1]).toContain("a@g.us");
expect(warn.mock.calls[1]?.[1]).toContain("b@g.us");
});
it("evicts old warning keys instead of growing without bound", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
for (let index = 0; index < 100; index += 1) {
await applyGroupGating(makeParams(makeUnregisteredGroupMsg(`overflow-${index}@g.us`), warn));
}
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(102);
expect(warn.mock.calls[0]?.[1]).toContain("evicted@g.us");
expect(warn.mock.calls[101]?.[1]).toContain("evicted@g.us");
});
it("does not warn when the group is registered", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("registered@g.us");
await applyGroupGating(makeParams(msg, warn));
expect(warn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,127 @@
// Whatsapp tests cover group gating.audio preflight plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import { resolveGroupActivationFor } from "./group-activation.js";
import { applyGroupGating, type GroupHistoryEntry } from "./group-gating.js";
function makeGroupAudioMsg(): AdmittedWebInboundMessage {
return createTestWebAudioInboundMessage({
platform: {
chatJid: "1203630@g.us",
sender: { e164: "+15550000002", name: "Alice" },
},
admission: {
conversation: {
kind: "group",
id: "1203630@g.us",
},
sender: {
id: "+15550000002",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
wasMentioned: false,
});
}
function makeParams(
msg: AdmittedWebInboundMessage,
groupHistories: Map<string, GroupHistoryEntry[]>,
) {
return {
cfg: {
channels: {
whatsapp: {
groupPolicy: "open",
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as never,
msg,
groupHistoryKey: "whatsapp:group:1203630",
agentId: "main",
sessionKey: "agent:main:whatsapp:group:1203630",
baseMentionConfig: { mentionRegexes: [/\bopenclaw\b/i] } satisfies MentionConfig,
groupHistories,
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn(), warn: vi.fn() },
};
}
describe("applyGroupGating audio preflight mention text", () => {
let groupHistories: Map<string, GroupHistoryEntry[]>;
beforeEach(() => {
groupHistories = new Map();
});
it("defers a missing mention without storing placeholder history", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
deferMissingMention: true,
});
expect(result).toEqual({ shouldProcess: false, needsMentionText: true });
expect(groupHistories.get("whatsapp:group:1203630")).toBeUndefined();
});
it("accepts voice transcript text that satisfies mention gating", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
mentionText: "openclaw please summarize the thread",
});
expect(result).toEqual({ shouldProcess: true });
expect(msg.groupMention).toEqual({ wasMentioned: true, requireMention: true });
expect(groupHistories.get("whatsapp:group:1203630")).toBeUndefined();
});
it("carries always-on activation into dispatch", async () => {
vi.mocked(resolveGroupActivationFor).mockResolvedValueOnce("always");
const msg = makeGroupAudioMsg();
const result = await applyGroupGating(makeParams(msg, groupHistories));
expect(result).toEqual({ shouldProcess: true });
expect(msg.groupMention).toEqual({ wasMentioned: false, requireMention: false });
});
it("stores transcript text instead of the audio placeholder when mention is still missing", async () => {
const msg = makeGroupAudioMsg();
const result = await applyGroupGating({
...makeParams(msg, groupHistories),
mentionText: "please summarize the thread",
});
expect(result).toEqual({ shouldProcess: false });
expect(groupHistories.get("whatsapp:group:1203630")).toEqual([
{
sender: "Alice (+15550000002)",
body: "please summarize the thread",
timestamp: 1700000000,
id: "msg-1",
senderJid: undefined,
},
]);
});
});

View File

@@ -0,0 +1,9 @@
// Whatsapp plugin module implements group gating behavior.
export {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-mention-gating";
export { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
export { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
export { parseActivationCommand } from "openclaw/plugin-sdk/group-activation";
export { normalizeE164 } from "../../text-runtime.js";

View File

@@ -0,0 +1,277 @@
// Whatsapp plugin module implements group gating behavior.
import type { BuildMentionRegexesOptions } from "openclaw/plugin-sdk/channel-mention-gating";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveWhatsAppGroupsConfigPath } from "../../group-config-path.js";
import {
getPrimaryIdentityId,
getReplyContext,
getSelfIdentity,
getSenderIdentity,
identitiesOverlap,
} from "../../identity.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor } from "./group-activation.js";
import {
hasControlCommand,
implicitMentionKindWhen,
normalizeE164,
parseActivationCommand,
createChannelHistoryWindow,
resolveInboundMentionDecision,
} from "./group-gating.runtime.js";
import { noteGroupMember } from "./group-members.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
type ApplyGroupGatingParams = {
cfg: OpenClawConfig;
msg: AdmittedWebInboundMessage;
mentionText?: string;
deferMissingMention?: boolean;
groupHistoryKey: string;
agentId: string;
sessionKey: string;
baseMentionConfig: MentionConfig;
providerMentionPatterns?: BuildMentionRegexesOptions["providerPolicy"];
authDir?: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryLimit: number;
groupMemberNames: Map<string, Map<string, string>>;
selfChatMode?: boolean;
logVerbose: (msg: string) => void;
replyLogger: {
debug: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
};
const MAX_GROUP_DROP_WARNINGS = 100;
const groupDropWarned = new Set<string>();
export function resetGroupDropWarningsForTests() {
groupDropWarned.clear();
}
function shouldWarnForGroupDrop(warnKey: string): boolean {
if (groupDropWarned.has(warnKey)) {
return false;
}
groupDropWarned.add(warnKey);
while (groupDropWarned.size > MAX_GROUP_DROP_WARNINGS) {
const oldest = groupDropWarned.values().next().value;
if (!oldest) {
break;
}
groupDropWarned.delete(oldest);
}
return true;
}
function isOwnerSender(
baseMentionConfig: MentionConfig,
msg: AdmittedWebInboundMessage,
authDir?: string,
) {
const sender = normalizeE164(getSenderIdentity(msg, authDir).e164 ?? "");
if (!sender) {
return false;
}
const owners = resolveOwnerList(
baseMentionConfig,
getSelfIdentity(msg, authDir).e164 ?? undefined,
);
return owners.includes(sender);
}
function recordPendingGroupHistoryEntry(params: {
msg: AdmittedWebInboundMessage;
body?: string;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string;
groupHistoryLimit: number;
}) {
const senderIdentity = getSenderIdentity(params.msg);
const sender =
senderIdentity.name && senderIdentity.e164
? `${senderIdentity.name} (${senderIdentity.e164})`
: (senderIdentity.name ??
senderIdentity.e164 ??
getPrimaryIdentityId(senderIdentity) ??
"Unknown");
createChannelHistoryWindow({ historyMap: params.groupHistories }).record({
historyKey: params.groupHistoryKey,
limit: params.groupHistoryLimit,
entry: {
sender,
body: params.body ?? params.msg.payload.body,
timestamp: params.msg.event.timestamp,
id: params.msg.event.id,
senderJid: senderIdentity.jid ?? params.msg.platform.senderJid,
},
});
}
function skipGroupMessageAndStoreHistory(
params: ApplyGroupGatingParams,
verboseMessage: string,
body?: string,
) {
params.logVerbose(verboseMessage);
recordPendingGroupHistoryEntry({
msg: params.msg,
body,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
groupHistoryLimit: params.groupHistoryLimit,
});
return { shouldProcess: false } as const;
}
export async function applyGroupGating(params: ApplyGroupGatingParams) {
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg, params.authDir);
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: admission.accountId,
selfE164: self.e164 ?? null,
});
const conversationGroupPolicy = inboundPolicy.resolveConversationGroupPolicy(conversationId);
if (conversationGroupPolicy.allowlistEnabled && !conversationGroupPolicy.allowed) {
const accountId = inboundPolicy.account.accountId;
const warnKey = `${accountId}:${conversationId}`;
if (shouldWarnForGroupDrop(warnKey)) {
const groupsPath = resolveWhatsAppGroupsConfigPath({ cfg: params.cfg, accountId });
params.replyLogger.warn(
{ conversationId, accountId, groupsPath },
`WhatsApp group ${conversationId} not in ${groupsPath} — inbound dropped. Add the group JID to ${groupsPath} (or add "*" there to admit all groups). Sender authorization still applies.`,
);
}
params.logVerbose(
`Dropping message from unregistered WhatsApp group ${conversationId}. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.`,
);
return { shouldProcess: false };
}
noteGroupMember(
params.groupMemberNames,
params.groupHistoryKey,
sender.e164 ?? undefined,
sender.name ?? undefined,
);
const baseMentionConfig = {
...params.baseMentionConfig,
allowFrom: inboundPolicy.configuredAllowFrom,
};
const mentionConfig = {
...buildMentionConfig(params.cfg, params.agentId, {
provider: "whatsapp",
conversationId,
providerPolicy: params.providerMentionPatterns,
}),
allowFrom: inboundPolicy.configuredAllowFrom,
};
const mentionMsg: AdmittedWebInboundMessage =
params.mentionText !== undefined
? { ...params.msg, payload: { ...params.msg.payload, body: params.mentionText } }
: {
...params.msg,
payload: {
...params.msg.payload,
body: params.msg.payload.commandBody ?? params.msg.payload.body,
},
};
const commandBody = stripMentionsForCommand(
mentionMsg.payload.body,
mentionConfig.mentionRegexes,
self.e164,
);
const activationCommand = parseActivationCommand(commandBody);
const owner = isOwnerSender(baseMentionConfig, params.msg, params.authDir);
const shouldBypassMention = owner && hasControlCommand(commandBody, params.cfg);
if (activationCommand.hasCommand && !owner) {
return skipGroupMessageAndStoreHistory(
params,
`Ignoring /activation from non-owner in group ${conversationId}`,
);
}
const mentionDebug = debugMention(mentionMsg, mentionConfig, params.authDir);
params.replyLogger.debug(
{
conversationId,
wasMentioned: mentionDebug.wasMentioned,
...mentionDebug.details,
},
"group mention debug",
);
const wasMentioned = mentionDebug.wasMentioned;
const activation = await resolveGroupActivationFor({
cfg: params.cfg,
accountId: inboundPolicy.account.accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId,
});
const requireMention = activation !== "always";
const replyContext = getReplyContext(params.msg, params.authDir);
const sharedNumberSelfChat = params.selfChatMode === true;
// Detect reply-to-bot: compare JIDs, LIDs, and E.164 numbers.
// WhatsApp may report the quoted message sender as either a phone JID
// (xxxxx@s.whatsapp.net) or a LID (xxxxx@lid), so we compare both.
// But in shared-number/selfChatMode setups, replies from the same self number
// should not count as implicit bot mentions unless the message explicitly
// mentioned the bot in text.
const implicitReplyToSelf = sharedNumberSelfChat && identitiesOverlap(self, sender);
const implicitMentionKinds = implicitMentionKindWhen(
"quoted_bot",
!implicitReplyToSelf && identitiesOverlap(self, replyContext?.sender),
);
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned,
implicitMentionKinds,
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: false,
hasControlCommand: false,
commandAuthorized: false,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned || shouldBypassMention;
// Carry the session activation and mention result together. Dispatch needs
// both facts to distinguish an always-on group from a blocked unmentioned turn.
params.msg.groupMention = { wasMentioned: effectiveWasMentioned, requireMention };
if (!shouldBypassMention && requireMention && mentionDecision.shouldSkip) {
if (params.deferMissingMention === true) {
params.logVerbose(
`Deferring group mention skip until audio preflight completes in ${conversationId}`,
);
return { shouldProcess: false, needsMentionText: true } as const;
}
return skipGroupMessageAndStoreHistory(
params,
`Group message stored for context (no mention detected) in ${conversationId}: ${mentionMsg.payload.body}`,
params.mentionText,
);
}
return { shouldProcess: true };
}

View File

@@ -0,0 +1,57 @@
// Whatsapp tests cover group members plugin behavior.
import { describe, expect, it } from "vitest";
import { formatGroupMembers, noteGroupMember } from "./group-members.js";
describe("noteGroupMember", () => {
it("normalizes member phone numbers before storing", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice");
expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice");
});
it("ignores incomplete member values", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", undefined, "Alice");
noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined);
expect(groupMemberNames.get("g1")).toBeUndefined();
});
});
describe("formatGroupMembers", () => {
it("deduplicates participants and appends named roster members", () => {
const roster = new Map<string, string>([
["+16660000000", "Bob"],
["+17770000000", "Carol"],
]);
const formatted = formatGroupMembers({
participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"],
roster,
});
expect(formatted).toBe("+15550000000, Bob (+16660000000), Carol (+17770000000)");
});
it("falls back to sender when no participants or roster are available", () => {
const formatted = formatGroupMembers({
participants: [],
roster: undefined,
fallbackE164: "+1 (555) 222-3333",
});
expect(formatted).toBe("+15552223333");
});
it("returns undefined when no members can be resolved", () => {
expect(
formatGroupMembers({
participants: [],
roster: undefined,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
// Whatsapp plugin module implements group members behavior.
import { normalizeE164 } from "../../text-runtime.js";
function appendNormalizedUnique(entries: Iterable<string>, seen: Set<string>, ordered: string[]) {
for (const entry of entries) {
const normalized = normalizeE164(entry) ?? entry;
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
ordered.push(normalized);
}
}
export function noteGroupMember(
groupMemberNames: Map<string, Map<string, string>>,
conversationId: string,
e164?: string,
name?: string,
) {
if (!e164 || !name) {
return;
}
const normalized = normalizeE164(e164);
const key = normalized ?? e164;
if (!key) {
return;
}
let roster = groupMemberNames.get(conversationId);
if (!roster) {
roster = new Map();
groupMemberNames.set(conversationId, roster);
}
roster.set(key, name);
}
export function formatGroupMembers(params: {
participants: string[] | undefined;
roster: Map<string, string> | undefined;
fallbackE164?: string;
}) {
const { participants, roster, fallbackE164 } = params;
const seen = new Set<string>();
const ordered: string[] = [];
if (participants?.length) {
appendNormalizedUnique(participants, seen, ordered);
}
if (roster) {
appendNormalizedUnique(roster.keys(), seen, ordered);
}
if (ordered.length === 0 && fallbackE164) {
const normalized = normalizeE164(fallbackE164) ?? fallbackE164;
if (normalized) {
ordered.push(normalized);
}
}
if (ordered.length === 0) {
return undefined;
}
return ordered
.map((entry) => {
const name = roster?.get(entry);
return name ? `${name} (${entry})` : entry;
})
.join(", ");
}

View File

@@ -0,0 +1,105 @@
// Whatsapp tests cover inbound context plugin behavior.
import { describe, expect, it } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
} from "./inbound-context.js";
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
createTestWebInboundMessage({
event: { id },
payload: { body: "Current message" },
platform: {
chatJid: "123@g.us",
recipientJid: "+2000",
senderName: "Alice",
senderJid: "111@s.whatsapp.net",
senderE164: "+111",
selfE164: "+999",
},
admission: {
accountId: "default",
conversation: {
kind: "group",
id: "123@g.us",
},
sender: {
id: "111@s.whatsapp.net",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
quote: {
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
displayName: "Mallory (+999)",
jid: "999@s.whatsapp.net",
},
},
});
describe("whatsapp inbound context visibility", () => {
it("filters non-allowlisted group history from supplemental context", () => {
const history = resolveVisibleWhatsAppGroupHistory({
history: [
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
{
sender: "Mallory (+999)",
body: "Blocked context",
senderJid: "999@s.whatsapp.net",
},
],
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(history).toEqual([
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
]);
});
it("redacts blocked quoted replies in allowlist mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-1"),
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toBeNull();
});
it("keeps blocked quoted replies in allowlist_quote mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-2"),
mode: "allowlist_quote",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toEqual({
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
jid: "999@s.whatsapp.net",
lid: null,
e164: "+999",
label: "Mallory (+999)",
},
});
});
});

View File

@@ -0,0 +1,102 @@
// Whatsapp plugin module implements inbound context behavior.
import { filterChannelInboundQuoteContext } from "openclaw/plugin-sdk/channel-inbound";
import { filterSupplementalContextItems } from "openclaw/plugin-sdk/security-runtime";
import {
getComparableIdentityValues,
getReplyContext,
resolveComparableIdentity,
type WhatsAppIdentity,
type WhatsAppReplyContext,
} from "../../identity.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { normalizeE164 } from "../../text-runtime.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
function isWhatsAppSupplementalSenderAllowed(params: {
allowFrom: string[];
authDir?: string;
sender?: WhatsAppIdentity | null;
}): boolean {
if (params.allowFrom.includes("*")) {
return true;
}
const senderValues = new Set(
getComparableIdentityValues(resolveComparableIdentity(params.sender, params.authDir)),
);
if (senderValues.size === 0) {
return false;
}
for (const entry of params.allowFrom) {
const rawEntry = entry.trim();
if (!rawEntry) {
continue;
}
const normalizedEntry = normalizeE164(rawEntry);
if ((normalizedEntry && senderValues.has(normalizedEntry)) || senderValues.has(rawEntry)) {
return true;
}
}
return false;
}
export function resolveVisibleWhatsAppGroupHistory(params: {
authDir?: string;
history: GroupHistoryEntry[];
mode: ContextVisibilityMode;
groupPolicy: "open" | "allowlist" | "disabled";
groupAllowFrom: string[];
}): GroupHistoryEntry[] {
if (params.groupPolicy !== "allowlist") {
return params.history;
}
return filterSupplementalContextItems({
items: params.history,
mode: params.mode,
kind: "history",
isSenderAllowed: (entry) =>
isWhatsAppSupplementalSenderAllowed({
allowFrom: params.groupAllowFrom,
authDir: params.authDir,
sender: entry.senderJid ? { jid: entry.senderJid } : null,
}),
}).items;
}
export function resolveVisibleWhatsAppReplyContext(params: {
msg: AdmittedWebInboundMessage;
authDir?: string;
mode: ContextVisibilityMode;
groupPolicy: "open" | "allowlist" | "disabled";
groupAllowFrom: string[];
}): WhatsAppReplyContext | null {
const replyTo = getReplyContext(params.msg, params.authDir);
if (!replyTo) {
return null;
}
const admission = requireWhatsAppInboundAdmission(params.msg);
const senderAllowed =
admission.conversation.kind !== "group" || params.groupPolicy !== "allowlist"
? true
: isWhatsAppSupplementalSenderAllowed({
allowFrom: params.groupAllowFrom,
authDir: params.authDir,
sender: replyTo.sender,
});
const visible = filterChannelInboundQuoteContext(params.mode, {
id: replyTo.id,
body: replyTo.body,
sender: replyTo.sender?.label ?? undefined,
senderAllowed,
});
return visible ? replyTo : null;
}

View File

@@ -0,0 +1,23 @@
// Whatsapp plugin module implements inbound dispatch behavior.
export {
createChannelMessageReplyPipeline,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
resolveMarkdownTableMode,
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,
type ReplyPayload,
type resolveAgentRoute,
} from "./runtime-api.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,906 @@
// Whatsapp plugin module implements inbound dispatch behavior.
import {
DEFAULT_TIMING,
type StatusReactionController,
} from "openclaw/plugin-sdk/channel-feedback";
import {
buildChannelInboundEventContext,
type CommandTurnContext,
toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-outbound";
import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import {
type DeliverableWhatsAppOutboundPayload,
normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation,
} from "../../outbound-media-contract.js";
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
import { markWhatsAppVisibleDeliveryError } from "../util.js";
import { formatGroupMembers } from "./group-members.js";
import type { GroupHistoryEntry } from "./inbound-context.js";
import {
createChannelMessageReplyPipeline,
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContext,
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
resolveMarkdownTableMode,
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,
type ReplyPayload,
type resolveAgentRoute,
} from "./inbound-dispatch.runtime.js";
type ReplyLifecycleKind = "tool" | "block" | "final";
type ChannelReplyOnModelSelected = NonNullable<
ReturnType<typeof createChannelMessageReplyPipeline>["onModelSelected"]
>;
type WhatsAppDispatchPipeline = {
responsePrefix?: string;
} & Record<string, unknown>;
type VisibleReplyTarget = {
id?: string;
body?: string;
sender?: {
label?: string | null;
} | null;
};
type ReplyThreadingContext = {
implicitCurrentMessage?: "default" | "allow" | "deny";
};
type SenderContext = {
id?: string;
name?: string;
e164?: string;
};
type ReplyDeliveryInfo = { kind: ReplyLifecycleKind };
type PendingWhatsAppMediaOnlyPayload = {
info: ReplyDeliveryInfo;
mediaUrls: Set<string>;
payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
};
type WhatsAppMediaOnlyFlushResult = {
delivered: number;
droppedDuplicateMedia: number;
};
function normalizeErrForLog(err: unknown): unknown {
if (err instanceof Error) {
const ownEnumerableProps = Object.fromEntries(Object.entries(err));
return { ...ownEnumerableProps, type: err.name, message: err.message, stack: err.stack };
}
return err;
}
type WhatsAppReplyDeliveryVisibility = {
visibleReplySent: boolean;
};
function whatsAppReplyDeliveryVisibility(
visibleReplySent: boolean,
): WhatsAppReplyDeliveryVisibility {
return { visibleReplySent };
}
function whatsAppReplyDeliveryVisibilityFromDurableResult(result: {
visibleReplySent?: boolean;
}): WhatsAppReplyDeliveryVisibility {
return whatsAppReplyDeliveryVisibility(result.visibleReplySent === true);
}
function readTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function markWhatsAppReplyDeliveryErrorVisibleAfterFlush(
error: unknown,
flushResult: WhatsAppMediaOnlyFlushResult,
): unknown {
return flushResult.delivered > 0 ? markWhatsAppVisibleDeliveryError(error) : error;
}
function logWhatsAppReplyDeliveryError(params: {
err: unknown;
info: ReplyDeliveryInfo;
connectionId: string;
msg: AdmittedWebInboundMessage;
replyLogger: ReturnType<typeof getChildLogger>;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
params.replyLogger.error(
{
err: normalizeErrForLog(params.err),
replyKind: params.info.kind,
correlationId: params.msg.event.id ?? null,
connectionId: params.connectionId,
conversationId: admission.conversation.id,
chatId: params.msg.platform.chatJid ?? null,
to: admission.conversation.id,
from: params.msg.platform.recipientJid ?? null,
},
"auto-reply delivery failed",
);
}
function resolveWhatsAppDurableReplyToId(params: {
context: Record<string, unknown>;
info: ReplyDeliveryInfo;
msg: AdmittedWebInboundMessage;
payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
}): string | null {
if (params.payload.replyToId === null) {
return null;
}
const explicitPayloadReplyToId = readTrimmedString(params.payload.replyToId);
if (explicitPayloadReplyToId) {
return explicitPayloadReplyToId;
}
const hasVisibleInboundReplyTarget =
Boolean(readTrimmedString(params.context.ReplyToId)) ||
Boolean(readTrimmedString(params.context.ReplyToIdFull));
const currentInboundMessageId = readTrimmedString(params.msg.event.id);
if (params.info.kind === "final" && hasVisibleInboundReplyTarget && currentInboundMessageId) {
return currentInboundMessageId;
}
return null;
}
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
return undefined;
}
return !cfg.channels.whatsapp.blockStreaming;
}
function resolveWhatsAppDeliverablePayload(
payload: ReplyPayload,
info: { kind: ReplyLifecycleKind },
): ReplyPayload | null {
if (payload.isReasoning === true || payload.isCompactionNotice === true) {
return null;
}
if (payload.isError === true) {
return null;
}
if (info.kind === "tool") {
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
return null;
}
return { ...payload, text: undefined };
}
return payload;
}
function getWhatsAppPayloadMediaUrls(payload: ReplyPayload): Set<string> {
return new Set(
normalizeStringEntries([
...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []),
]),
);
}
function hasWhatsAppMediaUrlOverlap(left: Set<string>, right: Set<string>): boolean {
for (const url of left) {
if (right.has(url)) {
return true;
}
}
return false;
}
function shouldDeferWhatsAppMediaOnlyPayload(params: {
info: ReplyDeliveryInfo;
mediaUrls: Set<string>;
reply: ReturnType<typeof resolveSendableOutboundReplyParts>;
}): boolean {
return (
params.info.kind !== "final" &&
params.reply.hasMedia &&
!params.reply.text.trim() &&
params.mediaUrls.size > 0
);
}
function createWhatsAppMediaOnlyReplyCoalescer(params: {
deliver: (pending: PendingWhatsAppMediaOnlyPayload) => Promise<WhatsAppReplyDeliveryVisibility>;
}) {
const pendingMediaOnlyPayloads: PendingWhatsAppMediaOnlyPayload[] = [];
const flushExceptDuplicateMedia = async (
mediaUrls?: Set<string>,
): Promise<WhatsAppMediaOnlyFlushResult> => {
const flushResult: WhatsAppMediaOnlyFlushResult = {
delivered: 0,
droppedDuplicateMedia: 0,
};
const pending = pendingMediaOnlyPayloads.splice(0);
for (const candidate of pending) {
if (mediaUrls && hasWhatsAppMediaUrlOverlap(candidate.mediaUrls, mediaUrls)) {
flushResult.droppedDuplicateMedia += 1;
continue;
}
try {
const delivery = await params.deliver(candidate);
if (delivery.visibleReplySent) {
flushResult.delivered += 1;
}
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
}
return flushResult;
};
return {
defer(pending: PendingWhatsAppMediaOnlyPayload) {
pendingMediaOnlyPayloads.push(pending);
},
flushExceptDuplicateMedia,
flushAll: () => flushExceptDuplicateMedia(),
};
}
function logWhatsAppMediaOnlyFlushResult(result: WhatsAppMediaOnlyFlushResult) {
if (!shouldLogVerbose()) {
return;
}
if (result.droppedDuplicateMedia > 0) {
logVerbose(
`Dropped ${result.droppedDuplicateMedia} deferred media-only WhatsApp reply payload(s) superseded by captioned media`,
);
}
if (result.delivered > 0) {
logVerbose(`Flushed ${result.delivered} deferred media-only WhatsApp reply payload(s)`);
}
}
export function resolveWhatsAppResponsePrefix(params: {
cfg: ReturnType<LoadConfigFn>;
agentId: string;
isSelfChat: boolean;
pipelineResponsePrefix?: string;
}): string | undefined {
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
return (
params.pipelineResponsePrefix ??
(configuredResponsePrefix === undefined && params.isSelfChat
? resolveIdentityNamePrefix(params.cfg, params.agentId)
: undefined)
);
}
export async function buildWhatsAppInboundContext(params: {
bodyForAgent?: string;
combinedBody: string;
commandBody?: string;
commandAuthorized?: boolean;
commandTurn?: CommandTurnContext;
commandSource?: "text";
groupHistory?: GroupHistoryEntry[];
groupMemberRoster?: Map<string, string>;
groupSystemPrompt?: string;
msg: AdmittedWebInboundMessage;
rawBody?: string;
route: ReturnType<typeof resolveAgentRoute>;
sender: SenderContext;
transcript?: string;
mediaTranscribedIndexes?: number[];
replyThreading?: ReplyThreadingContext;
visibleReplyTo?: VisibleReplyTarget;
suppressMessageReceivedHooks?: boolean;
}): Promise<FinalizedMsgContext> {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const wasMentioned = params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned;
const inboundHistory =
conversationKind === "group"
? buildInboundHistoryFromEntries({
entries: (params.groupHistory ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
messageId: entry.id,
})),
limit: params.groupHistory?.length ?? 1,
})
: undefined;
const media = toInboundMediaFacts(
params.msg.payload.media?.path || params.msg.payload.media?.url
? [
{
path: params.msg.payload.media?.path,
url: params.msg.payload.media?.url ?? params.msg.payload.media?.path,
contentType: params.msg.payload.media?.type,
},
]
: undefined,
{ transcribed: (_entry, index) => params.mediaTranscribedIndexes?.includes(index) === true },
);
return buildChannelInboundEventContext({
channel: "whatsapp",
finalize: finalizeInboundContext,
supplemental: {
quote: params.visibleReplyTo
? {
id: params.visibleReplyTo.id,
body: params.visibleReplyTo.body,
sender: params.visibleReplyTo.sender?.label ?? undefined,
}
: undefined,
groupSystemPrompt: params.groupSystemPrompt,
untrustedContext: params.msg.payload.untrustedStructuredContext,
},
media,
messageId: params.msg.event.id,
timestamp: params.msg.event.timestamp,
from: conversationId,
sender: {
id: params.sender.id ?? params.sender.e164,
name: params.sender.name,
},
conversation: {
kind: conversationKind,
id: conversationId,
label: conversationId,
},
route: {
agentId: params.route.agentId,
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
},
reply: {
to: params.msg.platform.recipientJid,
originatingTo: conversationId,
},
message: {
body: params.combinedBody,
bodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
inboundHistory,
rawBody: params.rawBody ?? params.msg.payload.body,
commandBody: params.commandBody ?? params.msg.payload.body,
},
access: {
...(wasMentioned !== undefined
? {
mentions: {
canDetectMention: conversationKind === "group",
wasMentioned,
requireMention: params.msg.groupMention?.requireMention,
},
}
: {}),
commands: {
authorized: params.commandAuthorized,
},
},
commandTurn: params.commandTurn,
extra: {
Transcript: params.transcript,
GroupSubject: params.msg.group?.subject,
GroupMembers: formatGroupMembers({
participants: params.msg.group?.participants,
roster: params.groupMemberRoster,
fallbackE164: params.sender.e164,
}),
SenderE164: params.sender.e164,
CommandSource:
params.commandSource ??
(params.commandTurn?.source === "native" || params.commandTurn?.source === "text"
? params.commandTurn.source
: undefined),
ReplyThreading: params.replyThreading,
SuppressMessageReceivedHooks: params.suppressMessageReceivedHooks,
...(params.msg.payload.location ? toLocationContext(params.msg.payload.location) : {}),
},
});
}
function normalizeCommandTurnFromContext(value: unknown): CommandTurnContext | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Partial<CommandTurnContext>;
const kind = record.kind;
const source = record.source;
if (kind === "native" && source === "native" && typeof record.authorized === "boolean") {
return {
kind: "native",
source: "native",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "text-slash" && source === "text" && typeof record.authorized === "boolean") {
return {
kind: "text-slash",
source: "text",
authorized: record.authorized,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
if (kind === "normal" && source === "message") {
return {
kind: "normal",
source: "message",
authorized: false,
commandName: typeof record.commandName === "string" ? record.commandName : undefined,
body: typeof record.body === "string" ? record.body : undefined,
};
}
return undefined;
}
export function resolveWhatsAppDmRouteTarget(params: {
msg: AdmittedWebInboundMessage;
senderE164?: string;
normalizeE164: (value: string) => string | null;
}): string | undefined {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
if (admission.conversation.kind === "group") {
return undefined;
}
if (params.senderE164) {
return params.normalizeE164(params.senderE164) ?? undefined;
}
if (conversationId.includes("@")) {
return jidToE164(conversationId) ?? undefined;
}
return params.normalizeE164(conversationId) ?? undefined;
}
export function updateWhatsAppMainLastRoute(params: {
backgroundTasks: Set<Promise<unknown>>;
cfg: ReturnType<LoadConfigFn>;
ctx: Record<string, unknown>;
dmRouteTarget?: string;
pinnedMainDmRecipient: string | null;
route: ReturnType<typeof resolveAgentRoute>;
updateLastRoute: (params: {
cfg: ReturnType<LoadConfigFn>;
backgroundTasks: Set<Promise<unknown>>;
storeAgentId: string;
sessionKey: string;
channel: "whatsapp";
to: string;
accountId?: string;
ctx: Record<string, unknown>;
warn: ReturnType<typeof getChildLogger>["warn"];
}) => void;
warn: ReturnType<typeof getChildLogger>["warn"];
}) {
const shouldUpdateMainLastRoute =
!params.pinnedMainDmRecipient || params.pinnedMainDmRecipient === params.dmRouteTarget;
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: params.route,
sessionKey: params.route.sessionKey,
});
if (
params.dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
shouldUpdateMainLastRoute
) {
params.updateLastRoute({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to: params.dmRouteTarget,
accountId: params.route.accountId,
ctx: params.ctx,
warn: params.warn,
});
return;
}
if (
params.dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
params.pinnedMainDmRecipient
) {
logVerbose(
`Skipping main-session last route update for ${params.dmRouteTarget} (pinned owner ${params.pinnedMainDmRecipient})`,
);
}
}
export async function dispatchWhatsAppBufferedReply(params: {
cfg: ReturnType<LoadConfigFn>;
connectionId: string;
context: Record<string, unknown>;
deliverReply: (params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
mediaLocalRoots: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ReturnType<typeof resolveChunkMode>;
replyLogger: ReturnType<typeof getChildLogger>;
connectionId?: string;
skipLog?: boolean;
tableMode?: ReturnType<typeof resolveMarkdownTableMode>;
}) => Promise<WhatsAppReplyDeliveryResult>;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string;
maxMediaBytes: number;
maxMediaTextChunkLimit?: number;
msg: AdmittedWebInboundMessage;
onModelSelected?: ChannelReplyOnModelSelected;
rememberSentText: (
text: string | undefined,
opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => void;
replyLogger: ReturnType<typeof getChildLogger>;
replyPipeline: WhatsAppDispatchPipeline;
replyResolver: typeof getReplyFromConfig;
route: ReturnType<typeof resolveAgentRoute>;
shouldClearGroupHistory: boolean;
statusReactionController?: StatusReactionController | null;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const statusReactionController = params.statusReactionController ?? null;
const statusReactionTiming = {
...DEFAULT_TIMING,
...params.cfg.messages?.statusReactions?.timing,
};
const removeAckAfterReply = params.cfg.messages?.removeAckAfterReply ?? false;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
const sourceReplyChatType =
typeof params.context.ChatType === "string" ? params.context.ChatType : conversationKind;
const sourceReplyCommandSource =
params.context.CommandSource === "native" || params.context.CommandSource === "text"
? params.context.CommandSource
: undefined;
const sourceReplyCommandTurn = normalizeCommandTurnFromContext(params.context.CommandTurn);
const sourceReplyCommandAuthorized =
typeof params.context.CommandAuthorized === "boolean"
? params.context.CommandAuthorized
: undefined;
const sourceReplyDeliveryMode =
sourceReplyChatType === "group" || sourceReplyChatType === "channel"
? resolveChannelMessageSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: {
ChatType: sourceReplyChatType,
CommandTurn: sourceReplyCommandTurn,
CommandSource: sourceReplyCommandSource,
CommandAuthorized: sourceReplyCommandAuthorized,
},
})
: undefined;
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const disableBlockStreaming = sourceRepliesAreToolOnly
? true
: resolveWhatsAppDisableBlockStreaming(params.cfg);
let didSendReply = false;
let didLogHeartbeatStrip = false;
const deliverNormalizedPayload = async (
normalizedDeliveryPayload: DeliverableWhatsAppOutboundPayload<ReplyPayload>,
info: ReplyDeliveryInfo,
): Promise<WhatsAppReplyDeliveryVisibility> => {
const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
if (!reply.hasMedia && !reply.text.trim()) {
return whatsAppReplyDeliveryVisibility(false);
}
const delivery = await params.deliverReply({
replyResult: normalizedDeliveryPayload,
normalizedReplyResult: normalizedDeliveryPayload,
msg: params.msg,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
textLimit,
chunkMode,
replyLogger: params.replyLogger,
connectionId: params.connectionId,
skipLog: false,
tableMode,
});
if (!delivery.providerAccepted) {
params.replyLogger.warn(
{
correlationId: params.msg.event.id ?? null,
connectionId: params.connectionId,
conversationId,
chatId: params.msg.platform.chatJid,
to: conversationId,
from: params.msg.platform.recipientJid,
replyKind: info.kind,
},
"auto-reply was not accepted by WhatsApp provider",
);
return whatsAppReplyDeliveryVisibility(false);
}
didSendReply = true;
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
params.rememberSentText(normalizedDeliveryPayload.text, {
combinedBody: params.context.Body as string | undefined,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
const fromDisplay = conversationId;
if (shouldLogVerbose()) {
const preview = normalizedDeliveryPayload.text != null ? reply.text : "<media>";
logVerbose(`Reply body: ${preview}${reply.hasMedia ? " (media)" : ""} -> ${fromDisplay}`);
}
return whatsAppReplyDeliveryVisibility(true);
};
const mediaOnlyCoalescer = createWhatsAppMediaOnlyReplyCoalescer({
deliver: async (pending) => {
return await deliverNormalizedPayload(pending.payload, pending.info);
},
});
if (statusReactionController) {
void statusReactionController.setThinking();
}
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
ctx: params.context,
cfg: params.cfg,
replyResolver: params.replyResolver,
dispatcherOptions: {
...params.replyPipeline,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
},
deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => {
const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info);
if (!deliveryPayload) {
return whatsAppReplyDeliveryVisibility(false);
}
const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, {
normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
});
const normalizedDeliveryPayload =
deliveryPayload.text === undefined
? { ...normalizedOutboundPayload, text: undefined }
: normalizedOutboundPayload;
const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload);
if (!reply.hasMedia && !reply.text.trim()) {
return whatsAppReplyDeliveryVisibility(false);
}
if (!reply.hasMedia) {
const flushResult = await mediaOnlyCoalescer.flushAll();
logWhatsAppMediaOnlyFlushResult(flushResult);
try {
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
agentId: params.route.agentId,
ctxPayload: params.context as FinalizedMsgContext,
payload: normalizedDeliveryPayload,
info,
to: conversationId,
replyToId: resolveWhatsAppDurableReplyToId({
context: params.context,
info,
msg: params.msg,
payload: normalizedDeliveryPayload,
}),
formatting: {
textLimit,
tableMode,
chunkMode,
},
});
if (durable.status === "failed") {
if (durable.sentBeforeError === true) {
throw markWhatsAppVisibleDeliveryError(durable.error);
}
throw durable.error;
}
if (durable.status === "handled_visible") {
didSendReply = true;
const shouldLog = normalizedDeliveryPayload.text ? true : undefined;
params.rememberSentText(normalizedDeliveryPayload.text, {
combinedBody: params.context.Body as string | undefined,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
return whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery);
}
if (durable.status === "handled_no_send") {
return flushResult.delivered > 0
? whatsAppReplyDeliveryVisibility(true)
: whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery);
}
const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info);
return flushResult.delivered > 0 && !delivery.visibleReplySent
? whatsAppReplyDeliveryVisibility(true)
: delivery;
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
}
const mediaUrls = getWhatsAppPayloadMediaUrls(normalizedDeliveryPayload);
if (shouldDeferWhatsAppMediaOnlyPayload({ info, mediaUrls, reply })) {
mediaOnlyCoalescer.defer({
info,
mediaUrls,
payload: normalizedDeliveryPayload,
});
return whatsAppReplyDeliveryVisibility(false);
}
const flushResult = await mediaOnlyCoalescer.flushExceptDuplicateMedia(mediaUrls);
logWhatsAppMediaOnlyFlushResult(flushResult);
try {
const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info);
return flushResult.delivered > 0 && !delivery.visibleReplySent
? whatsAppReplyDeliveryVisibility(true)
: delivery;
} catch (error: unknown) {
throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult);
}
},
onSettled: async () => {
const flushResult = await mediaOnlyCoalescer.flushAll();
logWhatsAppMediaOnlyFlushResult(flushResult);
return whatsAppReplyDeliveryVisibility(flushResult.delivered > 0);
},
onReplyStart: params.msg.platform.sendComposing,
...(statusReactionController
? {
onCompactionStart: async () => {
await statusReactionController.setCompacting();
},
onCompactionEnd: async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
},
}
: {}),
onError: (err, info) => {
logWhatsAppReplyDeliveryError({
err,
info,
connectionId: params.connectionId,
msg: params.msg,
replyLogger: params.replyLogger,
});
},
},
replyOptions: {
// Message-tool-only unmentioned group turns have no automatic visible reply.
// Suppress composing there so silent background runs do not leak presence.
suppressTyping:
sourceRepliesAreToolOnly &&
conversationKind === "group" &&
!(params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned),
disableBlockStreaming,
...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}),
onModelSelected: params.onModelSelected,
...(statusReactionController
? {
onToolStart: async (payload: { name?: string }) => {
const toolName = payload.name?.trim();
if (toolName) {
await statusReactionController.setTool(toolName);
}
},
}
: {}),
},
});
const didQueueVisibleReply = hasVisibleInboundReplyDispatch(dispatchResult);
const didDeliverVisibleReply = didSendReply || dispatchResult.observedReplyDelivery === true;
if (!didQueueVisibleReply) {
if (statusReactionController) {
void finalizeWhatsAppStatusReaction({
controller: statusReactionController,
outcome: "error",
hasFinalResponse: false,
removeAckAfterReply,
timing: statusReactionTiming,
});
}
if (params.shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver");
return false;
}
if (statusReactionController) {
void finalizeWhatsAppStatusReaction({
controller: statusReactionController,
outcome: didDeliverVisibleReply ? "done" : "error",
hasFinalResponse: didDeliverVisibleReply,
removeAckAfterReply,
timing: statusReactionTiming,
});
}
if (params.shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return didDeliverVisibleReply;
}
async function finalizeWhatsAppStatusReaction(params: {
controller: StatusReactionController;
outcome: "done" | "error";
hasFinalResponse: boolean;
removeAckAfterReply: boolean;
timing: typeof DEFAULT_TIMING;
}): Promise<void> {
if (params.outcome === "done") {
await params.controller.setDone();
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.doneHoldMs);
});
await params.controller.clear();
} else {
await params.controller.restoreInitial();
}
return;
}
await params.controller.setError();
if (params.hasFinalResponse) {
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.errorHoldMs);
});
await params.controller.clear();
} else {
await params.controller.restoreInitial();
}
return;
}
if (params.removeAckAfterReply) {
await new Promise<void>((resolve) => {
setTimeout(resolve, params.timing.errorHoldMs);
});
}
await params.controller.restoreInitial();
}

Some files were not shown because too many files have changed in this diff Show More