const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const path = require('path'); const fs = require('fs'); const dotenv = require('dotenv'); const url = require('url'); // ─── Environment Configuration ───────────────────────────────────────────── 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 ]; let envLoaded = false; for (const envPath of envPaths) { if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }); console.log(`[ENV] Loaded configuration from: ${envPath}`); envLoaded = true; break; } } if (!envLoaded) { dotenv.config(); console.log('[ENV] Loaded default environment configuration'); } // ─── Dependencies ────────────────────────────────────────────────────────── const { initDatabase, updateSlotStatus, archiveMessage, getChatHistory, searchMessages } = require('./database'); const admin = require('firebase-admin'); const PORT = process.env.PORT || 3025; // ─── Express & HTTP Setup ────────────────────────────────────────────────── const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // ─── Firebase Admin SDK Configuration (Background Pushes) ────────────────── let firebaseApp = null; 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 env JSON string...'); } else { serviceAccount = require(envServiceAccount); console.log(`[FCM] Initializing Firebase Admin SDK via env path: ${envServiceAccount}`); } firebaseApp = admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); } else if (envServiceAccountPath && fs.existsSync(envServiceAccountPath)) { console.log(`[FCM] Initializing Firebase Admin SDK via 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 serviceAccountKey.json...'); const serviceAccount = require(localServiceAccountPath); firebaseApp = admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); } else { console.warn('[FCM WARNING] No Firebase Service Account found. Background pushes will be disabled.'); } } catch (err) { console.error('[FCM ERROR] Failed to initialize Firebase Admin SDK:', err.message); } // ─── Send Background Push Notification ──────────────────────────────────── async function sendPushNotification(chatId, senderName, body) { if (!firebaseApp) return; const tokenPath = path.join(__dirname, 'fcm_token.json'); if (!fs.existsSync(tokenPath)) 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 } } } }; await admin.messaging().send(message); console.log('[FCM] Push notification sent successfully'); } catch (err) { console.error('[FCM SEND ERROR] Failed to send push:', err.message); } } // ─── 6-Slot Multi-Tenant Registry State ──────────────────────────────────── const slots = new Map(); // slotId (1-6) -> { waClient, clientReady, qrCodeCache } const connectedClients = new Set(); // Set of active WS connections // Uncaught Exceptions Catching process.on('uncaughtException', (err) => console.error('[CRITICAL] Uncaught Exception:', err)); process.on('unhandledRejection', (reason, p) => console.error('[CRITICAL] Unhandled Rejection at:', p, 'reason:', reason)); // ─── WebSocket Routing & Messaging ───────────────────────────────────────── function broadcast(slotId, payload) { const data = JSON.stringify(payload); connectedClients.forEach((ws) => { if (ws.readyState === WebSocket.OPEN && ws.slotId === slotId) { try { ws.send(data); } catch (err) { console.error(`[WS] Broadcast error on slot ${slotId}:`, 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); } } } // ─── Formatting helpers ──────────────────────────────────────────────────── async function formatChat(chat) { let avatar = null; try { 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); if (!avatar) { avatar = await Promise.race([ chat.getProfilePicUrl().catch(() => null), new Promise(resolve => setTimeout(() => resolve(null), 800)) ]); } } catch (_) {} 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 }; } function formatMessage(msg) { let ack = msg.ack || 0; if (ack < 0) ack = 0; 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 }; } // ─── Puppeteer / Chrome Config ────────────────────────────────────────────── 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' ]; for (const p of possiblePaths) { if (p && fs.existsSync(p)) { puppeteerConfig.executablePath = p; break; } } // ─── Bootstrap Slot ──────────────────────────────────────────────────────── function bootstrapSlot(slotId) { if (slotId < 1 || slotId > 6) return; if (slots.has(slotId)) return slots.get(slotId); console.log(`[BOOTSTRAP] Initializing Slot ${slotId}...`); const { Client, LocalAuth } = require('whatsapp-web.js'); const client = new Client({ authStrategy: new LocalAuth({ clientId: `slot-${slotId}`, dataPath: path.join(__dirname, '.wwebjs_auth') }), puppeteer: puppeteerConfig }); const slotState = { waClient: client, clientReady: false, qrCodeCache: null }; slots.set(slotId, slotState); // ─── Listeners ─────────────────────────────────────────────────────────── client.on('qr', (qr) => { console.log(`[WA] QR generated for Slot ${slotId}`); slotState.qrCodeCache = qr; slotState.clientReady = false; updateSlotStatus(slotId, 'qrcode'); broadcast(slotId, { type: 'qr', qr }); }); client.on('authenticated', () => { console.log(`[WA] Slot ${slotId} authenticated successfully.`); slotState.qrCodeCache = null; updateSlotStatus(slotId, 'authenticated'); broadcast(slotId, { type: 'authenticated' }); }); client.on('ready', () => { console.log(`[WA] Slot ${slotId} is ready and connected.`); slotState.clientReady = true; slotState.qrCodeCache = null; const phoneNumber = client.info.wid.user; updateSlotStatus(slotId, 'connected', phoneNumber); broadcast(slotId, { type: 'ready', phoneNumber }); broadcast(slotId, { type: 'status', ready: true }); }); client.on('message', async (msg) => { console.log(`[WA] [Slot ${slotId}] Message received from: ${msg.from}`); try { const chat = await msg.getChat(); // Auto-Archive Message to MySQL msg.senderName = msg._data.notifyName || chat.name; await archiveMessage(slotId, msg); const formatted = formatMessage(msg); broadcast(slotId, { type: 'new_message', chatId: msg.from, data: formatted }); // Handle Background Push Notification (skip if chat is muted) if (!msg.fromMe && !chat.isMuted) { 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] Background push failed:', fcmErr.message); } } } catch (err) { console.error('[WA] Error processing new message:', err.message); } }); client.on('message_create', async (msg) => { // Archive outgoing messages sent from this phone natively if (msg.fromMe) { try { msg.senderName = 'Me'; await archiveMessage(slotId, msg); } catch (_) {} } }); client.on('message_ack', (msg, ack) => { try { broadcast(slotId, { type: 'message_ack', messageId: msg.id._serialized, chatId: msg.to || msg.from, ack: ack }); } catch (err) { console.error('[WA] Error broadcasting message_ack:', err.message); } }); client.on('vote', (vote) => { try { console.log(`[WA] [Slot ${slotId}] Vote received for poll: ${vote.pollMessageId}`); broadcast(slotId, { type: 'poll_vote', pollMessageId: vote.pollMessageId, sender: vote.sender, selectedOptions: vote.selectedOptions, timestamp: vote.timestamp }); } catch (err) { console.error('[WA] Error broadcasting vote:', err.message); } }); client.on('disconnected', (reason) => { console.warn(`[WA] Slot ${slotId} disconnected! Reason:`, reason); slotState.clientReady = false; updateSlotStatus(slotId, 'disconnected'); broadcast(slotId, { type: 'disconnected', reason }); broadcast(slotId, { type: 'status', ready: false }); try { client.destroy(); } catch (_) {} slots.delete(slotId); // Auto-reconnect in 6 seconds console.log(`[WA] Slot ${slotId} reconnecting in 6 seconds...`); setTimeout(() => bootstrapSlot(slotId), 6000); }); client.initialize().catch((err) => { console.error(`[WA] Slot ${slotId} initialization failed:`, err.message); slots.delete(slotId); setTimeout(() => bootstrapSlot(slotId), 6000); }); return slotState; } // ─── WebSocket Packet Handler ───────────────────────────────────────────── async function handleMessage(ws, raw) { let payload; try { payload = JSON.parse(raw); } catch (_) { return sendTo(ws, { type: 'error', message: 'Invalid JSON payload' }); } const { type, requestId } = payload; if (!requestId) return sendTo(ws, { type: 'error', message: 'Missing requestId' }); const slotId = ws.slotId || 1; const slot = slots.get(slotId); const respond = (data) => sendTo(ws, { ...data, requestId }); if (!slot) return respond({ type: 'error', message: `Slot ${slotId} is not initialized` }); const waClient = slot.waClient; const clientReady = slot.clientReady; try { switch (type) { case 'ping': return respond({ type: 'pong', ready: clientReady }); case 'register_fcm': { const { token } = payload; if (!token) return respond({ type: 'error', message: 'Token is required' }); fs.writeFileSync(path.join(__dirname, 'fcm_token.json'), JSON.stringify({ token, updatedAt: new Date().toISOString() })); return respond({ type: 'fcm_registered', success: true }); } case 'get_conversations': { if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' }); try { 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 }); } catch (err) { return respond({ type: 'error', message: err.message }); } } 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 messages = await chat.fetchMessages({ limit: parseInt(limit) || 50 }); return respond({ type: 'messages', chatId, data: messages.map(formatMessage) }); } 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' }); const parts = messageId.split('_'); if (parts.length < 2) return respond({ type: 'error', message: 'Invalid messageId format' }); const chat = await waClient.getChatById(parts[1]); const messages = await chat.fetchMessages({ limit: 100 }); const msg = messages.find(m => m.id._serialized === messageId); if (!msg || !msg.hasMedia) return respond({ type: 'error', message: 'Media not found' }); let media = null; for (let i = 0; i < 3; i++) { try { media = await msg.downloadMedia(); if (media && media.data) break; } catch (_) {} await new Promise(r => setTimeout(r, 1500)); } if (!media) return respond({ type: 'error', message: 'Failed to download media' }); // On-the-fly Audio Conversion if (media.mimetype && (media.mimetype.includes('audio/ogg') || media.mimetype.includes('ogg'))) { try { media.data = await convertOggToMp3(media.data); media.mimetype = 'audio/mp3'; } catch (_) {} } return respond({ type: 'media', messageId, data: media.data, mimetype: media.mimetype }); } 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 }); } 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 }); } default: return respond({ type: 'error', message: `Unknown request: ${type}` }); } } catch (err) { return respond({ type: 'error', message: err.message }); } } // ─── REST HTTP API Proxy Endpoints ───────────────────────────────────────── app.use(express.json({ limit: '50mb' })); app.post('/api/connect', (req, res) => { const slotId = parseInt(req.body.slot || req.query.slot) || 1; if (slotId < 1 || slotId > 6) return res.status(400).json({ error: 'Slot must be between 1 and 6' }); bootstrapSlot(slotId); res.status(200).json({ success: true, message: `Slot ${slotId} initialization triggered.` }); }); app.post('/api/disconnect', async (req, res) => { const slotId = parseInt(req.body.slot || req.query.slot) || 1; const slot = slots.get(slotId); if (!slot) return res.status(404).json({ error: `Slot ${slotId} is not active.` }); try { await slot.waClient.destroy(); slots.delete(slotId); await updateSlotStatus(slotId, 'disconnected'); res.status(200).json({ success: true, message: `Slot ${slotId} disconnected and destroyed.` }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/slots', (req, res) => { const list = []; for (let id = 1; id <= 6; id++) { const slot = slots.get(id); list.push({ slot: id, active: !!slot, ready: slot ? slot.clientReady : false, hasQrCache: slot ? !!slot.qrCodeCache : false }); } res.status(200).json({ success: true, slots: list }); }); app.post('/api/send', async (req, res) => { const slotId = parseInt(req.body.slot || req.query.slot) || 1; const slot = slots.get(slotId); if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} 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 slot.waClient.sendMessage(chatId, message); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/send-media', async (req, res) => { const slotId = parseInt(req.body.slot || req.query.slot) || 1; const slot = slots.get(slotId); if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} 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 { 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 slot.waClient.sendMessage(chatId, media, { caption: caption || '' }); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/send-poll', async (req, res) => { const slotId = parseInt(req.body.slot || req.query.slot) || 1; const slot = slots.get(slotId); if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} 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) are required' }); } try { const { Poll } = require('whatsapp-web.js'); const poll = new Poll(question, options, { allowMultipleAnswers: allowMultiple !== false }); const chatId = phone.includes('@') ? phone : `${phone}@c.us`; const sentMsg = await slot.waClient.sendMessage(chatId, poll); res.status(200).json({ success: true, messageId: sentMsg.id.id }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/avatar', async (req, res) => { const slotId = parseInt(req.query.slot) || 1; const slot = slots.get(slotId); if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} is not ready` }); const { phone } = req.query; if (!phone) return res.status(400).json({ error: 'phone parameter is required' }); try { const chatId = phone.includes('@') ? phone : `${phone}@c.us`; const avatarUrl = await slot.waClient.getProfilePicUrl(chatId); res.status(200).json({ success: true, avatarUrl: avatarUrl || null }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ─── NEW: Message Archive endpoints from MySQL ───────────────────────────── app.get('/api/archive', async (req, res) => { const slotId = parseInt(req.query.slot) || 1; const { chatId, limit, offset } = req.query; if (!chatId) return res.status(400).json({ error: 'chatId parameter is required' }); try { const list = await getChatHistory(slotId, chatId, limit || 50, offset || 0); res.status(200).json({ success: true, slot: slotId, chatId, data: list }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/archive/search', async (req, res) => { const slotId = parseInt(req.query.slot) || 1; const { query, limit } = req.query; if (!query) return res.status(400).json({ error: 'query parameter is required' }); try { const list = await searchMessages(slotId, query, limit || 50); res.status(200).json({ success: true, slot: slotId, query, data: list }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', slots: slots.size, clients: connectedClients.size, port: PORT }); }); // ─── WebSocket Multi-Slot Connection Handler ────────────────────────────── wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; // Extract slot parameter from query string (e.g. wss://host/?slot=2) const query = url.parse(req.url, true).query; const slotId = parseInt(query.slot) || 1; if (slotId < 1 || slotId > 6) { console.warn(`[WS] Connection rejected: Invalid slotId ${slotId} from ${ip}`); ws.close(4000, 'Invalid slotId. Must be 1 to 6'); return; } ws.slotId = slotId; connectedClients.add(ws); console.log(`[WS] Client registered to Slot ${slotId} from IP: ${ip}`); // Fetch slot status const slot = slots.get(slotId); sendTo(ws, { type: 'status', ready: slot ? slot.clientReady : false }); if (slot) { if (!slot.clientReady && slot.qrCodeCache) { sendTo(ws, { type: 'qr', qr: slot.qrCodeCache }); } } else { // Lazy-load Slot 1 by default, others load on demand or if connect API hit if (slotId === 1) bootstrapSlot(1); } ws.on('message', (data) => { handleMessage(ws, data.toString()); }); ws.on('close', () => { connectedClients.delete(ws); console.log(`[WS] Client disconnected from Slot ${slotId}`); }); ws.on('error', (err) => { console.error(`[WS] Connection error on Slot ${slotId}:`, err.message); connectedClients.delete(ws); }); }); // ─── Start HTTP Server & Connect Database ───────────────────────────────── server.listen(PORT, async () => { console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`); try { // Initialize MySQL Database await initDatabase(); // Auto-bootstrap Slot 1 at startup bootstrapSlot(1); // Also auto-bootstrap any other slots that already have saved credentials for (let slotId = 2; slotId <= 6; slotId++) { const sessionPath = path.join(__dirname, '.wwebjs_auth', `session-slot-${slotId}`); if (fs.existsSync(sessionPath)) { console.log(`[SERVER] Detected existing credentials for Slot ${slotId}, auto-bootstrapping...`); bootstrapSlot(slotId); } } } catch (err) { console.error('[CRITICAL] Database initialization or startup failed:', err.message); } }); // ─── OGG to MP3 base64 converter using ffmpeg ───────────────────────────── 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.writeFile(inputPath, Buffer.from(base64Ogg, 'base64'), (err) => { if (err) return reject(err); exec(`ffmpeg -i ${inputPath} -acodec libmp3lame -y ${outputPath}`, (execErr) => { // Clean up input ogg file fs.unlink(inputPath, () => {}); if (execErr) return reject(execErr); fs.readFile(outputPath, (readErr, data) => { // Clean up output mp3 file fs.unlink(outputPath, () => {}); if (readErr) return reject(readErr); resolve(data.toString('base64')); }); }); }); }); }