Files

2672 lines
105 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
import fs from 'fs';
import pino from 'pino';
import qrcode from 'qrcode-terminal';
import makeWASocket, { useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, Browsers } from '@whiskeysockets/baileys';
const require = createRequire(import.meta.url);
const QRCodeNode = require('qrcode');
const { logger } = require('../config/logger.js');
const { getSetting, getNowLocal, formatDateLocal, getCurrentDateInTimezone } = require('../config/settingsManager.js');
const db = require('../config/database.js');
const customerDevice = require('./customerDeviceService.js');
const { WaLidStore } = require('./waLidStore.js');
const billingSvc = require('./billingService.js');
const mikrotikSvc = require('./mikrotikService.js');
const customerSvc = require('./customerService.js');
const agentSvc = require('./agentService.js');
const { formatIndonesianPeriod, formatUnpaidInvoicesSummary } = require('./whatsappService.js');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..');
// Cache Store Berkinerja Tinggi untuk Baileys Signal Keys & Retries (Anti 'Waiting for this message')
class BaileysCacheStore {
constructor(ttlMs = 600000, maxSize = 5000) {
this.cache = new Map();
this.ttlMs = ttlMs;
this.maxSize = maxSize;
}
get(key) {
const item = this.cache.get(key);
if (!item) return undefined;
if (Date.now() - item.time > this.ttlMs) {
this.cache.delete(key);
return undefined;
}
return item.val;
}
set(key, val) {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, { val, time: Date.now() });
}
del(key) {
this.cache.delete(key);
}
flushAll() {
this.cache.clear();
}
}
const msgRetryCounterCache = new BaileysCacheStore(600000);
const userDevicesCache = new BaileysCacheStore(3600000);
const placeholderResendCache = new BaileysCacheStore(600000);
// Multi-Layer Message Store (Memory LRU + SQLite Fallback) untuk menangani permintaan retry dari WhatsApp
class MultiLayerMessageStore {
constructor(maxSize = 5000) {
this.store = new Map();
this.maxSize = maxSize;
}
cache(key, message) {
if (!key || !key.id || !message) return;
const msgId = String(key.id);
const remote = String(key.remoteJid || '');
const combinedKey = `${remote}:${msgId}`;
let protoMsg = message;
if (message.message) protoMsg = message.message;
this.store.set(combinedKey, protoMsg);
this.store.set(msgId, protoMsg);
if (this.store.size > this.maxSize * 2) {
const keysToDelete = Array.from(this.store.keys()).slice(0, 500);
for (const k of keysToDelete) this.store.delete(k);
}
}
async get(key) {
if (!key || !key.id) return undefined;
const msgId = String(key.id);
const remote = String(key.remoteJid || '');
const combinedKey = `${remote}:${msgId}`;
if (this.store.has(combinedKey)) {
return this.store.get(combinedKey);
}
if (this.store.has(msgId)) {
return this.store.get(msgId);
}
for (const [k, v] of this.store.entries()) {
if (k.endsWith(`:${msgId}`) || k === msgId) {
return v;
}
}
// Database Fallback dari wa_chat_messages
try {
const row = db.prepare('SELECT message_text FROM wa_chat_messages WHERE meta_message_id = ? LIMIT 1').get(msgId);
if (row && row.message_text) {
return { conversation: row.message_text };
}
} catch (_) {}
return undefined;
}
}
const messageStore = new MultiLayerMessageStore(5000);
function cacheSentMessage(key, message) {
messageStore.cache(key, message);
}
/**
* Membersihkan file session dan pre-key usang jika terjadi Bad MAC desync
* tanpa menghapus creds.json (tidak perlu scan QR ulang)
*/
export function cleanStaleSessionKeys(authFolder) {
try {
const folder = authFolder || path.resolve(projectRoot, getSetting('whatsapp_auth_folder', 'auth_info_baileys'));
if (!fs.existsSync(folder)) return 0;
const files = fs.readdirSync(folder);
let cleaned = 0;
for (const f of files) {
if (f.startsWith('session-') || f.startsWith('pre-key-') || f.startsWith('sender-key-') || f.startsWith('app-state-sync-')) {
try {
fs.unlinkSync(path.join(folder, f));
cleaned++;
} catch (_) {}
}
}
logger.info(`[WA Session Auto-Repair] Membersihkan ${cleaned} session/pre-key usang (creds.json tetap dipertahankan).`);
return cleaned;
} catch (err) {
logger.warn(`[WA Session Auto-Repair] Gagal membersihkan session keys: ${err.message}`);
return 0;
}
}
// Rate Limiting untuk WhatsApp Bot Self-Service
const rateLimitStore = new Map(); // Format: { phone: { count: 0, lastReset: timestamp } }
const MAX_COMMANDS_PER_MINUTE = 10;
const COMMAND_COOLDOWN_MS = 2000; // 2 detik cooldown antar perintah
const commandCooldownStore = new Map(); // Format: { phone: lastCommandTimestamp }
function checkRateLimit(phone) {
const now = Date.now();
const userLimit = rateLimitStore.get(phone);
if (!userLimit) {
rateLimitStore.set(phone, { count: 1, lastReset: now });
return { allowed: true, remaining: MAX_COMMANDS_PER_MINUTE - 1 };
}
// Reset counter setiap menit
if (now - userLimit.lastReset >= 60000) {
rateLimitStore.set(phone, { count: 1, lastReset: now });
return { allowed: true, remaining: MAX_COMMANDS_PER_MINUTE - 1 };
}
// Cek limit
if (userLimit.count >= MAX_COMMANDS_PER_MINUTE) {
const resetTime = userLimit.lastReset + 60000;
const waitTime = Math.ceil((resetTime - now) / 1000);
return { allowed: false, waitTime };
}
// Increment counter
userLimit.count++;
return { allowed: true, remaining: MAX_COMMANDS_PER_MINUTE - userLimit.count };
}
function checkCommandCooldown(phone) {
const now = Date.now();
const lastCommand = commandCooldownStore.get(phone);
if (!lastCommand) {
commandCooldownStore.set(phone, now);
return { allowed: true };
}
const elapsed = now - lastCommand;
if (elapsed < COMMAND_COOLDOWN_MS) {
const waitTime = Math.ceil((COMMAND_COOLDOWN_MS - elapsed) / 1000);
return { allowed: false, waitTime };
}
commandCooldownStore.set(phone, now);
return { allowed: true };
}
let authLidReverse = new Map();
export function loadAuthLidReverseMap(authFolder) {
try {
const folder = authFolder || path.resolve(projectRoot, getSetting('whatsapp_auth_folder', 'auth_info_baileys'));
if (!fs.existsSync(folder)) return;
const files = fs.readdirSync(folder);
const next = new Map();
for (const f of files) {
const forward = /^lid-mapping-(\d+)\.json$/i.exec(f);
const reverse = /^lid-mapping-(\d+)_reverse\.json$/i.exec(f);
if (!forward && !reverse) continue;
const raw = fs.readFileSync(path.join(folder, f), 'utf8');
let value = null;
try {
value = JSON.parse(raw);
} catch (e) {
value = String(raw || '').trim().replace(/^"|"$/g, '');
}
if (forward) {
let phoneDigits = forward[1].replace(/\D/g, '');
if (phoneDigits.startsWith('0')) phoneDigits = '62' + phoneDigits.slice(1);
else if (phoneDigits.startsWith('8')) phoneDigits = '62' + phoneDigits;
const lidDigits = String(value || '').replace(/\D/g, '');
if (!phoneDigits || !lidDigits) continue;
next.set(lidDigits + '@lid', phoneDigits);
next.set(lidDigits, phoneDigits);
continue;
}
if (reverse) {
const lidDigits = String(reverse[1] || '').replace(/\D/g, '');
let phoneDigits = String(value || '').replace(/\D/g, '');
if (phoneDigits.startsWith('0')) phoneDigits = '62' + phoneDigits.slice(1);
else if (phoneDigits.startsWith('8')) phoneDigits = '62' + phoneDigits;
if (!phoneDigits || !lidDigits) continue;
next.set(lidDigits + '@lid', phoneDigits);
next.set(lidDigits, phoneDigits);
continue;
}
}
authLidReverse = next;
if (authLidReverse.size > 0) {
logger.info(`[WA LID Reverse Map] Memuat ${authLidReverse.size} pemetaan LID otomatis dari folder auth.`);
}
} catch (e) {
logger.warn(`[WA LID Reverse Map] Gagal memuat auth lid reverse map: ${e.message}`);
}
}
function getPhoneFromKey(key) {
if (!key) return null;
const remoteJid = key.remoteJid || key;
if (!remoteJid || typeof remoteJid !== 'string') return null;
// 1. Check senderPn jika ada
if (key.senderPn && key.senderPn.endsWith('@s.whatsapp.net')) {
let p = key.senderPn.split('@')[0].replace(/\D/g, '');
if (p.startsWith('0')) p = '62' + p.slice(1);
else if (p.startsWith('8')) p = '62' + p;
else if (!p.startsWith('62')) p = '62' + p;
return p;
}
// 2. Check jika remoteJid atau senderLid adalah @lid
if (remoteJid.endsWith('@lid') || (key.senderLid && key.senderLid.endsWith('@lid'))) {
const lidJid = remoteJid.endsWith('@lid') ? remoteJid : key.senderLid;
const lidUser = lidJid.split('@')[0];
// Cek authLidReverse
const fromAuth = authLidReverse.get(lidJid) || authLidReverse.get(lidUser);
if (fromAuth) return fromAuth;
// Cek lidStore
try {
const lidStore = getDefaultLidStore();
if (lidStore) {
const mapped = lidStore.get(lidJid) || lidStore.get(lidUser);
if (mapped) {
let p = String(mapped).replace(/\D/g, '');
if (p.startsWith('0')) p = '62' + p.slice(1);
else if (p.startsWith('8')) p = '62' + p;
else if (!p.startsWith('62')) p = '62' + p;
return p;
}
}
} catch (_) {}
}
// 3. Extract phone number dari JID standar
const [user, host] = remoteJid.split('@');
if (!user || !host) return null;
const phone = user.replace(/\D/g, '');
if (!phone) return null;
if (phone.startsWith('0')) return '62' + phone.slice(1);
if (phone.startsWith('8')) return '62' + phone;
return phone;
}
function waBrand() {
const companyHeader = getSetting('company_header', 'OPEN-ISP');
const footerInfo = getSetting('footer_info', 'Internet Tanpa Batas');
const sep = '─'.repeat(30);
return { companyHeader, footerInfo, sep };
}
function waWrap(title, body) {
const { companyHeader, footerInfo, sep } = waBrand();
const t = String(title || '').trim();
const b = String(body || '').trim();
const head = t ? `${t}\n${sep}\n🏢 *${companyHeader}*\n${sep}\n` : `🏢 *${companyHeader}*\n${sep}\n`;
const foot = footerInfo ? `\n${sep}\n${footerInfo}` : '';
return head + b + foot;
}
function waAutoWrap(text) {
const { sep } = waBrand();
const t = String(text || '').trim();
if (!t) return t;
if (t.includes(sep)) return t;
return waWrap('', t);
}
function getMessageText(m) {
const msg = m.message;
if (!msg) return '';
if (msg.conversation) return msg.conversation;
if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text;
return '';
}
/** Field tambahan Baileys 6.7: senderPn = JID nomor, senderLid = JID @lid */
function normalizeKey(key) {
if (!key) return {};
return {
remoteJid: key.remoteJid,
senderPn: key.senderPn || null,
senderLid: key.senderLid || null
};
}
async function resolveCustomerTag(key, lidStore) {
const { remoteJid, senderPn, senderLid } = normalizeKey(key);
if (!remoteJid || remoteJid.endsWith('@g.us')) return null;
const tryPnAndCache = async (pnJid, lidJid) => {
const digits = customerDevice.phoneFromPnJid(pnJid);
if (!digits) return null;
// 1. Coba cari di Billing Database dulu
const customer = customerSvc.findCustomerByAny(digits);
if (customer && (customer.genieacs_tag || customer.pppoe_username)) {
const tag = customer.genieacs_tag || customer.pppoe_username || digits;
if (lidJid) lidStore.set(lidJid, tag);
lidStore.set(pnJid, tag);
return tag;
}
// 2. Fallback: Cari langsung di GenieACS (berdasarkan tag yang mirip nomor)
const found = await customerDevice.findDeviceWithTagVariants(digits);
if (!found) return null;
if (lidJid) lidStore.set(lidJid, found.canonicalTag);
lidStore.set(pnJid, found.canonicalTag);
return found.canonicalTag;
};
if (remoteJid.endsWith('@s.whatsapp.net')) {
const found = await tryPnAndCache(remoteJid, senderLid && senderLid.endsWith('@lid') ? senderLid : null);
if (found) return found;
return lidStore.get(remoteJid);
}
if (remoteJid.endsWith('@lid')) {
const cached = lidStore.get(remoteJid);
if (cached) return cached;
if (senderPn && senderPn.endsWith('@s.whatsapp.net')) {
return tryPnAndCache(senderPn, remoteJid);
}
return null;
}
return null;
}
async function resolveCustomerContext(key, lidStore) {
const { remoteJid, senderPn, senderLid } = normalizeKey(key);
if (!remoteJid || remoteJid.endsWith('@g.us')) return null;
const pnDigits = senderPn && senderPn.endsWith('@s.whatsapp.net') ? customerDevice.phoneFromPnJid(senderPn) : null;
const remoteDigits = remoteJid.endsWith('@s.whatsapp.net') ? customerDevice.phoneFromPnJid(remoteJid) : null;
const digits = pnDigits || remoteDigits || null;
const cached =
(remoteJid.endsWith('@lid') && lidStore ? lidStore.get(remoteJid) : null) ||
(senderLid && senderLid.endsWith('@lid') && lidStore ? lidStore.get(senderLid) : null) ||
(senderPn && senderPn.endsWith('@s.whatsapp.net') && lidStore ? lidStore.get(senderPn) : null) ||
(remoteJid.endsWith('@s.whatsapp.net') && lidStore ? lidStore.get(remoteJid) : null) ||
null;
let customer = null;
if (digits) customer = customerSvc.findCustomerByAny(digits);
if (!customer && cached) customer = customerSvc.findCustomerByAny(cached);
let billingKey = digits || null;
if (!billingKey && customer && customer.phone) billingKey = String(customer.phone);
if (!billingKey && cached && /^\d+$/.test(String(cached))) billingKey = String(cached);
let deviceKey =
(customer && (customer.genieacs_tag || customer.pppoe_username) ? (customer.genieacs_tag || customer.pppoe_username) : null) ||
cached ||
digits ||
null;
if (!deviceKey) return null;
if (digits && lidStore) {
const tagToCache = (customer && (customer.genieacs_tag || customer.pppoe_username)) ? (customer.genieacs_tag || customer.pppoe_username) : deviceKey;
const pnJid = senderPn && senderPn.endsWith('@s.whatsapp.net') ? senderPn : (remoteJid.endsWith('@s.whatsapp.net') ? remoteJid : null);
const lidJid = remoteJid.endsWith('@lid') ? remoteJid : (senderLid && senderLid.endsWith('@lid') ? senderLid : null);
if (pnJid) lidStore.set(pnJid, tagToCache);
if (lidJid) lidStore.set(lidJid, tagToCache);
}
return { billingKey: billingKey || deviceKey, deviceKey };
}
function formatInfo(data) {
if (!data) return waWrap('📡 *STATUS ONU*', '❌ Data perangkat tidak ditemukan di GenieACS.');
const lines = [
`🟢 *Status:* ${data.status}`,
`📶 *SSID:* ${data.ssid}`,
`⏱️ *Last Inform:* ${data.lastInform}`,
`📡 *RX Power:* ${data.rxPower}`,
`🌐 *PPPoE IP:* ${data.pppoeIP}`,
`👤 *PPPoE User:* ${data.pppoeUsername}`,
`⏳ *Uptime:* ${data.uptime}`,
`⏳ *PPPoE Uptime:* ${data.pppoeUptime || '-'}`,
`📱 *User WiFi (2.4G):* ${data.totalAssociations}`,
`🔧 *Model:* ${data.model}`,
`🏷️ *Serial Number:* ${data.serialNumber}`,
`💾 *Firmware:* ${data.softwareVersion}`,
`📍 *Tag:* ${data.lokasi}`
];
return waWrap('📡 *STATUS ONU*', lines.join('\n'));
}
function formatCekTerhubung(data) {
if (!data) return waWrap('👥 *PERANGKAT TERHUBUNG*', '❌ Data tidak tersedia.');
const list = data.connectedUsers || [];
if (list.length === 0) {
return waWrap('👥 *PERANGKAT TERHUBUNG*', '⚠️ Tidak ada entri host/perangkat terhubung di data ONU.');
}
const content = `📊 *${list.length} perangkat tercatat:*\n`;
const rows = list.slice(0, 25).map((u, i) => {
const num = String(i + 1).padStart(2, '0');
return `${num}. 📱 ${u.hostname}\n 🌐 ${u.ip} | ${u.status}`;
}).join('\n\n');
const tail = list.length > 25 ? `\n\n_…dan ${list.length - 25} perangkat lainnya_` : '';
return waWrap('👥 *PERANGKAT TERHUBUNG*', content + rows + tail);
}
function formatBillingSummary(stats) {
const formatter = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
return waWrap(
'💰 *RINGKASAN BILLING*',
`📈 *Total Pendapatan:* ${formatter.format(stats.totalRevenue)}\n` +
`📅 *Bulan Ini:* ${formatter.format(stats.thisMonth)}\n` +
`⏳ *Piutang (Pending):* ${formatter.format(stats.pendingAmount)}\n` +
`🧾 *Tagihan Belum Lunas:* ${stats.unpaidCount} invoice\n\n` +
`💡 _Gunakan perintah lain untuk detail._`
);
}
function formatCustomerInvoices(invoices, name) {
const title = `🧾 *STATUS TAGIHAN*\n👤 *${name}*`;
if (!invoices || invoices.length === 0) return waWrap(title, "✅ Tidak ada data tagihan. Terima kasih!");
const formatter = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
const unpaid = invoices.filter(inv => inv.status !== 'paid');
let summaryHeader = '';
if (unpaid.length > 0) {
const summary = formatUnpaidInvoicesSummary(unpaid);
if (summary.isMultiple) {
summaryHeader = `⚠️ *TOTAL TUNGGAKAN:* *Rp ${summary.totalAmountStr}*\n📌 *Tunggakan:* ${summary.periodText}\n\n`;
} else {
summaryHeader = `⚠️ *TOTAL BELUM BAYAR:* *Rp ${summary.totalAmountStr}*\n📌 *Periode:* ${summary.periodText}\n\n`;
}
} else {
summaryHeader = `🟢 *STATUS:* Semua tagihan sudah *LUNAS* ✅\n\n`;
}
const list = invoices.map(inv => {
const isPaid = inv.status === 'paid';
const status = isPaid ? '✅ LUNAS' : '❌ BELUM BAYAR';
const periodStr = formatIndonesianPeriod(inv.period_month, inv.period_year);
return `📅 *Periode:* ${periodStr}\n💰 *Total:* ${formatter.format(Number(inv.amount || 0))}\n📌 *Status:* ${status}\n🆔 *No. Invoice:* #${inv.id}`;
}).join('\n\n');
const footer = unpaid.length > 0
? `\n\n💡 _Gunakan No. Invoice saat melakukan pembayaran._`
: `\n\nTerima kasih telah setia menggunakan layanan internet kami!`;
return waWrap(title, summaryHeader + list + footer);
}
function formatActiveMikrotik(pppoe, hotspot) {
const { sep } = waBrand();
const p = `👥 *PPPoE Active:* ${pppoe.length} user\n` + pppoe.slice(0, 10).map(u => ` ◦ ${u.name} (${u.address})`).join('\n') + (pppoe.length > 10 ? '\n _...dll_' : '');
const h = `\n\n🔥 *Hotspot Active:* ${hotspot.length} user\n` + hotspot.slice(0, 10).map(u => ` ◦ ${u.user} (${u.address})`).join('\n') + (hotspot.length > 10 ? '\n _...dll_' : '');
return waWrap('🌐 *MIKROTIK ACTIVE*', p + h);
}
function parseNumbers(input) {
if (Array.isArray(input)) return input.map(x => String(x).trim()).filter(Boolean);
if (typeof input === 'string' && input.trim()) {
return input.split(',').map(x => x.trim()).filter(Boolean);
}
return [];
}
function getWhatsappAdminNumbers() {
const primary = parseNumbers(getSetting('whatsapp_admin_numbers', []));
const legacy = parseNumbers(getSetting('admins', []));
const companyPhone = parseNumbers(getSetting('company_phone', ''));
let dbAdmin = [];
try {
const raw = db.getAppSetting('whatsapp_admin_numbers', null) || db.getAppSetting('admins', null);
if (raw) dbAdmin = parseNumbers(raw);
} catch (e) {}
const combined = Array.from(new Set([...primary, ...legacy, ...companyPhone, ...dbAdmin]));
return combined;
}
function loadWhatsappAdminSet(lidStore) {
const list = getWhatsappAdminNumbers();
const set = new Set();
for (const n of list) {
const s = String(n).trim();
const digits = s.replace(/\D/g, '');
if (digits.length >= 8) {
for (const c of customerDevice.expandTagCandidates(digits)) {
set.add(c);
}
} else if (s) {
set.add(s);
}
}
if (lidStore && typeof lidStore.getAll === 'function') {
const storeMap = lidStore.getAll() || {};
for (const [key, val] of Object.entries(storeMap)) {
const valDigits = String(val || '').replace(/\D/g, '');
if (valDigits) {
for (const c of customerDevice.expandTagCandidates(valDigits)) {
if (set.has(c)) {
set.add(key);
set.add(key.split('@')[0]);
}
}
}
}
}
return set;
}
/** Admin dikenali dari nomor WA (bukan @lid saja). Pakai senderPn atau remoteJid @s.whatsapp.net / @lid */
function isWhatsappAdminKey(key, adminSet, sock, lidStore) {
if (sock && sock.user && sock.user.id) {
const selfJid = sock.user.id.split(':')[0];
const selfLid = sock.user.lid ? sock.user.lid.split('@')[0] : null;
const nk = normalizeKey(key);
const sender = nk.senderPn || nk.remoteJid;
if (sender) {
const senderUser = sender.split('@')[0];
if (senderUser === selfJid || (selfLid && senderUser === selfLid)) {
return true; // Pesan dari nomor bot sendiri (self-chat) selalu dianggap admin
}
}
}
if (!adminSet || adminSet.size === 0) return false;
const nk = normalizeKey(key);
// 1. Cek JID nomor HP langsung
const pnJid =
nk.senderPn && nk.senderPn.endsWith('@s.whatsapp.net')
? nk.senderPn
: nk.remoteJid && nk.remoteJid.endsWith('@s.whatsapp.net')
? nk.remoteJid
: null;
let digits = null;
if (pnJid) {
digits = customerDevice.phoneFromPnJid(pnJid);
}
// 2. Cek jika LID JID
if (nk.remoteJid && nk.remoteJid.endsWith('@lid')) {
const lidUser = nk.remoteJid.split('@')[0];
if (adminSet.has(nk.remoteJid) || adminSet.has(lidUser)) return true;
if (lidStore) {
const cachedTag = lidStore.get(nk.remoteJid) || lidStore.get(lidUser);
if (cachedTag) {
digits = String(cachedTag).replace(/\D/g, '');
}
}
}
if (!digits) return false;
for (const c of customerDevice.expandTagCandidates(digits)) {
if (adminSet.has(c)) return true;
}
return false;
}
function parseCommand(text, isAdmin) {
const t = String(text || '').trim();
if (!t) return null;
const parts = t.split(/\s+/);
const rawFirst = parts[0];
const cleanFirst = rawFirst.replace(/^[\/!#]/, '');
const cmd = cleanFirst.toLowerCase();
const rest = t.slice(rawFirst.length).trim();
if (['menu', 'bantuan', 'help'].includes(cmd)) return { cmd: 'menu', rest: '' };
if (isAdmin && ['admin', 'adminmenu', 'menuadmin'].includes(cmd)) return { cmd: 'adminmenu', rest: '' };
if (isAdmin && ['listonu', 'listdevice', 'daftarperangkat'].includes(cmd)) {
return { cmd: 'listonu', admin: true };
}
// Admin Mikrotik
if (isAdmin && cmd === 'mtactive') return { cmd: 'mtactive', admin: true };
if (isAdmin && cmd === 'kickuser' && parts.length >= 2) return { cmd: 'kickuser', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'addpppoe' && parts.length >= 4) return { cmd: 'addpppoe', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'editpppoe' && parts.length >= 3) return { cmd: 'editpppoe', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'delpppoe' && parts.length >= 2) return { cmd: 'delpppoe', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'addhotspot' && parts.length >= 4) return { cmd: 'addhotspot', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'vcr' && parts.length >= 3) return { cmd: 'vcr', admin: true, args: parts.slice(1) };
if (isAdmin && cmd === 'delhotspot' && parts.length >= 2) return { cmd: 'delhotspot', admin: true, args: parts.slice(1) };
// Agent / Hotspot Voucher (Sistem Saldo): vcr or vcr <paket>
// Admin Billing & Pelanggan
if (isAdmin && cmd === 'ringkasan') return { cmd: 'ringkasan', admin: true };
if (isAdmin && cmd === 'lunas' && parts.length >= 2) return { cmd: 'lunas', admin: true, targetId: rest || parts.slice(1).join(' ') };
if (isAdmin && cmd === 'generate' && parts.length >= 3) return { cmd: 'generate', admin: true, month: parts[1], year: parts[2] };
if (isAdmin && cmd === 'isolir' && parts.length >= 2) return { cmd: 'isolir', admin: true, targetId: parts[1] };
if (isAdmin && cmd === 'buka' && parts.length >= 2) return { cmd: 'buka', admin: true, targetId: parts[1] };
if (isAdmin && ['info', 'cekstatus', 'cekonu', 'statusonu'].includes(cmd) && parts.length >= 2) {
return { cmd: 'info', admin: true, targetTag: parts[1], rest: '' };
}
if (isAdmin && cmd === 'cekterhubung' && parts.length >= 2) {
return { cmd: 'cekterhubung', admin: true, targetTag: parts[1] };
}
if (isAdmin && (cmd === 'reboot' || cmd === 'restartonu') && parts.length >= 2) {
return { cmd: 'reboot', admin: true, targetTag: parts[1] };
}
if (isAdmin && cmd === 'gantissid' && parts.length >= 3) {
return { cmd: 'gantissid', admin: true, targetTag: parts[1], rest: parts.slice(2).join(' ') };
}
if (isAdmin && cmd === 'gantisandi' && parts.length >= 3) {
return { cmd: 'gantisandi', admin: true, targetTag: parts[1], rest: parts.slice(2).join(' ') };
}
// Customer Commands
if (['cektagihan', 'tagihan', 'cek_tagihan', 'bill', 'invoicewa'].includes(cmd)) return { cmd: 'cektagihan', rest: '' };
if (['info', 'cekstatus', 'cekonu', 'statusonu', 'status'].includes(cmd)) return { cmd: 'info', rest: '' };
if (cmd === 'cekterhubung') return { cmd: 'cekterhubung', rest: '' };
if (cmd === 'gantissid') return { cmd: 'gantissid', rest };
if (cmd === 'gantisandi') return { cmd: 'gantisandi', rest };
if (cmd === 'daftar') return { cmd: 'daftar', rest };
if (cmd === 'reboot' || cmd === 'restartonu') return { cmd: 'reboot', rest: '' };
return null;
}
async function resolveTargetTagForAdmin(tagToken) {
if (!tagToken) return null;
// 1. Coba cari di database billing dulu (by name, pppoe, phone, etc)
const cust = customerSvc.findCustomerByAny(tagToken);
if (cust) return cust.genieacs_tag || cust.pppoe_username || cust.phone || tagToken;
return tagToken;
}
function formatListOnu(devices) {
const companyHeader = getSetting('company_header', 'OPEN-ISP');
const footerInfo = getSetting('footer_info', 'Internet Tanpa Batas');
const header = `📱 *DAFTAR ONU BER-TAG*
${'─'.repeat(30)}
📊 *${companyHeader}*
${'─'.repeat(30)}
`;
const footer = `
${'─'.repeat(30)}
${footerInfo}`;
if (!devices || devices.length === 0) {
return header + `❌ Tidak ada perangkat dengan tag.` + footer;
}
const content = `📊 *${devices.length} perangkat ditemukan:*
`;
const lines = devices.map((d, i) => {
const num = String(i + 1).padStart(2, '0');
const tags = Array.isArray(d._tags) ? d._tags.join(', ') : String(d._tags || '-');
let pppoeUsername = '-';
try {
if (d.InternetGatewayDevice?.WANDevice) {
for (const wanKey of Object.keys(d.InternetGatewayDevice.WANDevice)) {
if (wanKey.startsWith('_')) continue;
const wanDev = d.InternetGatewayDevice.WANDevice[wanKey];
if (wanDev?.WANConnectionDevice) {
for (const connKey of Object.keys(wanDev.WANConnectionDevice)) {
if (connKey.startsWith('_')) continue;
const connDev = wanDev.WANConnectionDevice[connKey];
if (connDev?.WANPPPConnection) {
for (const pppKey of Object.keys(connDev.WANPPPConnection)) {
if (pppKey.startsWith('_')) continue;
const pppConn = connDev.WANPPPConnection[pppKey];
if (pppConn?.Username?._value) {
pppoeUsername = pppConn.Username._value;
break;
}
}
}
if (pppoeUsername !== '-') break;
}
}
if (pppoeUsername !== '-') break;
}
}
if (pppoeUsername === '-' && d.Device?.PPP?.Interface) {
for (const pppKey of Object.keys(d.Device.PPP.Interface)) {
if (pppKey.startsWith('_')) continue;
const pppInt = d.Device.PPP.Interface[pppKey];
if (pppInt?.Username?._value) {
pppoeUsername = pppInt.Username._value;
break;
}
}
}
} catch (_) {}
const li = d._lastInform ? formatDateLocal(d._lastInform) : '-';
return `${num}. 🏷️ *${tags}*
� PPPoE: ${pppoeUsername}
⏱️ Last inform: ${li}`;
}).join('\n\n');
return header + content + lines + footer;
}
function splitWaChunks(text, maxLen = 3500) {
if (text.length <= maxLen) return [text];
const chunks = [];
let rest = text;
while (rest.length > 0) {
if (rest.length <= maxLen) {
chunks.push(rest);
break;
}
let cut = rest.lastIndexOf('\n\n', maxLen);
if (cut < maxLen * 0.5) cut = maxLen;
chunks.push(rest.slice(0, cut).trim());
rest = rest.slice(cut).trim();
}
return chunks;
}
/** Kirim notifikasi ke pelanggan saat admin mengubah SSID/Password */
async function notifyCustomer(sock, lidStore, tag, message) {
logger.info(`[WA notifyCustomer] Memulai pengiriman untuk tag: "${tag}"`);
try {
const text = waAutoWrap(message);
const normalizeDigitsTo62 = (raw) => {
let digits = String(raw || '').replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
else if (!digits.startsWith('62')) digits = '62' + digits;
return digits;
};
// 1. Coba cari data pelanggan dari database dulu untuk mendapatkan nomor HP aslinya
const cust = customerSvc.findCustomerByAny(tag);
let phoneNumber = '';
if (cust && cust.phone) {
phoneNumber = normalizeDigitsTo62(cust.phone);
}
logger.info(`[WA notifyCustomer] Resolusi database tag "${tag}" -> Phone: "${phoneNumber}"`);
// Jika tag itu sendiri adalah nomor HP, gunakan sebagai fallback
if (!phoneNumber) {
const parsedTag = normalizeDigitsTo62(tag);
if (parsedTag.length >= 10) {
phoneNumber = parsedTag;
}
}
if (phoneNumber) {
const sent = await sendWA(phoneNumber, text);
if (sent) {
logger.info(`[WA notifyCustomer] Notifikasi terkirim via gateway universal ke ${phoneNumber}`);
return true;
}
}
let targetJid = null;
// 2. Jika ada nomor HP, tanyakan ke WhatsApp server untuk JID yang benar (LID atau PN JID)
if (phoneNumber.length >= 10) {
const directJid = `${phoneNumber}@s.whatsapp.net`;
logger.info(`[WA notifyCustomer] Mengecek JID terdaftar untuk ${directJid} via onWhatsApp...`);
try {
const [waCheck] = await sock.onWhatsApp(directJid);
if (waCheck && waCheck.exists) {
targetJid = waCheck.jid || directJid || waCheck.lid;
logger.info(`[WA notifyCustomer] JID terverifikasi dari WhatsApp server untuk nomor ${phoneNumber}: ${targetJid}`);
} else {
logger.info(`[WA notifyCustomer] Nomor ${phoneNumber} tidak terdaftar menurut onWhatsApp`);
}
} catch (err) {
logger.error(`[WA notifyCustomer] Gagal onWhatsApp check untuk nomor ${phoneNumber}: ${err.message}`);
}
// Jika onWhatsApp gagal/error, fallback ke directJid asli
if (!targetJid) {
logger.info(`[WA notifyCustomer] Menggunakan fallback JID langsung: ${directJid}`);
targetJid = directJid;
}
}
// 3. Fallback: Cari JID pelanggan berdasarkan tag di lidStore jika targetJid belum terisi
if (!targetJid) {
const customerJid = lidStore ? lidStore.getByTag(tag) : null;
if (customerJid) {
logger.info(`[WA notifyCustomer] Menemukan JID dari lidStore untuk tag "${tag}": ${customerJid}`);
targetJid = customerJid;
}
}
if (targetJid) {
logger.info(`[WA notifyCustomer] Mengirim pesan ke JID target: ${targetJid}`);
const res = await sock.sendMessage(targetJid, { text });
if (res && res.key && res.message) {
cacheSentMessage(res.key, res.message);
}
logger.info(`[WA notifyCustomer] Pesan berhasil dikirim ke JID target: ${targetJid}`);
return true;
}
logger.warn(`[WA notifyCustomer] Tidak ada tujuan valid untuk mengirim notifikasi pelanggan tag: ${tag}`);
return false;
} catch (e) {
logger.error('[WA notifyCustomer] Gagal mengirim notifikasi ke pelanggan: ' + (e.message || e));
return false;
}
}
function getMenuText() {
const { companyHeader, footerInfo, sep } = waBrand();
return `📱 *MENU PELANGGAN*
${sep}
🏢 *${companyHeader}*
${sep}
📋 *Perintah Tersedia:*
🧾 \`menu\` — Tampilkan bantuan ini
📡 \`info\` / \`cekstatus\` — Status ONU Anda
💳 \`cektagihan\` — Lihat status tagihan
👥 \`cekterhubung\` — Daftar host terhubung
📶 \`gantissid\` _nama_ — Ubah nama WiFi
🔑 \`gantisandi\` _sandi_ — Ubah password
🔄 \`reboot\` — Restart ONU
🔗 \`daftar\` _tag/nomor_ — Bind nomor WA
${sep}
${footerInfo ? footerInfo : '💡 *Contoh:* `cektagihan`'}`;
}
function getAdminMenuText() {
const { companyHeader, footerInfo, sep } = waBrand();
return `🛠️ *MENU ADMIN*
${sep}
🏢 *${companyHeader}*
${sep}
👥 *Agent:*
💸 \`topup\` _nama/username/id/nohp nominal_ — Transfer saldo ke agent
📡 *MikroTik:*
🟢 \`mtactive\` — User active saat ini
✂️ \`kickuser\` _user_ — Putus session active
➕ \`addpppoe\` _user pass profile_
📝 \`editpppoe\` _user profile_
🗑️ \`delpppoe\` _user_
➕ \`addhotspot\` _user pass profile_
🎟️ \`vcr\` _kode profile_ — User=Pass + Comment
🗑️ \`delhotspot\` _user_
💰 *Billing:*
📊 \`ringkasan\` — Statistik billing
✅ \`lunas\` _ID_ — Tandai lunas ID tagihan
🧾 \`generate\` _bln thn_ — Generate tagihan
👥 *Pelanggan:*
⛔ \`isolir\` _ID_ — Suspend pelanggan
🟢 \`buka\` _ID_ — Aktifkan pelanggan
📱 *Device ONU:*
📋 \`listonu\` — Daftar semua ONU
📡 \`info\` / \`cekstatus\` _TAG_ — Status ONU
🔄 \`reboot\` _TAG_ — Restart ONU
📶 \`gantissid\` _TAG_ _namaSSID_ — Ubah SSID ONU
🔑 \`gantisandi\` _TAG_ _password_ — Ubah password ONU (min 8)
${sep}
${footerInfo ? footerInfo : '💡 _Tanpa TAG = perintah untuk device yang terikat ke WA Anda._'}`;
}
export const whatsappStatus = {
_connection: 'connecting',
get connection() {
try {
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') {
const token = getSetting('fonnte_token', '');
return token ? 'open' : 'close';
}
if (gw === 'meta') {
const token = getSetting('wa_meta_token', '');
return token ? 'open' : 'close';
}
if (gw === 'gowa') {
return getSetting('gowa_base_url', '') ? 'open' : 'close';
}
} catch (_) {}
return this._connection || 'connecting';
},
set connection(val) {
this._connection = val;
},
_user: null,
get user() {
try {
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') {
return { id: 'fonnte_gateway', name: 'Fonnte Gateway' };
}
if (gw === 'meta') {
return { id: 'meta_gateway', name: 'Meta Cloud API' };
}
if (gw === 'gowa') {
return { id: 'gowa_gateway', name: 'GOWA Gateway' };
}
} catch (_) {}
return this._user;
},
set user(val) {
this._user = val;
},
_phoneNumber: null,
get phoneNumber() {
try {
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') return 'Fonnte Gateway';
if (gw === 'meta') return getSetting('meta_phone_number_id', 'Meta API');
if (gw === 'gowa') return getSetting('gowa_device_id', '') || 'GOWA Gateway';
} catch (_) {}
return this._phoneNumber || this.user?.id?.split(':')[0] || '-';
},
set phoneNumber(val) {
this._phoneNumber = val;
},
_pushName: null,
get pushName() {
try {
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') return 'Fonnte Gateway';
if (gw === 'meta') return 'Meta Cloud API';
if (gw === 'gowa') return 'GOWA Gateway';
} catch (_) {}
return this._pushName || this.user?.name || '-';
},
set pushName(val) {
this._pushName = val;
},
qr: null,
qrImage: null,
lastUpdate: getCurrentDateInTimezone()
};
let currentSock = null;
let qrShownSinceStart = false;
let notifiedAdminForQr = false;
let isStarting = false;
export function isBotRunning() {
return !!currentSock;
}
function loadWhatsappAdminSendList() {
const list = getWhatsappAdminNumbers();
const out = [];
const seen = new Set();
for (const n of list) {
let digits = String(n).replace(/\D/g, '');
if (!digits) continue;
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
if (digits.length < 8) continue;
if (seen.has(digits)) continue;
seen.add(digits);
out.push(digits);
}
return out;
}
/**
* Send monitoring alert to admin and technicians
* @param {string} message - Alert message to send
* @param {string} priority - Priority level: 'high', 'medium', 'low'
*/
export async function sendMonitoringAlert(message, priority = 'medium') {
const gatewayType = getSetting('wa_gateway_type', 'gowa');
if (gatewayType === 'baileys' && (!currentSock || whatsappStatus._connection !== 'open')) {
logger.warn('[WhatsApp] Bot belum siap (koneksi belum terbuka), tidak dapat mengirim alert monitoring');
return { success: false, message: 'Bot belum siap' };
}
try {
const priorityIcon = priority === 'high' ? '🚨' : priority === 'medium' ? '⚠️' : 'ℹ️';
const formattedMessage = `${priorityIcon} *MONITORING ALERT*\n\n${message}`;
// Get admin numbers from settings
const adminNumbers = getWhatsappAdminNumbers();
// Get technician numbers from database (active technicians only)
const techSvc = require('./techService');
const technicians = techSvc.getAllTechnicians();
const techNumbers = technicians
.filter(tech => tech.is_active === 1 && tech.phone)
.map(tech => {
let phone = String(tech.phone || '').replace(/\D/g, '');
// Convert 08xxx to 628xxx
if (phone.startsWith('0')) {
phone = '62' + phone.slice(1);
}
return phone;
})
.filter(Boolean);
const toJid = (raw) => {
const s = String(raw || '').trim();
if (!s) return '';
if (s.includes('@')) return s;
let digits = s.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
if (digits.length < 8) return '';
return `${digits}@s.whatsapp.net`;
};
const adminJids = Array.from(new Set((adminNumbers || []).map(toJid).filter(Boolean)));
const techJids = Array.from(new Set((techNumbers || []).map(toJid).filter(Boolean)));
const recipients = Array.from(new Set([...adminJids, ...techJids]));
if (recipients.length === 0) {
logger.warn('[WhatsApp] Tidak ada nomor penerima alert monitoring yang dikonfigurasi');
return { success: false, message: 'Tidak ada penerima yang dikonfigurasi' };
}
logger.info(`[WhatsApp] Mengirim alert monitoring ke ${recipients.length} penerima (${adminJids.length} admin, ${techJids.length} teknisi)`);
const results = [];
for (const jid of recipients) {
try {
if (gatewayType === 'baileys') {
const res = await currentSock.sendMessage(jid, { text: formattedMessage });
if (res && res.key && res.message) {
cacheSentMessage(res.key, res.message);
}
results.push({ jid, success: true });
logger.info(`[WhatsApp] Alert monitoring terkirim ke ${jid}`);
} else {
const { sendText } = require('./waNotifyService');
const ok = await sendText(jid, formattedMessage);
if (!ok) throw new Error(`Gagal mengirim alert via ${gatewayType}`);
results.push({ jid, success: true });
logger.info(`[WhatsApp] Alert monitoring terkirim ke ${jid}`);
}
} catch (error) {
results.push({ jid, success: false, error: error.message });
logger.error(`[WhatsApp] Gagal mengirim alert ke ${jid}: ${error.message}`);
}
}
const successCount = results.filter(r => r.success).length;
return {
success: successCount > 0,
message: `Alert terkirim ke ${successCount}/${recipients.length} penerima (${adminJids.length} admin, ${techJids.length} teknisi)`,
results
};
} catch (error) {
logger.error(`[WhatsApp] Error mengirim monitoring alert: ${error.message}`);
return { success: false, message: error.message };
}
}
export function parseSpintax(text) {
if (!text) return '';
return String(text).replace(/\{([^{}|]+(?:\|[^{}|]+)+)\}/g, (match, choices) => {
const arr = choices.split('|');
return arr[Math.floor(Math.random() * arr.length)];
});
}
export async function simulateHumanTyping(sock, jid, text = '') {
if (!sock || !jid) return;
try {
await sock.sendPresenceUpdate('composing', jid);
const textLen = String(text || '').length;
const typingDuration = Math.min(Math.max(textLen * 35, 1200), 4000);
await new Promise(resolve => setTimeout(resolve, typingDuration));
await sock.sendPresenceUpdate('paused', jid);
} catch (err) {
// Non-critical, ignore presence errors
}
}
export async function sendWA(to, text, options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalText = text;
if (options.spintax !== false && typeof text === 'string' && text.includes('{') && text.includes('}')) {
finalText = parseSpintax(text);
}
// Routing Gateway FONNTE (Cloud / Self-Hosted)
if (gatewayType === 'fonnte') {
try {
const fonnteWAService = require('./fonnteWhatsappService');
const res = await fonnteWAService.sendFonnteMessage(to, finalText, options);
return !!(res && res.success);
} catch (err) {
logger.error('[WA Fonnte sendWA] Gagal kirim pesan:', err.message);
return false;
}
}
// Routing Gateway META OFFICIAL CLOUD API
if (gatewayType === 'meta') {
try {
const metaWAService = require('./metaWhatsappService');
if (options.templateName) {
return await metaWAService.sendMetaTemplateMessage(
to,
options.templateName,
options.langCode || 'id',
options.parameters || []
);
} else {
return await metaWAService.sendMetaTextMessage(to, finalText);
}
} catch (err) {
logger.error('[WA Meta sendWA] Gagal kirim pesan:', err.message);
return false;
}
}
// Routing Gateway GOWA (ringan, REST)
if (gatewayType === 'gowa') {
try {
const gowaSvc = require('./gowaWhatsappService');
const res = await gowaSvc.sendGowaMessage(to, finalText, options);
return !!(res && res.success);
} catch (err) {
logger.error('[WA GOWA sendWA] Gagal kirim pesan:', err.message);
return false;
}
}
// Gateway BAILEYS WEB (Default)
if (!currentSock || whatsappStatus._connection !== 'open') {
logger.warn('WhatsApp: Gagal kirim pesan, bot belum terhubung.');
return false;
}
try {
let digits = String(to || '').replace(/\D/g, '');
if (!digits) return false;
if (digits.startsWith('0')) {
digits = '62' + digits.slice(1);
} else if (digits.startsWith('8')) {
digits = '62' + digits;
}
const jid = digits + '@s.whatsapp.net';
logger.info(`[WA sendWA] Mengirim ke JID: ${jid}`);
const result = await currentSock.sendMessage(jid, { text: finalText });
if (result && result.key && result.message) {
cacheSentMessage(result.key, result.message);
}
logger.info(`[WA sendWA] sendMessage sukses ke ${jid} (ID: ${result?.key?.id || 'ok'})`);
return true;
} catch (e) {
logger.error('[WA sendWA] Gagal kirim WA:', e.message);
return false;
}
}
export async function sendWAImage(to, imageBuffer, caption = '', options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalCaption = caption;
if (options.spintax !== false && typeof caption === 'string' && caption.includes('{') && caption.includes('}')) {
finalCaption = parseSpintax(caption);
}
if (gatewayType === 'fonnte') {
try {
const fonnteWAService = require('./fonnteWhatsappService');
const res = await fonnteWAService.sendFonnteMessage(to, finalCaption, options);
return !!(res && res.success);
} catch (err) {
logger.error('[WA Fonnte sendWAImage] Gagal kirim image:', err.message);
return false;
}
}
if (gatewayType === 'meta') {
try {
const metaWAService = require('./metaWhatsappService');
return await metaWAService.sendMetaTextMessage(to, finalCaption);
} catch (err) {
logger.error('[WA Meta sendWAImage] Gagal kirim image:', err.message);
return false;
}
}
if (gatewayType === 'gowa') {
try {
const gowaSvc = require('./gowaWhatsappService');
const res = await gowaSvc.sendGowaImage(to, imageBuffer, finalCaption, options);
return !!(res && res.success);
} catch (err) {
logger.error('[WA GOWA sendWAImage] Gagal kirim image:', err.message);
return false;
}
}
if (!currentSock || whatsappStatus._connection !== 'open') {
logger.warn('WhatsApp: Gagal kirim pesan, bot belum terhubung.');
return false;
}
try {
let digits = String(to || '').replace(/\D/g, '');
if (!digits) return false;
if (digits.startsWith('0')) {
digits = '62' + digits.slice(1);
} else if (digits.startsWith('8')) {
digits = '62' + digits;
}
const jid = digits + '@s.whatsapp.net';
const img = Buffer.isBuffer(imageBuffer) ? imageBuffer : Buffer.from(imageBuffer || []);
if (!img.length) return false;
const result = await currentSock.sendMessage(jid, { image: img, caption: String(finalCaption || '') });
if (result && result.key && result.message) {
cacheSentMessage(result.key, result.message);
}
return true;
} catch (e) {
logger.error('Gagal kirim WA image:', e.message);
return false;
}
}
export async function sendWADocument(to, documentBuffer, filename = 'Invoice.pdf', caption = '', mimetype = 'application/pdf', options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalCaption = caption;
if (options.spintax !== false && typeof caption === 'string' && caption.includes('{') && caption.includes('}')) {
finalCaption = parseSpintax(caption);
}
if (gatewayType === 'fonnte') {
try {
const fonnteWAService = require('./fonnteWhatsappService');
const res = await fonnteWAService.sendFonnteMessage(to, finalCaption, { ...options, filename });
return !!(res && res.success);
} catch (err) {
logger.error('[WA Fonnte sendWADocument] Gagal kirim dokumen:', err.message);
return false;
}
}
if (gatewayType === 'meta') {
try {
const metaWAService = require('./metaWhatsappService');
return await metaWAService.sendMetaTextMessage(to, finalCaption);
} catch (err) {
logger.error('[WA Meta sendWADocument] Gagal kirim dokumen:', err.message);
return false;
}
}
if (gatewayType === 'gowa') {
try {
const gowaSvc = require('./gowaWhatsappService');
const res = await gowaSvc.sendGowaDocument(to, documentBuffer, filename, finalCaption, mimetype, options);
return !!(res && res.success);
} catch (err) {
logger.error('[WA GOWA sendWADocument] Gagal kirim dokumen:', err.message);
return false;
}
}
if (!currentSock || whatsappStatus._connection !== 'open') {
logger.warn('WhatsApp: Gagal kirim dokumen, bot belum terhubung.');
return false;
}
try {
let digits = String(to || '').replace(/\D/g, '');
if (!digits) return false;
if (digits.startsWith('0')) {
digits = '62' + digits.slice(1);
} else if (digits.startsWith('8')) {
digits = '62' + digits;
}
const jid = digits + '@s.whatsapp.net';
const doc = Buffer.isBuffer(documentBuffer) ? documentBuffer : Buffer.from(documentBuffer || []);
if (!doc.length) return false;
const result = await currentSock.sendMessage(jid, {
document: doc,
mimetype: mimetype || 'application/pdf',
fileName: filename || 'Document.pdf',
caption: String(finalCaption || '')
});
if (result && result.key && result.message) {
cacheSentMessage(result.key, result.message);
}
return true;
} catch (e) {
logger.error('Gagal kirim WA document:', e.message);
return false;
}
}
export async function restartWhatsAppBot() {
logger.info('WhatsApp: Memulai ulang bot...');
if (currentSock) {
try {
currentSock.end();
} catch (e) {
logger.error('WhatsApp: Gagal menghentikan socket lama:', e.message);
}
}
// Beri jeda sedikit agar socket lama benar-benar tertutup
setTimeout(() => {
startWhatsAppBot();
}, 1000);
}
let defaultLidStore = null;
export function getDefaultLidStore() {
if (!defaultLidStore) {
try {
const lidMapPath = path.resolve(projectRoot, getSetting('whatsapp_lid_map_file', 'data/wa-lid-map.json'));
defaultLidStore = new WaLidStore(lidMapPath);
} catch (e) {
logger.warn('[WA] WaLidStore init warning: ' + e.message);
}
}
return defaultLidStore;
}
/**
* Universal WhatsApp Bot Command Processor
* Dapat dipanggil oleh Baileys, Fonnte Webhook, maupun Meta Cloud API Webhook
*/
export async function processIncomingCommand({
senderPhone,
text,
reply,
senderName = '',
key = null,
sock = null,
lidStore = null
}) {
if (!text || typeof text !== 'string') return false;
const trimmed = text.trim();
if (!trimmed) return false;
let phone = String(senderPhone || '').replace(/\D/g, '');
if (phone.startsWith('0')) {
phone = '62' + phone.slice(1);
}
const msgKey = key || {
remoteJid: phone ? `${phone}@s.whatsapp.net` : null,
senderPn: phone ? `${phone}@s.whatsapp.net` : null
};
const currentLidStore = lidStore || getDefaultLidStore();
const currentSockInstance = sock || currentSock;
logger.info(`[WA] Pesan masuk dari ${(msgKey.remoteJid || phone)}: "${text}"`);
const adminSet = loadWhatsappAdminSet(currentLidStore);
const isAdmin = (phone && adminSet.has(phone)) || isWhatsappAdminKey(msgKey, adminSet, currentSockInstance, currentLidStore);
logger.info(`[WA] Sender details: JID=${(msgKey.remoteJid || phone)}, isAdmin=${isAdmin}, adminSet=${JSON.stringify([...adminSet])}`);
const parsed = parseCommand(text, isAdmin);
logger.info(`[WA] Parsed command: ${JSON.stringify(parsed)}`);
if (!parsed) return false;
// Rate Limiting Check
/* phone already defined */
if (phone) {
// Cek command cooldown (2 detik)
const cooldownCheck = checkCommandCooldown(phone);
if (!cooldownCheck.allowed) {
await reply(`⏳ Mohon tunggu *${cooldownCheck.waitTime} detik* sebelum mengirim perintah lagi.`);
logger.warn(`[WhatsApp Bot] Rate limit cooldown triggered for ${phone}`);
return true;
}
// Cek rate limit per menit (10 perintah)
const rateLimitCheck = checkRateLimit(phone);
if (!rateLimitCheck.allowed) {
await reply(`⚠️ Anda telah mencapai batas perintah. Tunggu *${rateLimitCheck.waitTime} detik* sebelum mencoba lagi.`);
logger.warn(`[WhatsApp Bot] Rate limit exceeded for ${phone}`);
return true;
}
// Log rate limit info
if (rateLimitCheck.remaining <= 3) {
logger.info(`[WhatsApp Bot] Rate limit warning for ${phone}: ${rateLimitCheck.remaining} commands remaining`);
}
}
if (parsed.cmd === 'menu') {
let body = getMenuText();
if (isAdmin) body += '\n\n_Anda admin — ketik `admin` untuk perintah kelola semua tag._';
/* phone already defined */
const agent = phone ? agentSvc.getAgentByPhone(phone) : null;
if (agent) {
body +=
`\n\n📱 *MENU AGENT (@${agent.username || agent.name})*\n` +
'💳 `saldo` — Cek sisa saldo & komisi agent\n' +
'💰 `bayar <nama/nohp/id>` — Bayar tagihan pelanggan (potong saldo)\n' +
'🎟️ `vcr` / `vcr <paket>` — Buat voucher hotspot (potong saldo)' +
''
}
await reply(body);
return true;
}
if (parsed.cmd === 'adminmenu') {
if (!isAdmin) {
await reply('❌ Perintah ini khusus nomor admin (pengaturan whatsapp_admin_numbers).');
return true;
}
await reply(getAdminMenuText());
return true;
}
if (parsed.cmd === 'listonu' && parsed.admin) {
if (!isAdmin) {
await reply('❌ Akses ditolak. Perintah ini khusus admin.');
return true;
}
let res = await customerDevice.listDevicesWithTags(300);
if (!res.ok || !res.devices || res.devices.length === 0) {
res = await customerDevice.listAllDevices(300);
}
if (!res.ok) {
await reply('❌ ' + (res.message || 'Gagal mengambil daftar.'));
return true;
}
const body = formatListOnu(res.devices || []);
const chunks = splitWaChunks(body);
for (const ch of chunks) {
await reply(ch);
}
return true;
}
// Admin MikroTik Logic
if (parsed.admin && parsed.cmd === 'mtactive') {
try {
const pppoe = await mikrotikSvc.getPppoeActive();
const hotspot = await mikrotikSvc.getHotspotActive();
await reply(formatActiveMikrotik(pppoe, hotspot));
} catch (e) {
await reply('❌ Gagal mengambil data aktif: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'kickuser') {
try {
const [user] = parsed.args;
const pk = await mikrotikSvc.kickPppoeUser(user);
const hk = await mikrotikSvc.kickHotspotUser(user);
if (pk || hk) await reply(`✅ Session user *${user}* berhasil diputus.`);
else await reply(`❌ User *${user}* tidak ditemukan di session aktif.`);
} catch (e) {
await reply('❌ Gagal kick user: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'addpppoe') {
try {
const [user, pass, profile] = parsed.args;
await mikrotikSvc.addPppoeSecret({ name: user, password: pass, profile, service: 'pppoe' });
await reply(`✅ PPPoE Secret *${user}* berhasil ditambahkan.`);
} catch (e) {
await reply('❌ Gagal tambah PPPoE: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'editpppoe') {
try {
const [user, profile] = parsed.args;
await mikrotikSvc.setPppoeProfile(user, profile);
await reply(`✅ Profile PPPoE *${user}* berhasil diubah ke *${profile}* dan session aktif telah diputus.`);
} catch (e) {
await reply('❌ Gagal edit PPPoE: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'delpppoe') {
try {
const [user] = parsed.args;
const secrets = await mikrotikSvc.getPppoeSecrets();
const found = secrets.find(s => s.name === user);
if (!found) return await reply(`❌ User *${user}* tidak ditemukan.`);
await mikrotikSvc.deletePppoeSecret(found['.id'] || found.id);
await mikrotikSvc.kickPppoeUser(user);
await reply(`✅ PPPoE Secret *${user}* berhasil dihapus dan session aktif diputus.`);
} catch (e) {
await reply('❌ Gagal hapus PPPoE: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'addhotspot') {
try {
const [user, pass, profile] = parsed.args;
await mikrotikSvc.addHotspotUser({ name: user, password: pass, profile });
await reply(`✅ Hotspot User *${user}* berhasil ditambahkan.`);
} catch (e) {
await reply('❌ Gagal tambah Hotspot: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'vcr') {
try {
const [code, profile] = parsed.args;
const now = getCurrentDateInTimezone();
const dateStr = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`;
const comment = `vc ${code} ${dateStr}`;
await mikrotikSvc.addHotspotUser({
name: code,
password: code,
profile: profile,
comment: comment
});
await reply(`✅ Voucher Hotspot *${code}* berhasil dibuat.\n\n👤 User: *${code}*\n🔑 Pass: *${code}*\n🏷️ Profile: *${profile}*\n📝 Comment: *${comment}*`);
} catch (e) {
await reply('❌ Gagal buat voucher: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'delhotspot') {
try {
const [user] = parsed.args;
const users = await mikrotikSvc.getHotspotUsers();
const found = users.find(u => u.name === user);
if (!found) return await reply(`❌ User Hotspot *${user}* tidak ditemukan.`);
await mikrotikSvc.deleteHotspotUser(found['.id'] || found.id);
await mikrotikSvc.kickHotspotUser(user);
await reply(`✅ Hotspot User *${user}* berhasil dihapus dan session aktif diputus.`);
} catch (e) {
await reply('❌ Gagal hapus Hotspot: ' + e.message);
}
return true;
}
// Admin Billing Logic
if (parsed.admin && parsed.cmd === 'ringkasan') {
const stats = billingSvc.getDashboardStats();
await reply(formatBillingSummary(stats));
return true;
}
if (parsed.admin && parsed.cmd === 'lunas') {
logger.info(`[WA lunas] Memulai pemrosesan lunas untuk target: "${parsed.targetId}"`);
try {
const keyRaw = String(parsed.targetId || '').trim();
if (!keyRaw) {
logger.warn(`[WA lunas] Target kosong!`);
return await reply('❌ Format: `lunas IDTAGIHAN` atau `lunas nama/nohp/pppoe/tag`');
}
let targetInvId = null;
let targetInv = null;
const isNumeric = /^\d+$/.test(keyRaw);
if (isNumeric) {
targetInvId = Number(keyRaw);
targetInv = billingSvc.getInvoiceById(targetInvId);
logger.info(`[WA lunas] Pencarian numerik ID Invoice: ${targetInvId} -> Found: ${!!targetInv}`);
}
// If not found by ID, try find customer and their oldest unpaid invoice
if (!targetInv) {
let cust =
(isNumeric ? customerSvc.getCustomerById(Number(keyRaw)) : null) ||
customerSvc.findCustomerByAny(keyRaw);
logger.info(`[WA lunas] Pencarian customer untuk key: "${keyRaw}" -> Found: ${cust ? cust.name + " (ID:" + cust.id + ")" : 'null'}`);
if (!cust) {
const candidates = customerSvc.getAllCustomers(keyRaw) || [];
const unique = Array.from(new Map(candidates.map(c => [c.id, c])).values());
logger.info(`[WA lunas] Ditemukan ${unique.length} kandidat unik`);
if (unique.length === 1) {
cust = customerSvc.getCustomerById(unique[0].id);
logger.info(`[WA lunas] Menggunakan kandidat tunggal: ${cust.name}`);
} else if (unique.length > 1) {
const top = unique.slice(0, 5).map(c =>
`- ID:${c.id} • ${c.name || '-'} • ${c.phone || '-'} • PPPoE:${c.pppoe_username || '-'}`
).join('\n');
return await reply(`⚠️ Nama/ID tidak spesifik. Ditemukan ${unique.length} pelanggan:\n\n${top}\n\nKirim ulang: \`lunas IDPELANGGAN\` atau \`lunas NOHP/PPPOE/TAG\``);
}
}
if (cust) {
const unpaid = billingSvc.getUnpaidInvoicesByCustomerId(cust.id);
logger.info(`[WA lunas] Tagihan belum dibayar untuk customer ${cust.name}: ${unpaid ? unpaid.length : 0}`);
if (unpaid && unpaid.length > 0) {
targetInv = unpaid[0];
targetInvId = targetInv.id;
} else {
return await reply(`✅ Pelanggan *${cust.name}* tidak memiliki tagihan menunggak.`);
}
}
}
if (!targetInv) {
logger.warn(`[WA lunas] Invoice/Customer tidak ditemukan untuk: "${keyRaw}"`);
return await reply(`❌ Tagihan atau Pelanggan *${keyRaw}* tidak ditemukan.`);
}
if (targetInvId != null) {
const enriched = billingSvc.getInvoiceById(targetInvId);
if (enriched) targetInv = enriched;
}
if (targetInv && targetInv.status === 'paid') {
logger.info(`[WA lunas] Invoice #${targetInv.id} sudah paid`);
return await reply(`✅ Invoice *#${targetInv.id}* sudah berstatus LUNAS.`);
}
logger.info(`[WA lunas] Menandai lunas invoice #${targetInvId}...`);
billingSvc.markAsPaid(targetInvId, 'WA Bot Admin', 'Paid via WhatsApp Command');
const customer = customerSvc.getCustomerById(targetInv.customer_id);
const formatter = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
const customerName = String(targetInv.customer_name || customer?.name || '-');
const notifyTag = customer?.genieacs_tag || customer?.pppoe_username || customer?.phone || targetInv.customer_phone || targetInv.genieacs_tag || '';
const custIdDisplay = customer?.id || targetInv.customer_id || '-';
const packageName = customer?.package_name || targetInv.package_name || '-';
const periodStr = formatIndonesianPeriod(targetInv.period_month, targetInv.period_year);
const totalBayarStr = formatter.format(Number(targetInv.amount || 0));
const tz = getSetting('timezone', 'Asia/Jakarta');
let tzSuffix = 'WIB';
if (/Makassar|Ujung_Pandang|Bali|Pontianak|Banjarmasin|Manado|Mataram|Kupang/i.test(tz)) tzSuffix = 'WITA';
else if (/Jayapura|Ambon|Papua|Maluku/i.test(tz)) tzSuffix = 'WIT';
const nowTimeStr = `${formatDateLocal(new Date())} ${tzSuffix}`;
// Ambil seluruh sisa invoice yang belum dibayar untuk pelanggan ini
const remainingUnpaid = (billingSvc.getUnpaidInvoicesByCustomerId(targetInv.customer_id) || [])
.filter(inv => Number(inv.id) !== Number(targetInvId));
const remainingSummary = formatUnpaidInvoicesSummary(remainingUnpaid);
const isSuspended = customer && customer.status === 'suspended';
let serviceStatusText = '';
let adminReplyNote = '';
if (isSuspended) {
if (remainingSummary.count === 0) {
logger.info(`[WA lunas] Mengaktifkan customer ID ${targetInv.customer_id}...`);
await customerSvc.activateCustomer(targetInv.customer_id);
serviceStatusText = `🟢 *Status Layanan:* AKTIF KEMBALI\n_Layanan internet Anda telah otomatis diaktifkan kembali._\n\n`;
adminReplyNote = `Pelanggan *${customerName}* otomatis diaktifkan kembali.`;
} else {
serviceStatusText = `⚠️ *Status Layanan:* TERISOLIR (Menunggu pelunasan sisa tunggakan)\n\n`;
adminReplyNote = `(Masih ada ${remainingSummary.count} tunggakan lain, isolir tetap aktif)`;
}
} else {
if (remainingSummary.count === 0) {
serviceStatusText = `🟢 *Status Layanan:* AKTIF\n_Semua tagihan internet Anda telah lunas. Terima kasih atas kerja samanya._\n\n`;
} else {
serviceStatusText = `🟢 *Status Layanan:* AKTIF\n\n`;
}
}
let tunggakanBlock = '';
if (remainingSummary.count > 0) {
tunggakanBlock = `⚠️ *SISA TUNGGAKAN LAIN:*\n` +
`Masih terdapat *${remainingSummary.count} tagihan* yang belum dibayar:\n` +
`${remainingSummary.breakdownText}\n` +
`💰 *Total Sisa Tunggakan:* *Rp ${remainingSummary.totalAmountStr}*\n` +
`_Mohon segera diselesaikan agar layanan tidak terisolir._\n\n`;
}
const receiptMsg = waWrap(
'✅ *PEMBAYARAN BERHASIL*',
`Terima kasih, pembayaran tagihan Anda telah kami terima:\n\n` +
`🧾 *No. Invoice:* #${targetInvId}\n` +
`👤 *Nama:* ${customerName}\n` +
`🆔 *ID Pelanggan:* #${custIdDisplay}\n` +
`📦 *Paket:* ${packageName}\n` +
`📅 *Periode:* ${periodStr}\n` +
`💰 *Total Bayar:* ${totalBayarStr}\n` +
`💳 *Metode:* WhatsApp Admin\n` +
`🕒 *Waktu:* ${nowTimeStr}\n` +
`📌 *Status:* *LUNAS* ✅\n\n` +
tunggakanBlock +
serviceStatusText +
`Terima kasih.`
);
logger.info(`[WA lunas] Mengirim notifikasi lunas ke ${notifyTag}...`);
const ok = await notifyCustomer(
currentSockInstance,
currentLidStore,
notifyTag,
receiptMsg
);
await reply(
`✅ Invoice *#${targetInvId}* (Periode: *${periodStr}* a.n *${customerName}*) berhasil ditandai LUNAS.\n` +
(adminReplyNote ? `${adminReplyNote}\n` : '') +
(remainingSummary.count > 0 ? `⚠️ Sisa tunggakan: ${remainingSummary.count} bulan (Rp ${remainingSummary.totalAmountStr})\n` : '') +
`📩 Notif pelanggan: ${ok ? 'terkirim' : 'gagal'}`
);
} catch (e) {
logger.error('[WA lunas] Gagal update status: ' + e.message);
await reply('❌ Gagal update status: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'isolir') {
try {
const cust = customerSvc.findCustomerByAny(parsed.targetId);
if (!cust) return await reply(`❌ Pelanggan *${parsed.targetId}* tidak ditemukan.`);
await customerSvc.suspendCustomer(cust.id);
await reply(`✅ Pelanggan *${cust.name}* (ID: ${cust.id}) berhasil di-isolir.`);
} catch (e) {
await reply('❌ Gagal isolir: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'buka') {
try {
const cust = customerSvc.findCustomerByAny(parsed.targetId);
if (!cust) return await reply(`❌ Pelanggan *${parsed.targetId}* tidak ditemukan.`);
await customerSvc.activateCustomer(cust.id);
await reply(`✅ Pelanggan *${cust.name}* (ID: ${cust.id}) berhasil diaktifkan kembali.`);
} catch (e) {
await reply('❌ Gagal buka isolir: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'generate') {
try {
const count = billingSvc.generateMonthlyInvoices(parseInt(parsed.month), parseInt(parsed.year));
await reply(`✅ Berhasil generate *${count}* tagihan untuk periode ${parsed.month}/${parsed.year}.`);
} catch (e) {
await reply('❌ Gagal generate: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.cmd === 'topupagent') {
if (!isAdmin) {
await reply('❌ Akses ditolak. Perintah ini khusus admin.');
return true;
}
try {
const agentKeyRaw = String(parsed.agentKey || '').trim();
const amount = Number(String(parsed.amount || '').replace(/[^\d]/g, '')) || 0;
if (!agentKeyRaw) throw new Error('Agent username/id tidak valid');
if (!amount) throw new Error('Nominal tidak valid');
const normalizeDigits = (v) => {
let d = String(v || '').replace(/\D/g, '');
if (!d) return '';
if (d.startsWith('0')) d = '62' + d.slice(1);
return d;
};
const agentKey = agentKeyRaw.startsWith('@') ? agentKeyRaw.slice(1) : agentKeyRaw;
const agentKeyLc = agentKey.toLowerCase();
const agentDigits = normalizeDigits(agentKey);
const agents = agentSvc.getAllAgents();
const candidates = [];
const byId = /^\d+$/.test(agentKey) ? agents.find(a => Number(a?.id) === Number(agentKey)) : null;
if (byId) candidates.push(byId);
const byUsername = agents.find(a => String(a?.username || '').toLowerCase() === agentKeyLc) || null;
if (byUsername) candidates.push(byUsername);
if (agentDigits) {
const byPhone = agents.find(a => normalizeDigits(a?.phone || '') === agentDigits) || null;
if (byPhone) candidates.push(byPhone);
}
const byNameExact = agents.find(a => String(a?.name || '').trim().toLowerCase() === agentKeyLc) || null;
if (byNameExact) candidates.push(byNameExact);
let agent = candidates.length > 0 ? candidates[0] : null;
if (!agent) {
const byNameContains = agents.filter(a => String(a?.name || '').trim().toLowerCase().includes(agentKeyLc));
if (byNameContains.length === 1) agent = byNameContains[0];
if (!agent && byNameContains.length > 1) {
const list = byNameContains.slice(0, 8).map(a => `- ${a.name} (@${a.username}) [ID:${a.id}]`).join('\n');
throw new Error(`Nama agent lebih dari satu. Gunakan username/ID/nohp.\n\n${list}`);
}
}
if (!agent) throw new Error('Agent tidak ditemukan');
/* phone already defined */
const actorName = phone ? `Admin WA (${phone})` : 'Admin WA';
const note = String(parsed.note || '').trim() || 'Transfer saldo via WhatsApp';
const r = agentSvc.topupAgent(agent.id, amount, note, actorName);
await reply(
`✅ *TOPUP AGENT BERHASIL*\n\n` +
`👤 Agent: *${agent.name}* (@${agent.username})\n` +
`💸 Nominal: Rp ${Number(amount || 0).toLocaleString('id-ID')}\n` +
`💳 Saldo: Rp ${Number(r.before || 0).toLocaleString('id-ID')} ➜ Rp ${Number(r.after || 0).toLocaleString('id-ID')}\n` +
`📝 Catatan: ${note}`
);
} catch (e) {
await reply('❌ Gagal topup agent: ' + e.message);
}
return true;
}
if (parsed.cmd === 'agent_saldo') {
try {
const agent = phone ? agentSvc.getAgentByPhone(phone) : null;
if (!agent) {
if (isAdmin) {
await reply('💡 _Nomor ini adalah Admin. Untuk cek saldo agent, daftarkan nomor ini di Menu Agent._');
return true;
}
await reply('❌ Nomor WhatsApp ini belum terdaftar sebagai Agent aktif.');
return true;
}
const fresh = agentSvc.getAgentById(agent.id);
const balance = Number(fresh?.balance || 0);
const fee = Number(fresh?.billing_fee || 0);
const { sep } = waBrand();
const msg =
`💳 *SALDO AGENT*\n` +
`${sep}\n` +
`👤 Agent: *${fresh.name}* (@${fresh.username})\n` +
`📞 No HP: *${fresh.phone || '-'}*\n` +
`💰 *Sisa Saldo: Rp ${balance.toLocaleString('id-ID')}*\n` +
`🎁 Komisi Tagihan: *Rp ${fee.toLocaleString('id-ID')} / transaksi*\n` +
`${sep}\n` +
`📋 *Perintah Transaksi:*\n` +
`• \`bayar <nama/nohp/id>\` — Bayar tagihan pelanggan\n` +
`• \`vcr\` — Lihat daftar paket voucher hotspot\n` +
`• \`vcr <paket>\` — Buat voucher hotspot`;
await reply(msg);
} catch (e) {
await reply('❌ Gagal cek saldo agent: ' + e.message);
}
return true;
}
if (parsed.cmd === 'agent_bayar') {
try {
const agent = phone ? agentSvc.getAgentByPhone(phone) : null;
if (!agent) {
await reply('❌ Fitur pembayaran tagihan via saldo hanya untuk nomor WhatsApp yang terdaftar sebagai Agent.');
return true;
}
const keyRaw = String(parsed.target || '').trim();
if (!keyRaw) {
await reply(
'❌ *Format Perintah:*\n' +
'`bayar <nama/nohp/pppoe/id_pelanggan/id_tagihan>`\n\n' +
'💡 *Contoh:*\n' +
'• `bayar Budi`\n' +
'• `bayar 081234567890`\n' +
'• `bayar 105`'
);
return true;
}
const isNumeric = /^\d+$/.test(keyRaw);
let targetInv = null;
// 1. Cek apakah input adalah ID Invoice
if (isNumeric) {
targetInv = billingSvc.getInvoiceById(Number(keyRaw));
}
// 2. Jika bukan ID invoice atau tidak ketemu, cari pelanggan
if (!targetInv) {
let cust =
(isNumeric ? customerSvc.getCustomerById(Number(keyRaw)) : null) ||
customerSvc.findCustomerByAny(keyRaw);
if (!cust) {
const candidates = customerSvc.getAllCustomers(keyRaw) || [];
const unique = Array.from(new Map(candidates.map(c => [c.id, c])).values());
if (unique.length === 1) {
cust = customerSvc.getCustomerById(unique[0].id);
} else if (unique.length > 1) {
const top = unique.slice(0, 5).map(c =>
`- ID:${c.id} • ${c.name || '-'} • ${c.phone || '-'} • PPPoE:${c.pppoe_username || '-'}`
).join('\n');
await reply(`⚠️ Ditemukan ${unique.length} pelanggan bernama serupa:\n\n${top}\n\nKetik lebih spesifik: \`bayar IDPELANGGAN\` atau \`bayar NOHP\``);
return true;
}
}
if (cust) {
const unpaid = billingSvc.getUnpaidInvoicesByCustomerId(cust.id);
if (unpaid && unpaid.length > 0) {
targetInv = unpaid[0];
} else {
await reply(`✅ Pelanggan *${cust.name}* (ID: ${cust.id}) tidak memiliki tagihan menunggak.`);
return true;
}
}
}
if (!targetInv) {
await reply(`❌ Data tagihan atau pelanggan "*${keyRaw}*" tidak ditemukan.`);
return true;
}
if (targetInv.status === 'paid') {
await reply(`✅ Invoice *#${targetInv.id}* sudah berstatus LUNAS.`);
return true;
}
// Hitung pemotongan saldo: tagihan dikurangi komisi
const fee = Math.max(0, Number(agent.billing_fee || 0) || 0);
const invoiceAmount = Number(targetInv.amount || 0);
const cost = Math.max(0, invoiceAmount - fee);
const freshAgent = agentSvc.getAgentById(agent.id);
const currentBalance = Number(freshAgent?.balance || 0);
if (currentBalance < cost) {
await reply(
`❌ *SALDO AGENT TIDAK MENCUKUPI*\n\n` +
`🧾 Tagihan: Rp ${invoiceAmount.toLocaleString('id-ID')}\n` +
`🎁 Komisi Agent: Rp ${fee.toLocaleString('id-ID')}\n` +
`💰 Potong Saldo: *Rp ${cost.toLocaleString('id-ID')}*\n` +
`💳 Sisa Saldo Anda: *Rp ${currentBalance.toLocaleString('id-ID')}*\n\n` +
`Silakan hubungi Admin untuk top up saldo agent.`
);
return true;
}
const result = await agentSvc.payInvoiceAsAgent(agent.id, targetInv.id, 'Via WhatsApp Bot');
const customer = customerSvc.getCustomerById(targetInv.customer_id);
const customerName = String(targetInv.customer_name || customer?.name || '-');
const remainingBalance = Number(result?.agent?.balance ?? result?.tx?.after ?? (currentBalance - cost));
const { sep } = waBrand();
const periodStr = formatIndonesianPeriod(targetInv.period_month, targetInv.period_year);
const receiptMsg =
`✅ *PEMBAYARAN TAGIHAN BERHASIL*\n` +
`${sep}\n` +
`👤 Pelanggan: *${customerName}* (ID: ${targetInv.customer_id})\n` +
`🧾 No. Invoice: *#${targetInv.id}*\n` +
`📦 Paket: *${targetInv.package_name || customer?.package_name || '-'}*\n` +
`📅 Periode: *${periodStr}*\n` +
`💵 Total Tagihan: Rp ${invoiceAmount.toLocaleString('id-ID')}\n` +
`🎁 Komisi Agent: Rp ${fee.toLocaleString('id-ID')}\n` +
`${sep}\n` +
`💰 *Potong Saldo:* Rp ${cost.toLocaleString('id-ID')}\n` +
`💳 *SISA SALDO AGENT: Rp ${remainingBalance.toLocaleString('id-ID')}*\n` +
`${sep}\n` +
`Terima kasih telah bertransaksi.`;
await reply(receiptMsg);
// Notifikasi ke pelanggan jika ada nomor kontak
const notifyTag = customer?.genieacs_tag || customer?.pppoe_username || customer?.phone || targetInv.customer_phone || '';
if (notifyTag) {
const formatter = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 });
const tz = getSetting('timezone', 'Asia/Jakarta');
let tzSuffix = 'WIB';
if (/Makassar|Ujung_Pandang|Bali|Pontianak|Banjarmasin|Manado|Mataram|Kupang/i.test(tz)) tzSuffix = 'WITA';
else if (/Jayapura|Ambon|Papua|Maluku/i.test(tz)) tzSuffix = 'WIT';
const nowTimeStr = `${formatDateLocal(new Date())} ${tzSuffix}`;
const remainingUnpaid = (billingSvc.getUnpaidInvoicesByCustomerId(targetInv.customer_id) || [])
.filter(inv => Number(inv.id) !== Number(targetInv.id));
const remainingSummary = formatUnpaidInvoicesSummary(remainingUnpaid);
let tunggakanBlock = '';
if (remainingSummary.count > 0) {
tunggakanBlock = `⚠️ *SISA TUNGGAKAN LAIN:*\n` +
`Masih terdapat *${remainingSummary.count} tagihan* yang belum dibayar:\n` +
`${remainingSummary.breakdownText}\n` +
`💰 *Total Sisa Tunggakan:* *Rp ${remainingSummary.totalAmountStr}*\n` +
`_Mohon segera diselesaikan agar layanan tidak terisolir._\n\n`;
}
notifyCustomer(
currentSockInstance,
currentLidStore,
notifyTag,
waWrap(
'✅ *PEMBAYARAN BERHASIL*',
`Tagihan internet Anda telah *LUNAS* dibayarkan via Agen *${agent.name}*.\n\n` +
`🧾 *No. Invoice:* #${targetInv.id}\n` +
`👤 *Nama:* ${customerName}\n` +
`🆔 *ID Pelanggan:* #${customer?.id || targetInv.customer_id || '-'}\n` +
`📦 *Paket:* ${targetInv.package_name || customer?.package_name || '-'}\n` +
`📅 *Periode:* ${periodStr}\n` +
`💰 *Total Bayar:* ${formatter.format(invoiceAmount)}\n` +
`💳 *Metode:* Agen / Kasir (${agent.name})\n` +
`🕒 *Waktu:* ${nowTimeStr}\n` +
`📌 *Status:* *LUNAS* ✅\n\n` +
tunggakanBlock +
`🟢 Layanan internet Anda aktif.\n\n` +
`Terima kasih.`
)
).catch(() => {});
}
} catch (e) {
logger.error('[WA agent_bayar] Gagal bayar tagihan: ' + e.message);
await reply('❌ Gagal memproses pembayaran tagihan: ' + e.message);
}
return true;
}
if (parsed.cmd === 'agent_voucher') {
try {
const agent = phone ? agentSvc.getAgentByPhone(phone) : null;
if (!agent) {
await reply('❌ Fitur pembuatan voucher hotspot via saldo hanya untuk nomor WhatsApp yang terdaftar sebagai Agent.');
return true;
}
const pkgKeyRaw = String(parsed.pkgKey || '').trim();
const prices = agentSvc.getAgentPrices(agent.id) || [];
if (!prices || prices.length === 0) {
await reply('⚠️ Belum ada paket voucher hotspot yang aktif untuk akun Agent Anda. Silakan hubungi Admin.');
return true;
}
const freshAgent = agentSvc.getAgentById(agent.id);
const currentBalance = Number(freshAgent?.balance || 0);
// Jika tanpa argumen, tampilkan daftar paket voucher
if (!pkgKeyRaw) {
const { sep } = waBrand();
let listStr =
`🎟️ *DAFTAR PAKET VOUCHER HOTSPOT*\n` +
`${sep}\n` +
`💳 *Sisa Saldo Anda: Rp ${currentBalance.toLocaleString('id-ID')}*\n\n` +
`Pilih paket di bawah ini:\n`;
prices.forEach((p, idx) => {
const buyPrice = Number(p.buy_price || 0);
const sellPrice = Number(p.sell_price || 0);
const profit = Math.max(0, sellPrice - buyPrice);
listStr +=
`\n*${idx + 1}. ${p.profile_name}* (ID: \`${p.id}\`)` +
`\n ⏳ Masa Aktif: ${p.validity || '24 Jam'}` +
`\n 💰 Potong Saldo: Rp ${buyPrice.toLocaleString('id-ID')}` +
`\n 🏷️ Harga Jual: Rp ${sellPrice.toLocaleString('id-ID')} (Untung: Rp ${profit.toLocaleString('id-ID')})` +
`\n 👉 Ketik: \`vcr ${p.id}\` atau \`vcr ${p.profile_name}\`\n`;
});
listStr += `\n${sep}\n💡 _Ketik \`vcr <ID/NamaPaket>\` untuk langsung membuat voucher._`;
await reply(listStr);
return true;
}
// Cari paket berdasarkan ID atau substring nama profile
const isNumeric = /^\d+$/.test(pkgKeyRaw);
let matchedPrice = null;
if (isNumeric) {
matchedPrice = prices.find(p => Number(p.id) === Number(pkgKeyRaw));
}
if (!matchedPrice) {
const pkgLc = pkgKeyRaw.toLowerCase();
matchedPrice = prices.find(p => String(p.profile_name || '').toLowerCase() === pkgLc) ||
prices.find(p => String(p.profile_name || '').toLowerCase().includes(pkgLc));
}
if (!matchedPrice) {
await reply(`❌ Paket voucher "*${pkgKeyRaw}*" tidak ditemukan.\n\nKetik \`vcr\` untuk melihat daftar paket yang tersedia.`);
return true;
}
const buyPrice = Number(matchedPrice.buy_price || 0);
const sellPrice = Number(matchedPrice.sell_price || 0);
if (currentBalance < buyPrice) {
await reply(
`❌ *SALDO AGENT TIDAK MENCUKUPI*\n\n` +
`🎟️ Paket: *${matchedPrice.profile_name}*\n` +
`💰 Potong Saldo: *Rp ${buyPrice.toLocaleString('id-ID')}*\n` +
`💳 Sisa Saldo Anda: *Rp ${currentBalance.toLocaleString('id-ID')}*\n\n` +
`Silakan hubungi Admin untuk top up saldo agent.`
);
return true;
}
const result = await agentSvc.sellVoucherAsAgent(agent.id, matchedPrice.id);
const voucher = result.voucher;
const remainingBalance = Number(result?.agent?.balance ?? result?.tx?.after ?? (currentBalance - buyPrice));
const { sep } = waBrand();
const voucherMsg =
`🎟️ *VOUCHER HOTSPOT BERHASIL DIBUAT*\n` +
`${sep}\n` +
`👤 *Username:* \`${voucher.code}\`\n` +
`🔑 *Password:* \`${voucher.password}\`\n` +
`🏷️ *Paket:* ${matchedPrice.profile_name}\n` +
`⏳ *Masa Aktif:* ${result.receipt?.validity || matchedPrice.validity || '24 Jam'}\n` +
`💵 *Harga Jual:* Rp ${sellPrice.toLocaleString('id-ID')}\n` +
`${sep}\n` +
`💰 *Potong Saldo:* Rp ${buyPrice.toLocaleString('id-ID')}\n` +
`💳 *SISA SALDO AGENT: Rp ${remainingBalance.toLocaleString('id-ID')}*\n` +
`${sep}\n` +
`_Voucher siap digunakan pelanggan._`;
await reply(voucherMsg);
} catch (e) {
logger.error('[WA agent_voucher] Gagal buat voucher: ' + e.message);
await reply('❌ Gagal membuat voucher: ' + e.message);
}
return true;
}
if (parsed.admin && parsed.targetTag) {
if (!isAdmin) {
await reply('❌ Akses ditolak. Perintah ini khusus admin.');
return true;
}
const targetTag = await resolveTargetTagForAdmin(parsed.targetTag);
const targetDevice = await customerDevice.resolveDeviceToken(targetTag);
if (!targetDevice) {
await reply(`❌ Target *${parsed.targetTag}* tidak ditemukan di GenieACS.`);
return true;
}
if (parsed.cmd === 'info') {
const data = await customerDevice.getCustomerDeviceData(targetTag);
await reply(formatInfo(data));
return true;
}
if (parsed.cmd === 'cekterhubung') {
const data = await customerDevice.getCustomerDeviceData(targetTag);
await reply(formatCekTerhubung(data));
return true;
}
if (parsed.cmd === 'gantissid') {
if (!parsed.rest) {
await reply('❌ Format salah. Gunakan: \`gantissid TAG namaSSID\`');
return true;
}
const ok = await customerDevice.updateSSID(targetTag, parsed.rest);
if (ok) {
await reply(`✅ SSID untuk *${targetTag}* berhasil diubah menjadi:\n\n📶 *${parsed.rest}*`);
// Kirim notifikasi ke pelanggan
const now = getNowLocal();
const cust = customerSvc.findCustomerByAny(targetTag);
const custName = cust?.name ? `👤 *Pelanggan:* ${cust.name}\n` : '';
const notifMsg =
`📶 *PERUBAHAN SSID WIFI*\n\n` +
custName +
`🏷️ *Tag/ID:* ${targetTag}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`SSID WiFi Anda sudah diperbarui oleh Admin menjadi:\n` +
`📡 *${parsed.rest}*\n\n` +
`Jika perangkat belum tersambung, silakan pilih SSID baru di HP/laptop Anda.\n` +
`⚠️ Jangan bagikan info ini ke orang lain.`;
const notifSent = await notifyCustomer(currentSockInstance, currentLidStore, targetTag, notifMsg);
if (notifSent) {
await reply(`📤 Notifikasi terkirim ke pelanggan *${targetTag}*`);
} else {
await reply(`⚠️ Tidak dapat mengirim notifikasi ke pelanggan *${targetTag}* (nomor belum terdaftar)`);
}
} else {
await reply('❌ Gagal mengubah SSID.');
}
return true;
}
if (parsed.cmd === 'gantisandi') {
if (!parsed.rest || parsed.rest.length < 8) {
await reply('❌ Sandi minimal 8 karakter.');
return true;
}
const ok = await customerDevice.updatePassword(targetTag, parsed.rest);
if (ok) {
await reply('✅ Password WiFi berhasil diubah.');
// Kirim notifikasi ke pelanggan
const now = getNowLocal();
const cust = customerSvc.findCustomerByAny(targetTag);
const custName = cust?.name ? `👤 *Pelanggan:* ${cust.name}\n` : '';
const notifMsg =
`🔑 *PERUBAHAN PASSWORD WIFI*\n\n` +
custName +
`🏷️ *Tag/ID:* ${targetTag}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`Password WiFi Anda sudah diperbarui oleh Admin menjadi:\n` +
`🔐 *${parsed.rest}*\n\n` +
`Silakan gunakan password baru untuk terhubung.\n` +
`⚠️ Jangan bagikan password ini ke orang lain.`;
const notifSent = await notifyCustomer(currentSockInstance, currentLidStore, targetTag, notifMsg);
if (notifSent) {
await reply(`📤 Notifikasi terkirim ke pelanggan *${targetTag}*`);
} else {
await reply(`⚠️ Tidak dapat mengirim notifikasi ke pelanggan *${targetTag}* (nomor belum terdaftar)`);
}
} else {
await reply('❌ Gagal mengubah password.');
}
return true;
}
if (parsed.cmd === 'reboot') {
const r = await customerDevice.requestReboot(targetTag);
await reply(`🔄 *${targetTag}*\n\n${r.message}`);
return true;
}
}
if (parsed.cmd === 'daftar') {
if (!parsed.rest) {
await reply('❌ Format salah. Gunakan:\n\n\`daftar 081234567890\`\n\n(gunakan tag/nomor yang sama dengan di GenieACS)');
return true;
}
const dev = await customerDevice.resolveDeviceToken(parsed.rest);
if (!dev) {
await reply('❌ Tag/nomor tidak ditemukan di GenieACS. Periksa penulisan atau hubungi admin.');
return true;
}
const nk = normalizeKey(msgKey);
const tagKey = String(parsed.rest || '').trim();
if (currentLidStore) {
currentLidStore.set((msgKey.remoteJid || phone), tagKey);
if (nk.senderLid) currentLidStore.set(nk.senderLid, tagKey);
if (nk.senderPn) currentLidStore.set(nk.senderPn, tagKey);
}
await reply(`✅ Berhasil! Nomor WA ini diikat ke tag:\n\n📍 *${tagKey}*\n\nSilakan gunakan perintah lain.`);
return true;
}
const ctx = await resolveCustomerContext(msgKey, currentLidStore);
if (!ctx) {
await reply(
'❌ Nomor/tag Anda belum dikenali (sering terjadi jika WA memakai @lid).\n\n' +
'Kirim sekali:\n\`daftar NOMORATAUTAG\`\n(sama persis dengan tag di GenieACS), lalu ulangi perintah.'
);
return true;
}
if (parsed.cmd === 'cektagihan') {
const invoices = billingSvc.getInvoicesByAny(ctx.billingKey);
await reply(formatCustomerInvoices(invoices, ctx.billingKey));
return true;
}
if (parsed.cmd === 'info') {
const data = await customerDevice.getCustomerDeviceData(ctx.deviceKey);
await reply(formatInfo(data));
return true;
}
if (parsed.cmd === 'cekterhubung') {
const data = await customerDevice.getCustomerDeviceData(ctx.deviceKey);
await reply(formatCekTerhubung(data));
return true;
}
if (parsed.cmd === 'gantissid') {
if (!parsed.rest) {
await reply('❌ Format salah. Gunakan:\n\n\`gantissid NamaWiFiBaru\`');
return true;
}
const ok = await customerDevice.updateSSID(ctx.deviceKey, parsed.rest);
if (ok) {
await reply(`✅ SSID berhasil diubah menjadi:\n\n📶 *${parsed.rest}*`);
// Kirim notifikasi konfirmasi ke pelanggan via WA
try {
const cust = customerSvc.findCustomerByAny(ctx.billingKey || ctx.deviceKey);
if (cust && cust.phone) {
const now = getNowLocal();
const notifMsg =
`📶 *PERUBAHAN SSID WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`SSID WiFi Anda sudah diperbarui menjadi:\n` +
`📡 *${parsed.rest}*\n\n` +
`Silakan pilih SSID baru di perangkat Anda untuk terhubung.\n` +
`⚠️ Jangan bagikan info ini ke orang lain.`;
await notifyCustomer(currentSockInstance, currentLidStore, ctx.deviceKey, notifMsg);
}
} catch (e) { /* ignore notification errors */ }
} else {
await reply('❌ Gagal mengubah SSID. Coba lagi atau hubungi admin.');
}
return true;
}
if (parsed.cmd === 'gantisandi') {
if (!parsed.rest || parsed.rest.length < 8) {
await reply('❌ Format salah. Gunakan:\n\n\`gantisandi sandibarumin8huruf\`\n\nSandi minimal 8 karakter.');
return true;
}
const ok = await customerDevice.updatePassword(ctx.deviceKey, parsed.rest);
if (ok) {
await reply('✅ Password WiFi berhasil diubah.');
// Kirim notifikasi konfirmasi ke pelanggan via WA
try {
const cust = customerSvc.findCustomerByAny(ctx.billingKey || ctx.deviceKey);
if (cust && cust.phone) {
const now = getNowLocal();
const notifMsg =
`🔑 *PERUBAHAN PASSWORD WIFI*\n\n` +
`👤 *Pelanggan:* ${cust.name}\n` +
`🕒 *Waktu:* ${now}\n\n` +
`Password WiFi Anda sudah diperbarui menjadi:\n` +
`🔐 *${parsed.rest}*\n\n` +
`Silakan gunakan password baru untuk terhubung.\n` +
`⚠️ Jangan bagikan password ini ke orang lain.`;
await notifyCustomer(currentSockInstance, currentLidStore, ctx.deviceKey, notifMsg);
}
} catch (e) { /* ignore notification errors */ }
} else {
await reply('❌ Gagal mengubah password.');
}
return true;
}
if (parsed.cmd === 'reboot') {
const r = await customerDevice.requestReboot(ctx.deviceKey);
await reply(`🔄 *Reboot ONU*\n\n${r.message}`);
}
return true;
}
export async function startWhatsAppBot() {
if (isStarting) {
logger.info('WhatsApp: startWhatsAppBot sedang berjalan, melewati panggilan duplikat.');
return;
}
isStarting = true;
try {
if (currentSock) {
try {
logger.info('WhatsApp: Menutup koneksi socket lama yang masih aktif sebelum reconnect...');
currentSock.ev.removeAllListeners();
currentSock.end();
} catch (e) {
logger.error('WhatsApp: Gagal menutup socket lama:', e.message);
}
currentSock = null;
}
const authFolder = path.resolve(projectRoot, getSetting('whatsapp_auth_folder', 'auth_info_baileys'));
const lidMapPath = path.resolve(projectRoot, getSetting('whatsapp_lid_map_file', 'data/wa-lid-map.json'));
const lidStore = getDefaultLidStore();
const { state, saveCreds } = await useMultiFileAuthState(authFolder);
loadAuthLidReverseMap(authFolder);
let version = [2, 3000, 1043857760]; // Fallback version terbaru
try {
const latest = await fetchLatestBaileysVersion();
if (latest && latest.version) {
version = latest.version;
}
} catch (err) {
logger.warn(`WhatsApp: Gagal mengambil versi terbaru Baileys: ${err.message}. Menggunakan fallback.`);
}
const sock = makeWASocket({
version,
auth: state,
browser: Browsers.ubuntu('Chrome'),
syncFullHistory: false,
markOnlineOnConnect: true,
generateHighQualityLinkPreview: false,
msgRetryCounterCache,
userDevicesCache,
placeholderResendCache,
retryRequestDelayMs: 250,
maxMsgRetryCount: 5,
keepAliveIntervalMs: 30000,
connectTimeoutMs: 60000,
defaultQueryTimeoutMs: 60000,
appStateMacVerification: {
patch: false,
snapshot: false
},
patchMessageBeforeSending: (message) => {
const requiresPatch = !!(
message.buttonsMessage ||
message.templateMessage ||
message.listMessage
);
if (requiresPatch) {
message = {
viewOnceMessage: {
message: {
messageContextInfo: {
deviceListMetadataVersion: 2,
deviceListMetadata: {},
},
...message,
},
},
};
}
return message;
},
getMessage: async (key) => {
return await messageStore.get(key);
},
logger: pino({ level: 'silent' })
});
currentSock = sock;
sock.ev.on('creds.update', () => {
saveCreds();
loadAuthLidReverseMap(authFolder);
});
// Merekam pemetaan nomor HP dari kontak (LID Mapping real-time)
sock.ev.on('contacts.upsert', (contacts) => {
for (const contact of contacts) {
if (contact.id && contact.id.endsWith('@lid') && contact.phoneNumber) {
let digits = String(contact.phoneNumber).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
else if (digits.startsWith('8')) digits = '62' + digits;
lidStore.set(contact.id, digits);
authLidReverse.set(contact.id, digits);
authLidReverse.set(contact.id.split('@')[0], digits);
}
}
});
sock.ev.on('messaging-history.set', ({ contacts }) => {
if (contacts) {
for (const contact of contacts) {
if (contact.id && contact.id.endsWith('@lid') && contact.phoneNumber) {
let digits = String(contact.phoneNumber).replace(/\D/g, '');
if (digits.startsWith('0')) digits = '62' + digits.slice(1);
else if (digits.startsWith('8')) digits = '62' + digits;
lidStore.set(contact.id, digits);
authLidReverse.set(contact.id, digits);
authLidReverse.set(contact.id.split('@')[0], digits);
}
}
}
});
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
whatsappStatus.lastUpdate = getCurrentDateInTimezone();
if (qr) {
whatsappStatus.qr = qr;
whatsappStatus.connection = 'qr';
qrShownSinceStart = true;
notifiedAdminForQr = false;
logger.info(`[WA] QR Code Baru Dihasilkan: ${qr.slice(0, 20)}...`);
qrcode.generate(qr, { small: true });
QRCodeNode.toDataURL(qr, { margin: 2, scale: 6 })
.then((url) => {
whatsappStatus.qrImage = url;
})
.catch(() => {
whatsappStatus.qrImage = null;
});
}
if (connection) {
logger.info(`[WA] Connection Update: ${connection}`);
}
if (connection === 'close') {
whatsappStatus.qr = null;
whatsappStatus.qrImage = null;
whatsappStatus.user = null;
const code = lastDisconnect?.error?.output?.statusCode;
const errorMsg = lastDisconnect?.error?.message || '';
const shouldReconnect = code !== DisconnectReason.loggedOut;
whatsappStatus.connection = code === DisconnectReason.loggedOut ? 'loggedOut' : 'connecting';
// Auto-repair jika terjadi desinkronisasi sesi/Bad MAC
const isBadMac = errorMsg.includes('Bad MAC') || errorMsg.includes('MAC mismatch') || code === 401;
if (isBadMac && code !== DisconnectReason.loggedOut) {
logger.warn(`[WA Auto-Repair] Terdeteksi desinkronisasi sesi/Bad MAC (${errorMsg}). Membersihkan pre-key usang...`);
cleanStaleSessionKeys(authFolder);
}
logger.warn(
`WhatsApp terputus (kode ${code}). ` +
(code === DisconnectReason.loggedOut
? 'Sesi logout — hapus folder auth dan pindai QR lagi.'
: 'Mencoba reconnect dalam 3 detik...')
);
if (shouldReconnect) {
const delay = code === DisconnectReason.restartRequired ? 500 : 3000;
setTimeout(() => startWhatsAppBot(), delay);
}
} else if (connection === 'open') {
whatsappStatus.qr = null;
whatsappStatus.qrImage = null;
whatsappStatus.connection = 'open';
whatsappStatus.user = sock.user;
logger.info('WhatsApp bot terhubung. Akun Bot JID: ' + (sock.user?.id || 'unknown') + ', Name: ' + (sock.user?.name || 'unknown'));
// Pre-resolve admin LIDs
(async () => {
try {
const adminList = getWhatsappAdminNumbers();
for (const n of adminList) {
const digits = String(n).replace(/\D/g, '');
if (digits.length >= 8) {
const formatted = digits.startsWith('0') ? '62' + digits.slice(1) : digits.startsWith('62') ? digits : '62' + digits;
const jid = `${formatted}@s.whatsapp.net`;
const waCheck = await sock.onWhatsApp(jid).catch(() => []);
if (waCheck && waCheck.length > 0 && waCheck[0].exists && waCheck[0].lid) {
const lidJid = waCheck[0].lid;
const lidUser = lidJid.split('@')[0];
lidStore.set(lidJid, formatted);
lidStore.set(lidUser, formatted);
authLidReverse.set(lidJid, formatted);
authLidReverse.set(lidUser, formatted);
logger.info(`[WA Admin Pre-resolve] Nomor admin ${formatted} terpetakan ke LID: ${lidJid}`);
}
}
}
} catch (e) {
logger.warn(`[WA Admin Pre-resolve] Pre-resolve error: ${e.message}`);
}
})();
if (qrShownSinceStart && !notifiedAdminForQr) {
notifiedAdminForQr = true;
const toList = loadWhatsappAdminSendList();
if (toList.length > 0) {
const wid = sock.user?.id ? String(sock.user.id).split(':')[0] : '-';
const body =
`✅ QR berhasil dipindai dan bot sudah aktif.\n\n` +
`Nomor Bot: ${wid}\n` +
`Waktu: ${getNowLocal()}\n\n` +
`Silakan gunakan menu Admin untuk fitur billing, notifikasi, dan broadcast.\n\n` +
`🙏 Jika aplikasi ini bermanfaat dan Anda ingin mendukung pengembangan, Anda dapat berdonasi secara sukarela ke nomor: 081947215703.\n` +
`Terima kasih atas dukungannya.`;
const msg = waWrap('🤖 *WHATSAPP BOT AKTIF*', body);
for (const digits of toList) {
const jid = `${digits}@s.whatsapp.net`;
sock.sendMessage(jid, { text: msg }).then(res => {
if (res && res.key && res.message) cacheSentMessage(res.key, res.message);
}).catch(() => { });
}
}
qrShownSinceStart = false;
}
}
});
sock.ev.on('messages.upsert', async ({ messages, type }) => {
// WA interaksi (perintah masuk) DINONAKTIFKAN — WA hanya untuk notifikasi keluar.
// Interaksi cepat kini di Telegram. (Fungsi kirim tetap aktif.)
return;
if (type !== 'notify') return;
for (const m of messages) {
try {
if (m.key && m.message) {
cacheSentMessage(m.key, m.message);
}
const selfPn = sock.user && sock.user.id ? sock.user.id.split(':')[0].split('@')[0] : null;
const selfLid = sock.user && sock.user.lid ? sock.user.lid.split('@')[0] : null;
const remoteUser = m.key.remoteJid ? m.key.remoteJid.split('@')[0] : null;
const isSelf = m.key.fromMe && (remoteUser === selfPn || (selfLid && remoteUser === selfLid));
if (m.key.fromMe && !isSelf) continue;
const text = getMessageText(m);
if (!text) continue;
const remote = m.key.remoteJid;
if (!remote || remote.endsWith('@g.us')) continue;
const phone = getPhoneFromKey(m.key);
const targetJid = (phone && phone.length >= 8) ? `${phone}@s.whatsapp.net` : remote;
// Pengiriman balasan langsung tanpa quote untuk mencegah isu "Menunggu pesan ini"
const reply = async (msg) => {
logger.info(`[WA reply] Menyiapkan balasan ke ${targetJid} (dari ${remote}): "${msg.substring(0, 60)}..."`);
try {
const result = await sock.sendMessage(targetJid, { text: waAutoWrap(msg) });
if (result && result.key && result.message) {
cacheSentMessage(result.key, result.message);
}
logger.info(`[WA reply] Balasan berhasil dikirim ke JID: ${targetJid}`);
} catch (sendErr) {
logger.error('[WA reply] Gagal mengirim balasan: ' + sendErr.message);
}
};
await processIncomingCommand({
senderPhone: phone,
text: text,
reply: reply,
senderName: m.pushName || '',
key: m.key,
sock: sock,
lidStore: lidStore
});
} catch (e) {
logger.error('WhatsApp message handler:', e.message || e);
}
}
});
} catch (err) {
logger.error('Gagal inisialisasi Baileys WhatsApp bot:', err);
throw err;
} finally {
isStarting = false;
}
}