Files
mywhatsapp/whatsapp_bridge/server.js

857 lines
31 KiB
JavaScript

/**
* 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 path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
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)
];
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}`);
envLoaded = true;
break;
}
}
if (!envLoaded) {
dotenv.config(); // Fallback to default CWD
console.log('[ENV] No specific .env found in known paths, loaded default configuration');
}
// ─── Firebase Admin SDK Configuration (Highly Secure Background Pushes) ─────
const admin = require('firebase-admin');
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');
try {
if (envServiceAccount) {
let serviceAccount;
if (envServiceAccount.trim().startsWith('{')) {
serviceAccount = JSON.parse(envServiceAccount);
console.log('[FCM] Initializing Firebase Admin SDK via direct env JSON string...');
} else {
serviceAccount = require(envServiceAccount);
console.log(`[FCM] Initializing Firebase Admin SDK via custom path from env: ${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}`);
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...');
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.');
}
} catch (err) {
console.error('[FCM ERROR] Failed to initialize Firebase Admin SDK:', err.message);
}
async function sendPushNotification(chatId, senderName, body) {
if (!firebaseApp) {
console.log('[FCM] Push skipped: Firebase Admin SDK not initialized.');
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;
}
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
}
}
}
};
const response = await admin.messaging().send(message);
console.log('[FCM] Push notification sent successfully, messageId:', response);
} catch (err) {
console.error('[FCM SEND ERROR] Failed to send push notification:', err.message);
}
}
// ─── State ─────────────────────────────────────────────────────────────────
let waClient = null;
let clientReady = false;
let qrCodeCache = null;
const connectedClients = new Set();
// ─── Error Handling (Never Crash) ──────────────────────────────────────────
process.on('uncaughtException', (err) => {
console.error('[CRITICAL] Uncaught Exception:', err);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[CRITICAL] Unhandled Rejection at:', promise, 'reason:', reason);
});
// ─── WebSocket: Broadcast to all Flutter clients ───────────────────────────
function broadcast(payload) {
const data = JSON.stringify(payload);
connectedClients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(data);
} catch (err) {
console.error('[WS] Broadcast send error:', 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);
}
}
}
// ─── Format conversation object for Flutter ────────────────────────────────
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;
}
}, 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),
new Promise(resolve => setTimeout(() => resolve(null), 800))
]);
}
} catch (_) {}
// Last Message formatting
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
};
}
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
};
}
// ─── 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;
} 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
};
}
// ─── Initialize WhatsApp Client ─────────────────────────────────────────────
function initWhatsApp() {
console.log('[WA] Initializing WhatsApp client using LocalAuth...');
clientReady = false;
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'
];
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,
});
// QR Code received -> send to all WS clients
waClient.on('qr', async (qr) => {
console.log('[WA] QR Code received');
try {
const qrDataUrl = await qrcode.toDataURL(qr);
qrCodeCache = qrDataUrl;
broadcast({ type: 'qr', qr: qrDataUrl });
} catch (err) {
console.error('[WA] QR generation error:', err);
}
});
// 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 });
// Trigger background push notification if not sent by me
if (!msg.fromMe) {
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 || '';
if (!body && msg.hasMedia) {
body = '📷 Media/Attachment';
}
await sendPushNotification(msg.from, senderName, body);
} catch (fcmErr) {
console.error('[FCM PUSH ERROR] Failed to send background push:', fcmErr.message);
}
}
} catch (err) {
console.error('[WA] Error formatting new message event:', err.message);
}
});
// Message ACK update
waClient.on('message_ack', (msg, ack) => {
try {
broadcast({
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);
}
});
// Poll Vote updates (Listen to real-time votes on polls!)
waClient.on('vote', (vote) => {
try {
console.log(`[WA] Poll vote received from: ${vote.sender} for poll message: ${vote.pollMessageId}`);
broadcast({
type: 'poll_vote',
pollMessageId: vote.pollMessageId,
sender: vote.sender,
selectedOptions: vote.selectedOptions,
timestamp: vote.timestamp
});
} catch (err) {
console.error('[WA] Error broadcasting poll_vote event:', err.message);
}
});
// Disconnected
waClient.on('disconnected', (reason) => {
console.warn('[WA] Disconnected! Reason:', reason);
clientReady = false;
broadcast({ type: 'disconnected', reason });
broadcast({ type: 'status', ready: false });
// Clean up client resources
try {
waClient.destroy();
} catch (_) {}
// Auto-reconnect after 5 seconds
console.log('[WA] Reconnecting in 5 seconds...');
setTimeout(initWhatsApp, 5000);
});
waClient.initialize().catch((err) => {
console.error('[WA] Client initialization failed:', err);
console.log('[WA] Retrying initialization in 5 seconds...');
setTimeout(initWhatsApp, 5000);
});
}
// ─── Handle WebSocket messages from Flutter ────────────────────────────────
async function handleMessage(ws, raw) {
let payload;
try {
payload = JSON.parse(raw);
} catch (err) {
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}`);
const respond = (data) => sendTo(ws, { ...data, requestId });
// Handle type specific requests
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) + '...');
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...`);
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 });
} 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 });
}
}
// ── Messages ───────────────────────────────────────────────────────
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 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
});
}
// ── Media ──────────────────────────────────────────────────────────
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' });
}
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 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);
}
}
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 });
}
}
// ── Mark as Read ───────────────────────────────────────────────────
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: chatId,
requestId
});
}
// ── Search Conversations ───────────────────────────────────────────
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,
requestId
});
}
default:
return respond({ type: 'error', message: `Unknown request type: ${type}` });
}
} catch (err) {
console.error(`[WS] Error processing request ${type}:`, err);
return respond({ type: 'error', message: err.message || 'Server error' });
}
}
// ─── 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) ───────
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' });
try {
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const sentMsg = await 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 { 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];
}
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 || '' });
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 { 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' });
}
try {
const { Poll } = require('whatsapp-web.js');
const poll = new Poll(question, options, {
allowMultipleAnswers: allowMultiple !== false // default to true
});
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const sentMsg = await 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 { 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);
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 ──────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
waReady: clientReady,
clients: connectedClients.size,
port: PORT
});
});
// ─── Start HTTP + WebSocket Server ─────────────────────────────────────────
server.listen(PORT, () => {
console.log(`[SERVER] Standalone WhatsApp Bridge running on port ${PORT}`);
initWhatsApp();
});
// ─── OGG to MP3 base64 converter using ffmpeg child process ────────────────
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.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);
}
});
});
}