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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8418-whatsapp-settings-pairing.md
Normal file
7
.changeset/fn-8418-whatsapp-settings-pairing.md
Normal file
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
<div className="plugin-detail-card">
|
||||
<h5 className="plugin-detail-section-heading">{t("plugins.settings", "Settings")}</h5>
|
||||
{selectedPlugin.id === "fusion-plugin-whatsapp-chat" && (
|
||||
<WhatsAppChatPairingPanel projectId={projectId} settings={pluginSettings} />
|
||||
)}
|
||||
{settingsLoading ? (
|
||||
<p className="text-muted"><LoadingSpinner label={t("plugins.loading", "Loading...")} /></p>
|
||||
) : (() => {
|
||||
|
||||
115
packages/dashboard/app/components/WhatsAppChatPairingPanel.css
Normal file
115
packages/dashboard/app/components/WhatsAppChatPairingPanel.css
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
||||
165
packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx
Normal file
165
packages/dashboard/app/components/WhatsAppChatPairingPanel.tsx
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<WhatsAppStatus | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [phoneNumber, setPhoneNumber] = useState(() => String(settings?.pairingPhoneNumber ?? ""));
|
||||
const [actionError, setActionError] = useState<string | null>(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 (
|
||||
<section className="whatsapp-pairing-panel" aria-labelledby="whatsapp-pairing-heading">
|
||||
<div className="whatsapp-pairing-heading-row">
|
||||
<div>
|
||||
<h5 id="whatsapp-pairing-heading" className="plugin-detail-section-heading">WhatsApp pairing</h5>
|
||||
<p className="whatsapp-pairing-description">Pair and monitor this project's WhatsApp connection here.</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" type="button" onClick={() => void refreshStatus()}>Refresh status</button>
|
||||
</div>
|
||||
|
||||
<div className={`whatsapp-pairing-status whatsapp-pairing-status--${state}`} role="status" data-testid="whatsapp-pairing-status">
|
||||
<strong>Status: {state}</strong>
|
||||
{connection?.jid && <span>Connected as {connection.jid}</span>}
|
||||
{(connection?.lastError || loadError) && <span className="field-error">{connection?.lastError ?? loadError}</span>}
|
||||
</div>
|
||||
|
||||
{state === "awaiting-qr" && (
|
||||
<div className="whatsapp-pairing-qr" data-testid="whatsapp-pairing-qr">
|
||||
{connection?.qrDataUrl ? (
|
||||
<img src={connection.qrDataUrl} alt="WhatsApp pairing QR code" className="whatsapp-pairing-qr-image" />
|
||||
) : (
|
||||
<p className="text-muted">Waiting for a fresh QR code. Keep this panel open and refresh if needed.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pairingMode === "code" || state === "awaiting-code") && (
|
||||
<div className="whatsapp-pairing-code">
|
||||
<label htmlFor="whatsapp-pairing-phone">Phone number (E.164 digits without +)</label>
|
||||
<div className="whatsapp-pairing-code-controls">
|
||||
<input id="whatsapp-pairing-phone" className="input" value={phoneNumber} onChange={(event) => setPhoneNumber(event.target.value)} inputMode="numeric" />
|
||||
<button className="btn btn-secondary" type="button" onClick={() => void requestPairingCode()} disabled={busy !== null}>
|
||||
{busy === "code" ? "Requesting code..." : "Request pairing code"}
|
||||
</button>
|
||||
</div>
|
||||
{connection?.pairingCode && <output className="whatsapp-pairing-code-output">{connection.pairingCode}</output>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "connected" && <p className="whatsapp-pairing-success">WhatsApp is paired and ready to receive messages from allowed senders.</p>}
|
||||
{actionError && <p className="field-error">{actionError}</p>}
|
||||
|
||||
<button className="btn btn-danger" type="button" onClick={() => void logoutForRepair()} disabled={busy !== null} data-testid="whatsapp-pairing-logout">
|
||||
{busy === "logout" ? "Logging out..." : "Logout and re-pair"}
|
||||
</button>
|
||||
|
||||
<aside className="whatsapp-pairing-instructions" aria-label="WhatsApp pairing instructions" data-testid="whatsapp-pairing-instructions">
|
||||
<h6>Pairing and configuration</h6>
|
||||
<ol>
|
||||
<li>Install and enable this plugin, then keep this settings panel open.</li>
|
||||
<li>Set <strong>Allowed WhatsApp Senders</strong>; an empty list blocks all inbound messages.</li>
|
||||
<li>Choose <strong>QR</strong> to scan in WhatsApp Linked Devices, or <strong>code</strong> to enter a phone number and request a pairing code.</li>
|
||||
<li>Wait for the status above to become <strong>connected</strong>.</li>
|
||||
<li>Use Logout and re-pair to start over. If QR is still pending, wait briefly or refresh status for a new code.</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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(<PluginManager addToast={addToast} projectId="project-a" />);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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(<WhatsAppChatPairingPanel projectId="project-a" settings={{ pairingMode: "qr" }} />);
|
||||
|
||||
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(<WhatsAppChatPairingPanel />);
|
||||
|
||||
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(<WhatsAppChatPairingPanel />);
|
||||
|
||||
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(<WhatsAppChatPairingPanel projectId="project-a" />);
|
||||
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(<WhatsAppChatPairingPanel projectId="project-a" settings={{ pairingMode: "code" }} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,19 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
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)),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user