'use strict'; require('dotenv').config(); // Media is encrypted into a temp file before upload. If the drive that holds // the temp folder is full, WhatsApp sends fail. MEDIA_TMP_DIR lets you point // that temp folder at a drive with space. Set it before anything is sent. const MEDIA_TMP_DIR = process.env.MEDIA_TMP_DIR || ''; if (MEDIA_TMP_DIR) { require('fs').mkdirSync(MEDIA_TMP_DIR, { recursive: true }); process.env.TMP = process.env.TEMP = process.env.TMPDIR = MEDIA_TMP_DIR; } const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const express = require('express'); const pino = require('pino'); const QRCode = require('qrcode'); const { default: makeWASocket, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, } = require('@whiskeysockets/baileys'); const db = require('./db'); // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const PORT = Number(process.env.PORT) || 3000; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; const COOLDOWN_MS = 5000; if (ADMIN_PASSWORD.length < 8) { console.error('ADMIN_PASSWORD is missing or shorter than 8 characters. Set it in .env and restart.'); process.exit(1); } const MIME = { '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.srt': 'text/plain', }; const GB = 1024 ** 3; const MB = 1024 ** 2; // --------------------------------------------------------------------------- // Temp-folder housekeeping // Baileys leaves "-enc" / "-original" files behind when a send // crashes. Remove those older than 15 minutes so they stop filling the disk. // --------------------------------------------------------------------------- const STALE_TEMP = /^(image|video|audio|document|sticker)[0-9a-z]+-(enc|original)$/i; function sweepStaleMediaTemp() { const dir = os.tmpdir(); let removed = 0; try { for (const name of fs.readdirSync(dir)) { if (!STALE_TEMP.test(name)) continue; const full = path.join(dir, name); try { if (Date.now() - fs.statSync(full).mtimeMs > 15 * 60 * 1000) { fs.unlinkSync(full); removed += 1; } } catch { /* in use or already gone */ } } } catch (e) { console.error('Temp sweep skipped:', e.message); } if (removed) console.log(`Removed ${removed} stale media temp file(s).`); } // Before sending a URL file, check the temp folder has room for it. // Returns an error message, or null when it is fine (or the size is unknown). async function checkTempSpace(url) { if (typeof fs.statfsSync !== 'function') return null; let size = 0; try { const head = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(10000) }); size = Number(head.headers.get('content-length')) || 0; } catch { /* server does not answer HEAD: try the send anyway */ } if (!size) return null; const st = fs.statfsSync(os.tmpdir()); const free = Number(st.bavail) * Number(st.bsize); const needed = size + 100 * MB; if (free >= needed) return null; return `Not enough free space for the temp folder (${os.tmpdir()}): need about ${(needed / GB).toFixed(2)} GB, free ${(free / GB).toFixed(2)} GB. Free up disk space or set MEDIA_TMP_DIR to a drive with room.`; } // --------------------------------------------------------------------------- // WhatsApp bot // --------------------------------------------------------------------------- let sock = null; let connected = false; let qrImage = null; let starting = false; let shuttingDown = false; let server = null; const cooldown = new Map(); function scheduleStart(ms) { if (shuttingDown) return; setTimeout(() => startBot().catch((e) => { console.error('Start failed:', e.message); scheduleStart(8000); }), ms); } async function startBot() { if (starting || shuttingDown) return; starting = true; try { const { state, saveCreds } = await db.useSqlAuthState(); let version; try { ({ version } = await fetchLatestBaileysVersion()); } catch { /* use library default */ } const logger = pino({ level: 'silent' }); const s = makeWASocket({ version, logger, printQRInTerminal: false, markOnlineOnConnect: false, syncFullHistory: false, auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, }); sock = s; // Only the current socket may write credentials (stops an old socket // from re-saving a session after Unlink). s.ev.on('creds.update', () => { if (s === sock) saveCreds().catch((e) => console.error('Save creds failed:', e.message)); }); s.ev.on('connection.update', async ({ connection, lastDisconnect, qr }) => { if (s !== sock) return; // old socket try { if (qr) qrImage = await QRCode.toDataURL(qr); if (connection === 'open') { connected = true; qrImage = null; console.log('WhatsApp connected.'); } if (connection === 'close') { connected = false; qrImage = null; sock = null; const code = lastDisconnect?.error?.output?.statusCode; const loggedOut = code === DisconnectReason.loggedOut; console.log(`Connection closed (code ${code}).`); if (loggedOut) await db.clearAuth(); scheduleStart(loggedOut ? 1000 : 3000); } } catch (e) { console.error('connection.update error:', e.message); } }); s.ev.on('messages.upsert', ({ messages, type }) => { if (type !== 'notify') return; for (const m of messages) onMessage(s, m).catch((e) => console.error('Message error:', e.message)); }); } finally { starting = false; } } async function onMessage(s, m) { if (!m.message || m.key.fromMe) return; const jid = m.key.remoteJid || ''; if (!/@(s\.whatsapp\.net|lid)$/.test(jid)) return; // private chats only const text = (m.message.conversation || m.message.extendedTextMessage?.text || '').trim(); // Accepts "ABC12", "get ABC12", ".get ABC12", "dl ABC12", "code ABC12" const match = text.match(/^(?:[.!/]?(?:get|dl|code)\s+)?([A-Za-z0-9_-]{3,20})$/i); if (!match) return; const item = await db.findFile(match[1]); const explicit = /^[.!/]?(get|dl|code)\s/i.test(text); if (!item) { if (explicit) await s.sendMessage(jid, { text: '❌ Code not found. Please check the code and try again.' }, { quoted: m }); return; } const now = Date.now(); if (now - (cooldown.get(jid) || 0) < COOLDOWN_MS) return; if (cooldown.size > 1000) cooldown.clear(); cooldown.set(jid, now); await s.sendMessage(jid, { text: `⏳ Sending *${item.title}*, please wait...` }, { quoted: m }); try { const problem = await checkTempSpace(item.url); if (problem) throw new Error(problem); const ext = path.extname(new URL(item.url).pathname).toLowerCase(); const caption = `📁 *${item.title}*`; if (item.type === 'video') { await s.sendMessage(jid, { video: { url: item.url }, caption, mimetype: MIME[ext] || 'video/mp4' }, { quoted: m }); } else { const fileName = `${item.title.replace(/[^\w.-]+/g, '_')}${ext}`; await s.sendMessage( jid, { document: { url: item.url }, mimetype: MIME[ext] || 'application/octet-stream', fileName, caption }, { quoted: m } ); } await db.markSent(item.code); } catch (e) { console.error(`Send failed for ${item.code}:`, e.message); await s.sendMessage(jid, { text: '⚠️ Could not send the file right now. Please try again later.' }, { quoted: m }); } } // --------------------------------------------------------------------------- // Admin API // --------------------------------------------------------------------------- const app = express(); app.set('trust proxy', 1); app.use(express.json({ limit: '100kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/health', (req, res) => res.json({ status: 'ok', whatsapp: connected })); const safeEq = (a, b) => { const x = Buffer.from(String(a)); const y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); }; const failed = new Map(); function auth(req, res, next) { const rec = failed.get(req.ip) || { n: 0, until: 0 }; if (Date.now() < rec.until) { return res.status(429).json({ error: 'Too many wrong attempts. Try again in a few minutes.' }); } if (safeEq(req.get('x-admin-key') || '', ADMIN_PASSWORD)) { failed.delete(req.ip); return next(); } rec.n += 1; if (rec.n >= 5) { rec.until = Date.now() + 10 * 60 * 1000; rec.n = 0; } failed.set(req.ip, rec); return res.status(401).json({ error: 'Wrong password.' }); } app.use('/api', auth); const wrap = (fn) => (req, res) => Promise.resolve(fn(req, res)).catch((e) => { console.error('API error:', e.message); res.status(500).json({ error: 'Server error. Check the console log.' }); }); app.get('/api/status', (req, res) => res.json({ connected, qr: qrImage })); app.post('/api/pair', wrap(async (req, res) => { const phone = String(req.body?.phone || '').replace(/\D/g, ''); if (phone.length < 10 || phone.length > 15) { return res.status(400).json({ error: 'Use country code + number, e.g. 94771234567.' }); } if (!sock) return res.status(503).json({ error: 'Bot is starting. Try again in 10 seconds.' }); if (connected || sock.authState?.creds?.registered) { return res.status(409).json({ error: 'Already linked. Use "Unlink" first to link a different number.' }); } try { const code = await sock.requestPairingCode(phone); return res.json({ code }); } catch (e) { return res.status(500).json({ error: `Could not create pairing code: ${e.message}` }); } })); app.post('/api/unlink', wrap(async (req, res) => { const old = sock; sock = null; connected = false; qrImage = null; try { await old?.logout(); } catch { /* already closed */ } try { old?.end(undefined); } catch { /* noop */ } await db.clearAuth(); scheduleStart(500); res.json({ ok: true }); })); app.get('/api/files', wrap(async (req, res) => res.json(await db.listFiles()))); app.post('/api/files', wrap(async (req, res) => { const title = String(req.body?.title || '').trim(); const url = String(req.body?.url || '').trim(); const type = req.body?.type === 'video' ? 'video' : 'document'; let code = String(req.body?.code || '').trim(); if (!title || title.length > 100) return res.status(400).json({ error: 'Title is required (max 100 characters).' }); try { const u = new URL(url); if (!['http:', 'https:'].includes(u.protocol)) throw new Error('bad protocol'); } catch { return res.status(400).json({ error: 'Enter a valid http(s) file URL.' }); } if (code) { if (!/^[A-Za-z0-9_-]{3,20}$/.test(code)) { return res.status(400).json({ error: 'Code must be 3-20 letters, numbers, - or _.' }); } if (await db.findFile(code)) return res.status(409).json({ error: 'That code is already used.' }); } else { code = await makeCode(); } try { const item = await db.addFile({ code, title, url, type }); return res.status(201).json(item); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'That code is already used.' }); throw e; } })); app.delete('/api/files/:code', wrap(async (req, res) => { const n = await db.deleteFile(req.params.code); if (n === 0) return res.status(404).json({ error: 'Code not found.' }); return res.json({ ok: true }); })); async function makeCode() { const abc = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code; do { code = Array.from(crypto.randomBytes(5), (b) => abc[b % abc.length]).join(''); } while (await db.findFile(code)); return code; } // --------------------------------------------------------------------------- // Boot + shutdown // --------------------------------------------------------------------------- // A full disk makes Baileys' temp-file stream emit an error nobody listens to, // which would kill the whole app (dashboard included). Log it and keep running. process.on('uncaughtException', (err) => { if (err.code === 'ENOSPC') { console.error('DISK FULL: a write failed with ENOSPC. Free space on the temp drive or set MEDIA_TMP_DIR.'); } else { console.error('Uncaught error:', err.message); } }); function shutdown() { shuttingDown = true; try { sock?.end(undefined); } catch { /* noop */ } const done = () => db.pool.end().catch(() => {}).finally(() => process.exit(0)); if (server) server.close(done); else done(); setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); process.on('unhandledRejection', (r) => console.error('Unhandled rejection:', r?.message || r)); (async () => { await db.init(); sweepStaleMediaTemp(); setInterval(sweepStaleMediaTemp, 30 * 60 * 1000).unref(); server = app.listen(PORT, '0.0.0.0', () => console.log(`Dashboard: http://localhost:${PORT}`)); scheduleStart(0); })().catch((e) => { console.error('Startup failed:', e.message); process.exit(1); });