feat(ai): provider OpenAI-compatible + DeepSeek, AI Telegram, self-healing dengan toggle (port upstream)

- telegramAiService/adminAiTools: dukung openai, deepseek, groq, openrouter, custom (base URL + key + model)
- selfHealingService: billing vs isolir, konsistensi secret PPPoE, sesi RADIUS stale, IP ACS kosong
- mikrotikService: getActivePppoeUsers, isolateCustomerPppoe, unisolateCustomerPppoe (upstream memanggil fungsi yang tidak ada)
- cron 30 menit hanya jalan bila ai_self_healing_enabled=true (default off) + scan brute force MikroTik tiap 2 menit
- Pengaturan: kartu AI & Self-Healing (provider, key/model, toggle, tombol Diagnosa/Perbaiki)
- bot Telegram: pesan non-command dari admin dijawab AI
This commit is contained in:
oxpoe
2026-11-05 01:48:50 +07:00
parent 1c40e6a0e8
commit 4003149046
9 changed files with 2022 additions and 4 deletions

View File

@@ -285,6 +285,14 @@ function ensureDefaultSettings() {
// Default settings yang wajib ada
const defaultSettings = {
// AI (OpenAI-compatible) & self-healing
ai_provider: 'deepseek',
deepseek_model: 'deepseek-chat',
openai_model: 'gpt-4o-mini',
groq_model: 'llama-3.3-70b-versatile',
custom_ai_base_url: '',
custom_ai_model: 'llama3',
ai_self_healing_enabled: false,
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
radius_enabled: '0',
radius_secret: 'secret123',

View File

@@ -4760,6 +4760,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
if (newSettings[k] !== undefined) newSettings[k] = String(newSettings[k]).trim();
}
if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true;
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
@@ -5228,6 +5234,26 @@ router.get('/api/inventory/serials/:itemId', requireAdminSession, (req, res) =>
}
});
router.get('/api/self-heal/diagnose', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const svc = require('../services/selfHealingService');
const result = await svc.diagnose();
res.json({ success: true, ...result });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/api/self-heal/run', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const svc = require('../services/selfHealingService');
const result = await svc.autoRepair(req.session.adminUser || 'Web Admin');
res.json({ success: true, ...result });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/api/soundbox/poll', requireAdminSession, (req, res) => {
try {
const soundboxSvc = require('../services/soundboxService');

587
services/adminAiTools.js Normal file
View File

@@ -0,0 +1,587 @@
const db = require('../config/database');
const customerSvc = require('./customerService');
const billingSvc = require('./billingService');
const mikrotikService = require('./mikrotikService');
const customerDevice = require('./customerDeviceService');
const agentSvc = require('./agentService');
const { getSetting, getCurrentDateInTimezone } = require('../config/settingsManager');
const { logger } = require('../config/logger');
/**
* Deklarasi Tools (Function Calling) untuk Google Gemini API
*/
const adminToolDeclarations = [
{
name: 'add_customer',
description: 'Mendaftarkan / menambah pelanggan internet baru ke database billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
name: { type: 'STRING', description: 'Nama lengkap pelanggan' },
phone: { type: 'STRING', description: 'Nomor WhatsApp atau telepon pelanggan' },
packageName: { type: 'STRING', description: 'Nama paket langganan (misal: BRONZE, SILVER, GOLD, dll)' },
area: { type: 'STRING', description: 'Nama area / dusun / lokasi pelanggan (opsional)' },
address: { type: 'STRING', description: 'Alamat lengkap rumah pelanggan (opsional)' },
pppoeUsername: { type: 'STRING', description: 'Username akun PPPoE untuk dial MikroTik (opsional)' },
pppoePassword: { type: 'STRING', description: 'Password akun PPPoE (opsional)' }
},
required: ['name', 'phone']
}
},
{
name: 'delete_customer_request',
description: 'Meminta konfirmasi penghapusan data pelanggan secara permanen dari sistem',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, nomor WhatsApp, atau username PPPoE yang ingin dihapus' }
},
required: ['target']
}
},
{
name: 'check_modem_status',
description: 'Mengecek kondisi modem ONU, daya redaman optik sinyal (RX Power), status PPPoE, dan uptime pelanggan via GenieACS dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, nomor telepon, akun PPPoE, atau tag modem ONU yang ingin dicek' }
}
}
},
{
name: 'reboot_onu',
description: 'Me-reboot / restart modem ONU pelanggan dari jarak jauh via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID, no HP, akun PPPoE, atau tag ONU' }
},
required: ['target']
}
},
{
name: 'update_wifi_password',
description: 'Mengubah kata sandi WiFi modem ONU pelanggan via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
newPassword: { type: 'STRING', description: 'Kata sandi WiFi baru (minimal 8 karakter)' }
},
required: ['target', 'newPassword']
}
},
{
name: 'update_wifi_ssid',
description: 'Mengubah nama SSID WiFi modem ONU pelanggan via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
newSsid: { type: 'STRING', description: 'Nama SSID WiFi baru' }
},
required: ['target', 'newSsid']
}
},
{
name: 'mark_invoice_paid',
description: 'Menandai lunas / melunasi tagihan invoice pelanggan dan cabut isolir jika ada',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'ID invoice (angka), nama pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'isolate_customer',
description: 'Mengisolir / suspend / memutus sementara layanan internet pelanggan di billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'unisolate_customer',
description: 'Membuka isolir / mengaktifkan kembali layanan internet pelanggan di billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'kick_pppoe_user',
description: 'Memutus paksa / kick sesi active PPPoE atau Hotspot pelanggan di router MikroTik',
parameters: {
type: 'OBJECT',
properties: {
username: { type: 'STRING', description: 'Username PPPoE atau nama pelanggan' }
},
required: ['username']
}
},
{
name: 'create_hotspot_voucher',
description: 'Membuat / generate voucher hotspot baru di router MikroTik (Mikhmon profile)',
parameters: {
type: 'OBJECT',
properties: {
profile: { type: 'STRING', description: 'Nama profil paket voucher (misal: 1jam, 5jam, 24jam, dll)' },
count: { type: 'INTEGER', description: 'Jumlah voucher yang ingin dibuat (opsional, default 1)' }
},
required: ['profile']
}
},
{
name: 'generate_invoices',
description: 'Menjalankan generate invoice tagihan bulanan seluruh pelanggan',
parameters: {
type: 'OBJECT',
properties: {
month: { type: 'INTEGER', description: 'Bulan (1-12)' },
year: { type: 'INTEGER', description: 'Tahun (misal: 2026)' }
},
required: ['month', 'year']
}
},
{
name: 'check_digiflazz_balance',
description: 'Mengecek sisa saldo deposit Digiflazz (PPOB / Pulsa) Admin',
parameters: {
type: 'OBJECT',
properties: {}
}
},
{
name: 'topup_agent',
description: 'Mentransfer / top up saldo agen reseller voucher atau pembayaran',
parameters: {
type: 'OBJECT',
properties: {
agentKey: { type: 'STRING', description: 'Username agent, nama agent, no HP, atau ID agent' },
amount: { type: 'INTEGER', description: 'Nominal saldo yang ditransfer (angka dalam Rupiah)' },
note: { type: 'STRING', description: 'Catatan transfer (opsional)' }
},
required: ['agentKey', 'amount']
}
},
{
name: 'auto_heal_network',
description: 'Menjalankan diagnosa dan perbaikan mandiri otomatis (Self-Healing) pada seluruh jaringan, router MikroTik (sinkronisasi secret PPPoE & isolir), status pelanggan billing, dan sesi yang macet',
parameters: {
type: 'OBJECT',
properties: {
mode: { type: 'STRING', description: 'Mode perbaikan: "repair" untuk langsung memperbaiki otomatis, atau "scan" untuk diagnosa saja (opsional, default: "repair")' }
}
}
},
{
name: 'check_security_status',
description: 'Mengecek status keamanan sistem, apakah ada anomali serangan brute force pada portal login aplikasi maupun router MikroTik, serta IP penyerang yang diblokir',
parameters: {
type: 'OBJECT',
properties: {}
}
}
];
/**
* Helper untuk mencari pelanggan secara fleksibel (Nama, HP, PPPoE, ID, Tag)
*/
function findCustomerHelper(query) {
if (!query) return null;
const q = String(query).trim();
let cust = customerSvc.findCustomerByAny(q);
if (cust) return cust;
const byExactName = db.prepare('SELECT id FROM customers WHERE LOWER(name) = LOWER(?) LIMIT 1').get(q);
if (byExactName) return customerSvc.getCustomerById(byExactName.id);
const candidates = customerSvc.getAllCustomers(q) || [];
if (candidates.length === 1) return customerSvc.getCustomerById(candidates[0].id);
if (candidates.length > 1) {
const exact = candidates.find(c => c.name.toLowerCase() === q.toLowerCase());
if (exact) return customerSvc.getCustomerById(exact.id);
return customerSvc.getCustomerById(candidates[0].id);
}
return null;
}
/**
* Eksekutor Tool / Fungsi Perintah WhatsApp Admin
*/
async function executeAdminTool(toolName, args, extraContext = {}) {
const { reply, sock, lidStore } = extraContext;
logger.info(`[Admin AI Tool] Memulai eksekusi tool "${toolName}" dengan args: ${JSON.stringify(args)}`);
try {
switch (toolName) {
// 1. Tambah Pelanggan
case 'add_customer': {
const { name, phone, packageName, area, address, pppoeUsername, pppoePassword } = args;
if (!name || !phone) {
return 'Mohon sebutkan minimal nama dan nomor WhatsApp pelanggan yang ingin didaftarkan.';
}
const allPkgs = customerSvc.getAllPackages();
let pkg = null;
if (packageName) {
pkg = allPkgs.find(p => p.name.toLowerCase() === packageName.toLowerCase().trim()) ||
allPkgs.find(p => p.name.toLowerCase().includes(packageName.toLowerCase().trim()));
}
if (!pkg && allPkgs.length > 0) {
pkg = allPkgs[0];
}
if (!pkg) {
return 'Mohon maaf, saat ini belum ada paket layanan internet yang aktif di database.';
}
let cleanPppoe = (pppoeUsername || name.toLowerCase().replace(/[^a-z0-9]/g, '')).trim();
if (!cleanPppoe) cleanPppoe = 'user' + Math.floor(1000 + Math.random() * 9000);
const existing = db.prepare('SELECT id FROM customers WHERE pppoe_username = ?').get(cleanPppoe);
if (existing) {
cleanPppoe += Math.floor(10 + Math.random() * 89);
}
const cleanPass = (pppoePassword || cleanPppoe).trim();
const routerRow = db.prepare('SELECT id FROM routers WHERE is_active = 1 LIMIT 1').get();
const routerId = routerRow ? routerRow.id : null;
const today = new Date().toISOString().slice(0, 10);
const newCustData = {
name: name.trim(),
phone: String(phone).replace(/\D/g, ''),
email: '',
address: address ? String(address).trim() : '',
area: area ? String(area).trim() : '',
package_id: pkg.id,
router_id: routerId,
connection_type: 'pppoe',
pppoe_username: cleanPppoe,
pppoe_password: cleanPass,
status: 'active',
install_date: today,
auto_isolate: 1,
isolate_day: 10
};
const res = customerSvc.createCustomer(newCustData);
const newId = res.lastInsertRowid;
if (routerId) {
try {
await mikrotikService.createPppoeSecret({
username: cleanPppoe,
password: cleanPass,
profile: pkg.name,
routerId: routerId
});
} catch (err) {
logger.warn(`[Admin AI Tool] Buat secret mikrotik: ${err.message}`);
}
}
try {
billingSvc.ensureCurrentInvoice(newId);
} catch (_) {}
return `Pelanggan baru atas nama ${name} dengan nomor WhatsApp ${newCustData.phone} berhasil didaftarkan dengan paket ${pkg.name}. Akun internet PPPoE dan tagihan periode berjalan sudah dibuatkan secara otomatis.`;
}
// 2. Hapus Pelanggan (Permintaan Konfirmasi)
case 'delete_customer_request': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dihapus.';
const cust = findCustomerHelper(target);
if (!cust) {
return `Pelanggan atas nama ${target} tidak ditemukan di sistem. Silakan pastikan nama atau nomornya sudah sesuai.`;
}
return `Apakah Anda yakin ingin menghapus data pelanggan ${cust.name}? Tindakan ini bersifat permanen. Jika setuju, silakan konfirmasi dengan ucapan: ya hapus ${cust.id}.`;
}
// 3. Cek Status Modem & Redaman
case 'check_modem_status': {
const { target } = args || {};
if (!target || target.toLowerCase() === 'semua' || target.toLowerCase() === 'general') {
return 'Secara umum kondisi jaringan dan modem pelanggan yang aktif terpantau normal dan lancar. Jika Anda ingin mengecek kondisi modem pelanggan tertentu, silakan sebutkan nama pelanggannya.';
}
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
let onuData = null;
try {
onuData = await Promise.race([
customerDevice.getCustomerDeviceData(tagToken),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 4000))
]).catch(() => null);
} catch (_) {}
const pppoeUser = cust?.pppoe_username ? cust.pppoe_username.toLowerCase() : '';
let pppoeSession = null;
if (pppoeUser) {
try {
const activeSessions = await mikrotikService.getActivePppoeUsers();
pppoeSession = (activeSessions || []).find(s => (s.user || '').toLowerCase() === pppoeUser);
} catch (_) {}
}
const name = cust ? cust.name : target;
if (!onuData && !pppoeSession) {
return `Perangkat modem untuk pelanggan ${name} saat ini belum terdeteksi aktif di sistem GenieACS maupun router MikroTik. Pastikan modem telah menyala dan kabel fiber optik terhubung dengan baik.`;
}
let redamanDesc = '';
if (onuData?.rxPower) {
const rx = parseFloat(onuData.rxPower);
if (!isNaN(rx)) {
if (rx > -23) {
redamanDesc = `Kualitas sinyal redaman optiknya sangat bagus di minus ${Math.abs(rx)} desibel.`;
} else if (rx >= -27) {
redamanDesc = `Kualitas sinyal redaman optiknya cukup baik di minus ${Math.abs(rx)} desibel.`;
} else {
redamanDesc = `Kualitas sinyal redaman optiknya agak lemah di minus ${Math.abs(rx)} desibel, perlu diperhatikan.`;
}
} else {
redamanDesc = `Kualitas daya sinyal tercatat ${onuData.rxPower}.`;
}
}
let pppoeDesc = pppoeSession ? 'Koneksi internet terpantau online lancar.' : 'Akun internet terpantau belum dial up ke router.';
return `Kondisi modem milik ${name} saat ini terpantau normal dan aktif. ${redamanDesc} ${pppoeDesc}`.trim();
}
// 4. Reboot Modem ONU
case 'reboot_onu': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau tag modem yang ingin dinyalakan ulang.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const targetDevice = await customerDevice.resolveDeviceToken(tagToken);
if (!targetDevice) {
return `Perangkat modem untuk ${target} tidak ditemukan di GenieACS.`;
}
await customerDevice.requestReboot(tagToken);
return `Perintah restart untuk modem milik ${cust ? cust.name : target} berhasil dikirim. Modem akan mulai menyala ulang dalam beberapa saat.`;
}
// 5. Update Password WiFi
case 'update_wifi_password': {
const { target, newPassword } = args;
if (!target || !newPassword) return 'Mohon sebutkan nama pelanggan dan kata sandi wifi baru.';
if (String(newPassword).length < 8) return 'Kata sandi wifi baru minimal harus delapan karakter.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const ok = await customerDevice.updatePassword(tagToken, String(newPassword).trim());
if (ok) {
return `Kata sandi wifi untuk pelanggan ${cust ? cust.name : target} berhasil diperbarui menjadi ${newPassword}. Pengaturan sudah diterapkan ke modem.`;
} else {
return `Gagal mengubah kata sandi wifi untuk ${target}. Pastikan modem pelanggan sedang dalam kondisi online.`;
}
}
// 6. Update SSID WiFi
case 'update_wifi_ssid': {
const { target, newSsid } = args;
if (!target || !newSsid) return 'Mohon sebutkan nama pelanggan dan nama wifi baru.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const ok = await customerDevice.updateSSID(tagToken, String(newSsid).trim());
if (ok) {
return `Nama wifi untuk pelanggan ${cust ? cust.name : target} berhasil diganti menjadi ${newSsid}. Perangkat modem sudah diperbarui.`;
} else {
return `Gagal mengubah nama wifi untuk ${target}. Pastikan modem pelanggan sedang aktif terhubung.`;
}
}
// 7. Melunasi Tagihan
case 'mark_invoice_paid': {
const { target } = args;
if (!target) return 'Mohon sebutkan ID invoice atau nama pelanggan yang tagihannya ingin dilunasi.';
const { executeAdminLunas } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminLunas(target, localReply, sock, lidStore);
return `Tagihan untuk ${target} telah berhasil dilunasi di sistem dan layanan sudah aktif normal.`;
}
// 8. Isolir Pelanggan
case 'isolate_customer': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin diisolir.';
const { executeAdminIsolir } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminIsolir(target, localReply);
return `Layanan internet untuk pelanggan ${target} telah berhasil diisolir sementara.`;
}
// 9. Buka Isolir Pelanggan
case 'unisolate_customer': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dibuka isolirnya.';
const { executeAdminBuka } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminBuka(target, localReply);
return `Isolir untuk pelanggan ${target} telah berhasil dibuka. Koneksi internet kini sudah normal kembali.`;
}
// 10. Kick PPPoE / Hotspot User
case 'kick_pppoe_user': {
const { username } = args;
if (!username) return 'Mohon sebutkan nama pengguna yang ingin diputus sesinya.';
let targetUser = username.trim();
const cust = findCustomerHelper(username);
if (cust && cust.pppoe_username) {
targetUser = cust.pppoe_username;
}
const pk = await mikrotikService.kickPppoeUser(targetUser);
const hk = await mikrotikService.kickHotspotUser(targetUser);
if (pk || hk) {
return `Sesi aktif untuk ${targetUser} telah berhasil diputus dari router. Perangkat akan terhubung kembali secara otomatis.`;
} else {
return `Sesi aktif untuk ${targetUser} tidak ditemukan di router.`;
}
}
// 11. Buat Voucher Hotspot
case 'create_hotspot_voucher': {
const { profile, count } = args;
if (!profile) return 'Mohon sebutkan nama profil paket voucher hotspot yang ingin dibuat.';
const numVouchers = Math.min(Math.max(parseInt(count, 10) || 1, 1), 10);
const created = [];
const now = getCurrentDateInTimezone();
const dateStr = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`;
for (let i = 0; i < numVouchers; i++) {
const code = Math.floor(1000 + Math.random() * 9000).toString();
const comment = `vc ${code} ${dateStr} AI`;
await mikrotikService.addHotspotUser({
name: code,
password: code,
profile: profile,
comment: comment
});
created.push(code);
}
return `Sebanyak ${created.length} voucher hotspot dengan paket ${profile} telah berhasil dibuat. Kode voucher pertama adalah ${created[0]}.`;
}
// 12. Generate Tagihan Bulanan
case 'generate_invoices': {
const { month, year } = args;
const m = parseInt(month, 10) || (new Date().getMonth() + 1);
const y = parseInt(year, 10) || new Date().getFullYear();
const r = await billingSvc.generateMonthlyInvoices(m, y);
return `Pembuatan tagihan bulanan untuk periode bulan ${m} tahun ${y} telah selesai. Sebanyak ${r?.created || 0} tagihan baru berhasil diproses.`;
}
// 13. Cek Saldo Digiflazz
case 'check_digiflazz_balance': {
const r = await agentSvc.digiflazzCheckBalance();
const dep = Number(r?.deposit || 0).toLocaleString('id-ID');
return `Sisa saldo deposit Digiflazz Anda saat ini tercatat sebesar ${dep} rupiah dan berstatus aktif.`;
}
// 14. Top Up Saldo Agent
case 'topup_agent': {
const { agentKey, amount, note } = args;
const nom = Number(String(amount || '').replace(/[^\d]/g, ''));
if (!nom || nom <= 0) return 'Nominal top up harus berupa angka lebih dari nol.';
const rawKey = String(agentKey || '').trim();
let agent = agentSvc.getAgentById(parseInt(rawKey, 10)) ||
agentSvc.getAgentByUsername(rawKey) ||
agentSvc.getAgentByPhone(rawKey);
if (!agent) {
const all = agentSvc.getAllAgents();
agent = all.find(a => String(a.name || '').toLowerCase().includes(rawKey.toLowerCase()));
}
if (!agent) return `Data agen ${agentKey} tidak ditemukan di sistem.`;
const r = agentSvc.topupAgent(agent.id, nom, note || 'Topup via Voice AI', 'Admin Voice AI');
return `Top up saldo untuk agen ${agent.name} sebesar ${nom.toLocaleString('id-ID')} rupiah telah berhasil diproses. Saldo saat ini menjadi ${Number(r.after || 0).toLocaleString('id-ID')} rupiah.`;
}
// 15. Self-Healing & Network Auto-Repair
case 'auto_heal_network': {
const { mode } = args || {};
const selfHealingSvc = require('./selfHealingService');
const isRepair = String(mode || 'repair').toLowerCase() !== 'scan';
const res = await (isRepair ? selfHealingSvc.autoRepair('Admin AI Command') : selfHealingSvc.diagnose());
const found = res.stats.anomaliesFound;
const fixed = res.stats.anomaliesRepaired;
const routers = res.stats.routersChecked;
if (found === 0) {
return `Pemeriksaan mandiri selesai. Seluruh sistem billing, status pelanggan, dan ${routers} router MikroTik terpantau normal dan tersinkronisasi sempurna tanpa kendala.`;
}
if (isRepair) {
const repairList = res.repairs.slice(0, 3).map(r => r.replace(/\[[^\]]+\]\s*/, '')).join('. ');
return `Proses perbaikan mandiri selesai. Sistem mendeteksi ${found} anomali dan berhasil memperbaiki ${fixed} masalah secara otomatis: ${repairList}.`;
} else {
return `Pemeriksaan sistem mendeteksi ${found} anomali yang perlu disinkronkan. Anda dapat memerintahkan perbaikan dengan ucapan: ai perbaiki jaringan.`;
}
}
// 16. Cek Status Keamanan & Brute Force Anomaly
case 'check_security_status': {
const securityWatchdog = require('./securityWatchdogService');
const overview = securityWatchdog.getSecurityOverview();
const blockedCount = overview.activeBlockedIps ? overview.activeBlockedIps.length : 0;
const recentAlerts = overview.recentAlerts || [];
if (blockedCount === 0 && recentAlerts.length === 0) {
return 'Status keamanan sistem saat ini aman dan terkendali. Tidak terdeteksi adanya anomali serangan brute force pada portal login aplikasi maupun router MikroTik.';
}
let resp = `Laporan keamanan sistem: Saat ini status sistem adalah ${overview.status === 'warning' ? 'waspada' : 'aman'}. `;
if (blockedCount > 0) {
const ipList = overview.activeBlockedIps.map(b => `${b.ip} (tersisa ${b.remainingSeconds} detik)`).join(', ');
resp += `Terdapat ${blockedCount} alamat i pe yang sedang diblokir sementara karena percobaan login berulang kali: ${ipList}. `;
}
if (recentAlerts.length > 0) {
resp += `Tercatat ${recentAlerts.length} riwayat insiden keamanan terbaru pada audit sistem.`;
}
return resp;
}
default:
return `Fungsi ${toolName} belum tersedia di sistem.`;
}
} catch (err) {
logger.error(`[Admin AI Tool] Error saat eksekusi ${toolName}: ${err.message}`);
return `Mohon maaf, terjadi kendala saat memproses permintaan: ${err.message}`;
}
}
module.exports = {
adminToolDeclarations,
executeAdminTool
};

View File

@@ -808,6 +808,33 @@ function startCronJobs() {
} catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); }
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// AI Self-Healing & Network Auto-Repair (tiap 30 menit, bisa dimatikan dari Pengaturan)
cron.schedule('*/30 * * * *', async () => {
const enabled = getSetting('ai_self_healing_enabled', false);
if (enabled !== true && enabled !== 'true') return;
try {
const selfHealingSvc = require('./selfHealingService');
const res = await selfHealingSvc.autoRepair('AI Autonomous Watchdog');
if (res && res.stats && res.stats.anomaliesRepaired > 0) {
logger.info('[CRON-AI-HEAL] Memperbaiki ' + res.stats.anomaliesRepaired + ' anomali: ' + (res.repairs || []).join('; '));
}
} catch (e) {
logger.error('[CRON-AI-HEAL] Error: ' + e.message);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// Security Watchdog - scan login gagal MikroTik (tiap 2 menit)
cron.schedule('*/2 * * * *', async () => {
try {
const securityWatchdog = require('./securityWatchdogService');
if (typeof securityWatchdog.scanMikrotikLoginFailures === 'function') {
await securityWatchdog.scanMikrotikLoginFailures();
}
} catch (e) {
logger.error('[CRON-SECURITY-WATCHDOG] Error: ' + e.message);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
logger.info('[CRON] Semua tugas penjadwalan telah aktif.');
}

View File

@@ -2128,6 +2128,44 @@ async function getBackup(routerId = null) {
}
}
// ─── KOMPATIBILITAS AI / SELF-HEALING ───────────────────────────────────────
async function getActivePppoeUsers() {
const map = await getActivePppoeSessionsMap();
const out = [];
map.forEach((v, user) => out.push({
user,
address: v.ip || '',
uptime: v.uptime || '',
callerId: v.callerId || '',
routerId: v.routerId ?? null,
routerName: v.routerName || ''
}));
return out;
}
async function isolateCustomerPppoe(routerId, username, opts = {}) {
const profile = String(opts.pppoeProfile || 'ISOLIR');
await setPppoeProfile(username, profile, routerId ?? null);
await kickPppoeUser(username, routerId ?? null).catch(() => {});
return true;
}
async function unisolateCustomerPppoe(routerId, username) {
const name = String(username || '').trim();
if (!name) return false;
const row = db.prepare(`
SELECT p.name as package_name
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.pppoe_username = ? COLLATE NOCASE
LIMIT 1
`).get(name);
const profile = row && row.package_name ? row.package_name : 'default';
await setPppoeProfile(name, profile, routerId ?? null);
return true;
}
module.exports = {
checkConnection,
getConnection,
@@ -2149,6 +2187,9 @@ module.exports = {
getHotspotProfiles,
getPppoeActive,
getActivePppoeSessionsMap,
getActivePppoeUsers,
isolateCustomerPppoe,
unisolateCustomerPppoe,
getAllActiveSessionsMap,
getHotspotActive,
getIpPools,

View File

@@ -0,0 +1,346 @@
/**
* Service: Autonomous Self-Healing & Network Auto-Repair Engine
* Memindai anomali dan secara otomatis memperbaiki ketidaksesuaian antara
* Billing System, Router MikroTik, GenieACS TR-069, dan RADIUS Server.
*/
const db = require('../config/database');
const mikrotikService = require('./mikrotikService');
const billingSvc = require('./billingService');
const customerSvc = require('./customerService');
const customerDevice = require('./customerDeviceService');
const { logger } = require('../config/logger');
let auditSvc = null;
try { auditSvc = require('./auditTrailService'); } catch (_) {}
let radiusSvc = null;
try { radiusSvc = require('./radiusServerService'); } catch (_) {}
class SelfHealingService {
/**
* Jalankan diagnosa lengkap tanpa eksekusi perbaikan (Dry Run / Scan Mode)
*/
async diagnose() {
return this.run({ autoRepair: false });
}
/**
* Jalankan diagnosa DAN perbaikan otomatis (Healing Mode)
*/
async autoRepair(actorName = 'AI Self-Healing Agent') {
return this.run({ autoRepair: true, actorName });
}
/**
* Mesin Utama Pemindaian & Perbaikan
*/
async run({ autoRepair = true, actorName = 'System' } = {}) {
const startTime = Date.now();
const result = {
timestamp: new Date().toISOString(),
mode: autoRepair ? 'repair' : 'scan_only',
actor: actorName,
stats: {
customersChecked: 0,
routersChecked: 0,
anomaliesFound: 0,
anomaliesRepaired: 0,
failedRepairs: 0
},
repairs: [],
issues: [],
warnings: [],
errors: []
};
try {
// ─────────────────────────────────────────────────────────────────
// 1. SINKRONISASI KONSISTENSI STATUS BILLING VS TAGIHAN PELANGGAN
// ─────────────────────────────────────────────────────────────────
await this._healBillingCustomerStatus(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 2. SINKRONISASI MIKROTIK (PPPoE SECRETS, ISOLIR, FIREWALL)
// ─────────────────────────────────────────────────────────────────
await this._healMikrotikConsistency(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 3. PEMBERSIHAN SESI RADIUS YANG STALE / KADALUARSA
// ─────────────────────────────────────────────────────────────────
await this._healRadiusSessions(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 4. SINKRONISASI GENIEACS & TR-069 MODEM ONU
// ─────────────────────────────────────────────────────────────────
await this._healAcsDevices(result, autoRepair);
result.durationMs = Date.now() - startTime;
// Catat di Audit Trail jika ada aksi perbaikan yang dieksekusi
if (autoRepair && result.repairs.length > 0 && auditSvc && typeof auditSvc.logAuditTrail === 'function') {
try {
auditSvc.logAuditTrail({
action: 'UPDATE',
entity_type: 'self_healing',
entity_id: 'network_auto_repair',
actor_type: 'system',
actor_id: 'ai-agent',
actor_name: actorName,
details: {
repairedCount: result.stats.anomaliesRepaired,
repairs: result.repairs,
issues: result.issues
}
});
} catch (_) {}
}
logger.info(`[SelfHealing] Pemindaian selesai dalam ${result.durationMs}ms. Ditemukan: ${result.stats.anomaliesFound}, Diperbaiki: ${result.stats.anomaliesRepaired}`);
} catch (err) {
logger.error(`[SelfHealing] Fatal error during self-healing: ${err.message}`);
result.errors.push(`Fatal error: ${err.message}`);
}
return result;
}
/**
* 1. Perbaiki Inkonsistensi Status Billing Pelanggan
* - Pelanggan berstatus isolir/suspended padahal semua tagihannya sudah lunas -> Buka Isolir
*/
async _healBillingCustomerStatus(result, autoRepair) {
try {
// Cari pelanggan yang statusnya isolated/suspended tetapi TIDAK punya invoice unpaid
const query = `
SELECT c.id, c.name, c.status, c.pppoe_username, c.router_id,
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count
FROM customers c
LEFT JOIN invoices i ON c.id = i.customer_id
WHERE LOWER(c.status) IN ('isolated', 'suspended', 'isolir')
GROUP BY c.id
HAVING unpaid_count = 0
`;
const falseIsolated = db.prepare(query).all() || [];
for (const cust of falseIsolated) {
result.stats.anomaliesFound++;
const desc = `Pelanggan #${cust.id} (${cust.name}) berstatus '${cust.status}' padahal seluruh tagihannya telah LUNAS.`;
result.issues.push({ type: 'false_isolation', customerId: cust.id, name: cust.name, description: desc });
if (autoRepair) {
try {
// Ubah status ke active di database
db.prepare("UPDATE customers SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cust.id);
// Eksekusi buka isolir di MikroTik jika terhubung
if (cust.router_id && cust.pppoe_username) {
await mikrotikService.unisolateCustomerPppoe(cust.router_id, cust.pppoe_username).catch(() => {});
}
result.stats.anomaliesRepaired++;
result.repairs.push(`[Billing] Buka isolir & aktifkan pelanggan #${cust.id} (${cust.name}) karena tagihan sudah lunas.`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal memulihkan status pelanggan #${cust.id}: ${e.message}`);
}
}
}
} catch (e) {
logger.error(`[SelfHealing] Error healing billing status: ${e.message}`);
}
}
/**
* 2. Perbaiki Inkonsistensi MikroTik (Secrets, Profiles, Address-Lists)
*/
async _healMikrotikConsistency(result, autoRepair) {
try {
const routers = db.prepare("SELECT * FROM routers WHERE is_active = 1").all() || [];
result.stats.routersChecked = routers.length;
for (const r of routers) {
let isOnline = false;
try {
isOnline = await mikrotikService.checkConnection(r.id);
} catch (_) {}
if (!isOnline) {
result.warnings.push(`Router "${r.name}" (${r.host}) offline atau API port tidak merespons.`);
continue;
}
// Ambil data secrets dan active sessions dari router
let secrets = [];
try {
secrets = await mikrotikService.getPppoeSecrets(r.id) || [];
} catch (e) {
result.warnings.push(`Gagal membaca PPP Secrets dari router "${r.name}": ${e.message}`);
continue;
}
const secretMap = new Map();
for (const s of secrets) {
if (s.name) secretMap.set(s.name.toLowerCase(), s);
}
// Ambil daftar pelanggan yang dialokasikan ke router ini
const customers = db.prepare(`
SELECT c.*, p.name as package_name, p.billing_type as package_billing_type
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.router_id = ? AND c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
`).all(r.id) || [];
result.stats.customersChecked += customers.length;
for (const cust of customers) {
const userLower = (cust.pppoe_username || '').trim().toLowerCase();
if (!userLower) continue;
const sec = secretMap.get(userLower);
const isActiveCustomer = String(cust.status || '').toLowerCase() === 'active';
const isIsolatedCustomer = ['isolated', 'suspended', 'isolir'].includes(String(cust.status || '').toLowerCase());
const targetProfile = cust.package_name || 'default';
// KASUS A: Pelanggan Aktif, tetapi Secret Belum Dibuat di MikroTik
if (isActiveCustomer && !sec) {
result.stats.anomaliesFound++;
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) belum memiliki Secret PPPoE '${cust.pppoe_username}' di router "${r.name}".`;
result.issues.push({ type: 'missing_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.createPppoeSecret({
username: cust.pppoe_username,
password: cust.pppoe_password || cust.pppoe_username,
profile: targetProfile,
routerId: r.id
});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Dibuatkan Secret PPPoE baru '${cust.pppoe_username}' (Profile: ${targetProfile}) di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal membuat secret '${cust.pppoe_username}': ${e.message}`);
}
}
}
// KASUS B: Pelanggan Aktif, tetapi Secret di MikroTik Disabled atau Masih Berprofil ISOLIR
else if (isActiveCustomer && sec) {
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
if (isSecDisabled || isSecIsolirProfile) {
result.stats.anomaliesFound++;
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) di MikroTik berstatus disabled (${isSecDisabled}) atau profil masih '${sec.profile}'.`;
result.issues.push({ type: 'active_but_blocked_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.updatePppoeSecret({
username: cust.pppoe_username,
password: cust.pppoe_password || sec.password,
profile: targetProfile,
disabled: false,
routerId: r.id
});
// Buka juga dari firewall isolir list jika ada
await mikrotikService.unisolateCustomerPppoe(r.id, cust.pppoe_username).catch(() => {});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Mengaktifkan kembali Secret '${cust.pppoe_username}' ke profil '${targetProfile}' di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal memulihkan secret '${cust.pppoe_username}': ${e.message}`);
}
}
}
}
// KASUS C: Pelanggan Terisolir di Billing, tetapi di MikroTik Masih Aktif / Profil Normal
else if (isIsolatedCustomer && sec) {
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
if (!isSecDisabled && !isSecIsolirProfile) {
result.stats.anomaliesFound++;
const desc = `Pelanggan terisolir #${cust.id} (${cust.name}) di MikroTik masih aktif dengan profil normal '${sec.profile}'.`;
result.issues.push({ type: 'isolated_not_enforced', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.isolateCustomerPppoe(r.id, cust.pppoe_username, { pppoeProfile: 'ISOLIR' });
await mikrotikService.kickPppoeUser(cust.pppoe_username, r.id).catch(() => {});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Menerapkan isolir & kick sesi '${cust.pppoe_username}' di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal menerapkan isolir '${cust.pppoe_username}': ${e.message}`);
}
}
}
}
}
}
} catch (e) {
logger.error(`[SelfHealing] Error healing MikroTik consistency: ${e.message}`);
}
}
/**
* 3. Bersihkan Sesi RADIUS yang Menggantung (Zombie / Stale)
*/
async _healRadiusSessions(result, autoRepair) {
try {
if (!radiusSvc) return;
const onlineCountBefore = (radiusSvc.getOnlineSessions() || []).length;
if (autoRepair && typeof radiusSvc.cleanupStaleSessions === 'function') {
const cleaned = await radiusSvc.cleanupStaleSessions();
if (cleaned > 0) {
result.stats.anomaliesFound += cleaned;
result.stats.anomaliesRepaired += cleaned;
result.repairs.push(`[RADIUS Server] Membersihkan ${cleaned} sesi accounting yang menggantung (stale).`);
}
}
} catch (e) {
logger.warn(`[SelfHealing] RADIUS session check: ${e.message}`);
}
}
/**
* 4. Sinkronisasi Perangkat ACS TR-069
*/
async _healAcsDevices(result, autoRepair) {
try {
const count = db.prepare("SELECT COUNT(*) as c FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").get();
if (count && count.c > 0) {
result.stats.anomaliesFound += count.c;
if (autoRepair) {
// Trigger update IP dari MikroTik active sessions
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
if (activeSessionsMap && activeSessionsMap.size > 0) {
let fixed = 0;
const devs = db.prepare("SELECT id, params FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").all() || [];
for (const d of devs) {
let p = {};
try { p = JSON.parse(d.params || '{}'); } catch (_) {}
const user = p['VirtualParameters.pppoeUsername'] || p['Device.PPP.Interface.1.Username'];
if (user && activeSessionsMap.has(user.toLowerCase())) {
const ip = activeSessionsMap.get(user.toLowerCase()).ip;
if (ip) {
db.prepare("UPDATE acs_devices SET ip_address = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(ip, d.id);
fixed++;
}
}
}
if (fixed > 0) {
result.stats.anomaliesRepaired += fixed;
result.repairs.push(`[ACS TR-069] Memperbarui IP address untuk ${fixed} modem ONU dari sesi aktif MikroTik.`);
}
}
}
}
} catch (e) {
logger.warn(`[SelfHealing] ACS check: ${e.message}`);
}
}
}
const selfHealingService = new SelfHealingService();
module.exports = selfHealingService;

View File

@@ -0,0 +1,904 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const { adminToolDeclarations, executeAdminTool } = require('./adminAiTools');
// Cache untuk dokumen knowledge base
let cachedKnowledge = null;
let lastLoadTime = 0;
const KNOWLEDGE_RELOAD_INTERVAL = 3600000; // Reload tiap 1 jam jika ada update doc
/**
* Muat dan ringkas dokumentasi aplikasi agar efisien dalam prompt AI
*/
function loadKnowledgeBase() {
const now = Date.now();
if (cachedKnowledge && (now - lastLoadTime < KNOWLEDGE_RELOAD_INTERVAL)) {
return cachedKnowledge;
}
try {
const docPath = path.join(__dirname, '../FITUR_APLIKASI_LENGKAP.md');
if (fs.existsSync(docPath)) {
const fullDoc = fs.readFileSync(docPath, 'utf8');
// Ambil seluruh dokumentasi fitur & panduan pemecahan masalah (mendukung context besar Gemini)
cachedKnowledge = fullDoc.length > 350000 ? fullDoc.substring(0, 340000) + '\n\n...(dokumentasi dipotong untuk efisiensi context)...' : fullDoc;
lastLoadTime = now;
logger.info(`[TelegramAI] Knowledge base loaded (${cachedKnowledge.length} bytes).`);
return cachedKnowledge;
}
} catch (err) {
logger.warn(`[TelegramAI] Gagal memuat FITUR_APLIKASI_LENGKAP.md: ${err.message}`);
}
// Fallback ringkasan jika file tidak terbaca
cachedKnowledge = `
Aplikasi: RTRWNET Management & Billing System (Botlokal Customer Portal)
Tech Stack: Node.js (>=20.0.0), Express.js, SQLite (better-sqlite3), EJS, Baileys WA, RouterOS API, SNMP OLT, GenieACS TR-069.
Fitur Utama:
1. Billing & Invoicing: Auto generate invoice tiap tanggal billing, reminder WA otomatis (H-3, H-1, saat tempo), auto isolir jika jatuh tempo, denda telat, kode unik nominal pembayaran.
2. MikroTik: Multi-router support, sinkronisasi secret PPPoE & user hotspot, auto create profile, auto isolir via address-list / pool, parser script mikhmon.
3. GenieACS & TR-069: Auto provisioning ONU baru, cek redaman (optical power RX/TX), reboot ONU jarak jauh, ganti SSID dan sandi WiFi langsung via web/bot, status WAN & PPPoE uptime.
4. OLT & SNMP: Dukungan OLT EPON & GPON (HIOSO, VSOL, ZTE, dll), monitor port PON, auto-discovery unconfigured ONU.
5. Payment Gateway: Tripay, Midtrans, Xendit, Duitku, QRIS Dinamis & Statis, notifikasi mutasi otomatis via SMS/MacroDroid.
6. WhatsApp & Telegram: Notifikasi tagihan, broadcast massal, chatbot interaktif pelanggan, bot Telegram untuk admin (cek Mikrotik, reboot ONU, cari pelanggan, ringkasan keuangan) & asisten grup.
7. Portal Pelanggan: Cek tagihan tanpa login, riwayat invoice, ganti WiFi sendiri, beli voucher, tiket komplain gangguan.
8. FreeRADIUS Server: Otentikasi PPPoE/Hotspot, radius accounting, dynamic pool isolir.
`;
lastLoadTime = now;
return cachedKnowledge;
}
/**
* Ambil status live seluruh pelanggan, MikroTik, dan GenieACS untuk Admin WhatsApp / Telegram
*/
async function getAdminGlobalContext(userQuestion) {
try {
const db = require('../config/database');
const customerDevice = require('./customerDeviceService');
const mikrotikService = require('./mikrotikService');
// 1. Ambil ringkasan seluruh pelanggan dari SQLite
const query = `
SELECT
c.id, c.name, c.phone, c.status, c.pppoe_username, c.genieacs_tag, c.area,
p.name as package_name, p.price as package_price,
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count,
COALESCE(SUM(CASE WHEN i.status != 'paid' THEN i.amount ELSE 0 END), 0) as unpaid_amount
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN invoices i ON c.id = i.customer_id
GROUP BY c.id
ORDER BY c.id ASC
`;
const customers = db.prepare(query).all();
const total = customers.length;
const active = customers.filter(c => (c.status || '').toLowerCase() === 'active').length;
const isolated = customers.filter(c => ['isolated', 'suspended', 'isolir'].includes((c.status || '').toLowerCase())).length;
const inactive = customers.filter(c => ['inactive', 'nonaktif', 'terminated'].includes((c.status || '').toLowerCase())).length;
const unpaidCustomers = customers.filter(c => c.unpaid_count > 0);
const totalUnpaidAmount = unpaidCustomers.reduce((acc, c) => acc + (c.unpaid_amount || 0), 0);
let contextText = `=== RINGKASAN GLOBAL SISTEM RTRW-NET (REALTIME ADMIN CONTEXT) ===\n`;
contextText += `Total Pelanggan Terdaftar: ${total} pelanggan\n`;
contextText += `- Aktif: ${active} pelanggan\n`;
contextText += `- Terisolir / Suspended: ${isolated} pelanggan\n`;
contextText += `- Non-Aktif / Terminated: ${inactive} pelanggan\n`;
contextText += `Tunggakan Belum Lunas: ${unpaidCustomers.length} pelanggan (Total Tunggakan: Rp ${totalUnpaidAmount.toLocaleString('id-ID')})\n\n`;
// 2. Direktori ringkas seluruh pelanggan
contextText += `=== DIREKTORI SELURUH PELANGGAN (ID | Nama | No HP | Status | PPPoE | Paket | Tunggakan) ===\n`;
for (const c of customers) {
const unpaidStr = c.unpaid_count > 0 ? `${c.unpaid_count} tagihan (Rp ${c.unpaid_amount.toLocaleString('id-ID')})` : 'Lunas';
contextText += `[ID:${c.id}] ${c.name} | ${c.phone || '-'} | Status: ${c.status} | PPPoE: ${c.pppoe_username || '-'} | Pkt: ${c.package_name || '-'} | Tunggakan: ${unpaidStr}\n`;
}
contextText += `\n`;
// 3. Deteksi apakah admin menanyakan pelanggan tertentu
const qLower = String(userQuestion || '').toLowerCase();
const matchedCustomers = [];
for (const c of customers) {
let isMatch = false;
const name = String(c.name || '').toLowerCase().trim();
const pppoe = String(c.pppoe_username || '').toLowerCase().trim();
const phoneDigits = String(c.phone || '').replace(/\D/g, '');
const tag = String(c.genieacs_tag || '').toLowerCase().trim();
const idStr = String(c.id);
// Cek ID
if (new RegExp(`\\b(id|#)\\s*${idStr}\\b`, 'i').test(qLower)) {
isMatch = true;
}
// Cek No HP
else if (phoneDigits.length >= 8 && qLower.includes(phoneDigits.slice(-8))) {
isMatch = true;
}
// Cek PPPoE username
else if (pppoe.length >= 3 && qLower.includes(pppoe)) {
isMatch = true;
}
// Cek GenieACS Tag
else if (tag.length >= 3 && qLower.includes(tag)) {
isMatch = true;
}
// Cek Nama Pelanggan
else if (name.length >= 3) {
if (qLower.includes(name)) {
isMatch = true;
} else {
const words = name.split(/\s+/).filter(w => w.length >= 4);
for (const w of words) {
if (['bisa', 'tolong', 'admin', 'saya', 'yang', 'dari', 'untuk', 'paket', 'status', 'kabel', 'modem', 'wifi', 'cek'].includes(w)) continue;
if (qLower.includes(w)) {
isMatch = true;
break;
}
}
}
}
if (isMatch) {
matchedCustomers.push(c);
if (matchedCustomers.length >= 3) break;
}
}
// 4. Jika ada pelanggan yang cocok, lakukan Deep Dive (Invoices + GenieACS + MikroTik)
if (matchedCustomers.length > 0) {
contextText += `=== DETAIL MENDALAM PELANGGAN YANG DITANYAKAN ===\n`;
let activeSessionsMap = new Map();
try {
activeSessionsMap = await Promise.race([
mikrotikService.getActivePppoeSessionsMap(),
new Promise((_, reject) => setTimeout(() => reject(new Error('MikroTik timeout')), 4000))
]).catch(() => new Map());
} catch (_) {}
for (const mc of matchedCustomers) {
const invRows = db.prepare('SELECT id, period_month, period_year, amount, status, paid_at, created_at FROM invoices WHERE customer_id = ? ORDER BY id DESC LIMIT 5').all(mc.id);
const unpaidInvs = invRows.filter(i => i.status !== 'paid');
const paidInvs = invRows.filter(i => i.status === 'paid');
let onuData = null;
const lookupTag = mc.genieacs_tag || mc.pppoe_username || mc.phone;
if (lookupTag) {
try {
onuData = await Promise.race([
customerDevice.getCustomerDeviceData(lookupTag),
new Promise((_, reject) => setTimeout(() => reject(new Error('GenieACS timeout')), 4000))
]).catch(() => null);
} catch (_) {}
}
const pppoeUser = (mc.pppoe_username || '').toLowerCase();
const pppoeSession = pppoeUser ? activeSessionsMap.get(pppoeUser) : null;
contextText += `\n>> PELANGGAN: ${mc.name} (ID: ${mc.id})\n`;
contextText += ` - No HP: ${mc.phone || '-'}\n`;
contextText += ` - Status Billing: ${mc.status}\n`;
contextText += ` - Area: ${mc.area || '-'}\n`;
contextText += ` - Paket: ${mc.package_name || '-'} (Rp ${Number(mc.package_price || 0).toLocaleString('id-ID')}/bulan)\n`;
contextText += ` - Akun PPPoE: ${mc.pppoe_username || '-'}\n`;
contextText += ` - Status PPPoE MikroTik: ${pppoeSession ? `ONLINE (IP: ${pppoeSession.ip}, Uptime: ${pppoeSession.uptime}, Router: ${pppoeSession.routerName || '-'})` : 'OFFLINE / Belum dial'}\n`;
if (onuData) {
contextText += ` - Status Modem ONU: ${onuData.status || 'Unknown'}\n`;
contextText += ` - Redaman Optical (RX Power): ${onuData.rxPower ? onuData.rxPower + ' dBm' : '-'}\n`;
contextText += ` - Nama WiFi (SSID): ${onuData.ssid || '-'}\n`;
contextText += ` - ONU Uptime: ${onuData.uptime || '-'}\n`;
contextText += ` - Pengguna WiFi Terhubung: ${onuData.totalAssociations || 0} user\n`;
} else {
contextText += ` - Status Modem ONU: (Data modem belum terhubung/tag GenieACS belum diisi)\n`;
}
contextText += ` - Rincian Tunggakan Tagihan: ${unpaidInvs.length > 0 ? unpaidInvs.map(i => `#${i.id} (Periode ${i.period_month}/${i.period_year}: Rp ${Number(i.amount).toLocaleString('id-ID')})`).join(', ') : 'TIDAK ADA (Semua tagihan LUNAS)'}\n`;
if (paidInvs.length > 0) {
contextText += ` - Tagihan Terakhir Lunas: #${paidInvs[0].id} (Periode ${paidInvs[0].period_month}/${paidInvs[0].period_year} sebesar Rp ${Number(paidInvs[0].amount).toLocaleString('id-ID')} pada ${paidInvs[0].paid_at || '-'})\n`;
}
}
}
return contextText;
} catch (err) {
logger.error(`[TelegramAI] Gagal mengambil admin global context: ${err.message}`);
return '';
}
}
/**
* Buat System Instruction Terpadu untuk AI
*/
function buildSystemInstruction(isAdmin, customerContext, adminContext, senderName) {
const knowledge = loadKnowledgeBase();
if (isAdmin) {
return `Anda adalah asisten cerdas, serba tahu, dan handal yang mendampingi Pemilik / Administrator "RTRWNET Management & Billing System" (Botlokal).
PEDOMAN UTAMA & ATURAN JAWABAN:
1. TANPA BASA-BASI / TANPA DISCLAIMER KAKU:
- DILARANG KERAS menggunakan frasa robotik seperti: "Sebagai Asisten AI...", "Sebagai Asisten AI Eksekutif...", "Sebagai model bahasa...", "Mohon maaf sepertinya Anda bercanda/terpotong...", atau "Saya hanya bisa membantu seputar billing...".
- Langsung berikan jawaban yang cerdas, tepat sasaran, to-the-point, dan santun layaknya rekan kerja manusia yang pintar dan berwawasan luas.
2. JAWAB SEMUA PERTANYAAN (TERMASUK DI LUAR TOPIK BILLING):
- Jika admin menanyakan hal apa pun di luar topik billing (seperti pengetahuan umum, tokoh dunia/politik, sains, sejarah, teknologi, edukasi, coding, resep masakan, cuaca, tips harian, obrolan santai, dll.), JAWABLAH DENGAN LENGKAP, AKURAT, DAN CERDAS menggunakan seluruh kemampuan wawasan umum Anda sendiri!
- JANGAN PERNAH menolak pertanyaan atau menganggap pertanyaan bercanda/salah hanya karena bukan tentang internet/billing.
3. DATA OPERASIONAL LIVE & STATUS PELANGGAN:
- Jika admin menanyakan status pelanggan tertentu (misal: "apakah diva nunggak?", "cek redaman diva", "siapa yang terisolir?", "kenapa pelanggan X putus?"), gunakan DATA OPERASIONAL LIVE di bawah ini untuk menjawab secara presisi dan profesional.
- Jika admin menanyakan ringkasan (misal: "berapa yang isolir?", "siapa saja yang belum bayar?", "total uang tunggakan?"), rangkum dengan angka pasti dan sebutkan nama-nama pelanggan secara rapi.
4. SISTEM, TEKNIS, & TROUBLESHOOTING JARINGAN:
- Jika admin menanyakan kendala teknis (MikroTik API timeout, redaman jelek/LOS, isolir bypass, webhook payment gateway, dll.), berikan diagnosis penyebab dan solusi konkret mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
5. EKSEKUSI PERINTAH ADMINISTRATIF VIA TOOLS:
- Anda DILENGKAPI FUNCTION TOOLS untuk mengeksekusi aksi administratif. Jika pesan admin mengandung instruksi / perintah tindakan (tambah pelanggan, hapus pelanggan, reboot/restart modem ONU, ganti password/SSID WiFi, lunasi tagihan, isolir, buka isolir, kick user aktif, buat voucher hotspot, generate invoice bulanan, cek saldo digiflazz, atau top up agent), PANGGIL FUNCTION TOOL YANG SESUAI dengan parameter yang diekstrak dari percakapan.
- Untuk hapus pelanggan: SELALU panggil tool 'delete_customer_request' agar sistem meminta konfirmasi 2 langkah yang aman.
- Untuk tambah pelanggan: Panggil 'add_customer' dengan mengekstrak data dari pesan admin.
- Jika admin HANYA bertanya informasi tanpa memerintahkan perubahan data/eksekusi aksi, JANGAN panggil tool pengubah data, melainkan berikan jawaban teks langsung.
6. FORMAT LISAN NATURAL (VOICE & CHAT READY - TANPA SIMBOL & TANPA EMOJI):
- Jawaban Anda akan dibacakan langsung oleh mesin Text-to-Speech (TTS) dan ditampilkan pada aplikasi mobile.
- DILARANG KERAS MENGGUNAKAN SIMBOL ATAU EMOJI APAPUN (seperti lingkaran warna 🟢, 🔴, 🟡, ⚪, peringatan ⚠️, sinyal 📡, centang ✅, silang ❌, robot 🤖, dll.) karena akan dibaca salah oleh mesin suara menjadi "lingkaran hijau besar", "lingkaran merah", dll.
- DILARANG MENGGUNAKAN KARAKTER MARKDOWN KAKU seperti bullet points (•, -, *), tanda pagar (#), tanda bintang ganda, backticks, atau tabel.
- Jangan menyajikan data teknis mentah baris-demi-baris yang kaku. SEDERHANAKAN DATA dan rangkum menjadi kalimat percakapan yang mengalir enak didengar.
- Tambahkan logat bicara yang santun, luwes, hangat, dan ramah seperti asisten pribadi profesional, jangan kaku atau robotik.
- DILARANG KERAS menggunakan header formal di awal pesan atau template penutup klise di akhir pesan (seperti "Semoga membantu", "Ada yang bisa dibantu lagi").
- Langsung berikan penjelasan atau ringkasan data ke inti jawaban secara bersahabat.
7. PENYEDERHANAAN DAFTAR PANJANG (DATA DISAJIKAN RINGKAS & LISAN):
- Jika data yang ditanyakan berjumlah banyak (seperti daftar penunggak, pelanggan aktif, dll.), DILARANG KERAS menyebutkan puluhan nama satu per satu karena akan terlalu panjang dan melelahkan saat didengarkan.
- CUKUP SEBUTKAN total jumlahnya, total nilai nominalnya, dan sebutkan 3 sampai 5 nama teratas sebagai perwakilan.
- Akhiri dengan tawaran santun, misalnya: "Jika Anda ingin mengecek rincian pelanggan tertentu, Anda cukup sebutkan namanya."
DATA OPERASIONAL LIVE (ADMIN ACCESS):
${adminContext || '(Tidak ada data kontekstual khusus)'}
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
${knowledge}
`;
}
let liveCustomerDataPrompt = '';
if (customerContext) {
liveCustomerDataPrompt = `
----------------------------------------
DATA STATUS LIVE PELANGGAN (Dari Database Server Billing):
- Nama Pelanggan: ${customerContext.customerName || senderName}
- Paket Layanan: ${customerContext.package || '-'}
- Akun PPPoE: ${customerContext.pppoeUsername || '-'}
- Status Langganan: ${customerContext.customerStatus || 'Active'}
- Status Tagihan Saat Ini: ${customerContext.hasUnpaid ? `MEMILIKI ${customerContext.unpaidCount} TAGIHAN BELUM LUNAS (Total: Rp ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')})` : (customerContext.allPaid ? 'SEMUA TAGIHAN SUDAH LUNAS' : 'Belum ada data tagihan')}
${customerContext.unpaidInvoices ? `Rincian Tagihan Belum Lunas:\n${customerContext.unpaidInvoices}` : ''}
PETUNJUK KHUSUS JIKA MENANYAKAN TAGIHAN / TUNGGAKAN / PEMBAYARAN:
Jika pelanggan bertanya mengenai sisa tagihan, status pembayaran, jumlah uang yang harus dibayar, atau tunggakan miliknya, gunakan DATA STATUS LIVE di atas! Sebutkan nama pelanggan, total tagihan yang belum lunas beserta rincian periode bulannya secara sopan, jelas, dan ramah. Jika semua tagihan sudah lunas, beritahukan bahwa tagihan sudah lunas dan ucapkan terima kasih.
----------------------------------------
`;
}
return `Anda adalah asisten cerdas, ramah, dan solutif untuk komunitas dan layanan pelanggan "RTRWNET Management & Billing System" (Botlokal).
PEDOMAN UTAMA & ATURAN JAWABAN:
1. TANPA BASA-BASI KAKU:
- DILARANG menggunakan kata-kata klise seperti "Sebagai Asisten AI...", "Sebagai model bahasa...", atau menolak pertanyaan. Langsung jawab ke inti pertanyaan dengan ramah, santun, dan bersahabat.
2. JAWAB PERTANYAAN UMUM & DI LUAR APLIKASI:
- Jika anggota grup atau pelanggan bertanya tentang topik umum di luar aplikasi (pengetahuan umum, sains, teknologi, komputer, tips harian, edukasi, obrolan santai), JAWABLAH DENGAN CERDAS, INFORMATIF, DAN BERMANFAAT menggunakan seluruh wawasan Anda sendiri.
3. PERTANYAAN TERKAIT FITUR & TROUBLESHOOTING APLIKASI:
- Jika berkaitan dengan layanan internet, cara bayar tagihan, cara ganti sandi wifi, cek tagihan, atau troubleshooting jaringan rumah, jawablah dengan panduan praktis yang mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
4. PERTANYAAN DATA TAGIHAN PRIBADI PELANGGAN:
- Jika tersedia DATA STATUS LIVE PELANGGAN di bawah, gunakan informasi riil tersebut untuk menjawab status tunggakan atau pembayaran pelanggan secara sopan, jelas, dan ramah.
5. FORMAT LISAN NATURAL & BEBAS SIMBOL/EMOJI:
- Jawablah seperti percakapan personal yang wajar, luwes, ramah, dan santun layaknya manusia (tanpa robotik).
- DILARANG menggunakan simbol grafis atau emoji apapun (lingkaran 🟢, 🔴, 🟡, ⚠️, 💰, 📋, 🙏, dll.) dan hindari karakter formatting kaku (bullet point, bintang, pagar).
- DILARANG menggunakan header kaku atau kalimat footer penutup klise.
- Sampaikan penjelasan dengan bahasa yang santun, mudah dipahami, dan mengalir natural saat dibacakan suara.
${liveCustomerDataPrompt}
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
${knowledge}
`;
}
/**
* Konversi skema function calling Gemini ke format standar OpenAI tools
*/
function convertGeminiToolsToOpenAI(declarations) {
if (!Array.isArray(declarations) || declarations.length === 0) return [];
function lowercaseTypes(schema) {
if (!schema || typeof schema !== 'object') return schema;
const copy = Array.isArray(schema) ? [] : {};
for (const [key, val] of Object.entries(schema)) {
if (key === 'type' && typeof val === 'string') {
copy[key] = val.toLowerCase();
} else if (typeof val === 'object' && val !== null) {
copy[key] = lowercaseTypes(val);
} else {
copy[key] = val;
}
}
return copy;
}
return declarations.map(decl => ({
type: 'function',
function: {
name: decl.name,
description: decl.description,
parameters: decl.parameters ? lowercaseTypes(decl.parameters) : { type: 'object', properties: {} }
}
}));
}
/**
* Panggil Google Gemini API
*/
async function callGeminiApi(apiKey, userQuestion, senderName = 'Member', customerContext = null, isAdmin = false, adminContext = null) {
const models = ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash'];
const preferredModel = getSetting('gemini_model', 'gemini-2.5-flash');
const modelList = [preferredModel, ...models.filter(m => m !== preferredModel)];
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
let lastError = null;
for (const model of modelList) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const payload = {
contents: [
{
role: 'user',
parts: [
{
text: `${systemInstruction}\n\nPesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
}
]
}
],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 1024
}
};
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
payload.tools = [
{
functionDeclarations: adminToolDeclarations
}
];
}
const response = await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 25000
});
const candidate = response.data?.candidates?.[0];
const parts = candidate?.content?.parts || [];
// Periksa apakah model memutuskan memanggil function tool
const funcPart = parts.find(p => p.functionCall);
if (funcPart && funcPart.functionCall) {
return {
type: 'function_call',
functionCall: {
name: funcPart.functionCall.name,
args: funcPart.functionCall.args || {}
}
};
}
// Jawaban teks biasa
const textPart = parts.find(p => p.text && p.text.trim());
if (textPart && textPart.text) {
return {
type: 'text',
text: textPart.text.trim()
};
}
} catch (err) {
lastError = err;
const status = err.response?.status;
if (status === 404) {
logger.warn(`[TelegramAI] Model ${model} tidak tersedia (404), mencoba model cadangan...`);
continue;
}
if (status === 429) {
logger.warn(`[TelegramAI] Model ${model} kena limit (429), mencoba model cadangan...`);
continue;
}
if (status === 400) {
throw new Error('API Key Gemini tidak valid atau belum diaktifkan.');
}
logger.warn(`[TelegramAI] Error calling ${model}: ${err.message}`);
}
}
throw lastError || new Error('Gagal mendapatkan respon dari AI.');
}
/**
* Panggil OpenAI-Compatible API (OpenAI, DeepSeek, Groq, OpenRouter, Custom / Local Ollama)
*/
async function callOpenAiCompatibleApi({
apiKey,
baseUrl,
model,
userQuestion,
senderName = 'Member',
customerContext = null,
isAdmin = false,
adminContext = null
}) {
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
const cleanBaseUrl = String(baseUrl || 'https://api.openai.com/v1').trim().replace(/\/+$/, '');
const url = `${cleanBaseUrl}/chat/completions`;
const messages = [
{
role: 'system',
content: systemInstruction
},
{
role: 'user',
content: `Pesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
}
];
const payload = {
model: model,
messages: messages,
temperature: 0.2
};
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
payload.tools = convertGeminiToolsToOpenAI(adminToolDeclarations);
payload.tool_choice = 'auto';
}
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
}
if (cleanBaseUrl.includes('openrouter.ai')) {
headers['HTTP-Referer'] = 'https://alijayanet.net';
headers['X-Title'] = 'Alijaya Billing System';
}
const response = await axios.post(url, payload, {
headers,
timeout: 35000
});
const choice = response.data?.choices?.[0];
const message = choice?.message;
// Periksa apakah model memutuskan memanggil function tool
if (message?.tool_calls && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
const firstCall = message.tool_calls[0];
let parsedArgs = {};
if (typeof firstCall.function?.arguments === 'string') {
try {
parsedArgs = JSON.parse(firstCall.function.arguments);
} catch (e) {
parsedArgs = {};
}
} else if (typeof firstCall.function?.arguments === 'object' && firstCall.function?.arguments !== null) {
parsedArgs = firstCall.function.arguments;
}
return {
type: 'function_call',
functionCall: {
name: firstCall.function?.name,
args: parsedArgs || {}
}
};
}
// Jawaban teks biasa
if (message?.content && typeof message.content === 'string' && message.content.trim()) {
return {
type: 'text',
text: message.content.trim()
};
}
throw new Error('Respon AI kosong dari provider.');
}
/**
* Fallback Jawaban Cerdas berbasis Kata Kunci jika belum ada Gemini API Key atau offline
*/
function getOfflineAnswer(question, customerContext = null, isAdmin = false, adminContext = null) {
const q = String(question || '').toLowerCase();
// Khusus Admin jika offline: Jawab ringkasan data riil dari database
if (isAdmin) {
try {
const db = require('../config/database');
// 1. Cek pertanyaan tunggakan / tagihan belum lunas
if (q.includes('tunggak') || q.includes('belum bayar') || q.includes('tagihan') || q.includes('piutang') || q.includes('nunggak')) {
const unpaidList = db.prepare(`
SELECT c.id, c.name, c.phone, c.status, COUNT(i.id) as count, SUM(i.amount) as total
FROM customers c
JOIN invoices i ON c.id = i.customer_id
WHERE i.status != 'paid'
GROUP BY c.id
ORDER BY total DESC
`).all();
if (unpaidList.length === 0) {
return 'Alhamdulillah, saat ini seluruh pelanggan sudah melunasi tagihannya dan tidak ada tunggakan.';
}
const totalNominal = unpaidList.reduce((sum, item) => sum + Number(item.total || 0), 0);
const topNames = unpaidList.slice(0, 3).map(c => c.name).join(', ');
return `Saat ini ada ${unpaidList.length} pelanggan yang memiliki tagihan belum lunas dengan total nilai sekitar ${totalNominal.toLocaleString('id-ID')} rupiah. Beberapa di antaranya yaitu ${topNames}. Apakah Anda ingin rincian tagihan tertentu?`;
}
// 2. Cek pertanyaan isolir / terisolir / putus
if (q.includes('isolir') || q.includes('suspend') || q.includes('putus')) {
const isolatedList = db.prepare(`
SELECT id, name, phone, pppoe_username, area
FROM customers
WHERE LOWER(status) IN ('isolated', 'suspended', 'isolir')
ORDER BY id ASC
`).all();
if (isolatedList.length === 0) {
return 'Saat ini tidak ada pelanggan yang sedang terisolir. Seluruh pelanggan terpantau dalam status aktif dan normal.';
}
const isoNames = isolatedList.map(c => c.name).join(', ');
return `Saat ini ada ${isolatedList.length} pelanggan yang sedang terisolir, yaitu atas nama ${isoNames}. Layanan mereka akan langsung aktif begitu tagihan dilunasi.`;
}
// 3. Cek total / ringkasan / rekap
if (q.includes('total') || q.includes('berapa') || q.includes('rekap') || q.includes('ringkasan') || q.includes('pelanggan')) {
const stats = db.prepare(`
SELECT
COUNT(*) as total,
COUNT(CASE WHEN LOWER(status) = 'active' THEN 1 END) as active,
COUNT(CASE WHEN LOWER(status) IN ('isolated', 'suspended', 'isolir') THEN 1 END) as isolated,
COUNT(CASE WHEN LOWER(status) IN ('inactive', 'terminated', 'nonaktif') THEN 1 END) as inactive
FROM customers
`).get();
return `Saat ini tercatat ada total ${stats.total} pelanggan di dalam sistem. Rinciannya terdiri dari ${stats.active} pelanggan aktif, ${stats.isolated} pelanggan terisolir, dan ${stats.inactive} pelanggan nonaktif. Secara umum seluruh jaringan terpantau lancar.`;
}
// 4. Cek modem / redaman / ont / onu / sinyal
if (q.includes('modem') || q.includes('onu') || q.includes('ont') || q.includes('redaman') || q.includes('sinyal') || q.includes('kabel optik')) {
const allCustomers = db.prepare('SELECT id, name, pppoe_username, phone, status, genieacs_tag FROM customers').all();
let matchedCust = null;
for (const c of allCustomers) {
const lowerName = (c.name || '').toLowerCase();
const firstName = lowerName.split(/\s+/)[0];
if (firstName && firstName.length > 2 && q.includes(firstName)) {
matchedCust = c;
break;
}
}
if (matchedCust) {
return `Kondisi modem milik ${matchedCust.name} saat ini terpantau normal dan aktif di sistem. Status layanannya ${matchedCust.status === 'active' ? 'aktif lancar' : matchedCust.status}, dan koneksi internetnya berjalan baik.`;
}
return 'Secara keseluruhan kondisi modem pelanggan dan router terpantau aktif normal. Kualitas redaman optik ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika Anda ingin mengecek modem pelanggan tertentu, cukup sebutkan nama pelanggannya.';
}
} catch (err) {
logger.error(`[TelegramAI] Offline admin answer error: ${err.message}`);
}
}
// Jika pelanggan menanyakan tagihan/tunggakan dan ada data live pelanggan
if (customerContext && (q.includes('tagihan') || q.includes('tunggak') || q.includes('bayar') || q.includes('lunas') || q.includes('tempo') || q.includes('invoice'))) {
if (customerContext.hasUnpaid) {
return `Halo Kak ${customerContext.customerName}, saat ini ada ${customerContext.unpaidCount} tagihan belum lunas dengan total sebesar ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')} rupiah. Pembayaran dapat dilakukan via transfer bank atau QRIS di portal pelanggan.`;
} else if (customerContext.allPaid) {
return `Halo Kak ${customerContext.customerName}, terima kasih banyak. Seluruh tagihan internet Anda sudah tercatat lunas dan koneksi internet Anda aktif lancar.`;
}
}
// --- DIAGNOSTIK & PEMECAHAN MASALAH (TROUBLESHOOTING) DENGAN BAHASA LISAN NATURAL ---
// 1. Masalah Sudah Lunas tapi Masih Isolir
if (q.includes('masih isolir') || (q.includes('sudah bayar') && q.includes('isolir')) || (q.includes('lunas') && q.includes('isolir')) || q.includes('sudah bayar tapi')) {
return 'Jika pelanggan sudah lunas tetapi statusnya masih terisolir, biasanya alamat IP pelanggan masih tersimpan di daftar isolir MikroTik. Anda dapat membuka menu data pelanggan di web lalu klik tombol buka isolir, atau katakan buka isolir diikuti nama pelanggan.';
}
// 2. Masalah Terisolir tapi Masih Bisa Internetan
if (q.includes('masih bisa') && (q.includes('isolir') || q.includes('suspend') || q.includes('putus'))) {
return 'Jika pelanggan berstatus terisolir namun masih dapat mengakses internet, kemungkinan sesi aktif lamanya belum diputus oleh router. Anda bisa melakukan kick sesi aktif atau memastikan aturan firewall isolir berada di urutan teratas pada router MikroTik.';
}
// 3. Masalah Koneksi MikroTik API (Timeout / Gagal Konek)
if (q.includes('tidak bisa konek') || q.includes('gagal konek') || q.includes('api timeout') || q.includes('port 8728') || q.includes('port 8729') || q.includes('service api')) {
return 'Jika sistem gagal terhubung ke MikroTik API, pastikan layanan API pada port delapan ribu tujuh ratus dua puluh delapan sudah diaktifkan di menu IP Service router, dan pastikan user serta password di menu Routers sudah sesuai.';
}
// 4. Masalah Redaman Optik / LOS Lampu Merah
if ((q.includes('redaman') && (q.includes('jelek') || q.includes('tinggi') || q.includes('drop') || q.includes('dbm') || q.includes('solusi') || q.includes('masalah') || q.includes('kenapa') || q.includes('buruk'))) || q.includes('los') || q.includes('lampu merah') || q.includes('kabel putus') || q.includes('rx power')) {
return 'Nilai redaman optik yang ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika redaman lebih tinggi dari minus dua puluh delapan desibel atau muncul lampu merah sinyal putus, periksa kemungkinan kabel optik tertekuk atau konektor kotor yang perlu dibersihkan.';
}
// 5. Masalah Webhook / Payment Gateway / QRIS
if (q.includes('webhook') || (q.includes('qris') && (q.includes('gagal') || q.includes('belum lunas') || q.includes('unpaid') || q.includes('tidak otomatis')))) {
return 'Jika pembayaran QRIS belum otomatis lunas, pastikan URL callback webhook di dashboard payment gateway sudah terpasang dengan benar dan server menggunakan protokol HTTPS yang valid. Anda juga bisa melunasi tagihan secara manual melalui perintah suara asisten.';
}
// 6. Masalah WhatsApp "Menunggu Pesan Ini" / Logout
if (q.includes('menunggu pesan ini') || q.includes('waiting for this message') || q.includes('wa logout') || q.includes('wa putus')) {
return 'Jika sesi WhatsApp sering terputus, pastikan ponsel yang terhubung memiliki koneksi internet yang stabil dan mode hemat baterai telah dimatikan. Anda juga bisa melakukan reset sesi dan scan ulang kode QR di menu WhatsApp Gateway.';
}
// 7. Masalah Telegram Bot
if (q.includes('telegram') && (q.includes('akses ditolak') || q.includes('conflict') || q.includes('409') || q.includes('tidak respon'))) {
return 'Jika akses bot Telegram ditolak, pastikan ID Telegram Anda sudah dimasukkan ke pengaturan Telegram Admin ID di web admin.';
}
// 8. Masalah Database SQLite Locked
if (q.includes('database is locked') || q.includes('sqlite_busy') || q.includes('restore database')) {
return 'Jika terjadi kendala database terkunci, sistem biasanya sedang memproses transaksi. Anda dapat melakukan restart service melalui perintah pm2 restart billing.';
}
// 9. Masalah Digiflazz
if (q.includes('digiflazz') && (q.includes('gagal') || q.includes('rc 40') || q.includes('rc 41') || q.includes('rc 42') || q.includes('saldo habis'))) {
return 'Untuk transaksi Digiflazz, pastikan IP publik server sudah didaftarkan ke whitelist IP di member area Digiflazz dan saldo deposit Anda mencukupi.';
}
if (q.includes('isolir') || q.includes('putus') || q.includes('tunggak')) {
return 'Sistem isolir berjalan otomatis sesuai tanggal jatuh tempo. Begitu tagihan dibayarkan, isolir pelanggan akan langsung dicabut secara otomatis tanpa perlu tindakan manual.';
}
if (q.includes('genieacs') || q.includes('tr069') || q.includes('tr-069') || q.includes('onu') || q.includes('redaman') || q.includes('modem')) {
return 'Sistem terhubung langsung dengan GenieACS untuk auto provisioning modem baru, pengecekan redaman optik secara berkala, serta restart modem dan perubahan kata sandi wifi dari jarak jauh.';
}
if (q.includes('olt') || q.includes('snmp') || q.includes('gpon') || q.includes('epon')) {
return 'Sistem mendukung manajemen OLT GPON dan EPON untuk memantau port PON, mendeteksi modem baru yang belum terkonfigurasi, dan monitoring redaman optik massal.';
}
if (q.includes('payment') || q.includes('tripay') || q.includes('midtrans') || q.includes('xendit') || q.includes('duitku') || q.includes('qris')) {
return 'Sistem mendukung beragam gateway pembayaran otomatis seperti QRIS, transfer bank, dan e-wallet yang langsung mencabut isolir begitu pembayaran sukses.';
}
if (q.includes('whatsapp') || q.includes('wa') || q.includes('baileys') || q.includes('notif')) {
return 'Layanan WhatsApp Gateway digunakan untuk notifikasi tagihan otomatis, pengingat jatuh tempo, dan chatbot pelanggan.';
}
if (q.includes('mikrotik') || q.includes('router') || q.includes('pppoe') || q.includes('hotspot')) {
return 'Integrasi MikroTik mendukung sinkronisasi akun PPPoE dan Hotspot, monitoring pengguna aktif, serta pemutusan sesi secara otomatis.';
}
if (q.includes('radius') || q.includes('freeradius')) {
return 'Fitur FreeRADIUS Server menyediakan autentikasi terpusat untuk akun PPPoE dan Hotspot dengan alokasi IP dinamis.';
}
if (q.includes('portal') || q.includes('pelanggan') || q.includes('customer')) {
return 'Pelanggan dapat mengakses portal mandiri untuk mengecek tagihan tanpa login, membayar via QRIS, melihat riwayat pembayaran, serta mengubah nama dan sandi wifi rumah sendiri.';
}
// Jawaban standar jika belum ada keyword yang cocok
return 'Halo, ada yang bisa saya bantu hari ini? Anda bisa menanyakan status modem pelanggan, info tagihan, jumlah pelanggan aktif, atau kondisi jaringan, dan saya siap membantu.';
}
/**
* Membersihkan jawaban AI agar tampil bersih tanpa simbol, emoji, atau header/footer formal
*/
function cleanAiResponse(text) {
if (!text || typeof text !== 'string') return '';
let cleaned = text.trim();
// 1. Hapus pembungkus code block markdown
if (cleaned.startsWith('```') && cleaned.endsWith('```')) {
cleaned = cleaned.replace(/^```[a-zA-Z]*\n?/, '').replace(/\n?```$/, '').trim();
}
cleaned = cleaned.replace(/```[\s\S]*?```/g, ' ');
cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
// 2. Hapus header garis / separator
cleaned = cleaned.replace(/^[─—\-_=]{2,}\s*/gm, '');
cleaned = cleaned.replace(/\s*[─—\-_=]{2,}$/gm, '');
// 3. Hapus header bot / perusahaan di awal
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual|Bot Billing|Portal Billing)[^\n*]*\*?\n+[─—\-_=]{2,}\n*/gi, '');
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual)[^\n*]*\*?\n+/gi, '');
// 4. Hapus footer template atau slogan
cleaned = cleaned.replace(/\n+[─—\-_=]{2,}\n+.*(?:Internet Tanpa Batas|AliJaya|ALIJAYA|Semoga membantu|Ada yang bisa dibantu lagi).*$/gi, '');
cleaned = cleaned.replace(/\n+(?:Semoga membantu!?|Semoga informasinya bermanfaat!?|Ada yang bisa dibantu lagi\??|Ada hal lain yang perlu ditanyakan\??|Salam hangat,?[^\n]*)$/gi, '');
// 5. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran 🟢 🔴 🟡 ⚪ ⚠️ 📡 📶 💡 🤖 🧾 💳 ❌ ✅ dll.)
cleaned = cleaned.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, ' ');
cleaned = cleaned.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
// 6. Hapus format markdown asterisks, hashes, bullets, pipes, brackets
cleaned = cleaned.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
cleaned = cleaned.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
cleaned = cleaned.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
// 7. Bersihkan spasi ganda dan baris kosong berlebih
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
cleaned = cleaned.replace(/[ \t]+/g, ' ');
cleaned = cleaned.replace(/\s*([,.\?!])\s*/g, '$1 ');
cleaned = cleaned.replace(/,\s*,+/g, ',');
cleaned = cleaned.replace(/\.\s*\.+/g, '.');
return cleaned.trim();
}
/**
* Handle tanya jawab AI utama
*/
async function askAi({ question, senderName, customerContext = null, isAdmin = false, extraContext = {} }) {
const cleanQ = String(question || '').trim();
if (!cleanQ) {
return 'Silakan ajukan pertanyaan seputar fitur aplikasi atau layanan internet Anda.';
}
let adminContext = null;
if (isAdmin) {
try {
adminContext = await getAdminGlobalContext(cleanQ);
} catch (err) {
logger.error(`[TelegramAI] Gagal build admin context: ${err.message}`);
}
}
const provider = (getSetting('ai_provider', 'deepseek') || 'gemini').toLowerCase().trim();
let aiReply = null;
if (provider === 'gemini') {
const apiKey = getSetting('gemini_api_key', '');
if (apiKey && apiKey.trim()) {
try {
aiReply = await callGeminiApi(apiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
} catch (err) {
logger.error(`[TelegramAI] Gagal call Gemini API: ${err.message}. Menggunakan fallback jawaban lokal.`);
}
}
} else {
// Multi-provider OpenAI compatible: openai, deepseek, groq, openrouter, custom
let apiKey = '';
let baseUrl = '';
let model = '';
if (provider === 'openai') {
apiKey = getSetting('openai_api_key', '');
baseUrl = 'https://api.openai.com/v1';
model = getSetting('openai_model', 'gpt-4o-mini') || 'gpt-4o-mini';
} else if (provider === 'deepseek') {
apiKey = getSetting('deepseek_api_key', '');
baseUrl = 'https://api.deepseek.com';
model = getSetting('deepseek_model', 'deepseek-chat') || 'deepseek-chat';
} else if (provider === 'groq') {
apiKey = getSetting('groq_api_key', '');
baseUrl = 'https://api.groq.com/openai/v1';
model = getSetting('groq_model', 'llama-3.3-70b-versatile') || 'llama-3.3-70b-versatile';
} else if (provider === 'openrouter') {
apiKey = getSetting('openrouter_api_key', '');
baseUrl = 'https://openrouter.ai/api/v1';
model = getSetting('openrouter_model', 'meta-llama/llama-3.3-70b-instruct') || 'meta-llama/llama-3.3-70b-instruct';
} else if (provider === 'custom') {
apiKey = getSetting('custom_ai_api_key', '');
baseUrl = getSetting('custom_ai_base_url', 'http://localhost:11434/v1');
model = getSetting('custom_ai_model', 'llama3') || 'llama3';
}
if ((apiKey && apiKey.trim()) || provider === 'custom') {
try {
aiReply = await callOpenAiCompatibleApi({
apiKey: apiKey ? apiKey.trim() : (provider === 'custom' ? 'ollama' : ''),
baseUrl,
model,
userQuestion: cleanQ,
senderName,
customerContext,
isAdmin,
adminContext
});
} catch (err) {
logger.error(`[TelegramAI] Gagal call AI (${provider}): ${err.message}`);
// Fallback ke Gemini jika gemini_api_key tersedia
const geminiKey = getSetting('gemini_api_key', '');
if (geminiKey && geminiKey.trim()) {
try {
logger.info('[TelegramAI] Mencoba fallback ke Gemini API...');
aiReply = await callGeminiApi(geminiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
} catch (geminiErr) {
logger.error(`[TelegramAI] Fallback ke Gemini juga gagal: ${geminiErr.message}`);
}
}
}
}
}
// Jika API berhasil memberikan respon (baik function tool maupun teks)
if (aiReply) {
if (typeof aiReply === 'object' && aiReply?.type === 'function_call') {
const toolResult = await executeAdminTool(aiReply.functionCall.name, aiReply.functionCall.args, extraContext);
return cleanAiResponse(toolResult);
}
if (typeof aiReply === 'object' && aiReply?.type === 'text') {
return cleanAiResponse(aiReply.text);
}
return cleanAiResponse(typeof aiReply === 'string' ? aiReply : (aiReply?.text || JSON.stringify(aiReply)));
}
// Jika API key belum diatur atau semua panggilan API gagal, gunakan offline knowledge base
return cleanAiResponse(getOfflineAnswer(cleanQ, customerContext, isAdmin, adminContext));
}
/**
* Utilitas Diagnostik: Cek DNS via DNS-over-HTTPS (DoH Cloudflare / Google)
*/
async function checkDns(domain, type = 'A') {
try {
const cleanDomain = String(domain || '').trim().replace(/^https?:\/\//i, '').split('/')[0];
const recType = String(type || 'A').toUpperCase();
// Gunakan Cloudflare DoH untuk kehandalan tanpa terkena port 53 hijack ISP
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(cleanDomain)}&type=${recType}`;
const res = await axios.get(url, {
headers: { 'Accept': 'application/dns-json' },
timeout: 7000
});
const data = res.data;
if (!data.Answer || data.Answer.length === 0) {
return `🔍 *HASIL CEK DNS*\n\nTarget: \`${cleanDomain}\`\nRecord: *${recType}*\nStatus: ⚠️ Tidak ditemukan record ${recType} untuk domain ini.`;
}
let out = `🌐 *HASIL CEK DNS (Cloudflare Resolver)*\n\n`;
out += `Domain: \`${cleanDomain}\`\nRecord: *${recType}*\n\n*Data Jawaban:*\n`;
data.Answer.forEach(ans => {
out += `• \`${ans.data}\` (TTL: ${ans.TTL}s)\n`;
});
return out;
} catch (err) {
return `❌ Gagal melakukan lookup DNS: ${err.message}`;
}
}
/**
* Utilitas Diagnostik: Cek Host / Latency (HTTP Head Ping)
*/
async function checkPing(host) {
try {
let cleanHost = String(host || '').trim();
if (!cleanHost.startsWith('http://') && !cleanHost.startsWith('https://')) {
cleanHost = 'http://' + cleanHost;
}
const startTime = Date.now();
const res = await axios.head(cleanHost, { timeout: 8000, validateStatus: () => true });
const latency = Date.now() - startTime;
return `🏓 *HASIL PING / KONEKSI*\n\n` +
`Target: \`${cleanHost}\`\n` +
`Status: ✅ Terhubung (HTTP ${res.status})\n` +
`Latensi: *${latency} ms*`;
} catch (err) {
return `❌ Gagal menghubungi \`${host}\`: ${err.message}`;
}
}
module.exports = {
askAi,
cleanAiResponse,
checkDns,
checkPing,
getOfflineAnswer,
getAdminGlobalContext
};

View File

@@ -312,10 +312,25 @@ function initTelegram() {
bot.on('message', async (msg) => {
if (!isAdmin(msg)) return;
const text = msg.text;
if (text === '/start' || text === '/menu') return; // Handled by onText
// Logika handle text manual jika diperlukan (misal untuk perintah kick/edit)
const text = String(msg.text || '').trim();
if (!text || text.startsWith('/')) return; // command ditangani onText
if (text === '/start' || text === '/menu') return;
try {
bot.sendChatAction(msg.chat.id, 'typing').catch(() => {});
const telegramAi = require('./telegramAiService');
const answer = await telegramAi.askAi({
question: text,
senderName: (msg.from && msg.from.first_name) || 'Admin',
isAdmin: true
});
if (!answer) return;
const clean = typeof telegramAi.cleanAiResponse === 'function' ? telegramAi.cleanAiResponse(answer) : answer;
await bot.sendMessage(msg.chat.id, clean, { parse_mode: 'Markdown', reply_to_message_id: msg.message_id })
.catch(() => bot.sendMessage(msg.chat.id, clean, { reply_to_message_id: msg.message_id }));
} catch (e) {
logger.error('[TelegramBot] AI error: ' + e.message);
}
});
// Callback Query Handling

View File

@@ -208,6 +208,55 @@
</div>
</div>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI &amp; Self-Healing</h6></div>
<div class="card-bd settings-grid">
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-cpu"></i> Provider AI</h6>
<div class="fg">
<label class="fl">Provider</label>
<select class="fs" name="ai_provider">
<% const _aiProv = String(settings.ai_provider || 'deepseek'); %>
<option value="deepseek" <%= _aiProv === 'deepseek' ? 'selected' : '' %>>DeepSeek</option>
<option value="openai" <%= _aiProv === 'openai' ? 'selected' : '' %>>OpenAI</option>
<option value="groq" <%= _aiProv === 'groq' ? 'selected' : '' %>>Groq</option>
<option value="openrouter" <%= _aiProv === 'openrouter' ? 'selected' : '' %>>OpenRouter</option>
<option value="custom" <%= _aiProv === 'custom' ? 'selected' : '' %>>Custom (OpenAI-compatible)</option>
</select>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Semua provider di atas memakai format API OpenAI (chat/completions).</div>
</div>
<div class="fg"><label class="fl">DeepSeek API Key</label><input class="fc" name="deepseek_api_key" value="<%= settings.deepseek_api_key || '' %>" placeholder="sk-..."></div>
<div class="fg"><label class="fl">DeepSeek Model</label><input class="fc" name="deepseek_model" value="<%= settings.deepseek_model || 'deepseek-chat' %>"></div>
<div class="fg"><label class="fl">OpenAI API Key</label><input class="fc" name="openai_api_key" value="<%= settings.openai_api_key || '' %>" placeholder="sk-..."></div>
<div class="fg"><label class="fl">OpenAI Model</label><input class="fc" name="openai_model" value="<%= settings.openai_model || 'gpt-4o-mini' %>"></div>
<div class="fg"><label class="fl">Groq API Key</label><input class="fc" name="groq_api_key" value="<%= settings.groq_api_key || '' %>"></div>
<div class="fg"><label class="fl">Groq Model</label><input class="fc" name="groq_model" value="<%= settings.groq_model || 'llama-3.3-70b-versatile' %>"></div>
<div class="fg"><label class="fl">OpenRouter API Key</label><input class="fc" name="openrouter_api_key" value="<%= settings.openrouter_api_key || '' %>"></div>
<div class="fg"><label class="fl">OpenRouter Model</label><input class="fc" name="openrouter_model" value="<%= settings.openrouter_model || '' %>"></div>
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
</div>
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
<div class="fg">
<label class="fl">Jalankan Self-Healing otomatis (tiap 30 menit)</label>
<select class="fs" name="ai_self_healing_enabled">
<option value="false" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? '' : 'selected' %>>Nonaktif</option>
<option value="true" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? 'selected' : '' %>>Aktif</option>
</select>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Saat aktif, sistem memeriksa &amp; memperbaiki: status isolir vs tagihan, secret PPPoE hilang/tertukar profil, sesi RADIUS menggantung, dan IP modem ACS kosong.</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="btn btn-g" onclick="selfHeal('diagnose')"><i class="bi bi-search"></i> Diagnosa</button>
<button type="button" class="btn btn-p" onclick="selfHeal('run')"><i class="bi bi-wrench-adjustable"></i> Perbaiki Sekarang</button>
</div>
<pre id="selfheal-out" class="mono" style="display:none;margin-top:12px;max-height:280px;overflow:auto;font-size:11px;white-space:pre-wrap"></pre>
</div>
</div>
</div>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-sliders"></i> <%= t('admin.settings.system_title', 'Pengaturan Sistem') %></h6></div>
<div class="card-bd settings-grid">
@@ -1187,5 +1236,20 @@ function setSearch(q) {
document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); });
})();
</script>
<script>
async function selfHeal(mode) {
const out = document.getElementById('selfheal-out');
out.style.display = 'block';
out.textContent = mode === 'run' ? 'Menjalankan perbaikan...' : 'Mendiagnosa...';
try {
const res = await fetch('/admin/api/self-heal/' + (mode === 'run' ? 'run' : 'diagnose'), { method: mode === 'run' ? 'POST' : 'GET' });
const data = await res.json();
out.textContent = JSON.stringify(data, null, 2);
} catch (e) {
out.textContent = 'Gagal: ' + e.message;
}
}
</script>
</body>
</html>