/** * 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 }); // Load environment variables from .env file (Smart Multi-Path Lookup) const path = require('path'); const fs = require('fs'); const dotenv = require('dotenv'); const envPaths = [ path.join(__dirname, '.env'), // whatsapp_bridge/.env path.join(__dirname, '..', '.env'), // mywhatsapp.intaleqapp.com/.env '/home/intaleqapp-mywhatsapp/.env' // Server user root-level .env (as in user screenshot) ]; let envLoaded = false; for (const envPath of envPaths) { if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }); console.log(`[ENV] Successfully loaded environment variables from: ${envPath}`); envLoaded = true; break; } } if (!envLoaded) { dotenv.config(); // Fallback to default CWD console.log('[ENV] No specific .env found in known paths, loaded default configuration'); } // ─── Firebase Admin SDK Configuration (Highly Secure Background Pushes) ───── const admin = require('firebase-admin'); let firebaseApp = null; // Support three secure options: // 1. Raw JSON string in environment variable (FIREBASE_SERVICE_ACCOUNT) // 2. Custom secure file path in environment variable (FIREBASE_SERVICE_ACCOUNT_PATH) // 3. Fallback local file ignored by Git (serviceAccountKey.json) const envServiceAccount = process.env.FIREBASE_SERVICE_ACCOUNT; const envServiceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH; const localServiceAccountPath = path.join(__dirname, 'serviceAccountKey.json'); try { if (envServiceAccount) { let serviceAccount; if (envServiceAccount.trim().startsWith('{')) { serviceAccount = JSON.parse(envServiceAccount); console.log('[FCM] Initializing Firebase Admin SDK via direct env JSON string...'); } else { serviceAccount = require(envServiceAccount); console.log(`[FCM] Initializing Firebase Admin SDK via custom path from env: ${envServiceAccount}`); } firebaseApp = admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); } else if (envServiceAccountPath && fs.existsSync(envServiceAccountPath)) { console.log(`[FCM] Initializing Firebase Admin SDK via secure custom path: ${envServiceAccountPath}`); const serviceAccount = require(envServiceAccountPath); firebaseApp = admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); } else if (fs.existsSync(localServiceAccountPath)) { console.log('[FCM] Initializing Firebase Admin SDK via fallback local serviceAccountKey.json...'); const serviceAccount = require(localServiceAccountPath); firebaseApp = admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); } else { console.warn('[FCM WARNING] No Firebase Service Account found in environment or local files. Background push notifications will be disabled.'); } } catch (err) { console.error('[FCM ERROR] Failed to initialize Firebase Admin SDK:', err.message); } async function sendPushNotification(chatId, senderName, body) { if (!firebaseApp) { console.log('[FCM] Push skipped: Firebase Admin SDK not initialized.'); return; } const tokenPath = path.join(__dirname, 'fcm_token.json'); if (!fs.existsSync(tokenPath)) { console.log('[FCM] Push skipped: No registered FCM device token found.'); return; } try { const tokenData = JSON.parse(fs.readFileSync(tokenPath)); const token = tokenData.token; if (!token) return; const message = { token: token, notification: { title: senderName || 'WhatsApp Message', body: body || 'New Message' }, data: { chatId: chatId, name: senderName || 'WhatsApp' }, apns: { payload: { aps: { sound: 'default', badge: 1 } } } }; const response = await admin.messaging().send(message); console.log('[FCM] Push notification sent successfully, messageId:', response); } catch (err) { console.error('[FCM SEND ERROR] Failed to send push notification:', err.message); } } // ─── 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 { // 1. Try memory-based avatar lookup first (takes < 1ms) avatar = await chat.client.pupPage.evaluate((chatId) => { try { const contact = window.Store.Contact.get(chatId); return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null; } catch (_) { return null; } }, chat.id._serialized); // 2. If memory has no avatar, fallback to strict-timeout network query (max 800ms) if (!avatar) { avatar = await Promise.race([ chat.getProfilePicUrl().catch(() => null), new Promise(resolve => setTimeout(() => resolve(null), 800)) ]); } } 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; const fs = require('fs'); const puppeteerConfig = { headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-gpu', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' ] }; const possiblePaths = [ process.env.CHROME_BIN, '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable' ]; let detectedPath = null; for (const p of possiblePaths) { if (p && fs.existsSync(p)) { detectedPath = p; break; } } if (detectedPath) { console.log(`[WA] Detected system chromium at: ${detectedPath}`); puppeteerConfig.executablePath = detectedPath; } else { console.log('[WA] No system chromium found. Falling back to bundled Puppeteer chromium...'); } waClient = new Client({ authStrategy: new LocalAuth({ clientId: 'whatsapp-bridge' }), puppeteer: puppeteerConfig, }); // 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 }); // Trigger background push notification if not sent by me if (!msg.fromMe) { try { const contact = await msg.getContact(); const senderName = contact.name || contact.pushname || msg.from.split('@')[0]; let body = msg.body || ''; if (!body && msg.hasMedia) { body = '📷 Media/Attachment'; } await sendPushNotification(msg.from, senderName, body); } catch (fcmErr) { console.error('[FCM PUSH ERROR] Failed to send background push:', fcmErr.message); } } } 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' }); } console.log(`[WS RECV] Request type: ${type}, requestId: ${requestId}`); const respond = (data) => sendTo(ws, { ...data, requestId }); // Handle type specific requests try { switch (type) { // ── Ping ─────────────────────────────────────────────────────────── case 'ping': return respond({ type: 'pong', ready: clientReady }); // ── Register FCM Token ───────────────────────────────────────────── case 'register_fcm': { const { token } = payload; if (!token) { return respond({ type: 'error', message: 'Token is required' }); } const fs = require('fs'); const path = require('path'); const tokenPath = path.join(__dirname, 'fcm_token.json'); fs.writeFileSync(tokenPath, JSON.stringify({ token, updatedAt: new Date().toISOString() })); console.log('[FCM] Token registered and saved successfully:', token.substring(0, 15) + '...'); return respond({ type: 'fcm_registered', success: true }); } // ── Conversations ────────────────────────────────────────────────── case 'get_conversations': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const startTime = Date.now(); console.log(`[WS] get_conversations fetch started...`); try { const fetchChats = async () => { 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)); const duration = Date.now() - startTime; console.log(`[WS] get_conversations fetched ${formatted.length}/${chats.length} chats in ${duration}ms`); return { type: 'conversations', data: formatted, total: chats.length }; }; const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Server request to WhatsApp timed out')), 45000) ); const result = await Promise.race([fetchChats(), timeoutPromise]); return respond({ ...result, requestId }); } catch (err) { const duration = Date.now() - startTime; console.error(`[WS] get_conversations failed or timed out after ${duration}ms:`, err.message); return respond({ type: 'error', message: err.message || 'Failed to fetch conversations', 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 }); } // ── Media ────────────────────────────────────────────────────────── case 'get_media': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } const { messageId } = payload; if (!messageId) { return respond({ type: 'error', message: 'messageId is required' }); } try { // Extract chatId from messageId (format: true_447701407332@c.us_3EB0C8B196C5F354) const parts = messageId.split('_'); if (parts.length < 2) { return respond({ type: 'error', message: 'Invalid messageId format' }); } const chatId = parts[1]; const chat = await waClient.getChatById(chatId); const messages = await chat.fetchMessages({ limit: 100 }); const msg = messages.find(m => m.id._serialized === messageId); if (!msg) { return respond({ type: 'error', message: 'Message not found in chat history' }); } if (!msg.hasMedia) { return respond({ type: 'error', message: 'Message has no media attachments' }); } console.log(`[WS] Downloading media for message: ${messageId}`); let media = null; // Attempt to download media with retries and a delay to allow decryption for (let i = 0; i < 3; i++) { try { media = await msg.downloadMedia(); if (media && media.data) { console.log(`[WS] Successfully downloaded media on attempt ${i + 1}`); break; } } catch (err) { console.warn(`[WS] Media download attempt ${i + 1} failed:`, err.message); } // Wait 1.5 seconds before retrying await new Promise(resolve => setTimeout(resolve, 1500)); } if (!media || !media.data) { return respond({ type: 'error', message: 'Failed to download media file from WhatsApp servers after multiple attempts' }); } // If the media is an Ogg/Opus audio file, convert it to MP3 on-the-fly if (media.mimetype && (media.mimetype.includes('audio/ogg') || media.mimetype.includes('ogg'))) { try { console.log(`[WS] Converting OGG audio file for message ${messageId} to MP3 for iOS compatibility...`); const mp3Data = await convertOggToMp3(media.data); media.data = mp3Data; media.mimetype = 'audio/mp3'; media.filename = 'voice_note.mp3'; } catch (err) { console.error(`[WS] Ogg to MP3 conversion failed (sending raw Ogg instead):`, err.message); } } return respond({ type: 'media', messageId: messageId, data: media.data, // base64 string mimetype: media.mimetype, filename: media.filename || 'file', requestId }); } catch (err) { console.error('[WS] get_media failed:', err.message); return respond({ type: 'error', message: err.message || 'Failed to download media', 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 }); } // ── Send Media ────────────────────────────────────────────────────── case 'send_media': { if (!clientReady) { return respond({ type: 'error', message: 'WhatsApp is not ready' }); } let { chatId, base64, mimetype, filename, caption } = payload; if (!chatId || !base64 || !mimetype) { return respond({ type: 'error', message: 'chatId, base64, and mimetype are required' }); } // If it is an OGG audio file being sent, convert it to MP3 so the headless browser can decode it and calculate duration if (mimetype && (mimetype.includes('audio/ogg') || mimetype.includes('ogg'))) { try { console.log(`[WS] Converting outgoing OGG audio to MP3 for Puppeteer compatibility...`); const mp3Base64 = await convertOggToMp3(base64); base64 = mp3Base64; mimetype = 'audio/mp3'; filename = 'voice_note.mp3'; } catch (err) { console.error(`[WS] Outgoing audio conversion failed (sending raw Ogg instead):`, err.message); } } try { const { MessageMedia } = require('whatsapp-web.js'); const media = new MessageMedia(mimetype, base64, filename || 'file'); const sentMsg = await waClient.sendMessage(chatId, media, { caption: caption || '' }); return respond({ type: 'message_sent', chatId: chatId, data: formatMessage(sentMsg), requestId }); } catch (err) { console.error('[WS] send_media failed:', err.message); return respond({ type: 'error', message: err.message || 'Failed to send media', 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 REST API Endpoints (For easy integration as a Proxy/API) ─────── app.use(express.json({ limit: '50mb' })); app.post('/api/send', async (req, res) => { if (!clientReady) return res.status(503).json({ error: 'WhatsApp is not ready' }); const { phone, message } = req.body; if (!phone || !message) return res.status(400).json({ error: 'phone and message are required' }); try { const chatId = phone.includes('@') ? phone : `${phone}@c.us`; const sentMsg = await waClient.sendMessage(chatId, message); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { console.error('[API] Send error:', err.message); res.status(500).json({ error: err.message }); } }); app.post('/api/send-media', async (req, res) => { if (!clientReady) return res.status(503).json({ error: 'WhatsApp is not ready' }); const { phone, base64, mimetype, filename, caption } = req.body; if (!phone || !base64 || !mimetype) return res.status(400).json({ error: 'phone, base64, and mimetype are required' }); try { // Sanitize the base64 string (strip data url prefixes if present) let cleanBase64 = base64.trim(); if (cleanBase64.includes(';base64,')) { cleanBase64 = cleanBase64.split(';base64,')[1]; } const { MessageMedia } = require('whatsapp-web.js'); const media = new MessageMedia(mimetype, cleanBase64, filename || 'file'); const chatId = phone.includes('@') ? phone : `${phone}@c.us`; const sentMsg = await waClient.sendMessage(chatId, media, { caption: caption || '' }); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { console.error('[API] Send media error:', err.message); res.status(500).json({ error: err.message }); } }); app.post('/api/send-poll', async (req, res) => { if (!clientReady) return res.status(503).json({ error: 'WhatsApp is not ready' }); const { phone, question, options, allowMultiple } = req.body; if (!phone || !question || !options || !Array.isArray(options)) { return res.status(400).json({ error: 'phone, question, and options (array of strings) are required' }); } try { const { Poll } = require('whatsapp-web.js'); const poll = new Poll(question, options, { allowMultipleAnswers: allowMultiple !== false // default to true }); const chatId = phone.includes('@') ? phone : `${phone}@c.us`; const sentMsg = await waClient.sendMessage(chatId, poll); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { console.error('[API] Send poll error:', err.message); res.status(500).json({ error: err.message }); } }); // ─── 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(); }); // ─── OGG to MP3 base64 converter using ffmpeg child process ──────────────── function convertOggToMp3(base64Ogg) { const { exec } = require('child_process'); const tmp = require('os').tmpdir(); const path = require('path'); const fs = require('fs'); return new Promise((resolve, reject) => { const timeId = Date.now(); const inputPath = path.join(tmp, `input_${timeId}.ogg`); const outputPath = path.join(tmp, `output_${timeId}.mp3`); fs.writeFileSync(inputPath, Buffer.from(base64Ogg, 'base64')); exec(`ffmpeg -i "${inputPath}" -acodec libmp3lame -aq 2 "${outputPath}"`, (error, stdout, stderr) => { // Clean up input file try { fs.unlinkSync(inputPath); } catch(_) {} if (error) { console.error('[FFMPEG ERROR]', error); return reject(error); } try { const mp3Base64 = fs.readFileSync(outputPath).toString('base64'); try { fs.unlinkSync(outputPath); } catch(_) {} resolve(mp3Base64); } catch (err) { reject(err); } }); }); }