Files
mywhatsapp/whatsapp_bridge/server.js
2026-05-18 16:01:39 +03:00

462 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',
'--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 });
} 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
});
}
// ── 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();
});