import dns from "dns"; dns.setServers(['8.8.8.8', '1.1.1.1']); import { randomUUID } from "crypto"; import db from "./routes/db.js"; import { rmSync, readdir, writeFile, existsSync } from "fs"; import fs from "fs"; import path from "path"; import baileys, { useMultiFileAuthState, makeInMemoryStore, DisconnectReason, delay, downloadMediaMessage, Browsers, fetchLatestBaileysVersion, } from "@whiskeysockets/baileys"; import pino from "pino"; import { toDataURL } from "qrcode"; import axios from "axios"; import dirname from "./dirname.js"; import response from "./response.js"; // ==================== AUTO CREATE & FIX SESSIONS FOLDER ==================== import { execSync } from "child_process"; // Path folder sessions const sessionDir = path.join(dirname, "sessions"); try { // Pastikan folder utama ada if (!fs.existsSync(sessionDir)) { fs.mkdirSync(sessionDir, { recursive: true }); console.log(`๐Ÿ“ Folder sessions dibuat: ${sessionDir}`); } // โœ… Set owner ke root dan group ke www-data execSync(`chown -R root:www-data "${sessionDir}"`); // โœ… Izinkan semua user (root, www-data, dan lainnya) baca/tulis/eksekusi execSync(`chmod -R 777 "${sessionDir}"`); // โœ… Aktifkan sticky bit agar user lain tidak bisa hapus file bukan miliknya execSync(`chmod +t "${sessionDir}"`); console.log(`๐Ÿ” Permission '${sessionDir}' diset ke root:www-data dengan akses penuh (777 + sticky bit)`); // ๐Ÿงน Bersihkan file "ghost" dari macOS seperti ._device_* fs.readdirSync(sessionDir) .filter(f => f.startsWith("._")) .forEach(f => { fs.unlinkSync(path.join(sessionDir, f)); console.log(`๐Ÿงน File ghost dihapus: ${f}`); }); } catch (permErr) { console.warn(`โš ๏ธ Gagal memperbaiki permission di '${sessionDir}':`, permErr.message); } // ======================= // ๐Ÿ”„ SYNC STATUS KE LARAVEL // ======================= const lastSyncedStatus = {}; async function updateLaravelStatus(sessionId, status) { if (lastSyncedStatus[sessionId] === status) return; // ๐Ÿ›‘ STOP SPAM lastSyncedStatus[sessionId] = status; try { const cleanId = sessionId.replace(/^device_/, ""); await axios.post( `https://whatsapp.gudangdistribusi.com/api-app/whatsapp/set-status/${cleanId}/${status}` ); // ๐Ÿ”ฅ TAMBAHKAN INI if (io) { io.emit("device:status", { deviceId: cleanId, // HARUS ID DATABASE status: status }); } console.log(`โœ… [Laravel Sync] ${cleanId} โ†’ ${status}`); } catch (error) { console.error(`โŒ Laravel Sync gagal (${sessionId}):`, error.message); } } const aiStatus = {}; // { sessionId: true/false } global.sessions = global.sessions || new Map(); const sessions = global.sessions; // ๐Ÿง  Project context per nomor const lastProjectByPhone = new Map(); // ๐Ÿงญ LAST TARGET per project (INI PENTING) const lastRelayTargetByProject = new Map(); // key : project_id // value : phone (uploaderWa / lockedWa) // ================= ADMIN DEVICE (WAJIB 1 DEVICE) ================= const ADMIN_PHONE = "601111166486"; const ADMIN_SESSION_ID = "device_a26d98c3-0e27-4450-b429-54df42abbdaa"; // helper ambil admin session const getAdminSession = () => { const adminSession = sessions.get(ADMIN_SESSION_ID); if (!adminSession) { console.error("โŒ ADMIN SESSION belum aktif / belum connect"); return null; } return adminSession; }; export const getSessionMap = () => global.sessions; const retries = new Map(); const processedMessages = new Map(); // sessionId => Set let io = null; export const setSocketInstance = (ioInstance) => { io = ioInstance; }; // ================= SEND QUEUE (ANTI DOUBLE SEND & RACE CONDITION) ================= const sendQueues = new Map(); // sessionId => Promise chain const enqueueSend = async (sessionId, task) => { const last = sendQueues.get(sessionId) || Promise.resolve(); const next = last .then(() => task()) .catch(err => { console.error("โŒ enqueueSend error:", err.message); }); sendQueues.set(sessionId, next); return next; }; const sessionsDir = (subDir = "") => path.join(dirname, "sessions", subDir ? subDir : ""); // Directory for session files const getSessionDir = (filename = "") => path.join(dirname, "sessions", filename ? filename : ""); // Check if a session exists const isSessionExists = (sessionId) => sessions.has(sessionId); // Determine if reconnection should be attempted const shouldReconnect = (sessionId) => { const retryCount = retries.get(sessionId) ?? 0; if (retryCount < 5) { retries.set(sessionId, retryCount + 1); return true; } return false; }; const checkSession = (sessionId, isLegacy) => { const sessionFile = getSessionDir( sessionId + (isLegacy ? "_legacy.json" : "_md.json") ); if (existsSync(sessionFile)) { return JSON.parse(fs.readFileSync(sessionFile)); } return null; }; const getBrowserConfig = () => { return Browsers.ubuntu("Chrome"); }; const backoffReconnect = (attempts) => Math.min(3000* Math.pow(2, attempts), 60000); // ================= HELPER FUNCTIONS (GLOBAL) ================= const isGroupJid = (jid) => typeof jid === "string" && jid.endsWith("@g.us"); const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // Canonical WA ID (untuk DB & logic backend) const getCanonicalWaId = (jid = "") => { if (!jid) return null; return jid .replace(/@(s\.whatsapp\.net|g\.us|lid)/g, "") .split(":")[0]; // ๐Ÿ”ฅ buang device suffix }; const normalizeUiWaId = (jid) => { if (!jid) return null; return jid .replace("@lid", "") .replace("@s.whatsapp.net", ""); }; // ================= CREATE SESSION ================== const createSession = async ( sessionId, isLegacy = false, responseObject = null, markOnline = false, historyMode = false ) => { const existingSession = checkSession(sessionId, isLegacy); if (existingSession) { return existingSession; } // ๐ŸŽŸ๏ธ Load token lama, buat baru jika belum ada try { const cleanId = sessionId.replace(/^device_/, ""); const [rows] = await db.query( "SELECT api_token FROM whatsapp_devices WHERE uid = ? LIMIT 1", [cleanId] ); let newToken = rows?.[0]?.api_token; if (!newToken || newToken.trim() === "") { newToken = randomUUID(); // โŒ Node TIDAK BOLEH INSERT ke whatsapp_devices // โœ… Cukup kirim token ke Laravel console.log(`๐Ÿ”‘ Token baru dibuat untuk ${sessionId}: ${newToken}`); // Sinkron ke Laravel hanya kalau token baru try { const cleanId = sessionId.replace(/^device_/, ""); await axios.post( `${process.env.APP_URL}/whatsapp/set-status/${cleanId}/active`, { api_token: newToken }, { timeout: 8000 } ); console.log(`๐Ÿ“ก Token baru disinkronkan ke Laravel untuk ${sessionId}`); } catch (syncErr) { console.error(`โš ๏ธ Gagal sync token ke Laravel (${sessionId}): ${syncErr.message}`); } } else { console.log(`โœ… Token lama digunakan untuk ${sessionId}`); } } catch (tokenErr) { console.error("โŒ Gagal load/generate API token:", tokenErr.message); } const sessionFilename = (isLegacy ? "legacy_" : "md_") + sessionId + (isLegacy ? ".json" : ""); const logger = pino({ level: "warn" }); const store = makeInMemoryStore({ logger }); let authState, saveCreds; if (!isLegacy) { ({ state: authState, saveCreds } = await useMultiFileAuthState( getSessionDir(sessionFilename) )); } // ๐Ÿงฐ Auto-fix permission untuk folder session baru try { const subSessionPath = path.join(getSessionDir(), sessionFilename.replace(".json", "")); if (fs.existsSync(subSessionPath)) { execSync(`chown -R www-data:www-data "${subSessionPath}"`); execSync(`chmod -R 755 "${subSessionPath}"`); console.log(`๐Ÿ” Auto-fixed permission for ${subSessionPath}`); } } catch (permErr) { console.warn(`โš ๏ธ Gagal auto-fix permission untuk session '${sessionId}':`, permErr.message); } const { version } = await fetchLatestBaileysVersion(); // โœ… FIX: Corrected baileysOptions with getMessage, browser, and emitOwnEvents const baileysOptions = { waWebSocketUrl: "wss://web.whatsapp.com/ws/chat", version, auth: authState, printQRInTerminal: false, logger: logger, generateHighQualityLinkPreview: true, markOnlineOnConnect: markOnline, syncFullHistory: true, connectTimeoutMs: 120000, maxRetries: 5, defaultQueryTimeoutMs: 60000, emitOwnEvents: true, // โœ… FIX: changed from false to true browser: Browsers.ubuntu("Chrome"), // โœ… FIX: changed from ["WhatsApp", "Android", "10"] getMessage: async (key) => { // โœ… FIX: added getMessage handler if (store) { const msg = await store.loadMessage(key.remoteJid, key.id); return msg?.message || undefined; } return undefined; }, }; const baileysClient = baileys.default(baileysOptions); if (!isLegacy) { store.readFromFile(getSessionDir(sessionId + "_store.json")); store.bind(baileysClient.ev); } sessions.set(sessionId, { ...baileysClient, store: store, isLegacy: isLegacy, }); baileysClient.ev.on("creds.update", saveCreds); baileysClient.ev.on("chats.set", ({ chats }) => { if (isLegacy) { store.chats.insertIfAbsent(...chats); } }); // ================= MESSAGE LISTENER ================== baileysClient.ev.on("messages.upsert", async (messageUpdate) => { const { messages } = messageUpdate; if (!messages || !messages[0]) return; const message = messages[0]; let detectedProjectId = null; const isFromMe = message.key?.fromMe === true; const deviceWaId = getCanonicalWaId( baileysClient.user?.id || baileysClient.user?.jid ); const senderWaId = getCanonicalWaId(message.key.remoteJid); // โœ… TRUE = PESAN ASLI USER const isIncoming = message.key.fromMe !== true; // โ›” Skip hanya pesan history / sinkronisasi lama if ( message.key?.fromMe === true && messageUpdate.type === "append" ) { return; } let vendorId = null; try { if (!messages || !messages[0]) return; const msg = message.message; if (isFromMe) { console.log("โ›” Pesan fromMe, lanjut emit saja (tanpa AI)"); } // ๐Ÿ”’ Lock message ID const msgId = message.key?.id; if (!msgId) return; // ================= ANTI DUPLICATE MESSAGE (PER SESSION) ================= if (!processedMessages.has(sessionId)) { processedMessages.set(sessionId, new Set()); } const msgSet = processedMessages.get(sessionId); // jika pesan sudah pernah diproses โ†’ stop if (msgSet.has(msgId)) { console.log("โ›” Duplicate message skipped:", msgId); return; } // tandai pesan sudah diproses msgSet.add(msgId); // bersihkan setelah 5 menit agar tidak bocor memory setTimeout(() => { msgSet.delete(msgId); }, 5 * 60 * 1000); // ======================================================================== //const { messages: [message] } = messageUpdate; // const { message: msg, key, pushName } = message; //const { message: msg } = message; // โœ… Definisikan semua variabel global di awal // ================= MESSAGE CONTENT & TYPE (DEFAULT) ================= let messageContent = msg?.conversation || msg?.buttonsResponseMessage?.selectedDisplayText || msg?.listResponseMessage?.title || msg?.extendedTextMessage?.text || msg?.imageMessage?.caption || msg?.videoMessage?.caption || msg?.documentMessage?.caption || (msg?.stickerMessage?.fileSha256 ? Buffer.from(msg.stickerMessage.fileSha256).toString("hex") : "") || ""; // โ›” Skip notify kosong (SETELAH messageContent ada) if ( messageUpdate.type === "notify" && (!messageContent || messageContent.trim() === "") ) { console.log("โ›” Skip notify kosong"); return; } let messageType = "text"; if (msg?.imageMessage) messageType = "image"; else if (msg?.videoMessage) messageType = "video"; else if (msg?.documentMessage) messageType = "document"; else if (msg?.stickerMessage) messageType = "sticker"; else if (msg?.audioMessage) messageType = "audio"; // ================= HANDLE INBOUND IMAGE (DOWNLOAD + OVERRIDE) ================= if (msg?.imageMessage) { try { const buffer = await downloadMediaMessage( message, "buffer", {}, { reuploadRequest: baileysClient.updateMediaMessage } ); const filename = `${Date.now()}_${randomUUID()}.jpg`; const savePath = "/var/www/html/whatsapp/public/uploads/whatsapp/" + filename; fs.mkdirSync(path.dirname(savePath), { recursive: true }); fs.writeFileSync(savePath, buffer); // ๐Ÿ”ฅ PENTING: // messageContent DI-OVERRIDE jadi URL gambar messageContent = `${process.env.APP_URL}/uploads/whatsapp/${filename}`; messageType = "image"; console.log("๐Ÿ–ผ๏ธ Inbound image saved:", messageContent); } catch (err) { console.error("โŒ Gagal download image inbound:", err.message); } } // โŒ Jika pesan gagal didecrypt, JANGAN balas AI if (!message.message || Object.keys(message.message).length === 0) { console.log("โš ๏ธ Pesan notify / kosong, tetap lanjut DB"); } // ๐Ÿ”’ Ambil nomor device const deviceNumber = getCanonicalWaId( baileysClient.user?.id || baileysClient.user?.jid ); // ๐Ÿ”’ Ambil nomor contact const contactNumber = getCanonicalWaId(message.key.remoteJid); // ๐Ÿ”’ Tentukan from & contact const fromNumber = isIncoming ? contactNumber : deviceNumber; const plainWaId = contactNumber; const uiWaId = normalizeUiWaId(message.key.remoteJid); console.log( `๐Ÿ“ฅ Direction detect: ${isIncoming ? "INBOUND" : "OUTBOUND"} | fromMe=${message.key.fromMe} | type=${messageUpdate.type}` ); const inboundUid = randomUUID(); //const uuid = randomUUID(); console.log(`๐Ÿงพ ${isIncoming ? "INBOUND" : "OUTBOUND"} dari ${fromNumber} | "${messageContent}"`); // === SIMPAN KE DATABASE === if (messageContent) { try { // ๐Ÿ”น Cari vendorId vendorId = null; try { const [deviceRows] = await db.query( "SELECT merchant_id FROM whatsapp_devices WHERE id = ? OR uid = ? OR whatsapp_session = ? LIMIT 1", [sessionId, sessionId, sessionId] ); if (deviceRows?.length && deviceRows[0].merchant_id) { const merchantUid = deviceRows[0].merchant_id; const [vendorRows] = await db.query( "SELECT _id FROM vendors WHERE _uid = ? LIMIT 1", [merchantUid] ); if (vendorRows?.length) vendorId = vendorRows[0]._id; } } catch (lookupErr) { console.warn("โš ๏ธ Lookup vendor gagal:", lookupErr.message); } if (!vendorId) vendorId = 3; let contactId = null; // 1๏ธโƒฃ cek contact const [rows] = await db.query( "SELECT _id FROM contacts WHERE wa_id = ? AND vendors__id = ? LIMIT 1", [plainWaId, vendorId] ); // 2๏ธโƒฃ jika belum ada โ†’ insert if (rows.length) { contactId = rows[0]._id; } else { const [insertResult] = await db.query( "INSERT INTO contacts (_uid, wa_id, vendors__id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())", [randomUUID(), plainWaId, vendorId] ); contactId = insertResult.insertId; } // 3๏ธโƒฃ BARU update last_inbound_jid (PASTI KENA) await db.query( "UPDATE contacts SET last_inbound_jid = ? WHERE _id = ?", [plainWaId, contactId] ); // ๐Ÿ”น Timestamp const ts = typeof message.messageTimestamp === "object" ? message.messageTimestamp.low || null : message.messageTimestamp; // ๐Ÿ”น Insert log console.log(`๐Ÿงฉ Debug Insert DB: isIncoming=${isIncoming} | fromMe=${message.key.fromMe} | type=${messageUpdate.type}`); if (isIncoming === true) { if (message.key?.id) { const [exists] = await db.query( "SELECT 1 FROM whatsapp_message_logs WHERE wamid = ? LIMIT 1", [message.key.id] ); if (exists.length > 0) { console.log("โ›” Skip duplicate wamid"); } else { await db.query( `INSERT INTO whatsapp_message_logs (_uid, from_number, contact_wa_id, message, type, is_incoming_message, device_id, vendors__id, wamid, contacts__id, created_at, messaged_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, NOW(), ?)`, [ inboundUid, fromNumber, plainWaId, messageContent.toString(), messageType, sessionId, vendorId, message.key.id || null, contactId, ts ? new Date(ts * 1000) : null, ] ); console.log(`โœ… INBOUND tersimpan (uid=${inboundUid})`); } } // ๐Ÿš€ EMIT REALTIME INBOUND (WAJIB ADA) if (io) { io.to(sessionId).emit("chat:new", { sessionId, contact: plainWaId, wa_id: plainWaId, message: messageContent.toString(), type: messageType, isIncoming: true, message_id: inboundUid, }); console.log(`๐Ÿ“ก Emit chat:new INBOUND ke room ${sessionId}`); } } // โœ… PENUTUP if (isIncoming === true) // ================= HANDLE OUTBOUND (fromMe) ================= if (message.key?.fromMe === true) { // โš ๏ธ pesan echo dari WhatsApp (notify) // kita cek: sudah ada di DB atau belum? if (!message.key.id) { return; } const [exists] = await db.query( "SELECT 1 FROM whatsapp_message_logs WHERE wamid = ? LIMIT 1", [message.key.id] ); if (exists.length > 0) { // โœ… SUDAH disimpan oleh sendMessage() // โŒ JANGAN emit // โŒ JANGAN insert console.log("โ›” Outbound echo (sudah ada di DB)"); return; } // ๐Ÿ”ฅ FALLBACK: pesan dari HP manual / case aneh await db.query( `INSERT INTO whatsapp_message_logs (_uid, from_number, contact_wa_id, message, type, is_incoming_message, device_id, vendors__id, wamid, contacts__id, created_at, messaged_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, NOW(), ?)`, [ randomUUID(), deviceNumber, plainWaId, messageContent, messageType, sessionId, vendorId, message.key.id, contactId, ts ? new Date(ts * 1000) : null, ] ); console.log("โœ… Outbound manual / fallback tersimpan"); // ๐Ÿ“ก emit SEKALI if (io) { io.to(sessionId).emit("chat:new", { sessionId, contact: plainWaId, wa_id: plainWaId, message: messageContent, type: messageType, isIncoming: false, }); } return; } // ================= DETECT PROJECT ID (SETELAH INSERT) ================= const projectIdMatch = messageContent.match( /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i ); if (projectIdMatch) { detectedProjectId = projectIdMatch[1]; console.log("๐Ÿงฉ Project ID TERDETEKSI:", detectedProjectId); } if (detectedProjectId) { await db.query( "UPDATE whatsapp_message_logs SET project_id = ? WHERE _uid = ?", [detectedProjectId, inboundUid] ); console.log("โœ… project_id di-update pada pesan:", detectedProjectId); } const resolveSenderPhone = async (message, vendorId) => { const lid = getCanonicalWaId(message.key.remoteJid); // 1๏ธโƒฃ Coba dari contacts.wa_id (LID) let [rows] = await db.query( "SELECT wa_id FROM contacts WHERE wa_id = ? AND vendors__id = ? LIMIT 1", [lid, vendorId] ); if (rows.length) { return rows[0].wa_id; } // 2๏ธโƒฃ Fallback dari last_inbound_jid [rows] = await db.query( "SELECT last_inbound_jid FROM contacts WHERE last_inbound_jid = ? AND vendors__id = ? LIMIT 1", [lid, vendorId] ); if (rows.length) { return rows[0].last_inbound_jid; } return null; }; // ================= PROJECT CONTEXT (GLOBAL PER MESSAGE) ================= let lastProjectId = null; // ================= ADMIN SMART ROUTER (FINAL) ================= if ( sessionId === ADMIN_SESSION_ID && isIncoming === true && message.key.fromMe !== true ) { console.log("๐Ÿง  ADMIN SMART ROUTER"); const senderWa = plainWaId; // nomor pengirim ke admin let projectId = detectedProjectId; // 1๏ธโƒฃ Jika tidak ada project_id โ†’ pakai context terakhir if (!projectId) { projectId = lastProjectByPhone.get(senderWa) || null; if (projectId) { console.log("๐Ÿง  Project dipulihkan dari context:", projectId); } } // 2๏ธโƒฃ Fallback: jika cuma 1 project aktif (is_upload=0) if (!projectId) { const [rows] = await db.query(` SELECT id, uploader_phone, locked_phone FROM projects WHERE is_upload = 0 AND uploader_phone IS NOT NULL AND locked_phone IS NOT NULL LIMIT 2 `); if (rows.length === 1) { projectId = rows[0].id; console.log("๐Ÿง  Project fallback tunggal:", projectId); } } if (!projectId) { console.log("โŒ Project tidak bisa ditentukan โ†’ STOP"); return; } // 3๏ธโƒฃ Ambil data project const [projectRows] = await db.query( "SELECT uploader_phone, locked_phone FROM projects WHERE id = ? LIMIT 1", [projectId] ); if (!projectRows.length) return; // ๐Ÿงญ DEBUG: lihat arah relay terakhir untuk project ini console.log( "๐Ÿงญ Last relay target:", lastRelayTargetByProject.get(projectId) || "(belum ada)" ); const uploaderWa = projectRows[0].uploader_phone ?.replace(/\D/g, "") .replace(/^0/, "60"); const lockedWa = projectRows[0].locked_phone ?.replace(/\D/g, "") .replace(/^0/, "60"); if (!uploaderWa || !lockedWa) { console.log("โŒ Project tidak valid (phone kosong)"); return; } // 4๏ธโƒฃ Tentukan arah relay (SMART + CONTEXT) let targetWa = null; let reason = ""; const lastTarget = lastRelayTargetByProject.get(projectId); // PRIORITAS 1: sender asli (locked / uploader) if (senderWa === lockedWa) { targetWa = uploaderWa; reason = "locked โ†’ uploader"; } else if (senderWa === uploaderWa) { targetWa = lockedWa; reason = "uploader โ†’ locked"; } // PRIORITAS 2: sender external โ†’ BALIKAN ARAH else if (lastTarget === uploaderWa) { targetWa = lockedWa; reason = "external โ†’ locked (context)"; } else if (lastTarget === lockedWa) { targetWa = uploaderWa; reason = "external โ†’ uploader (context)"; } // PRIORITAS 3: fallback terakhir else { targetWa = uploaderWa; reason = "external โ†’ uploader (default)"; } console.log(`๐Ÿ” ${reason}`); console.log(`๐ŸŽฏ Target: ${targetWa}`); const adminSession = getAdminSession(); if (!adminSession) return; // 5๏ธโƒฃ Kirim sebagai PESAN BARU (anti waiting message) await adminSession.sendMessage( `${targetWa}@s.whatsapp.net`, { text: [ "๐Ÿ’ฌ *Pesan Project*", "", messageContent, "", `๐Ÿ†” ${projectId}` ].join("\n") } ); // 6๏ธโƒฃ SIMPAN CONTEXT (INI PALING PENTING) lastProjectByPhone.set(senderWa, projectId); lastProjectByPhone.set(targetWa, projectId); lastRelayTargetByProject.set(projectId, targetWa); console.log(`โœ… RELAY OK (${senderWa} โ†’ ${targetWa})`); return; // โ›” STOP TOTAL (AI & LOGIC LAIN MATI) } // โ›” ADMIN TIDAK BOLEH KENA AI if (plainWaId === ADMIN_PHONE) { console.log("โ›” Skip AI untuk ADMIN"); return; } const aiKey = plainWaId; // ๐Ÿ”ฅ AI per CONTACT, bukan per device // ๐Ÿง  AI Status Default if (aiStatus[sessionId] === undefined) { aiStatus[sessionId] = { __device: true }; } if (aiStatus[sessionId][aiKey] === undefined) { aiStatus[sessionId][aiKey] = true; } const textMsg = msg?.conversation || msg?.extendedTextMessage?.text || ""; // ๐Ÿงฉ Command untuk ON/OFF per nomor if (textMsg.toLowerCase().includes("off")) { aiStatus[sessionId][aiKey] = false; console.log(`[ChatGPT] AI OFF untuk nomor ${message.key.remoteJid}`); return; } if (textMsg.toLowerCase().includes("on")) { aiStatus[sessionId][aiKey] = true; console.log(`[ChatGPT] AI ON untuk nomor ${message.key.remoteJid}`); return; } if (["#aioff", "#aion"].includes(textMsg.toLowerCase())) { aiStatus[sessionId].__device = textMsg.toLowerCase() === "#aion"; console.log(`[ChatGPT] AI ${aiStatus[sessionId].__device ? "ON" : "OFF"} untuk device ${sessionId}`); await baileysClient.sendMessage(message.key.remoteJid, { delete: message.key }); return; } // ๐ŸŸก Skip AI jika OFF if (!message.key.fromMe && (aiStatus[sessionId].__device === false || aiStatus[sessionId][aiKey] === false)) { console.log(`[ChatGPT] Skip balasan (AI OFF) untuk ${message.key.remoteJid}`); return; } } catch (dbErr) { console.error("โŒ Gagal insert ke DB:", dbErr); } if (isIncoming === true && message.key?.fromMe !== true) { console.log(`๐Ÿ’พ Pesan inbound baru tersimpan di DB (${fromNumber})`); } } // ๐Ÿค– ChatGPT Auto-Reply hanya untuk pesan masuk const isLid = message.key.remoteJid.endsWith("@lid"); console.log("๐Ÿงช AI CHECK:", { isIncoming, hasContent: !!messageContent, autoReplyMethod: process.env.AUTO_REPLY_METHOD, isGroup: isGroupJid(message.key.remoteJid), }); // ๐Ÿ–ผ๏ธ AUTO-REPLY KHUSUS MEDIA (IMAGE / VIDEO / DOCUMENT) if ( isIncoming === true && messageType !== "text" && !isGroupJid(message.key.remoteJid) ) { console.log("๐Ÿ“Ž Media diterima โ†’ kirim auto-reply attachment"); await sendMessage( sessions.get(sessionId), message.key.remoteJid, { text: "Thank you for your attachment." }, sessionId, vendorId ); return; // โ›” STOP โ†’ jangan lanjut ke AI } if ( isIncoming === true && messageContent && process.env.AUTO_REPLY_METHOD === "chatgpt" && sessions.has(sessionId) && !isGroupJid(message.key.remoteJid) && !detectedProjectId && !lastProjectId ){ if (isLid) { console.log("๐ŸŸก Pesan dari LID, decrypt OK โ†’ lanjut AI"); } try { const [rows] = await db.query( "SELECT message, is_incoming_message FROM whatsapp_message_logs WHERE contact_wa_id = ? ORDER BY _id DESC LIMIT 10", [ plainWaId ] ); const history = rows .reverse() .filter(row => typeof row.message === "string" && row.message.trim() !== "") .map(row => ({ role: row.is_incoming_message === 1 ? "user" : "assistant", content: row.message })); if (typeof messageContent === "string" && messageContent.trim() !== "") { history.push({ role: "user", content: messageContent }); } else { console.log("โš ๏ธ Skip push ke AI: messageContent kosong / bukan string"); } const { generateAiReply } = await import("./openai.js"); const aiReply = await generateAiReply(history, message.key.remoteJid, sessionId); if (aiReply) { const replyJid = message.key.remoteJid; await sendMessage( sessions.get(sessionId), replyJid, { text: aiReply }, sessionId, vendorId ); axios.post(`${process.env.APP_URL}/api-app/whatsapp/ai-reply/${sessionId}`, { contact_id: fromNumber, reply: aiReply, }).catch(err => console.error("Laravel Sync Error", err.message)); } } catch (aiErr) { console.error("โŒ ChatGPT Auto-Reply Error:", aiErr.message); } } } catch (err) { console.error("โŒ Error di messages.upsert:", err); } }); // ================= CONNECTION LISTENER ================== baileysClient.ev.on("connection.update", async (update) => { const { connection, lastDisconnect } = update; // โœ… Filter tambahan untuk cegah status 'disconnected' palsu if (connection === "close" && baileysClient.ws?.readyState === 1) { console.log(`โš ๏ธ Abaikan false disconnect (still open) untuk ${sessionId}`); return; } // ๐ŸŸข HANYA saat koneksi BENAR-BENAR terbuka if (connection === "open") { retries.delete(sessionId); if (sessions.has(sessionId)) { sessions.get(sessionId).state = "connected"; await updateLaravelStatus(sessionId, "active"); console.log(`โœ… Device ${sessionId} connected dan status dikirim ke Laravel`); } else { console.warn(`โš ๏ธ Session ${sessionId} belum terdaftar saat connection=open`); } } try { const lastErrMsg = update.lastDisconnect?.error?.message || ""; if (lastErrMsg.includes("Bad MAC") || lastErrMsg.toLowerCase().includes("session error")) { console.warn(`โš ๏ธ Detected session crypto error (${sessionId}): ${lastErrMsg}`); safeResetSession(sessionId, sessions.get(sessionId)?.isLegacy); return; } } catch (e) { console.warn("โš ๏ธ Error saat memeriksa lastDisconnect:", e.message); } if (connection === "close") { const statusCode = lastDisconnect?.error?.output?.statusCode; // ๐Ÿ”ด BENAR-BENAR LOGOUT if (statusCode === DisconnectReason.loggedOut) { console.log(`๐Ÿ”ด Device ${sessionId} LOGGED OUT`); if (sessions.has(sessionId)) { sessions.get(sessionId).state = "disconnected"; } await updateLaravelStatus(sessionId, "disconnected"); if (responseObject && !responseObject.headersSent) { response(responseObject, 401, false, "Device logged out."); } return deleteSession(sessionId, isLegacy); } // ๐ŸŸก DISCONNECT SEMENTARA โ†’ RECONNECT const attempts = retries.get(sessionId) || 0; if (attempts < 5) { retries.set(sessionId, attempts + 1); const reconnectDelay = backoffReconnect(attempts); console.log( `๐ŸŸก Temporary disconnect ${sessionId}, reconnect attempt ${attempts + 1}` ); setTimeout(() => createSession(sessionId, isLegacy), reconnectDelay); } else { console.warn( `โš ๏ธ Reconnect limit tercapai untuk ${sessionId}, tetap JANGAN delete session` ); } } if (update.qr) { if (responseObject && !responseObject.headersSent) { try { const qrCodeDataUrl = await toDataURL(update.qr); response( responseObject, 200, true, "QR code tersedia, silakan scan ulang", { qr: qrCodeDataUrl } ); return; } catch (err) { response(responseObject, 500, false, "Gagal generate QR"); } } // โœ… PENTING: // QR ulang = minta scan ulang // โŒ JANGAN logout // โŒ JANGAN delete session return; } }); }; // ================= PERIODIC TASK ================== /* setInterval(() => { const siteKey = process.env.SITE_KEY ?? null; const appUrl = process.env.APP_URL ?? null; const authorizationUrl = process.env.AUTH_API ?? null; // pakai AUTH_API dari .env if (!authorizationUrl || !appUrl || !siteKey) { console.warn("โš ๏ธ Environment variables AUTH_API, APP_URL, atau SITE_KEY belum di-set!"); return; } axios.post(authorizationUrl, { from: appUrl, key: siteKey }) .then(response => { if (response.data.isauthorised === 401) { console.warn("โš ๏ธ Unauthorized access detected! .env tidak dihapus."); } }) .catch(err => { console.error("โš ๏ธ Error cek authorization:", err.message); }); }, 3600000); // dijalankan setiap 1 jam */ // ================= UTIL FUNCS ================== const getSession = (sessionId) => sessions.get(sessionId) ?? null; //const sentWebHook = (sessionId, messageDetails, type) => { // const webhookUrl = `${process.env.APP_URL}/api-app/whatsapp/callback/${sessionId}`; // axios.post(webhookUrl, { // from: messageDetails.remote_id, // message_id: messageDetails.message_id, // message: messageDetails.message, // from_name: messageDetails.from, // type: type, // }).catch((error) => { // console.log("error webhook", error); // }); //}; const sentWebHook = async (sessionId, messageDetails, type) => { const webhookUrl = `${process.env.APP_URL}/api-app/whatsapp/callback/${sessionId}`; const payload = { from: messageDetails.remote_id, message_id: messageDetails.message_id, message: messageDetails.message, from_name: messageDetails.from, type: type, }; try { const response = await axios.post(webhookUrl, payload, { timeout: 8000, // โณ Timeout biar tidak menggantung validateStatus: () => true, // โ›” Jangan lempar error untuk status non-200 }); // Jika webhook tidak 200 if (response.status !== 200) { console.warn( `[WEBHOOK WARNING] Status ${response.status} dari ${webhookUrl}` ); return; } // Jika webhook mengirim auto-reply if (response.data && response.data.reply === true) { const session = sessions.get(response.data.session_id); if (!session) { console.warn("[WEBHOOK] Session not found:", response.data.session_id); return; } // autoread if (response.data.autoread) { try { await session.readMessages([ { remoteJid: messageDetails.remote_id, id: messageDetails.message_id, }, ]); } catch (err) { console.error("โŒ Gagal auto-read:", err); } } // kirim reply try { await sendMessage( session, response.data.receiver, response.data.message, response.data.session_id ); } catch (err) { console.error("โŒ Gagal kirim auto-reply:", err); } } } catch (err) { console.error("[WEBHOOK ERROR]", err.message); } }; const sendForme = (sessionId, messageDetails, type) => { const webhookUrl = `${process.env.APP_URL}/api-app/whatsapp/to-me/${sessionId}`; axios.post(webhookUrl, { from: messageDetails.remote_id, message_id: messageDetails.message_id, message: messageDetails.message, from_name: messageDetails.from, type: type, }).catch((error) => { console.log("error webhook", error); }); }; const deleteSession = (sessionId, isLegacy = false) => { const sessionFile = (isLegacy ? "legacy_" : "md_") + sessionId + (isLegacy ? ".json" : ""); const storeFile = sessionId + "_store.json"; const options = { force: true, recursive: true }; rmSync(getSessionDir(sessionFile), options); rmSync(getSessionDir(storeFile), options); sessions.delete(sessionId); retries.delete(sessionId); }; const getChatList = (sessionId, isGroup = false, lastChat = null) => { const chatSuffix = isGroup ? "@g.us" : "@s.whatsapp.net"; return (sessions.get(sessionId) ?? null).store.chats.filter((chat) => { const timestamp = typeof chat.conversationTimestamp === "number" ? chat.conversationTimestamp : chat.conversationTimestamp?.low ? chat.conversationTimestamp.low : chat.conversationTimestamp?.low?.low ? chat.conversationTimestamp.low.low : null; return ( chat.id.endsWith(chatSuffix) && timestamp && (lastChat != null ? timestamp > lastChat : true) ); }); }; const getContactList = (sessionId) => (sessions.get(sessionId) ?? null).store.contacts; const isExists = async (session, id, isGroup = false) => { try { let metadata; if (isGroup) { metadata = await session.groupMetadata(id); return Boolean(metadata.id); } if (session.isLegacy) { metadata = await session.onWhatsApp(id); } else { [metadata] = await session.onWhatsApp(id); } return metadata.exists; } catch { return false; } }; const getVendorIdFromSession = async (sessionId) => { const cleanId = sessionId.replace(/^device_/, ""); const [rows] = await db.query( ` SELECT v._id AS vendor_id FROM whatsapp_devices d JOIN vendors v ON v._uid = d.merchant_id WHERE d.uid = ? OR d.id = ? OR d.whatsapp_session = ? LIMIT 1 `, [cleanId, cleanId, cleanId] ); if (!rows.length) { throw new Error("Vendor tidak ditemukan untuk session " + sessionId); } return rows[0].vendor_id; }; const sendMessage = async (session, receiverId, messageContent, sessionId, vendorId, delayMs = 800) => { // ๐Ÿ”’ PASTIKAN vendorId SELALU ADA if (!vendorId) { vendorId = await getVendorIdFromSession(sessionId); console.log("๐Ÿ”Ž vendorId diambil dari DB:", vendorId); } // ๐Ÿ”’ Ambil nomor device (WA pengirim) const deviceNumber = getCanonicalWaId( session.user?.id || session.user?.jid ); // receiverId HARUS SUDAH JID (@lid atau @s.whatsapp.net) if (!receiverId.includes("@")) { receiverId = `${receiverId}@s.whatsapp.net`; } let sentMsg = null; let dbSaved = false; // Track apakah pesan tersimpan di DB try { // Typing effect await session.sendPresenceUpdate("composing", receiverId); await delay(parseInt(delayMs)); // Kirim pesan ke WhatsApp let sendPayload = {}; let messageType = "text"; let messageText = ""; if (messageContent.text) { // TEXT sendPayload = { text: messageContent.text }; messageText = messageContent.text; messageType = "text"; } else if (messageContent.image) { // IMAGE sendPayload = { image: { url: messageContent.image }, caption: messageContent.caption || "" }; messageText = messageContent.caption || "[IMAGE]"; messageType = "image"; } else if (messageContent.document) { // DOCUMENT / PDF sendPayload = { document: { url: messageContent.document }, fileName: messageContent.fileName || "file", mimetype: messageContent.mimetype || "application/octet-stream", caption: messageContent.caption || "" }; messageText = messageContent.caption || "[DOCUMENT]"; messageType = "document"; } else { throw new Error("Unsupported message format"); } sentMsg = await session.sendMessage(receiverId, sendPayload); console.log(`๐Ÿ“ค Pesan berhasil dikirim ke ${receiverId}`); // === SIMPAN KE DATABASE === try { const plainWaId = getCanonicalWaId(receiverId); const uuid = randomUUID(); const ts = Math.floor(Date.now() / 1000); // ๐Ÿ”น Cari contacts__id atau buat jika belum ada let contactId = null; const [rows] = await db.query( "SELECT _id FROM contacts WHERE wa_id = ? AND vendors__id = ? LIMIT 1", [plainWaId, vendorId] ); if (rows.length > 0) { contactId = rows[0]._id; } else { const [insertResult] = await db.query( "INSERT INTO contacts (_uid, wa_id, vendors__id, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())", [randomUUID(), plainWaId, vendorId] ); contactId = insertResult.insertId; } // ๐Ÿ”น Insert ke whatsapp_message_logs const [result] = await db.query( `INSERT INTO whatsapp_message_logs (_uid, from_number, contact_wa_id, message, type, is_incoming_message, device_id, vendors__id, wamid, contacts__id, created_at, messaged_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?) ON DUPLICATE KEY UPDATE updated_at = NOW()`, [ uuid, deviceNumber, plainWaId, messageText, messageType, 0, sessionId, vendorId, sentMsg.key?.id || null, contactId, new Date(ts * 1000) ] ); if (result.affectedRows === 1) { console.log(`๐Ÿ’พ Pesan outbound baru tersimpan di DB (${receiverId})`); } else if (result.affectedRows === 2) { console.log(`โ™ป๏ธ Pesan outbound duplikat terdeteksi, hanya update timestamp (${receiverId})`); } else { console.warn(`โš ๏ธ Tidak ada perubahan di DB untuk pesan outbound (${receiverId})`); } dbSaved = true; console.log(`โœ… Outbound tersimpan di DB untuk ${receiverId} (uid=${uuid})`); // ๐Ÿ”Ž Ambil contact_uid untuk frontend (Laravel pakai _uid) let contact_uid = null; try { const [contactRows] = await db.query( "SELECT _uid FROM contacts WHERE wa_id = ? LIMIT 1", [plainWaId] ); if (contactRows.length > 0) { contact_uid = contactRows[0]._uid; } } catch (err) { console.error("โŒ Gagal ambil contact_uid:", err.message); } if (io) { io.to(sessionId).emit("chat:new", { sessionId, contact: plainWaId, wa_id: plainWaId, message: messageText, type: messageType, isIncoming: false, message_id: uuid, }); console.log(`๐Ÿ“ก Emit chat:new OUTBOUND ke room ${sessionId}`); } // ๐Ÿ”„ Trigger refresh Laravel if (contactId) { axios .post(`${process.env.APP_URL}/api-app/whatsapp/refresh-chat/${session.user?.id}/${contactId}`) .then(() => { console.log(`๐Ÿ”„ Refresh chat dipanggil untuk contactId=${contactId}`); }) .catch((err) => { console.error("โš ๏ธ Refresh gagal:", err.message); }); } return { success: true, sent: true, dbSaved, message: "Pesan berhasil dikirim dan disimpan", sentMsg }; } catch (dbErr) { console.error("โŒ Gagal insert outbound ke DB:", dbErr); return { success: true, sent: true, dbSaved, message: "Pesan terkirim tapi gagal disimpan ke DB", error: dbErr }; } } catch (err) { console.error(`โŒ Pesan gagal dikirim ke ${receiverId}:`, err.message); return { success: false, sent: false, dbSaved, message: "Pesan gagal dikirim", error: err }; } }; const changeStatusMessage = async (session, messages, jid) => { try { await session.readMessages(messages); await modifyChat(session, jid, true); } catch (err) { return Promise.reject(err); } }; const getLastChat = async (session, jid) => { try { if (session.isLegacy) { return await session.fetchMessagesFromWA(jid, 1, null); } else { return await session.store.loadMessages(jid, 1, null); } } catch (err) { return Promise.reject(err); } }; // โœ… Versi aman (anti-502, tidak lempar error ke client) const modifyChat = async (session, jid, status = false) => { try { if (!session || !jid) { console.warn("โš ๏ธ modifyChat: session atau jid tidak valid"); return; } const lastMessages = await getLastChat(session, jid); if (!lastMessages || lastMessages.length === 0) { console.warn(`โš ๏ธ modifyChat: tidak ada pesan terakhir untuk ${jid}`); return; } await session.chatModify( { markRead: status, lastMessages: [lastMessages[0]] }, jid ); console.log(`โœ… modifyChat sukses untuk ${jid}`); } catch (err) { console.warn( `โš ๏ธ modifyChat gagal (${jid}): ${err?.message || "unknown error"}` ); // Jangan return Promise.reject agar tidak bubble ke Socket.IO / browser } }; const downloadMediaorDoc = async (session, messageContent, mimes, medianame) => { try { const buffer = await downloadMediaMessage( messageContent, "buffer", {}, { reuploadRequest: session.updateMediaMessage } ); await writeFile(`./public/uploads/wamedia/${medianame}.${mimes}`, buffer); return { path: `${process.env.APP_URL}/uploads/wamedia/${medianame}.${mimes}`, }; } catch { return Promise.reject(null); } }; const getPhotoProfileUrl = async (session, phone) => { try { phone = phone.replace(/\D/g, ""); if (!phone.endsWith("@s.whatsapp.net") && !phone.endsWith("@g.us")) { phone += "@s.whatsapp.net"; } const ppUrl = await session.profilePictureUrl(phone, "preview"); console.log(`โœ… Foto profil ditemukan untuk ${phone}`); return { url: ppUrl, id: phone }; } catch (err) { console.log(`Tidak bisa ambil foto profil ${phone}: ${err.message || 'unknown'}`); return { url: null, id: phone }; } }; const formatPhone = (phone) => phone.endsWith("@s.whatsapp.net") ? phone : phone.replace(/\D/g, "") + "@s.whatsapp.net"; const formatGroup = (group) => group.endsWith("@g.us") ? group : group.replace(/[^\d-]/g, "") + "@g.us"; // Helper: hapus dan recreate session yang rusak const safeResetSession = (sessionId, isLegacy = false) => { try { console.log(`๐Ÿ”ง safeResetSession: menghapus session ${sessionId} (isLegacy=${isLegacy})`); deleteSession(sessionId, isLegacy); // restart session otomatis setTimeout(() => { console.log(`๐Ÿ” Mencoba recreate session ${sessionId} setelah reset`); createSession(sessionId, isLegacy).catch((e) => { console.error("โš ๏ธ Gagal recreate session otomatis:", e.message); }); }, 2000); } catch (err) { console.error("โš ๏ธ safeResetSession error:", err.message); } }; const initSession = async (req, res) => { const { sessionId, isLegacy } = req.body; if (!sessionId) { return response(res, 400, false, "Session ID is required."); } if (isSessionExists(sessionId)) { return response(res, 400, false, "Session already exists."); } await createSession(sessionId, isLegacy, res); response(res, 200, true, "Session created successfully."); }; const cleanup = () => { sessions.forEach((session, index) => { if (!session.isLegacy) { session.store.writeToFile(sessionsDir(index + "_store.json")); } }); }; const init = () => { readdir(sessionsDir(), (error, files) => { if (error) throw error; for (const file of files) { if ( (!file.startsWith("md_") && !file.startsWith("legacy_")) || file.endsWith("_store") ) { continue; } const sessionId = file.replace(".json", ""); const isLegacy = sessionId.split("_")[0] !== "md"; const cleanedSessionId = sessionId.substring(isLegacy ? 7 : 3); createSession(cleanedSessionId, isLegacy); } }); }; const checkNumber = async () => {}; // Delete chat related const deleteMessage = async (session, jid, message) => { try { await session.chatModify({ deleteForMe: message }, jid); } catch (err) { return Promise.reject(err); } }; const deleteEveryOne = async (session, jid, message) => { try { await session.sendMessage(jid, { delete: message }); } catch (err) { return Promise.reject(err); } }; const deleteChat = async (session, jid) => { try { const lastMessage = await getLastChat(session, jid); if (lastMessage.length > 0) { const lastMsgInChat = lastMessage[0]; await session.chatModify( { delete: true, lastMessages: [ { key: lastMsgInChat.key, messageTimestamp: lastMsgInChat.messageTimestamp, }, ], }, jid ); } } catch (err) { return Promise.reject(err); } }; // ๐ŸŽŸ๏ธ Auto-fix token null (tanpa regenerate jika sudah ada) const fixNullTokens = async () => { try { const [rows] = await db.query( "SELECT id, api_token FROM whatsapp_devices" ); if (!rows || rows.length === 0) { console.log("โœ… Tidak ada device di database."); return; } for (const row of rows) { let token = row.api_token; // Hanya buat token baru jika kosong/null if (!token || token.trim() === "") { token = randomUUID(); await db.query( "UPDATE whatsapp_devices SET api_token = ?, updated_at = NOW() WHERE id = ?", [token, row.id] ); console.log(`๐Ÿ”‘ Token baru dibuat untuk device ${row.id}: ${token}`); // Sinkron ke Laravel (hanya kalau baru dibuat) try { await axios.post( `${process.env.APP_URL}/api-app/whatsapp/set-token/${row.id}`, { token }, { timeout: 8000 } ); console.log(`๐Ÿ“ก Token baru disinkronkan ke Laravel untuk ${row.id}`); } catch (syncErr) { console.error(`โš ๏ธ Gagal sync token ke Laravel (${row.id}): ${syncErr.message}`); } } else { console.log(`โœ… Token lama dipertahankan untuk device ${row.id}`); } } console.log("๐ŸŽฏ Pemeriksaan token selesai."); } catch (err) { console.error("โŒ Gagal memperbaiki token:", err.message); } }; // Jalankan saat Node mulai fixNullTokens(); // โ™ป๏ธ Auto-fix state + SYNC KE LARAVEL (HANYA CONNECTED, ANTI-SPAM) setInterval(() => { sessions.forEach((client, sessionId) => { if (!client || !client.ws) return; const wsActive = client.ws.readyState === 1; const currentState = client.state; // ๐ŸŸข WebSocket hidup โ†’ paksa CONNECTED & sync ke Laravel if (wsActive && currentState !== "connected") { client.state = "connected"; console.log(`๐ŸŸข Auto-fix: ${sessionId} dipaksa ke CONNECTED`); // ๐Ÿ”ฅ sinkron sekali saja saat berubah updateLaravelStatus(sessionId, "active"); } }); }, 10000); // 10 detik // ================= EXPORTS ================== export { isSessionExists, createSession, getSession, deleteSession, getChatList, isExists, sendMessage, enqueueSend, formatPhone, formatGroup, cleanup, init, checkNumber, downloadMediaorDoc, getPhotoProfileUrl, changeStatusMessage, modifyChat, deleteMessage, deleteChat, deleteEveryOne, getContactList, };