734 lines
27 KiB
JavaScript
734 lines
27 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
|
|
require('dotenv').config();
|
|
|
|
// ─── Firebase Admin SDK Configuration (Highly Secure Background Pushes) ─────
|
|
const admin = require('firebase-admin');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
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 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);
|
|
}
|
|
});
|
|
|
|
// 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 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);
|
|
}
|
|
});
|
|
});
|
|
}
|