MusicGen / server.js
000x's picture
Upload 16 files
3760017 verified
Raw History Blame Contribute Delete
13.4 kB
'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 "<type><id>-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);
});