Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 67 additions & 84 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ function authenticateWhatsApp(): Promise<void> {
const closeSock = (s: unknown, removeListeners = false) => {
try {
const socket = s as { ev?: { removeAllListeners: () => void }; ws?: { close: () => void } };
if (removeListeners) socket?.ev?.removeAllListeners();
if (removeListeners) {
socket?.ev?.removeAllListeners();
}
socket?.ws?.close();
} catch {
// Ignore
Expand All @@ -80,7 +82,6 @@ function authenticateWhatsApp(): Promise<void> {

try {
fs.rmSync(WA_AUTH_DIR, { recursive: true, force: true });
await new Promise((r) => setTimeout(r, 1000));
fs.mkdirSync(WA_AUTH_DIR, { recursive: true });
} catch {
console.log(chalk.yellow("Warning: Could not clear old session"));
Expand All @@ -93,28 +94,7 @@ function authenticateWhatsApp(): Promise<void> {
console.log(chalk.gray("Initializing WhatsApp connection..."));
console.log();

const { state, saveCreds: _saveCreds } = await useMultiFileAuthState(WA_AUTH_DIR);
const saveCreds = async () => {
const credsPath = path.join(WA_AUTH_DIR, "creds.json");
const tmpPath = credsPath + "." + Date.now() + ".tmp";
for (let attempt = 0; attempt < 10; attempt++) {
try {
fs.writeFileSync(tmpPath, JSON.stringify(state.creds, null, 2));
try {
fs.unlinkSync(credsPath);
} catch {}
fs.renameSync(tmpPath, credsPath);
return;
} catch {
try {
fs.unlinkSync(tmpPath);
} catch {}
if (attempt < 9) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
// Last resort: try the original saveCreds
await _saveCreds();
};
const { state, saveCreds } = await useMultiFileAuthState(WA_AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();

connectionTimeout = setTimeout(() => {
Expand Down Expand Up @@ -177,10 +157,11 @@ function authenticateWhatsApp(): Promise<void> {
pairingComplete = true;
console.log(chalk.green("\n[OK] WhatsApp authenticated successfully!"));

// Keep socket alive so the phone can finalize the handshake
setTimeout(() => {
closeSock(sock, true);
resolvePromise();
}, 500);
}, 3000);
}

if (connection === "close" && !pairingComplete) {
Expand Down Expand Up @@ -214,69 +195,71 @@ function authenticateWhatsApp(): Promise<void> {
);

// 515 = stream replaced, normal after QR pairing.
// Close websocket but DON'T remove listeners yet (let creds.update finish saving)
closeSock(sock, false);
await new Promise((r) => setTimeout(r, 3000));

// Now remove old listeners and reconnect using the SAME in-memory state
// (don't re-read from disk — cached keys may not be flushed yet)
// Creds are already saved. Close old socket and create a fresh one from disk.
closeSock(sock, true);
const retryVersion = await fetchLatestBaileysVersion();
const retrySock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, silentLogger),
},
version: retryVersion.version,
printQRInTerminal: false,
browser: ["TxtCode", "CLI", "1.0.0"],
syncFullHistory: false,
markOnlineOnConnect: false,
logger: silentLogger,
});

retrySock.ev.on("creds.update", saveCreds);

const retryTimeout = setTimeout(() => {
if (!pairingComplete) {
closeSock(retrySock, true);
rejectPromise(
new Error("WhatsApp linking timed out after restart. Please try again."),
);
}
}, 30000);

retrySock.ev.on("connection.update", (retryUpdate) => {
if (retryUpdate.connection === "open") {
clearTimeout(retryTimeout);
pairingComplete = true;
console.log(chalk.green("[OK] WhatsApp linked successfully!\n"));

setTimeout(() => {

try {
const { state: freshState, saveCreds: freshSaveCreds } =
await useMultiFileAuthState(WA_AUTH_DIR);
const retrySock = makeWASocket({
auth: {
creds: freshState.creds,
keys: makeCacheableSignalKeyStore(freshState.keys, silentLogger),
},
version,
printQRInTerminal: false,
browser: ["TxtCode", "CLI", "1.0.0"],
syncFullHistory: false,
markOnlineOnConnect: false,
logger: silentLogger,
});

retrySock.ev.on("creds.update", freshSaveCreds);

const retryTimeout = setTimeout(() => {
if (!pairingComplete) {
closeSock(retrySock, true);
resolvePromise();
}, 1000);
}

if (retryUpdate.connection === "close" && !pairingComplete) {
const retryStatusCode = (
retryUpdate as {
lastDisconnect?: { error?: { output?: { statusCode?: number } } };
rejectPromise(
new Error("WhatsApp linking timed out after restart. Please try again."),
);
}
}, 30000);

retrySock.ev.on("connection.update", (retryUpdate) => {
if (retryUpdate.connection === "open") {
clearTimeout(retryTimeout);
pairingComplete = true;
console.log(chalk.green("[OK] WhatsApp linked successfully!\n"));
// Keep socket alive briefly so the phone can finalize the handshake
setTimeout(() => {
closeSock(retrySock, true);
resolvePromise();
}, 3000);
}

if (retryUpdate.connection === "close" && !pairingComplete) {
const retryStatusCode = (
retryUpdate as {
lastDisconnect?: { error?: { output?: { statusCode?: number } } };
}
)?.lastDisconnect?.error?.output?.statusCode;
if (retryStatusCode === 515) {
return;
}
)?.lastDisconnect?.error?.output?.statusCode;
// If we get another 515, keep retrying
if (retryStatusCode === 515) {
return;
clearTimeout(retryTimeout);
closeSock(retrySock, true);
rejectPromise(
new Error(
`WhatsApp authentication failed after restart (code: ${retryStatusCode})`,
),
);
}
clearTimeout(retryTimeout);
closeSock(retrySock, true);
rejectPromise(
new Error(
`WhatsApp authentication failed after restart (code: ${retryStatusCode})`,
),
);
}
});
});
} catch (err) {
rejectPromise(
err instanceof Error ? err : new Error("Failed to restart WhatsApp connection"),
);
}
} else if (hasShownQR && statusCode !== 515) {
closeSock(sock, true);
rejectPromise(new Error(`WhatsApp authentication failed (code: ${statusCode})`));
Expand Down
84 changes: 56 additions & 28 deletions src/platforms/whatsapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { WhatsAppTypingSignaler } from "../shared/typing-signaler";

const WA_AUTH_DIR = path.join(os.homedir(), ".txtcode", ".wacli_auth");
const MAX_WA_LENGTH = 4096;
const MAX_RECONNECT_ATTEMPTS = 12;
const RECONNECT_BASE_MS = 2000;
const RECONNECT_MAX_MS = 30000;
const RECONNECT_FACTOR = 1.8;

const noop = () => {};
const silentLogger = {
Expand All @@ -35,11 +39,27 @@ interface ActiveRequest {
aborted: boolean;
}

function computeBackoff(attempt: number): number {
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(RECONNECT_FACTOR, attempt), RECONNECT_MAX_MS);
const jitter = delay * 0.25 * Math.random();
return delay + jitter;
}

// Serialized credential save queue (prevents concurrent writes racing on Windows)
function createCredsSaver(saveCreds: () => Promise<void>): () => void {
let queue: Promise<void> = Promise.resolve();
return () => {
queue = queue.then(() => saveCreds()).catch(() => {});
};
}

export class WhatsAppBot {
private agent: AgentCore;
private sock!: WASocket;
private lastProcessedTimestamp: number = 0;
private activeRequests: Map<string, ActiveRequest> = new Map();
private reconnectAttempts = 0;
private connectedAt = 0;

constructor(agent: AgentCore) {
this.agent = agent;
Expand All @@ -65,27 +85,9 @@ export class WhatsAppBot {
process.exit(1);
}

const { state, saveCreds: _saveCreds } = await useMultiFileAuthState(WA_AUTH_DIR);
const saveCreds = async () => {
const credsPath = path.join(WA_AUTH_DIR, "creds.json");
const tmpPath = credsPath + "." + Date.now() + ".tmp";
for (let attempt = 0; attempt < 10; attempt++) {
try {
fs.writeFileSync(tmpPath, JSON.stringify(state.creds, null, 2));
try {
fs.unlinkSync(credsPath);
} catch {}
fs.renameSync(tmpPath, credsPath);
return;
} catch {
try {
fs.unlinkSync(tmpPath);
} catch {}
if (attempt < 9) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
await _saveCreds();
};
const { state, saveCreds } = await useMultiFileAuthState(WA_AUTH_DIR);
const enqueueSaveCreds = createCredsSaver(saveCreds);

const { version } = await fetchLatestBaileysVersion().catch(() => ({
version: undefined as unknown as [number, number, number],
}));
Expand All @@ -98,33 +100,59 @@ export class WhatsAppBot {
version,
printQRInTerminal: false,
browser: ["TxtCode", "CLI", "1.0.0"],
syncFullHistory: false,
markOnlineOnConnect: false,
logger: silentLogger,
});

// Handle WebSocket errors to prevent unhandled crashes
if (this.sock.ws && typeof (this.sock.ws as { on?: Function }).on === "function") {
(this.sock.ws as { on: Function }).on("error", (err: Error) => {
logger.error("WebSocket error:", err.message);
});
}

this.sock.ev.on("connection.update", async (update: Partial<ConnectionState>) => {
const { connection, lastDisconnect } = update;

if (connection === "open") {
logger.info("WhatsApp connected!");
logger.info("Waiting for messages...");
this.connectedAt = Date.now();
this.reconnectAttempts = 0;
this.sock.sendPresenceUpdate("available").catch(() => {});
}

if (connection === "close") {
const shouldReconnect =
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;

if (shouldReconnect) {
logger.error("Connection closed. Reconnecting...");
await this.start();
} else {
if (!shouldReconnect) {
logger.error("WhatsApp logged out. Run txtcode auth again.");
process.exit(1);
}

// Reset backoff if connection was healthy for > 60s
if (this.connectedAt && Date.now() - this.connectedAt > 60000) {
this.reconnectAttempts = 0;
}

if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
logger.error(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Exiting.`);
process.exit(1);
}

const delay = computeBackoff(this.reconnectAttempts);
this.reconnectAttempts++;
logger.error(
`Connection closed (code: ${statusCode}). Reconnecting in ${Math.round(delay / 1000)}s...`,
);
await new Promise((r) => setTimeout(r, delay));
await this.start();
}
});

this.sock.ev.on("creds.update", saveCreds);
this.sock.ev.on("creds.update", enqueueSaveCreds);

this.sock.ev.on(
"messages.upsert",
Expand Down