352 lines
14 KiB
JavaScript
352 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
|
|
}
|
|
});
|
|
|
|
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();
|