diff --git a/lib/public/assets/icons/whatsapp.svg b/lib/public/assets/icons/whatsapp.svg
new file mode 100644
index 00000000..70e0037e
--- /dev/null
+++ b/lib/public/assets/icons/whatsapp.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/lib/public/js/components/agents-tab/create-channel-modal.js b/lib/public/js/components/agents-tab/create-channel-modal.js
index 52e4e029..191c251d 100644
--- a/lib/public/js/components/agents-tab/create-channel-modal.js
+++ b/lib/public/js/components/agents-tab/create-channel-modal.js
@@ -18,6 +18,7 @@ const kChannelEnvKeys = {
telegram: "TELEGRAM_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
slack: "SLACK_BOT_TOKEN",
+ whatsapp: "WHATSAPP_OWNER_NUMBER",
};
const kChannelExtraEnvKeys = {
@@ -229,8 +230,10 @@ export const CreateChannelModal = ({
}
setName(providerLabel);
}, [provider, providerHasAccounts, nameEditedManually, isEditMode]);
+ const normalizedProvider = String(provider || "").trim();
const isSingleAccountProvider = isSingleAccountChannelProvider(provider);
- const needsAppToken = String(provider || "").trim() === "slack";
+ const needsAppToken = normalizedProvider === "slack";
+ const isWhatsApp = normalizedProvider === "whatsapp";
const accountId = useMemo(() => {
if (isEditMode) {
@@ -419,20 +422,34 @@ export const CreateChannelModal = ({
-
- ${needsAppToken ? "Bot Token" : "Token"}
+
+ ${isWhatsApp ? "Owner Number" : needsAppToken ? "Bot Token" : "Token"}
- <${SecretInput}
- value=${token}
- onInput=${(event) => setToken(event.target.value)}
- placeholder=${token ? "" : "Paste bot token"}
- loading=${loadingToken}
- isSecret=${true}
- inputClass="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
- />
+ ${isWhatsApp
+ ? html`
+ setToken(event.target.value)}
+ placeholder="+15551234567"
+ class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
+ />
+ `
+ : html`
+ <${SecretInput}
+ value=${token}
+ onInput=${(event) => setToken(event.target.value)}
+ placeholder=${token ? "" : "Paste bot token"}
+ loading=${loadingToken}
+ isSecret=${true}
+ inputClass="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted"
+ />
+ `}
- Saved behind the scenes as
- ${envKey || "CHANNEL_TOKEN"}.
+ ${isWhatsApp
+ ? "E.164 format phone number used for allowlist pairing."
+ : html`Saved behind the scenes as
+ ${envKey || "CHANNEL_TOKEN"}.`}
diff --git a/lib/public/js/components/channel-login-modal.js b/lib/public/js/components/channel-login-modal.js
new file mode 100644
index 00000000..e59a074e
--- /dev/null
+++ b/lib/public/js/components/channel-login-modal.js
@@ -0,0 +1,82 @@
+import { h } from "https://esm.sh/preact";
+import htm from "https://esm.sh/htm";
+import { ActionButton } from "./action-button.js";
+import { CloseIcon } from "./icons.js";
+import { ModalShell } from "./modal-shell.js";
+import { PageHeader } from "./page-header.js";
+
+const html = htm.bind(h);
+
+export const ChannelLoginModal = ({
+ visible = false,
+ loading = false,
+ title = "Link Channel",
+ output = "",
+ error = "",
+ runDisabled = false,
+ runLabel = "Generate QR",
+ runLoadingLabel = "Running...",
+ closeLabel = "Close",
+ onRun = async () => {},
+ onClose = () => {},
+}) => {
+ if (!visible) return null;
+ const hasOutput = !!String(output || "").trim();
+ const hasError = !!String(error || "").trim();
+ const displayOutput = hasOutput
+ ? String(output)
+ : hasError
+ ? String(error)
+ : "No output yet. Generate QR to start login.";
+ return html`
+ <${ModalShell}
+ visible=${visible}
+ onClose=${onClose}
+ panelClassName="bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4"
+ >
+ <${PageHeader}
+ title=${title}
+ actions=${html`
+
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
+
+ `}
+ />
+
+ <${ActionButton}
+ onClick=${onClose}
+ disabled=${loading}
+ loading=${false}
+ tone="secondary"
+ size="sm"
+ idleLabel=${closeLabel}
+ />
+ <${ActionButton}
+ onClick=${onRun}
+ disabled=${loading || runDisabled}
+ loading=${loading}
+ tone="primary"
+ size="sm"
+ idleLabel=${runLabel}
+ loadingLabel=${runLoadingLabel}
+ />
+
+ ${ModalShell}>
+ `;
+};
diff --git a/lib/public/js/components/channels.js b/lib/public/js/components/channels.js
index 1380c458..3564f5e1 100644
--- a/lib/public/js/components/channels.js
+++ b/lib/public/js/components/channels.js
@@ -8,14 +8,19 @@ import {
import htm from "htm";
import { AddChannelMenu } from "./add-channel-menu.js";
import { ChannelAccountStatusBadge } from "./channel-account-status-badge.js";
+import { ChannelLoginModal } from "./channel-login-modal.js";
import { ConfirmDialog } from "./confirm-dialog.js";
import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
import {
deleteChannelAccount,
fetchChannelAccounts,
+ fetchChannelAccountLoginStatus,
+ fetchRestartStatus,
+ runChannelAccountLogin,
updateChannelAccount,
} from "../lib/api.js";
import { useCachedFetch } from "../hooks/use-cached-fetch.js";
+import { usePolling } from "../hooks/usePolling.js";
import {
isImplicitDefaultAccount,
resolveChannelAccountLabel,
@@ -27,11 +32,12 @@ import { showToast } from "./toast.js";
const html = htm.bind(h);
-const ALL_CHANNELS = ["telegram", "discord", "slack"];
+const ALL_CHANNELS = ["telegram", "discord", "slack", "whatsapp"];
const kChannelMeta = {
telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg" },
discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg" },
slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg" },
+ whatsapp: { label: "WhatsApp", iconSrc: "/assets/icons/whatsapp.svg" },
};
const getChannelMeta = (channelId = "") => {
@@ -49,6 +55,48 @@ const getChannelMeta = (channelId = "") => {
const announceRestartRequired = () =>
window.dispatchEvent(new CustomEvent("alphaclaw:restart-required"));
+const appendTerminalOutput = (previousOutput = "", nextChunk = "") =>
+ [String(previousOutput || "").trim(), String(nextChunk || "").trim()]
+ .filter(Boolean)
+ .join("\n\n");
+
+const cloneLoginModalState = (state = {}) => ({
+ loginAccount: state.loginAccount || null,
+ loginOutput: String(state.loginOutput || ""),
+ loginError: String(state.loginError || ""),
+ loginRunning: !!state.loginRunning,
+ loginMonitoring: !!state.loginMonitoring,
+ loginCompleted: !!state.loginCompleted,
+ loginLinked: !!state.loginLinked,
+ loginRestartingGateway: !!state.loginRestartingGateway,
+ loginRestartedGateway: !!state.loginRestartedGateway,
+});
+
+let kPreservedChannelLoginModalState = null;
+
+const clearChannelLoginModalState = ({
+ setLoginAccount,
+ setLoginOutput,
+ setLoginError,
+ setLoginRunning,
+ setLoginMonitoring,
+ setLoginCompleted,
+ setLoginLinked,
+ setLoginRestartingGateway,
+ setLoginRestartedGateway,
+}) => {
+ kPreservedChannelLoginModalState = null;
+ setLoginAccount(null);
+ setLoginOutput("");
+ setLoginError("");
+ setLoginRunning(false);
+ setLoginMonitoring(false);
+ setLoginCompleted(false);
+ setLoginLinked(false);
+ setLoginRestartingGateway(false);
+ setLoginRestartedGateway(false);
+};
+
export const ChannelsCard = ({
title = "Channels",
items = [],
@@ -140,7 +188,9 @@ export const Channels = ({
agents = [],
onNavigate = () => {},
onRefreshStatuses = () => {},
+ onRestartGateway = async () => ({ ok: false }),
}) => {
+ const preservedLoginState = cloneLoginModalState(kPreservedChannelLoginModalState || {});
const [saving, setSaving] = useState(false);
const [createLoadingLabel, setCreateLoadingLabel] = useState("Creating...");
const [menuOpenId, setMenuOpenId] = useState("");
@@ -156,6 +206,20 @@ export const Channels = ({
const channelAccounts = Array.isArray(channelAccountsPayload?.channels)
? channelAccountsPayload.channels
: [];
+ const [loginAccount, setLoginAccount] = useState(preservedLoginState.loginAccount);
+ const [loginOutput, setLoginOutput] = useState(preservedLoginState.loginOutput);
+ const [loginError, setLoginError] = useState(preservedLoginState.loginError);
+ const [loginRunning, setLoginRunning] = useState(preservedLoginState.loginRunning);
+ const [loginMonitoring, setLoginMonitoring] = useState(preservedLoginState.loginMonitoring);
+ const [loginCompleted, setLoginCompleted] = useState(preservedLoginState.loginCompleted);
+ const [loginLinked, setLoginLinked] = useState(preservedLoginState.loginLinked);
+ const [loginRestartingGateway, setLoginRestartingGateway] = useState(
+ preservedLoginState.loginRestartingGateway,
+ );
+ const [loginRestartedGateway, setLoginRestartedGateway] = useState(
+ preservedLoginState.loginRestartedGateway,
+ );
+ const [loginRestartStatusChecked, setLoginRestartStatusChecked] = useState(false);
const loadChannelAccounts = useCallback(async () => {
try {
@@ -163,6 +227,62 @@ export const Channels = ({
} catch {}
}, [refreshChannelAccounts]);
+ const loginStatusPoll = usePolling(
+ () =>
+ fetchChannelAccountLoginStatus({
+ provider: loginAccount?.provider,
+ accountId: loginAccount?.id,
+ }),
+ 1000,
+ {
+ enabled:
+ !!loginAccount &&
+ !!loginMonitoring &&
+ String(loginAccount?.provider || "").trim() === "whatsapp",
+ },
+ );
+ const restartStatusPoll = usePolling(fetchRestartStatus, 2000, {
+ enabled: !!loginAccount && !!loginRestartingGateway,
+ });
+
+ const appendLoginOutput = useCallback((nextChunk = "") => {
+ setLoginOutput((currentOutput) => appendTerminalOutput(currentOutput, nextChunk));
+ }, []);
+
+ useEffect(() => {
+ const nextState = cloneLoginModalState({
+ loginAccount,
+ loginOutput,
+ loginError,
+ loginRunning,
+ loginMonitoring,
+ loginCompleted,
+ loginLinked,
+ loginRestartingGateway,
+ loginRestartedGateway,
+ });
+ const hasActiveLoginState =
+ !!nextState.loginAccount ||
+ !!nextState.loginOutput ||
+ !!nextState.loginError ||
+ !!nextState.loginRunning ||
+ !!nextState.loginMonitoring ||
+ !!nextState.loginCompleted ||
+ !!nextState.loginLinked ||
+ !!nextState.loginRestartingGateway ||
+ !!nextState.loginRestartedGateway;
+ kPreservedChannelLoginModalState = hasActiveLoginState ? nextState : null;
+ }, [
+ loginAccount,
+ loginCompleted,
+ loginError,
+ loginLinked,
+ loginMonitoring,
+ loginOutput,
+ loginRestartedGateway,
+ loginRestartingGateway,
+ loginRunning,
+ ]);
const configuredChannelMap = useMemo(
() =>
@@ -192,6 +312,51 @@ export const Channels = ({
);
const showAgentBadge = agents.length > 0;
+ useEffect(() => {
+ const handleOpenWhatsAppQr = () => {
+ const configuredWhatsApp = channelAccounts.find(
+ (entry) => String(entry?.channel || "").trim() === "whatsapp",
+ );
+ const account = Array.isArray(configuredWhatsApp?.accounts)
+ ? configuredWhatsApp.accounts[0]
+ : null;
+ if (!account) return;
+ const accountId = String(account?.id || "").trim() || "default";
+ const boundAgentId = String(account?.boundAgentId || "").trim();
+ const ownerAgentId =
+ boundAgentId ||
+ (isImplicitDefaultAccount({ accountId, boundAgentId })
+ ? defaultAgentId
+ : "");
+ const accountData = {
+ id: accountId,
+ provider: "whatsapp",
+ name: resolveChannelAccountLabel({
+ channelId: "whatsapp",
+ account,
+ providerLabel: getChannelMeta("whatsapp").label || "WhatsApp",
+ }),
+ ownerAgentId,
+ envKey: String(account?.envKey || "").trim(),
+ token: String(account?.token || "").trim(),
+ };
+ setLoginAccount(accountData);
+ setLoginOutput("");
+ setLoginError("");
+ setLoginRunning(false);
+ setLoginMonitoring(false);
+ setLoginCompleted(false);
+ setLoginLinked(false);
+ setLoginRestartingGateway(false);
+ setLoginRestartedGateway(false);
+ setLoginRestartStatusChecked(false);
+ };
+ window.addEventListener("alphaclaw:open-whatsapp-qr", handleOpenWhatsAppQr);
+ return () => {
+ window.removeEventListener("alphaclaw:open-whatsapp-qr", handleOpenWhatsAppQr);
+ };
+ }, [channelAccounts, defaultAgentId]);
+
const handleUpdateChannel = async (payload) => {
setSaving(true);
try {
@@ -259,6 +424,134 @@ export const Channels = ({
setSaving(false);
}
};
+ const handleRunChannelLogin = async () => {
+ if (!loginAccount) return;
+ setLoginRunning(true);
+ setLoginMonitoring(true);
+ setLoginCompleted(false);
+ setLoginLinked(false);
+ setLoginRestartingGateway(false);
+ setLoginRestartedGateway(false);
+ setLoginRestartStatusChecked(false);
+ setLoginError("");
+ setLoginOutput("");
+ try {
+ const result = await runChannelAccountLogin({
+ provider: loginAccount.provider,
+ accountId: loginAccount.id,
+ });
+ const combinedOutput = appendTerminalOutput(result?.stdout || "", result?.stderr || "");
+ setLoginOutput(combinedOutput || "No terminal output captured.");
+ setLoginCompleted(!!result?.completed);
+ if (result?.completed) {
+ await loginStatusPoll.refresh();
+ }
+ } catch (error) {
+ setLoginError(String(error?.message || "Could not start channel login"));
+ setLoginMonitoring(false);
+ } finally {
+ setLoginRunning(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!loginAccount || !loginMonitoring || loginLinked || loginRestartingGateway) {
+ return;
+ }
+ if (!loginStatusPoll.data?.linked) return;
+
+ let cancelled = false;
+ setLoginLinked(true);
+ setLoginError("");
+ appendLoginOutput("✅ Saved WhatsApp credentials detected.");
+
+ (async () => {
+ setLoginRestartingGateway(true);
+ setLoginRestartStatusChecked(false);
+ appendLoginOutput("Restarting the gateway so the new WhatsApp session comes online...");
+ try {
+ const restartResult = await onRestartGateway();
+ if (restartResult && restartResult.ok === false) {
+ throw new Error(restartResult.error || "Could not restart gateway");
+ }
+ if (cancelled) return;
+ appendLoginOutput("✅ Gateway restart triggered. Waiting for it to come back online...");
+ await restartStatusPoll.refresh();
+ if (cancelled) return;
+ setLoginRestartStatusChecked(true);
+ } catch (error) {
+ if (cancelled) return;
+ setLoginError(String(error?.message || "Could not restart gateway"));
+ appendLoginOutput(
+ "WhatsApp linked, but the gateway restart failed. You may need to restart it manually.",
+ );
+ setLoginRestartStatusChecked(false);
+ setLoginRestartingGateway(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ appendLoginOutput,
+ loadChannelAccounts,
+ loginAccount,
+ loginLinked,
+ loginMonitoring,
+ loginRestartingGateway,
+ loginStatusPoll.data?.linked,
+ onRefreshStatuses,
+ onRestartGateway,
+ restartStatusPoll.refresh,
+ ]);
+
+ useEffect(() => {
+ if (!loginAccount || !loginRestartingGateway) return;
+ if (!loginRestartStatusChecked) return;
+ const restartInProgress = !!restartStatusPoll.data?.restartInProgress;
+ const gatewayRunning = restartStatusPoll.data?.gatewayRunning !== false;
+ if (restartInProgress || !gatewayRunning) return;
+
+ let cancelled = false;
+
+ (async () => {
+ setLoginRestartedGateway(true);
+ setLoginMonitoring(false);
+ appendLoginOutput("✅ Gateway restart complete.");
+ showToast("Channel linked", "success");
+ await Promise.all([
+ loadChannelAccounts(),
+ Promise.resolve(onRefreshStatuses?.()),
+ ]);
+ if (cancelled) return;
+ clearChannelLoginModalState({
+ setLoginAccount,
+ setLoginOutput,
+ setLoginError,
+ setLoginRunning,
+ setLoginMonitoring,
+ setLoginCompleted,
+ setLoginLinked,
+ setLoginRestartingGateway,
+ setLoginRestartedGateway,
+ });
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ appendLoginOutput,
+ loadChannelAccounts,
+ loginAccount,
+ loginRestartStatusChecked,
+ loginRestartingGateway,
+ onRefreshStatuses,
+ restartStatusPoll.data?.gatewayRunning,
+ restartStatusPoll.data?.restartInProgress,
+ ]);
+
const openCreateChannelModal = (provider) => {
setMenuOpenId("");
setEditingAccount({
@@ -396,6 +689,27 @@ export const Channels = ({
>
Edit
${OverflowMenuItem}>
+ ${channelId === "whatsapp"
+ ? html`
+ <${OverflowMenuItem}
+ onClick=${() => {
+ setMenuOpenId("");
+ setLoginAccount(accountData);
+ setLoginOutput("");
+ setLoginError("");
+ setLoginRunning(false);
+ setLoginMonitoring(false);
+ setLoginCompleted(false);
+ setLoginLinked(false);
+ setLoginRestartingGateway(false);
+ setLoginRestartedGateway(false);
+ setLoginRestartStatusChecked(false);
+ }}
+ >
+ Link WhatsApp (QR)
+ ${OverflowMenuItem}>
+ `
+ : null}
<${OverflowMenuItem}
className="text-status-error hover:text-status-error"
onClick=${() => {
@@ -518,6 +832,38 @@ export const Channels = ({
setDeletingAccount(null);
}}
/>
+ <${ChannelLoginModal}
+ visible=${!!loginAccount}
+ loading=${loginRunning || loginRestartingGateway}
+ title=${`Link ${String(loginAccount?.name || "WhatsApp").trim()} via QR`}
+ output=${loginOutput}
+ error=${loginError}
+ onRun=${handleRunChannelLogin}
+ onClose=${() => {
+ if (loginRunning || loginRestartingGateway) return;
+ clearChannelLoginModalState({
+ setLoginAccount,
+ setLoginOutput,
+ setLoginError,
+ setLoginRunning,
+ setLoginMonitoring,
+ setLoginCompleted,
+ setLoginLinked,
+ setLoginRestartingGateway,
+ setLoginRestartedGateway,
+ });
+ }}
+ runDisabled=${loginRunning || loginRestartingGateway || loginRestartedGateway}
+ runLabel=${loginLinked
+ ? loginRestartingGateway
+ ? "Restarting..."
+ : loginRestartedGateway
+ ? "Linked"
+ : "Awaiting restart..."
+ : "Generate QR"}
+ runLoadingLabel=${loginRestartingGateway ? "Restarting..." : "Running..."}
+ closeLabel=${loginRestartedGateway ? "Done" : "Close"}
+ />
`;
};
diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js
index c5c9d3ba..c9841a74 100644
--- a/lib/public/js/components/general/index.js
+++ b/lib/public/js/components/general/index.js
@@ -5,6 +5,7 @@ import { Channels } from "../channels.js";
import { ChannelOperationsPanel } from "../channel-operations-panel.js";
import { Pairings } from "../pairings.js";
import { DevicePairings } from "../device-pairings.js";
+import { ActionButton } from "../action-button.js";
import { Google } from "../google/index.js";
import { Features } from "../features.js";
import { GeneralDoctorWarning } from "../doctor/general-warning.js";
@@ -14,6 +15,11 @@ import { useGeneralTab } from "./use-general-tab.js";
const html = htm.bind(h);
+const openWhatsAppQrModal = () => {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new CustomEvent("alphaclaw:open-whatsapp-qr"));
+};
+
export const GeneralTab = ({
statusData = null,
watchdogData = null,
@@ -39,6 +45,23 @@ export const GeneralTab = ({
isActive,
restartSignal,
});
+ const whatsappStatus = state.channels?.whatsapp || null;
+ const whatsappAccounts =
+ whatsappStatus?.accounts && typeof whatsappStatus.accounts === "object"
+ ? whatsappStatus.accounts
+ : {};
+ const hasWhatsAppAwaitingPairing =
+ Object.keys(whatsappAccounts).length > 0
+ ? Object.values(whatsappAccounts).some(
+ (account) => account && account.status !== "paired",
+ )
+ : String(whatsappStatus?.status || "").trim() === "configured";
+ const showWhatsAppPairingCard =
+ state.hasUnpaired &&
+ !state.pairingStatusRefreshing &&
+ Array.isArray(state.pending) &&
+ state.pending.length === 0 &&
+ hasWhatsAppAwaitingPairing;
return html`
@@ -64,17 +87,42 @@ export const GeneralTab = ({
agents=${agents}
onNavigate=${onNavigate}
onRefreshStatuses=${onRefreshStatuses}
+ onRestartGateway=${onRestartGateway}
/>
`}
pairingsSection=${html`
- <${Pairings}
- pending=${state.pending}
- channels=${state.channels}
- visible=${state.hasUnpaired}
- statusRefreshing=${state.pairingStatusRefreshing}
- onApprove=${actions.handleApprove}
- onReject=${actions.handleReject}
- />
+ ${showWhatsAppPairingCard
+ ? html`
+
+
Pending Pairings
+
+
+
WhatsApp needs to be linked
+
Scan the QR code to finish pairing this channel.
+ <${ActionButton}
+ onClick=${openWhatsAppQrModal}
+ tone="primary"
+ size="sm"
+ idleLabel="Open QR Code"
+ />
+
+
+ `
+ : html`
+ <${Pairings}
+ pending=${state.pending}
+ channels=${state.channels}
+ visible=${state.hasUnpaired}
+ statusRefreshing=${state.pairingStatusRefreshing}
+ onApprove=${actions.handleApprove}
+ onReject=${actions.handleReject}
+ />
+ `}
`}
/>
<${Features} onSwitchTab=${onSwitchTab} />
diff --git a/lib/public/js/components/modal-shell.js b/lib/public/js/components/modal-shell.js
index c0108aba..ea618a26 100644
--- a/lib/public/js/components/modal-shell.js
+++ b/lib/public/js/components/modal-shell.js
@@ -1,5 +1,5 @@
import { h } from "preact";
-import { useEffect } from "preact/hooks";
+import { useEffect, useRef } from "preact/hooks";
import { createPortal } from "preact/compat";
import htm from "htm";
@@ -13,6 +13,8 @@ export const ModalShell = ({
panelClassName = "bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3",
children = null,
}) => {
+ const overlayPointerDownRef = useRef(false);
+
useEffect(() => {
if (!visible || !closeOnEscape) return;
@@ -30,8 +32,22 @@ export const ModalShell = ({
html`
{
+ overlayPointerDownRef.current = event.target === event.currentTarget;
+ }}
+ onpointerup=${(event) => {
+ const shouldClose =
+ closeOnOverlayClick &&
+ overlayPointerDownRef.current &&
+ event.target === event.currentTarget;
+ overlayPointerDownRef.current = false;
+ if (shouldClose) onClose?.();
+ }}
+ onpointercancel=${() => {
+ overlayPointerDownRef.current = false;
+ }}
onclick=${(event) => {
- if (closeOnOverlayClick && event.target === event.currentTarget) onClose?.();
+ event.preventDefault();
}}
>
${children}
diff --git a/lib/public/js/components/onboarding/welcome-pairing-step.js b/lib/public/js/components/onboarding/welcome-pairing-step.js
index b7ee0eac..3e3a4d04 100644
--- a/lib/public/js/components/onboarding/welcome-pairing-step.js
+++ b/lib/public/js/components/onboarding/welcome-pairing-step.js
@@ -14,6 +14,14 @@ const kChannelMeta = {
label: "Discord",
iconSrc: "/assets/icons/discord.svg",
},
+ slack: {
+ label: "Slack",
+ iconSrc: "/assets/icons/slack.svg",
+ },
+ whatsapp: {
+ label: "WhatsApp",
+ iconSrc: "/assets/icons/whatsapp.svg",
+ },
};
const PairingRow = ({ pairing, onApprove, onReject }) => {
@@ -79,7 +87,6 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
export const WelcomePairingStep = ({
channel,
pairings,
- channels,
loading,
error,
onApprove,
@@ -94,15 +101,13 @@ export const WelcomePairingStep = ({
: "Channel",
iconSrc: "",
};
- const channelInfo = channels?.[channel];
if (!channel) {
return html`
- Missing channel configuration. Go back and add a Telegram or Discord bot
- token.
+ Missing channel configuration. Go back and add a channel credential.
`;
}
@@ -116,8 +121,8 @@ export const WelcomePairingStep = ({
🎉 Setup complete
- Your ${channelMeta.label} channel is connected. You can switch
- to ${channelMeta.label} and start using your agent now.
+ Your ${channelMeta.label} channel is connected. You can switch to${" "}
+ ${channelMeta.label} and start using your agent now.
Continue to the dashboard to explore extras like Google Workspace
diff --git a/lib/public/js/components/pairings.js b/lib/public/js/components/pairings.js
index ceb1689e..bac853b7 100644
--- a/lib/public/js/components/pairings.js
+++ b/lib/public/js/components/pairings.js
@@ -62,7 +62,7 @@ export const PairingRow = ({ p, onApprove, onReject }) => {
`;
};
-const ALL_CHANNELS = ['telegram', 'discord', 'slack'];
+const ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp'];
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js
index 026f4348..a23cea91 100644
--- a/lib/public/js/components/welcome/index.js
+++ b/lib/public/js/components/welcome/index.js
@@ -66,7 +66,6 @@ export const Welcome = ({ onComplete, acVersion }) => {
? html`<${WelcomePairingStep}
channel=${state.selectedPairingChannel}
pairings=${state.pairingRequestsPoll.data || []}
- channels=${state.pairingChannels}
loading=${!state.pairingStatusPoll.data}
error=${state.pairingError}
onApprove=${actions.handlePairingApprove}
diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js
index ad432ebe..9c755d24 100644
--- a/lib/public/js/components/welcome/use-welcome.js
+++ b/lib/public/js/components/welcome/use-welcome.js
@@ -334,7 +334,7 @@ export const useWelcome = ({ onComplete }) => {
const pairingChannel = getPreferredPairingChannel(normalizedVals);
if (!pairingChannel) {
throw new Error(
- "No Telegram or Discord bot token configured for pairing.",
+ "No channel credential configured for pairing.",
);
}
setVals((prev) => ({
diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js
index 2d4cf3c3..324851b4 100644
--- a/lib/public/js/lib/api.js
+++ b/lib/public/js/lib/api.js
@@ -982,6 +982,29 @@ export const deleteChannelAccount = async (payload) => {
return parseJsonOrThrow(res, "Could not delete channel account");
};
+export const runChannelAccountLogin = async (payload) => {
+ const res = await authFetch("/api/channels/accounts/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload || {}),
+ });
+ return parseJsonOrThrow(res, "Could not run channel login");
+};
+
+export const fetchChannelAccountLoginStatus = async ({
+ provider = "",
+ accountId = "default",
+} = {}) => {
+ const params = new URLSearchParams({
+ provider: String(provider || ""),
+ accountId: String(accountId || "default"),
+ });
+ const res = await authFetch(
+ `/api/channels/accounts/login-status?${params.toString()}`,
+ );
+ return parseJsonOrThrow(res, "Could not load channel login status");
+};
+
export const fetchAgent = async (agentId) => {
const res = await authFetch(`/api/agents/${encodeURIComponent(String(agentId || ""))}`);
return parseJsonOrThrow(res, "Could not load agent");
diff --git a/lib/public/js/lib/channel-provider-availability.js b/lib/public/js/lib/channel-provider-availability.js
index b4b9b9d8..3b8fde7b 100644
--- a/lib/public/js/lib/channel-provider-availability.js
+++ b/lib/public/js/lib/channel-provider-availability.js
@@ -1,4 +1,4 @@
-const kSingleAccountChannelProviders = new Set(["discord"]);
+const kSingleAccountChannelProviders = new Set(["discord", "whatsapp"]);
const hasConfiguredAccounts = ({ configuredChannelMap, provider }) => {
const channelEntry = configuredChannelMap instanceof Map
diff --git a/lib/server.js b/lib/server.js
index fb4470f3..81f76265 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -232,6 +232,7 @@ const watchdogNotifier = createWatchdogNotifier({
telegramApi,
discordApi,
slackApi,
+ clawCmd,
readEnvFile,
});
const watchdog = createWatchdog({
diff --git a/lib/server/agents/channels.js b/lib/server/agents/channels.js
index be3c2ee2..5701b9a8 100644
--- a/lib/server/agents/channels.js
+++ b/lib/server/agents/channels.js
@@ -18,6 +18,7 @@ const {
deriveChannelExtraEnvKeys,
getConfiguredChannelEnvKeys,
assertActiveChannelTokenEnvVars,
+ hasSavedWhatsAppCredentials,
normalizeChannelConfig,
appendBindingToConfig,
buildBindingSpec,
@@ -101,8 +102,6 @@ const createChannelsDomain = ({
const provider = normalizeChannelProvider(input.provider);
const name =
String(input.name || "").trim() || kChannelLabels[provider] || provider;
- const token = String(input.token || "").trim();
- if (!token) throw new Error("Channel token is required");
const cfg = withNormalizedAgentsConfig({
OPENCLAW_DIR,
@@ -143,12 +142,31 @@ const createChannelsDomain = ({
`Channel account "${provider}/${accountId}" already exists`,
);
}
- if (provider === "discord" && Object.keys(existingAccounts).length > 0) {
+ if (
+ (provider === "discord" || provider === "whatsapp") &&
+ Object.keys(existingAccounts).length > 0
+ ) {
throw new Error(
`${kChannelLabels[provider] || "This provider"} supports a single channel account`,
);
}
+ if (provider === "whatsapp") {
+ return await createWhatsAppChannelAccount({
+ input,
+ cfg,
+ agentId,
+ accountId,
+ name,
+ normalizedChannelConfig,
+ existingAccounts,
+ onProgress,
+ });
+ }
+
+ const token = String(input.token || "").trim();
+ if (!token) throw new Error("Channel token is required");
+
const envKey = deriveChannelEnvKey({ provider, accountId });
const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });
const appToken = String(input.appToken || "").trim();
@@ -157,7 +175,9 @@ const createChannelsDomain = ({
}
const tokenField = kChannelTokenFields[provider];
const currentEnvVars = readEnvFile();
- const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : [];
+ const previousEnvVars = Array.isArray(currentEnvVars)
+ ? currentEnvVars
+ : [];
const duplicateEnvEntry = previousEnvVars.find((entry) => {
const existingKey = String(entry?.key || "").trim();
const existingValue = String(entry?.value || "").trim();
@@ -555,6 +575,53 @@ const createChannelsDomain = ({
}
};
+ const cleanupWhatsAppAuthFiles = ({ accountId }) => {
+ const credDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
+ const providerCredDir = path.join(credDir, "whatsapp");
+ const normalizedAccountId =
+ String(accountId || "")
+ .trim()
+ .toLowerCase() || "default";
+
+ try {
+ fsImpl.rmSync(path.join(credDir, "whatsapp", normalizedAccountId), {
+ recursive: true,
+ force: true,
+ });
+ } catch {}
+
+ try {
+ fsImpl.rmSync(providerCredDir, {
+ recursive: true,
+ force: true,
+ });
+ } catch {}
+
+ if (normalizedAccountId !== "default") {
+ return;
+ }
+
+ const legacyAuthPatterns = [
+ "creds.json",
+ "creds.json.bak",
+ ];
+ try {
+ const entries = fsImpl.readdirSync(credDir);
+ for (const entry of Array.isArray(entries) ? entries : []) {
+ const fileName = String(entry || "").trim();
+ if (!fileName) continue;
+ if (
+ legacyAuthPatterns.includes(fileName) ||
+ /^(app-state-sync|session|sender-key|pre-key)-.*\.json$/.test(fileName)
+ ) {
+ try {
+ fsImpl.rmSync(path.join(credDir, fileName), { force: true });
+ } catch {}
+ }
+ }
+ } catch {}
+ };
+
const deleteChannelAccount = async (input = {}) => {
const provider = normalizeChannelProvider(input.provider);
const accountId = String(input.accountId || "").trim() || "default";
@@ -731,9 +798,21 @@ const createChannelsDomain = ({
if (hasScopedFields) return true;
return !matchesBinding(match, targetMatch);
});
+ if (!nextChannels[provider] && nextCfg.plugins?.entries?.[provider]) {
+ nextCfg.plugins.entries[provider] = {
+ ...(nextCfg.plugins.entries[provider] || {}),
+ enabled: false,
+ };
+ }
saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });
cleanupChannelAccountPairingFiles({ provider, accountId });
+ if (provider === "whatsapp") {
+ cleanupWhatsAppAuthFiles({ accountId });
+ }
+ if (provider === "whatsapp") {
+ await restartGateway();
+ }
return { ok: true };
};
@@ -763,11 +842,196 @@ const createChannelsDomain = ({
}));
};
+ const createWhatsAppChannelAccount = async ({
+ input,
+ cfg,
+ agentId,
+ accountId,
+ name,
+ normalizedChannelConfig,
+ existingAccounts,
+ onProgress,
+ }) => {
+ const ownerNumber = String(input.token || "").trim();
+ if (!ownerNumber) throw new Error("WhatsApp owner number is required");
+
+ const envKey = deriveChannelEnvKey({ provider: "whatsapp", accountId });
+ const currentEnvVars = readEnvFile();
+ const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : [];
+ const previousConfig = cloneJson(cfg);
+
+ const nextEnvVars = previousEnvVars.filter(
+ (entry) => String(entry?.key || "").trim() !== envKey,
+ );
+ nextEnvVars.push({ key: envKey, value: ownerNumber });
+
+ try {
+ onProgress({ phase: "configuring", label: "Configuring..." });
+ writeEnvFile(nextEnvVars);
+ reloadEnv();
+
+ const nextCfg = withNormalizedAgentsConfig({
+ OPENCLAW_DIR,
+ cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),
+ });
+ ensurePluginAllowed({ cfg: nextCfg, pluginKey: "whatsapp" });
+ saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });
+
+ onProgress({ phase: "configuring", label: "Adding channel..." });
+ const addArgs = [
+ "channels add",
+ "--channel whatsapp",
+ accountId !== "default" ? `--account ${shellEscapeArg(accountId)}` : "",
+ name ? `--name ${shellEscapeArg(name)}` : "",
+ `--token ${shellEscapeArg(ownerNumber)}`,
+ ].filter(Boolean);
+ const addResult = await clawCmd(addArgs.join(" "), {
+ quiet: true,
+ timeoutMs: 30000,
+ });
+ if (!addResult?.ok) {
+ throw new Error(
+ addResult?.stderr ||
+ addResult?.stdout ||
+ "Could not add WhatsApp channel account",
+ );
+ }
+
+ const refreshedCfg = withNormalizedAgentsConfig({
+ OPENCLAW_DIR,
+ cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),
+ });
+
+ const nextAccounts = { ...existingAccounts };
+ nextAccounts[accountId] = {
+ ...(nextAccounts[accountId] &&
+ typeof nextAccounts[accountId] === "object"
+ ? nextAccounts[accountId]
+ : {}),
+ ...(name ? { name } : {}),
+ allowFrom: [`\${${envKey}}`],
+ groupAllowFrom: [`\${${envKey}}`],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ };
+ normalizedChannelConfig.accounts = nextAccounts;
+ normalizedChannelConfig.enabled = true;
+ if (!String(normalizedChannelConfig.defaultAccount || "").trim()) {
+ normalizedChannelConfig.defaultAccount = "default";
+ }
+ refreshedCfg.channels =
+ refreshedCfg.channels && typeof refreshedCfg.channels === "object"
+ ? { ...refreshedCfg.channels }
+ : {};
+ refreshedCfg.channels.whatsapp = normalizedChannelConfig;
+
+ const bindSpec = buildBindingSpec({ provider: "whatsapp", accountId });
+ appendBindingToConfig({
+ cfg: refreshedCfg,
+ agentId,
+ match: normalizeBindingMatch({ channel: "whatsapp", accountId }),
+ });
+ saveConfig({ fsImpl, OPENCLAW_DIR, config: refreshedCfg });
+
+ onProgress({ phase: "restarting", label: "Rebooting..." });
+ await restartGateway();
+ } catch (error) {
+ try {
+ await clawCmd(
+ [
+ "channels remove",
+ "--channel whatsapp",
+ accountId !== "default" ? `--account ${shellEscapeArg(accountId)}` : "",
+ "--delete",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ { quiet: true, timeoutMs: 30000 },
+ );
+ } catch {}
+ try {
+ writeEnvFile(previousEnvVars);
+ reloadEnv();
+ } catch {}
+ try {
+ saveConfig({ fsImpl, OPENCLAW_DIR, config: previousConfig });
+ } catch {}
+ throw error;
+ }
+
+ return {
+ channel: "whatsapp",
+ account: { id: accountId, name, envKey },
+ binding: {
+ agentId,
+ match: normalizeBindingMatch({ channel: "whatsapp", accountId }),
+ },
+ restartRequired: true,
+ };
+ };
+
+ const runChannelAccountLogin = async ({
+ provider: rawProvider,
+ accountId: rawAccountId,
+ } = {}) => {
+ const provider = normalizeChannelProvider(rawProvider);
+ if (provider !== "whatsapp") {
+ throw new Error("Channel login is currently only supported for WhatsApp");
+ }
+ const accountId = String(rawAccountId || "").trim() || "default";
+ const loginArgs = [
+ "channels login",
+ `--channel ${shellEscapeArg(provider)}`,
+ accountId !== "default" ? `--account ${shellEscapeArg(accountId)}` : "",
+ ].filter(Boolean);
+ const loginStartedAt = Date.now();
+ const result = await clawCmd(loginArgs.join(" "), {
+ quiet: true,
+ timeoutMs: 12000,
+ killSignal: "SIGKILL",
+ });
+ const elapsedMs = Date.now() - loginStartedAt;
+ console.log(
+ `[channels] login ${provider}/${accountId} finished ok=${!!result?.ok} code=${String(
+ result?.code ?? "",
+ )} elapsedMs=${elapsedMs}`,
+ );
+ return {
+ ok: !!result?.ok,
+ stdout: String(result?.stdout || ""),
+ stderr: String(result?.stderr || ""),
+ completed: !!result?.ok,
+ };
+ };
+
+ const getChannelAccountLoginStatus = ({
+ provider: rawProvider,
+ accountId: rawAccountId,
+ } = {}) => {
+ const provider = normalizeChannelProvider(rawProvider);
+ if (provider !== "whatsapp") {
+ throw new Error("Channel login status is currently only supported for WhatsApp");
+ }
+ const accountId = String(rawAccountId || "").trim() || "default";
+ return {
+ provider,
+ accountId,
+ linked: hasSavedWhatsAppCredentials({
+ fsImpl,
+ OPENCLAW_DIR,
+ accountId,
+ }),
+ };
+ };
+
return {
getChannelAccountToken,
createChannelAccount,
updateChannelAccount,
deleteChannelAccount,
+ runChannelAccountLogin,
+ getChannelAccountLoginStatus,
listConfiguredChannelAccountsWithMaskedTokens,
};
};
diff --git a/lib/server/agents/service.js b/lib/server/agents/service.js
index d09e213a..36a13b85 100644
--- a/lib/server/agents/service.js
+++ b/lib/server/agents/service.js
@@ -41,6 +41,8 @@ const createAgentsService = ({
createChannelAccount: channelsDomain.createChannelAccount,
updateChannelAccount: channelsDomain.updateChannelAccount,
deleteChannelAccount: channelsDomain.deleteChannelAccount,
+ runChannelAccountLogin: channelsDomain.runChannelAccountLogin,
+ getChannelAccountLoginStatus: channelsDomain.getChannelAccountLoginStatus,
listConfiguredChannelAccounts:
channelsDomain.listConfiguredChannelAccountsWithMaskedTokens,
};
diff --git a/lib/server/agents/shared.js b/lib/server/agents/shared.js
index 0c80d1e4..fefe32b3 100644
--- a/lib/server/agents/shared.js
+++ b/lib/server/agents/shared.js
@@ -14,6 +14,7 @@ const kChannelEnvKeys = {
telegram: "TELEGRAM_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
slack: "SLACK_BOT_TOKEN",
+ whatsapp: "WHATSAPP_OWNER_NUMBER",
};
const kChannelExtraEnvKeys = {
slack: ["SLACK_APP_TOKEN"],
@@ -22,6 +23,7 @@ const kChannelTokenFields = {
telegram: "botToken",
discord: "token",
slack: "botToken",
+ // WhatsApp uses owner number, not a bot token field
};
const kChannelExtraTokenFields = {
slack: ["appToken"],
@@ -30,6 +32,13 @@ const kChannelLabels = {
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
+ whatsapp: "WhatsApp",
+};
+const kChannelProviderAliases = {
+ wa: "whatsapp",
+ "whats-app": "whatsapp",
+ whats_app: "whatsapp",
+ "whats app": "whatsapp",
};
const kMaskedChannelToken = "********";
@@ -39,6 +48,44 @@ const shellEscapeArg = (value) =>
const resolveCredentialsDirPath = ({ OPENCLAW_DIR }) =>
path.join(OPENCLAW_DIR, "credentials");
+const resolveWhatsAppCredentialCandidatePaths = ({
+ OPENCLAW_DIR,
+ accountId,
+}) => {
+ const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
+ const normalizedAccountId = normalizeChannelAccountId(accountId);
+ return [
+ path.join(credentialsDir, "whatsapp", normalizedAccountId, "creds.json"),
+ ...(normalizedAccountId === "default"
+ ? [path.join(credentialsDir, "creds.json")]
+ : []),
+ ];
+};
+
+const hasSavedWhatsAppCredentials = ({
+ fsImpl,
+ OPENCLAW_DIR,
+ accountId,
+}) => {
+ const candidatePaths = resolveWhatsAppCredentialCandidatePaths({
+ OPENCLAW_DIR,
+ accountId,
+ });
+ const matches = candidatePaths.map((targetPath) => {
+ try {
+ const exists = !!String(fsImpl.readFileSync(targetPath, "utf8") || "").trim();
+ return { path: targetPath, exists };
+ } catch (error) {
+ return {
+ path: targetPath,
+ exists: false,
+ error: String(error?.message || error || "read failed"),
+ };
+ }
+ });
+ return matches.some((entry) => entry.exists);
+};
+
const resolveAgentWorkspacePath = ({ OPENCLAW_DIR, agentId }) =>
path.join(
OPENCLAW_DIR,
@@ -149,7 +196,7 @@ const normalizeChannelProvider = (value) => {
.trim()
.toLowerCase();
if (!provider || !kChannelEnvKeys[provider]) {
- throw new Error("Unsupported channel provider");
+ throw new Error(`Unsupported channel provider "${provider}"`);
}
return provider;
};
@@ -397,6 +444,26 @@ const resolveCredentialPairingAccountId = ({ channelId, fileName }) => {
return normalizeChannelAccountId(rawAccountId);
};
+const hasImplicitWhatsAppSelfPairing = ({
+ fsImpl,
+ OPENCLAW_DIR,
+ channelId,
+ accountId,
+ accountConfig,
+}) => {
+ if (String(channelId || "").trim() !== "whatsapp") return false;
+ if (!accountConfig || typeof accountConfig !== "object") return false;
+ if (accountConfig.selfChatMode === false) return false;
+ if (String(accountConfig.dmPolicy || "").trim().toLowerCase() === "disabled") {
+ return false;
+ }
+ return hasSavedWhatsAppCredentials({
+ fsImpl,
+ OPENCLAW_DIR,
+ accountId,
+ });
+};
+
const readPairedCountsByAccount = ({
fsImpl,
OPENCLAW_DIR,
@@ -412,30 +479,33 @@ const readPairedCountsByAccount = ({
);
const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });
try {
- const files = fsImpl
- .readdirSync(credentialsDir)
- .filter(
- (fileName) =>
- String(fileName || "").startsWith(
- `${String(channelId || "").trim()}-`,
- ) && String(fileName || "").endsWith("-allowFrom.json"),
- );
- for (const fileName of files) {
- const accountId = resolveCredentialPairingAccountId({
- channelId,
- fileName,
- });
- if (!accountId || !counts.has(accountId)) continue;
- const filePath = path.join(credentialsDir, fileName);
- const parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
- const pairedCount = Array.isArray(parsed?.allowFrom)
- ? parsed.allowFrom.length
- : 0;
- counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);
+ if (String(channelId || "").trim() !== "whatsapp") {
+ const files = fsImpl
+ .readdirSync(credentialsDir)
+ .filter(
+ (fileName) =>
+ String(fileName || "").startsWith(
+ `${String(channelId || "").trim()}-`,
+ ) && String(fileName || "").endsWith("-allowFrom.json"),
+ );
+ for (const fileName of files) {
+ const accountId = resolveCredentialPairingAccountId({
+ channelId,
+ fileName,
+ });
+ if (!accountId || !counts.has(accountId)) continue;
+ const filePath = path.join(credentialsDir, fileName);
+ const parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
+ const pairedCount = Array.isArray(parsed?.allowFrom)
+ ? parsed.allowFrom.length
+ : 0;
+ counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);
+ }
}
} catch {}
for (const accountId of counts.keys()) {
+ if (String(channelId || "").trim() === "whatsapp") continue;
const accountConfig =
accountId === "default" &&
!(config.accounts && typeof config.accounts === "object")
@@ -449,6 +519,26 @@ const readPairedCountsByAccount = ({
);
}
+ for (const accountId of counts.keys()) {
+ if (Number(counts.get(accountId) || 0) > 0) continue;
+ const accountConfig =
+ accountId === "default" &&
+ !(config.accounts && typeof config.accounts === "object")
+ ? config
+ : config.accounts?.[accountId] || {};
+ if (
+ hasImplicitWhatsAppSelfPairing({
+ fsImpl,
+ OPENCLAW_DIR,
+ channelId,
+ accountId,
+ accountConfig,
+ })
+ ) {
+ counts.set(accountId, 1);
+ }
+ }
+
return counts;
};
@@ -509,27 +599,26 @@ const listConfiguredChannelAccounts = ({ fsImpl, OPENCLAW_DIR, cfg }) => {
});
return {
channel: String(channelId || "").trim(),
- accounts: normalizedAccountIds
- .map((accountId) => {
- const accountConfig =
- accountId === "default" && accountIds.length === 0
- ? config
- : accountsConfig?.[accountId] || {};
- return {
- id: accountId,
- name: String(accountConfig?.name || "").trim(),
- envKey: deriveChannelEnvKey({ provider: channelId, accountId }),
- boundAgentId:
- boundAccountMap.get(
- `${String(channelId || "").trim()}:${accountId}`,
- ) || "",
- paired: Number(pairedCounts.get(accountId) || 0),
- status:
- Number(pairedCounts.get(accountId) || 0) > 0
- ? "paired"
- : "configured",
- };
- }),
+ accounts: normalizedAccountIds.map((accountId) => {
+ const accountConfig =
+ accountId === "default" && accountIds.length === 0
+ ? config
+ : accountsConfig?.[accountId] || {};
+ return {
+ id: accountId,
+ name: String(accountConfig?.name || "").trim(),
+ envKey: deriveChannelEnvKey({ provider: channelId, accountId }),
+ boundAgentId:
+ boundAccountMap.get(
+ `${String(channelId || "").trim()}:${accountId}`,
+ ) || "",
+ paired: Number(pairedCounts.get(accountId) || 0),
+ status:
+ Number(pairedCounts.get(accountId) || 0) > 0
+ ? "paired"
+ : "configured",
+ };
+ }),
};
})
.filter(Boolean);
@@ -673,6 +762,8 @@ module.exports = {
kMaskedChannelToken,
shellEscapeArg,
resolveCredentialsDirPath,
+ resolveWhatsAppCredentialCandidatePaths,
+ hasSavedWhatsAppCredentials,
resolveAgentWorkspacePath,
loadConfig,
saveConfig,
diff --git a/lib/server/commands.js b/lib/server/commands.js
index 4b1e2af3..2da1d76a 100644
--- a/lib/server/commands.js
+++ b/lib/server/commands.js
@@ -32,7 +32,10 @@ const createCommands = ({ gatewayEnv }) => {
});
});
- const clawCmd = (cmd, { quiet = false, timeoutMs = 15000 } = {}) =>
+ const clawCmd = (
+ cmd,
+ { quiet = false, timeoutMs = 15000, killSignal = "SIGTERM" } = {},
+ ) =>
new Promise((resolve) => {
if (!quiet) console.log(`[alphaclaw] Running: openclaw ${cmd}`);
exec(
@@ -40,6 +43,7 @@ const createCommands = ({ gatewayEnv }) => {
{
env: gatewayEnv(),
timeout: timeoutMs,
+ killSignal,
},
(err, stdout, stderr) => {
const result = {
diff --git a/lib/server/constants.js b/lib/server/constants.js
index 3c9a6a50..051f3043 100644
--- a/lib/server/constants.js
+++ b/lib/server/constants.js
@@ -256,6 +256,12 @@ const kKnownVars = [
group: "channels",
hint: "From Basic Information → App-Level Tokens (xapp-...)",
},
+ {
+ key: "WHATSAPP_OWNER_NUMBER",
+ label: "WhatsApp Owner Number",
+ group: "channels",
+ hint: "E.164 number, e.g. +15551234567",
+ },
{
key: "MISTRAL_API_KEY",
label: "Mistral API Key",
@@ -358,6 +364,7 @@ const kChannelDefs = {
telegram: { envKey: "TELEGRAM_BOT_TOKEN" },
discord: { envKey: "DISCORD_BOT_TOKEN" },
slack: { envKey: "SLACK_BOT_TOKEN", extraEnvKeys: ["SLACK_APP_TOKEN"] },
+ whatsapp: { envKey: "WHATSAPP_OWNER_NUMBER", sync: false },
};
const kProtectedBrowsePaths = new Set(
Array.isArray(kBrowseFilePolicies?.protectedPaths)
diff --git a/lib/server/gateway.js b/lib/server/gateway.js
index fd0584f2..e388b3d1 100644
--- a/lib/server/gateway.js
+++ b/lib/server/gateway.js
@@ -371,6 +371,32 @@ const getChannelStatus = () => {
);
const credDir = `${OPENCLAW_DIR}/credentials`;
const channels = {};
+ const hasImplicitWhatsAppSelfPairing = ({ accountId, accountConfig }) => {
+ if (!accountConfig || typeof accountConfig !== "object") return false;
+ if (accountConfig.selfChatMode === false) return false;
+ if (String(accountConfig.dmPolicy || "").trim().toLowerCase() === "disabled") {
+ return false;
+ }
+ const candidatePaths = [
+ `${credDir}/whatsapp/${accountId}/creds.json`,
+ ...(accountId === "default" ? [`${credDir}/creds.json`] : []),
+ ];
+ const matches = candidatePaths.map((targetPath) => {
+ try {
+ return {
+ path: targetPath,
+ exists: !!String(fs.readFileSync(targetPath, "utf8") || "").trim(),
+ };
+ } catch (error) {
+ return {
+ path: targetPath,
+ exists: false,
+ error: String(error?.message || error || "read failed"),
+ };
+ }
+ });
+ return matches.some((entry) => entry.exists);
+ };
for (const ch of Object.keys(kChannelDefs)) {
const channelConfig =
@@ -404,27 +430,30 @@ const getChannelStatus = () => {
Array.from(configuredAccountIds).map((accountId) => [accountId, 0]),
);
try {
- const files = fs
- .readdirSync(credDir)
- .filter(
- (f) => f.startsWith(`${ch}-`) && f.endsWith("-allowFrom.json"),
- );
- for (const file of files) {
- const accountId = resolveCredentialPairingAccountId({
- channel: ch,
- fileName: file,
- });
- if (!accountId || !configuredAccountIds.has(accountId)) continue;
- const data = JSON.parse(
- fs.readFileSync(`${credDir}/${file}`, "utf8"),
- );
- const nextCount =
- Number(pairedByAccount.get(accountId) || 0)
- + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);
- pairedByAccount.set(accountId, nextCount);
+ if (ch !== "whatsapp") {
+ const files = fs
+ .readdirSync(credDir)
+ .filter(
+ (f) => f.startsWith(`${ch}-`) && f.endsWith("-allowFrom.json"),
+ );
+ for (const file of files) {
+ const accountId = resolveCredentialPairingAccountId({
+ channel: ch,
+ fileName: file,
+ });
+ if (!accountId || !configuredAccountIds.has(accountId)) continue;
+ const data = JSON.parse(
+ fs.readFileSync(`${credDir}/${file}`, "utf8"),
+ );
+ const nextCount =
+ Number(pairedByAccount.get(accountId) || 0)
+ + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);
+ pairedByAccount.set(accountId, nextCount);
+ }
}
} catch {}
for (const [accountId, accountConfig] of accountEntries) {
+ if (ch === "whatsapp") continue;
const inlineAllowFrom = accountConfig?.allowFrom;
if (!Array.isArray(inlineAllowFrom)) continue;
const normalizedAccountId = normalizeChannelAccountId(accountId);
@@ -432,6 +461,20 @@ const getChannelStatus = () => {
Number(pairedByAccount.get(normalizedAccountId) || 0) + inlineAllowFrom.length;
pairedByAccount.set(normalizedAccountId, nextCount);
}
+ if (ch === "whatsapp") {
+ for (const [accountId, accountConfig] of accountEntries) {
+ const normalizedAccountId = normalizeChannelAccountId(accountId);
+ if (Number(pairedByAccount.get(normalizedAccountId) || 0) > 0) continue;
+ if (
+ hasImplicitWhatsAppSelfPairing({
+ accountId: normalizedAccountId,
+ accountConfig,
+ })
+ ) {
+ pairedByAccount.set(normalizedAccountId, 1);
+ }
+ }
+ }
const accounts = Object.fromEntries(
Array.from(pairedByAccount.entries()).map(([accountId, paired]) => [
accountId,
diff --git a/lib/server/onboarding/import/secret-detector.js b/lib/server/onboarding/import/secret-detector.js
index e092c58a..4f49e9e8 100644
--- a/lib/server/onboarding/import/secret-detector.js
+++ b/lib/server/onboarding/import/secret-detector.js
@@ -311,6 +311,15 @@ const extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => {
if (channels.discord?.token && !isAlreadyEnvRef(channels.discord.token)) {
preFill.DISCORD_BOT_TOKEN = channels.discord.token;
}
+ const whatsAppAllowFrom = Array.isArray(channels.whatsapp?.allowFrom)
+ ? channels.whatsapp.allowFrom
+ : [];
+ const whatsAppOwner = whatsAppAllowFrom.find(
+ (v) => v && !isAlreadyEnvRef(String(v)),
+ );
+ if (whatsAppOwner) {
+ preFill.WHATSAPP_OWNER_NUMBER = String(whatsAppOwner);
+ }
const braveKey = cfg.tools?.web?.search?.apiKey;
if (braveKey && !isAlreadyEnvRef(braveKey)) {
diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js
index 8bf9a7bf..bc6973f5 100644
--- a/lib/server/onboarding/openclaw.js
+++ b/lib/server/onboarding/openclaw.js
@@ -198,6 +198,19 @@ const applyFreshOnboardingChannels = ({ cfg, varMap }) => {
ensurePluginAllowed({ cfg, pluginKey: "slack" });
console.log("[onboard] Slack configured");
}
+ if (varMap.WHATSAPP_OWNER_NUMBER) {
+ cfg.channels.whatsapp = {
+ enabled: true,
+ allowFrom: [varMap.WHATSAPP_OWNER_NUMBER],
+ groupAllowFrom: [varMap.WHATSAPP_OWNER_NUMBER],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ };
+ cfg.plugins.entries.whatsapp = { enabled: true };
+ ensurePluginAllowed({ cfg, pluginKey: "whatsapp" });
+ console.log("[onboard] WhatsApp configured");
+ }
ensureUsageTrackerPluginEntry(cfg);
};
@@ -280,6 +293,32 @@ const writeManagedImportOpenclawConfig = ({ fs, openclawDir, varMap }) => {
ensurePluginAllowed({ cfg, pluginKey: "slack" });
}
+ if (varMap.WHATSAPP_OWNER_NUMBER) {
+ const existingWhatsApp = cfg.channels.whatsapp || {};
+ const existingAllowFrom = Array.isArray(existingWhatsApp.allowFrom)
+ ? existingWhatsApp.allowFrom
+ : [];
+ const ownerRef = "${WHATSAPP_OWNER_NUMBER}";
+ cfg.channels.whatsapp = {
+ ...existingWhatsApp,
+ enabled: true,
+ allowFrom: existingAllowFrom.includes(ownerRef)
+ ? existingAllowFrom
+ : [...existingAllowFrom, ownerRef],
+ groupAllowFrom: existingAllowFrom.includes(ownerRef)
+ ? existingAllowFrom
+ : [...existingAllowFrom, ownerRef],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ };
+ cfg.plugins.entries.whatsapp = {
+ ...(cfg.plugins.entries.whatsapp || {}),
+ enabled: true,
+ };
+ ensurePluginAllowed({ cfg, pluginKey: "whatsapp" });
+ }
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
};
diff --git a/lib/server/onboarding/validation.js b/lib/server/onboarding/validation.js
index b2c8af9c..4cd77921 100644
--- a/lib/server/onboarding/validation.js
+++ b/lib/server/onboarding/validation.js
@@ -94,7 +94,7 @@ const validateOnboardingInput = ({ vars, modelKey, resolveModelProvider, hasCode
return hasAnyAi;
})();
const hasGithub = !!(githubToken && githubRepoInput);
- const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN));
+ const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) || varMap.WHATSAPP_OWNER_NUMBER);
if (!hasAi) {
if (selectedProvider === "openai-codex") {
diff --git a/lib/server/routes/agents.js b/lib/server/routes/agents.js
index e0fce94e..6af4c029 100644
--- a/lib/server/routes/agents.js
+++ b/lib/server/routes/agents.js
@@ -124,6 +124,45 @@ const registerAgentRoutes = ({
}
});
+ app.post("/api/channels/accounts/login", async (req, res) => {
+ try {
+ const body = req.body || {};
+ const result = await agentsService.runChannelAccountLogin({
+ provider: body.provider,
+ accountId: body.accountId,
+ });
+ return res.json({
+ ok: true,
+ completed: !!result?.ok,
+ stdout: String(result?.stdout || ""),
+ stderr: String(result?.stderr || ""),
+ code: result?.code ?? null,
+ });
+ } catch (error) {
+ const status = String(error.message || "").includes("only supported")
+ ? 400
+ : 500;
+ return res.status(status).json({ ok: false, error: error.message });
+ }
+ });
+
+ app.get("/api/channels/accounts/login-status", (req, res) => {
+ try {
+ const provider = String(req.query?.provider || "").trim();
+ const accountId = String(req.query?.accountId || "").trim() || "default";
+ const result = agentsService.getChannelAccountLoginStatus({
+ provider,
+ accountId,
+ });
+ return res.json({ ok: true, ...result });
+ } catch (error) {
+ const status = String(error.message || "").includes("only supported")
+ ? 400
+ : 500;
+ return res.status(status).json({ ok: false, error: error.message });
+ }
+ });
+
app.delete("/api/channels/accounts", async (req, res) => {
try {
const body = req.body || {};
diff --git a/lib/server/routes/pairings.js b/lib/server/routes/pairings.js
index 30fa6de8..8778a317 100644
--- a/lib/server/routes/pairings.js
+++ b/lib/server/routes/pairings.js
@@ -5,7 +5,7 @@ const { buildManagedPaths } = require("../internal-files-migration");
const { parseJsonObjectFromNoisyOutput } = require("../utils/json");
const { quoteShellArg } = require("../utils/shell");
-const kAllowedPairingChannels = new Set(["telegram", "discord", "slack"]);
+const kAllowedPairingChannels = new Set(["telegram", "discord", "slack", "whatsapp"]);
const kSafePairingArgPattern = /^[\w\-:.]+$/;
const kDevicesListCliTimeoutMs = 5000;
const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
@@ -112,7 +112,7 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
}
const pending = [];
- const channels = ["telegram", "discord", "slack"];
+ const channels = ["telegram", "discord", "slack", "whatsapp"];
for (const ch of channels) {
try {
diff --git a/lib/server/watchdog-notify.js b/lib/server/watchdog-notify.js
index a3c452ae..01fbeda6 100644
--- a/lib/server/watchdog-notify.js
+++ b/lib/server/watchdog-notify.js
@@ -2,8 +2,10 @@ const fs = require("fs");
const path = require("path");
const { OPENCLAW_DIR } = require("./constants");
const { createSlackApi } = require("./slack-api");
+const { quoteShellArg } = require("./utils/shell");
const kSlackBotEnvKey = "SLACK_BOT_TOKEN";
+const kWhatsAppOwnerNumberEnvKey = "WHATSAPP_OWNER_NUMBER";
const normalizeAccountId = (value) =>
String(value || "").trim().toLowerCase() || "default";
@@ -106,6 +108,7 @@ const createWatchdogNotifier = ({
telegramApi,
discordApi,
slackApi,
+ clawCmd = null,
readEnvFile = () => [],
createSlackApi: createSlackApiFactory = createSlackApi,
fsImpl = fs,
@@ -116,7 +119,17 @@ const createWatchdogNotifier = ({
telegram: { sent: 0, failed: 0, skipped: false, targets: 0 },
discord: { sent: 0, failed: 0, skipped: false, targets: 0 },
slack: { sent: 0, failed: 0, skipped: false, targets: 0 },
+ whatsapp: { sent: 0, failed: 0, skipped: false, targets: 0 },
};
+ const envVars = typeof readEnvFile === "function" ? readEnvFile() : [];
+ const envMap = new Map(
+ (Array.isArray(envVars) ? envVars : [])
+ .map((entry) => [
+ String(entry?.key || "").trim(),
+ String(entry?.value || "").trim(),
+ ])
+ .filter(([key]) => key),
+ );
const telegramTargets = getPairedIds({
channel: "telegram",
fsImpl,
@@ -174,17 +187,6 @@ const createWatchdogNotifier = ({
summary.slack.skipped = true;
} else {
const eventType = opts.eventType || "info"; // crash, recovery, health, info
- const envVars = typeof readEnvFile === "function" ? readEnvFile() : [];
-
- const envMap = new Map(
- (Array.isArray(envVars) ? envVars : [])
- .map((entry) => [
- String(entry?.key || "").trim(),
- String(entry?.value || "").trim(),
- ])
- .filter(([key]) => key),
- );
-
for (const [accountId, slackTargets] of slackTargetsByAccount.entries()) {
if (!slackTargets.length) continue;
const envKey = deriveSlackBotEnvKey(accountId);
@@ -261,8 +263,47 @@ const createWatchdogNotifier = ({
}
}
- const sent = summary.telegram.sent + summary.discord.sent + summary.slack.sent;
- const failed = summary.telegram.failed + summary.discord.failed + summary.slack.failed;
+ const whatsAppOwnerNumber = String(
+ envMap.get(kWhatsAppOwnerNumberEnvKey) ||
+ process.env[kWhatsAppOwnerNumberEnvKey] ||
+ "",
+ ).trim();
+ const whatsappTargets = whatsAppOwnerNumber ? [whatsAppOwnerNumber] : [];
+ summary.whatsapp.targets = whatsappTargets.length;
+ if (!clawCmd || whatsappTargets.length === 0) {
+ summary.whatsapp.skipped = true;
+ } else {
+ for (const target of whatsappTargets) {
+ try {
+ const result = await clawCmd(
+ `message send --channel whatsapp --target ${quoteShellArg(
+ String(target || "").trim(),
+ )} --message ${quoteShellArg(String(message || ""))}`,
+ { quiet: true, timeoutMs: 30000 },
+ );
+ if (!result?.ok) {
+ throw new Error(
+ String(result?.stderr || result?.stdout || "WhatsApp send failed"),
+ );
+ }
+ summary.whatsapp.sent += 1;
+ } catch (err) {
+ summary.whatsapp.failed += 1;
+ console.error(`[watchdog] whatsapp notification failed for ${target}: ${err.message}`);
+ }
+ }
+ }
+
+ const sent =
+ summary.telegram.sent +
+ summary.discord.sent +
+ summary.slack.sent +
+ summary.whatsapp.sent;
+ const failed =
+ summary.telegram.failed +
+ summary.discord.failed +
+ summary.slack.failed +
+ summary.whatsapp.failed;
return {
ok: sent > 0,
sent,
diff --git a/tests/server/agents-service.test.js b/tests/server/agents-service.test.js
index c23c8a54..05b4b94b 100644
--- a/tests/server/agents-service.test.js
+++ b/tests/server/agents-service.test.js
@@ -7,7 +7,21 @@ const buildFsMock = ({ initialConfig = {}, fileContents = {} } = {}) => {
const extraFiles = new Map(Object.entries(fileContents));
return {
existsSync: vi.fn(
- (targetPath) => files.has(targetPath) || directories.has(targetPath),
+ (targetPath) => {
+ const normalizedTargetPath = String(targetPath || "");
+ if (files.has(normalizedTargetPath) || directories.has(normalizedTargetPath)) {
+ return true;
+ }
+ if (extraFiles.has(normalizedTargetPath)) {
+ return true;
+ }
+ const prefix = normalizedTargetPath.endsWith("/")
+ ? normalizedTargetPath
+ : `${normalizedTargetPath}/`;
+ return Array.from(extraFiles.keys()).some((filePath) =>
+ String(filePath || "").startsWith(prefix),
+ );
+ },
),
mkdirSync: vi.fn((targetPath) => {
directories.add(targetPath);
@@ -31,7 +45,7 @@ const buildFsMock = ({ initialConfig = {}, fileContents = {} } = {}) => {
if (extraFiles.has(normalizedTargetPath)) {
return String(extraFiles.get(normalizedTargetPath));
}
- return JSON.stringify(currentConfig);
+ throw new Error(`ENOENT: ${normalizedTargetPath}`);
}),
writeFileSync: vi.fn((targetPath, content) => {
if (String(targetPath || "").endsWith("openclaw.json")) {
@@ -556,6 +570,200 @@ describe("server/agents/service", () => {
]);
});
+ it("treats whatsapp owner-number self chat as paired when saved creds exist", () => {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ dmPolicy: "pairing",
+ },
+ },
+ },
+ },
+ },
+ fileContents: {
+ "/tmp/openclaw/credentials/whatsapp/default/creds.json": "{}",
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/tmp/openclaw",
+ readEnvFile: () => [{ key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" }],
+ });
+
+ expect(service.listConfiguredChannelAccounts()).toEqual([
+ {
+ channel: "whatsapp",
+ accounts: [
+ {
+ id: "default",
+ name: "WhatsApp",
+ envKey: "WHATSAPP_OWNER_NUMBER",
+ token: "********",
+ boundAgentId: "",
+ paired: 1,
+ status: "paired",
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("keeps whatsapp configured when owner number exists but saved creds do not", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ dmPolicy: "pairing",
+ },
+ },
+ },
+ },
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/tmp/openclaw",
+ readEnvFile: () => [{ key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" }],
+ });
+
+ expect(service.listConfiguredChannelAccounts()).toEqual([
+ {
+ channel: "whatsapp",
+ accounts: [
+ {
+ id: "default",
+ name: "WhatsApp",
+ envKey: "WHATSAPP_OWNER_NUMBER",
+ token: "********",
+ boundAgentId: "",
+ paired: 0,
+ status: "configured",
+ },
+ ],
+ },
+ ]);
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
+
+ it("does not treat whatsapp allowFrom owner placeholder as paired without saved creds", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ allowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ groupAllowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/tmp/openclaw",
+ readEnvFile: () => [{ key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" }],
+ });
+
+ expect(service.listConfiguredChannelAccounts()).toEqual([
+ {
+ channel: "whatsapp",
+ accounts: [
+ {
+ id: "default",
+ name: "WhatsApp",
+ envKey: "WHATSAPP_OWNER_NUMBER",
+ token: "********",
+ boundAgentId: "",
+ paired: 0,
+ status: "configured",
+ },
+ ],
+ },
+ ]);
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
+
+ it("treats whatsapp allowFrom owner placeholder as paired when saved creds exist", () => {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ allowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ groupAllowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ },
+ },
+ },
+ },
+ },
+ fileContents: {
+ "/tmp/openclaw/credentials/whatsapp/default/creds.json": "{}",
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/tmp/openclaw",
+ readEnvFile: () => [{ key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" }],
+ });
+
+ expect(service.listConfiguredChannelAccounts()).toEqual([
+ {
+ channel: "whatsapp",
+ accounts: [
+ {
+ id: "default",
+ name: "WhatsApp",
+ envKey: "WHATSAPP_OWNER_NUMBER",
+ token: "********",
+ boundAgentId: "",
+ paired: 1,
+ status: "paired",
+ },
+ ],
+ },
+ ]);
+ });
+
it("masks configured channel token values when listing accounts", () => {
const fsMock = buildFsMock({
initialConfig: {
@@ -1408,6 +1616,232 @@ describe("server/agents/service", () => {
);
});
+ it("creates a whatsapp channel account with allowlist defaults", async () => {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ agents: {
+ list: [{ id: "main", default: true }],
+ },
+ },
+ });
+ const writeEnvFile = vi.fn();
+ const reloadEnv = vi.fn();
+ const restartGateway = vi.fn(async () => {});
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ readEnvFile: vi.fn(() => []),
+ writeEnvFile,
+ reloadEnv,
+ restartGateway,
+ clawCmd: vi.fn(async () => ({ ok: true })),
+ });
+
+ const result = await service.createChannelAccount({
+ provider: "whatsapp",
+ name: "WhatsApp",
+ accountId: "default",
+ token: "+15551234567",
+ agentId: "main",
+ });
+
+ expect(result).toMatchObject({
+ channel: "whatsapp",
+ account: {
+ id: "default",
+ name: "WhatsApp",
+ envKey: "WHATSAPP_OWNER_NUMBER",
+ },
+ binding: {
+ agentId: "main",
+ match: { channel: "whatsapp", accountId: "default" },
+ },
+ });
+ expect(writeEnvFile).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ { key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" },
+ ]),
+ );
+ expect(reloadEnv).toHaveBeenCalled();
+ expect(restartGateway).toHaveBeenCalled();
+ const savedConfig = fsMock.readConfig();
+ expect(savedConfig.channels?.whatsapp?.accounts?.default).toMatchObject({
+ name: "WhatsApp",
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ });
+ });
+
+ it("prevents creating multiple whatsapp channel accounts", async () => {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ agents: {
+ list: [{ id: "main", default: true }],
+ },
+ channels: {
+ whatsapp: {
+ enabled: true,
+ defaultAccount: "default",
+ accounts: {
+ default: {
+ allowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ },
+ },
+ },
+ },
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ readEnvFile: vi.fn(() => [{ key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" }]),
+ writeEnvFile: vi.fn(),
+ reloadEnv: vi.fn(),
+ restartGateway: vi.fn(async () => {}),
+ clawCmd: vi.fn(async () => ({ ok: true })),
+ });
+
+ await expect(
+ service.createChannelAccount({
+ provider: "whatsapp",
+ name: "WhatsApp 2",
+ accountId: "alerts",
+ token: "+15557654321",
+ agentId: "main",
+ }),
+ ).rejects.toThrow("WhatsApp supports a single channel account");
+ });
+
+ it("runs channel account login for whatsapp", async () => {
+ const fsMock = buildFsMock({
+ initialConfig: {},
+ });
+ const clawCmd = vi.fn(async () => ({
+ ok: true,
+ stdout: "QR code displayed",
+ stderr: "",
+ }));
+ const restartGateway = vi.fn(async () => {});
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ readEnvFile: vi.fn(() => []),
+ writeEnvFile: vi.fn(),
+ reloadEnv: vi.fn(),
+ restartGateway,
+ clawCmd,
+ });
+
+ const result = await service.runChannelAccountLogin({
+ provider: "whatsapp",
+ accountId: "default",
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.completed).toBe(true);
+ expect(clawCmd).toHaveBeenCalledWith(
+ expect.stringContaining("channels login"),
+ expect.objectContaining({ quiet: true }),
+ );
+ expect(restartGateway).not.toHaveBeenCalled();
+ });
+
+ it("does not restart gateway when whatsapp login is not complete", async () => {
+ const fsMock = buildFsMock({
+ initialConfig: {},
+ });
+ const clawCmd = vi.fn(async () => ({
+ ok: false,
+ stdout: "Waiting for WhatsApp connection...",
+ stderr: "",
+ }));
+ const restartGateway = vi.fn(async () => {});
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ readEnvFile: vi.fn(() => []),
+ writeEnvFile: vi.fn(),
+ reloadEnv: vi.fn(),
+ restartGateway,
+ clawCmd,
+ });
+
+ const result = await service.runChannelAccountLogin({
+ provider: "whatsapp",
+ accountId: "default",
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.completed).toBe(false);
+ expect(restartGateway).not.toHaveBeenCalled();
+ });
+
+ it("reports whatsapp login linked status when saved creds exist", () => {
+ const fsMock = buildFsMock({
+ initialConfig: {},
+ fileContents: {
+ "/test/.openclaw/credentials/whatsapp/default/creds.json": "{}",
+ },
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ });
+
+ expect(
+ service.getChannelAccountLoginStatus({
+ provider: "whatsapp",
+ accountId: "default",
+ }),
+ ).toEqual({
+ provider: "whatsapp",
+ accountId: "default",
+ linked: true,
+ });
+ });
+
+ it("reports whatsapp login unlinked status when saved creds do not exist", () => {
+ const fsMock = buildFsMock({
+ initialConfig: {},
+ });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ });
+
+ expect(
+ service.getChannelAccountLoginStatus({
+ provider: "whatsapp",
+ accountId: "default",
+ }),
+ ).toEqual({
+ provider: "whatsapp",
+ accountId: "default",
+ linked: false,
+ });
+ });
+
+ it("rejects channel login for non-whatsapp providers", async () => {
+ const fsMock = buildFsMock({ initialConfig: {} });
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/test/.openclaw",
+ readEnvFile: vi.fn(() => []),
+ writeEnvFile: vi.fn(),
+ reloadEnv: vi.fn(),
+ restartGateway: vi.fn(async () => {}),
+ clawCmd: vi.fn(async () => ({ ok: true })),
+ });
+
+ await expect(
+ service.runChannelAccountLogin({
+ provider: "telegram",
+ accountId: "default",
+ }),
+ ).rejects.toThrow("Channel login is currently only supported for WhatsApp");
+ });
+
it("updates channel account name and bound agent", () => {
const fsMock = buildFsMock({
initialConfig: {
@@ -2053,6 +2487,112 @@ describe("server/agents/service", () => {
);
});
+ it("deletes whatsapp channels via channel cli and disables the plugin entry", async () => {
+ const fsMock = buildFsMock({
+ initialConfig: {
+ agents: {
+ list: [{ id: "main", default: true }],
+ },
+ channels: {
+ whatsapp: {
+ enabled: true,
+ dmPolicy: "pairing",
+ groupPolicy: "allowlist",
+ debounceMs: 0,
+ mediaMaxMb: 50,
+ },
+ },
+ plugins: {
+ allow: ["whatsapp"],
+ entries: {
+ whatsapp: { enabled: true },
+ },
+ },
+ bindings: [
+ {
+ agentId: "main",
+ match: { channel: "whatsapp", accountId: "default" },
+ },
+ ],
+ },
+ fileContents: {
+ "/tmp/openclaw/credentials/creds.json": "{}",
+ "/tmp/openclaw/credentials/creds.json.bak": "{}",
+ "/tmp/openclaw/credentials/session-foo.json": "{}",
+ },
+ });
+ const readEnvFile = vi.fn(() => [
+ { key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" },
+ ]);
+ const writeEnvFile = vi.fn();
+ const reloadEnv = vi.fn();
+ const clawCmd = vi.fn(async () => {
+ const config = fsMock.readConfig();
+ delete config.channels.whatsapp;
+ fsMock.writeFileSync(
+ "/tmp/openclaw/openclaw.json",
+ JSON.stringify(config),
+ );
+ return { ok: true, stdout: "", stderr: "" };
+ });
+ const restartGateway = vi.fn(async () => {});
+ const service = createAgentsService({
+ fs: fsMock,
+ OPENCLAW_DIR: "/tmp/openclaw",
+ readEnvFile,
+ writeEnvFile,
+ reloadEnv,
+ clawCmd,
+ restartGateway,
+ });
+
+ const result = await service.deleteChannelAccount({
+ provider: "whatsapp",
+ accountId: "default",
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(clawCmd).toHaveBeenCalledWith(
+ "channels remove --channel 'whatsapp' --account 'default' --delete",
+ { quiet: true, timeoutMs: 30000 },
+ );
+ expect(writeEnvFile).toHaveBeenCalledWith([]);
+ expect(reloadEnv).toHaveBeenCalled();
+ expect(restartGateway).toHaveBeenCalledTimes(1);
+ expect(fsMock.rmSync).toHaveBeenCalledWith(
+ "/tmp/openclaw/credentials/whatsapp/default",
+ { recursive: true, force: true },
+ );
+ expect(fsMock.rmSync).toHaveBeenCalledWith(
+ "/tmp/openclaw/credentials/whatsapp",
+ { recursive: true, force: true },
+ );
+ expect(fsMock.rmSync).toHaveBeenCalledWith(
+ "/tmp/openclaw/credentials/creds.json",
+ { force: true },
+ );
+ expect(fsMock.rmSync).toHaveBeenCalledWith(
+ "/tmp/openclaw/credentials/creds.json.bak",
+ { force: true },
+ );
+ expect(fsMock.rmSync).toHaveBeenCalledWith(
+ "/tmp/openclaw/credentials/session-foo.json",
+ { force: true },
+ );
+ expect(fsMock.readConfig()).toEqual(
+ expect.objectContaining({
+ channels: {},
+ plugins: {
+ allow: ["whatsapp"],
+ entries: {
+ whatsapp: { enabled: false },
+ },
+ },
+ bindings: [],
+ }),
+ );
+ });
+
it("deletes slack channel env vars including app token", async () => {
const fsMock = buildFsMock({
initialConfig: {
diff --git a/tests/server/gateway.test.js b/tests/server/gateway.test.js
index d3626fc7..8a0547c7 100644
--- a/tests/server/gateway.test.js
+++ b/tests/server/gateway.test.js
@@ -418,4 +418,198 @@ describe("server/gateway restart behavior", () => {
},
});
});
+
+ it("treats whatsapp owner-number self chat as paired when saved creds exist", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ fs.existsSync = vi.fn(() => true);
+ fs.readdirSync = vi.fn(() => []);
+ fs.readFileSync = vi.fn((targetPath, ...args) => {
+ if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {
+ return JSON.stringify({
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ dmPolicy: "pairing",
+ },
+ },
+ },
+ },
+ });
+ }
+ if (targetPath === `${OPENCLAW_DIR}/credentials/whatsapp/default/creds.json`) {
+ return "{}";
+ }
+ return originalReadFileSync(targetPath, ...args);
+ });
+ delete require.cache[modulePath];
+ const gateway = require(modulePath);
+
+ expect(gateway.getChannelStatus()).toEqual({
+ whatsapp: {
+ status: "paired",
+ paired: 1,
+ accounts: {
+ default: { status: "paired", paired: 1 },
+ },
+ },
+ });
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
+
+ it("keeps whatsapp configured when owner number exists but saved creds do not", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ fs.existsSync = vi.fn(() => true);
+ fs.readdirSync = vi.fn(() => []);
+ fs.readFileSync = vi.fn((targetPath, ...args) => {
+ if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {
+ return JSON.stringify({
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ dmPolicy: "pairing",
+ },
+ },
+ },
+ },
+ });
+ }
+ return originalReadFileSync(targetPath, ...args);
+ });
+ delete require.cache[modulePath];
+ const gateway = require(modulePath);
+
+ expect(gateway.getChannelStatus()).toEqual({
+ whatsapp: {
+ status: "configured",
+ paired: 0,
+ accounts: {
+ default: { status: "configured", paired: 0 },
+ },
+ },
+ });
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
+
+ it("does not treat whatsapp allowFrom owner placeholder as paired without saved creds", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ fs.existsSync = vi.fn(() => true);
+ fs.readdirSync = vi.fn(() => []);
+ fs.readFileSync = vi.fn((targetPath, ...args) => {
+ if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {
+ return JSON.stringify({
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ allowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ groupAllowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ },
+ },
+ },
+ },
+ });
+ }
+ return originalReadFileSync(targetPath, ...args);
+ });
+ delete require.cache[modulePath];
+ const gateway = require(modulePath);
+
+ expect(gateway.getChannelStatus()).toEqual({
+ whatsapp: {
+ status: "configured",
+ paired: 0,
+ accounts: {
+ default: { status: "configured", paired: 0 },
+ },
+ },
+ });
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
+
+ it("treats whatsapp allowFrom owner placeholder as paired when saved creds exist", () => {
+ const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;
+ process.env.WHATSAPP_OWNER_NUMBER = "+15551234567";
+ try {
+ fs.existsSync = vi.fn(() => true);
+ fs.readdirSync = vi.fn(() => []);
+ fs.readFileSync = vi.fn((targetPath, ...args) => {
+ if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {
+ return JSON.stringify({
+ channels: {
+ whatsapp: {
+ enabled: true,
+ accounts: {
+ default: {
+ name: "WhatsApp",
+ allowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ groupAllowFrom: ["${WHATSAPP_OWNER_NUMBER}"],
+ dmPolicy: "allowlist",
+ groupPolicy: "allowlist",
+ selfChatMode: true,
+ },
+ },
+ },
+ },
+ });
+ }
+ if (targetPath === `${OPENCLAW_DIR}/credentials/whatsapp/default/creds.json`) {
+ return "{}";
+ }
+ return originalReadFileSync(targetPath, ...args);
+ });
+ delete require.cache[modulePath];
+ const gateway = require(modulePath);
+
+ expect(gateway.getChannelStatus()).toEqual({
+ whatsapp: {
+ status: "paired",
+ paired: 1,
+ accounts: {
+ default: { status: "paired", paired: 1 },
+ },
+ },
+ });
+ } finally {
+ if (previousOwnerNumber === undefined) {
+ delete process.env.WHATSAPP_OWNER_NUMBER;
+ } else {
+ process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;
+ }
+ }
+ });
});
diff --git a/tests/server/onboarding-validation.test.js b/tests/server/onboarding-validation.test.js
index f1612a8e..b179738c 100644
--- a/tests/server/onboarding-validation.test.js
+++ b/tests/server/onboarding-validation.test.js
@@ -39,4 +39,19 @@ describe("onboarding/validation", () => {
expect(res.ok).toBe(false);
expect(res.error).toBe('Missing credentials for selected provider "openrouter"');
});
+
+ it("accepts whatsapp owner number as the required channel credential", () => {
+ const res = validateOnboardingInput({
+ vars: [
+ { key: "GITHUB_TOKEN", value: "ghp_test" },
+ { key: "GITHUB_WORKSPACE_REPO", value: "owner/repo" },
+ { key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" },
+ { key: "OPENAI_API_KEY", value: "sk-test-123" },
+ ],
+ modelKey: "openai/gpt-5.1-codex",
+ resolveModelProvider: kResolveProvider,
+ hasCodexOauthProfile: () => false,
+ });
+ expect(res.ok).toBe(true);
+ });
});
diff --git a/tests/server/routes-agents.test.js b/tests/server/routes-agents.test.js
index d67dc2bf..cc36434a 100644
--- a/tests/server/routes-agents.test.js
+++ b/tests/server/routes-agents.test.js
@@ -50,6 +50,18 @@ const createAgentsServiceMock = () => ({
token: "123:abc",
})),
deleteChannelAccount: vi.fn(() => ({ ok: true })),
+ runChannelAccountLogin: vi.fn(() => ({
+ ok: true,
+ stdout: "QR code displayed",
+ stderr: "",
+ code: 0,
+ completed: true,
+ })),
+ getChannelAccountLoginStatus: vi.fn((input) => ({
+ provider: input.provider,
+ accountId: input.accountId || "default",
+ linked: true,
+ })),
getAgent: vi.fn((id) =>
id === "main" ? { id: "main", name: "Main Agent", default: true } : null,
),
@@ -282,6 +294,80 @@ describe("server/routes/agents", () => {
expect(response.body.appToken).toBe("xapp-token");
});
+ it("runs channel login on POST /api/channels/accounts/login", async () => {
+ const agentsService = createAgentsServiceMock();
+ const app = createApp(agentsService);
+
+ const response = await request(app)
+ .post("/api/channels/accounts/login")
+ .send({ provider: "whatsapp", accountId: "default" });
+
+ expect(response.status).toBe(200);
+ expect(response.body.ok).toBe(true);
+ expect(response.body.completed).toBe(true);
+ expect(response.body.code).toBe(0);
+ expect(agentsService.runChannelAccountLogin).toHaveBeenCalledWith({
+ provider: "whatsapp",
+ accountId: "default",
+ });
+ });
+
+ it("returns login output with completed=false when CLI login is not complete", async () => {
+ const agentsService = createAgentsServiceMock();
+ agentsService.runChannelAccountLogin.mockReturnValue({
+ ok: false,
+ stdout: "Waiting for WhatsApp connection...",
+ stderr: "",
+ code: 1,
+ });
+ const app = createApp(agentsService);
+
+ const response = await request(app)
+ .post("/api/channels/accounts/login")
+ .send({ provider: "whatsapp", accountId: "default" });
+
+ expect(response.status).toBe(200);
+ expect(response.body.ok).toBe(true);
+ expect(response.body.completed).toBe(false);
+ expect(response.body.stdout).toContain("Waiting for WhatsApp connection");
+ });
+
+ it("returns 400 for unsupported channel login provider", async () => {
+ const agentsService = createAgentsServiceMock();
+ agentsService.runChannelAccountLogin.mockImplementation(() => {
+ throw new Error("Channel login is currently only supported for WhatsApp");
+ });
+ const app = createApp(agentsService);
+
+ const response = await request(app)
+ .post("/api/channels/accounts/login")
+ .send({ provider: "telegram", accountId: "default" });
+
+ expect(response.status).toBe(400);
+ expect(response.body.ok).toBe(false);
+ });
+
+ it("returns whatsapp login status on GET /api/channels/accounts/login-status", async () => {
+ const agentsService = createAgentsServiceMock();
+ const app = createApp(agentsService);
+
+ const response = await request(app).get(
+ "/api/channels/accounts/login-status?provider=whatsapp&accountId=default",
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ ok: true,
+ provider: "whatsapp",
+ accountId: "default",
+ linked: true,
+ });
+ expect(agentsService.getChannelAccountLoginStatus).toHaveBeenCalledWith({
+ provider: "whatsapp",
+ accountId: "default",
+ });
+ });
+
it("deletes a configured channel account on DELETE /api/channels/accounts", async () => {
const agentsService = createAgentsServiceMock();
const app = createApp(agentsService);
diff --git a/tests/server/watchdog-notify.test.js b/tests/server/watchdog-notify.test.js
index d941f84a..92f4f953 100644
--- a/tests/server/watchdog-notify.test.js
+++ b/tests/server/watchdog-notify.test.js
@@ -156,4 +156,62 @@ describe("server/watchdog-notify", () => {
"[watchdog] slack notification failed for alerts/U_ALERTS_MISSING: missing SLACK_BOT_TOKEN_ALERTS",
);
});
+
+ it("delivers whatsapp watchdog notices via clawCmd message send for owner self chat", async () => {
+ const clawCmd = vi.fn(async () => ({ ok: true, stdout: "sent", stderr: "" }));
+ const notifier = createWatchdogNotifier({
+ clawCmd,
+ readEnvFile: () => [
+ { key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" },
+ ],
+ });
+
+ const result = await notifier.notify("Gateway healthy again");
+
+ expect(result.ok).toBe(true);
+ expect(result.sent).toBe(1);
+ expect(result.channels.whatsapp).toEqual({
+ sent: 1,
+ failed: 0,
+ skipped: false,
+ targets: 1,
+ });
+ expect(clawCmd).toHaveBeenCalledWith(
+ expect.stringContaining("message send --channel whatsapp"),
+ expect.objectContaining({ quiet: true, timeoutMs: 30000 }),
+ );
+ expect(clawCmd).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '--target "+15551234567" --message "Gateway healthy again"',
+ ),
+ expect.any(Object),
+ );
+ });
+
+ it("counts whatsapp watchdog notices as failed when clawCmd returns ok false", async () => {
+ const clawCmd = vi.fn(async () => ({
+ ok: false,
+ stdout: "",
+ stderr: "No active WhatsApp Web listener",
+ code: 1,
+ }));
+ const notifier = createWatchdogNotifier({
+ clawCmd,
+ readEnvFile: () => [
+ { key: "WHATSAPP_OWNER_NUMBER", value: "+15551234567" },
+ ],
+ });
+
+ const result = await notifier.notify("Gateway healthy again");
+
+ expect(result.ok).toBe(false);
+ expect(result.sent).toBe(0);
+ expect(result.failed).toBe(1);
+ expect(result.channels.whatsapp).toEqual({
+ sent: 0,
+ failed: 1,
+ skipped: false,
+ targets: 1,
+ });
+ });
});