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).
|
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)
|
### 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 { fetchPlugins, fetchPluginRegistry, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api";
|
||||||
import { DirectoryPicker } from "./DirectoryPicker";
|
import { DirectoryPicker } from "./DirectoryPicker";
|
||||||
import { LoadingSpinner } from "./LoadingSpinner";
|
import { LoadingSpinner } from "./LoadingSpinner";
|
||||||
|
import { WhatsAppChatPairingPanel } from "./WhatsAppChatPairingPanel";
|
||||||
import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
|
import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
|
||||||
import type { PluginSetupStatusResponse, RegistryPluginEntry } from "../api";
|
import type { PluginSetupStatusResponse, RegistryPluginEntry } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -792,6 +793,9 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
|||||||
|
|
||||||
<div className="plugin-detail-card">
|
<div className="plugin-detail-card">
|
||||||
<h5 className="plugin-detail-section-heading">{t("plugins.settings", "Settings")}</h5>
|
<h5 className="plugin-detail-section-heading">{t("plugins.settings", "Settings")}</h5>
|
||||||
|
{selectedPlugin.id === "fusion-plugin-whatsapp-chat" && (
|
||||||
|
<WhatsAppChatPairingPanel projectId={projectId} settings={pluginSettings} />
|
||||||
|
)}
|
||||||
{settingsLoading ? (
|
{settingsLoading ? (
|
||||||
<p className="text-muted"><LoadingSpinner label={t("plugins.loading", "Loading...")} /></p>
|
<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 () => {
|
it("shows plugin detail view when settings button is clicked", async () => {
|
||||||
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
|
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
|
## Setup
|
||||||
|
|
||||||
1. Enable/install the plugin in Plugin Manager.
|
1. Enable/install the plugin, then open **Settings → Plugins → Fusion Plugins → WhatsApp Chat**.
|
||||||
2. Configure `allowedSenders` (empty means **nobody is allowed**).
|
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. Choose `pairingMode`:
|
3. Configure `allowedSenders` (empty means **nobody is allowed**).
|
||||||
- `qr` (default): fetch QR from `/api/plugins/fusion-plugin-whatsapp-chat/qr` and scan in WhatsApp.
|
4. Choose `pairingMode`:
|
||||||
- `code`: set `pairingPhoneNumber` (E.164 digits without `+`) and request code via `/pair-code`.
|
- `qr` (default): scan the in-settings QR code in WhatsApp.
|
||||||
4. Confirm `/status` reports `connected`.
|
- `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
|
## Settings
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ No Meta Cloud app, webhook URL, verify token, or Graph API credentials are requi
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- Stuck `awaiting-qr`: fetch a fresh QR and scan promptly.
|
- Stuck `awaiting-qr`: use **Refresh status** in the plugin settings panel and scan the newly displayed QR promptly.
|
||||||
- `loggedOut`: call `/logout` (or wait for clear) and re-pair.
|
- 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 `+`.
|
- 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.
|
- No replies: check `allowedSenders`; empty list blocks all inbound messages by design.
|
||||||
|
|
||||||
## Compliance warning
|
## Compliance warning
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ describe("WhatsAppConnection", () => {
|
|||||||
expect(connection.getStatus()).toMatchObject({ state: "error", lastError: "bad qr" });
|
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();
|
vi.useFakeTimers();
|
||||||
const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence());
|
const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryPersistence());
|
||||||
await connection.start();
|
await connection.start();
|
||||||
@@ -146,6 +146,20 @@ describe("WhatsAppConnection", () => {
|
|||||||
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
|
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
await mockState.handlers.get("connection.update")?.({ connection: "close", lastDisconnect: { error: { output: { statusCode: 401 } } } });
|
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);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
|
expect(mockState.makeWASocket).toHaveBeenCalledTimes(2);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -61,6 +61,39 @@ describe("whatsapp plugin", () => {
|
|||||||
expect(paths).toContain("POST /logout");
|
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", () => {
|
it("uses only pairing-era settings", () => {
|
||||||
const schema = plugin.manifest.settingsSchema ?? {};
|
const schema = plugin.manifest.settingsSchema ?? {};
|
||||||
expect(Object.keys(schema).sort()).toEqual([
|
expect(Object.keys(schema).sort()).toEqual([
|
||||||
|
|||||||
@@ -140,12 +140,18 @@ export class WhatsAppConnection {
|
|||||||
} finally {
|
} finally {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
await this.resetLoggedOutAuth(socket);
|
await this.resetLoggedOutAuth(socket);
|
||||||
if (this.sock === null || this.sock === socket) {
|
|
||||||
this.status = { state: "disconnected" };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await this.persistence.clearAuthState();
|
await this.persistence.clearAuthState();
|
||||||
this.authState = await createPersistenceAuthState(this.persistence);
|
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" };
|
this.status = { state: "disconnected" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,9 +234,16 @@ export class WhatsAppConnection {
|
|||||||
if (update.connection === "close") {
|
if (update.connection === "close") {
|
||||||
if (isLoggedOutDisconnect(update.lastDisconnect?.error)) {
|
if (isLoggedOutDisconnect(update.lastDisconnect?.error)) {
|
||||||
await this.resetLoggedOutAuth(socket);
|
await this.resetLoggedOutAuth(socket);
|
||||||
if (this.sock === null || this.sock === socket) {
|
if (this.stopped) {
|
||||||
this.status = { state: "disconnected", lastError: "loggedOut" };
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,19 @@ const settingsSchema: Record<string, PluginSettingSchema> = {
|
|||||||
label: "Pairing Mode",
|
label: "Pairing Mode",
|
||||||
enumValues: ["qr", "code"],
|
enumValues: ["qr", "code"],
|
||||||
defaultValue: "qr",
|
defaultValue: "qr",
|
||||||
|
description: "Use QR to scan from WhatsApp Linked Devices, or code to pair a phone number from Plugin Manager.",
|
||||||
},
|
},
|
||||||
pairingPhoneNumber: {
|
pairingPhoneNumber: {
|
||||||
type: "string",
|
type: "string",
|
||||||
label: "Pairing Phone Number",
|
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: {
|
agentSystemPrompt: {
|
||||||
type: "string",
|
type: "string",
|
||||||
label: "Agent System Prompt",
|
label: "Agent System Prompt",
|
||||||
@@ -98,6 +104,9 @@ const routes: PluginRouteDefinition[] = [
|
|||||||
/**
|
/**
|
||||||
* FNXC:WhatsAppStatusVisibility 2026-07-17-09:15:
|
* 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.
|
* /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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -105,6 +114,8 @@ const routes: PluginRouteDefinition[] = [
|
|||||||
status: status.state,
|
status: status.state,
|
||||||
jid: status.jid,
|
jid: status.jid,
|
||||||
lastError: status.lastError,
|
lastError: status.lastError,
|
||||||
|
qrDataUrl: status.qrDataUrl,
|
||||||
|
pairingCode: status.pairingCode,
|
||||||
allowedSenders: Array.from(getAllowedSenders(ctx.settings)),
|
allowedSenders: Array.from(getAllowedSenders(ctx.settings)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user