fix: auto-restart WhatsApp QR login
This commit is contained in:
@@ -17,7 +17,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
|||||||
## Deep diagnostics
|
## Deep diagnostics
|
||||||
- Creds on disk: `ls -l ~/.clawdis/credentials/creds.json` (mtime should be recent).
|
- Creds on disk: `ls -l ~/.clawdis/credentials/creds.json` (mtime should be recent).
|
||||||
- Session store: `ls -l ~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`; path can be overridden in config). Count and recent recipients are surfaced via `status`.
|
- Session store: `ls -l ~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`; path can be overridden in config). Count and recent recipients are surfaced via `status`.
|
||||||
- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409–515 or `loggedOut` appear in logs.
|
- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
|
||||||
|
|
||||||
## When something fails
|
## When something fails
|
||||||
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ read_when:
|
|||||||
- **Logged out:** Console prints “session logged out”; re-link with `clawdis login`.
|
- **Logged out:** Console prints “session logged out”; re-link with `clawdis login`.
|
||||||
- **Repeated retries then exit:** Tune reconnect behavior via config `web.reconnect` and restart the Gateway.
|
- **Repeated retries then exit:** Tune reconnect behavior via config `web.reconnect` and restart the Gateway.
|
||||||
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
||||||
|
- **Status 515 right after pairing:** The QR login flow now auto-restarts once; you should not need a manual gateway restart after scanning.
|
||||||
- **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to request a supervised restart (launchd/mac app setups); wait a few seconds for it to come back.
|
- **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to request a supervised restart (launchd/mac app setups); wait a few seconds for it to come back.
|
||||||
|
|
||||||
## Helpful commands
|
## Helpful commands
|
||||||
|
|||||||
66
src/web/login-qr.test.ts
Normal file
66
src/web/login-qr.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./session.js", () => {
|
||||||
|
const createWaSocket = vi.fn(
|
||||||
|
async (
|
||||||
|
_printQr: boolean,
|
||||||
|
_verbose: boolean,
|
||||||
|
opts?: { onQr?: (qr: string) => void },
|
||||||
|
) => {
|
||||||
|
const sock = { ws: { close: vi.fn() } };
|
||||||
|
if (opts?.onQr) {
|
||||||
|
setImmediate(() => opts.onQr?.("qr-data"));
|
||||||
|
}
|
||||||
|
return sock;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const waitForWaConnection = vi.fn();
|
||||||
|
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
|
||||||
|
const getStatusCode = vi.fn(
|
||||||
|
(err: unknown) =>
|
||||||
|
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||||
|
(err as { status?: number })?.status,
|
||||||
|
);
|
||||||
|
const webAuthExists = vi.fn(async () => false);
|
||||||
|
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
|
||||||
|
const logoutWeb = vi.fn(async () => true);
|
||||||
|
return {
|
||||||
|
createWaSocket,
|
||||||
|
waitForWaConnection,
|
||||||
|
formatError,
|
||||||
|
getStatusCode,
|
||||||
|
webAuthExists,
|
||||||
|
readWebSelfId,
|
||||||
|
logoutWeb,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./qr-image.js", () => ({
|
||||||
|
renderQrPngBase64: vi.fn(async () => "base64"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js");
|
||||||
|
const { createWaSocket, waitForWaConnection, logoutWeb } = await import(
|
||||||
|
"./session.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("login-qr", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts login once on status 515 and completes", async () => {
|
||||||
|
waitForWaConnection
|
||||||
|
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||||
|
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
|
||||||
|
|
||||||
|
const result = await waitForWebLogin({ timeoutMs: 5000 });
|
||||||
|
|
||||||
|
expect(result.connected).toBe(true);
|
||||||
|
expect(createWaSocket).toHaveBeenCalledTimes(2);
|
||||||
|
expect(logoutWeb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,8 @@ type ActiveLogin = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
errorStatus?: number;
|
errorStatus?: number;
|
||||||
waitPromise: Promise<void>;
|
waitPromise: Promise<void>;
|
||||||
|
restartAttempted: boolean;
|
||||||
|
verbose: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
|
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
|
||||||
@@ -55,6 +57,45 @@ function isLoginFresh(login: ActiveLogin) {
|
|||||||
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachLoginWaiter(login: ActiveLogin) {
|
||||||
|
login.waitPromise = waitForWaConnection(login.sock)
|
||||||
|
.then(() => {
|
||||||
|
if (activeLogin?.id === login.id) {
|
||||||
|
activeLogin.connected = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (activeLogin?.id === login.id) {
|
||||||
|
activeLogin.error = formatError(err);
|
||||||
|
activeLogin.errorStatus = getStatusCode(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
|
||||||
|
if (login.restartAttempted) return false;
|
||||||
|
login.restartAttempted = true;
|
||||||
|
runtime.log(
|
||||||
|
info(
|
||||||
|
"WhatsApp asked for a restart after pairing (code 515); retrying connection once…",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
closeSocket(login.sock);
|
||||||
|
try {
|
||||||
|
const sock = await createWaSocket(false, login.verbose);
|
||||||
|
login.sock = sock;
|
||||||
|
login.connected = false;
|
||||||
|
login.error = undefined;
|
||||||
|
login.errorStatus = undefined;
|
||||||
|
attachLoginWaiter(login);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
login.error = formatError(err);
|
||||||
|
login.errorStatus = getStatusCode(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startWebLoginWithQr(
|
export async function startWebLoginWithQr(
|
||||||
opts: {
|
opts: {
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -120,21 +161,11 @@ export async function startWebLoginWithQr(
|
|||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
connected: false,
|
connected: false,
|
||||||
waitPromise: Promise.resolve(),
|
waitPromise: Promise.resolve(),
|
||||||
|
restartAttempted: false,
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
};
|
};
|
||||||
activeLogin = login;
|
activeLogin = login;
|
||||||
|
attachLoginWaiter(login);
|
||||||
login.waitPromise = waitForWaConnection(sock)
|
|
||||||
.then(() => {
|
|
||||||
if (activeLogin?.id === login.id) {
|
|
||||||
activeLogin.connected = true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (activeLogin?.id === login.id) {
|
|
||||||
activeLogin.error = formatError(err);
|
|
||||||
activeLogin.errorStatus = getStatusCode(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let qr: string;
|
let qr: string;
|
||||||
try {
|
try {
|
||||||
@@ -175,43 +206,61 @@ export async function waitForWebLogin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
|
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
|
||||||
const timeout = new Promise<"timeout">((resolve) =>
|
const deadline = Date.now() + timeoutMs;
|
||||||
setTimeout(() => resolve("timeout"), timeoutMs),
|
|
||||||
);
|
|
||||||
const result = await Promise.race([
|
|
||||||
login.waitPromise.then(() => "done"),
|
|
||||||
timeout,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (result === "timeout") {
|
while (true) {
|
||||||
return {
|
const remaining = deadline - Date.now();
|
||||||
connected: false,
|
if (remaining <= 0) {
|
||||||
message:
|
return {
|
||||||
"Still waiting for the QR scan. Let me know when you’ve scanned it.",
|
connected: false,
|
||||||
};
|
message:
|
||||||
}
|
"Still waiting for the QR scan. Let me know when you’ve scanned it.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const timeout = new Promise<"timeout">((resolve) =>
|
||||||
|
setTimeout(() => resolve("timeout"), remaining),
|
||||||
|
);
|
||||||
|
const result = await Promise.race([
|
||||||
|
login.waitPromise.then(() => "done"),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
if (login.error) {
|
if (result === "timeout") {
|
||||||
if (login.errorStatus === DisconnectReason.loggedOut) {
|
return {
|
||||||
await logoutWeb(runtime);
|
connected: false,
|
||||||
const message =
|
message:
|
||||||
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
|
"Still waiting for the QR scan. Let me know when you’ve scanned it.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (login.error) {
|
||||||
|
if (login.errorStatus === DisconnectReason.loggedOut) {
|
||||||
|
await logoutWeb(runtime);
|
||||||
|
const message =
|
||||||
|
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
|
||||||
|
await resetActiveLogin(message);
|
||||||
|
runtime.log(danger(message));
|
||||||
|
return { connected: false, message };
|
||||||
|
}
|
||||||
|
if (login.errorStatus === 515) {
|
||||||
|
const restarted = await restartLoginSocket(login, runtime);
|
||||||
|
if (restarted && isLoginFresh(login)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = `WhatsApp login failed: ${login.error}`;
|
||||||
await resetActiveLogin(message);
|
await resetActiveLogin(message);
|
||||||
runtime.log(danger(message));
|
runtime.log(danger(message));
|
||||||
return { connected: false, message };
|
return { connected: false, message };
|
||||||
}
|
}
|
||||||
const message = `WhatsApp login failed: ${login.error}`;
|
|
||||||
await resetActiveLogin(message);
|
|
||||||
runtime.log(danger(message));
|
|
||||||
return { connected: false, message };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (login.connected) {
|
if (login.connected) {
|
||||||
const message = "✅ Linked! WhatsApp is ready.";
|
const message = "✅ Linked! WhatsApp is ready.";
|
||||||
runtime.log(success(message));
|
runtime.log(success(message));
|
||||||
await resetActiveLogin();
|
await resetActiveLogin();
|
||||||
return { connected: true, message };
|
return { connected: true, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { connected: false, message: "Login ended without a connection." };
|
return { connected: false, message: "Login ended without a connection." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user