import express from 'express'; import makeWASocket, { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from '@whiskeysockets/baileys'; import QRCode from 'qrcode'; import cors from 'cors'; import pino from 'pino'; import fs from 'fs'; const app = express(); // Hugging Face menggunakan port 7860 secara default const PORT = process.env.PORT || 7860; app.use(cors()); app.use(express.json()); // ========================================== // SISTEM PENGAMAN (API KEY) HUGGING FACE // ========================================== const API_PASSWORD = process.env.API_PASSWORD; // Diambil dari Settings -> Secrets HF const authMiddleware = (req, res, next) => { const userKey = req.headers['x-api-key']; if (!API_PASSWORD) { return res.status(500).json({ error: 'Server belum dikonfigurasi (Secret API_PASSWORD kosong)' }); } if (userKey !== API_PASSWORD) { return res.status(401).json({ error: 'Unauthorized: Password salah / tidak ada header x-api-key!' }); } next(); }; // Terapkan middleware keamanan HANYA untuk semua endpoint yang berawalan /api app.use('/api', authMiddleware); // ========================================== // VARIABEL GLOBAL & WEBHOOKS // ========================================== const sessions = new Map(); const sessionLocks = new Map(); // Kunci Mutlak Anti Race-Condition const activeBlasts = new Map(); const logger = pino({ level: 'silent' }); const WEBHOOK_URL = 'https://jghd.space/api/webhook/wa-status'; const WEBHOOK_REPORT_URL = 'https://jghd.space/api/webhook/target-status'; const WEBHOOK_INCOMING_URL = 'https://jghd.space/api/webhook/incoming-message'; // ========================================== // SISTEM LOCKING DENGAN LOG // ========================================== async function acquireLock(sessionId, actionName) { if (sessionLocks.has(sessionId)) { console.log(`[šŸ”’ LOCK] ā›” Akses DITOLAK untuk aksi [${actionName}]. Sesi ${sessionId} sedang diproses!`); return false; } console.log(`[šŸ”’ LOCK] āœ… Akses DIBERIKAN untuk aksi [${actionName}] pada sesi ${sessionId}.`); sessionLocks.set(sessionId, true); return true; } function releaseLock(sessionId) { sessionLocks.delete(sessionId); console.log(`[šŸ”“ UNLOCK] Kunci dilepas untuk sesi ${sessionId}.`); } // ========================================== // FUNGSI PEMBUNUH DENGAN LOG // ========================================== async function killSession(sessionId, reason = "unknown") { console.log(`\nšŸ’€ [KILL] Mengeksekusi killSession untuk [${sessionId}]. Alasan: ${reason}`); if (sessions.has(sessionId)) { const sock = sessions.get(sessionId); try { sock.isKilledBySystem = true; sock.ev.removeAllListeners(); // TULIKAN SOCKET sock.ws.close(); console.log(` -> āœ… Socket WebSocket berhasil ditutup.`); } catch (e) { console.log(` -> āš ļø Socket sudah tertutup sebelumnya.`); } sessions.delete(sessionId); } else { console.log(` -> ā„¹ļø Sesi tidak ada di memori Map.`); } const sessionDir = `./sessions/${sessionId}`; if (fs.existsSync(sessionDir)) { fs.rmSync(sessionDir, { recursive: true, force: true }); console.log(` -> šŸ—‘ļø Folder fisik sesi dihapus.`); } } // ========================================== // INISIALISASI KONEKSI BAILEYS V7 // ========================================== async function initWaConnection(sessionId, reqType = 'qr', phoneNumber = null, res = null) { console.log(`\nšŸš€ [INIT] Membangun sesi [${sessionId}] | Mode: ${reqType.toUpperCase()}`); const sessionDir = `./sessions/${sessionId}`; let destroyTimeout = null; if (!fs.existsSync('./sessions')) fs.mkdirSync('./sessions'); if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true }); const { state, saveCreds } = await useMultiFileAuthState(sessionDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ version, auth: state, logger, browser: ['Doctor Blast', 'Chrome', '1.0.0'], syncFullHistory: false, markOnlineOnConnect: true, // Di V7, otomatis menggunakan LID mapping di latar belakang }); sessions.set(sessionId, sock); console.log(` -> āœ… Socket berhasil disuntikkan ke memori Map.`); sock.ev.on('creds.update', saveCreds); if (reqType === 'pairing' && phoneNumber && !sock.authState.creds.me) { console.log(` -> ā³ Menunggu 3 detik untuk request kode pairing ke ${phoneNumber}...`); setTimeout(async () => { try { let code = await sock.requestPairingCode(phoneNumber); console.log(` -> šŸŽ‰ KODE PAIRING DIDAPAT: ${code}`); if (res && !res.headersSent) res.json({ status: 'success', pairingCode: code }); if (!destroyTimeout) { destroyTimeout = setTimeout(async () => { console.log(`ā³ [TIMEOUT] 2 Menit Pairing habis untuk [${sessionId}]!`); await killSession(sessionId, 'Timeout Pairing Code'); fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, status: 'inactive' }) }).catch(() => {}); }, 120000); } } catch (error) { console.log(` -> āŒ GAGAL mendapatkan Pairing Code:`, error.message); if (res && !res.headersSent) res.status(500).json({ status: 'error', message: 'Gagal mendapatkan kode' }); } }, 3000); } sock.ev.on('messages.upsert', async (m) => { const msg = m.messages[0]; if (!msg.message || msg.key.fromMe) return; // V7 menggunakan kombinasi LID / PN, fallback aman: const senderNumber = msg.key.remoteJid.split('@')[0]; let incomingText = ''; let buttonId = null; if (msg.message.interactiveResponseMessage) { try { const responseJson = JSON.parse(msg.message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson); buttonId = responseJson.id; incomingText = "[MENGKLIK TOMBOL QUICK REPLY]"; } catch (e) {} } else { incomingText = msg.message.conversation || msg.message.extendedTextMessage?.text || ''; } if (incomingText || buttonId) { console.log(`šŸ’¬ [PESAN MASUK] Sesi ${sessionId} | Dari: ${senderNumber} | Teks/ID: ${buttonId || incomingText}`); fetch(WEBHOOK_INCOMING_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, sender: senderNumber, text: incomingText, button_id: buttonId }) }).catch(() => {}); } }); sock.ev.on('connection.update', async (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { console.log(` -> šŸ“ø QR Code berhasil di-generate untuk [${sessionId}].`); if (reqType === 'qr' && res && !res.headersSent) { try { const qrBase64 = await QRCode.toDataURL(qr); res.json({ status: 'success', qr: qrBase64 }); } catch (err) {} } if (!destroyTimeout) { destroyTimeout = setTimeout(async () => { console.log(`ā³ [TIMEOUT] 2 Menit QR habis untuk [${sessionId}]!`); await killSession(sessionId, 'Timeout QR Code'); fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, status: 'inactive' }) }).catch(() => {}); }, 120000); } } if (connection === 'close') { if (destroyTimeout) { clearTimeout(destroyTimeout); destroyTimeout = null; } const isKilled = sock.isKilledBySystem || false; const statusCode = lastDisconnect?.error?.output?.statusCode; const shouldReconnect = statusCode !== DisconnectReason.loggedOut && !isKilled; console.log(`\nāŒ [DISCONNECT] Koneksi Sesi ${sessionId} terputus.`); console.log(` -> Status Code: ${statusCode}`); console.log(` -> Dibunuh Sistem (isKilled): ${isKilled}`); console.log(` -> Will Reconnect: ${shouldReconnect}`); sessions.delete(sessionId); if (shouldReconnect) { console.log(` -> šŸ”„ Auto-reconnecting dalam 5 detik...`); setTimeout(() => initWaConnection(sessionId), 5000); } else if (!isKilled) { console.log(` -> šŸ‘‹ PERMANENT LOGOUT via Device untuk [${sessionId}]`); if (fs.existsSync(sessionDir)) fs.rmSync(sessionDir, { recursive: true, force: true }); fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, status: 'inactive' }) }).catch(() => {}); } } else if (connection === 'open') { if (destroyTimeout) { clearTimeout(destroyTimeout); destroyTimeout = null; } console.log(`\nāœ… [CONNECTED] Sesi ${sessionId} STABIL & SIAP DIGUNAKAN!`); let waNumber = sock.user.id.split(':')[0]; fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, status: 'active', phone_number: waNumber }) }).catch(() => {}); } }); return sock; } // ========================================== // API ENDPOINTS // ========================================== app.delete('/api/session/:id', async (req, res) => { const sessionId = req.params.id; console.log(`\nšŸ“„ [API] REQUEST: DELETE SESSION [${sessionId}]`); await killSession(sessionId, 'Perintah Manual via API (Delete)'); res.json({ status: 'success' }); }); app.get('/api/session/:id/qr', (req, res) => { console.log(`šŸ›”ļø [BLOCKED] Menangkis tembakan otomatis dari cache lama untuk sesi [${req.params.id}]!`); return res.json({ status: 'blocked' }); }); app.get('/api/session/:id/request-qr', async (req, res) => { const sessionId = req.params.id; console.log(`\nšŸ“„ [API] REQUEST MANUAL: GENERATE QR [${sessionId}]`); if (sessions.has(sessionId) && sessions.get(sessionId).user) { console.log(` -> Sesi sudah aktif, bypass QR.`); return res.json({ status: 'connected' }); } if (!await acquireLock(sessionId, 'Generate QR')) return res.status(429).json({ status: 'error', message: 'Sedang diproses, mohon tunggu sedetik.' }); try { await killSession(sessionId, 'Reset Paksa sebelum Generate QR'); await initWaConnection(sessionId, 'qr', null, res); } finally { releaseLock(sessionId); } }); app.post('/api/session/:id/pairing', async (req, res) => { const sessionId = req.params.id; const { phoneNumber } = req.body; console.log(`\nšŸ“„ [API] REQUEST: PAIRING CODE [${sessionId}] untuk nomor ${phoneNumber}`); if (!phoneNumber) return res.status(400).json({ error: 'required' }); if (sessions.has(sessionId) && sessions.get(sessionId).user) { console.log(` -> Sesi sudah aktif, bypass Pairing.`); return res.json({ status: 'connected' }); } if (!await acquireLock(sessionId, 'Generate Pairing')) return res.status(429).json({ status: 'error', message: 'Sedang diproses, mohon tunggu sedetik.' }); try { await killSession(sessionId, 'Reset Paksa sebelum Pairing Code'); await initWaConnection(sessionId, 'pairing', phoneNumber.replace(/[^0-9]/g, ''), res); } finally { releaseLock(sessionId); } }); // ========================================== // ENDPOINT BLASTING (GRACEFUL DEGRADATION ANTI-BAN) // ========================================== app.post('/api/session/:id/send-bulk', async (req, res) => { const sessionId = req.params.id; const { targets, message, buttons, delay, is_auto_delay, user_campaign_id, image_url } = req.body; console.log(`\nšŸš€ [API] REQUEST: SEND BULK [${sessionId}] - ${targets.length} Target`); if (!sessions.has(sessionId)) return res.status(400).json({ status: 'error', message: 'Session tidak aktif.' }); const sock = sessions.get(sessionId); res.json({ status: 'processing', message: `Mulai mengirim...` }); activeBlasts.set(sessionId, true); // 1. SIAPKAN GAMBAR JIKA ADA let imageBuffer = null; if (image_url) { console.log(` šŸ“ø Mengunduh gambar dari server: ${image_url}`); try { const response = await fetch(image_url); const arrayBuffer = await response.arrayBuffer(); imageBuffer = Buffer.from(arrayBuffer); } catch (err) { console.log(` -> āš ļø Gagal mengunduh gambar: ${err.message}`); } } // 2. SUSUN PESAN FINAL (KONVERSI TOMBOL MENJADI TEKS RAPI) let finalMessage = message || ''; if (buttons && buttons.length > 0) { finalMessage += '\n\n'; buttons.forEach((btn) => { let btnType = btn.type || 'cta_url'; let btnValue = btn.value || btn.url; if (btnType === 'cta_url') { finalMessage += `šŸ”— *${btn.display_text}* :\n${btnValue}\n\n`; } else if (btnType === 'cta_copy') { finalMessage += `šŸ“‹ *${btn.display_text}* :\n${btnValue}\n\n`; } else if (btnType === 'cta_call') { finalMessage += `šŸ“ž *${btn.display_text}* :\nwa.me/${btnValue.replace(/[^0-9]/g, '')}\n\n`; } else if (btnType === 'quick_reply') { finalMessage += `šŸ’¬ *${btn.display_text}* :\nBalas dengan mengetik: *${btnValue}*\n\n`; } }); } // 3. PROSES PENGIRIMAN LOOPING for (let i = 0; i < targets.length; i++) { if (!activeBlasts.get(sessionId)) { console.log(`šŸ›‘ [BLAST] PROSES BLASTING SESI ${sessionId} DIHENTIKAN PAKSA!`); break; } let targetJid = targets[i] + '@s.whatsapp.net'; try { if (imageBuffer) { await sock.sendMessage(targetJid, { image: imageBuffer, caption: finalMessage.trim() }); } else { await sock.sendMessage(targetJid, { text: finalMessage.trim() }); } console.log(` -> [SUKSES] Pesan terkirim ke ${targets[i]}`); if (user_campaign_id) { fetch(WEBHOOK_REPORT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_campaign_id: user_campaign_id, phone_number: targets[i], status: 'sent' }) }).catch(() => {}); } } catch (err) { console.log(` -> [GAGAL] Pesan ke ${targets[i]} gagal dikirim:`, err.message); } if (i < targets.length - 1) { let delaySecs = is_auto_delay ? Math.floor(Math.random() * (15 - 5 + 1)) + 5 : parseInt(delay); if (delaySecs > 0) await new Promise(r => setTimeout(r, delaySecs * 1000)); } } activeBlasts.delete(sessionId); console.log(`šŸŽ‰ [BLAST] Blasting sesi ${sessionId} SELESAI!`); }); app.post('/api/session/:id/stop-bulk', (req, res) => { const sessionId = req.params.id; console.log(`\nšŸ›‘ [API] REQUEST: STOP BULK [${sessionId}]`); if (activeBlasts.has(sessionId) && activeBlasts.get(sessionId) === true) { activeBlasts.set(sessionId, false); return res.json({ status: 'success' }); } return res.json({ status: 'ignored' }); }); app.post('/api/session/:id/send-message', async (req, res) => { const sessionId = req.params.id; const { target, message } = req.body; console.log(`\nšŸ¤– [API] REQUEST: AUTO REPLY [${sessionId}] ke ${target}`); if (!sessions.has(sessionId)) return res.status(400).json({ error: 'Session tidak aktif' }); try { await sessions.get(sessionId).sendMessage(target + '@s.whatsapp.net', { text: message }); console.log(` -> āœ… Auto reply sukses.`); res.json({ success: true }); } catch (err) { console.log(` -> āŒ Auto reply gagal:`, err.message); res.status(500).json({ error: err.message }); } }); // Ubah binding IP menjadi '0.0.0.0' agar bisa diakses public di container Hugging Face app.listen(PORT, '0.0.0.0', () => console.log(`šŸš€ Doctor Blast Baileys Server running on port ${PORT}`));