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:
gsxdsm
2026-07-20 00:54:01 -07:00
parent e1dddcbfae
commit 2c1567c6b8
12 changed files with 479 additions and 17 deletions

View 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.

View File

@@ -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)

View File

@@ -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>
) : (() => {

View 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%;
}
}

View 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&apos;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>
);
}

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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();

View File

@@ -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([

View File

@@ -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;
}

View File

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