From 1c40e6a0e827d8d82af1e01cd3aabc7651c1e2d7 Mon Sep 17 00:00:00 2001 From: oxpoe Date: Thu, 5 Nov 2026 01:27:33 +0700 Subject: [PATCH] feat(keamanan): security watchdog brute-force + soundbox TTS (port upstream) - securityWatchdogService: deteksi brute force login admin/kasir/teknisi/kolektor & log MikroTik, blokir IP 15 menit, alert suara + WA/Telegram - soundboxService + voiceTtsService (TTS Google Translate, cache) - hook login admin/teknisi/kolektor, hook soundbox saat invoice lunas - route /admin/api/soundbox/poll dan /test --- routes/adminPortal.js | 48 +++- routes/collectorPortal.js | 20 +- routes/techPortal.js | 20 +- services/billingService.js | 13 + services/securityWatchdogService.js | 369 ++++++++++++++++++++++++++++ services/soundboxService.js | 85 +++++++ services/voiceTtsService.js | 190 ++++++++++++++ 7 files changed, 742 insertions(+), 3 deletions(-) create mode 100644 services/securityWatchdogService.js create mode 100644 services/soundboxService.js create mode 100644 services/voiceTtsService.js diff --git a/routes/adminPortal.js b/routes/adminPortal.js index d96c41a..136ade9 100644 --- a/routes/adminPortal.js +++ b/routes/adminPortal.js @@ -56,6 +56,7 @@ const promoUpload = multer({ const backupSvc = require('../services/backupService'); const monitoringSvc = require('../services/monitoringService'); const inventorySvc = require('../services/inventoryService'); +const securityWatchdog = require('../services/securityWatchdogService'); const auditSvc = require('../services/auditTrailService'); const whatsappService = require('../services/whatsappService'); const { parseMikhmonOnLogin } = require('../utils/mikhmonParser'); @@ -554,9 +555,19 @@ router.get('/login', (req, res) => { res.render('admin/login', { title: 'Admin Login', company: company(), error: null }); }); -router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { +router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => { + const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1'; + if (securityWatchdog.isIpBlocked(ip)) { + return res.render('admin/login', { + title: 'Admin Login', + company: company(), + error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.' + }); + } + const { username, password } = req.body; if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) { + await securityWatchdog.recordAppLoginAttempt({ type: 'admin', username, ip, success: true }); req.session.isAdmin = true; req.session.adminUser = username; return res.redirect('/admin'); @@ -565,6 +576,7 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), // Check Cashier const cashier = adminSvc.authenticateCashier(username, password); if (cashier) { + await securityWatchdog.recordAppLoginAttempt({ type: 'cashier', username, ip, success: true }); req.session.isCashier = true; req.session.cashierId = cashier.id; req.session.cashierName = cashier.name; @@ -572,6 +584,13 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), return res.redirect('/admin'); } + await securityWatchdog.recordAppLoginAttempt({ + type: 'admin', + username: username || '', + ip, + success: false, + userAgent: req.get('user-agent') + }); res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' }); }); @@ -5209,6 +5228,33 @@ router.get('/api/inventory/serials/:itemId', requireAdminSession, (req, res) => } }); +router.get('/api/soundbox/poll', requireAdminSession, (req, res) => { + try { + const soundboxSvc = require('../services/soundboxService'); + const events = soundboxSvc.getLatestEvents(Number(req.query.afterId || 0), 15); + res.json({ success: true, events }); + } catch (err) { + res.status(500).json({ success: false, message: err.message, events: [] }); + } +}); + +router.post('/api/soundbox/test', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => { + try { + const soundboxSvc = require('../services/soundboxService'); + const amount = Number(req.body?.amount || 50000); + const id = soundboxSvc.queueSoundboxEvent({ + type: 'test', + title: 'Uji Suara Kasir', + speechText: req.body?.speechText || ('Uji coba speaker kasir berhasil. Pembayaran sebesar ' + soundboxSvc.formatRupiahSpoken(amount) + ' diterima.'), + amount, + customerName: 'Admin Soundbox' + }); + res.json({ success: true, eventId: id }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => { const filters = { action: req.query.action || null, diff --git a/routes/collectorPortal.js b/routes/collectorPortal.js index 27a6fb3..34241c7 100644 --- a/routes/collectorPortal.js +++ b/routes/collectorPortal.js @@ -5,6 +5,7 @@ const { logger } = require('../config/logger'); const db = require('../config/database'); const billingSvc = require('../services/billingService'); const customerSvc = require('../services/customerService'); +const securityWatchdog = require('../services/securityWatchdogService'); const adminSvc = require('../services/adminService'); const attendanceSvc = require('../services/attendanceService'); const pdfSvc = require('../services/pdfInvoiceService'); @@ -49,11 +50,21 @@ router.get('/login', (req, res) => { res.render('collector/login', { title: 'Login Kolektor', company: company(), error: null }); }); -router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { +router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => { + const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1'; + if (securityWatchdog.isIpBlocked(ip)) { + return res.render('collector/login', { + title: 'Login Kolektor', + company: company(), + error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.' + }); + } + const username = String(req.body.username || '').trim(); const password = String(req.body.password || ''); const collector = adminSvc.authenticateCollector(username, password); if (collector) { + await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true }); req.session.isCollector = true; req.session.collectorId = collector.id; req.session.collectorName = collector.name; @@ -61,6 +72,13 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), req.session.collectorArea = collector.area || ''; return res.redirect('/collector'); } + await securityWatchdog.recordAppLoginAttempt({ + type: 'collector', + username, + ip, + success: false, + userAgent: req.get('user-agent') + }); return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' }); }); diff --git a/routes/techPortal.js b/routes/techPortal.js index 83a175d..3b14e4c 100644 --- a/routes/techPortal.js +++ b/routes/techPortal.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const techSvc = require('../services/techService'); +const securityWatchdog = require('../services/securityWatchdogService'); const customerSvc = require('../services/customerService'); const odpSvc = require('../services/odpService'); const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager'); @@ -140,15 +141,32 @@ router.get('/login', (req, res) => { res.render('tech/login', { title: 'Teknisi Login', company: company(), error: null }); }); -router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { +router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => { + const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1'; + if (securityWatchdog.isIpBlocked(ip)) { + return res.render('tech/login', { + title: 'Teknisi Login', + company: company(), + error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.' + }); + } + const { username, password } = req.body; const tech = techSvc.authenticate(username, password); if (tech) { + await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true }); req.session.isTechnician = true; req.session.techId = tech.id; req.session.techName = tech.name; return res.redirect('/tech'); } + await securityWatchdog.recordAppLoginAttempt({ + type: 'technician', + username: username || '', + ip, + success: false, + userAgent: req.get('user-agent') + }); res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' }); }); diff --git a/services/billingService.js b/services/billingService.js index aedb2da..1b41652 100644 --- a/services/billingService.js +++ b/services/billingService.js @@ -397,6 +397,19 @@ function markAsPaid(invoiceId, paidByName, notes, actor = null) { UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid' `).run(paidByName || 'Admin', finalNotes, invId); + if (result.changes > 0) { + try { + const soundboxSvc = require('./soundboxService'); + const cust = db.prepare('SELECT name FROM customers WHERE id=?').get(existing.customer_id); + soundboxSvc.queueSoundboxEvent({ + type: 'invoice', + title: 'Tagihan #' + invId, + amount: existing.amount, + customerName: cust?.name || 'Pelanggan' + }); + } catch (e) { /* soundbox opsional */ } + } + if (result.changes > 0 && existing.customer_id) { renewCustomerPrepaidValidity(existing.customer_id, 1); try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {} diff --git a/services/securityWatchdogService.js b/services/securityWatchdogService.js new file mode 100644 index 0000000..d42c432 --- /dev/null +++ b/services/securityWatchdogService.js @@ -0,0 +1,369 @@ +/** + * Service: AI Security & Brute-Force Anomaly Watchdog + * Mendeteksi anomali serangan brute force pada aplikasi billing dan router MikroTik. + * Mengumumkan peringatan darurat secara otomatis melalui Voice TTS (Soundbox) + * serta mengirim notifikasi siaga ke WhatsApp & Telegram Admin. + */ +const db = require('../config/database'); +const { logger } = require('../config/logger'); +const soundboxSvc = require('./soundboxService'); +const { getSetting } = require('../config/settingsManager'); + +// In-memory state tracking +const failedAttemptsByIp = new Map(); +const blockedIps = new Map(); // ip -> unblockTime +const mikrotikAlertCache = new Map(); // routerId:ip -> timestamp +const processedLogIdsByRouter = new Map(); // routerId -> Set of log IDs + +const WINDOW_MS = 5 * 60 * 1000; // 5 menit +const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 menit +const WARNING_THRESHOLD = 3; +const CRITICAL_THRESHOLD = 5; + +/** + * Mengonversi IP address menjadi format lisan ramah suara TTS + * Contoh: "192.168.8.50" -> "1 9 2 titik 1 6 8 titik 8 titik 5 0" + */ +function formatIpForSpeech(ip) { + if (!ip) return 'tidak diketahui'; + const cleanIp = String(ip).replace(/^::ffff:/, ''); + return cleanIp.split('.').map(octet => octet.split('').join(' ')).join(' titik '); +} + +class SecurityWatchdogService { + /** + * Cek apakah IP sedang dalam masa blokir sementara + */ + isIpBlocked(rawIp) { + const ip = String(rawIp || '').replace(/^::ffff:/, '').trim(); + if (!ip) return false; + + const unblockTime = blockedIps.get(ip); + if (unblockTime) { + if (Date.now() < unblockTime) { + return true; + } else { + blockedIps.delete(ip); + } + } + return false; + } + + /** + * Buka blokir IP secara manual + */ + unblockIp(rawIp) { + const ip = String(rawIp || '').replace(/^::ffff:/, '').trim(); + if (!ip) return false; + blockedIps.delete(ip); + failedAttemptsByIp.delete(ip); + return true; + } + + /** + * Mencatat percobaan login pada aplikasi (Admin / Kasir / Agent / Tech) + */ + async recordAppLoginAttempt({ type = 'admin', username = '', ip: rawIp = '127.0.0.1', success = false, userAgent = '' }) { + const ip = String(rawIp || '').replace(/^::ffff:/, '').trim(); + const now = Date.now(); + + // Jika login berhasil, reset kegagalan IP ini + if (success) { + failedAttemptsByIp.delete(ip); + return { allowed: true }; + } + + // Login Gagal: Ambil atau buat record tracking + let record = failedAttemptsByIp.get(ip); + if (!record || (now - record.lastAttempt > WINDOW_MS)) { + record = { + ip, + count: 1, + firstAttempt: now, + lastAttempt: now, + usernames: new Set([username]), + alerted: false + }; + } else { + record.count++; + record.lastAttempt = now; + if (username) record.usernames.add(username); + } + failedAttemptsByIp.set(ip, record); + + logger.warn(`[SecurityWatchdog] Login gagal ke ${type} dari IP ${ip} (Percobaan ke-${record.count}, target: ${username})`); + + // Cek Ambang Batas Kritis (Brute Force Detected) + if (record.count >= CRITICAL_THRESHOLD && !record.alerted) { + record.alerted = true; + blockedIps.set(ip, now + BLOCK_DURATION_MS); + + const targetList = Array.from(record.usernames).filter(Boolean).join(', ') || 'admin'; + const ipSpoken = formatIpForSpeech(ip); + + // 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN) + const speechText = `Peringatan keamanan! Terdeteksi anomali percobaan login gagal berulang kali ke aplikasi ${type} dari alamat i pe ${ipSpoken}. Sistem telah memblokir sementara akses tersebut.`; + + soundboxSvc.queueSoundboxEvent({ + type: 'security_alert', + title: 'PERINGATAN BRUTE FORCE APLIKASI', + speechText: speechText, + amount: 0, + customerName: `IP: ${ip} (Target: ${targetList})` + }); + + // 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN + const waMsg = + `🚨 *PERINGATAN KEAMANAN (BRUTE FORCE DETECTED)*\n\n` + + `Sistem mendeteksi percobaan login gagal berulang kali ke portal *${type.toUpperCase()}*:\n` + + `🌐 *Alamat IP:* \`${ip}\`\n` + + `πŸ‘€ *Target Username:* ${targetList}\n` + + `πŸ“Š *Jumlah Kegagalan:* ${record.count} kali dalam 5 menit\n` + + `⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n` + + `πŸ›‘οΈ *Status:* IP telah diblokir sementara selama 15 menit.\n\n` + + `_Peringatan suara telah dibunyikan otomatis melalui Soundbox._`; + + await this._notifyAdmin(waMsg); + + // 3. CATAT KE AUDIT TRAIL + this._recordAudit({ + action: 'BRUTE_FORCE_BLOCKED', + entity_type: 'app_login', + entity_id: ip, + details: { + portal: type, + failedCount: record.count, + targets: Array.from(record.usernames), + userAgent + }, + ip_address: ip, + user_agent: userAgent + }); + } + + return { + allowed: !this.isIpBlocked(ip), + attempts: record.count, + remaining: Math.max(0, CRITICAL_THRESHOLD - record.count) + }; + } + + /** + * Pindai log MikroTik untuk mendeteksi brute-force login SSH / Telnet / Winbox / WebFig + */ + async scanMikrotikLoginFailures() { + try { + const mt = require('./mikrotikService'); + const routers = db.prepare("SELECT id, name, host FROM routers WHERE is_active = 1").all() || []; + + for (const r of routers) { + try { + const conn = await mt.getConnection(r.id); + if (!conn || !conn.client) continue; + + // Ambil log dari MikroTik + const logs = await conn.client.menu('/log').get(); + if (!Array.isArray(logs) || logs.length === 0) continue; + + // Ambil atau buat set log ID yang sudah diproses agar tidak duplikat + let processedSet = processedLogIdsByRouter.get(r.id); + if (!processedSet) { + processedSet = new Set(); + processedLogIdsByRouter.set(r.id, processedSet); + } + + // Filter log kegagalan login + const failures = []; + for (const l of logs) { + const logId = l['.id'] || l.id; + if (processedSet.has(logId)) continue; + processedSet.add(logId); + + const msg = String(l.message || ''); + if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) { + failures.push(l); + } + } + + // Bersihkan cache log ID jika terlalu besar + if (processedSet.size > 2000) { + processedSet.clear(); + } + + if (failures.length === 0) continue; + + // Kelompokkan kegagalan berdasarkan IP penyerang + const failuresByIp = new Map(); + for (const f of failures) { + const msg = f.message || ''; + // Regex MikroTik format: "login failure for user admin from 192.168.1.50 via winbox" + const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) || + msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i); + + if (match) { + const targetUser = match[1] || 'unknown'; + const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim(); + const service = match[3] || 'router-service'; + + if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue; + + if (!failuresByIp.has(attackerIp)) { + failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service }); + } else { + const item = failuresByIp.get(attackerIp); + item.count++; + item.targets.add(targetUser); + } + } + } + + // Periksa setiap penyerang + const now = Date.now(); + for (const [attackerIp, data] of failuresByIp.entries()) { + if (data.count >= 2) { + const cacheKey = `${r.id}:${attackerIp}`; + const lastAlert = mikrotikAlertCache.get(cacheKey) || 0; + + // Debounce alert per IP router tiap 15 menit + if (now - lastAlert > 15 * 60 * 1000) { + mikrotikAlertCache.set(cacheKey, now); + + const targetsStr = Array.from(data.targets).join(', '); + const ipSpoken = formatIpForSpeech(attackerIp); + + logger.warn(`[SecurityWatchdog] Terdeteksi brute-force MikroTik '${r.name}' dari ${attackerIp} (${data.count}x via ${data.service})`); + + // 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN) + const speechText = `Peringatan darurat! Terdeteksi serangan brute force ke router MikroTik ${r.name} dari alamat i pe ${ipSpoken} melalui ${data.service}. Segera periksa keamanan router Anda.`; + + soundboxSvc.queueSoundboxEvent({ + type: 'security_alert', + title: 'PERINGATAN BRUTE FORCE MIKROTIK', + speechText: speechText, + amount: 0, + customerName: `Router: ${r.name} | IP: ${attackerIp}` + }); + + // 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN + const waMsg = + `🚨 *PERINGATAN KEAMANAN (SERANGAN MIKROTIK)*\n\n` + + `Terdeteksi serangan brute force login pada router *${r.name}* (${r.host}):\n` + + `🌐 *IP Penyerang:* \`${attackerIp}\`\n` + + `πŸ”Œ *Layanan:* ${data.service.toUpperCase()}\n` + + `πŸ‘€ *Target User:* ${targetsStr}\n` + + `πŸ“Š *Jumlah Percobaan Gagal:* ${data.count} kali\n` + + `⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n\n` + + `_Peringatan suara darurat telah dibunyikan melalui Soundbox._`; + + await this._notifyAdmin(waMsg); + + // 3. CATAT KE AUDIT TRAIL + this._recordAudit({ + action: 'ROUTER_BRUTE_FORCE', + entity_type: 'mikrotik_login', + entity_id: `${r.name}:${attackerIp}`, + details: { + router: r.name, + routerHost: r.host, + attackerIp, + service: data.service, + count: data.count, + targets: Array.from(data.targets) + }, + ip_address: attackerIp + }); + } + } + } + } catch (err) { + logger.warn(`[SecurityWatchdog] Scan router ${r.name} error: ${err.message}`); + } + } + } catch (e) { + logger.error(`[SecurityWatchdog] Scan MikroTik failures fatal: ${e.message}`); + } + } + + /** + * Ringkasan status keamanan saat ini + */ + getSecurityOverview() { + const blockedList = []; + const now = Date.now(); + for (const [ip, unblockTime] of blockedIps.entries()) { + if (now < unblockTime) { + blockedList.push({ ip, remainingSeconds: Math.ceil((unblockTime - now) / 1000) }); + } + } + + let recentAudit = []; + try { + recentAudit = db.prepare(` + SELECT * FROM audit_trail + WHERE action IN ('BRUTE_FORCE_BLOCKED', 'ROUTER_BRUTE_FORCE') + ORDER BY id DESC LIMIT 10 + `).all() || []; + } catch (_) {} + + return { + status: blockedList.length > 0 ? 'warning' : 'secure', + activeBlockedIps: blockedList, + recentAlerts: recentAudit, + timestamp: new Date().toISOString() + }; + } + + /** + * Helper: Kirim notifikasi ke WhatsApp dan Telegram Admin + */ + async _notifyAdmin(message) { + try { + const adminPhone = getSetting('admin_phone') || getSetting('company_phone'); + if (adminPhone) { + const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs'); + if (whatsappStatus && whatsappStatus.connection === 'open') { + await sendWA(adminPhone, message); + } + } + + // Telegram Admin Notification + const adminChatId = getSetting('telegram_admin_chat_id') || getSetting('telegram_chat_id'); + const botToken = getSetting('telegram_bot_token'); + if (adminChatId && botToken) { + const axios = require('axios'); + await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, { + chat_id: adminChatId, + text: message, + parse_mode: 'Markdown' + }).catch(() => {}); + } + } catch (err) { + logger.warn(`[SecurityWatchdog] Gagal kirim notifikasi siaga: ${err.message}`); + } + } + + /** + * Helper: Catat audit log + */ + _recordAudit({ action, entity_type, entity_id, details, ip_address, user_agent }) { + try { + const audit = require('./auditTrailService'); + if (audit && typeof audit.logAuditTrail === 'function') { + audit.logAuditTrail({ + action, + entity_type, + entity_id, + actor_type: 'system', + actor_id: 'security-watchdog', + actor_name: 'AI Security Watchdog', + details, + ip_address, + user_agent + }); + } + } catch (_) {} + } +} + +const securityWatchdogService = new SecurityWatchdogService(); +module.exports = securityWatchdogService; diff --git a/services/soundboxService.js b/services/soundboxService.js new file mode 100644 index 0000000..5ea3126 --- /dev/null +++ b/services/soundboxService.js @@ -0,0 +1,85 @@ +const db = require('../config/database'); +const { logger } = require('../config/logger'); + +// Pastikan tabel soundbox_events ada +try { + db.exec(` + CREATE TABLE IF NOT EXISTS soundbox_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + title TEXT NOT NULL, + speech_text TEXT NOT NULL, + amount INTEGER DEFAULT 0, + customer_name TEXT DEFAULT '', + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + CREATE INDEX IF NOT EXISTS idx_soundbox_created ON soundbox_events(created_at); + `); +} catch (e) { + logger.error('[Soundbox] Init table error: ' + e.message); +} + +function formatRupiahSpoken(n) { + const num = Number(n || 0); + return num.toLocaleString('id-ID') + ' rupiah'; +} + +/** + * Memasukkan notifikasi pembayaran masuk ke antrean Soundbox + */ +function queueSoundboxEvent({ type = 'payment', title = 'Pembayaran Masuk', speechText = '', amount = 0, customerName = '' }) { + try { + if (!speechText) { + const spokenAmt = formatRupiahSpoken(amount); + if (type === 'voucher') { + speechText = `Pembayaran voucher hotspot ${customerName ? 'paket ' + customerName : ''} sebesar ${spokenAmt} berhasil diterima.`; + } else if (type === 'invoice') { + speechText = `Pembayaran tagihan ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil lunas.`; + } else if (type === 'donation') { + speechText = `Donasi masuk sebesar ${spokenAmt} ${customerName ? 'dari ' + customerName : ''} berhasil diterima.`; + } else if (type === 'topup') { + speechText = `Top up saldo ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil masuk.`; + } else { + speechText = `Pembayaran sebesar ${spokenAmt} berhasil diterima.`; + } + } + + const res = db.prepare(` + INSERT INTO soundbox_events (type, title, speech_text, amount, customer_name, created_at) + VALUES (?, ?, ?, ?, ?, (NOW_LOCAL())) + `).run(type, title, speechText, Math.floor(amount || 0), customerName); + + // Bersihkan entri lama jika > 200 agar database tetap ramping + db.prepare('DELETE FROM soundbox_events WHERE id < (SELECT COALESCE(MAX(id), 0) - 200 FROM soundbox_events)').run(); + + logger.info(`[Soundbox] Event #${res.lastInsertRowid} (${type}): ${speechText}`); + return res.lastInsertRowid; + } catch (err) { + logger.error('[Soundbox] Queue error: ' + err.message); + return null; + } +} + +/** + * Mengambil event terbaru untuk polling browser + */ +function getLatestEvents(afterId = 0, limit = 10) { + try { + const after = Number(afterId || 0); + if (after > 0) { + return db.prepare('SELECT * FROM soundbox_events WHERE id > ? ORDER BY id ASC LIMIT ?').all(after, limit); + } else { + return db.prepare('SELECT * FROM soundbox_events ORDER BY id DESC LIMIT ?').all(limit).reverse(); + } + } catch (err) { + logger.error('[Soundbox] Fetch error: ' + err.message); + return []; + } +} + +module.exports = { + queueSoundboxEvent, + getLatestEvents, + formatRupiahSpoken +}; diff --git a/services/voiceTtsService.js b/services/voiceTtsService.js new file mode 100644 index 0000000..aa6f6c0 --- /dev/null +++ b/services/voiceTtsService.js @@ -0,0 +1,190 @@ +const https = require('https'); +const { logger } = require('../config/logger'); + +// Cache audio sederhana dalam memori (maks 50 entri) +const ttsCache = new Map(); + +/** + * Membersihkan dan menerjemahkan format teks (markdown, emoji, simbol, istilah teknis) + * menjadi bahasa Indonesia lisan yang santun, luwes, dan natural saat dibacakan oleh mesin suara TTS. + */ +function cleanTextForSpeech(text) { + if (!text || typeof text !== 'string') return ''; + let str = text; + + // 1. Hapus blok kode, backtick, link markdown, html tags + str = str.replace(/```[\s\S]*?```/g, ' '); + str = str.replace(/`([^`]+)`/g, '$1'); + str = str.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + str = str.replace(/<[^>]+>/g, ' '); + + // 2. Terjemahkan simbol mata uang & nomor + str = str.replace(/\bRp\.?\s*([0-9.,]+)\b/gi, '$1 rupiah'); + str = str.replace(/\bNo\.\s*/gi, 'nomor '); + str = str.replace(/\bNo\s+(\d+)/gi, 'nomor $1'); + + // 3. Terjemahkan nilai redaman optik & tanda minus angka + str = str.replace(/-(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, 'minus $1 desibel'); + str = str.replace(/\b(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, '$1 desibel'); + str = str.replace(/(^|\s)-(\d+(?:\.\d+)?)/g, '$1minus $2'); + + // 4. Terjemahkan singkatan & istilah teknis ke bahasa percakapan santun + str = str.replace(/\bRX\s*Power\b/gi, 'daya sinyal'); + str = str.replace(/\bTX\s*Power\b/gi, 'daya kirim'); + str = str.replace(/\bONU\b|\bONT\b/gi, 'modem'); + str = str.replace(/\bPPPoE\b/gi, 'pe pe po e'); + str = str.replace(/\bSSID\b/gi, 'nama wifi'); + str = str.replace(/\bUptime\b/gi, 'waktu aktif'); + str = str.replace(/\bIP\s*Address\b|\bIP\b/gi, 'alamat i pe'); + str = str.replace(/\bDNS\b/gi, 'de en es'); + str = str.replace(/\bAPI\b/gi, 'a pe i'); + str = str.replace(/\bWA\b/gi, 'whatsapp'); + str = str.replace(/\bQRIS\b/gi, 'kris'); + str = str.replace(/\bLOS\b/gi, 'lampu merah sinyal putus'); + str = str.replace(/\b(\d+)\s*Mbps\b/gi, '$1 mega bit per detik'); + str = str.replace(/\b(\d+)\s*Kbps\b/gi, '$1 kilo bit per detik'); + str = str.replace(/\b(\d+)\s*Gbps\b/gi, '$1 giga bit per detik'); + str = str.replace(/\b(\d+)\s*ms\b/gi, '$1 mili detik'); + + // 5. Terjemahkan simbol umum + str = str.replace(/&/g, ' dan '); + str = str.replace(/%/g, ' persen '); + str = str.replace(/\+/g, ' plus '); + str = str.replace(/=/g, ' adalah '); + str = str.replace(/@/g, ' di '); + str = str.replace(/\//g, ' atau '); + + // 6. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran warna hijau, merah, kuning, dll.) + str = str.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' '); + str = str.replace(/[πŸŸ’πŸ”΄πŸŸ‘βšͺβš«πŸŸ πŸ”΅πŸŸ£πŸŸ₯πŸŸ©πŸŸ¦πŸŸ§πŸŸ¨β­•βŒβœ…βš οΈπŸ’‘πŸ€–πŸ§ΎπŸ’³πŸ“‘πŸ“ΆπŸ”’πŸ”“πŸ”„πŸ”‘πŸ‘‰β±οΈπŸ“±πŸ”§πŸ·οΈπŸ’ΎπŸ’¬βš™οΈπŸ›‘οΈπŸ’°πŸ“‹πŸ™βœ”οΈβœ–οΈβ—€οΈβ–ΆοΈβ–²β–Όβ—†β– ]/gu, ' '); + + // 7. Hapus markdown bullets, pembatas, dan format kaku + str = str.replace(/^[β€’Β·β–ͺβ–«βž’βž”β˜…β˜†βœ“βœ•βœ–\-\*]\s+/gm, ''); + str = str.replace(/[β€’Β·β–ͺβ–«βž’βž”β˜…β˜†βœ“βœ•βœ–]/g, ' '); + str = str.replace(/^[─—\-_=]{2,}\s*$/gm, ''); + str = str.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' '); + + // 8. Hapus semua karakter selain huruf, angka, spasi, dan tanda jeda suara (. , ? !) + str = str.replace(/[^\p{L}\p{N}\s,.\?!]/gu, ' '); + + // 9. Normalisasi jeda tanda baca dan spasi + str = str.replace(/\s*([,.\?!])\s*/g, '$1 '); + str = str.replace(/,\s*,+/g, ','); + str = str.replace(/\.\s*\.+/g, '.'); + str = str.replace(/\s+/g, ' ').trim(); + + return str; +} + +/** + * Memecah teks panjang menjadi potongan kalimat (< 180 karakter) + * agar pas dengan batasan API TTS tanpa memotong kata di tengah. + */ +function splitTextIntoChunks(text, maxLen = 180) { + if (!text) return []; + const sentences = text.match(/[^.!?\n,]+[.!?\n,]*/g) || [text]; + const chunks = []; + let currentChunk = ''; + + for (const sentence of sentences) { + const trimmed = sentence.trim(); + if (!trimmed) continue; + + if (trimmed.length > maxLen) { + // Jika satu kalimat saja sudah lebih dari maxLen, pecah berdasarkan kata + const words = trimmed.split(/\s+/); + for (const word of words) { + if ((currentChunk + ' ' + word).trim().length > maxLen) { + if (currentChunk.trim()) chunks.push(currentChunk.trim()); + currentChunk = word; + } else { + currentChunk = (currentChunk + ' ' + word).trim(); + } + } + } else if ((currentChunk + ' ' + trimmed).trim().length > maxLen) { + if (currentChunk.trim()) chunks.push(currentChunk.trim()); + currentChunk = trimmed; + } else { + currentChunk = (currentChunk + ' ' + trimmed).trim(); + } + } + + if (currentChunk.trim()) { + chunks.push(currentChunk.trim()); + } + + return chunks; +} + +/** + * Download satu potongan audio MP3 dari Google Translate TTS (Bahasa Indonesia) + */ +function fetchTtsChunk(chunkText, lang = 'id') { + return new Promise((resolve, reject) => { + const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunkText)}&tl=${lang}&client=tw-ob`; + const req = https.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + }, + timeout: 10000 + }, (res) => { + if (res.statusCode !== 200) { + return reject(new Error(`TTS gagal dengan status HTTP ${res.statusCode}`)); + } + const data = []; + res.on('data', d => data.push(d)); + res.on('end', () => resolve(Buffer.concat(data))); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('TTS timeout setelah 10 detik')); + }); + }); +} + +/** + * Generate audio MP3 utuh dari teks bahasa Indonesia + */ +async function generateIndonesianSpeech(text) { + const clean = cleanTextForSpeech(text); + if (!clean) return null; + + // Cek cache + const cacheKey = clean.toLowerCase(); + if (ttsCache.has(cacheKey)) { + return ttsCache.get(cacheKey); + } + + const chunks = splitTextIntoChunks(clean, 175); + if (chunks.length === 0) return null; + + try { + const bufferList = []; + for (const chunk of chunks) { + const buf = await fetchTtsChunk(chunk, 'id'); + bufferList.push(buf); + } + + const combinedBuffer = Buffer.concat(bufferList); + + // Simpan di cache (maksimal 50 item) + if (ttsCache.size > 50) { + const firstKey = ttsCache.keys().next().value; + ttsCache.delete(firstKey); + } + ttsCache.set(cacheKey, combinedBuffer); + + return combinedBuffer; + } catch (err) { + logger.error(`[VoiceTTS] Gagal generate suara: ${err.message}`); + throw err; + } +} + +module.exports = { + cleanTextForSpeech, + splitTextIntoChunks, + generateIndonesianSpeech +};