openclaw/extensions/whatsapp/src/login-qr.ts

298 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from "node:crypto";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { loadConfig } from "../../../src/config/config.js";
import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { renderQrPngBase64 } from "./qr-image.js";
import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
readWebSelfId,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
webAuthExists,
} from "./session.js";
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
type ActiveLogin = {
accountId: string;
authDir: string;
isLegacyAuthDir: boolean;
id: string;
sock: WaSocket;
startedAt: number;
qr?: string;
qrDataUrl?: string;
connected: boolean;
error?: string;
errorStatus?: number;
waitPromise: Promise<void>;
restartAttempted: boolean;
verbose: boolean;
};
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
const activeLogins = new Map<string, ActiveLogin>();
function closeSocket(sock: WaSocket) {
try {
sock.ws?.close();
} catch {
// ignore
}
}
async function resetActiveLogin(accountId: string, reason?: string) {
const login = activeLogins.get(accountId);
if (login) {
closeSocket(login.sock);
activeLogins.delete(accountId);
}
if (reason) {
logInfo(reason);
}
}
function isLoginFresh(login: ActiveLogin) {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function attachLoginWaiter(accountId: string, login: ActiveLogin) {
login.waitPromise = waitForWaConnection(login.sock)
.then(() => {
const current = activeLogins.get(accountId);
if (current?.id === login.id) {
current.connected = true;
}
})
.catch((err) => {
const current = activeLogins.get(accountId);
if (current?.id !== login.id) {
return;
}
current.error = formatError(err);
current.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); waiting for creds to save…"),
);
closeSocket(login.sock);
await waitForCredsSaveQueueWithTimeout(login.authDir);
try {
const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir,
});
login.sock = sock;
login.connected = false;
login.error = undefined;
login.errorStatus = undefined;
attachLoginWaiter(login.accountId, login);
return true;
} catch (err) {
login.error = formatError(err);
login.errorStatus = getStatusCode(err);
return false;
}
}
export async function startWebLoginWithQr(
opts: {
verbose?: boolean;
timeoutMs?: number;
force?: boolean;
accountId?: string;
runtime?: RuntimeEnv;
} = {},
): Promise<{ qrDataUrl?: string; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const hasWeb = await webAuthExists(account.authDir);
const selfId = readWebSelfId(account.authDir);
if (hasWeb && !opts.force) {
const who = selfId.e164 ?? selfId.jid ?? "unknown";
return {
message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`,
};
}
const existing = activeLogins.get(account.accountId);
if (existing && isLoginFresh(existing) && existing.qrDataUrl) {
return {
qrDataUrl: existing.qrDataUrl,
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
};
}
await resetActiveLogin(account.accountId);
let resolveQr: ((qr: string) => void) | null = null;
let rejectQr: ((err: Error) => void) | null = null;
const qrPromise = new Promise<string>((resolve, reject) => {
resolveQr = resolve;
rejectQr = reject;
});
const qrTimer = setTimeout(
() => {
rejectQr?.(new Error("Timed out waiting for WhatsApp QR"));
},
Math.max(opts.timeoutMs ?? 30_000, 5000),
);
let sock: WaSocket;
let pendingQr: string | null = null;
try {
sock = await createWaSocket(false, Boolean(opts.verbose), {
authDir: account.authDir,
onQr: (qr: string) => {
if (pendingQr) {
return;
}
pendingQr = qr;
const current = activeLogins.get(account.accountId);
if (current && !current.qr) {
current.qr = qr;
}
clearTimeout(qrTimer);
runtime.log(info("WhatsApp QR received."));
resolveQr?.(qr);
},
});
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin(account.accountId);
return {
message: `Failed to start WhatsApp login: ${String(err)}`,
};
}
const login: ActiveLogin = {
accountId: account.accountId,
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
id: randomUUID(),
sock,
startedAt: Date.now(),
connected: false,
waitPromise: Promise.resolve(),
restartAttempted: false,
verbose: Boolean(opts.verbose),
};
activeLogins.set(account.accountId, login);
if (pendingQr && !login.qr) {
login.qr = pendingQr;
}
attachLoginWaiter(account.accountId, login);
let qr: string;
try {
qr = await qrPromise;
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin(account.accountId);
return {
message: `Failed to get QR: ${String(err)}`,
};
}
const base64 = await renderQrPngBase64(qr);
login.qrDataUrl = `data:image/png;base64,${base64}`;
return {
qrDataUrl: login.qrDataUrl,
message: "Scan this QR in WhatsApp → Linked Devices.",
};
}
export async function waitForWebLogin(
opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {},
): Promise<{ connected: boolean; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const activeLogin = activeLogins.get(account.accountId);
if (!activeLogin) {
return {
connected: false,
message: "No active WhatsApp login in progress.",
};
}
const login = activeLogin;
if (!isLoginFresh(login)) {
await resetActiveLogin(account.accountId);
return {
connected: false,
message: "The login QR expired. Ask me to generate a new one.",
};
}
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
const deadline = Date.now() + timeoutMs;
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 (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({
authDir: login.authDir,
isLegacyAuthDir: login.isLegacyAuthDir,
runtime,
});
const message =
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
await resetActiveLogin(account.accountId, 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(account.accountId, message);
runtime.log(danger(message));
return { connected: false, message };
}
if (login.connected) {
const message = "✅ Linked! WhatsApp is ready.";
runtime.log(success(message));
await resetActiveLogin(account.accountId);
return { connected: true, message };
}
return { connected: false, message: "Login ended without a connection." };
}
}