trangshopeeaffiliate/zalo-userbot/userbot.js

370 lines
14 KiB
JavaScript

/**
* 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();