/** * Zalo Personal User-bot (Self-bot) * * Simulates a real Zalo user account to read messages, detect Shopee links, * call the Laravel backend to shorten/convert them, and post the reply. */ import { Zalo, ThreadType } from "zca-js"; import fetch from "node-fetch"; import fs from "fs"; import path from "path"; // Prevent crash when stdout/stderr pipes are broken (common when running in background) process.stdout.on("error", (err) => { if (err.code === "EPIPE") { // Detached pipe, swallow the error } }); process.stderr.on("error", (err) => { if (err.code === "EPIPE") { // Detached pipe, swallow the error } }); // Load parent .env file to sync config with Laravel try { const parentEnvPath = path.resolve(process.cwd(), "../.env"); if (fs.existsSync(parentEnvPath)) { const envContent = fs.readFileSync(parentEnvPath, "utf-8"); envContent.split(/\r?\n/).forEach(line => { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) { const [key, ...valueParts] = trimmed.split("="); const value = valueParts.join("=").trim().replace(/^[\\'"\s]+|[\\'"\s]+$/g, ""); process.env[key.trim()] = value; } }); } } catch (err) { console.error("Warning: Failed to load parent .env file:", err.message); } const BASE_URL = process.env.APP_URL ? process.env.APP_URL.trim().replace(/^[\\'"\s]+|[\\'"\s]+$/g, "").replace(/\/$/, "") : "http://127.0.0.1:8000"; const LARAVEL_API_URL = `${BASE_URL}/api/zalo/process-link`; const STATUS_API_URL = `${BASE_URL}/api/zalo/status`; const SYNC_GROUPS_API_URL = `${BASE_URL}/api/zalo/sync-groups`; console.log("DEBUG: process.env.APP_URL =", JSON.stringify(process.env.APP_URL)); console.log("DEBUG: resolved BASE_URL =", JSON.stringify(BASE_URL)); console.log("DEBUG: resolved STATUS_API_URL =", JSON.stringify(STATUS_API_URL)); // Shopee link matching regex (matches links with or without https:// protocol) const SHOPEE_REGEX = /(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)?shopee\.vn\/[^\s]+|(?:https?:\/\/)?shp\.ee\/[^\s]+/gi; // Duplicate reply prevention (chatId_url -> timestamp) const processedUrls = new Map(); /** * Report connection status to Laravel backend. */ async function reportStatus(status, userName = "") { try { await fetch(STATUS_API_URL, { method: "POST", headers: { "Content-Type": "application/json", "ngrok-skip-browser-warning": "true" }, body: JSON.stringify({ status: status, user_name: userName }) }); console.log(`[Status] Đã cập nhật trạng thái lên Laravel: ${status} ${userName ? `(${userName})` : ''}`); } catch (err) { console.error("[Status] Không thể gửi trạng thái tới Laravel (hãy chắc chắn server Laravel đang chạy):", err.message); } } /** * Scan all joined groups and sync them to Laravel discovered pool. */ async function syncJoinedGroups(api) { try { console.log("-> Đang quét danh sách nhóm Zalo đã tham gia..."); const groupsResponse = await api.getAllGroups(); const groupIds = Object.keys(groupsResponse.gridVerMap || {}); if (groupIds.length > 0) { const groupInfoResponse = await api.getGroupInfo(groupIds); const groups = Object.values(groupInfoResponse.gridInfoMap).map(g => ({ id: g.groupId, name: g.name })); await fetch(SYNC_GROUPS_API_URL, { method: "POST", headers: { "Content-Type": "application/json", "ngrok-skip-browser-warning": "true" }, body: JSON.stringify({ groups: groups }) }); console.log(`-> Đã đồng bộ thành công ${groups.length} nhóm Zalo vào kho khám phá của Laravel.`); } else { console.log("-> Không tìm thấy nhóm Zalo nào đã tham gia."); } } catch (err) { console.error("-> Lỗi quét/đồng bộ nhóm Zalo:", err.message); } } /** * Clean up old QR images before a new scan. */ function cleanupQrFiles() { try { if (fs.existsSync("qr.png")) { fs.unlinkSync("qr.png"); } if (fs.existsSync("../public/zalo/qr.png")) { fs.unlinkSync("../public/zalo/qr.png"); } } catch (e) { // ignore } } // Watcher/Timer to copy qr.png to Laravel public folder based on modification time let lastMtime = 0; setInterval(() => { if (fs.existsSync("qr.png")) { try { const stats = fs.statSync("qr.png"); const mtime = stats.mtimeMs; if (mtime !== lastMtime && stats.size > 0) { // Ensure public directory exists fs.mkdirSync("../public/zalo", { recursive: true }); fs.copyFileSync("qr.png", "../public/zalo/qr.png"); lastMtime = mtime; console.log("-> Đã đồng bộ qr.png sang Laravel public/zalo/"); } } catch (e) { console.error("-> Lỗi đồng bộ qr.png:", e.message); } } }, 1000); async function startBot() { console.log("=== KHỞI ĐỘNG ZALO USER-BOT AFFILIATE ==="); console.log("Đang kết nối tới Zalo Web client..."); // Write current process PID to bot.pid file for Laravel management try { fs.writeFileSync("bot.pid", process.pid.toString()); } catch (e) { console.error("-> Không thể ghi file bot.pid:", e.message); } cleanupQrFiles(); // Set status to waiting scan in Laravel UI await reportStatus("waiting_scan"); const zalo = new Zalo(); try { // This generates qr.png in current working directory const api = await zalo.loginQR(); console.log("\n✅ Đăng nhập Zalo thành công!"); // Try to get self user profile name if possible let selfName = ""; try { const accountInfo = await api.fetchAccountInfo(); if (accountInfo && accountInfo.profile) { selfName = accountInfo.profile.displayName || accountInfo.profile.zaloName || ""; } } catch(e) { console.error("-> Lỗi lấy thông tin cá nhân:", e.message); } await reportStatus("connected", selfName); console.log("Bot đang hoạt động và nghe tin nhắn từ các nhóm & cá nhân..."); // Sync joined groups in background (discovered pool) syncJoinedGroups(api).catch(err => { console.error("-> Lỗi chạy ngầm syncJoinedGroups:", err); }); // Setup message listener api.listener.on("message", async (message) => { console.log("\n[DEBUG Raw Message]:", JSON.stringify(message, null, 2)); try { let text = ""; if (typeof message.data.content === "string") { text = message.data.content; } else if (message.data.content && typeof message.data.content === "object") { text = (message.data.content.href || "") + " " + (message.data.content.title || "") + " " + (message.data.content.description || ""); } if (!text) { return; } // Search for Shopee links const matches = text.match(SHOPEE_REGEX); if (!matches || matches.length === 0) { return; } // Get sender display name (Zalo Web API uses dName) const senderName = message.data.dName || message.data.sender?.name || message.data.sender?.displayName || "bạn"; // Resolve Chat/Group Name dynamically let chatName = "Cuộc trò chuyện"; if (message.type === ThreadType.Group) { try { const groupInfo = await api.getGroupInfo(message.threadId); chatName = groupInfo?.gridInfoMap?.[message.threadId]?.name || `Nhóm ID: ${message.threadId}`; } catch (err) { chatName = `Nhóm ID: ${message.threadId}`; } } else { chatName = `Chat riêng với ${senderName}`; } console.log(`[Tin nhắn mới] Từ: ${senderName} (Kênh: ${chatName}, Thread ID: ${message.threadId})`); for (let matchedUrl of matches) { // Normalize url (ensure it has https://) if (!matchedUrl.toLowerCase().startsWith("http://") && !matchedUrl.toLowerCase().startsWith("https://")) { matchedUrl = "https://" + matchedUrl; } // Duplicate Prevention (5 second rule per URL per chat) const cacheKey = `${message.threadId}_${matchedUrl}`; const now = Date.now(); if (processedUrls.has(cacheKey) && (now - processedUrls.get(cacheKey) < 5000)) { console.log(`-> Bỏ qua link trùng lặp trong vòng 5s: ${matchedUrl}`); continue; } processedUrls.set(cacheKey, now); // Clean up cache to prevent memory leak if (processedUrls.size > 1000) { for (const [key, value] of processedUrls.entries()) { if (now - value > 10000) { processedUrls.delete(key); } } } console.log(`-> Tìm thấy link Shopee: ${matchedUrl}`); // Parse product title and thumbnail from Zalo preview data let productTitle = ""; let productImage = ""; if (message.data.content && typeof message.data.content === "object") { const content = message.data.content; if (content.params) { try { const params = JSON.parse(content.params); productTitle = params.mediaTitle || ""; } catch (e) {} } if (!productTitle && content.description) { let desc = content.description; if (desc.startsWith("Mua ")) { desc = desc.substring(4); } const index = desc.indexOf(" giá tốt"); if (index !== -1) { productTitle = desc.substring(0, index); } else { productTitle = desc; } } productImage = content.thumb || ""; } console.log(`-> Đang gọi Laravel API để convert link... (Tên sản phẩm: ${productTitle || 'Không tìm thấy'})`); const response = await fetch(LARAVEL_API_URL, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", "ngrok-skip-browser-warning": "true" }, body: JSON.stringify({ url: matchedUrl, sender_name: senderName, thread_id: message.threadId, chat_name: chatName, product_title: productTitle, product_image: productImage }) }); const result = await response.json(); if (result.success && result.reply_text) { console.log(`-> Convert thành công! Đang gửi phản hồi vào chat Zalo...`); // Send the reply message back to the same chat as a reply (quote) await api.sendMessage( { msg: result.reply_text, quote: { content: message.data.content, msgType: message.data.msgType, propertyExt: message.data.propertyExt, uidFrom: message.data.uidFrom, msgId: message.data.msgId, cliMsgId: message.data.cliMsgId, ts: message.data.ts, ttl: message.data.ttl } }, message.threadId, message.type ); console.log("-> Đã gửi phản hồi thành công.\n"); } else { console.error(`-> Lỗi từ Laravel API:`, result.error || "Không rõ lỗi"); } } } catch (err) { console.error("Lỗi khi xử lý tin nhắn cụ thể:", err); } }); // Start listening to WebSocket events api.listener.start(); } catch (error) { console.error("❌ Lỗi khởi động Zalo User-bot:", error); await reportStatus("disconnected"); console.log("Đang thử lại sau 10 giây..."); setTimeout(startBot, 10000); } } // Handle exit signals to report disconnection to Laravel process.on("SIGINT", async () => { console.log("\nĐang dừng bot..."); try { if (fs.existsSync("bot.pid")) fs.unlinkSync("bot.pid"); } catch(e){} await reportStatus("disconnected"); process.exit(); }); process.on("SIGTERM", async () => { console.log("\nĐang dừng bot..."); try { if (fs.existsSync("bot.pid")) fs.unlinkSync("bot.pid"); } catch(e){} await reportStatus("disconnected"); process.exit(); }); startBot();