/** * Step: whatsapp-auth — WhatsApp interactive auth (QR code / pairing code). */ import { execSync, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { logger } from '../src/logger.js'; import { openBrowser, isHeadless } from './platform.js'; import { emitStatus } from './status.js'; const QR_AUTH_TEMPLATE = ` NanoClaw - WhatsApp Auth

Scan with WhatsApp

Expires in 60s
{{QR_SVG}}
Settings \\u2192 Linked Devices \\u2192 Link a Device
`; const SUCCESS_HTML = ` NanoClaw - Connected!

Connected to WhatsApp

You can close this tab.

`; function parseArgs(args: string[]): { method: string; phone: string } { let method = ''; let phone = ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; } if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; } } return { method, phone }; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function readFileSafe(filePath: string): string { try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; } } function getPhoneNumber(projectRoot: string): string { try { const creds = JSON.parse( fs.readFileSync( path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8', ), ); if (creds.me?.id) { return creds.me.id.split(':')[0].split('@')[0]; } } catch { // Not available yet } return ''; } function emitAuthStatus( method: string, authStatus: string, status: string, extra: Record = {}, ): void { const fields: Record = { AUTH_METHOD: method, AUTH_STATUS: authStatus, ...extra, STATUS: status, LOG: 'logs/setup.log', }; emitStatus('AUTH_WHATSAPP', fields); } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { method, phone } = parseArgs(args); const statusFile = path.join(projectRoot, 'store', 'auth-status.txt'); const qrFile = path.join(projectRoot, 'store', 'qr-data.txt'); if (!method) { emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag', }); process.exit(4); } // qr-terminal is a manual flow if (method === 'qr-terminal') { emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot, }); return; } if (method === 'pairing-code' && !phone) { emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number', }); process.exit(4); } if (!['qr-browser', 'pairing-code'].includes(method)) { emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' }); process.exit(4); } // Clean stale state logger.info({ method }, 'Starting channel authentication'); try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true, }); } catch { /* ok */ } try { fs.unlinkSync(qrFile); } catch { /* ok */ } try { fs.unlinkSync(statusFile); } catch { /* ok */ } // Start auth process in background const authArgs = method === 'pairing-code' ? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone] : ['src/whatsapp-auth.ts']; const authProc = spawn('npx', ['tsx', ...authArgs], { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], detached: false, }); const logFile = path.join(projectRoot, 'logs', 'setup.log'); const logStream = fs.createWriteStream(logFile, { flags: 'a' }); authProc.stdout?.pipe(logStream); authProc.stderr?.pipe(logStream); // Cleanup on exit const cleanup = () => { try { authProc.kill(); } catch { /* ok */ } }; process.on('exit', cleanup); try { if (method === 'qr-browser') { await handleQrBrowser(projectRoot, statusFile, qrFile); } else { await handlePairingCode(projectRoot, statusFile, phone); } } finally { cleanup(); process.removeListener('exit', cleanup); } } async function handleQrBrowser( projectRoot: string, statusFile: string, qrFile: string, ): Promise { // Poll for QR data (15s) let qrReady = false; for (let i = 0; i < 15; i++) { const statusContent = readFileSafe(statusFile); if (statusContent === 'already_authenticated') { emitAuthStatus('qr-browser', 'already_authenticated', 'success'); return; } if (fs.existsSync(qrFile)) { qrReady = true; break; } await sleep(1000); } if (!qrReady) { emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' }); process.exit(3); } // Generate QR SVG and HTML const qrData = fs.readFileSync(qrFile, 'utf-8'); try { const svg = execSync( `node -e "const QR=require('qrcode');const data='${qrData}';QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`, { cwd: projectRoot, encoding: 'utf-8' }, ); const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg); const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html'); fs.writeFileSync(htmlPath, html); // Open in browser (cross-platform) if (!isHeadless()) { const opened = openBrowser(htmlPath); if (!opened) { logger.warn( 'Could not open browser — display QR in terminal as fallback', ); } } else { logger.info( 'Headless environment — QR HTML saved but browser not opened', ); } } catch (err) { logger.error({ err }, 'Failed to generate QR HTML'); } // Poll for completion (120s) await pollAuthCompletion('qr-browser', statusFile, projectRoot); } async function handlePairingCode( projectRoot: string, statusFile: string, phone: string, ): Promise { // Poll for pairing code (15s) let pairingCode = ''; for (let i = 0; i < 15; i++) { const statusContent = readFileSafe(statusFile); if (statusContent === 'already_authenticated') { emitAuthStatus('pairing-code', 'already_authenticated', 'success'); return; } if (statusContent.startsWith('pairing_code:')) { pairingCode = statusContent.replace('pairing_code:', ''); break; } if (statusContent.startsWith('failed:')) { emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: statusContent.replace('failed:', ''), }); process.exit(1); } await sleep(1000); } if (!pairingCode) { emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout', }); process.exit(3); } // Write to file immediately so callers can read it without waiting for stdout try { fs.writeFileSync( path.join(projectRoot, 'store', 'pairing-code.txt'), pairingCode, ); } catch { /* non-fatal */ } // Emit pairing code immediately so the caller can display it to the user emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode, }); // Poll for completion (120s) await pollAuthCompletion( 'pairing-code', statusFile, projectRoot, pairingCode, ); } async function pollAuthCompletion( method: string, statusFile: string, projectRoot: string, pairingCode?: string, ): Promise { const extra: Record = {}; if (pairingCode) extra.PAIRING_CODE = pairingCode; for (let i = 0; i < 60; i++) { const content = readFileSafe(statusFile); if (content === 'authenticated' || content === 'already_authenticated') { // Write success page if qr-auth.html exists const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html'); if (fs.existsSync(htmlPath)) { fs.writeFileSync(htmlPath, SUCCESS_HTML); } const phoneNumber = getPhoneNumber(projectRoot); if (phoneNumber) extra.PHONE_NUMBER = phoneNumber; emitAuthStatus(method, content, 'success', extra); return; } if (content.startsWith('failed:')) { const error = content.replace('failed:', ''); emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra }); process.exit(1); } await sleep(2000); } emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra }); process.exit(3); }