770 lines
27 KiB
JavaScript
770 lines
27 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
|
|
};
|
|
}
|
|
|
|
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}`);
|
|
slotState.qrCodeCache = qr;
|
|
slotState.clientReady = false;
|
|
|
|
updateSlotStatus(slotId, 'qrcode');
|
|
broadcast(slotId, { type: 'qr', qr });
|
|
});
|
|
|
|
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 '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();
|
|
|
|
// 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'));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|