logger

Self-hosted WhatsApp logger us...
Log | Files | Refs | Activity

commit 361f4a6abe513c8b8dbecad9158b2e7f39904a10
parent 8ef0ee5d6e2ff60dcdfa97150bd1818b57ca37b6
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Wed, 26 Aug 2026 09:56:53 +0530

Merge pull request #1 from notamitgamer/modularize

Modularize index.js into src/ modules
Diffstat:
A.gitignore | 4++++
Mindex.js | 756+++----------------------------------------------------------------------------
Asrc/auth.js | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/authState.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/cache.js | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/config.js | 18++++++++++++++++++
Asrc/firebase.js | 23+++++++++++++++++++++++
Asrc/logger.js | 25+++++++++++++++++++++++++
Asrc/routes/api.js | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/routes/auth.js | 40++++++++++++++++++++++++++++++++++++++++
Asrc/routes/logs.js | 19+++++++++++++++++++
Asrc/routes/status.js | 29+++++++++++++++++++++++++++++
Asrc/sseManager.js | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/views/pages.js | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/whatsapp.js | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 1182 insertions(+), 736 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +serviceAccountKey.json +.env diff --git a/index.js b/index.js @@ -1,26 +1,20 @@ -const { - default: makeWASocket, - DisconnectReason, - fetchLatestBaileysVersion, - BufferJSON, - initAuthCreds, - proto -} = require('@whiskeysockets/baileys'); +require('./src/logger').install(); + const express = require('express'); -const QRCode = require('qrcode'); -const pino = require('pino'); -const admin = require('firebase-admin'); -const crypto = require('crypto'); +const { PORT } = require('./src/config'); +const { warmCache } = require('./src/cache'); +const { startWhatsApp } = require('./src/whatsapp'); +const { startHeartbeat } = require('./src/sseManager'); -// --- CONFIGURATION --- -const PORT = process.env.PORT || 3000; -const AUTH_USER = process.env.AUTH_USER; -const AUTH_PASS = process.env.AUTH_PASS; +const authRoutes = require('./src/routes/auth'); +const apiRoutes = require('./src/routes/api'); +const logsRoutes = require('./src/routes/logs'); +const statusRoutes = require('./src/routes/status'); // Initialize Express const app = express(); app.use(express.urlencoded({ extended: true })); -app.use(express.json()); +app.use(express.json()); app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); @@ -33,726 +27,16 @@ app.options('*', (req, res) => { res.sendStatus(200); }); -// --- IN-MEMORY LOGGING BUFFER --- -const MAX_LOGS = 500; -const logBuffer = []; - -const originalLog = console.log; -const originalError = console.error; - -function teeLog(level, originalFn, ...args) { - const message = args.map(arg => - typeof arg === 'object' ? JSON.stringify(arg) : String(arg) - ).join(' '); - - logBuffer.push({ timestamp: Date.now(), level, message }); - if (logBuffer.length > MAX_LOGS) logBuffer.shift(); - - originalFn.apply(console, args); -} - -console.log = (...args) => teeLog('log', originalLog, ...args); -console.error = (...args) => teeLog('error', originalError, ...args); - -// --- FIREBASE SETUP --- -let serviceAccount; -try { - if (process.env.FIREBASE_SERVICE_ACCOUNT) { - serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); - } else { - serviceAccount = require('./serviceAccountKey.json'); - } - - admin.initializeApp({ - credential: admin.credential.cert(serviceAccount) - }); - console.log("System: Firebase Admin initialized successfully."); -} catch (error) { - console.error("System Error: Failed to initialize Firebase. Make sure FIREBASE_SERVICE_ACCOUNT env var is set."); - process.exit(1); -} - -const db = admin.firestore(); - -// --- IN-MEMORY CACHE --- -const EXCLUDED_JIDS = new Set(['917278779512@s.whatsapp.net', '201554426618024@lid']); - -let chatsCache = new Map(); // chatId -> chat data -let messagesCache = new Map(); // chatId -> Map(msgId -> message data) -let cacheReady = false; -let consecutiveCacheFailures = 0; - -async function warmCache() { - try { - console.log("System: Warming cache from Firestore..."); - - // 1. Fetch Chats - const chatsSnap = await db.collection('Chats').get(); - chatsSnap.forEach(doc => { - if (!EXCLUDED_JIDS.has(doc.id)) { - chatsCache.set(doc.id, { id: doc.id, ...doc.data() }); - } - }); - - // 2. Fetch all Messages via CollectionGroup - const msgsSnap = await db.collectionGroup('Messages').get(); - msgsSnap.forEach(doc => { - const chatId = doc.ref.parent.parent.id; - if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map()); - messagesCache.get(chatId).set(doc.id, doc.data()); - }); - - cacheReady = true; - consecutiveCacheFailures = 0; - console.log(`System: Cache warm. ${chatsCache.size} chats, ${msgsSnap.size} messages.`); - - // 3. Start Permanent Listeners - startPermanentListeners(); - - } catch (err) { - consecutiveCacheFailures++; - const backoff = Math.min(5000 * Math.pow(2, consecutiveCacheFailures), 300000); // Max 5 min - console.error(`System: Cache warm failed (attempt ${consecutiveCacheFailures}). Retrying in ${backoff/1000}s.`, err.message); - setTimeout(warmCache, backoff); - } -} - -function startPermanentListeners() { - // Shared Permanent Listener for Chats - db.collection('Chats').onSnapshot(snapshot => { - const changes = []; - snapshot.docChanges().forEach(change => { - if (EXCLUDED_JIDS.has(change.doc.id)) return; - const data = { id: change.doc.id, ...change.doc.data() }; - chatsCache.set(change.doc.id, data); - changes.push({ type: change.type, doc: data }); - }); - if (changes.length > 0) { - const payload = `event: update\ndata: ${JSON.stringify(changes)}\n\n`; - clients.chats.forEach(res => { try { res.write(payload); } catch(e){} }); - } - }); - - // Shared Permanent Listener for Messages - db.collectionGroup('Messages').onSnapshot(snapshot => { - snapshot.docChanges().forEach(change => { - const chatId = change.doc.ref.parent.parent.id; - if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map()); - - const data = change.doc.data(); - messagesCache.get(chatId).set(change.doc.id, data); - - const chatClients = clients.messages.get(chatId); - if (chatClients) { - const payload = `event: update\ndata: ${JSON.stringify([{ type: change.type, doc: data }])}\n\n`; - chatClients.forEach(res => { try { res.write(payload); } catch(e){} }); - } - }); - }); -} - -// --- FIRESTORE AUTH ADAPTER FOR BAILEYS --- -async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') { - const collection = db.collection(collectionName); - - const writeData = async (data, id) => { - try { - const str = JSON.stringify(data, BufferJSON.replacer); - await collection.doc(id).set({ data: str }); - } catch (err) { - console.error("System: Error writing auth state:", err.message); - } - }; - - const readData = async (id, throwOnError = false) => { - try { - const doc = await collection.doc(id).get(); - if (doc.exists) { - return JSON.parse(doc.data().data, BufferJSON.reviver); - } - } catch (err) { - console.error("System: Error reading auth state:", err.message); - if (throwOnError) throw err; - } - return null; - }; - - const removeData = async (id) => { - try { - await collection.doc(id).delete(); - } catch (err) { - console.error("System: Error removing auth state:", err.message); - } - }; - - let creds; - try { - // Pass true to strictly enforce failure up to startWhatsApp for backoff - creds = (await readData('creds', true)) || initAuthCreds(); - } catch (err) { - throw err; - } - - return { - state: { - creds, - keys: { - get: async (type, ids) => { - const data = {}; - await Promise.all(ids.map(async id => { - let value = await readData(`${type}-${id}`); - if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.fromObject(value); - } - data[id] = value; - })); - return data; - }, - set: async (data) => { - const tasks = []; - for (const category in data) { - for (const id in data[category]) { - const value = data[category][id]; - const docId = `${category}-${id}`; - if (value) { - tasks.push(writeData(value, docId)); - } else { - tasks.push(removeData(docId)); - } - } - } - await Promise.all(tasks); - } - } - }, - saveCreds: () => { - return writeData(creds, 'creds'); - }, - clearState: async () => { - await removeData('creds'); - } - }; -} - -// --- BAILEYS SETUP --- -let qrCodeData = null; -let sock = null; -let isConnected = false; - -let consecutiveAuthFailures = 0; -let consecutiveConnectFailures = 0; - -async function startWhatsApp() { - const logger = pino({ level: 'silent' }); - - let authResult; - try { - authResult = await useFirestoreAuthState(db, 'whatsapp_auth'); - } catch (err) { - consecutiveAuthFailures++; - const backoff = Math.min(5000 * Math.pow(2, consecutiveAuthFailures), 300000); // cap 5 min - console.error(`System: Auth state read failed (attempt ${consecutiveAuthFailures}). Retrying in ${backoff/1000}s.`); - setTimeout(startWhatsApp, backoff); - return; - } - consecutiveAuthFailures = 0; // Reset on success - - const { state, saveCreds, clearState } = authResult; - const { version } = await fetchLatestBaileysVersion(); - - console.log("System: Connecting to WhatsApp servers..."); - - sock = makeWASocket({ - version, - logger, - auth: state, - browser: ["WhatsApp Logger v4.2.1", "Chrome", "4.2.1"], - syncFullHistory: true - }); - - sock.ev.on('connection.update', async (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - console.log("System: No valid credentials. New QR Code generated."); - qrCodeData = qr; - isConnected = false; - } - - if (connection === 'close') { - isConnected = false; - const statusCode = lastDisconnect?.error?.output?.statusCode; - const shouldReconnect = statusCode !== DisconnectReason.loggedOut; - - if (shouldReconnect) { - consecutiveConnectFailures++; - const backoff = Math.min(5000 * Math.pow(2, consecutiveConnectFailures), 300000); // cap 5 min - console.log(`System: Connection closed (Status: ${statusCode}). Reconnecting in ${backoff/1000}s...`); - setTimeout(startWhatsApp, backoff); - } else { - console.log("System: Device Logged Out. Wiping session from Firestore."); - await clearState(); - qrCodeData = null; - consecutiveConnectFailures = 0; - startWhatsApp(); - } - } else if (connection === 'open') { - console.log("System: Connection Open and Authenticated. Firebase Auth Sync Active."); - qrCodeData = null; - isConnected = true; - consecutiveConnectFailures = 0; // Reset on success - } - }); - - sock.ev.on('creds.update', saveCreds); - - sock.ev.on('contacts.upsert', async (contacts) => { - for (const contact of contacts) { - let updateData = {}; - const displayName = contact.name || contact.notify; - - if (displayName) updateData.displayName = displayName; - - if (contact.id && contact.id.endsWith('@s.whatsapp.net')) { - updateData.phoneNumber = contact.id.split('@')[0]; - } - - const primaryId = contact.lid || contact.id; - - if (primaryId && Object.keys(updateData).length > 0) { - try { - await db.collection('Chats').doc(primaryId).set(updateData, { merge: true }); - - if (contact.lid && contact.id !== contact.lid) { - await db.collection('Chats').doc(contact.id).set(updateData, { merge: true }); - } - } catch (err) {} - } - } - }); - - sock.ev.on('messages.upsert', async ({ messages, type }) => { - if (type !== 'notify' && type !== 'append') return; - - for (const msg of messages) { - try { - if (!msg.message) continue; - - const remoteJid = msg.key.remoteJid; - if (remoteJid === 'status@broadcast') continue; - - const textContent = - msg.message.conversation || - msg.message.extendedTextMessage?.text || - msg.message.imageMessage?.caption || - msg.message.videoMessage?.caption || - ""; - - if (!textContent) continue; - - const timestamp = msg.messageTimestamp - ? (typeof msg.messageTimestamp === 'number' ? msg.messageTimestamp : msg.messageTimestamp.low) - : Math.floor(Date.now() / 1000); - - const isFromMe = msg.key.fromMe || false; - const senderName = isFromMe ? "Me" : (msg.pushName || "Unknown"); - - // 1. Ensure Chat Document Exists - await db.collection('Chats').doc(remoteJid).set({ - lastActive: timestamp, - id: remoteJid, - preview: textContent - }, { merge: true }); - - // 2. Save Message - await db.collection('Chats') - .doc(remoteJid) - .collection('Messages') - .doc(msg.key.id) - .set({ - text: textContent, - senderId: remoteJid, - senderName: senderName, - timestamp: timestamp, - fromMe: isFromMe, - id: msg.key.id - }, { merge: true }); - - } catch (err) {} - } - }); -} - -// --- AUTH UTILS --- -const SESSION_SECRET = crypto.createHash('sha256').update(AUTH_PASS || 'default').digest('hex'); - -function parseCookies(request) { - const list = {}; - const rc = request.headers.cookie; - if (rc) { - rc.split(';').forEach((cookie) => { - const parts = cookie.split('='); - list[parts.shift().trim()] = decodeURI(parts.join('=')); - }); - } - return list; -} - -const verifyLogsAccess = (req, res, next) => { - let token = req.query.token; - if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { - token = req.headers.authorization.split(' ')[1]; - } - const cookies = parseCookies(req); - - if (token === SESSION_SECRET || cookies.auth_session === SESSION_SECRET) { - return next(); - } - res.status(401).send('Unauthorized'); -}; - -// --- SSE CONNECTION MANAGER --- -const MAX_CONNECTIONS_PER_TOKEN = 15; -const activeConnections = []; -const clients = { - chats: new Set(), - messages: new Map() -}; - -function enforceConnectionCeiling(req, res, cleanupFunction) { - const token = req.query.token || req.headers.authorization?.split(' ')[1]; - activeConnections.push({ res, token, cleanup: cleanupFunction }); - - const userConns = activeConnections.filter(c => c.token === token); - if (userConns.length > MAX_CONNECTIONS_PER_TOKEN) { - const oldestIdx = activeConnections.findIndex(c => c.token === token); - if (oldestIdx > -1) { - const oldest = activeConnections[oldestIdx]; - oldest.cleanup(); - oldest.res.end(); - activeConnections.splice(oldestIdx, 1); - } - } - - res.on('close', () => { - const idx = activeConnections.findIndex(c => c.res === res); - if (idx > -1) activeConnections.splice(idx, 1); - cleanupFunction(); - }); -} - -// Global heartbeat to keep Render connections alive -setInterval(() => { - activeConnections.forEach(({ res }) => { - try { res.write(': ping\n\n'); } catch (e) {} - }); -}, 25000); - - -// --- EXPRESS ROUTES --- - -app.get('/ping', (req, res) => { - res.status(200).send('Pong'); -}); - -// Auth Middleware for APIs -const verifyApiToken = (req, res, next) => { - const authHeader = req.headers.authorization; - let token = req.query.token; - - if (authHeader && authHeader.startsWith('Bearer ')) { - token = authHeader.split(' ')[1]; - } - - if (token === SESSION_SECRET) return next(); - res.status(401).json({ error: 'Unauthorized' }); -}; - -app.post('/api/verify', (req, res) => { - const { username, password } = req.body; +startHeartbeat(); - if (username === AUTH_USER && password === AUTH_PASS) { - return res.json({ success: true, token: SESSION_SECRET }); - } else { - return res.status(401).json({ success: false }); - } -}); - -// --- API: Server Sent Events --- -app.get('/api/chats/stream', verifyApiToken, async (req, res) => { - if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - if (res.flushHeaders) res.flushHeaders(); - if (res.socket) res.socket.setNoDelay(true); - res.write('\n'); - - const cleanup = () => { - clients.chats.delete(res); - }; - - enforceConnectionCeiling(req, res, cleanup); - clients.chats.add(res); - - try { - const grouped = {}; - - chatsCache.forEach((data, docId) => { - const phone = data.phoneNumber || data.id.split('@')[0]; - - if (!grouped[phone]) { - grouped[phone] = { ...data, ids: [data.id] }; - } else { - grouped[phone].ids.push(data.id); - if ((data.lastActive || 0) > (grouped[phone].lastActive || 0)) { - grouped[phone].lastActive = data.lastActive; - grouped[phone].preview = data.preview || grouped[phone].preview; - } - if (data.customName) grouped[phone].customName = data.customName; - } - }); - - res.write(`event: initial\ndata: ${JSON.stringify(Object.values(grouped))}\n\n`); - } catch (e) { - console.error("Error sending initial chats:", e); - } -}); - -app.get('/api/messages/stream', verifyApiToken, async (req, res) => { - if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); - - const { chatId, since } = req.query; - if (!chatId) return res.status(400).send('Missing chatId'); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - if (res.flushHeaders) res.flushHeaders(); - if (res.socket) res.socket.setNoDelay(true); - res.write('\n'); - - const cleanup = () => { - const chatClients = clients.messages.get(chatId); - if (chatClients) { - chatClients.delete(res); - if (chatClients.size === 0) clients.messages.delete(chatId); - } - }; - - enforceConnectionCeiling(req, res, cleanup); - - if (!clients.messages.has(chatId)) { - clients.messages.set(chatId, new Set()); - } - clients.messages.get(chatId).add(res); - - try { - let initialMessages = []; - const chatMsgs = messagesCache.get(chatId); - - if (chatMsgs) { - const sinceTs = since ? parseInt(since, 10) : 0; - for (const msg of chatMsgs.values()) { - if (msg.timestamp > sinceTs) { - initialMessages.push(msg); - } - } - initialMessages.sort((a, b) => a.timestamp - b.timestamp); - } - - res.write(`event: initial\ndata: ${JSON.stringify(initialMessages)}\n\n`); - } catch (e) { - console.error("Error sending initial messages:", e); - } -}); - -// --- API: Standard Actions --- -app.post('/api/rename', verifyApiToken, async (req, res) => { - if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); - - const { id, customName } = req.body; - if (!id || !customName) return res.status(400).json({ error: 'Missing parameters' }); - - try { - // Fire & Forget to DB - await db.collection('Chats').doc(id).set({ customName }, { merge: true }); - - // Instant RAM update - if (chatsCache.has(id)) { - chatsCache.get(id).customName = customName; - } - - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -app.get('/api/export', verifyApiToken, async (req, res) => { - if (!cacheReady) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); - - try { - const exportData = { chats: {}, messages: {} }; - - chatsCache.forEach((data, id) => exportData.chats[id] = data); - messagesCache.forEach((msgMap, chatId) => { - exportData.messages[chatId] = Array.from(msgMap.values()); - }); - - res.json(exportData); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -// --- SYSTEM LOGS ROUTE --- -app.get('/logs', verifyLogsAccess, (req, res) => { - const wantsJson = req.query.format === 'json' || (req.headers.accept && req.headers.accept.includes('application/json')); - - if (wantsJson) { - return res.json(logBuffer); - } - - const logLines = logBuffer.map(l => { - const time = new Date(l.timestamp).toLocaleTimeString(); - const color = l.level === 'error' ? '#ff6b6b' : '#a9dc76'; - return `<div style="color: ${color}">[${time}] [${l.level.toUpperCase()}] ${l.message}</div>`; - }).join(''); - - res.send(` - <html> - <head> - <meta http-equiv="refresh" content="5"> - <title>System Logs</title> - <style> - body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; } - .log-container { background: #000; padding: 15px; border-radius: 5px; overflow-x: auto; max-width: 100%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; } - </style> - </head> - <body onload="window.scrollTo(0,document.body.scrollHeight);"> - <h2 style="color: #fff; margin-top: 0;">System Logs</h2> - <div class="log-container"> - ${logLines.length > 0 ? logLines : '<div>No logs yet...</div>'} - </div> - </body> - </html> - `); -}); - -// --- WEB UI ROUTES --- -app.get('/login', (req, res) => { - res.send(` - <html> - <body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5;"> - <form action="/login" method="POST" style="background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 300px;"> - <h2 style="margin-top: 0; text-align: center;">WhatsApp Logger <span style="font-size: 14px; font-weight: normal; color: #666;">v4.2.1</span></h2> - <div style="margin-bottom: 1rem;"> - <label style="display: block; margin-bottom: 0.5rem;">Username</label> - <input type="text" name="username" required style="width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;"> - </div> - <div style="margin-bottom: 1rem;"> - <label style="display: block; margin-bottom: 0.5rem;">Password</label> - <input type="password" name="password" required style="width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;"> - </div> - <div style="margin-bottom: 1rem;"> - <label style="display: flex; align-items: center; font-size: 0.9rem;"> - <input type="checkbox" name="remember" value="yes" style="margin-right: 0.5rem;"> - Keep me logged in for 5 mins - </label> - </div> - <button type="submit" style="width: 100%; padding: 0.75rem; background: #25D366; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer;">Login</button> - </form> - </body> - </html> - `); -}); - -app.post('/login', (req, res) => { - const { username, password, remember } = req.body; - - if (username === AUTH_USER && password === AUTH_PASS) { - let cookieSettings = 'HttpOnly; Path=/;'; - if (remember === 'yes') cookieSettings += ' Max-Age=300;'; - - res.setHeader('Set-Cookie', `auth_session=${SESSION_SECRET}; ${cookieSettings}`); - return res.redirect('/'); - } - res.status(401).send('Invalid credentials. <a href="/login">Try again</a>'); -}); - -app.get('/logout', (req, res) => { - res.setHeader('Set-Cookie', 'auth_session=; Max-Age=0; Path=/;'); - res.redirect('/login'); -}); - -const checkAuth = (req, res, next) => { - if (!AUTH_USER || !AUTH_PASS) return next(); - const cookies = parseCookies(req); - if (cookies.auth_session === SESSION_SECRET) return next(); - - if (req.path.startsWith('/api')) res.status(401).send('Unauthorized'); - else res.redirect('/login'); -}; - -app.use(checkAuth); - -app.get('/', async (req, res) => { - const logoutBtn = `<a href="/logout" style="position: absolute; top: 10px; right: 10px; padding: 8px 16px; background: #ff4444; color: white; text-decoration: none; border-radius: 4px; font-size: 14px;">Logout</a>`; - - if (isConnected) { - return res.send(` - <html> - <body style="font-family: sans-serif; text-align: center; padding-top: 50px; background-color: #f0f2f5;"> - ${logoutBtn} - <div style="background: white; padding: 40px; border-radius: 10px; display: inline-block; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"> - <h2 style="color: green;">System Operational</h2> - <p style="color: #555;">Connected to WhatsApp. State synced to Firestore.</p> - <p style="color: #999; font-size: 12px;">Back-end Service</p> - </div> - </body> - </html> - `); - } - - if (qrCodeData) { - try { - const qrImage = await QRCode.toDataURL(qrCodeData); - return res.send(` - <html> - <head><meta http-equiv="refresh" content="5"></head> - <body style="font-family: sans-serif; text-align: center; padding-top: 50px; background-color: #f0f2f5;"> - ${logoutBtn} - <div style="background: white; padding: 40px; border-radius: 10px; display: inline-block; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"> - <h2>Scan to Link</h2> - <img src="${qrImage}" alt="QR Code" /> - <p style="color: #666;">Refreshes every 5 seconds...</p> - </div> - </body> - </html> - `); - } catch (e) { - return res.send("Error generating QR."); - } - } - - return res.send(` - <html> - <head><meta http-equiv="refresh" content="2"></head> - <body style="font-family: sans-serif; text-align: center; padding-top: 50px;"> - <p>Initializing connection or restoring auth state... please wait.</p> - ${logoutBtn} - </body> - </html> - `); -}); +// --- ROUTES --- +// Unauthenticated: /ping, SSE + action endpoints (own token-based auth), and system logs +app.use(apiRoutes); +app.use(logsRoutes); +// Login/logout/verify (unauthenticated by nature) +app.use(authRoutes); +// Cookie-authenticated web UI (home/status/QR page) — must be last, applies checkAuth +app.use(statusRoutes); // --- START SERVER --- app.listen(PORT, () => { diff --git a/src/auth.js b/src/auth.js @@ -0,0 +1,60 @@ +const crypto = require('crypto'); +const { AUTH_PASS, AUTH_USER } = require('./config'); + +// --- AUTH UTILS --- +const SESSION_SECRET = crypto.createHash('sha256').update(AUTH_PASS || 'default').digest('hex'); + +function parseCookies(request) { + const list = {}; + const rc = request.headers.cookie; + if (rc) { + rc.split(';').forEach((cookie) => { + const parts = cookie.split('='); + list[parts.shift().trim()] = decodeURI(parts.join('=')); + }); + } + return list; +} + +// Auth Middleware for APIs +const verifyApiToken = (req, res, next) => { + const authHeader = req.headers.authorization; + let token = req.query.token; + + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.split(' ')[1]; + } + + if (token === SESSION_SECRET) return next(); + res.status(401).json({ error: 'Unauthorized' }); +}; + +const verifyLogsAccess = (req, res, next) => { + let token = req.query.token; + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + token = req.headers.authorization.split(' ')[1]; + } + const cookies = parseCookies(req); + + if (token === SESSION_SECRET || cookies.auth_session === SESSION_SECRET) { + return next(); + } + res.status(401).send('Unauthorized'); +}; + +const checkAuth = (req, res, next) => { + if (!AUTH_USER || !AUTH_PASS) return next(); + const cookies = parseCookies(req); + if (cookies.auth_session === SESSION_SECRET) return next(); + + if (req.path.startsWith('/api')) res.status(401).send('Unauthorized'); + else res.redirect('/login'); +}; + +module.exports = { + SESSION_SECRET, + parseCookies, + verifyApiToken, + verifyLogsAccess, + checkAuth +}; diff --git a/src/authState.js b/src/authState.js @@ -0,0 +1,86 @@ +const { BufferJSON, initAuthCreds, proto } = require('@whiskeysockets/baileys'); + +// --- FIRESTORE AUTH ADAPTER FOR BAILEYS --- +async function useFirestoreAuthState(db, collectionName = 'whatsapp_auth') { + const collection = db.collection(collectionName); + + const writeData = async (data, id) => { + try { + const str = JSON.stringify(data, BufferJSON.replacer); + await collection.doc(id).set({ data: str }); + } catch (err) { + console.error("System: Error writing auth state:", err.message); + } + }; + + const readData = async (id, throwOnError = false) => { + try { + const doc = await collection.doc(id).get(); + if (doc.exists) { + return JSON.parse(doc.data().data, BufferJSON.reviver); + } + } catch (err) { + console.error("System: Error reading auth state:", err.message); + if (throwOnError) throw err; + } + return null; + }; + + const removeData = async (id) => { + try { + await collection.doc(id).delete(); + } catch (err) { + console.error("System: Error removing auth state:", err.message); + } + }; + + let creds; + try { + // Pass true to strictly enforce failure up to startWhatsApp for backoff + creds = (await readData('creds', true)) || initAuthCreds(); + } catch (err) { + throw err; + } + + return { + state: { + creds, + keys: { + get: async (type, ids) => { + const data = {}; + await Promise.all(ids.map(async id => { + let value = await readData(`${type}-${id}`); + if (type === 'app-state-sync-key' && value) { + value = proto.Message.AppStateSyncKeyData.fromObject(value); + } + data[id] = value; + })); + return data; + }, + set: async (data) => { + const tasks = []; + for (const category in data) { + for (const id in data[category]) { + const value = data[category][id]; + const docId = `${category}-${id}`; + if (value) { + tasks.push(writeData(value, docId)); + } else { + tasks.push(removeData(docId)); + } + } + } + await Promise.all(tasks); + } + } + }, + saveCreds: () => { + return writeData(creds, 'creds'); + }, + clearState: async () => { + await removeData('creds'); + } + }; +} + +module.exports = { useFirestoreAuthState }; diff --git a/src/cache.js b/src/cache.js @@ -0,0 +1,85 @@ +const { db } = require('./firebase'); +const { EXCLUDED_JIDS } = require('./config'); +const { clients } = require('./sseManager'); + +// --- IN-MEMORY CACHE --- +let chatsCache = new Map(); // chatId -> chat data +let messagesCache = new Map(); // chatId -> Map(msgId -> message data) +let cacheReady = false; +let consecutiveCacheFailures = 0; + +async function warmCache() { + try { + console.log("System: Warming cache from Firestore..."); + + // 1. Fetch Chats + const chatsSnap = await db.collection('Chats').get(); + chatsSnap.forEach(doc => { + if (!EXCLUDED_JIDS.has(doc.id)) { + chatsCache.set(doc.id, { id: doc.id, ...doc.data() }); + } + }); + + // 2. Fetch all Messages via CollectionGroup + const msgsSnap = await db.collectionGroup('Messages').get(); + msgsSnap.forEach(doc => { + const chatId = doc.ref.parent.parent.id; + if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map()); + messagesCache.get(chatId).set(doc.id, doc.data()); + }); + + cacheReady = true; + consecutiveCacheFailures = 0; + console.log(`System: Cache warm. ${chatsCache.size} chats, ${msgsSnap.size} messages.`); + + // 3. Start Permanent Listeners + startPermanentListeners(); + + } catch (err) { + consecutiveCacheFailures++; + const backoff = Math.min(5000 * Math.pow(2, consecutiveCacheFailures), 300000); // Max 5 min + console.error(`System: Cache warm failed (attempt ${consecutiveCacheFailures}). Retrying in ${backoff / 1000}s.`, err.message); + setTimeout(warmCache, backoff); + } +} + +function startPermanentListeners() { + // Shared Permanent Listener for Chats + db.collection('Chats').onSnapshot(snapshot => { + const changes = []; + snapshot.docChanges().forEach(change => { + if (EXCLUDED_JIDS.has(change.doc.id)) return; + const data = { id: change.doc.id, ...change.doc.data() }; + chatsCache.set(change.doc.id, data); + changes.push({ type: change.type, doc: data }); + }); + if (changes.length > 0) { + const payload = `event: update\ndata: ${JSON.stringify(changes)}\n\n`; + clients.chats.forEach(res => { try { res.write(payload); } catch (e) {} }); + } + }); + + // Shared Permanent Listener for Messages + db.collectionGroup('Messages').onSnapshot(snapshot => { + snapshot.docChanges().forEach(change => { + const chatId = change.doc.ref.parent.parent.id; + if (!messagesCache.has(chatId)) messagesCache.set(chatId, new Map()); + + const data = change.doc.data(); + messagesCache.get(chatId).set(change.doc.id, data); + + const chatClients = clients.messages.get(chatId); + if (chatClients) { + const payload = `event: update\ndata: ${JSON.stringify([{ type: change.type, doc: data }])}\n\n`; + chatClients.forEach(res => { try { res.write(payload); } catch (e) {} }); + } + }); + }); +} + +module.exports = { + chatsCache, + messagesCache, + warmCache, + isCacheReady: () => cacheReady +}; diff --git a/src/config.js b/src/config.js @@ -0,0 +1,18 @@ +// --- CONFIGURATION --- +const PORT = process.env.PORT || 3000; +const AUTH_USER = process.env.AUTH_USER; +const AUTH_PASS = process.env.AUTH_PASS; +const MAX_LOGS = 500; +const MAX_CONNECTIONS_PER_TOKEN = 15; +const VERSION = '4.2.1'; +const EXCLUDED_JIDS = new Set(['917278779512@s.whatsapp.net', '201554426618024@lid']); + +module.exports = { + PORT, + AUTH_USER, + AUTH_PASS, + MAX_LOGS, + MAX_CONNECTIONS_PER_TOKEN, + VERSION, + EXCLUDED_JIDS +}; diff --git a/src/firebase.js b/src/firebase.js @@ -0,0 +1,23 @@ +const admin = require('firebase-admin'); + +// --- FIREBASE SETUP --- +let serviceAccount; +try { + if (process.env.FIREBASE_SERVICE_ACCOUNT) { + serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); + } else { + serviceAccount = require('../serviceAccountKey.json'); + } + + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount) + }); + console.log("System: Firebase Admin initialized successfully."); +} catch (error) { + console.error("System Error: Failed to initialize Firebase. Make sure FIREBASE_SERVICE_ACCOUNT env var is set."); + process.exit(1); +} + +const db = admin.firestore(); + +module.exports = { admin, db }; diff --git a/src/logger.js b/src/logger.js @@ -0,0 +1,25 @@ +const { MAX_LOGS } = require('./config'); + +// --- IN-MEMORY LOGGING BUFFER --- +const logBuffer = []; + +const originalLog = console.log; +const originalError = console.error; + +function teeLog(level, originalFn, ...args) { + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : String(arg) + ).join(' '); + + logBuffer.push({ timestamp: Date.now(), level, message }); + if (logBuffer.length > MAX_LOGS) logBuffer.shift(); + + originalFn.apply(console, args); +} + +function install() { + console.log = (...args) => teeLog('log', originalLog, ...args); + console.error = (...args) => teeLog('error', originalError, ...args); +} + +module.exports = { install, logBuffer }; diff --git a/src/routes/api.js b/src/routes/api.js @@ -0,0 +1,148 @@ +const express = require('express'); +const { db } = require('../firebase'); +const { chatsCache, messagesCache, isCacheReady } = require('../cache'); +const { clients, enforceConnectionCeiling } = require('../sseManager'); +const { verifyApiToken } = require('../auth'); + +const router = express.Router(); + +router.get('/ping', (req, res) => { + res.status(200).send('Pong'); +}); + +// --- API: Server Sent Events --- +router.get('/api/chats/stream', verifyApiToken, async (req, res) => { + if (!isCacheReady()) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + if (res.flushHeaders) res.flushHeaders(); + if (res.socket) res.socket.setNoDelay(true); + res.write('\n'); + + const cleanup = () => { + clients.chats.delete(res); + }; + + enforceConnectionCeiling(req, res, cleanup); + clients.chats.add(res); + + try { + const grouped = {}; + + chatsCache.forEach((data, docId) => { + const phone = data.phoneNumber || data.id.split('@')[0]; + + if (!grouped[phone]) { + grouped[phone] = { ...data, ids: [data.id] }; + } else { + grouped[phone].ids.push(data.id); + if ((data.lastActive || 0) > (grouped[phone].lastActive || 0)) { + grouped[phone].lastActive = data.lastActive; + grouped[phone].preview = data.preview || grouped[phone].preview; + } + if (data.customName) grouped[phone].customName = data.customName; + } + }); + + res.write(`event: initial\ndata: ${JSON.stringify(Object.values(grouped))}\n\n`); + } catch (e) { + console.error("Error sending initial chats:", e); + } +}); + +router.get('/api/messages/stream', verifyApiToken, async (req, res) => { + if (!isCacheReady()) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); + + const { chatId, since } = req.query; + if (!chatId) return res.status(400).send('Missing chatId'); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + if (res.flushHeaders) res.flushHeaders(); + if (res.socket) res.socket.setNoDelay(true); + res.write('\n'); + + const cleanup = () => { + const chatClients = clients.messages.get(chatId); + if (chatClients) { + chatClients.delete(res); + if (chatClients.size === 0) clients.messages.delete(chatId); + } + }; + + enforceConnectionCeiling(req, res, cleanup); + + if (!clients.messages.has(chatId)) { + clients.messages.set(chatId, new Set()); + } + clients.messages.get(chatId).add(res); + + try { + let initialMessages = []; + const chatMsgs = messagesCache.get(chatId); + + if (chatMsgs) { + const sinceTs = since ? parseInt(since, 10) : 0; + for (const msg of chatMsgs.values()) { + if (msg.timestamp > sinceTs) { + initialMessages.push(msg); + } + } + initialMessages.sort((a, b) => a.timestamp - b.timestamp); + } + + res.write(`event: initial\ndata: ${JSON.stringify(initialMessages)}\n\n`); + } catch (e) { + console.error("Error sending initial messages:", e); + } +}); + +// --- API: Standard Actions --- +router.post('/api/rename', verifyApiToken, async (req, res) => { + if (!isCacheReady()) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); + + const { id, customName } = req.body; + if (!id || !customName) return res.status(400).json({ error: 'Missing parameters' }); + + try { + // Fire & Forget to DB + await db.collection('Chats').doc(id).set({ customName }, { merge: true }); + + // Instant RAM update + if (chatsCache.has(id)) { + chatsCache.get(id).customName = customName; + } + + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/api/export', verifyApiToken, async (req, res) => { + if (!isCacheReady()) return res.status(503).json({ error: 'Cache still warming, retry shortly' }); + + try { + const exportData = { chats: {}, messages: {} }; + + chatsCache.forEach((data, id) => exportData.chats[id] = data); + messagesCache.forEach((msgMap, chatId) => { + exportData.messages[chatId] = Array.from(msgMap.values()); + }); + + res.json(exportData); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/src/routes/auth.js b/src/routes/auth.js @@ -0,0 +1,40 @@ +const express = require('express'); +const { AUTH_USER, AUTH_PASS } = require('../config'); +const { SESSION_SECRET } = require('../auth'); +const { loginPage, loginFailedPage } = require('../views/pages'); + +const router = express.Router(); + +router.get('/login', (req, res) => { + res.send(loginPage()); +}); + +router.post('/login', (req, res) => { + const { username, password, remember } = req.body; + + if (username === AUTH_USER && password === AUTH_PASS) { + let cookieSettings = 'HttpOnly; Path=/;'; + if (remember === 'yes') cookieSettings += ' Max-Age=300;'; + + res.setHeader('Set-Cookie', `auth_session=${SESSION_SECRET}; ${cookieSettings}`); + return res.redirect('/'); + } + res.status(401).send(loginFailedPage()); +}); + +router.get('/logout', (req, res) => { + res.setHeader('Set-Cookie', 'auth_session=; Max-Age=0; Path=/;'); + res.redirect('/login'); +}); + +router.post('/api/verify', (req, res) => { + const { username, password } = req.body; + + if (username === AUTH_USER && password === AUTH_PASS) { + return res.json({ success: true, token: SESSION_SECRET }); + } else { + return res.status(401).json({ success: false }); + } +}); + +module.exports = router; diff --git a/src/routes/logs.js b/src/routes/logs.js @@ -0,0 +1,19 @@ +const express = require('express'); +const { verifyLogsAccess } = require('../auth'); +const { logBuffer } = require('../logger'); +const { logsPage } = require('../views/pages'); + +const router = express.Router(); + +// --- SYSTEM LOGS ROUTE --- +router.get('/logs', verifyLogsAccess, (req, res) => { + const wantsJson = req.query.format === 'json' || (req.headers.accept && req.headers.accept.includes('application/json')); + + if (wantsJson) { + return res.json(logBuffer); + } + + res.send(logsPage(logBuffer)); +}); + +module.exports = router; diff --git a/src/routes/status.js b/src/routes/status.js @@ -0,0 +1,29 @@ +const express = require('express'); +const QRCode = require('qrcode'); +const { checkAuth } = require('../auth'); +const { getIsConnected, getQrCodeData } = require('../whatsapp'); +const { connectedPage, qrPage, qrErrorPage, initializingPage } = require('../views/pages'); + +const router = express.Router(); + +router.use(checkAuth); + +router.get('/', async (req, res) => { + if (getIsConnected()) { + return res.send(connectedPage()); + } + + const qrCodeData = getQrCodeData(); + if (qrCodeData) { + try { + const qrImage = await QRCode.toDataURL(qrCodeData); + return res.send(qrPage(qrImage)); + } catch (e) { + return res.send(qrErrorPage()); + } + } + + return res.send(initializingPage()); +}); + +module.exports = router; diff --git a/src/sseManager.js b/src/sseManager.js @@ -0,0 +1,41 @@ +const { MAX_CONNECTIONS_PER_TOKEN } = require('./config'); + +// --- SSE CONNECTION MANAGER --- +const activeConnections = []; +const clients = { + chats: new Set(), + messages: new Map() +}; + +function enforceConnectionCeiling(req, res, cleanupFunction) { + const token = req.query.token || req.headers.authorization?.split(' ')[1]; + activeConnections.push({ res, token, cleanup: cleanupFunction }); + + const userConns = activeConnections.filter(c => c.token === token); + if (userConns.length > MAX_CONNECTIONS_PER_TOKEN) { + const oldestIdx = activeConnections.findIndex(c => c.token === token); + if (oldestIdx > -1) { + const oldest = activeConnections[oldestIdx]; + oldest.cleanup(); + oldest.res.end(); + activeConnections.splice(oldestIdx, 1); + } + } + + res.on('close', () => { + const idx = activeConnections.findIndex(c => c.res === res); + if (idx > -1) activeConnections.splice(idx, 1); + cleanupFunction(); + }); +} + +function startHeartbeat() { + // Global heartbeat to keep Render connections alive + setInterval(() => { + activeConnections.forEach(({ res }) => { + try { res.write(': ping\n\n'); } catch (e) {} + }); + }, 25000); +} + +module.exports = { clients, enforceConnectionCeiling, startHeartbeat }; diff --git a/src/views/pages.js b/src/views/pages.js @@ -0,0 +1,421 @@ +const { VERSION } = require('../config'); + +// --- SHARED LAYOUT --- +// One small design system so every page (login, status, logs) shares the +// same responsive behavior instead of hand-rolled inline styles per page. +const BASE_STYLES = ` + :root { + --accent: #25D366; + --accent-dark: #1da851; + --bg: #f0f2f5; + --card: #ffffff; + --text: #1c1e21; + --muted: #65676b; + --border: #e4e6eb; + --danger: #ff4444; + --radius: 12px; + } + * { box-sizing: border-box; } + body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + padding: 16px; + } + .page { + max-width: 480px; + margin: 0 auto; + display: flex; + flex-direction: column; + min-height: calc(100vh - 32px); + justify-content: center; + gap: 16px; + } + .card { + background: var(--card); + border-radius: var(--radius); + box-shadow: 0 2px 10px rgba(0,0,0,0.06); + padding: 28px 24px; + width: 100%; + } + .brand { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 4px; + } + .brand-mark { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 18px; + flex-shrink: 0; + } + .brand-text { text-align: left; } + .brand-text h1 { font-size: 17px; margin: 0; line-height: 1.2; } + .brand-text .version { font-size: 12px; color: var(--muted); } + .subtitle { + text-align: center; + color: var(--muted); + font-size: 13.5px; + margin: 0 0 20px; + } + label { display: block; margin-bottom: 6px; font-size: 13.5px; font-weight: 600; } + input[type="text"], input[type="password"] { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 15px; + background: #fafafa; + } + input[type="text"]:focus, input[type="password"]:focus { + outline: none; + border-color: var(--accent); + background: white; + } + .field { margin-bottom: 14px; } + .checkbox-row { + display: flex; + align-items: center; + font-size: 13.5px; + color: var(--muted); + margin-bottom: 18px; + } + .checkbox-row input { margin-right: 8px; } + button, .btn { + width: 100%; + padding: 12px; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 15px; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; + } + button:hover, .btn:hover { background: var(--accent-dark); } + .btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + } + .btn-secondary:hover { background: #f5f5f5; } + .btn-danger { background: var(--danger); } + .error-text { color: var(--danger); font-size: 13px; margin-top: 10px; text-align: center; } + .topbar { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 480px; + margin: 0 auto 4px; + width: 100%; + } + .topbar h1 { font-size: 16px; margin: 0; } + .status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + padding: 4px 10px 4px 8px; + border-radius: 999px; + white-space: nowrap; + } + .status-badge .dot { width: 8px; height: 8px; border-radius: 50%; } + .status-connected { background: #e6f8ec; color: #1a7a3d; } + .status-connected .dot { background: #22c55e; } + .status-waiting { background: #fff6e0; color: #92650a; } + .status-waiting .dot { background: #f59e0b; } + .status-init { background: #eee; color: var(--muted); } + .status-init .dot { background: #9ca3af; } + .qr-wrap { text-align: center; margin: 6px 0 4px; } + .qr-wrap img { width: 100%; max-width: 260px; border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: white; } + .hint { color: var(--muted); font-size: 13px; text-align: center; margin: 8px 0 0; } + .section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0 0 10px; font-weight: 700; } + .link-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + text-decoration: none; + color: var(--text); + font-size: 14.5px; + font-weight: 600; + margin-bottom: 8px; + } + .link-row:hover { background: #fafafa; } + .link-row .arrow { color: var(--muted); font-weight: 400; } + .endpoint { + display: grid; + grid-template-columns: 52px 1fr; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; + } + .endpoint:last-child { border-bottom: none; } + .method { + font-weight: 700; + font-size: 11px; + padding: 3px 0; + border-radius: 5px; + text-align: center; + height: fit-content; + color: white; + } + .method-get { background: #3b82f6; } + .method-post { background: #8b5cf6; } + .endpoint-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; word-break: break-all; } + .endpoint-desc { color: var(--muted); font-size: 12.5px; margin-top: 2px; } + .endpoint-auth { display: inline-block; margin-top: 4px; font-size: 11px; color: var(--muted); background: #f2f3f5; padding: 1px 7px; border-radius: 4px; } + @media (max-width: 400px) { + .card { padding: 22px 18px; } + body { padding: 12px; } + } +`; + +function layout({ title, refresh, content, wide }) { + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> + <title>${title}</title> + ${refresh ? `<meta http-equiv="refresh" content="${refresh}">` : ''} + <style>${BASE_STYLES}${wide ? ' .page { max-width: 560px; }' : ''}</style> +</head> +<body> + ${content} +</body> +</html>`; +} + +const brand = ` + <div class="brand"> + <div class="brand-mark">W</div> + <div class="brand-text"> + <h1>WhatsApp Logger</h1> + <div class="version">v${VERSION}</div> + </div> + </div> +`; + +function logoutBtn() { + return `<a href="/logout" class="btn btn-secondary" style="width:auto; padding: 8px 16px; font-size: 13px;">Logout</a>`; +} + +// --- LOGIN PAGE --- +function loginPage() { + const content = ` + <div class="page"> + <div class="card"> + ${brand} + <p class="subtitle">Sign in to view your archived chats</p> + <form action="/login" method="POST"> + <div class="field"> + <label>Username</label> + <input type="text" name="username" autocomplete="username" required> + </div> + <div class="field"> + <label>Password</label> + <input type="password" name="password" autocomplete="current-password" required> + </div> + <label class="checkbox-row"> + <input type="checkbox" name="remember" value="yes"> + Keep me logged in for 5 mins + </label> + <button type="submit">Log in</button> + </form> + </div> + <p class="hint">Self-hosted &middot; your data never leaves your own Firebase project</p> + </div> + `; + return layout({ title: 'Login · WhatsApp Logger', content }); +} + +function loginFailedPage() { + const content = ` + <div class="page"> + <div class="card" style="text-align: center;"> + ${brand} + <p class="error-text" style="margin-bottom: 18px;">Invalid username or password.</p> + <a class="btn" href="/login">Try again</a> + </div> + </div> + `; + return layout({ title: 'Login failed · WhatsApp Logger', content }); +} + +// --- API DOCS (shown on the status page) --- +const ENDPOINTS = [ + { method: 'POST', path: '/api/verify', desc: 'Exchange username/password for a bearer token.', auth: 'none' }, + { method: 'GET', path: '/api/chats/stream', desc: 'Server-sent events stream of chat list updates.', auth: 'token' }, + { method: 'GET', path: '/api/messages/stream', desc: 'Server-sent events stream of messages for a chat (?chatId=&since=).', auth: 'token' }, + { method: 'POST', path: '/api/rename', desc: 'Set a custom name for a chat. Body: { id, customName }.', auth: 'token' }, + { method: 'GET', path: '/api/export', desc: 'Dump all cached chats and messages as JSON.', auth: 'token' }, + { method: 'GET', path: '/logs', desc: 'View server logs. Add ?format=json for raw JSON.', auth: 'token/cookie' }, + { method: 'GET', path: '/ping', desc: 'Health check. Always returns 200.', auth: 'none' } +]; + +function apiDocsSection() { + const rows = ENDPOINTS.map(e => ` + <div class="endpoint"> + <span class="method method-${e.method.toLowerCase()}">${e.method}</span> + <div> + <div class="endpoint-path">${e.path}</div> + <div class="endpoint-desc">${e.desc}</div> + <span class="endpoint-auth">auth: ${e.auth}</span> + </div> + </div> + `).join(''); + + return ` + <div class="card"> + <p class="section-title">API Endpoints</p> + ${rows} + </div> + `; +} + +// --- STATUS PAGE (unified: connected / waiting for QR scan / initializing) --- +function statusBody({ badgeHtml, innerContent }) { + return ` + <div class="page"> + <div class="topbar"> + <h1>WhatsApp Logger</h1> + ${logoutBtn()} + </div> + <div class="card"> + ${badgeHtml} + ${innerContent} + </div> + <div class="card"> + <p class="section-title">Quick links</p> + <a class="link-row" href="/logs"> + <span>View server logs</span> + <span class="arrow">&rarr;</span> + </a> + </div> + ${apiDocsSection()} + </div> + `; +} + +function connectedPage() { + const badgeHtml = `<span class="status-badge status-connected"><span class="dot"></span>Connected</span>`; + const innerContent = ` + <p style="margin: 14px 0 0; color: var(--muted); font-size: 14px;"> + Linked to WhatsApp and syncing to Firestore in real time. + </p> + `; + return layout({ + title: 'Status · WhatsApp Logger', + content: statusBody({ badgeHtml, innerContent }) + }); +} + +function qrPage(qrImage) { + const badgeHtml = `<span class="status-badge status-waiting"><span class="dot"></span>Waiting for scan</span>`; + const innerContent = ` + <div class="qr-wrap"> + <img src="${qrImage}" alt="QR Code"> + </div> + <p class="hint">Open WhatsApp &rarr; Linked Devices &rarr; Link a Device, then scan this code. Refreshes every 5 seconds.</p> + `; + return layout({ + title: 'Scan to link · WhatsApp Logger', + refresh: 5, + content: statusBody({ badgeHtml, innerContent }) + }); +} + +function qrErrorPage() { + const content = ` + <div class="page"> + <div class="card" style="text-align: center;"> + ${brand} + <p class="error-text">Couldn't generate the QR code. Refresh to try again.</p> + </div> + </div> + `; + return layout({ title: 'Error · WhatsApp Logger', content }); +} + +function initializingPage() { + const badgeHtml = `<span class="status-badge status-init"><span class="dot"></span>Initializing</span>`; + const innerContent = ` + <p style="margin: 14px 0 0; color: var(--muted); font-size: 14px;"> + Connecting to WhatsApp and restoring the saved session. This page refreshes automatically. + </p> + `; + return layout({ + title: 'Initializing · WhatsApp Logger', + refresh: 2, + content: statusBody({ badgeHtml, innerContent }) + }); +} + +// --- LOGS PAGE --- +function logsPage(logBuffer) { + const logLines = logBuffer.map(l => { + const time = new Date(l.timestamp).toLocaleTimeString(); + const color = l.level === 'error' ? '#ff6b6b' : '#a9dc76'; + return `<div style="color: ${color}">[${time}] [${l.level.toUpperCase()}] ${l.message}</div>`; + }).join(''); + + const content = ` + <div style="max-width: 100%;"> + <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;"> + <h2 style="color: #fff; margin: 0; font-size: 18px;">System Logs</h2> + <a href="/" style="color: #a9dc76; font-size: 13px; text-decoration: none;">&larr; Back to status</a> + </div> + <div class="log-container"> + ${logLines.length > 0 ? logLines : '<div>No logs yet...</div>'} + </div> + </div> + `; + + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> + <meta http-equiv="refresh" content="5"> + <title>System Logs</title> + <style> + * { box-sizing: border-box; } + body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #1e1e1e; color: #d4d4d4; padding: 16px; margin: 0; } + .log-container { background: #000; padding: 14px; border-radius: 8px; overflow-x: auto; max-width: 100%; white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.6; } + @media (max-width: 400px) { body { padding: 10px; } .log-container { padding: 10px; font-size: 12px; } } + </style> +</head> +<body onload="window.scrollTo(0,document.body.scrollHeight);"> + ${content} +</body> +</html>`; +} + +module.exports = { + logsPage, + loginPage, + loginFailedPage, + connectedPage, + qrPage, + qrErrorPage, + initializingPage +}; diff --git a/src/whatsapp.js b/src/whatsapp.js @@ -0,0 +1,163 @@ +const { + default: makeWASocket, + DisconnectReason, + fetchLatestBaileysVersion +} = require('@whiskeysockets/baileys'); +const pino = require('pino'); +const { db } = require('./firebase'); +const { useFirestoreAuthState } = require('./authState'); + +// --- BAILEYS SETUP --- +let qrCodeData = null; +let sock = null; +let isConnected = false; + +let consecutiveAuthFailures = 0; +let consecutiveConnectFailures = 0; + +async function startWhatsApp() { + const logger = pino({ level: 'silent' }); + + let authResult; + try { + authResult = await useFirestoreAuthState(db, 'whatsapp_auth'); + } catch (err) { + consecutiveAuthFailures++; + const backoff = Math.min(5000 * Math.pow(2, consecutiveAuthFailures), 300000); // cap 5 min + console.error(`System: Auth state read failed (attempt ${consecutiveAuthFailures}). Retrying in ${backoff / 1000}s.`); + setTimeout(startWhatsApp, backoff); + return; + } + consecutiveAuthFailures = 0; // Reset on success + + const { state, saveCreds, clearState } = authResult; + const { version } = await fetchLatestBaileysVersion(); + + console.log("System: Connecting to WhatsApp servers..."); + + sock = makeWASocket({ + version, + logger, + auth: state, + browser: ["WhatsApp Logger v4.2.1", "Chrome", "4.2.1"], + syncFullHistory: true + }); + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + console.log("System: No valid credentials. New QR Code generated."); + qrCodeData = qr; + isConnected = false; + } + + if (connection === 'close') { + isConnected = false; + const statusCode = lastDisconnect?.error?.output?.statusCode; + const shouldReconnect = statusCode !== DisconnectReason.loggedOut; + + if (shouldReconnect) { + consecutiveConnectFailures++; + const backoff = Math.min(5000 * Math.pow(2, consecutiveConnectFailures), 300000); // cap 5 min + console.log(`System: Connection closed (Status: ${statusCode}). Reconnecting in ${backoff / 1000}s...`); + setTimeout(startWhatsApp, backoff); + } else { + console.log("System: Device Logged Out. Wiping session from Firestore."); + await clearState(); + qrCodeData = null; + consecutiveConnectFailures = 0; + startWhatsApp(); + } + } else if (connection === 'open') { + console.log("System: Connection Open and Authenticated. Firebase Auth Sync Active."); + qrCodeData = null; + isConnected = true; + consecutiveConnectFailures = 0; // Reset on success + } + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('contacts.upsert', async (contacts) => { + for (const contact of contacts) { + let updateData = {}; + const displayName = contact.name || contact.notify; + + if (displayName) updateData.displayName = displayName; + + if (contact.id && contact.id.endsWith('@s.whatsapp.net')) { + updateData.phoneNumber = contact.id.split('@')[0]; + } + + const primaryId = contact.lid || contact.id; + + if (primaryId && Object.keys(updateData).length > 0) { + try { + await db.collection('Chats').doc(primaryId).set(updateData, { merge: true }); + + if (contact.lid && contact.id !== contact.lid) { + await db.collection('Chats').doc(contact.id).set(updateData, { merge: true }); + } + } catch (err) {} + } + } + }); + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify' && type !== 'append') return; + + for (const msg of messages) { + try { + if (!msg.message) continue; + + const remoteJid = msg.key.remoteJid; + if (remoteJid === 'status@broadcast') continue; + + const textContent = + msg.message.conversation || + msg.message.extendedTextMessage?.text || + msg.message.imageMessage?.caption || + msg.message.videoMessage?.caption || + ""; + + if (!textContent) continue; + + const timestamp = msg.messageTimestamp + ? (typeof msg.messageTimestamp === 'number' ? msg.messageTimestamp : msg.messageTimestamp.low) + : Math.floor(Date.now() / 1000); + + const isFromMe = msg.key.fromMe || false; + const senderName = isFromMe ? "Me" : (msg.pushName || "Unknown"); + + // 1. Ensure Chat Document Exists + await db.collection('Chats').doc(remoteJid).set({ + lastActive: timestamp, + id: remoteJid, + preview: textContent + }, { merge: true }); + + // 2. Save Message + await db.collection('Chats') + .doc(remoteJid) + .collection('Messages') + .doc(msg.key.id) + .set({ + text: textContent, + senderId: remoteJid, + senderName: senderName, + timestamp: timestamp, + fromMe: isFromMe, + id: msg.key.id + }, { merge: true }); + + } catch (err) {} + } + }); +} + +module.exports = { + startWhatsApp, + getQrCodeData: () => qrCodeData, + getIsConnected: () => isConnected +};
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror