324 lines
12 KiB
JavaScript
324 lines
12 KiB
JavaScript
const baileys = require('@whiskeysockets/baileys');
|
|
const makeWASocket = baileys.default || baileys.makeWASocket || baileys;
|
|
const { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } = baileys;
|
|
const pino = require('pino');
|
|
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 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 socketConfig = {
|
|
auth: state,
|
|
printQRInTerminal: false,
|
|
logger: pino({ level: 'silent' }),
|
|
browser: ['Nabeh Gateway', 'Chrome', '120.0.0']
|
|
};
|
|
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));
|
|
}
|
|
|
|
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];
|
|
} 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,
|
|
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;
|
|
const retries = (retryCounters.get(session_key) || 0) + 1;
|
|
retryCounters.set(session_key, retries);
|
|
|
|
console.log(`[Connection] ${session_key} closed | code=${statusCode} | retry=${retries}/${MAX_RETRIES} | shouldReconnect=${shouldReconnect}`);
|
|
|
|
if (shouldReconnect && retries <= MAX_RETRIES) {
|
|
// Try reconnecting with exponential backoff
|
|
sessions.delete(session_key);
|
|
const delay = Math.min(retries * 3000, 15000); // 3s, 6s, 9s, 12s, 15s
|
|
console.log(`[Connection] Reconnecting ${session_key} in ${delay}ms...`);
|
|
setTimeout(() => startSession(session_key, webhook_url), delay);
|
|
} else {
|
|
// Either logged out, banned, or max retries exceeded
|
|
console.log(`[Connection] ${session_key} permanently closed. Cleaning up.`);
|
|
await cleanupSession(session_key);
|
|
await sendWebhook(webhook_url, {
|
|
session_key,
|
|
state: 'disconnected'
|
|
});
|
|
}
|
|
} 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a message using an active session
|
|
*/
|
|
async function sendMessage(session_key, phone, message, mediaUrl = null) {
|
|
const sock = sessions.get(session_key);
|
|
if (!sock) {
|
|
throw new Error(`Session ${session_key} is not active or connected`);
|
|
}
|
|
|
|
let jid = phone.includes('@') ? phone : `${phone}@s.whatsapp.net`;
|
|
|
|
if (mediaUrl) {
|
|
const ext = mediaUrl.split('.').pop().toLowerCase();
|
|
if (['jpg', 'jpeg', 'png', 'webp'].includes(ext)) {
|
|
return await sock.sendMessage(jid, { image: { url: mediaUrl }, caption: message });
|
|
} else if (['mp4', 'mkv', 'avi'].includes(ext)) {
|
|
return await sock.sendMessage(jid, { video: { url: mediaUrl }, caption: message });
|
|
} else {
|
|
return await sock.sendMessage(jid, { document: { url: mediaUrl }, caption: message, fileName: mediaUrl.split('/').pop() });
|
|
}
|
|
}
|
|
|
|
return await sock.sendMessage(jid, { text: message });
|
|
}
|
|
|
|
function getActiveSessions() {
|
|
return Array.from(sessions.keys());
|
|
}
|
|
|
|
module.exports = {
|
|
startSession,
|
|
disconnectSession,
|
|
sendMessage,
|
|
getActiveSessions
|
|
};
|
|
|