fix: auto-restart WhatsApp QR login

This commit is contained in:
Peter Steinberger
2025-12-21 13:36:26 +01:00
parent 5703b9e737
commit 3b63d1cb77
4 changed files with 161 additions and 45 deletions

View File

@@ -17,7 +17,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
## Deep diagnostics
- 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`.
- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409515 or `loggedOut` appear in logs.
- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
## When something fails
- `logged out` or status 409515 → relink with `clawdis logout` then `clawdis login`.

View File

@@ -10,6 +10,7 @@ read_when:
- **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.
- **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.
## Helpful commands

66
src/web/login-qr.test.ts Normal file
View 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();
});
});

View File

@@ -28,6 +28,8 @@ type ActiveLogin = {
error?: string;
errorStatus?: number;
waitPromise: Promise<void>;
restartAttempted: boolean;
verbose: boolean;
};
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;
}
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(
opts: {
verbose?: boolean;
@@ -120,21 +161,11 @@ export async function startWebLoginWithQr(
startedAt: Date.now(),
connected: false,
waitPromise: Promise.resolve(),
restartAttempted: false,
verbose: Boolean(opts.verbose),
};
activeLogin = 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);
}
});
attachLoginWaiter(login);
let qr: string;
try {
@@ -175,43 +206,61 @@ export async function waitForWebLogin(
};
}
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
const timeout = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), timeoutMs),
);
const result = await Promise.race([
login.waitPromise.then(() => "done"),
timeout,
]);
const deadline = Date.now() + timeoutMs;
if (result === "timeout") {
return {
connected: false,
message:
"Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
return {
connected: false,
message:
"Still waiting for the QR scan. Let me know when youve 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 (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.";
if (result === "timeout") {
return {
connected: false,
message:
"Still waiting for the QR scan. Let me know when youve 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);
runtime.log(danger(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) {
const message = "✅ Linked! WhatsApp is ready.";
runtime.log(success(message));
await resetActiveLogin();
return { connected: true, message };
}
if (login.connected) {
const message = "✅ Linked! WhatsApp is ready.";
runtime.log(success(message));
await resetActiveLogin();
return { connected: true, message };
}
return { connected: false, message: "Login ended without a connection." };
return { connected: false, message: "Login ended without a connection." };
}
}