mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 18:44:02 +00:00
fix(whatsapp): retry QR login 408 timeouts (#88183)
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { DisconnectReason } from "baileys";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
|
||||
import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js";
|
||||
import {
|
||||
closeWaSocket,
|
||||
waitForWhatsAppLoginResult,
|
||||
WhatsAppConnectionController,
|
||||
} from "./connection-controller.js";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
|
||||
@@ -127,6 +132,120 @@ describe("WhatsAppConnectionController", () => {
|
||||
expect(callOrder).toEqual(["create", "wait-for-connection"]);
|
||||
});
|
||||
|
||||
it("restarts login once on status 408 and preserves replacement socket options", async () => {
|
||||
const initialSock = createSocketWithTransportEmitter();
|
||||
const replacementSock = createSocketWithTransportEmitter();
|
||||
const waitForConnection = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const onQr = vi.fn();
|
||||
const onSocketReplaced = vi.fn();
|
||||
const createSocket = vi.fn(
|
||||
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
|
||||
opts?.onQr?.("qr-after-timeout");
|
||||
return replacementSock;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: initialSock as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: true,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
createSocket: createSocket as never,
|
||||
socketTiming: {
|
||||
connectTimeoutMs: 10_000,
|
||||
defaultQueryTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 30_000,
|
||||
},
|
||||
onQr,
|
||||
onSocketReplaced,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "connected",
|
||||
restarted: true,
|
||||
sock: replacementSock,
|
||||
});
|
||||
expect(initialSock.end).toHaveBeenCalledOnce();
|
||||
expect(createSocket).toHaveBeenCalledWith(false, true, {
|
||||
authDir: "/tmp/wa-auth",
|
||||
connectTimeoutMs: 10_000,
|
||||
defaultQueryTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 30_000,
|
||||
onQr,
|
||||
});
|
||||
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
|
||||
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
|
||||
});
|
||||
|
||||
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
|
||||
const initialSock = createSocketWithTransportEmitter();
|
||||
const afterTimeoutSock = createSocketWithTransportEmitter();
|
||||
const afterPairingRestartSock = createSocketWithTransportEmitter();
|
||||
const waitForConnection = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
|
||||
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const createSocket = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(afterTimeoutSock)
|
||||
.mockResolvedValueOnce(afterPairingRestartSock);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: initialSock as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
createSocket: createSocket as never,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "connected",
|
||||
restarted: true,
|
||||
sock: afterPairingRestartSock,
|
||||
});
|
||||
expect(createSocket).toHaveBeenCalledTimes(2);
|
||||
expect(waitForConnection).toHaveBeenCalledTimes(3);
|
||||
expect(initialSock.end).toHaveBeenCalledOnce();
|
||||
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not keep recreating sockets when login status 408 persists", async () => {
|
||||
const initialSock = createSocketWithTransportEmitter();
|
||||
const replacementSock = createSocketWithTransportEmitter();
|
||||
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
|
||||
const waitForConnection = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockRejectedValueOnce(timeoutError);
|
||||
const createSocket = vi.fn(async () => replacementSock);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: initialSock as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
verbose: false,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
createSocket: createSocket as never,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: "failed",
|
||||
statusCode: DisconnectReason.timedOut,
|
||||
error: timeoutError,
|
||||
});
|
||||
expect(createSocket).toHaveBeenCalledOnce();
|
||||
expect(waitForConnection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the previous registered controller until a replacement listener is ready", async () => {
|
||||
const liveController = new WhatsAppConnectionController({
|
||||
accountId: "work",
|
||||
|
||||
@@ -17,8 +17,12 @@ import {
|
||||
import type { WhatsAppSocketTimingOptions } from "./socket-timing.js";
|
||||
|
||||
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
|
||||
const POST_PAIRING_RESTART_STATUS = 515;
|
||||
const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408;
|
||||
const WHATSAPP_LOGIN_RESTART_MESSAGE =
|
||||
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
|
||||
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
|
||||
"WhatsApp connection timed out before login; retrying with a fresh socket…";
|
||||
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
|
||||
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
|
||||
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
|
||||
@@ -85,10 +89,28 @@ type WhatsAppReconnectAttemptDecision = {
|
||||
healthState: "stopped" | "reconnecting";
|
||||
};
|
||||
|
||||
type LoginSocketRestartKind = "post-pairing" | "timeout";
|
||||
|
||||
function createNeverResolvePromise<T>(): Promise<T> {
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
|
||||
function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null {
|
||||
if (statusCode === POST_PAIRING_RESTART_STATUS) {
|
||||
return "post-pairing";
|
||||
}
|
||||
if (statusCode === TIMED_OUT_STATUS) {
|
||||
return "timeout";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string {
|
||||
return kind === "timeout"
|
||||
? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE
|
||||
: WHATSAPP_LOGIN_RESTART_MESSAGE;
|
||||
}
|
||||
|
||||
type SocketActivityEmitter = {
|
||||
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
@@ -201,21 +223,30 @@ export async function waitForWhatsAppLoginResult(params: {
|
||||
const wait = params.waitForConnection ?? waitForWaConnection;
|
||||
const createSocket = params.createSocket ?? createWaSocket;
|
||||
let currentSock = params.sock;
|
||||
let restarted = false;
|
||||
let postPairingRestarted = false;
|
||||
let timeoutRestarted = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await wait(currentSock);
|
||||
return {
|
||||
outcome: "connected",
|
||||
restarted,
|
||||
restarted: postPairingRestarted || timeoutRestarted,
|
||||
sock: currentSock,
|
||||
};
|
||||
} catch (err) {
|
||||
const statusCode = getStatusCode(err);
|
||||
if (statusCode === 515 && !restarted) {
|
||||
restarted = true;
|
||||
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
|
||||
const restartKind = getLoginSocketRestartKind(statusCode);
|
||||
const canRestart =
|
||||
(restartKind === "post-pairing" && !postPairingRestarted) ||
|
||||
(restartKind === "timeout" && !timeoutRestarted);
|
||||
if (restartKind && canRestart) {
|
||||
if (restartKind === "post-pairing") {
|
||||
postPairingRestarted = true;
|
||||
} else {
|
||||
timeoutRestarted = true;
|
||||
}
|
||||
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
|
||||
closeWaSocket(currentSock);
|
||||
try {
|
||||
currentSock = await createSocket(false, params.verbose, {
|
||||
|
||||
@@ -129,6 +129,37 @@ describe("login-qr", () => {
|
||||
expect(logoutWebMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a replacement QR when status 408 happens before the first QR", async () => {
|
||||
const accountId = "timeout-before-first-qr";
|
||||
createWaSocketMock
|
||||
.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_printQr: boolean,
|
||||
_verbose: boolean,
|
||||
opts?: { authDir?: string; onQr?: (qr: string) => void },
|
||||
) => {
|
||||
const sock = { ws: { close: vi.fn() } };
|
||||
setImmediate(() => opts?.onQr?.("qr-after-timeout"));
|
||||
return sock as never;
|
||||
},
|
||||
);
|
||||
waitForWaConnectionMock
|
||||
.mockRejectedValueOnce({ output: { statusCode: 408 } })
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
|
||||
expect(start).toEqual({
|
||||
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
|
||||
message: "Scan this QR in WhatsApp → Linked Devices.",
|
||||
});
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
|
||||
waitForWaConnectionMock.mockRejectedValueOnce({
|
||||
output: { statusCode: 401 },
|
||||
|
||||
@@ -187,6 +187,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
|
||||
return;
|
||||
}
|
||||
const qrVersion = updateLoginQrState(current, qr);
|
||||
notifyQrUpdate(current);
|
||||
renderLatestQrDataUrlInBackground({
|
||||
accountId,
|
||||
loginId: login.id,
|
||||
@@ -269,8 +270,29 @@ async function waitForQrOrRecoveredLogin(params: {
|
||||
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
|
||||
} as const;
|
||||
});
|
||||
const qrUpdateResult = params.login.qrUpdatePromise.then(() => {
|
||||
const current = activeLogins.get(params.accountId);
|
||||
if (current?.id !== params.login.id) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: "WhatsApp login was replaced by a newer request.",
|
||||
} as const;
|
||||
}
|
||||
if (current.qr) {
|
||||
return { outcome: "qr", qr: current.qr } as const;
|
||||
}
|
||||
if (current.connected) {
|
||||
return { outcome: "connected" } as const;
|
||||
}
|
||||
return {
|
||||
outcome: "failed",
|
||||
message: current.error
|
||||
? `WhatsApp login failed: ${current.error}`
|
||||
: "WhatsApp QR update ended without an active QR.",
|
||||
} as const;
|
||||
});
|
||||
|
||||
return await Promise.race([qrResult, loginResult]);
|
||||
return await Promise.race([qrResult, loginResult, qrUpdateResult]);
|
||||
}
|
||||
|
||||
export async function startWebLoginWithQr(
|
||||
|
||||
Reference in New Issue
Block a user