Files
flash-call-otp/whatsapp-gateway/puppeteer-client.js
2026-06-24 15:26:15 +03:00

336 lines
11 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { execSync } = require('child_process');
const { Client, LocalAuth, MessageMedia } = require('whatsapp-web.js');
const sessions = new Map();
const readyTimers = new Map();
const SESSIONS_DIR = path.join(__dirname, 'sessions');
if (!fs.existsSync(SESSIONS_DIR)) {
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
}
// 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',
'--disable-session-crashed-bubble',
'--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;
}
}
// Clean Chrome Profile Locks to prevent startup freezes
function cleanChromeLocks(sessionKey) {
const sessionPath = path.join(SESSIONS_DIR, `session-${sessionKey}`);
const lockFiles = [
path.join(sessionPath, 'SingletonLock'),
path.join(sessionPath, 'Default', 'SingletonLock'),
path.join(sessionPath, 'SingletonCookie'),
path.join(sessionPath, 'Default', 'SingletonCookie')
];
for (const lockFile of lockFiles) {
try {
if (fs.existsSync(lockFile)) {
console.log(`[CLEANUP] Removing dangling lock file for ${sessionKey}: ${lockFile}`);
fs.unlinkSync(lockFile);
}
} catch (err) {
console.warn(`[CLEANUP WARNING] Could not remove lock file for ${sessionKey} (${lockFile}):`, err.message);
}
}
}
// Kill any Chrome processes still holding this session folder
function killChromeProcessesForSession(sessionKey) {
try {
const sessionPath = path.join(SESSIONS_DIR, `session-${sessionKey}`);
execSync(
`lsof +D "${sessionPath}" 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill -9 2>/dev/null; sleep 1`,
{ stdio: 'ignore', timeout: 5000 }
);
} catch (_) {}
}
// Clear Session Folder with retry mechanism
function clearSessionFolder(sessionKey) {
const sessionPath = path.join(SESSIONS_DIR, `session-${sessionKey}`);
for (let attempt = 0; attempt < 3; attempt++) {
try {
if (fs.existsSync(sessionPath)) {
console.log(`[CLEANUP] Removing session folder for ${sessionKey} (attempt ${attempt + 1})`);
fs.rmSync(sessionPath, { recursive: true, force: true });
}
return;
} catch (err) {
console.warn(`[CLEANUP] Attempt ${attempt + 1} failed for ${sessionKey}: ${err.message}`);
if (attempt < 2) {
killChromeProcessesForSession(sessionKey);
}
}
}
}
async function sendWebhook(webhook_url, payload) {
try {
console.log(`[Webhook] Sending to ${webhook_url} | state=${payload.state}`);
const response = await axios.post(webhook_url, payload, {
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': process.env.WEBHOOK_SECRET || ''
},
timeout: 10000
});
console.log(`[Webhook] ✅ Success | HTTP ${response.status}`);
} catch (err) {
console.error(`[Webhook] ❌ Failed: ${err.message}`);
}
}
async function startSession(session_key, webhook_url) {
if (sessions.has(session_key)) {
return sessions.get(session_key);
}
console.log(`[Session] Starting ${session_key} → webhook: ${webhook_url}`);
// Clean dangling locks from previous crashes before launching Chrome
cleanChromeLocks(session_key);
const client = new Client({
authStrategy: new LocalAuth({
clientId: session_key,
dataPath: SESSIONS_DIR
}),
puppeteer: puppeteerConfig
});
sessions.set(session_key, client);
client.on('qr', async (qr) => {
console.log(`[QR] Generated for ${session_key}`);
await sendWebhook(webhook_url, {
session_key,
state: 'waiting_qr',
qr_code: qr
});
});
client.on('authenticated', () => {
console.log(`[Session] ${session_key} authenticated successfully.`);
});
client.on('auth_failure', async (reason) => {
console.error(`[Session] ${session_key} auth failure:`, reason);
await sendWebhook(webhook_url, {
session_key,
state: 'auth_failure',
reason: String(reason)
});
});
client.on('ready', async () => {
console.log(`[Session] ${session_key} is ready and connected.`);
if (readyTimers.has(session_key)) {
clearTimeout(readyTimers.get(session_key));
readyTimers.delete(session_key);
}
const phoneNumber = client.info.wid.user;
await sendWebhook(webhook_url, {
session_key,
state: 'connected',
phone: phoneNumber
});
});
client.on('disconnected', async (reason) => {
console.warn(`[Session] ${session_key} disconnected! Reason:`, reason);
await sendWebhook(webhook_url, {
session_key,
state: 'disconnected',
reason: reason
});
sessions.delete(session_key);
if (readyTimers.has(session_key)) {
clearTimeout(readyTimers.get(session_key));
readyTimers.delete(session_key);
}
killChromeProcessesForSession(session_key);
try { client.destroy(); } catch (_) {}
clearSessionFolder(session_key);
});
// Ready timeout: if ready doesn't fire within 90s, destroy and restart
const readyTimeout = setTimeout(async () => {
if (sessions.get(session_key) === client && (!client.info || !client.info.wid)) {
console.warn(`[Session] ${session_key} timeout: ready not fired in 90s, restarting...`);
sessions.delete(session_key);
readyTimers.delete(session_key);
killChromeProcessesForSession(session_key);
try { client.destroy(); } catch (_) {}
clearSessionFolder(session_key);
await startSession(session_key, webhook_url);
}
}, 90000);
readyTimers.set(session_key, readyTimeout);
// Handle Incoming Messages to Webhook
client.on('message', async (msg) => {
if (msg.fromMe) return;
const senderPhone = msg.from.split('@')[0];
const contact = await msg.getContact();
const senderName = contact.name || contact.pushname || '';
let audioBase64 = null;
let imageBase64 = null;
let mimeType = null;
let duration = null;
if (msg.hasMedia) {
try {
const media = await msg.downloadMedia();
if (media) {
if (media.mimetype.startsWith('audio/')) {
audioBase64 = media.data;
mimeType = media.mimetype;
} else if (media.mimetype.startsWith('image/')) {
imageBase64 = media.data;
mimeType = media.mimetype;
}
}
} catch (e) {
console.error('Failed to download media:', e.message);
}
}
await sendWebhook(webhook_url, {
session_key,
state: 'message_received',
message: {
id: msg.id.id,
phone: senderPhone,
name: senderName,
body: msg.body,
audio: audioBase64,
mimeType: mimeType,
duration: duration,
image: imageBase64,
imageMimeType: mimeType,
timestamp: msg.timestamp
}
});
});
client.initialize().catch(err => {
console.error(`[Session] ${session_key} failed to initialize:`, err.message);
sessions.delete(session_key);
});
return client;
}
async function disconnectSession(session_key) {
const client = sessions.get(session_key);
if (client) {
if (readyTimers.has(session_key)) {
clearTimeout(readyTimers.get(session_key));
readyTimers.delete(session_key);
}
// Kill Chrome processes first to release file locks
killChromeProcessesForSession(session_key);
try {
await client.logout();
} catch (err) {
console.warn(`[Session] Logout error for ${session_key}: ${err.message}`);
}
try {
client.destroy();
} catch (_) {}
sessions.delete(session_key);
}
clearSessionFolder(session_key);
}
async function checkContact(session_key, phone) {
const client = sessions.get(session_key);
if (!client) throw new Error('Session not active');
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
const isRegistered = await client.isRegisteredUser(chatId);
return { exists: isRegistered };
}
async function sendMessage(session_key, phone, message, media_url, audio, mimetype, image) {
const client = sessions.get(session_key);
if (!client) throw new Error('Session not active');
const chatId = phone.includes('@') ? phone : `${phone}@c.us`;
let content = message || '';
let options = {};
if (audio || image) {
const base64Data = audio || image;
const mt = mimetype || (audio ? 'audio/ogg' : 'image/jpeg');
let cleanBase64 = base64Data.trim();
if (cleanBase64.includes(';base64,')) {
cleanBase64 = cleanBase64.split(';base64,')[1];
}
const media = new MessageMedia(mt, cleanBase64, 'file');
content = media;
if (audio) {
options.sendAudioAsVoice = true;
}
}
// Bypass WhatsApp Web's broken sendSeen evaluation
options.sendSeen = false;
const sentMsg = await client.sendMessage(chatId, content, options);
return { messageId: sentMsg.id.id, status: 'sent' };
}
function getActiveSessions() {
return Array.from(sessions.keys());
}
function isSessionReady(session_key) {
const client = sessions.get(session_key);
return !!(client && client.info && client.info.wid);
}
module.exports = {
startSession,
disconnectSession,
sendMessage,
getActiveSessions,
checkContact,
isSessionReady
};