إضافة كود بوابة الواتساب وملفات التهيئة والتحكم في الطريقة
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -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
|
||||
@@ -1,27 +0,0 @@
|
||||
# Protect includes directory
|
||||
<IfModule mod_rewrite.c>
|
||||
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]
|
||||
</IfModule>
|
||||
|
||||
# Disable directory listing
|
||||
Options -Indexes
|
||||
|
||||
# Prevent script execution in includes
|
||||
<Directory "includes">
|
||||
php_flag engine off
|
||||
</Directory>
|
||||
@@ -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 {
|
||||
$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();
|
||||
|
||||
7
backend/composer.json
Normal file
7
backend/composer.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "intaleq/flash-call-otp",
|
||||
"description": "Flash Call OTP Backend",
|
||||
"require": {
|
||||
"vlucas/phpdotenv": "^5.5"
|
||||
}
|
||||
}
|
||||
2
whatsapp-gateway/.env.example
Normal file
2
whatsapp-gateway/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PORT=3732
|
||||
WEBHOOK_SECRET=flash_call_otp_webhook_secret_key
|
||||
469
whatsapp-gateway/baileys-client.js
Normal file
469
whatsapp-gateway/baileys-client.js
Normal file
@@ -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
|
||||
};
|
||||
|
||||
18
whatsapp-gateway/package.json
Normal file
18
whatsapp-gateway/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
125
whatsapp-gateway/server.js
Normal file
125
whatsapp-gateway/server.js
Normal file
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user