diff --git a/.changeset/fn-8418-whatsapp-settings-pairing.md b/.changeset/fn-8418-whatsapp-settings-pairing.md
new file mode 100644
index 0000000000..2bb2abe3dd
--- /dev/null
+++ b/.changeset/fn-8418-whatsapp-settings-pairing.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Show WhatsApp pairing QR and setup instructions in plugin settings.
+category: feature
+dev: Plugin Manager mounts WhatsAppChatPairingPanel; /status includes pairing data; logout starts fresh QR/code pairing.
diff --git a/docs/plugin-management.md b/docs/plugin-management.md
index 9ec198f011..b4bacd7413 100644
--- a/docs/plugin-management.md
+++ b/docs/plugin-management.md
@@ -83,7 +83,7 @@ Expected outcome: You have a terminal view of installed plugins for scripting/re
Expected outcome: Plugin is registered and appears with an initial state (typically `installed` then `started` when enabled/loaded).
-> WhatsApp Chat plugin note: Pair it directly to WhatsApp Web (multi-device) via QR (`/api/plugins/fusion-plugin-whatsapp-chat/qr`) or pairing code (`/api/plugins/fusion-plugin-whatsapp-chat/pair-code`). No public webhook endpoint or Meta Cloud credentials are required. Keep `allowedSenders` populated (empty list blocks all inbound messages), and use `/logout` to force re-pairing.
+> WhatsApp Chat plugin note: After installing, open the **WhatsApp Chat** settings detail in **Settings → Plugins → Fusion Plugins**. Its pairing panel shows live status and the scannable QR for WhatsApp Web (multi-device), or lets you request a pairing code after setting a phone number. Set `allowedSenders` before relying on inbound messages—an empty list blocks all inbound senders. Use **Logout / re-pair** in the same panel to start a fresh QR/code session. The `/qr`, `/pair-code`, `/status`, and `/logout` routes remain available for advanced troubleshooting; no public webhook endpoint or Meta Cloud credentials are required.
### Install from local path (dashboard)
diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx
index 89bc2f78fe..03614e129b 100644
--- a/packages/dashboard/app/components/PluginManager.tsx
+++ b/packages/dashboard/app/components/PluginManager.tsx
@@ -17,6 +17,7 @@ import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink,
import { fetchPlugins, fetchPluginRegistry, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import { LoadingSpinner } from "./LoadingSpinner";
+import { WhatsAppChatPairingPanel } from "./WhatsAppChatPairingPanel";
import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
import type { PluginSetupStatusResponse, RegistryPluginEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
@@ -792,6 +793,9 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
{t("plugins.settings", "Settings")}
+ {selectedPlugin.id === "fusion-plugin-whatsapp-chat" && (
+
+ )}
{settingsLoading ? (
) : (() => {
diff --git a/packages/dashboard/app/components/WhatsAppChatPairingPanel.css b/packages/dashboard/app/components/WhatsAppChatPairingPanel.css
new file mode 100644
index 0000000000..c0e5b5b176
--- /dev/null
+++ b/packages/dashboard/app/components/WhatsAppChatPairingPanel.css
@@ -0,0 +1,115 @@
+/*
+FNXC:WhatsAppSettingsPairing 2026-07-20-12:00:
+The pairing panel must remain readable in the constrained Settings modal on desktop and mobile. Its QR, code action, and instructions use the shared plugin/button primitives and design tokens rather than a parallel settings surface.
+*/
+.whatsapp-pairing-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ padding: var(--space-md);
+ border: var(--btn-border-width) solid var(--border);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--surface) 92%, var(--accent));
+}
+
+.whatsapp-pairing-heading-row,
+.whatsapp-pairing-code-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+}
+
+.whatsapp-pairing-description,
+.whatsapp-pairing-instructions h6 {
+ margin: 0;
+}
+
+.whatsapp-pairing-description {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.whatsapp-pairing-status {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ padding: var(--space-sm);
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--text-muted) 10%, transparent);
+}
+
+.whatsapp-pairing-status--connected,
+.whatsapp-pairing-success {
+ color: var(--color-success);
+}
+
+.whatsapp-pairing-status--error {
+ color: var(--color-error);
+}
+
+.whatsapp-pairing-qr {
+ width: fit-content;
+ max-width: 100%;
+ padding: var(--space-sm);
+ border: var(--btn-border-width) solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+}
+
+.whatsapp-pairing-qr-image {
+ display: block;
+ width: min(calc(var(--space-2xl) * 6), 100%);
+ height: auto;
+ aspect-ratio: 1 / 1;
+}
+
+.whatsapp-pairing-code {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.whatsapp-pairing-code-controls .input {
+ flex: 1;
+ min-inline-size: min(calc(var(--space-2xl) * 6), 100%);
+}
+
+.whatsapp-pairing-code-output {
+ width: fit-content;
+ padding: var(--space-sm);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-family: var(--font-mono);
+ font-weight: 600;
+}
+
+.whatsapp-pairing-success {
+ margin: 0;
+}
+
+.whatsapp-pairing-instructions {
+ padding-top: var(--space-md);
+ border-top: var(--btn-border-width) solid var(--border);
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.whatsapp-pairing-instructions ol {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-block: var(--space-sm) 0;
+ padding-inline-start: var(--space-lg);
+}
+
+.whatsapp-pairing-instructions strong {
+ color: var(--text);
+}
+
+@media (max-width: 640px) {
+ .whatsapp-pairing-code-controls > * {
+ width: 100%;
+ }
+}
diff --git a/packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx b/packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx
new file mode 100644
index 0000000000..1b1dff6321
--- /dev/null
+++ b/packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useEffect, useState } from "react";
+import "./WhatsAppChatPairingPanel.css";
+
+type WhatsAppStatus = {
+ status: "starting" | "awaiting-qr" | "awaiting-code" | "connected" | "disconnected" | "error";
+ jid?: string;
+ lastError?: string;
+ qrDataUrl?: string;
+ pairingCode?: string;
+};
+
+export interface WhatsAppChatPairingPanelProps {
+ projectId?: string;
+ settings?: Record
;
+}
+
+const PLUGIN_PATH = "/api/plugins/fusion-plugin-whatsapp-chat";
+const E164_DIGITS = /^\d+$/;
+
+function pluginUrl(path: string, projectId?: string): string {
+ const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
+ return `${PLUGIN_PATH}${path}${query}`;
+}
+
+/**
+ * FNXC:WhatsAppSettingsPairing 2026-07-20-12:00:
+ * WhatsApp pairing belongs in Plugin Manager settings: operators need a scannable QR, connection feedback, and configuration guidance without discovering raw plugin API routes.
+ */
+export function WhatsAppChatPairingPanel({ projectId, settings }: WhatsAppChatPairingPanelProps) {
+ const [connection, setConnection] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [phoneNumber, setPhoneNumber] = useState(() => String(settings?.pairingPhoneNumber ?? ""));
+ const [actionError, setActionError] = useState(null);
+ const [busy, setBusy] = useState<"code" | "logout" | null>(null);
+
+ const refreshStatus = useCallback(async () => {
+ try {
+ const response = await fetch(pluginUrl("/status", projectId));
+ const payload = await response.json() as WhatsAppStatus & { error?: string };
+ if (!response.ok) throw new Error(payload.error ?? "WhatsApp pairing is unavailable. Enable the plugin and try again.");
+ setConnection(payload);
+ setLoadError(null);
+ } catch (error) {
+ setLoadError(error instanceof Error ? error.message : String(error));
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ void refreshStatus();
+ const interval = window.setInterval(() => void refreshStatus(), 5000);
+ return () => window.clearInterval(interval);
+ }, [refreshStatus]);
+
+ const requestPairingCode = async () => {
+ const normalizedPhoneNumber = phoneNumber.trim();
+ if (!normalizedPhoneNumber || !E164_DIGITS.test(normalizedPhoneNumber)) {
+ setActionError("Enter an E.164 phone number using digits only, without +.");
+ return;
+ }
+ setBusy("code");
+ setActionError(null);
+ try {
+ const response = await fetch(pluginUrl("/pair-code", projectId), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ phoneNumber: normalizedPhoneNumber, ...(projectId ? { projectId } : {}) }),
+ });
+ const payload = await response.json() as { pairingCode?: string; error?: string };
+ if (!response.ok) throw new Error(payload.error ?? "Could not request a pairing code.");
+ setConnection((current) => ({ status: "awaiting-code", ...current, pairingCode: payload.pairingCode }));
+ await refreshStatus();
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const logoutForRepair = async () => {
+ setBusy("logout");
+ setActionError(null);
+ try {
+ const response = await fetch(pluginUrl("/logout", projectId), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(projectId ? { projectId } : {}),
+ });
+ if (!response.ok) {
+ const payload = await response.json() as { error?: string };
+ throw new Error(payload.error ?? "Could not log out from WhatsApp.");
+ }
+ /**
+ * FNXC:WhatsAppSettingsRePair 2026-07-20-12:00:
+ * The logout control relies on the connection layer immediately scheduling a fresh pairing session, so polling can replace the old session with a QR or code-ready state.
+ */
+ await refreshStatus();
+ } catch (error) {
+ setActionError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const pairingMode = settings?.pairingMode === "code" ? "code" : "qr";
+ const state = connection?.status ?? "starting";
+
+ return (
+
+
+
+
WhatsApp pairing
+
Pair and monitor this project's WhatsApp connection here.
+
+
void refreshStatus()}>Refresh status
+
+
+
+ Status: {state}
+ {connection?.jid && Connected as {connection.jid} }
+ {(connection?.lastError || loadError) && {connection?.lastError ?? loadError} }
+
+
+ {state === "awaiting-qr" && (
+
+ {connection?.qrDataUrl ? (
+
+ ) : (
+
Waiting for a fresh QR code. Keep this panel open and refresh if needed.
+ )}
+
+ )}
+
+ {(pairingMode === "code" || state === "awaiting-code") && (
+
+
Phone number (E.164 digits without +)
+
+ setPhoneNumber(event.target.value)} inputMode="numeric" />
+ void requestPairingCode()} disabled={busy !== null}>
+ {busy === "code" ? "Requesting code..." : "Request pairing code"}
+
+
+ {connection?.pairingCode &&
{connection.pairingCode} }
+
+ )}
+
+ {state === "connected" && WhatsApp is paired and ready to receive messages from allowed senders.
}
+ {actionError && {actionError}
}
+
+ void logoutForRepair()} disabled={busy !== null} data-testid="whatsapp-pairing-logout">
+ {busy === "logout" ? "Logging out..." : "Logout and re-pair"}
+
+
+
+ Pairing and configuration
+
+ Install and enable this plugin, then keep this settings panel open.
+ Set Allowed WhatsApp Senders ; an empty list blocks all inbound messages.
+ Choose QR to scan in WhatsApp Linked Devices, or code to enter a phone number and request a pairing code.
+ Wait for the status above to become connected .
+ Use Logout and re-pair to start over. If QR is still pending, wait briefly or refresh status for a new code.
+
+
+
+ );
+}
diff --git a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
index 1274dc36bc..b24e949720 100644
--- a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
+++ b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
@@ -885,6 +885,27 @@ describe("PluginManager", () => {
});
});
+ it("mounts WhatsApp pairing instructions above its settings form", async () => {
+ vi.mocked(fetchPlugins).mockResolvedValueOnce([{
+ ...mockPlugins[0],
+ id: "fusion-plugin-whatsapp-chat",
+ name: "WhatsApp Chat",
+ settings: { pairingMode: "qr", allowedSenders: [] },
+ settingsSchema: {
+ pairingMode: { type: "enum", label: "Pairing Mode", enumValues: ["qr", "code"] },
+ allowedSenders: { type: "array", label: "Allowed WhatsApp Senders", itemType: "string" },
+ },
+ }]);
+ vi.mocked(fetchPluginSettings).mockResolvedValueOnce({ pairingMode: "qr", allowedSenders: [] });
+
+ render( );
+ await screen.findAllByText("WhatsApp Chat");
+ await userEvent.click(screen.getByTitle("Settings"));
+
+ expect(await screen.findByTestId("whatsapp-pairing-instructions")).toBeTruthy();
+ expect(screen.getAllByText("Allowed WhatsApp Senders")).toHaveLength(2);
+ });
+
it("shows plugin detail view when settings button is clicked", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
diff --git a/packages/dashboard/app/components/__tests__/WhatsAppChatPairingPanel.test.tsx b/packages/dashboard/app/components/__tests__/WhatsAppChatPairingPanel.test.tsx
new file mode 100644
index 0000000000..6d136a6709
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/WhatsAppChatPairingPanel.test.tsx
@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { WhatsAppChatPairingPanel } from "../WhatsAppChatPairingPanel";
+
+function jsonResponse(body: unknown, ok = true) {
+ return { ok, json: async () => body } as Response;
+}
+
+describe("WhatsAppChatPairingPanel", () => {
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("shows a QR and instructions while awaiting scan", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ status: "awaiting-qr", qrDataUrl: "data:image/png;base64,qr" }));
+ render( );
+
+ expect(await screen.findByAltText("WhatsApp pairing QR code")).toHaveAttribute("src", "data:image/png;base64,qr");
+ expect(screen.getByTestId("whatsapp-pairing-instructions")).toHaveTextContent("empty list blocks all inbound messages");
+ expect(fetchMock).toHaveBeenCalledWith("/api/plugins/fusion-plugin-whatsapp-chat/status?projectId=project-a");
+ });
+
+ it("hides QR when connected and shows the JID", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ status: "connected", jid: "15550001111@s.whatsapp.net" }));
+ render( );
+
+ expect(await screen.findByText(/Connected as 15550001111/)).toBeTruthy();
+ expect(screen.queryByAltText("WhatsApp pairing QR code")).toBeNull();
+ });
+
+ it("shows connection errors", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ status: "error", lastError: "session expired" }));
+ render( );
+
+ expect(await screen.findByText("session expired")).toBeTruthy();
+ });
+
+ it("posts logout with project scope and refreshes status", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ status: "connected" }))
+ .mockResolvedValueOnce(jsonResponse({ ok: true }))
+ .mockResolvedValueOnce(jsonResponse({ status: "starting" }));
+ render( );
+ await screen.findByText("Status: connected");
+
+ fireEvent.click(screen.getByTestId("whatsapp-pairing-logout"));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
+ "/api/plugins/fusion-plugin-whatsapp-chat/logout?projectId=project-a",
+ expect.objectContaining({ method: "POST", body: JSON.stringify({ projectId: "project-a" }) }),
+ ));
+ expect(await screen.findByText("Status: starting")).toBeTruthy();
+ });
+
+ it("requests a code with a project-scoped body", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ status: "awaiting-code" }))
+ .mockResolvedValueOnce(jsonResponse({ pairingCode: "123-456" }))
+ .mockResolvedValueOnce(jsonResponse({ status: "awaiting-code", pairingCode: "123-456" }));
+ render( );
+ const input = await screen.findByLabelText("Phone number (E.164 digits without +)");
+ fireEvent.change(input, { target: { value: "15550001111" } });
+ fireEvent.click(screen.getByRole("button", { name: "Request pairing code" }));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
+ "/api/plugins/fusion-plugin-whatsapp-chat/pair-code?projectId=project-a",
+ expect.objectContaining({ body: JSON.stringify({ phoneNumber: "15550001111", projectId: "project-a" }) }),
+ ));
+ expect(await screen.findByText("123-456")).toBeTruthy();
+ });
+});
diff --git a/plugins/fusion-plugin-whatsapp-chat/README.md b/plugins/fusion-plugin-whatsapp-chat/README.md
index f3de07982f..0654ec8b38 100644
--- a/plugins/fusion-plugin-whatsapp-chat/README.md
+++ b/plugins/fusion-plugin-whatsapp-chat/README.md
@@ -6,12 +6,13 @@ No Meta Cloud app, webhook URL, verify token, or Graph API credentials are requi
## Setup
-1. Enable/install the plugin in Plugin Manager.
-2. Configure `allowedSenders` (empty means **nobody is allowed**).
-3. Choose `pairingMode`:
- - `qr` (default): fetch QR from `/api/plugins/fusion-plugin-whatsapp-chat/qr` and scan in WhatsApp.
- - `code`: set `pairingPhoneNumber` (E.164 digits without `+`) and request code via `/pair-code`.
-4. Confirm `/status` reports `connected`.
+1. Enable/install the plugin, then open **Settings → Plugins → Fusion Plugins → WhatsApp Chat**.
+2. Use the pairing panel at the top of the plugin settings. It shows live connection status and, in QR mode, a QR code to scan from WhatsApp **Linked devices**.
+3. Configure `allowedSenders` (empty means **nobody is allowed**).
+4. Choose `pairingMode`:
+ - `qr` (default): scan the in-settings QR code in WhatsApp.
+ - `code`: set `pairingPhoneNumber` (E.164 digits without `+`) and use **Request pairing code** in the settings panel.
+5. Wait for the panel to report `connected`. Use **Logout / re-pair** to start a fresh pairing session; if a QR is still pending, refresh or wait for a new one.
## Settings
@@ -42,9 +43,9 @@ No Meta Cloud app, webhook URL, verify token, or Graph API credentials are requi
## Troubleshooting
-- Stuck `awaiting-qr`: fetch a fresh QR and scan promptly.
-- `loggedOut`: call `/logout` (or wait for clear) and re-pair.
-- Pair code not generated: ensure `pairingPhoneNumber` is E.164 digits without `+`.
+- Stuck `awaiting-qr`: use **Refresh status** in the plugin settings panel and scan the newly displayed QR promptly.
+- Need to re-pair: use **Logout / re-pair** in plugin settings; it starts a fresh QR or code pairing session.
+- Pair code not generated: ensure `pairingPhoneNumber` is E.164 digits without `+`, then use **Request pairing code** in the settings panel.
- No replies: check `allowedSenders`; empty list blocks all inbound messages by design.
## Compliance warning
diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts
index c8471e8746..44b87fa17a 100644
--- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts
+++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts
@@ -136,7 +136,7 @@ describe("WhatsAppConnection", () => {
expect(connection.getStatus()).toMatchObject({ state: "error", lastError: "bad qr" });
});
- it("reconnects on close unless logged out", async () => {
+ it("reconnects after a logged-out close while the plugin remains started", async () => {
vi.useFakeTimers();
const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence());
await connection.start();
@@ -146,6 +146,20 @@ describe("WhatsAppConnection", () => {
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
await mockState.handlers.get("connection.update")?.({ connection: "close", lastDisconnect: { error: { output: { statusCode: 401 } } } });
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(mockState.makeWASocket).toHaveBeenCalledTimes(3);
+ expect(connection.getStatus()).toMatchObject({ state: "starting" });
+ vi.useRealTimers();
+ });
+
+ it("reconnects after explicit logout so settings can re-pair", async () => {
+ vi.useFakeTimers();
+ const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence());
+ await connection.start();
+
+ await connection.logout();
+
+ expect(connection.getStatus()).toMatchObject({ state: "starting" });
await vi.advanceTimersByTimeAsync(1000);
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
vi.useRealTimers();
diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts
index 6fb1e90723..a8d878171e 100644
--- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts
+++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts
@@ -61,6 +61,39 @@ describe("whatsapp plugin", () => {
expect(paths).toContain("POST /logout");
});
+ it("returns pairing data in status for the settings panel", async () => {
+ const ctx = {
+ pluginId: "fusion-plugin-whatsapp-chat",
+ settings: { allowedSenders: ["15550001111"] },
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+ emitEvent: vi.fn(),
+ taskStore: {
+ getRootDir: () => "/repo-status",
+ getAsyncLayer: () => ({ projectId: "/repo-status" }),
+ },
+ } as unknown as PluginContext;
+ await plugin.hooks!.onLoad!(ctx);
+ connectionInstances[0]?.getStatus.mockReturnValue({
+ state: "awaiting-qr",
+ qrDataUrl: "data:image/png;base64,pairing",
+ pairingCode: "123-456",
+ });
+
+ const statusRoute = plugin.routes!.find((route) => route.method === "GET" && route.path === "/status")!;
+ const response = await statusRoute.handler({} as never, ctx) as { status: number; body: Record };
+
+ expect(response).toMatchObject({
+ status: 200,
+ body: {
+ status: "awaiting-qr",
+ qrDataUrl: "data:image/png;base64,pairing",
+ pairingCode: "123-456",
+ allowedSenders: ["15550001111"],
+ },
+ });
+ await plugin.hooks!.onUnload!(ctx);
+ });
+
it("uses only pairing-era settings", () => {
const schema = plugin.manifest.settingsSchema ?? {};
expect(Object.keys(schema).sort()).toEqual([
diff --git a/plugins/fusion-plugin-whatsapp-chat/src/connection.ts b/plugins/fusion-plugin-whatsapp-chat/src/connection.ts
index 5b123b6066..25ca643f50 100644
--- a/plugins/fusion-plugin-whatsapp-chat/src/connection.ts
+++ b/plugins/fusion-plugin-whatsapp-chat/src/connection.ts
@@ -140,12 +140,18 @@ export class WhatsAppConnection {
} finally {
if (socket) {
await this.resetLoggedOutAuth(socket);
- if (this.sock === null || this.sock === socket) {
- this.status = { state: "disconnected" };
- }
} else {
await this.persistence.clearAuthState();
this.authState = await createPersistenceAuthState(this.persistence);
+ }
+ if (!this.stopped) {
+ /**
+ * FNXC:WhatsAppSettingsRePair 2026-07-20-12:00:
+ * Logout from Plugin Manager is a re-pair action, not a terminal disconnect. Once auth is cleared, reconnect so settings can present a fresh QR or code instead of stranding the operator on disconnected.
+ */
+ this.status = { state: "starting" };
+ this.scheduleReconnect();
+ } else {
this.status = { state: "disconnected" };
}
}
@@ -228,9 +234,16 @@ export class WhatsAppConnection {
if (update.connection === "close") {
if (isLoggedOutDisconnect(update.lastDisconnect?.error)) {
await this.resetLoggedOutAuth(socket);
- if (this.sock === null || this.sock === socket) {
+ if (this.stopped) {
this.status = { state: "disconnected", lastError: "loggedOut" };
+ return;
}
+ /**
+ * FNXC:WhatsAppSettingsRePair 2026-07-20-12:00:
+ * A logged-out socket also needs a fresh pairing connection while the plugin remains enabled. Scheduling through the shared backoff guard prevents logout and close events from creating competing sockets.
+ */
+ this.status = { state: "starting" };
+ this.scheduleReconnect();
return;
}
diff --git a/plugins/fusion-plugin-whatsapp-chat/src/index.ts b/plugins/fusion-plugin-whatsapp-chat/src/index.ts
index f35d9e0d03..8819df7060 100644
--- a/plugins/fusion-plugin-whatsapp-chat/src/index.ts
+++ b/plugins/fusion-plugin-whatsapp-chat/src/index.ts
@@ -16,13 +16,19 @@ const settingsSchema: Record = {
label: "Pairing Mode",
enumValues: ["qr", "code"],
defaultValue: "qr",
+ description: "Use QR to scan from WhatsApp Linked Devices, or code to pair a phone number from Plugin Manager.",
},
pairingPhoneNumber: {
type: "string",
label: "Pairing Phone Number",
- description: "E.164 digits without + (required when pairingMode is code)",
+ description: "E.164 digits without +. Required to request a pairing code in code mode.",
+ },
+ allowedSenders: {
+ type: "array",
+ label: "Allowed WhatsApp Senders",
+ description: "WhatsApp JIDs or E.164 digits. Leave empty to block every inbound sender.",
+ itemType: "string",
},
- allowedSenders: { type: "array", label: "Allowed WhatsApp Senders", itemType: "string" },
agentSystemPrompt: {
type: "string",
label: "Agent System Prompt",
@@ -98,6 +104,9 @@ const routes: PluginRouteDefinition[] = [
/**
* FNXC:WhatsAppStatusVisibility 2026-07-17-09:15:
* /status must expose lastError: a stale-protocol 405 rejection previously showed only a bare "disconnected" with no way to diagnose it from the API surface the README points troubleshooters at.
+ *
+ * FNXC:WhatsAppSettingsPairing 2026-07-20-12:00:
+ * Status includes the current QR/code so Plugin Manager can offer settings-first pairing from one poll. Operators should not need to discover raw API endpoints or coordinate a separate QR request.
*/
return {
status: 200,
@@ -105,6 +114,8 @@ const routes: PluginRouteDefinition[] = [
status: status.state,
jid: status.jid,
lastError: status.lastError,
+ qrDataUrl: status.qrDataUrl,
+ pairingCode: status.pairingCode,
allowedSenders: Array.from(getAllowedSenders(ctx.settings)),
},
};