diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a7da54 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Database Configuration +DB_HOST=localhost +DB_NAME=otp_db +DB_USER=otp_user +DB_PASS=STRONG_PASSWORD + +# Redis Configuration +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=1 + +# WhatsApp Gateway Configuration +WHATSAPP_GATEWAY_URL=http://localhost:3732 +WHATSAPP_WEBHOOK_SECRET=flash_call_otp_webhook_secret_key +WHATSAPP_SESSION_KEY=flash_call_otp + +# Application Security Keys +APP_KEY=f3a9e7c1b8d5f2a4c6e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1 +DEVICE_KEY=er4er4 diff --git a/backend/.htaccess b/backend/.htaccess deleted file mode 100644 index a3cbe6c..0000000 --- a/backend/.htaccess +++ /dev/null @@ -1,27 +0,0 @@ -# Protect includes directory - - RewriteEngine On - - # Block direct access to includes/ - RewriteRule ^includes/ - [F,L] - - # Block access to config files - RewriteRule ^config\.php$ - [F,L] - - # Block access to hidden files - RewriteRule (^|/)\. - [F,L] - - # Block access to SQL files - RewriteRule \.sql$ - [F,L] - - # Block access to log files - RewriteRule \.log$ - [F,L] - - -# Disable directory listing -Options -Indexes - -# Prevent script execution in includes - - php_flag engine off - diff --git a/backend/api/request-otp.php b/backend/api/request-otp.php index d1c43b1..fd9f31d 100644 --- a/backend/api/request-otp.php +++ b/backend/api/request-otp.php @@ -102,18 +102,23 @@ $otpCode = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); // Determine delivery method $method = 'flash_call'; // Default fallback -$whatsappAvailable = false; +$methodInput = isset($input['method']) ? strtolower(trim($input['method'])) : null; -try { - $whatsappAvailable = WhatsAppClient::isAvailable($phone); -} catch (\Throwable $e) { - error_log('WhatsApp check failed: ' . $e->getMessage()); -} - -if ($whatsappAvailable) { - $method = 'whatsapp'; +if ($methodInput && in_array($methodInput, ['flash_call', 'sms', 'whatsapp', 'telegram'], true)) { + $method = $methodInput; } else { - $method = ($deviceType === 'ios') ? 'sms' : 'flash_call'; + $whatsappAvailable = false; + try { + $whatsappAvailable = WhatsAppClient::isAvailable($phone); + } catch (\Throwable $e) { + error_log('WhatsApp check failed: ' . $e->getMessage()); + } + + if ($whatsappAvailable) { + $method = 'whatsapp'; + } else { + $method = ($deviceType === 'ios') ? 'sms' : 'flash_call'; + } } $db = Database::getInstance(); diff --git a/backend/composer.json b/backend/composer.json new file mode 100644 index 0000000..8440648 --- /dev/null +++ b/backend/composer.json @@ -0,0 +1,7 @@ +{ + "name": "intaleq/flash-call-otp", + "description": "Flash Call OTP Backend", + "require": { + "vlucas/phpdotenv": "^5.5" + } +} diff --git a/whatsapp-gateway/.env.example b/whatsapp-gateway/.env.example new file mode 100644 index 0000000..669c2f9 --- /dev/null +++ b/whatsapp-gateway/.env.example @@ -0,0 +1,2 @@ +PORT=3732 +WEBHOOK_SECRET=flash_call_otp_webhook_secret_key diff --git a/whatsapp-gateway/baileys-client.js b/whatsapp-gateway/baileys-client.js new file mode 100644 index 0000000..e60190d --- /dev/null +++ b/whatsapp-gateway/baileys-client.js @@ -0,0 +1,469 @@ +const baileys = require('@whiskeysockets/baileys'); +const makeWASocket = baileys.default || baileys.makeWASocket || baileys; +const { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage, makeCacheableSignalKeyStore } = baileys; +const pino = require('pino'); +const NodeCache = require('node-cache'); +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); + +const sessions = new Map(); // Store active sockets in memory +const retryCounters = new Map(); // Track reconnection attempts per session +const recentMessages = new Map(); // Cache of recent messages in memory to serve getMessage callback +const phoneToLid = new Map(); // Map phone numbers to LID JIDs for correct E2EE routing + +// Global retry counter cache — persists across socket reconnects +// This is CRITICAL for E2EE: tracks message retry attempts so Baileys can +// re-encrypt messages when a recipient's device requests a retry +const msgRetryCounterCache = new NodeCache({ stdTTL: 600, checkperiod: 120 }); + +const MAX_RETRIES = 5; // Maximum reconnection attempts before giving up + +// Local folder for saving auth keys +const SESSIONS_DIR = path.join(__dirname, 'sessions'); +if (!fs.existsSync(SESSIONS_DIR)) { + fs.mkdirSync(SESSIONS_DIR, { recursive: true }); +} + +async function sendWebhook(webhook_url, payload) { + try { + console.log(`[Webhook] Sending to ${webhook_url} | state=${payload.state}`); + const response = await axios.post(webhook_url, payload, { + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Secret': process.env.WEBHOOK_SECRET || '' + }, + timeout: 10000 + }); + console.log(`[Webhook] ✅ Success | HTTP ${response.status}`); + } catch (err) { + if (err.response) { + console.error(`[Webhook] ❌ HTTP ${err.response.status} | ${JSON.stringify(err.response.data)}`); + } else { + console.error(`[Webhook] ❌ Network Error: ${err.message}`); + } + } +} + +async function startSession(session_key, webhook_url) { + // Return existing socket if it's already active + if (sessions.has(session_key)) { + console.log(`[Session] ${session_key} already active, reusing`); + return sessions.get(session_key); + } + + console.log(`[Session] Starting ${session_key} → webhook: ${webhook_url}`); + + const sessionFolder = path.join(SESSIONS_DIR, session_key); + const { state, saveCreds } = await useMultiFileAuthState(sessionFolder); + + // Fetch the latest WhatsApp Web version to avoid 405 rejection + let version; + try { + const versionInfo = await fetchLatestBaileysVersion(); + version = versionInfo.version; + console.log(`[Baileys] Using WA version: ${version}`); + } catch (e) { + console.warn(`[Baileys] Could not fetch version, using default`); + } + + const logger = pino({ level: 'silent' }); + + const socketConfig = { + auth: { + creds: state.creds, + // Wrap keys with makeCacheableSignalKeyStore for fast in-memory + // Signal key access — this prevents E2EE key lookup failures + // that cause "Waiting for this message" on recipient devices + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + printQRInTerminal: false, + logger: logger, + browser: ['Nabeh Gateway', 'Chrome', '120.0.0'], + // Message retry counter cache — tracks how many times each message + // retry has been attempted, preventing infinite retry loops + msgRetryCounterCache, + getMessage: async (key) => { + if (recentMessages.has(key.id)) { + return recentMessages.get(key.id); + } + return undefined; + } + }; + if (version) socketConfig.version = version; + + const sock = makeWASocket(socketConfig); + console.log(`[Session] Socket created for ${session_key}`); + + sessions.set(session_key, sock); + + sock.ev.on('creds.update', saveCreds); + + // Listen for incoming messages + sock.ev.on('messages.upsert', async (m) => { + console.log(`[Upsert] Event received type=${m.type} messagesCount=${m.messages?.length}`); + if (m.messages && m.messages.length > 0) { + console.log(`[Upsert] First message keys:`, Object.keys(m.messages[0])); + console.log(`[Upsert] First message key:`, JSON.stringify(m.messages[0].key)); + console.log(`[Upsert] First message structure:`, JSON.stringify(m.messages[0].message)); + + // Cache all incoming messages to serve E2EE retries + for (const msg of m.messages) { + if (msg.key && msg.key.id && msg.message) { + recentMessages.set(msg.key.id, msg.message); + if (recentMessages.size > 2000) { + const firstKey = recentMessages.keys().next().value; + recentMessages.delete(firstKey); + } + } + } + } + + if (m.type !== 'notify') return; + + for (const msg of m.messages) { + // Ignore messages sent by ourselves + if (msg.key.fromMe) continue; + + const remoteJid = msg.key.remoteJid; + if (!remoteJid) continue; + + // Only process individual chats (ignore groups and broadcasts) + const isGroup = remoteJid.endsWith('@g.us'); + const isBroadcast = remoteJid.endsWith('@broadcast'); + if (isGroup || isBroadcast) continue; + + // Extract text body + const body = msg.message?.conversation || + msg.message?.extendedTextMessage?.text || + msg.message?.imageMessage?.caption || + msg.message?.videoMessage?.caption || ''; + + const isAudio = !!msg.message?.audioMessage; + const isImage = !!msg.message?.imageMessage; + + // Only process messages that have text content OR are audio/image messages + if (!body && !isAudio && !isImage) continue; + + let audioBase64 = null; + let audioMimeType = null; + let imageBase64 = null; + let imageMimeType = null; + + if (isAudio) { + try { + console.log(`[Baileys] Downloading audio message for ${remoteJid}`); + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { + logger: pino({ level: 'silent' }), + rekey: true + } + ); + audioBase64 = buffer.toString('base64'); + audioMimeType = msg.message.audioMessage.mimetype || 'audio/ogg'; + } catch (e) { + console.error('[Baileys] Failed to download audio message:', e.message); + continue; // Skip if audio download fails to prevent empty processing + } + } else if (isImage) { + try { + console.log(`[Baileys] Downloading image message for ${remoteJid}`); + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { + logger: pino({ level: 'silent' }), + rekey: true + } + ); + imageBase64 = buffer.toString('base64'); + imageMimeType = msg.message.imageMessage.mimetype || 'image/jpeg'; + } catch (e) { + console.error('[Baileys] Failed to download image message:', e.message); + continue; // Skip if image download fails + } + } + + // Extract sender phone number (handle LID privacy scheme) + let senderPhone = ''; + if (msg.key.senderPn && msg.key.senderPn.endsWith('@s.whatsapp.net')) { + senderPhone = msg.key.senderPn.split('@')[0]; + // CRITICAL: Map this phone number to the LID JID for reply routing. + // Signal E2EE sessions are bound to the LID, so replies MUST go + // to the LID JID, not the phone JID, to prevent session conflicts. + if (remoteJid.endsWith('@lid')) { + phoneToLid.set(senderPhone, remoteJid); + console.log(`[LID] Mapped ${senderPhone} → ${remoteJid}`); + } + } else if (remoteJid.endsWith('@s.whatsapp.net')) { + senderPhone = remoteJid.split('@')[0]; + } else if (remoteJid.endsWith('@lid')) { + senderPhone = remoteJid.split('@')[0]; + } + + if (!senderPhone) continue; + + const senderName = msg.pushName || ''; + + if (isAudio) { + console.log(`[Message] Received audio voice note from ${senderPhone} (JID: ${remoteJid})`); + } else if (isImage) { + console.log(`[Message] Received image from ${senderPhone} (JID: ${remoteJid})`); + } else { + console.log(`[Message] Received from ${senderPhone} (JID: ${remoteJid}): ${body}`); + } + + await sendWebhook(webhook_url, { + session_key, + state: 'message_received', + message: { + id: msg.key.id, + phone: senderPhone, + name: senderName, + body: body, + audio: audioBase64, + mimeType: audioMimeType, + duration: msg.message?.audioMessage?.seconds ? Number(msg.message.audioMessage.seconds) : null, + image: imageBase64, + imageMimeType: imageMimeType, + timestamp: msg.messageTimestamp + } + }); + } + }); + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + console.log(`[QR] Generated for ${session_key}`); + // Reset retry counter on QR generation (session is alive) + retryCounters.set(session_key, 0); + await sendWebhook(webhook_url, { + session_key, + state: 'waiting_qr', + qr_code: qr + }); + } + + if (connection === 'close') { + const statusCode = lastDisconnect?.error?.output?.statusCode; + const shouldReconnect = statusCode !== DisconnectReason.loggedOut; + + if (!shouldReconnect) { + console.log(`[Connection] ${session_key} permanently closed (logged out). Cleaning up.`); + sessions.delete(session_key); + await cleanupSession(session_key); + await sendWebhook(webhook_url, { + session_key, + state: 'disconnected' + }); + } else { + const retries = (retryCounters.get(session_key) || 0) + 1; + retryCounters.set(session_key, retries); + + sessions.delete(session_key); + let delay; + if (retries <= MAX_RETRIES) { + delay = Math.min(retries * 3000, 15000); // 3s, 6s, 9s, 12s, 15s + console.log(`[Connection] Reconnecting ${session_key} (quick retry ${retries}/${MAX_RETRIES}) in ${delay}ms...`); + } else { + delay = 60000; // 60s + console.log(`[Connection] Reconnecting ${session_key} (long-term retry ${retries}) in 60s...`); + } + setTimeout(() => startSession(session_key, webhook_url), delay); + } + } else if (connection === 'open') { + console.log(`[Connection] ✅ ${session_key} connected successfully!`); + retryCounters.set(session_key, 0); // Reset on successful connection + + // Parse phone number from the JID (e.g. 9665XXXXXXX@s.whatsapp.net) + const phone = sock.user.id.split(':')[0]; + + await sendWebhook(webhook_url, { + session_key, + state: 'connected', + phone: phone + }); + } + }); + + return sock; +} + +/** + * Cleanup session: remove from memory and delete auth files + */ +async function cleanupSession(session_key) { + const sock = sessions.get(session_key); + if (sock) { + try { sock.end(); } catch (e) { } // Gracefully close socket without logout + sessions.delete(session_key); + } + retryCounters.delete(session_key); + + // Wipe the auth directory so a fresh session can be created next time + const sessionFolder = path.join(SESSIONS_DIR, session_key); + if (fs.existsSync(sessionFolder)) { + fs.rmSync(sessionFolder, { recursive: true, force: true }); + console.log(`[Cleanup] Deleted session folder for ${session_key}`); + } +} + +/** + * Disconnect session: logout from WhatsApp and cleanup + */ +async function disconnectSession(session_key) { + const sock = sessions.get(session_key); + if (sock) { + try { sock.logout(); } catch (e) { } // best effort logout + sessions.delete(session_key); + } + retryCounters.delete(session_key); + + // Completely wipe the auth directory + const sessionFolder = path.join(SESSIONS_DIR, session_key); + if (fs.existsSync(sessionFolder)) { + fs.rmSync(sessionFolder, { recursive: true, force: true }); + console.log(`[Disconnect] Deleted session folder for ${session_key}`); + } +} + +const { exec } = require('child_process'); +const os = require('os'); + +function convertToOggOpus(base64Audio) { + return new Promise((resolve, reject) => { + const timeId = Date.now() + Math.random().toString(36).substring(7); + const inputPath = path.join(os.tmpdir(), `input_${timeId}.tmp`); + const outputPath = path.join(os.tmpdir(), `output_${timeId}.ogg`); + + fs.writeFile(inputPath, Buffer.from(base64Audio, 'base64'), (err) => { + if (err) return reject(err); + exec(`ffmpeg -i ${inputPath} -c:a libopus -y ${outputPath}`, (execErr) => { + fs.unlink(inputPath, () => {}); + if (execErr) return reject(execErr); + fs.readFile(outputPath, (readErr, data) => { + fs.unlink(outputPath, () => {}); + if (readErr) return reject(readErr); + resolve(data.toString('base64')); + }); + }); + }); + }); +} + +/** + * Send a message using an active session + */ +async function sendMessage(session_key, phone, message, mediaUrl = null, audioBase64 = null, mimetype = null, imageBase64 = null) { + const sock = sessions.get(session_key); + if (!sock) { + throw new Error(`Session ${session_key} is not active or connected`); + } + + // Use the LID JID if we have a mapping for this phone number. + // This is CRITICAL: Signal E2EE sessions are bound to the LID JID, + // so replying to the phone JID creates a separate session that + // conflicts with the LID session, causing "Waiting for this message". + let jid; + if (phone.includes('@')) { + jid = phone; + } else if (phoneToLid.has(phone)) { + jid = phoneToLid.get(phone); + console.log(`[LID] Routing reply to ${phone} via LID: ${jid}`); + } else { + jid = `${phone}@s.whatsapp.net`; + } + let sentMsg; + + if (imageBase64) { + const buffer = Buffer.from(imageBase64, 'base64'); + sentMsg = await sock.sendMessage(jid, { + image: buffer, + caption: message || '' + }); + } else if (audioBase64) { + let finalAudioBase64 = audioBase64; + let finalMime = mimetype || 'audio/mp4'; + + // If it's MP3, convert to OGG Opus to ensure iPhone PTT compatibility + if (finalMime.includes('mpeg') || finalMime.includes('mp3')) { + try { + console.log(`[Baileys] Converting MP3 to OGG Opus for native PTT...`); + finalAudioBase64 = await convertToOggOpus(audioBase64); + finalMime = 'audio/ogg; codecs=opus'; + } catch (err) { + console.error(`[Baileys] FFmpeg conversion failed:`, err.message); + // Fallback to sending as normal audio if conversion fails + finalMime = 'audio/mp4'; + } + } + + const buffer = Buffer.from(finalAudioBase64, 'base64'); + const isMp3 = finalMime.includes('mpeg') || finalMime.includes('mp3'); + + sentMsg = await sock.sendMessage(jid, { + audio: buffer, + mimetype: finalMime, + ptt: !isMp3 // PTT enabled for OGG/MP4, disabled for raw MP3 + }); + } else if (mediaUrl) { + const ext = mediaUrl.split('.').pop().toLowerCase(); + if (['jpg', 'jpeg', 'png', 'webp'].includes(ext)) { + sentMsg = await sock.sendMessage(jid, { image: { url: mediaUrl }, caption: message }); + } else if (['mp4', 'mkv', 'avi'].includes(ext)) { + sentMsg = await sock.sendMessage(jid, { video: { url: mediaUrl }, caption: message }); + } else { + sentMsg = await sock.sendMessage(jid, { document: { url: mediaUrl }, caption: message, fileName: mediaUrl.split('/').pop() }); + } + } else { + sentMsg = await sock.sendMessage(jid, { text: message }); + } + + // Cache outbound messages for E2EE decryption retries + if (sentMsg && sentMsg.key && sentMsg.key.id && sentMsg.message) { + recentMessages.set(sentMsg.key.id, sentMsg.message); + if (recentMessages.size > 2000) { + const firstKey = recentMessages.keys().next().value; + recentMessages.delete(firstKey); + } + } + + return sentMsg; +} + +async function checkContact(session_key, phone) { + const sock = sessions.get(session_key); + if (!sock) { + throw new Error(`Session ${session_key} is not active or connected`); + } + const jid = phone.includes('@') ? phone : `${phone}@s.whatsapp.net`; + try { + const result = await sock.onWhatsApp(jid); + if (result && result.length > 0) { + return result[0]; + } + return { exists: false, jid }; + } catch (err) { + console.error(`[Baileys] Error checking contact ${jid}:`, err.message); + throw err; + } +} + +function getActiveSessions() { + return Array.from(sessions.keys()); +} + +module.exports = { + startSession, + disconnectSession, + sendMessage, + getActiveSessions, + checkContact +}; + diff --git a/whatsapp-gateway/package.json b/whatsapp-gateway/package.json new file mode 100644 index 0000000..5837100 --- /dev/null +++ b/whatsapp-gateway/package.json @@ -0,0 +1,18 @@ +{ + "name": "nabeh-whatsapp-gateway", + "version": "1.0.0", + "description": "WhatsApp Baileys microservice for Nabeh", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@whiskeysockets/baileys": "^6.7.9", + "axios": "^1.7.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "node-cache": "^5.1.2", + "pino": "^9.2.0" + } +} diff --git a/whatsapp-gateway/server.js b/whatsapp-gateway/server.js new file mode 100644 index 0000000..09f75c4 --- /dev/null +++ b/whatsapp-gateway/server.js @@ -0,0 +1,125 @@ +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); + +// Find .env file identically to how PHP bootstrap does it +const envPaths = [ + path.join(__dirname, '.env'), + path.join(__dirname, '../.env'), + path.join(__dirname, '../backend/.env'), + path.join(__dirname, '../../../.env') +]; + +for (const p of envPaths) { + if (fs.existsSync(p)) { + dotenv.config({ path: p }); + console.log(`Loaded environment from ${p}`); + break; + } +} + +const express = require('express'); +const cors = require('cors'); +const { startSession, disconnectSession, sendMessage, getActiveSessions, checkContact } = require('./baileys-client'); + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ limit: '50mb', extended: true })); + +const PORT = process.env.PORT || 3722; + +// Health check endpoint (Public) +app.get('/health', (req, res) => { + res.json({ status: 'healthy', service: 'Nabeh WhatsApp Gateway' }); +}); + +// Security Middleware: Protect all /api/ routes +app.use('/api', (req, res, next) => { + const secret = req.header('X-Webhook-Secret'); + if (!process.env.WEBHOOK_SECRET || secret !== process.env.WEBHOOK_SECRET) { + return res.status(403).json({ error: 'Unauthorized gateway access' }); + } + next(); +}); + +// Start or retrieve a session +app.post('/api/sessions/start', async (req, res) => { + const { session_key, webhook_url } = req.body; + + if (!session_key || !webhook_url) { + return res.status(400).json({ error: 'Missing session_key or webhook_url' }); + } + + try { + await startSession(session_key, webhook_url); + res.json({ status: 'success', message: 'Session started or retrieved' }); + } catch (err) { + console.error(`Error starting session ${session_key}:`, err); + res.status(500).json({ error: 'Failed to start session' }); + } +}); + +// Disconnect and remove a session (e.g., when banned or logged out) +app.post('/api/sessions/disconnect', async (req, res) => { + const { session_key } = req.body; + + if (!session_key) { + return res.status(400).json({ error: 'Missing session_key' }); + } + + try { + await disconnectSession(session_key); + res.json({ status: 'success', message: 'Session disconnected and cleaned up' }); + } catch (err) { + console.error(`Error disconnecting session ${session_key}:`, err); + res.status(500).json({ error: 'Failed to disconnect session' }); + } +}); + +// Get list of active session keys in memory +app.get('/api/sessions/active', (req, res) => { + res.json({ status: 'success', active_sessions: getActiveSessions() }); +}); + +// Check if contact is on WhatsApp +app.post('/api/contacts/check', async (req, res) => { + const { session_key, phone } = req.body; + + if (!session_key || !phone) { + return res.status(400).json({ error: 'Missing session_key or phone' }); + } + + try { + const result = await checkContact(session_key, phone); + res.json({ status: 'success', data: result }); + } catch (err) { + console.error(`Error checking contact ${phone} via ${session_key}:`, err); + res.status(500).json({ error: err.message || 'Failed to check contact' }); + } +}); + +// Send outbound message +app.post('/api/messages/send', async (req, res) => { + const { session_key, phone, message, media_url, audio, mimetype, image } = req.body; + + if (!session_key || !phone) { + return res.status(400).json({ error: 'Missing session_key or phone' }); + } + + if (!message && !audio && !media_url && !image) { + return res.status(400).json({ error: 'Missing message, audio, media_url, or image' }); + } + + try { + const result = await sendMessage(session_key, phone, message, media_url, audio, mimetype, image); + res.json({ status: 'success', data: result }); + } catch (err) { + console.error(`Error sending message via ${session_key} to ${phone}:`, err); + res.status(500).json({ error: err.message || 'Failed to send message' }); + } +}); + +app.listen(PORT, () => { + console.log(`🚀 Nabeh WhatsApp Gateway running on port ${PORT}`); +});