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
This commit is contained in:
@@ -56,6 +56,7 @@ const promoUpload = multer({
|
|||||||
const backupSvc = require('../services/backupService');
|
const backupSvc = require('../services/backupService');
|
||||||
const monitoringSvc = require('../services/monitoringService');
|
const monitoringSvc = require('../services/monitoringService');
|
||||||
const inventorySvc = require('../services/inventoryService');
|
const inventorySvc = require('../services/inventoryService');
|
||||||
|
const securityWatchdog = require('../services/securityWatchdogService');
|
||||||
const auditSvc = require('../services/auditTrailService');
|
const auditSvc = require('../services/auditTrailService');
|
||||||
const whatsappService = require('../services/whatsappService');
|
const whatsappService = require('../services/whatsappService');
|
||||||
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
|
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 });
|
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;
|
const { username, password } = req.body;
|
||||||
if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) {
|
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.isAdmin = true;
|
||||||
req.session.adminUser = username;
|
req.session.adminUser = username;
|
||||||
return res.redirect('/admin');
|
return res.redirect('/admin');
|
||||||
@@ -565,6 +576,7 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
|
|||||||
// Check Cashier
|
// Check Cashier
|
||||||
const cashier = adminSvc.authenticateCashier(username, password);
|
const cashier = adminSvc.authenticateCashier(username, password);
|
||||||
if (cashier) {
|
if (cashier) {
|
||||||
|
await securityWatchdog.recordAppLoginAttempt({ type: 'cashier', username, ip, success: true });
|
||||||
req.session.isCashier = true;
|
req.session.isCashier = true;
|
||||||
req.session.cashierId = cashier.id;
|
req.session.cashierId = cashier.id;
|
||||||
req.session.cashierName = cashier.name;
|
req.session.cashierName = cashier.name;
|
||||||
@@ -572,6 +584,13 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
|
|||||||
return res.redirect('/admin');
|
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' });
|
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) => {
|
router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => {
|
||||||
const filters = {
|
const filters = {
|
||||||
action: req.query.action || null,
|
action: req.query.action || null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { logger } = require('../config/logger');
|
|||||||
const db = require('../config/database');
|
const db = require('../config/database');
|
||||||
const billingSvc = require('../services/billingService');
|
const billingSvc = require('../services/billingService');
|
||||||
const customerSvc = require('../services/customerService');
|
const customerSvc = require('../services/customerService');
|
||||||
|
const securityWatchdog = require('../services/securityWatchdogService');
|
||||||
const adminSvc = require('../services/adminService');
|
const adminSvc = require('../services/adminService');
|
||||||
const attendanceSvc = require('../services/attendanceService');
|
const attendanceSvc = require('../services/attendanceService');
|
||||||
const pdfSvc = require('../services/pdfInvoiceService');
|
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 });
|
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 username = String(req.body.username || '').trim();
|
||||||
const password = String(req.body.password || '');
|
const password = String(req.body.password || '');
|
||||||
const collector = adminSvc.authenticateCollector(username, password);
|
const collector = adminSvc.authenticateCollector(username, password);
|
||||||
if (collector) {
|
if (collector) {
|
||||||
|
await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true });
|
||||||
req.session.isCollector = true;
|
req.session.isCollector = true;
|
||||||
req.session.collectorId = collector.id;
|
req.session.collectorId = collector.id;
|
||||||
req.session.collectorName = collector.name;
|
req.session.collectorName = collector.name;
|
||||||
@@ -61,6 +72,13 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
|
|||||||
req.session.collectorArea = collector.area || '';
|
req.session.collectorArea = collector.area || '';
|
||||||
return res.redirect('/collector');
|
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!' });
|
return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const techSvc = require('../services/techService');
|
const techSvc = require('../services/techService');
|
||||||
|
const securityWatchdog = require('../services/securityWatchdogService');
|
||||||
const customerSvc = require('../services/customerService');
|
const customerSvc = require('../services/customerService');
|
||||||
const odpSvc = require('../services/odpService');
|
const odpSvc = require('../services/odpService');
|
||||||
const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
|
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 });
|
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 { username, password } = req.body;
|
||||||
const tech = techSvc.authenticate(username, password);
|
const tech = techSvc.authenticate(username, password);
|
||||||
if (tech) {
|
if (tech) {
|
||||||
|
await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true });
|
||||||
req.session.isTechnician = true;
|
req.session.isTechnician = true;
|
||||||
req.session.techId = tech.id;
|
req.session.techId = tech.id;
|
||||||
req.session.techName = tech.name;
|
req.session.techName = tech.name;
|
||||||
return res.redirect('/tech');
|
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!' });
|
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid'
|
||||||
`).run(paidByName || 'Admin', finalNotes, invId);
|
`).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) {
|
if (result.changes > 0 && existing.customer_id) {
|
||||||
renewCustomerPrepaidValidity(existing.customer_id, 1);
|
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) {}
|
try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {}
|
||||||
|
|||||||
369
services/securityWatchdogService.js
Normal file
369
services/securityWatchdogService.js
Normal file
@@ -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;
|
||||||
85
services/soundboxService.js
Normal file
85
services/soundboxService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
190
services/voiceTtsService.js
Normal file
190
services/voiceTtsService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user