454 lines
16 KiB
JavaScript
454 lines
16 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 });
|
|
|
|
// ─── 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 {
|
|
const contact = await chat.getContact();
|
|
const pic = await contact.getProfilePicUrl();
|
|
if (pic) avatar = pic;
|
|
} 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',
|
|
'--no-zygote',
|
|
'--single-process',
|
|
'--disable-gpu',
|
|
'--disable-web-security',
|
|
'--disable-features=IsolateOrigins,site-per-process'
|
|
],
|
|
// Realistic modern desktop user agent to bypass anti-bot detections (detaching frame errors)
|
|
userAgent: '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 });
|
|
} 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' });
|
|
}
|
|
|
|
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));
|
|
return {
|
|
type: 'conversations',
|
|
data: formatted,
|
|
total: chats.length
|
|
};
|
|
};
|
|
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('Server request to WhatsApp timed out')), 25000)
|
|
);
|
|
|
|
const result = await Promise.race([fetchChats(), timeoutPromise]);
|
|
return respond({ ...result, requestId });
|
|
} catch (err) {
|
|
console.error('[WS] get_conversations failed or timed out:', 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
|
|
});
|
|
}
|
|
|
|
// ── 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
|
|
});
|
|
}
|
|
|
|
// ── 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();
|
|
});
|