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:
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 · 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">→</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 → Linked Devices → 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;">← 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
+};