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
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:
11
extensions/whatsapp/README.md
Normal file
11
extensions/whatsapp/README.md
Normal 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.
|
||||
2
extensions/whatsapp/action-runtime-api.ts
Normal file
2
extensions/whatsapp/action-runtime-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export { handleWhatsAppAction } from "./src/action-runtime.js";
|
||||
2
extensions/whatsapp/action-runtime.runtime.ts
Normal file
2
extensions/whatsapp/action-runtime.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements action runtime behavior.
|
||||
export { handleWhatsAppAction } from "./src/action-runtime.js";
|
||||
72
extensions/whatsapp/api.ts
Normal file
72
extensions/whatsapp/api.ts
Normal 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";
|
||||
81
extensions/whatsapp/auth-presence.ts
Normal file
81
extensions/whatsapp/auth-presence.ts
Normal 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));
|
||||
}
|
||||
2
extensions/whatsapp/call-tool-api.ts
Normal file
2
extensions/whatsapp/call-tool-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// WhatsApp call tool facade keeps the bundled entrypoint light during discovery.
|
||||
export { registerWhatsAppCallTool } from "./src/agent-tools-call.js";
|
||||
2
extensions/whatsapp/channel-config-api.ts
Normal file
2
extensions/whatsapp/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export { WhatsAppChannelConfigSchema } from "./src/config-schema.js";
|
||||
3
extensions/whatsapp/channel-plugin-api.ts
Normal file
3
extensions/whatsapp/channel-plugin-api.ts
Normal 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";
|
||||
5
extensions/whatsapp/config-api.ts
Normal file
5
extensions/whatsapp/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
WhatsAppConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
2
extensions/whatsapp/constants.ts
Normal file
2
extensions/whatsapp/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements constants behavior.
|
||||
export { DEFAULT_WEB_MEDIA_BYTES } from "./src/auto-reply/constants.js";
|
||||
22
extensions/whatsapp/contract-api.ts
Normal file
22
extensions/whatsapp/contract-api.ts
Normal 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;
|
||||
15
extensions/whatsapp/directory-contract-api.ts
Normal file
15
extensions/whatsapp/directory-contract-api.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
9
extensions/whatsapp/doctor-contract-api.ts
Normal file
9
extensions/whatsapp/doctor-contract-api.ts
Normal 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[] = [];
|
||||
22
extensions/whatsapp/index.test.ts
Normal file
22
extensions/whatsapp/index.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
33
extensions/whatsapp/index.ts
Normal file
33
extensions/whatsapp/index.ts
Normal 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,
|
||||
});
|
||||
7
extensions/whatsapp/legacy-session-surface-api.ts
Normal file
7
extensions/whatsapp/legacy-session-surface-api.ts
Normal 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,
|
||||
};
|
||||
2
extensions/whatsapp/legacy-state-migrations-api.ts
Normal file
2
extensions/whatsapp/legacy-state-migrations-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
14
extensions/whatsapp/light-runtime-api.ts
Normal file
14
extensions/whatsapp/light-runtime-api.ts
Normal 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";
|
||||
2
extensions/whatsapp/login-qr-api.ts
Normal file
2
extensions/whatsapp/login-qr-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export { startWebLoginWithQr, waitForWebLogin } from "./login-qr-runtime.js";
|
||||
20
extensions/whatsapp/login-qr-runtime.ts
Normal file
20
extensions/whatsapp/login-qr-runtime.ts
Normal 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
958
extensions/whatsapp/npm-shrinkwrap.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
extensions/whatsapp/openclaw.plugin.json
Normal file
30
extensions/whatsapp/openclaw.plugin.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
extensions/whatsapp/outbound-payload-test-api.ts
Normal file
2
extensions/whatsapp/outbound-payload-test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export { whatsappOutbound } from "./src/outbound-adapter.js";
|
||||
73
extensions/whatsapp/package.json
Normal file
73
extensions/whatsapp/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
89
extensions/whatsapp/runtime-api.ts
Normal file
89
extensions/whatsapp/runtime-api.ts
Normal 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";
|
||||
3
extensions/whatsapp/runtime-setter-api.ts
Normal file
3
extensions/whatsapp/runtime-setter-api.ts
Normal 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";
|
||||
4
extensions/whatsapp/secret-contract-api.ts
Normal file
4
extensions/whatsapp/secret-contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// WhatsApp does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
5
extensions/whatsapp/security-contract-api.ts
Normal file
5
extensions/whatsapp/security-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Whatsapp API module exposes the plugin public contract.
|
||||
export {
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
} from "./src/security-contract.js";
|
||||
76
extensions/whatsapp/setup-entry.test.ts
Normal file
76
extensions/whatsapp/setup-entry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
extensions/whatsapp/setup-entry.ts
Normal file
22
extensions/whatsapp/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
extensions/whatsapp/setup-plugin-api.ts
Normal file
3
extensions/whatsapp/setup-plugin-api.ts
Normal 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";
|
||||
72
extensions/whatsapp/skills/wacli/SKILL.md
Normal file
72
extensions/whatsapp/skills/wacli/SKILL.md
Normal 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).
|
||||
BIN
extensions/whatsapp/src/__fixtures__/large-noisy.webp
Normal file
BIN
extensions/whatsapp/src/__fixtures__/large-noisy.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 634 KiB |
78
extensions/whatsapp/src/account-config.ts
Normal file
78
extensions/whatsapp/src/account-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
extensions/whatsapp/src/account-ids.ts
Normal file
18
extensions/whatsapp/src/account-ids.ts
Normal 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,
|
||||
};
|
||||
6
extensions/whatsapp/src/account-types.ts
Normal file
6
extensions/whatsapp/src/account-types.ts
Normal 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];
|
||||
205
extensions/whatsapp/src/accounts.test.ts
Normal file
205
extensions/whatsapp/src/accounts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
175
extensions/whatsapp/src/accounts.ts
Normal file
175
extensions/whatsapp/src/accounts.ts
Normal 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);
|
||||
}
|
||||
94
extensions/whatsapp/src/accounts.whatsapp-auth.test.ts
Normal file
94
extensions/whatsapp/src/accounts.whatsapp-auth.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
28
extensions/whatsapp/src/action-runtime-target-auth.ts
Normal file
28
extensions/whatsapp/src/action-runtime-target-auth.ts
Normal 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 };
|
||||
}
|
||||
323
extensions/whatsapp/src/action-runtime.test.ts
Normal file
323
extensions/whatsapp/src/action-runtime.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
77
extensions/whatsapp/src/action-runtime.ts
Normal file
77
extensions/whatsapp/src/action-runtime.ts
Normal 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}`);
|
||||
}
|
||||
66
extensions/whatsapp/src/active-listener.test.ts
Normal file
66
extensions/whatsapp/src/active-listener.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
extensions/whatsapp/src/active-listener.ts
Normal file
18
extensions/whatsapp/src/active-listener.ts
Normal 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;
|
||||
}
|
||||
308
extensions/whatsapp/src/agent-tools-call.test.ts
Normal file
308
extensions/whatsapp/src/agent-tools-call.test.ts
Normal 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'`);
|
||||
});
|
||||
});
|
||||
362
extensions/whatsapp/src/agent-tools-call.ts
Normal file
362
extensions/whatsapp/src/agent-tools-call.ts
Normal 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,
|
||||
};
|
||||
115
extensions/whatsapp/src/agent-tools-login.test.ts
Normal file
115
extensions/whatsapp/src/agent-tools-login.test.ts
Normal 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:",
|
||||
"",
|
||||
"",
|
||||
].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,
|
||||
});
|
||||
});
|
||||
});
|
||||
112
extensions/whatsapp/src/agent-tools-login.ts
Normal file
112
extensions/whatsapp/src/agent-tools-login.ts
Normal 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:",
|
||||
"",
|
||||
``,
|
||||
].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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
62
extensions/whatsapp/src/approval-auth.test.ts
Normal file
62
extensions/whatsapp/src/approval-auth.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
64
extensions/whatsapp/src/approval-auth.ts
Normal file
64
extensions/whatsapp/src/approval-auth.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
160
extensions/whatsapp/src/approval-handler.runtime.test.ts
Normal file
160
extensions/whatsapp/src/approval-handler.runtime.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
170
extensions/whatsapp/src/approval-handler.runtime.ts
Normal file
170
extensions/whatsapp/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
483
extensions/whatsapp/src/approval-native.test.ts
Normal file
483
extensions/whatsapp/src/approval-native.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
274
extensions/whatsapp/src/approval-native.ts
Normal file
274
extensions/whatsapp/src/approval-native.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
454
extensions/whatsapp/src/approval-reactions.test.ts
Normal file
454
extensions/whatsapp/src/approval-reactions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
400
extensions/whatsapp/src/approval-reactions.ts
Normal file
400
extensions/whatsapp/src/approval-reactions.ts
Normal 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();
|
||||
}
|
||||
24
extensions/whatsapp/src/approval-resolver.ts
Normal file
24
extensions/whatsapp/src/approval-resolver.ts
Normal 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"})`,
|
||||
});
|
||||
}
|
||||
57
extensions/whatsapp/src/auth-store.lazy-dir.test.ts
Normal file
57
extensions/whatsapp/src/auth-store.lazy-dir.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
2
extensions/whatsapp/src/auth-store.runtime.ts
Normal file
2
extensions/whatsapp/src/auth-store.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements auth store behavior.
|
||||
export { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
|
||||
430
extensions/whatsapp/src/auth-store.test.ts
Normal file
430
extensions/whatsapp/src/auth-store.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
483
extensions/whatsapp/src/auth-store.ts
Normal file
483
extensions/whatsapp/src/auth-store.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
7
extensions/whatsapp/src/auto-reply.impl.ts
Normal file
7
extensions/whatsapp/src/auto-reply.impl.ts
Normal 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";
|
||||
469
extensions/whatsapp/src/auto-reply.test-harness.ts
Normal file
469
extensions/whatsapp/src/auto-reply.test-harness.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
2
extensions/whatsapp/src/auto-reply.ts
Normal file
2
extensions/whatsapp/src/auto-reply.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements auto reply behavior.
|
||||
export * from "./auto-reply.impl.js";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
17
extensions/whatsapp/src/auto-reply/config.runtime.ts
Normal file
17
extensions/whatsapp/src/auto-reply/config.runtime.ts
Normal 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";
|
||||
2
extensions/whatsapp/src/auto-reply/constants.ts
Normal file
2
extensions/whatsapp/src/auto-reply/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements constants behavior.
|
||||
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||
1014
extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
Normal file
1014
extensions/whatsapp/src/auto-reply/deliver-reply.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
365
extensions/whatsapp/src/auto-reply/deliver-reply.ts
Normal file
365
extensions/whatsapp/src/auto-reply/deliver-reply.ts
Normal 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();
|
||||
}
|
||||
7
extensions/whatsapp/src/auto-reply/loggers.ts
Normal file
7
extensions/whatsapp/src/auto-reply/loggers.ts
Normal 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");
|
||||
141
extensions/whatsapp/src/auto-reply/mentions.ts
Normal file
141
extensions/whatsapp/src/auto-reply/mentions.ts
Normal 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));
|
||||
}
|
||||
136
extensions/whatsapp/src/auto-reply/monitor-state.test.ts
Normal file
136
extensions/whatsapp/src/auto-reply/monitor-state.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
128
extensions/whatsapp/src/auto-reply/monitor-state.ts
Normal file
128
extensions/whatsapp/src/auto-reply/monitor-state.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
714
extensions/whatsapp/src/auto-reply/monitor.ts
Normal file
714
extensions/whatsapp/src/auto-reply/monitor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
110
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal file
110
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts
Normal 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("👀");
|
||||
});
|
||||
});
|
||||
28
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal file
28
extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts
Normal 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;
|
||||
}
|
||||
205
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts
Normal file
205
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
115
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
Normal file
115
extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
Normal 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)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
156
extensions/whatsapp/src/auto-reply/monitor/broadcast.ts
Normal file
156
extensions/whatsapp/src/auto-reply/monitor/broadcast.ts
Normal 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;
|
||||
}
|
||||
20
extensions/whatsapp/src/auto-reply/monitor/commands.ts
Normal file
20
extensions/whatsapp/src/auto-reply/monitor/commands.ts
Normal 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();
|
||||
}
|
||||
65
extensions/whatsapp/src/auto-reply/monitor/echo.ts
Normal file
65
extensions/whatsapp/src/auto-reply/monitor/echo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Whatsapp plugin module implements group activation behavior.
|
||||
export { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
277
extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
Normal file
277
extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
66
extensions/whatsapp/src/auto-reply/monitor/group-members.ts
Normal file
66
extensions/whatsapp/src/auto-reply/monitor/group-members.ts
Normal 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(", ");
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
102
extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts
Normal file
102
extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
1630
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts
Normal file
1630
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
906
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts
Normal file
906
extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts
Normal 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
Reference in New Issue
Block a user