Files
mywhatsapp/whatsapp_bridge/server.js
2026-05-19 23:27:14 +03:00

846 lines
30 KiB
JavaScript

const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
const url = require('url');
// ─── Environment Configuration ─────────────────────────────────────────────
const envPaths = [
path.join(__dirname, '.env'), // whatsapp_bridge/.env
path.join(__dirname, '..', '.env'), // mywhatsapp.intaleqapp.com/.env
'/home/intaleqapp-mywhatsapp/.env' // Server user root-level .env
];
let envLoaded = false;
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
console.log(`[ENV] Loaded configuration from: ${envPath}`);
envLoaded = true;
break;
}
}
if (!envLoaded) {
dotenv.config();
console.log('[ENV] Loaded default environment configuration');
}
// ─── Dependencies ──────────────────────────────────────────────────────────
const {
initDatabase,
updateSlotStatus,
archiveMessage,
getChatHistory,
searchMessages
} = require('./database');
const admin = require('firebase-admin');
const PORT = process.env.PORT || 3025;
// ─── Express & HTTP Setup ──────────────────────────────────────────────────
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// ─── Firebase Admin SDK Configuration (Background Pushes) ──────────────────
let firebaseApp = null;
const envServiceAccount = process.env.FIREBASE_SERVICE_ACCOUNT;
const envServiceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH;
const localServiceAccountPath = path.join(__dirname, 'serviceAccountKey.json');
try {
if (envServiceAccount) {
let serviceAccount;
if (envServiceAccount.trim().startsWith('{')) {
serviceAccount = JSON.parse(envServiceAccount);
console.log('[FCM] Initializing Firebase Admin SDK via env JSON string...');
} else {
serviceAccount = require(envServiceAccount);
console.log(`[FCM] Initializing Firebase Admin SDK via env path: ${envServiceAccount}`);
}
firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
} else if (envServiceAccountPath && fs.existsSync(envServiceAccountPath)) {
console.log(`[FCM] Initializing Firebase Admin SDK via custom path: ${envServiceAccountPath}`);
const serviceAccount = require(envServiceAccountPath);
firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
} else if (fs.existsSync(localServiceAccountPath)) {
console.log('[FCM] Initializing Firebase Admin SDK via serviceAccountKey.json...');
const serviceAccount = require(localServiceAccountPath);
firebaseApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
} else {
console.warn('[FCM WARNING] No Firebase Service Account found. Background pushes will be disabled.');
}
} catch (err) {
console.error('[FCM ERROR] Failed to initialize Firebase Admin SDK:', err.message);
}
// ─── Send Background Push Notification ────────────────────────────────────
async function sendPushNotification(chatId, senderName, body) {
if (!firebaseApp) return;
const tokenPath = path.join(__dirname, 'fcm_token.json');
if (!fs.existsSync(tokenPath)) return;
try {
const tokenData = JSON.parse(fs.readFileSync(tokenPath));
const token = tokenData.token;
if (!token) return;
const message = {
token: token,
notification: {
title: senderName || 'WhatsApp Message',
body: body || 'New Message'
},
data: {
chatId: chatId,
name: senderName || 'WhatsApp'
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1
}
}
}
};
await admin.messaging().send(message);
console.log('[FCM] Push notification sent successfully');
} catch (err) {
console.error('[FCM SEND ERROR] Failed to send push:', err.message);
}
}
// ─── 6-Slot Multi-Tenant Registry State ────────────────────────────────────
const slots = new Map(); // slotId (1-6) -> { waClient, clientReady, qrCodeCache }
const connectedClients = new Set(); // Set of active WS connections
// Uncaught Exceptions Catching
process.on('uncaughtException', (err) => console.error('[CRITICAL] Uncaught Exception:', err));
process.on('unhandledRejection', (reason, p) => console.error('[CRITICAL] Unhandled Rejection at:', p, 'reason:', reason));
// ─── WebSocket Routing & Messaging ─────────────────────────────────────────
function broadcast(slotId, payload) {
const data = JSON.stringify(payload);
connectedClients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN && ws.slotId === slotId) {
try {
ws.send(data);
} catch (err) {
console.error(`[WS] Broadcast error on slot ${slotId}:`, err.message);
}
}
});
}
function sendTo(ws, payload) {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(payload));
} catch (err) {
console.error('[WS] Send error:', err.message);
}
}
}
// ─── Formatting helpers ────────────────────────────────────────────────────
async function formatChat(chat) {
let avatar = null;
try {
avatar = await chat.client.pupPage.evaluate((chatId) => {
try {
const contact = window.Store.Contact.get(chatId);
return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null;
} catch (_) { return null; }
}, chat.id._serialized);
if (!avatar) {
avatar = await Promise.race([
chat.getProfilePicUrl().catch(() => null),
new Promise(resolve => setTimeout(() => resolve(null), 800))
]);
}
} catch (_) {}
let lastMessageFormatted = null;
if (chat.lastMessage) {
lastMessageFormatted = {
body: chat.lastMessage.body || '',
timestamp: chat.lastMessage.timestamp || Math.floor(Date.now() / 1000),
fromMe: chat.lastMessage.fromMe || false,
hasMedia: chat.lastMessage.hasMedia || false,
ack: chat.lastMessage.ack || 0
};
}
return {
id: chat.id._serialized,
name: chat.name || 'Unknown',
isGroup: chat.isGroup || false,
unreadCount: chat.unreadCount || 0,
avatar: avatar,
lastMessage: lastMessageFormatted,
timestamp: chat.timestamp || Math.floor(Date.now() / 1000),
pinned: chat.pinned || false,
isMuted: chat.isMuted || false
};
}
function formatMessage(msg) {
let ack = msg.ack || 0;
if (ack < 0) ack = 0;
let type = "chat";
if (["chat", "image", "video", "audio", "document", "sticker"].includes(msg.type)) {
type = msg.type;
} else if (msg.type === "ptt") {
type = "audio";
}
return {
id: msg.id._serialized,
body: msg.body || '',
fromMe: msg.fromMe || false,
timestamp: msg.timestamp || Math.floor(Date.now() / 1000),
type: type,
hasMedia: msg.hasMedia || false,
isForwarded: msg.isForwarded || false,
author: msg.author || null,
ack: ack
};
}
// ─── Puppeteer / Chrome Config ──────────────────────────────────────────────
const puppeteerConfig = {
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--disable-gpu',
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
]
};
const possiblePaths = [
process.env.CHROME_BIN,
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable'
];
for (const p of possiblePaths) {
if (p && fs.existsSync(p)) {
puppeteerConfig.executablePath = p;
break;
}
}
// ─── Bootstrap Slot ────────────────────────────────────────────────────────
function bootstrapSlot(slotId) {
if (slotId < 1 || slotId > 6) return;
if (slots.has(slotId)) return slots.get(slotId);
console.log(`[BOOTSTRAP] Initializing Slot ${slotId}...`);
const { Client, LocalAuth } = require('whatsapp-web.js');
const client = new Client({
authStrategy: new LocalAuth({
clientId: `slot-${slotId}`,
dataPath: path.join(__dirname, '.wwebjs_auth')
}),
puppeteer: puppeteerConfig
});
const slotState = {
waClient: client,
clientReady: false,
qrCodeCache: null
};
slots.set(slotId, slotState);
// ─── Listeners ───────────────────────────────────────────────────────────
client.on('qr', (qr) => {
console.log(`[WA] QR generated for Slot ${slotId}`);
const QRCode = require('qrcode');
QRCode.toDataURL(qr, (err, url) => {
if (err) {
console.error(`[WA] Failed to generate QR DataURL for Slot ${slotId}:`, err.message);
return;
}
slotState.qrCodeCache = url;
slotState.clientReady = false;
updateSlotStatus(slotId, 'qrcode');
broadcast(slotId, { type: 'qr', qr: url });
});
});
client.on('authenticated', () => {
console.log(`[WA] Slot ${slotId} authenticated successfully.`);
slotState.qrCodeCache = null;
updateSlotStatus(slotId, 'authenticated');
broadcast(slotId, { type: 'authenticated' });
});
client.on('ready', () => {
console.log(`[WA] Slot ${slotId} is ready and connected.`);
slotState.clientReady = true;
slotState.qrCodeCache = null;
const phoneNumber = client.info.wid.user;
updateSlotStatus(slotId, 'connected', phoneNumber);
broadcast(slotId, { type: 'ready', phoneNumber });
broadcast(slotId, { type: 'status', ready: true });
});
client.on('message', async (msg) => {
console.log(`[WA] [Slot ${slotId}] Message received from: ${msg.from}`);
try {
const chat = await msg.getChat();
// Auto-Archive Message to MySQL
msg.senderName = msg._data.notifyName || chat.name;
await archiveMessage(slotId, msg);
const formatted = formatMessage(msg);
broadcast(slotId, { type: 'new_message', chatId: msg.from, data: formatted });
// Handle Background Push Notification (skip if chat is muted)
if (!msg.fromMe && !chat.isMuted) {
try {
const contact = await msg.getContact();
const senderName = contact.name || contact.pushname || msg.from.split('@')[0];
let body = msg.body || '';
if (!body && msg.hasMedia) {
body = '📷 Media/Attachment';
}
await sendPushNotification(msg.from, senderName, body);
} catch (fcmErr) {
console.error('[FCM PUSH ERROR] Background push failed:', fcmErr.message);
}
}
} catch (err) {
console.error('[WA] Error processing new message:', err.message);
}
});
client.on('message_create', async (msg) => {
// Archive outgoing messages sent from this phone natively
if (msg.fromMe) {
try {
msg.senderName = 'Me';
await archiveMessage(slotId, msg);
} catch (_) {}
}
});
client.on('message_ack', (msg, ack) => {
try {
broadcast(slotId, {
type: 'message_ack',
messageId: msg.id._serialized,
chatId: msg.to || msg.from,
ack: ack
});
} catch (err) {
console.error('[WA] Error broadcasting message_ack:', err.message);
}
});
client.on('vote', (vote) => {
try {
console.log(`[WA] [Slot ${slotId}] Vote received for poll: ${vote.pollMessageId}`);
broadcast(slotId, {
type: 'poll_vote',
pollMessageId: vote.pollMessageId,
sender: vote.sender,
selectedOptions: vote.selectedOptions,
timestamp: vote.timestamp
});
} catch (err) {
console.error('[WA] Error broadcasting vote:', err.message);
}
});
client.on('disconnected', (reason) => {
console.warn(`[WA] Slot ${slotId} disconnected! Reason:`, reason);
slotState.clientReady = false;
updateSlotStatus(slotId, 'disconnected');
broadcast(slotId, { type: 'disconnected', reason });
broadcast(slotId, { type: 'status', ready: false });
try { client.destroy(); } catch (_) {}
slots.delete(slotId);
// Auto-reconnect in 6 seconds
console.log(`[WA] Slot ${slotId} reconnecting in 6 seconds...`);
setTimeout(() => bootstrapSlot(slotId), 6000);
});
client.initialize().catch((err) => {
console.error(`[WA] Slot ${slotId} initialization failed:`, err.message);
slots.delete(slotId);
setTimeout(() => bootstrapSlot(slotId), 6000);
});
return slotState;
}
// ─── WebSocket Packet Handler ─────────────────────────────────────────────
async function handleMessage(ws, raw) {
let payload;
try { payload = JSON.parse(raw); } catch (_) {
return sendTo(ws, { type: 'error', message: 'Invalid JSON payload' });
}
const { type, requestId } = payload;
if (!requestId) return sendTo(ws, { type: 'error', message: 'Missing requestId' });
const slotId = ws.slotId || 1;
const slot = slots.get(slotId);
const respond = (data) => sendTo(ws, { ...data, requestId });
if (!slot) return respond({ type: 'error', message: `Slot ${slotId} is not initialized` });
const waClient = slot.waClient;
const clientReady = slot.clientReady;
try {
switch (type) {
case 'ping':
return respond({ type: 'pong', ready: clientReady });
case 'register_fcm': {
const { token } = payload;
if (!token) return respond({ type: 'error', message: 'Token is required' });
fs.writeFileSync(path.join(__dirname, 'fcm_token.json'), JSON.stringify({ token, updatedAt: new Date().toISOString() }));
return respond({ type: 'fcm_registered', success: true });
}
case 'get_conversations': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
try {
const chats = await waClient.getChats();
const limit = parseInt(payload.limit) || 50;
const offset = parseInt(payload.offset) || 0;
const slice = chats.slice(offset, offset + limit);
const formatted = await Promise.all(slice.map(formatChat));
return respond({ type: 'conversations', data: formatted, total: chats.length });
} catch (err) {
return respond({ type: 'error', message: err.message });
}
}
case 'get_messages': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
const { chatId, limit } = payload;
if (!chatId) return respond({ type: 'error', message: 'chatId is required' });
const chat = await waClient.getChatById(chatId);
const messages = await chat.fetchMessages({ limit: parseInt(limit) || 50 });
return respond({ type: 'messages', chatId, data: messages.map(formatMessage) });
}
case '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' });
try {
const sentMsg = await waClient.sendMessage(chatId, text);
return respond({
type: 'message_sent',
chatId: chatId,
data: formatMessage(sentMsg)
});
} catch (err) {
return respond({ type: 'error', message: err.message });
}
}
case 'send_media': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
const { chatId, base64, mimetype, filename, caption } = payload;
if (!chatId || !base64 || !mimetype) {
return respond({ type: 'error', message: 'chatId, base64, and mimetype are required' });
}
try {
let cleanBase64 = base64.trim();
if (cleanBase64.includes(';base64,')) {
cleanBase64 = cleanBase64.split(';base64,')[1];
}
const { MessageMedia } = require('whatsapp-web.js');
const media = new MessageMedia(mimetype, cleanBase64, filename || 'file');
const sentMsg = await waClient.sendMessage(chatId, media, { caption: caption || '' });
return respond({
type: 'message_sent',
chatId: chatId,
data: formatMessage(sentMsg)
});
} catch (err) {
return respond({ type: 'error', message: err.message });
}
}
case 'get_media': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
const { messageId } = payload;
if (!messageId) return respond({ type: 'error', message: 'messageId is required' });
const parts = messageId.split('_');
if (parts.length < 2) return respond({ type: 'error', message: 'Invalid messageId format' });
const chat = await waClient.getChatById(parts[1]);
const messages = await chat.fetchMessages({ limit: 100 });
const msg = messages.find(m => m.id._serialized === messageId);
if (!msg || !msg.hasMedia) return respond({ type: 'error', message: 'Media not found' });
let media = null;
for (let i = 0; i < 3; i++) {
try {
media = await msg.downloadMedia();
if (media && media.data) break;
} catch (_) {}
await new Promise(r => setTimeout(r, 1500));
}
if (!media) return respond({ type: 'error', message: 'Failed to download media' });
// On-the-fly Audio Conversion
if (media.mimetype && (media.mimetype.includes('audio/ogg') || media.mimetype.includes('ogg'))) {
try {
media.data = await convertOggToMp3(media.data);
media.mimetype = 'audio/mp3';
} catch (_) {}
}
return respond({ type: 'media', messageId, data: media.data, mimetype: media.mimetype });
}
case 'mark_read': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
const { chatId } = payload;
if (!chatId) return respond({ type: 'error', message: 'chatId is required' });
const chat = await waClient.getChatById(chatId);
await chat.sendSeen();
return respond({ type: 'marked_read', chatId });
}
case 'search_conversations': {
if (!clientReady) return respond({ type: 'error', message: 'WhatsApp is not ready' });
const query = (payload.query || '').toLowerCase();
const chats = await waClient.getChats();
const filtered = chats.filter(c => (c.name || '').toLowerCase().includes(query));
const formatted = await Promise.all(filtered.slice(0, 50).map(formatChat));
return respond({ type: 'conversations', data: formatted, search: true });
}
default:
return respond({ type: 'error', message: `Unknown request: ${type}` });
}
} catch (err) {
return respond({ type: 'error', message: err.message });
}
}
// ─── REST HTTP API Proxy Endpoints ─────────────────────────────────────────
app.use(express.json({ limit: '50mb' }));
app.post('/api/connect', (req, res) => {
const slotId = parseInt(req.body.slot || req.query.slot) || 1;
if (slotId < 1 || slotId > 6) return res.status(400).json({ error: 'Slot must be between 1 and 6' });
bootstrapSlot(slotId);
res.status(200).json({ success: true, message: `Slot ${slotId} initialization triggered.` });
});
app.post('/api/disconnect', async (req, res) => {
const slotId = parseInt(req.body.slot || req.query.slot) || 1;
const slot = slots.get(slotId);
if (!slot) return res.status(404).json({ error: `Slot ${slotId} is not active.` });
try {
await slot.waClient.destroy();
slots.delete(slotId);
await updateSlotStatus(slotId, 'disconnected');
res.status(200).json({ success: true, message: `Slot ${slotId} disconnected and destroyed.` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/slots', (req, res) => {
const list = [];
for (let id = 1; id <= 6; id++) {
const slot = slots.get(id);
list.push({
slot: id,
active: !!slot,
ready: slot ? slot.clientReady : false,
hasQrCache: slot ? !!slot.qrCodeCache : false
});
}
res.status(200).json({ success: true, slots: list });
});
app.post('/api/send', async (req, res) => {
const slotId = parseInt(req.body.slot || req.query.slot) || 1;
const slot = slots.get(slotId);
if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} is not ready` });
const { phone, message } = req.body;
if (!phone || !message) return res.status(400).json({ error: 'phone and message are required' });
try {
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const sentMsg = await slot.waClient.sendMessage(chatId, message);
res.status(200).json({ success: true, messageId: sentMsg.id.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/send-media', async (req, res) => {
const slotId = parseInt(req.body.slot || req.query.slot) || 1;
const slot = slots.get(slotId);
if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} is not ready` });
const { phone, base64, mimetype, filename, caption } = req.body;
if (!phone || !base64 || !mimetype) return res.status(400).json({ error: 'phone, base64, and mimetype are required' });
try {
let cleanBase64 = base64.trim();
if (cleanBase64.includes(';base64,')) {
cleanBase64 = cleanBase64.split(';base64,')[1];
}
const { MessageMedia } = require('whatsapp-web.js');
const media = new MessageMedia(mimetype, cleanBase64, filename || 'file');
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const sentMsg = await slot.waClient.sendMessage(chatId, media, { caption: caption || '' });
res.status(200).json({ success: true, messageId: sentMsg.id.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/send-poll', async (req, res) => {
const slotId = parseInt(req.body.slot || req.query.slot) || 1;
const slot = slots.get(slotId);
if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} is not ready` });
const { phone, question, options, allowMultiple } = req.body;
if (!phone || !question || !options || !Array.isArray(options)) {
return res.status(400).json({ error: 'phone, question, and options (array) are required' });
}
try {
const { Poll } = require('whatsapp-web.js');
const poll = new Poll(question, options, {
allowMultipleAnswers: allowMultiple !== false
});
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const sentMsg = await slot.waClient.sendMessage(chatId, poll);
res.status(200).json({ success: true, messageId: sentMsg.id.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/avatar', async (req, res) => {
const slotId = parseInt(req.query.slot) || 1;
const slot = slots.get(slotId);
if (!slot || !slot.clientReady) return res.status(503).json({ error: `Slot ${slotId} is not ready` });
const { phone } = req.query;
if (!phone) return res.status(400).json({ error: 'phone parameter is required' });
try {
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const avatarUrl = await slot.waClient.getProfilePicUrl(chatId);
res.status(200).json({ success: true, avatarUrl: avatarUrl || null });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── NEW: Message Archive endpoints from MySQL ─────────────────────────────
app.get('/api/archive', async (req, res) => {
const slotId = parseInt(req.query.slot) || 1;
const { chatId, limit, offset } = req.query;
if (!chatId) return res.status(400).json({ error: 'chatId parameter is required' });
try {
const list = await getChatHistory(slotId, chatId, limit || 50, offset || 0);
res.status(200).json({ success: true, slot: slotId, chatId, data: list });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/archive/search', async (req, res) => {
const slotId = parseInt(req.query.slot) || 1;
const { query, limit } = req.query;
if (!query) return res.status(400).json({ error: 'query parameter is required' });
try {
const list = await searchMessages(slotId, query, limit || 50);
res.status(200).json({ success: true, slot: slotId, query, data: list });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
slots: slots.size,
clients: connectedClients.size,
port: PORT
});
});
// ─── WebSocket Multi-Slot Connection Handler ──────────────────────────────
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
// Extract slot parameter from query string (e.g. wss://host/?slot=2)
const query = url.parse(req.url, true).query;
const slotId = parseInt(query.slot) || 1;
if (slotId < 1 || slotId > 6) {
console.warn(`[WS] Connection rejected: Invalid slotId ${slotId} from ${ip}`);
ws.close(4000, 'Invalid slotId. Must be 1 to 6');
return;
}
ws.slotId = slotId;
connectedClients.add(ws);
console.log(`[WS] Client registered to Slot ${slotId} from IP: ${ip}`);
// Fetch slot status
const slot = slots.get(slotId);
sendTo(ws, { type: 'status', ready: slot ? slot.clientReady : false });
if (slot) {
if (!slot.clientReady && slot.qrCodeCache) {
sendTo(ws, { type: 'qr', qr: slot.qrCodeCache });
}
} else {
// Lazy-load Slot 1 by default, others load on demand or if connect API hit
if (slotId === 1) bootstrapSlot(1);
}
ws.on('message', (data) => {
handleMessage(ws, data.toString());
});
ws.on('close', () => {
connectedClients.delete(ws);
console.log(`[WS] Client disconnected from Slot ${slotId}`);
});
ws.on('error', (err) => {
console.error(`[WS] Connection error on Slot ${slotId}:`, err.message);
connectedClients.delete(ws);
});
});
// ─── Start HTTP Server & Connect Database ─────────────────────────────────
server.listen(PORT, async () => {
console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`);
try {
// Initialize MySQL Database
await initDatabase();
// Session Migration Logic: Move old session to slot-1 if slot-1 is empty
const oldSessionPaths = [
path.join(__dirname, '.wwebjs_auth', 'session-whatsapp-bridge'),
path.join(__dirname, '.wwebjs_auth', 'session')
];
const slot1SessionPath = path.join(__dirname, '.wwebjs_auth', 'session-slot-1');
if (!fs.existsSync(slot1SessionPath)) {
for (const oldPath of oldSessionPaths) {
if (fs.existsSync(oldPath)) {
console.log(`[MIGRATION] Migrating active WhatsApp session from ${oldPath} to Slot 1...`);
try {
if (fs.cpSync) {
fs.cpSync(oldPath, slot1SessionPath, { recursive: true });
} else {
const { execSync } = require('child_process');
execSync(`cp -R "${oldPath}" "${slot1SessionPath}"`);
}
console.log('[MIGRATION] Session migrated successfully! Slot 1 will load connected.');
break;
} catch (migrationErr) {
console.error('[MIGRATION ERROR] Failed to migrate session:', migrationErr.message);
}
}
}
}
// Auto-bootstrap Slot 1 at startup
bootstrapSlot(1);
// Also auto-bootstrap any other slots that already have saved credentials
for (let slotId = 2; slotId <= 6; slotId++) {
const sessionPath = path.join(__dirname, '.wwebjs_auth', `session-slot-${slotId}`);
if (fs.existsSync(sessionPath)) {
console.log(`[SERVER] Detected existing credentials for Slot ${slotId}, auto-bootstrapping...`);
bootstrapSlot(slotId);
}
}
} catch (err) {
console.error('[CRITICAL] Database initialization or startup failed:', err.message);
}
});
// ─── OGG to MP3 base64 converter using ffmpeg ─────────────────────────────
function convertOggToMp3(base64Ogg) {
const { exec } = require('child_process');
const tmp = require('os').tmpdir();
const path = require('path');
const fs = require('fs');
return new Promise((resolve, reject) => {
const timeId = Date.now();
const inputPath = path.join(tmp, `input_${timeId}.ogg`);
const outputPath = path.join(tmp, `output_${timeId}.mp3`);
fs.writeFile(inputPath, Buffer.from(base64Ogg, 'base64'), (err) => {
if (err) return reject(err);
exec(`ffmpeg -i ${inputPath} -acodec libmp3lame -y ${outputPath}`, (execErr) => {
// Clean up input ogg file
fs.unlink(inputPath, () => {});
if (execErr) return reject(execErr);
fs.readFile(outputPath, (readErr, data) => {
// Clean up output mp3 file
fs.unlink(outputPath, () => {});
if (readErr) return reject(readErr);
resolve(data.toString('base64'));
});
});
});
});
}