diff --git a/whatsapp_bridge/database.js b/whatsapp_bridge/database.js new file mode 100644 index 0000000..40eb7d8 --- /dev/null +++ b/whatsapp_bridge/database.js @@ -0,0 +1,154 @@ +const mysql = require('mysql2/promise'); + +let pool = null; + +function getPool() { + if (!pool) { + const config = { + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 + }; + + console.log(`[DB] Initializing MySQL Connection Pool to ${config.host}:${config.port}/${config.database}`); + pool = mysql.createPool(config); + } + return pool; +} + +// ─── Automatic Database Schema Migration ────────────────────────────────── +async function initDatabase() { + const connectionPool = getPool(); + + try { + // 1. Create Slots Table + await connectionPool.query(` + CREATE TABLE IF NOT EXISTS slots ( + id INT PRIMARY KEY, + phone_number VARCHAR(30) NULL, + status VARCHAR(50) DEFAULT 'disconnected', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + // Seed exactly 6 slots if they don't exist yet + for (let slotId = 1; slotId <= 6; slotId++) { + await connectionPool.query(` + INSERT INTO slots (id, status) + VALUES (?, 'disconnected') + ON DUPLICATE KEY UPDATE id=id; + `, [slotId]); + } + + // 2. Create Messages Table + await connectionPool.query(` + CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR(150) PRIMARY KEY, + slot_id INT NOT NULL, + chat_id VARCHAR(100) NOT NULL, + sender_name VARCHAR(150) NULL, + body TEXT NULL, + from_me BOOLEAN DEFAULT FALSE, + timestamp INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (slot_id) REFERENCES slots(id) ON DELETE CASCADE, + INDEX idx_slot_chat (slot_id, chat_id), + INDEX idx_timestamp (timestamp) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + console.log('[DB] MySQL Tables initialized successfully.'); + } catch (err) { + console.error('[DB ERROR] Migration failed:', err.message); + throw err; + } +} + +// ─── Helper Queries ──────────────────────────────────────────────────────── +async function updateSlotStatus(slotId, status, phoneNumber = null) { + try { + const connectionPool = getPool(); + await connectionPool.query(` + UPDATE slots + SET status = ?, phone_number = COALESCE(?, phone_number) + WHERE id = ?; + `, [status, phoneNumber, slotId]); + console.log(`[DB] Slot ${slotId} status updated to: ${status}`); + } catch (err) { + console.error(`[DB ERROR] Failed to update slot ${slotId} status:`, err.message); + } +} + +async function archiveMessage(slotId, msg) { + try { + const connectionPool = getPool(); + + // We only archive text-based body messages (or custom media representations) + let bodyText = msg.body || ''; + if (!bodyText && msg.hasMedia) { + bodyText = '📷 Media/Attachment'; + } + + await connectionPool.query(` + INSERT INTO messages (id, slot_id, chat_id, sender_name, body, from_me, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE body = VALUES(body); + `, [ + msg.id._serialized || msg.id.id, + slotId, + msg.to || msg.from, + msg.senderName || null, + bodyText, + msg.fromMe ? 1 : 0, + msg.timestamp + ]); + console.log(`[DB] Message ${msg.id.id} archived successfully in Slot ${slotId}`); + } catch (err) { + console.error('[DB ERROR] Failed to archive message:', err.message); + } +} + +async function getChatHistory(slotId, chatId, limit = 50, offset = 0) { + try { + const connectionPool = getPool(); + const [rows] = await connectionPool.query(` + SELECT * FROM messages + WHERE slot_id = ? AND chat_id = ? + ORDER BY timestamp DESC + LIMIT ? OFFSET ?; + `, [slotId, chatId, parseInt(limit), parseInt(offset)]); + return rows.reverse(); // Return in chronological order + } catch (err) { + console.error('[DB ERROR] Failed to get chat history:', err.message); + return []; + } +} + +async function searchMessages(slotId, query, limit = 50) { + try { + const connectionPool = getPool(); + const [rows] = await connectionPool.query(` + SELECT * FROM messages + WHERE slot_id = ? AND body LIKE ? + ORDER BY timestamp DESC + LIMIT ?; + `, [slotId, `%${query}%`, parseInt(limit)]); + return rows; + } catch (err) { + console.error('[DB ERROR] Failed to search messages:', err.message); + return []; + } +} + +module.exports = { + initDatabase, + updateSlotStatus, + archiveMessage, + getChatHistory, + searchMessages +}; diff --git a/whatsapp_bridge/server.js b/whatsapp_bridge/server.js index 95cc947..391584e 100644 --- a/whatsapp_bridge/server.js +++ b/whatsapp_bridge/server.js @@ -1,55 +1,51 @@ -/** - * 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 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 (as in user screenshot) + '/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] Successfully loaded environment variables from: ${envPath}`); + console.log(`[ENV] Loaded configuration 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'); + dotenv.config(); + console.log('[ENV] Loaded default environment configuration'); } -// ─── Firebase Admin SDK Configuration (Highly Secure Background Pushes) ───── +// ─── 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; - -// 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'); @@ -59,44 +55,39 @@ try { let serviceAccount; if (envServiceAccount.trim().startsWith('{')) { serviceAccount = JSON.parse(envServiceAccount); - console.log('[FCM] Initializing Firebase Admin SDK via direct env JSON string...'); + console.log('[FCM] Initializing Firebase Admin SDK via env JSON string...'); } else { serviceAccount = require(envServiceAccount); - console.log(`[FCM] Initializing Firebase Admin SDK via custom path from env: ${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 secure custom path: ${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 fallback local serviceAccountKey.json...'); + 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 in environment or local files. Background push notifications will be disabled.'); + 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) { - console.log('[FCM] Push skipped: Firebase Admin SDK not initialized.'); - return; - } + if (!firebaseApp) 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; - } + if (!fs.existsSync(tokenPath)) return; try { const tokenData = JSON.parse(fs.readFileSync(tokenPath)); @@ -123,37 +114,30 @@ async function sendPushNotification(chatId, senderName, body) { } }; - const response = await admin.messaging().send(message); - console.log('[FCM] Push notification sent successfully, messageId:', response); + await admin.messaging().send(message); + console.log('[FCM] Push notification sent successfully'); } catch (err) { - console.error('[FCM SEND ERROR] Failed to send push notification:', err.message); + console.error('[FCM SEND ERROR] Failed to send push:', err.message); } } -// ─── State ───────────────────────────────────────────────────────────────── -let waClient = null; -let clientReady = false; -let qrCodeCache = null; -const connectedClients = new Set(); +// ─── 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 -// ─── Error Handling (Never Crash) ────────────────────────────────────────── -process.on('uncaughtException', (err) => { - console.error('[CRITICAL] Uncaught Exception:', err); -}); +// 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)); -process.on('unhandledRejection', (reason, promise) => { - console.error('[CRITICAL] Unhandled Rejection at:', promise, 'reason:', reason); -}); - -// ─── WebSocket: Broadcast to all Flutter clients ─────────────────────────── -function broadcast(payload) { +// ─── WebSocket Routing & Messaging ───────────────────────────────────────── +function broadcast(slotId, payload) { const data = JSON.stringify(payload); connectedClients.forEach((ws) => { - if (ws.readyState === WebSocket.OPEN) { + if (ws.readyState === WebSocket.OPEN && ws.slotId === slotId) { try { ws.send(data); } catch (err) { - console.error('[WS] Broadcast send error:', err.message); + console.error(`[WS] Broadcast error on slot ${slotId}:`, err.message); } } }); @@ -169,21 +153,17 @@ function sendTo(ws, payload) { } } -// ─── Format conversation object for Flutter ──────────────────────────────── +// ─── Formatting helpers ──────────────────────────────────────────────────── 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; - } + } 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), @@ -192,7 +172,6 @@ async function formatChat(chat) { } } catch (_) {} - // Last Message formatting let lastMessageFormatted = null; if (chat.lastMessage) { lastMessageFormatted = { @@ -216,14 +195,10 @@ async function formatChat(chat) { }; } -// ─── 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; @@ -244,102 +219,105 @@ function formatMessage(msg) { }; } -// ─── Initialize WhatsApp Client ───────────────────────────────────────────── -function initWhatsApp() { - console.log('[WA] Initializing WhatsApp client using LocalAuth...'); - clientReady = false; +// ─── 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 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' +]; +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); - 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, + // ─── 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 }); }); - // QR Code received -> send to all WS clients - waClient.on('qr', async (qr) => { - console.log('[WA] QR Code received'); + 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 qrDataUrl = await qrcode.toDataURL(qr); - qrCodeCache = qrDataUrl; - broadcast({ type: 'qr', qr: qrDataUrl }); - } catch (err) { - console.error('[WA] QR generation error:', err); - } - }); + const chat = await msg.getChat(); + + // Auto-Archive Message to MySQL + msg.senderName = msg._data.notifyName || chat.name; + await archiveMessage(slotId, msg); - // 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 }); + broadcast(slotId, { type: 'new_message', chatId: msg.from, data: formatted }); - // Trigger background push notification if not sent by me - if (!msg.fromMe) { + // Handle Background Push Notification (skip if chat is muted) + if (!msg.fromMe && !chat.isMuted) { try { - const chat = await msg.getChat(); - if (chat.isMuted) { - console.log(`[FCM] Push skipped for muted chat: ${msg.from}`); - return; - } - const contact = await msg.getContact(); const senderName = contact.name || contact.pushname || msg.from.split('@')[0]; let body = msg.body || ''; @@ -348,33 +326,41 @@ function initWhatsApp() { } await sendPushNotification(msg.from, senderName, body); } catch (fcmErr) { - console.error('[FCM PUSH ERROR] Failed to send background push:', fcmErr.message); + console.error('[FCM PUSH ERROR] Background push failed:', fcmErr.message); } } } catch (err) { - console.error('[WA] Error formatting new message event:', err.message); + console.error('[WA] Error processing new message:', err.message); } }); - // Message ACK update - waClient.on('message_ack', (msg, ack) => { + 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({ + broadcast(slotId, { 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); + console.error('[WA] Error broadcasting message_ack:', err.message); } }); - // Poll Vote updates (Listen to real-time votes on polls!) - waClient.on('vote', (vote) => { + client.on('vote', (vote) => { try { - console.log(`[WA] Poll vote received from: ${vote.sender} for poll message: ${vote.pollMessageId}`); - broadcast({ + console.log(`[WA] [Slot ${slotId}] Vote received for poll: ${vote.pollMessageId}`); + broadcast(slotId, { type: 'poll_vote', pollMessageId: vote.pollMessageId, sender: vote.sender, @@ -382,373 +368,213 @@ function initWhatsApp() { timestamp: vote.timestamp }); } catch (err) { - console.error('[WA] Error broadcasting poll_vote event:', err.message); + console.error('[WA] Error broadcasting vote:', err.message); } }); - // Disconnected - waClient.on('disconnected', (reason) => { - console.warn('[WA] Disconnected! Reason:', reason); - clientReady = false; - broadcast({ type: 'disconnected', reason }); - broadcast({ type: 'status', ready: false }); + 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 }); - // Clean up client resources - try { - waClient.destroy(); - } catch (_) {} + try { client.destroy(); } catch (_) {} + slots.delete(slotId); - // Auto-reconnect after 5 seconds - console.log('[WA] Reconnecting in 5 seconds...'); - setTimeout(initWhatsApp, 5000); + // Auto-reconnect in 6 seconds + console.log(`[WA] Slot ${slotId} reconnecting in 6 seconds...`); + setTimeout(() => bootstrapSlot(slotId), 6000); }); - waClient.initialize().catch((err) => { - console.error('[WA] Client initialization failed:', err); - console.log('[WA] Retrying initialization in 5 seconds...'); - setTimeout(initWhatsApp, 5000); + client.initialize().catch((err) => { + console.error(`[WA] Slot ${slotId} initialization failed:`, err.message); + slots.delete(slotId); + setTimeout(() => bootstrapSlot(slotId), 6000); }); + + return slotState; } -// ─── Handle WebSocket messages from Flutter ──────────────────────────────── +// ─── WebSocket Packet Handler ───────────────────────────────────────────── async function handleMessage(ws, raw) { let payload; - try { - payload = JSON.parse(raw); - } catch (err) { + 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' }); - } - - console.log(`[WS RECV] Request type: ${type}, requestId: ${requestId}`); + 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 }); - // Handle type specific requests + if (!slot) return respond({ type: 'error', message: `Slot ${slotId} is not initialized` }); + const waClient = slot.waClient; + const clientReady = slot.clientReady; + 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) + '...'); + 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 }); } - // ── 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...`); - + if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' }); 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 }); + 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) { - 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 }); + return respond({ type: 'error', message: err.message }); } } - // ── Messages ─────────────────────────────────────────────────────── case 'get_messages': { - if (!clientReady) { - return respond({ type: 'error', message: 'WhatsApp is not ready' }); - } + 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' }); - } + 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 - }); + const messages = await chat.fetchMessages({ limit: parseInt(limit) || 50 }); + return respond({ type: 'messages', chatId, data: messages.map(formatMessage) }); } - // ── Media ────────────────────────────────────────────────────────── case 'get_media': { - if (!clientReady) { - return respond({ type: 'error', message: 'WhatsApp is not ready' }); - } + 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 (!messageId) return respond({ type: 'error', message: 'messageId is 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); - } - } + 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' }); - 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 }); + 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 }); } - // ── Mark as Read ─────────────────────────────────────────────────── case 'mark_read': { - if (!clientReady) { - return respond({ type: 'error', message: 'WhatsApp is not ready' }); - } + if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' }); const { chatId } = payload; - if (!chatId) { - return respond({ type: 'error', message: 'chatId is required' }); - } + 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 - }); + return respond({ type: 'marked_read', chatId }); } - // ── Search Conversations ─────────────────────────────────────────── case 'search_conversations': { - if (!clientReady) { - return respond({ type: 'error', message: 'WhatsApp is not ready' }); - } + 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 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 - }); + return respond({ type: 'conversations', data: formatted, search: true }); } default: - return respond({ type: 'error', message: `Unknown request type: ${type}` }); + return respond({ type: 'error', message: `Unknown request: ${type}` }); } } catch (err) { - console.error(`[WS] Error processing request ${type}:`, err); - return respond({ type: 'error', message: err.message || 'Server error' }); + return respond({ type: 'error', message: err.message }); } } -// ─── 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) ─────── +// ─── REST HTTP API Proxy Endpoints ───────────────────────────────────────── 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' }); +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 waClient.sendMessage(chatId, message); + const sentMsg = await slot.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 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 { - // Sanitize the base64 string (strip data url prefixes if present) let cleanBase64 = base64.trim(); if (cleanBase64.includes(';base64,')) { cleanBase64 = cleanBase64.split(';base64,')[1]; @@ -757,71 +583,159 @@ app.post('/api/send-media', async (req, res) => { 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 || '' }); + const sentMsg = await slot.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 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 of strings) are required' }); + 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 // default to true + allowMultipleAnswers: allowMultiple !== false }); const chatId = phone.includes('@') ? phone : `${phone}@c.us`; - const sentMsg = await waClient.sendMessage(chatId, poll); + const sentMsg = await slot.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 Profile Pic Avatar Endpoint ─────────────────────────────────────── app.get('/api/avatar', async (req, res) => { - if (!clientReady) return res.status(503).json({ error: 'WhatsApp is not ready' }); + 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 waClient.getProfilePicUrl(chatId); + const avatarUrl = await slot.waClient.getProfilePicUrl(chatId); res.status(200).json({ success: true, avatarUrl: avatarUrl || null }); } catch (err) { - console.error('[API] Avatar error:', err.message); res.status(500).json({ error: err.message }); } }); -// ─── HTTP Health Endpoint ────────────────────────────────────────────────── +// ─── 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', - waReady: clientReady, + 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; -// ─── Start HTTP + WebSocket Server ───────────────────────────────────────── -server.listen(PORT, () => { - console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`); - initWhatsApp(); + 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); + }); }); -// ─── OGG to MP3 base64 converter using ffmpeg child process ──────────────── +// ─── 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(); @@ -832,25 +746,24 @@ function convertOggToMp3(base64Ogg) { 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); - } + + 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')); + }); + }); }); }); }