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 // 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 ` β€” Bayar tagihan pelanggan (potong saldo)\n' + '🎟️ `vcr` / `vcr ` β€” 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 \` β€” Bayar tagihan pelanggan\n` + `β€’ \`vcr\` β€” Lihat daftar paket voucher hotspot\n` + `β€’ \`vcr \` β€” 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 `\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 \` 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; } }