/** * WhatsApp WebSocket Bridge Server (Node.js) * Uses whatsapp-web.js to connect to WhatsApp via Puppeteer * Exposes a WebSocket API and HTTP health endpoint on Port 3025 */ const { Client, LocalAuth } = require('whatsapp-web.js'); const qrcode = require('qrcode'); const { WebSocketServer, WebSocket } = require('ws'); const express = require('express'); const http = require('http'); // ─── Config ──────────────────────────────────────────────────────────────── const PORT = 3025; const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); // ─── State ───────────────────────────────────────────────────────────────── let waClient = null; let clientReady = false; let qrCodeCache = null; const connectedClients = new Set(); // ─── Error Handling (Never Crash) ────────────────────────────────────────── process.on('uncaughtException', (err) => { console.error('[CRITICAL] Uncaught Exception:', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('[CRITICAL] Unhandled Rejection at:', promise, 'reason:', reason); }); // ─── WebSocket: Broadcast to all Flutter clients ─────────────────────────── function broadcast(payload) { const data = JSON.stringify(payload); connectedClients.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(data); } catch (err) { console.error('[WS] Broadcast send error:', err.message); } } }); } function sendTo(ws, payload) { if (ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(payload)); } catch (err) { console.error('[WS] Send error:', err.message); } } } // ─── Format conversation object for Flutter ──────────────────────────────── async function formatChat(chat) { let avatar = null; try { const contact = await chat.getContact(); const pic = await contact.getProfilePicUrl(); if (pic) avatar = pic; } catch (_) {} // Last Message formatting let lastMessageFormatted = null; if (chat.lastMessage) { lastMessageFormatted = { body: chat.lastMessage.body || '', timestamp: chat.lastMessage.timestamp || Math.floor(Date.now() / 1000), fromMe: chat.lastMessage.fromMe || false, hasMedia: chat.lastMessage.hasMedia || false }; } return { id: chat.id._serialized, name: chat.name || 'Unknown', isGroup: chat.isGroup || false, unreadCount: chat.unreadCount || 0, avatar: avatar, lastMessage: lastMessageFormatted, timestamp: chat.timestamp || Math.floor(Date.now() / 1000), pinned: chat.pinned || false, isMuted: chat.isMuted || false }; } // ─── Format message object for Flutter ───────────────────────────────────── function formatMessage(msg) { // Map internal ack values if needed, otherwise fallback // ack: 0=error/none 1=pending 2=sent 3=delivered 4=read let ack = msg.ack || 0; if (ack < 0) ack = 0; // Restrict types to allowed values: "chat"|"image"|"video"|"audio"|"document"|"sticker" let type = "chat"; if (["chat", "image", "video", "audio", "document", "sticker"].includes(msg.type)) { type = msg.type; } else if (msg.type === "ptt") { type = "audio"; } return { id: msg.id._serialized, body: msg.body || '', fromMe: msg.fromMe || false, timestamp: msg.timestamp || Math.floor(Date.now() / 1000), type: type, hasMedia: msg.hasMedia || false, isForwarded: msg.isForwarded || false, author: msg.author || null, ack: ack }; } // ─── Initialize WhatsApp Client ───────────────────────────────────────────── function initWhatsApp() { console.log('[WA] Initializing WhatsApp client using LocalAuth...'); clientReady = false; waClient = new Client({ authStrategy: new LocalAuth({ clientId: 'whatsapp-bridge' }), puppeteer: { headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', '--single-process', '--disable-gpu', ], }, }); // QR Code received -> send to all WS clients waClient.on('qr', async (qr) => { console.log('[WA] QR Code received'); try { const qrDataUrl = await qrcode.toDataURL(qr); qrCodeCache = qrDataUrl; broadcast({ type: 'qr', qr: qrDataUrl }); } catch (err) { console.error('[WA] QR generation error:', err); } }); // Authenticated waClient.on('authenticated', () => { console.log('[WA] Authenticated successfully'); qrCodeCache = null; broadcast({ type: 'authenticated' }); }); // Ready waClient.on('ready', () => { console.log('[WA] WhatsApp Client Ready'); clientReady = true; qrCodeCache = null; broadcast({ type: 'ready' }); broadcast({ type: 'status', ready: true }); }); // New message received waClient.on('message', async (msg) => { console.log(`[WA] New message received from: ${msg.from}`); try { const formatted = formatMessage(msg); broadcast({ type: 'new_message', chatId: msg.from, data: formatted }); } catch (err) { console.error('[WA] Error formatting new message event:', err.message); } }); // Message ACK update waClient.on('message_ack', (msg, ack) => { try { broadcast({ type: 'message_ack', messageId: msg.id._serialized, chatId: msg.to || msg.from, ack: ack }); } catch (err) { console.error('[WA] Error sending message_ack event:', err.message); } }); // Disconnected waClient.on('disconnected', (reason) => { console.warn('[WA] Disconnected! Reason:', reason); clientReady = false; broadcast({ type: 'disconnected', reason }); broadcast({ type: 'status', ready: false }); // Clean up client resources try { waClient.destroy(); } catch (_) {} // Auto-reconnect after 5 seconds console.log('[WA] Reconnecting in 5 seconds...'); setTimeout(initWhatsApp, 5000); }); waClient.initialize().catch((err) => { console.error('[WA] Client initialization failed:', err); console.log('[WA] Retrying initialization in 5 seconds...'); setTimeout(initWhatsApp, 5000); }); } // ─── Handle WebSocket messages from Flutter ──────────────────────────────── async function handleMessage(ws, raw) { let payload; try { payload = JSON.parse(raw); } catch (err) { return sendTo(ws, { type: 'error', message: 'Invalid JSON payload' }); } const { type, requestId } = payload; if (!requestId) { return sendTo(ws, { type: 'error', message: 'Missing requestId' }); } const respond = (data) => sendTo(ws, { ...data, requestId }); // Handle type specific requests try { switch (type) { // ── Ping ─────────────────────────────────────────────────────────── case 'ping': return respond({ type: 'pong', ready: clientReady }); // ── Conversations ────────────────────────────────────────────────── case 'get_conversations': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const chats = await waClient.getChats(); const limit = parseInt(payload.limit) || 50; const offset = parseInt(payload.offset) || 0; const slice = chats.slice(offset, offset + limit); const formatted = await Promise.all(slice.map(formatChat)); return respond({ type: 'conversations', data: formatted, total: chats.length, requestId }); } // ── Messages ─────────────────────────────────────────────────────── case 'get_messages': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const { chatId, limit } = payload; if (!chatId) { return respond({ type: 'error', message: 'chatId is required' }); } const chat = await waClient.getChatById(chatId); const fetchLimit = parseInt(limit) || 50; const messages = await chat.fetchMessages({ limit: fetchLimit }); const formatted = messages.map(formatMessage); return respond({ type: 'messages', chatId: chatId, data: formatted, requestId }); } // ── Send Message ─────────────────────────────────────────────────── case 'send_message': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const { chatId, text } = payload; if (!chatId || !text) { return respond({ type: 'error', message: 'chatId and text are required' }); } const sentMsg = await waClient.sendMessage(chatId, text); return respond({ type: 'message_sent', chatId: chatId, data: formatMessage(sentMsg), requestId }); } // ── Mark as Read ─────────────────────────────────────────────────── case 'mark_read': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const { chatId } = payload; if (!chatId) { return respond({ type: 'error', message: 'chatId is required' }); } const chat = await waClient.getChatById(chatId); await chat.sendSeen(); return respond({ type: 'marked_read', chatId: chatId, requestId }); } // ── Search Conversations ─────────────────────────────────────────── case 'search_conversations': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const query = (payload.query || '').toLowerCase(); const chats = await waClient.getChats(); const filtered = chats.filter((c) => (c.name || '').toLowerCase().includes(query) ); const formatted = await Promise.all(filtered.slice(0, 50).map(formatChat)); return respond({ type: 'conversations', data: formatted, search: true, requestId }); } default: return respond({ type: 'error', message: `Unknown request type: ${type}` }); } } catch (err) { console.error(`[WS] Error processing request ${type}:`, err); return respond({ type: 'error', message: err.message || 'Server error' }); } } // ─── WebSocket Connection Handler ────────────────────────────────────────── wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; console.log(`[WS] New client connected from IP: ${ip}`); connectedClients.add(ws); // Send status immediately on connection sendTo(ws, { type: 'status', ready: clientReady }); // If a QR code is active and client is not ready, push it immediately if (!clientReady && qrCodeCache) { sendTo(ws, { type: 'qr', qr: qrCodeCache }); } ws.on('message', (data) => { try { handleMessage(ws, data.toString()); } catch (err) { console.error('[WS] Error handling incoming data:', err.message); } }); ws.on('close', () => { console.log(`[WS] Client disconnected: ${ip}`); connectedClients.delete(ws); }); ws.on('error', (err) => { console.error(`[WS] Error on client connection ${ip}:`, err.message); connectedClients.delete(ws); }); }); // ─── HTTP Health Endpoint ────────────────────────────────────────────────── app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', waReady: clientReady, clients: connectedClients.size, port: PORT }); }); // ─── Start HTTP + WebSocket Server ───────────────────────────────────────── server.listen(PORT, () => { console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`); initWhatsApp(); });