From 2c1567c6b8d92bcd85d53b3f9d4025039868668e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 00:54:01 -0700 Subject: [PATCH] FN-8418: add WhatsApp QR pairing settings Add WhatsApp connection pairing guidance directly in plugin settings. - Display a QR code and phone pairing instructions for WhatsApp Chat connections. - Expose connection details through the plugin and document the setup flow. - Add dashboard and plugin regression coverage plus a release changeset. Files changed: .changeset/fn-8418-whatsapp-settings-pairing.md | 7 + docs/plugin-management.md | 2 +- .../dashboard/app/components/PluginManager.tsx | 4 + .../app/components/WhatsAppChatPairingPanel.css | 115 ++++++++++++++ .../app/components/WhatsAppChatPairingPanel.tsx | 165 +++++++++++++++++++++ .../components/__tests__/PluginManager.test.tsx | 21 +++ .../__tests__/WhatsAppChatPairingPanel.test.tsx | 78 ++++++++++ plugins/fusion-plugin-whatsapp-chat/README.md | 19 +-- .../src/__tests__/connection.test.ts | 16 +- .../src/__tests__/index.test.ts | 33 +++++ .../fusion-plugin-whatsapp-chat/src/connection.ts | 21 ++- plugins/fusion-plugin-whatsapp-chat/src/index.ts | 15 +- 12 files changed, 479 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-8418 Fusion-Task-Lineage: a16b7d25-422d-4612-83d4-d5ac9c049aec Co-authored-by: Fusion (runfusion.ai) --- .../fn-8418-whatsapp-settings-pairing.md | 7 + docs/plugin-management.md | 2 +- .../app/components/PluginManager.tsx | 4 + .../components/WhatsAppChatPairingPanel.css | 115 ++++++++++++ .../components/WhatsAppChatPairingPanel.tsx | 165 ++++++++++++++++++ .../__tests__/PluginManager.test.tsx | 21 +++ .../WhatsAppChatPairingPanel.test.tsx | 78 +++++++++ plugins/fusion-plugin-whatsapp-chat/README.md | 19 +- .../src/__tests__/connection.test.ts | 16 +- .../src/__tests__/index.test.ts | 33 ++++ .../src/connection.ts | 21 ++- .../fusion-plugin-whatsapp-chat/src/index.ts | 15 +- 12 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 .changeset/fn-8418-whatsapp-settings-pairing.md create mode 100644 packages/dashboard/app/components/WhatsAppChatPairingPanel.css create mode 100644 packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx create mode 100644 packages/dashboard/app/components/__tests__/WhatsAppChatPairingPanel.test.tsx 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.

+
+ +
+ +
+ Status: {state} + {connection?.jid && Connected as {connection.jid}} + {(connection?.lastError || loadError) && {connection?.lastError ?? loadError}} +
+ + {state === "awaiting-qr" && ( +
+ {connection?.qrDataUrl ? ( + WhatsApp pairing QR code + ) : ( +

Waiting for a fresh QR code. Keep this panel open and refresh if needed.

+ )} +
+ )} + + {(pairingMode === "code" || state === "awaiting-code") && ( +
+ +
+ setPhoneNumber(event.target.value)} inputMode="numeric" /> + +
+ {connection?.pairingCode && {connection.pairingCode}} +
+ )} + + {state === "connected" &&

WhatsApp is paired and ready to receive messages from allowed senders.

} + {actionError &&

{actionError}

} + + + + +
+ ); +} 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)), }, };