const { Client, LocalAuth } = require('whatsapp-web.js'); const qrcode = require('qrcode'); const qrcodeTerminal = require('qrcode-terminal'); const fs = require('fs'); const path = require('path'); const config = require('./config'); const { sendSMS } = require('./sms'); const { sendTelegramPhoto } = require('./telegram'); let messageQueue = []; let flushTimer = null; let flushTimerStart = null; let client = null; let restartDelay = 1000; let starting = false; let restarting = false; let msgCounter = 0; let startTime = Date.now(); let totalForwarded = 0; let smsReplies = 0; let userStats = {}; let groupStats = {}; function ts() { return new Date().toLocaleString('he-IL', { hour12: false }); } function log(level, msg) { console.log(`[${ts()}] [${level}] ${msg}`); } function flushTime() { if (!flushTimerStart) return '--:--'; const t = new Date(Date.now() + Math.max(0, config.batch.intervalMs - (Date.now() - flushTimerStart))); return t.toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); } function formatBatch(queue) { const groups = {}; for (const m of queue) { if (!groups[m.group]) groups[m.group] = []; groups[m.group].push(m); } const parts = []; for (const [group, msgs] of Object.entries(groups)) { parts.push(`[đŸ‘Ĩ ${group} đŸ‘Ĩ]`); let prevSender = ''; for (const m of msgs) { if (m.showSender === false) { parts.push(''); parts.push(m.text); } else { if (prevSender && prevSender !== m.sender) parts.push(''); prevSender = m.sender; parts.push(`${m.sender}: ${m.text}`); } } } return parts.join('\n'); } function queueSize() { return messageQueue.reduce( (sum, m) => sum + m.group.length + m.sender.length + m.text.length + 6, 0 ); } function scheduleFlush() { if (flushTimer) clearTimeout(flushTimer); if (!flushTimerStart) flushTimerStart = Date.now(); flushTimer = setTimeout(flushQueue, config.batch.intervalMs); } async function flushQueue() { if (restarting) return; if (messageQueue.length === 0) return; log('FLUSH', `Flushing ${messageQueue.length} queued messages`); flushTimer = null; flushTimerStart = null; const batch = messageQueue; messageQueue = []; msgCounter = 0; const prodBatch = batch.filter((m) => !m.isTest); const testBatch = batch.filter((m) => m.isTest); if (testBatch.length) log('FLUSH', `Split: ${prodBatch.length} prod → ${config.smsGateway.recipientNumber}, ${testBatch.length} test → ${config.test.smsRecipient || config.smsGateway.recipientNumber}`); async function doFlush(subset, recipient, label) { if (subset.length === 0) return; const text = formatBatch(subset); log('FLUSH', `Sending ${label} batch (${subset.length} msgs) to ${recipient}`); try { await sendSMS(text, recipient); log('INFO', `Flushed ${subset.length} ${label} messages to ${recipient}`); for (const m of subset) { userStats[m.sender] = (userStats[m.sender] || 0) + 1; groupStats[m.group] = (groupStats[m.group] || 0) + 1; totalForwarded++; } } catch (err) { log('ERROR', `${label} flush failed for ${recipient}: ${err.message}`); messageQueue = subset.concat(messageQueue); msgCounter = messageQueue.length; } } await doFlush(prodBatch, config.smsGateway.recipientNumber, 'prod'); const testRecipient = config.test.smsRecipient || config.smsGateway.recipientNumber; await doFlush(testBatch, testRecipient, 'test'); if (messageQueue.length > 0) scheduleFlush(); } function enqueue(group, sender, text, showSender = true, isTest = false) { msgCounter++; messageQueue.push({ group, sender, text, showSender, isTest }); scheduleFlush(); const tag = isTest ? '[TEST]' : '[PROD]'; log('QUEUE', `Queue #${msgCounter} ${tag} ${sender} → ${group}: ${text.length > 80 ? text.slice(0, 80) + '...' : text}`); if (queueSize() >= config.batch.maxChars) { clearTimeout(flushTimer); flushTimer = null; flushTimerStart = null; flushQueue(); } } async function killClient() { if (!client) return; try { client.removeAllListeners(); } catch {} try { await client.destroy(); } catch {} /** * IMPORTANT: * Give Chromium time to release Windows file locks */ await new Promise(r => setTimeout(r, 3000)); client = null; } let keepAliveTimer = null; function startKeepAlive() { if (keepAliveTimer) clearInterval(keepAliveTimer); const ka = config.keepAlive; if (!ka || !ka.url) return; log('INIT', `Keep-alive ping every ${ka.intervalMs / 1000}s to ${ka.url}`); const ping = () => { fetch(ka.url).catch(() => {}); }; ping(); keepAliveTimer = setInterval(ping, ka.intervalMs); } async function startClient() { if (starting) { log('WARN', 'Already starting — skipping duplicate call'); return; } starting = true; try { restarting = true; log('INIT', 'Starting OmegaBaSMS...'); await killClient(); const allGroups = [...new Set([...config.groupNames, ...config.test.groupNames])]; log('INIT', `Groups to monitor: ${allGroups.join(', ')}`); if (config.test.groupNames.length) log('INIT', `Test groups: ${config.test.groupNames.join(', ')} → SMS to ${config.test.smsRecipient || config.smsGateway.recipientNumber}`); log('INIT', `Batch interval: ${config.batch.intervalMs / 1000}s / max ${config.batch.maxChars} chars`); log('INIT', `Forwarding SMS to: ${config.smsGateway.recipientNumber}`); if (config.telegram.botToken) { log('INIT', 'Telegram notifications enabled'); } // startKeepAlive(); log('INIT', 'Launching WhatsApp Web...'); client = new Client({ authStrategy: new LocalAuth(), puppeteer: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], protocolTimeout: 120000 // 2 minutes instead of default 30s } }); client.on('qr', async (qr) => { log('QR', 'New QR code received — scan with WhatsApp on your phone'); qrcodeTerminal.generate(qr, { small: true }); if (config.telegram.botToken) { try { const buf = await qrcode.toBuffer(qr, { width: 400 }); await sendTelegramPhoto(buf, 'WhatsApp re-auth needed - scan this QR\nhttps://wa.me/settings/linked_devices'); log('QR', 'QR photo sent to Telegram'); } catch (err) { log('ERROR', `Failed to send QR photo: ${err.message}`); } } }); client.on('ready', () => { restarting = false; restartDelay = 1000; const allGroups = [...new Set([...config.groupNames, ...config.test.groupNames])]; log('READY', 'WhatsApp connected successfully'); log('READY', `Monitoring ${allGroups.length} group(s): ${allGroups.join(', ')}`); log('READY', `Forwarding to ${config.smsGateway.recipientNumber}`); }); client.on('auth_failure', (msg) => { log('ERROR', `Auth failure: ${msg}`); }); client.on('disconnected', async (reason) => { if (starting) return; log('WARN', `Disconnected: ${reason}. Restarting in ${restartDelay / 1000}s...`); restarting = true; if (reason === 'LOGOUT') { const authDir = path.join(__dirname, '.wwebjs_auth'); if (fs.existsSync(authDir)) { try { fs.rmSync(authDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); log('WARN', 'Cleared old session data'); } catch (err) { log('ERROR', `Failed clearing auth data: ${err.message}`); } } } await new Promise((r) => setTimeout(r, restartDelay)); restartDelay = Math.min(restartDelay * 2, 30000); startClient(); }); client.on('message_create', async (message) => { try { if (restarting) return; const allowed = ['chat', 'image', 'video', 'ptt', 'audio', 'document', 'sticker']; if (!allowed.includes(message.type)) return; const chat = await message.getChat(); if (!chat.isGroup) return; const isTest = config.test.groupNames.length && config.test.groupNames.includes(chat.name); if (!config.groupNames.includes(chat.name) && !isTest) return; if (message.fromMe && !config.includeOwnMessages) return; if (isTest) log('TEST', `Message from test group "${chat.name}" by ${message.fromMe ? config.ownName : '?'}`); const contact = await message.getContact(); const sender = message.fromMe ? config.ownName : (contact.name || contact.pushname || contact.shortName || contact.number || 'Unknown'); const typeLabel = { image: '📷 Image', video: 'đŸŽĨ Video', ptt: '🎤 Voice', audio: 'đŸŽĩ Audio', document: '📄 Document', sticker: 'đŸ–ŧī¸ Sticker' }[message.type]; let body = message.body || ''; let isReply = false; if (message.hasQuotedMsg) { try { const quoted = await message.getQuotedMessage(); if (quoted) { const qContact = await quoted.getContact(); const qName = qContact.name || qContact.pushname || qContact.shortName || 'Unknown'; let qText = quoted.body || ({ image: '📷 Image', video: 'đŸŽĨ Video', ptt: '🎤 Voice', audio: 'đŸŽĩ Audio', document: '📄 Document', sticker: 'đŸ–ŧī¸ Sticker' })[quoted.type] || '[media]'; if (qText.length > 50) qText = qText.slice(0, 50) + '...'; body = `â†Šī¸ ${qName} אמר: ${qText} â†Šī¸\n${sender}: ${body}`; isReply = true; } } catch (_) {} } if (message.isForwarded) { let fwdFrom = ''; if (message.author) { try { const fwdContact = await client.getContactById(message.author); fwdFrom = fwdContact.name || fwdContact.pushname || fwdContact.shortName || ''; } catch (_) {} } body = `⏊ הו×ĸבר${fwdFrom ? ' מ' + fwdFrom : ''}⏊\n` + body; } if (message.hasMedia && config.appsScriptUrl && message.type !== 'sticker') { try { const media = await message.downloadMedia(); if (media) { const { uploadMedia } = require('./uploader'); const ext = media.mimeType ? media.mimeType.split('/')[1] || '' : ''; const link = await uploadMedia(media.data, media.mimeType, ext, media.filename); if (link) body = (body ? `${body}\n[${typeLabel}]\n[${link}]` : `[${typeLabel}]\n[${link}]`); } } catch (err) { log('ERROR', `Media upload: ${err.message}`); } } if (!body && typeLabel) body = typeLabel; if (!body) return; if (message.mentionedIds && message.mentionedIds.length > 0) { for (const id of message.mentionedIds) { const num = id.split('@')[0]; if (!body.includes(`@${num}`)) continue; try { const c = await client.getContactById(id); const name = c.name || c.pushname || c.shortName || num; body = body.replaceAll(`@${num}`, `@${name}`); } catch (_) {} } } enqueue(chat.name, sender, body, !isReply && !message.isForwarded, isTest); } catch (err) { log('ERROR', `Message handler: ${err.message}`); } }); client.initialize(); } finally { starting = false; } } const http = require('http'); function renderDashboard(clientState) { const uptime = Math.floor((Date.now() - startTime) / 1000); const h = Math.floor(uptime / 3600); const m = Math.floor((uptime % 3600) / 60); const s = uptime % 60; const uptimeStr = `${h}h ${m}m ${s}s`; const userRows = Object.entries(userStats) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => `${name}${count}` ).join(''); const groupRows = Object.entries(groupStats) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => `${name}${count}` ).join(''); const connected = clientState === 'CONNECTED'; const api = JSON.stringify({ uptime, uptimeStr, connected, totalForwarded, smsReplies, queued: messageQueue.length, flushTime: flushTime(), userStats, groupStats, }); return ` OmegaBaSMS

OmegaBaSMS

WhatsApp group messages forwarded via SMS

Status
—
Uptime
—
Forwarded
—
SMS Replies
—
Queued
—

By Sender

NameMessages

By Group

GroupMessages
`; } async function getClientState() { if (!client || restarting) return 'DISCONNECTED'; try { return await client.getState(); } catch { return 'DISCONNECTED'; } } const server = http.createServer(async (req, res) => { if (req.url === '/liveness') { const state = await getClientState(); if (state === 'CONNECTED') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } else { res.writeHead(503, { 'Content-Type': 'text/plain' }); res.end('NOT_CONNECTED'); } return; } if (req.url === '/api/stats') { const clientState = await getClientState(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ uptime: Math.floor((Date.now() - startTime) / 1000), uptimeStr: (() => { const t = Math.floor((Date.now() - startTime) / 1000); return Math.floor(t/3600)+'h '+Math.floor((t%3600)/60)+'m '+t%60+'s'; })(), connected: clientState === 'CONNECTED', clientState, totalForwarded, smsReplies, queued: messageQueue.length, flushTime: flushTime(), userStats, groupStats, })); return; } if (req.url.startsWith('/api/webhook/sms') && req.method === 'POST') { const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const token = urlObj.searchParams.get('token'); if (config.smsWebhookToken && token !== config.smsWebhookToken) { res.writeHead(403); res.end('Forbidden'); return; } let raw = ''; req.on('data', (chunk) => (raw += chunk)); await new Promise((r) => req.on('end', r)); let data; try { data = JSON.parse(raw); } catch { res.writeHead(400); res.end('Invalid JSON'); return; } const from = data.from || 'Unknown'; const msg = (data.message || '').trim(); if (!msg) { res.writeHead(400); res.end('Empty message'); return; } const colonIdx = msg.indexOf(':'); if (colonIdx === -1) { res.writeHead(400); res.end('Format: : '); return; } const groupName = msg.slice(0, colonIdx).trim(); const replyText = msg.slice(colonIdx + 1).trim(); if (!replyText) { res.writeHead(400); res.end('Empty reply'); return; } const isTest = config.test.smsFrom && from === config.test.smsFrom; log('SMS', `Incoming webhook from ${from}${isTest ? ' (test mode)' : ''}: "${msg}"`); const candidates = isTest && config.test.groupNames.length ? config.test.groupNames : config.groupNames; const matchedGroup = candidates.find((g) => g.toLowerCase() === groupName.toLowerCase()); if (!matchedGroup) { log('SMS', `Group "${groupName}" not found in ${candidates.join(', ')}`); res.writeHead(404); res.end(`Group "${groupName}" not found`); return; } try { const chats = await client.getChats(); const chat = chats.find((c) => c.isGroup && c.name === matchedGroup); if (!chat) { res.writeHead(404); res.end(`Chat "${matchedGroup}" not found on WhatsApp`); return; } const smsBody = `[${from}]\n ${replyText}`; log('SMS', `Sending${isTest ? ' [TEST]' : ''} reply to WhatsApp group "${matchedGroup}": ${smsBody}`); await chat.sendMessage(smsBody); smsReplies++; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, group: matchedGroup })); } catch (err) { log('ERROR', `SMS reply failed: ${err.message}`); res.writeHead(500); res.end(`Failed: ${err.message}`); } return; } const clientState = await getClientState(); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderDashboard(clientState)); }); const PORT = process.env.PORT || 3000; server.listen(PORT, () => log('INIT', `Dashboard on http://0.0.0.0:${PORT}`)); /** Recoverable error detection */ function shouldRestart(err) { const msg = (err && err.message) || String(err); return msg.includes('detached') || msg.includes('Execution context was destroyed') || msg.includes('Target closed') || msg.includes('Session closed') || msg.includes('Navigation failed') || msg.includes('Protocol error'); } process.on('uncaughtException', (err) => { if (shouldRestart(err)) { log('WARN', `Recoverable: ${err.message}. Restarting...`); startClient(); return; } log('FATAL', err.message); process.exit(1); }); process.on('unhandledRejection', (err) => { if (shouldRestart(err)) { log('WARN', `Recoverable: ${err.message}. Restarting...`); startClient(); return; } log('FATAL', `Unhandled rejection: ${err.message}`); process.exit(1); }); startClient();