2026-04-18 16:26:29 +00:00

677 lines
22 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const os = require("os");
const mime = require("mime-types");
const qrcode = require("qrcode-terminal");
require("dotenv").config();
const { Client, LocalAuth } = require("whatsapp-web.js");
const { google } = require("googleapis");
const CREDENTIALS_PATH = process.env.GOOGLE_CREDENTIALS || "./credentials.json";
const TOKEN_PATH = process.env.GOOGLE_TOKEN || "./token.json";
const DEFAULT_ROOT_FOLDER = process.env.DEFAULT_ROOT_FOLDER || "Whatsapp-Drive";
const WA_CLIENT_ID = process.env.WA_CLIENT_ID || "MCB-bot";
const BATCH_TTL_MIN = Number(process.env.BATCH_TTL_MIN || "30");
const IDLE_RESTART_CHECK_MIN = Number(process.env.IDLE_RESTART_CHECK_MIN || "60");
const IDLE_RESTART_MIN = Number(process.env.IDLE_RESTART_MIN || "240");
const IDLE_RESTART_MAX_PER_DAY = Number(process.env.IDLE_RESTART_MAX_PER_DAY || "6");
const DOWNLOAD_TIMEOUT_MS = Number(process.env.DOWNLOAD_TIMEOUT_MS || "900000");
const DOWNLOAD_MAX_RETRIES = Number(process.env.DOWNLOAD_MAX_RETRIES || "2");
const DOWNLOAD_RETRY_DELAY_MS = Number(process.env.DOWNLOAD_RETRY_DELAY_MS || "5000");
const CMD_PREFIX = "mcb";
const STATE_FILE = "./state.json"; // stores per-user selected project folderId
const TMP_BASE = path.join(os.tmpdir(), "MCB-batches");
const LOG_FILE = "./app.log";
const OWNER_NUMBER = process.env.OWNER_NUMBER || ""; // e.g. 15551234567 (no +)
function loadJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
function saveJson(file, obj) {
fs.writeFileSync(file, JSON.stringify(obj, null, 2));
}
function logLine(level, message, meta) {
const ts = new Date().toISOString();
const base = `[${ts}] [${level}] ${message}`;
const line = meta ? `${base} ${JSON.stringify(meta)}` : base;
console.log(line);
try {
fs.appendFileSync(LOG_FILE, line + "\n");
} catch { }
}
function logInfo(message, meta) {
logLine("INFO", message, meta);
}
function logWarn(message, meta) {
logLine("WARN", message, meta);
}
function logError(message, meta) {
logLine("ERROR", message, meta);
}
function getOwnerChatId() {
if (!OWNER_NUMBER) return null;
return `${OWNER_NUMBER}@c.us`;
}
function loadCredentials() {
const raw = fs.readFileSync(CREDENTIALS_PATH, "utf8");
const json = JSON.parse(raw);
const cfg = json.installed || json.web;
if (!cfg) throw new Error("Invalid credentials.json. Expected installed/web client.");
return cfg;
}
function getDriveClient() {
const cfg = loadCredentials();
const tokens = loadJson(TOKEN_PATH, null);
if (!tokens) throw new Error("Missing token.json. Run `node auth.js` first.");
const oAuth2Client = new google.auth.OAuth2(
cfg.client_id,
cfg.client_secret,
(cfg.redirect_uris || [])[0]
);
oAuth2Client.setCredentials(tokens);
oAuth2Client.on("tokens", (newTokens) => {
const current = loadJson(TOKEN_PATH, {});
saveJson(TOKEN_PATH, { ...current, ...newTokens });
});
return google.drive({ version: "v3", auth: oAuth2Client });
}
async function findOrCreateFolder(drive, folderName, parentId = null) {
const qParts = [
`mimeType='application/vnd.google-apps.folder'`,
`name='${folderName.replace(/'/g, "\\'")}'`,
`trashed=false`,
];
if (parentId) qParts.push(`'${parentId}' in parents`);
const res = await drive.files.list({
q: qParts.join(" and "),
fields: "files(id, name)",
spaces: "drive",
});
if (res.data.files && res.data.files.length > 0) return res.data.files[0].id;
const createRes = await drive.files.create({
requestBody: {
name: folderName,
mimeType: "application/vnd.google-apps.folder",
parents: parentId ? [parentId] : undefined,
},
fields: "id",
});
return createRes.data.id;
}
async function getClientFolderName(msg) {
try {
const contact = await msg.getContact();
const name = (contact?.name || contact?.pushname || "").trim();
if (name) return name;
} catch { }
const chatId = msg.from;
const number = String(chatId).split("@")[0];
return `${number}`;
}
async function setFolderPublicView(drive, folderId) {
await drive.permissions.create({
fileId: folderId,
requestBody: {
type: "anyone",
role: "reader",
},
});
}
async function uploadFileStreamToDrive(drive, { filePath, filename, parentFolderId, mimeType }) {
const res = await drive.files.create({
requestBody: {
name: filename,
parents: [parentFolderId],
},
media: {
mimeType: mimeType || "application/octet-stream",
body: fs.createReadStream(filePath),
},
fields: "id, webViewLink, name",
});
return res.data;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function uploadWithRetry(drive, file, opts, maxRetries = 3) {
let attempt = 0;
while (true) {
try {
return await uploadFileStreamToDrive(drive, opts);
} catch (err) {
attempt += 1;
const isLast = attempt >= maxRetries;
logWarn("Upload failed", { attempt, maxRetries, error: err?.message, file: file?.filename });
if (isLast) throw err;
await sleep(1000 * attempt);
}
}
}
// ---------- Batch handling (disk-based) ----------
const BATCH = new Map();
const BATCH_TTL_MS = BATCH_TTL_MIN * 60 * 1000;
function now() {
return Date.now();
}
function getBatch(chatId) {
const b = BATCH.get(chatId);
if (!b) return null;
if (now() - b.lastActivityAt > BATCH_TTL_MS) {
clearBatch(chatId);
return null;
}
return b;
}
function startBatch(chatId, folderId, folderName) {
const dir = path.join(TMP_BASE, sanitizeId(chatId), sanitizeId(folderName));
fs.mkdirSync(dir, { recursive: true });
BATCH.set(chatId, {
startedAt: now(),
lastActivityAt: now(),
dir,
files: [],
folderId,
folderName,
});
}
function touchBatch(chatId) {
const b = BATCH.get(chatId);
if (b) b.lastActivityAt = now();
}
function clearBatch(chatId) {
const b = BATCH.get(chatId);
if (b && b.dir && fs.existsSync(b.dir)) {
fs.rmSync(b.dir, { recursive: true, force: true });
}
BATCH.delete(chatId);
}
function sanitizeId(id) {
return String(id).replace(/[^a-zA-Z0-9_-]/g, "_");
}
function sanitizeFileName(name) {
const safe = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").trim();
return safe || `file_${Date.now()}`;
}
function buildSafeFileName(media, index) {
const ts = new Date().toISOString().replace(/[:.]/g, "");
const original = media.filename ? path.basename(media.filename) : "";
const extFromMime = mime.extension(media.mimetype) || "";
if (original) {
const parsed = path.parse(original);
const base = sanitizeFileName(parsed.name);
const ext = parsed.ext || (extFromMime ? `.${extFromMime}` : "");
return `${base}_${ts}${ext}`;
}
const base = `file_${index}`;
const ext = extFromMime ? `.${extFromMime}` : "";
return `${base}_${ts}${ext}`;
}
function formatProgressBar(done, total, width = 12) {
const ratio = total ? done / total : 0;
const filled = Math.round(ratio * width);
const empty = Math.max(0, width - filled);
return `${"🟩".repeat(filled)}${"⬜".repeat(empty)}`;
}
function bytesToMb(bytes) {
return (bytes / (1024 * 1024)).toFixed(2);
}
function formatElapsedSec(ms) {
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}m ${sec}s`;
}
async function downloadMediaWithTimeout(msg, timeoutMs) {
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("download timeout")), timeoutMs);
});
try {
return await Promise.race([msg.downloadMedia(), timeoutPromise]);
} finally {
clearTimeout(timer);
}
}
// ---------- Idle restart ----------
let lastMessageAt = Date.now();
let isRestarting = false;
function getDateKey(ts = Date.now()) {
const d = new Date(ts);
return d.toISOString().slice(0, 10);
}
function getRestartStats(state) {
if (!state.restartStats || state.restartStats.date !== getDateKey()) {
state.restartStats = { date: getDateKey(), count: 0 };
}
return state.restartStats;
}
async function idleRestartCheck() {
if (isRestarting) return;
const idleMs = Date.now() - lastMessageAt;
const idleMin = idleMs / 60000;
if (idleMin < IDLE_RESTART_MIN) return;
const state = loadJson(STATE_FILE, { users: {} });
const stats = getRestartStats(state);
if (stats.count >= IDLE_RESTART_MAX_PER_DAY) return;
stats.count += 1;
saveJson(STATE_FILE, state);
isRestarting = true;
logWarn("Idle restart triggered", { idleMin: Math.round(idleMin), count: stats.count });
const ownerChatId = getOwnerChatId();
if (ownerChatId) {
try {
await client.sendMessage(
ownerChatId,
`♻️ Auto-restart (idle ${Math.round(idleMin)} min). Restart #${stats.count} today.`
);
} catch (err) {
logWarn("Failed to send idle restart notice", { error: err?.message });
}
}
// setTimeout(() => process.exit(0), 1000);
}
function startIdleMonitor() {
const checkMs = IDLE_RESTART_CHECK_MIN * 60 * 1000;
setInterval(() => {
idleRestartCheck().catch((err) =>
logWarn("Idle restart check failed", { error: err?.message })
);
}, checkMs);
}
// ---------- WhatsApp ----------
const client = new Client({
authStrategy: new LocalAuth({ clientId: WA_CLIENT_ID }),
puppeteer: {
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
protocolTimeout: Number(process.env.WA_PROTOCOL_TIMEOUT || "9000000"),
},
});
client.on("qr", (qr) => {
qrcode.generate(qr, { small: true });
logInfo("QR received. Scan to login.");
});
client.on("ready", async () => {
logInfo("WhatsApp bot is ready");
const ownerChatId = getOwnerChatId();
if (ownerChatId) {
try {
await client.sendMessage(
ownerChatId,
"✅ MCB bot started and ready. Logs are being written to app.log."
);
} catch (err) {
logWarn("Failed to send ready message to owner", { error: err?.message });
}
}
startIdleMonitor();
});
client.on("authenticated", () => logInfo("WhatsApp authenticated"));
client.on("auth_failure", (msg) => logError("WhatsApp auth failure", { msg }));
client.on("disconnected", (reason) => logWarn("WhatsApp disconnected", { reason }));
client.on("message", async (msg) => {
try {
lastMessageAt = Date.now();
const text = (msg.body || "").trim();
const textLower = text.toLowerCase();
const chatId = msg.from;
logInfo("Message received", {
from: chatId,
hasMedia: msg.hasMedia,
textPreview: text.slice(0, 120),
});
const drive = getDriveClient();
const state = loadJson(STATE_FILE, { users: {} });
if (!state.users[chatId])
state.users[chatId] = {
projectName: null,
folderId: null,
lastBatchFolderName: null,
lastBatchFolderId: null,
};
const rootFolderId = await findOrCreateFolder(drive, DEFAULT_ROOT_FOLDER);
const clientFolderName = await getClientFolderName(msg);
const clientFolderId = await findOrCreateFolder(drive, clientFolderName, rootFolderId);
// ---------- Commands (must start with MCB-) ----------
if (textLower.includes(CMD_PREFIX)) {
if (textLower === "mcb-help") {
await msg.reply(
[
"MCB Batch Upload commands:",
"hi mcb <name> → start batch in folder (send multiple files)",
"bye mcb → upload all received files in this batch",
"mcb-cancel → cancel batch session",
"mcb-project <name> → set project folder",
"mcb-status → show batch count + time left",
"",
"After hi mcb, send files one-by-one. Then bye mcb.",
].join("\n")
);
return;
}
if (textLower.startsWith("mcb-project")) {
const projectName = text.replace(/mcb-project/i, "").trim();
if (!projectName) {
await msg.reply("Send like: mcb-project MyProjectName");
return;
}
const folderId = await findOrCreateFolder(drive, projectName, clientFolderId);
state.users[chatId] = { projectName, folderId };
saveJson(STATE_FILE, state);
await msg.reply(`✅ Project set: *${projectName}*\nStart batch with hi mcb.`);
logInfo("Project set", { chatId, projectName, folderId });
return;
}
if (textLower.startsWith("hi mcb")) {
const name = text.replace(/hi mcb/i, "").trim();
if (!name) {
const lastName = state.users[chatId].lastBatchFolderName;
const lastId = state.users[chatId].lastBatchFolderId;
if (lastName && lastId) {
startBatch(chatId, lastId, lastName);
await msg.reply(
`✅ Batch started in last folder: *${lastName}*\nNow send your files one-by-one.\nWhen finished, send: bye mcb\n(To cancel: mcb-cancel)`
);
logInfo("Batch started (last folder)", { chatId, folderName: lastName, folderId: lastId });
return;
}
const existing = await drive.files.list({
q: [
`mimeType='application/vnd.google-apps.folder'`,
`trashed=false`,
`'${clientFolderId}' in parents`,
].join(" and "),
fields: "files(id, name)",
spaces: "drive",
});
const names = (existing.data.files || []).map((f) => f.name).slice(0, 15);
await msg.reply(
[
"Send batch folder name like:",
"hi mcb MyBatchFolder",
"",
names.length ? "Existing folders:" : "No existing folders.",
...names.map((n) => `${n}`),
].join("\n")
);
return;
}
const batchFolderId = await findOrCreateFolder(drive, name, clientFolderId);
startBatch(chatId, batchFolderId, name);
state.users[chatId].lastBatchFolderName = name;
state.users[chatId].lastBatchFolderId = batchFolderId;
saveJson(STATE_FILE, state);
await msg.reply(
`✅ Batch started in folder: *${name}*\nNow send your files one-by-one.\nWhen finished, send: bye mcb\n(To cancel: mcb-cancel)`
);
logInfo("Batch started", { chatId, folderName: name, folderId: batchFolderId });
return;
}
if (textLower === "mcb-cancel") {
clearBatch(chatId);
await msg.reply("🗑️ Batch cancelled and cleared.");
logInfo("Batch cancelled", { chatId });
return;
}
if (textLower === "mcb-status") {
const batch = getBatch(chatId);
if (!batch) {
await msg.reply("No active batch. Send hi mcb first.");
return;
}
const msLeft = Math.max(0, BATCH_TTL_MS - (now() - batch.lastActivityAt));
const minLeft = Math.ceil(msLeft / 60000);
await msg.reply(`📦 Batch files: *${batch.files.length}*\n⏱️ Time left: *${minLeft} min*`);
logInfo("Batch status", { chatId, count: batch.files.length, minLeft });
return;
}
if (textLower === "bye mcb" || textLower === "by mcb") {
const batch = getBatch(chatId);
if (!batch || batch.files.length === 0) {
await msg.reply("No active batch or no files received. Send hi mcb first.");
return;
}
const folderId = batch.folderId || state.users[chatId].folderId || clientFolderId;
const folderLabel = batch.folderName || state.users[chatId].projectName || "Default";
const total = batch.files.length;
const startMs = Date.now();
let uploadedBytes = 0;
const totalBytes = batch.files.reduce((sum, f) => {
try {
return sum + fs.statSync(f.filePath).size;
} catch {
return sum;
}
}, 0);
const progressMsg = await msg.reply(
`⏫ Uploading *${total}* file(s) to *${folderLabel}*...\n${formatProgressBar(0, totalBytes)}\n0/${total} | Elapsed: 0s | ETA: --\nUploaded: 0/${bytesToMb(totalBytes)} MB | Speed: 0.00 MB/s`
);
logInfo("Batch upload started", { chatId, count: batch.files.length, folderLabel });
for (let i = 0; i < batch.files.length; i++) {
const f = batch.files[i];
let uploaded;
try {
uploaded = await uploadWithRetry(
drive,
f,
{
filePath: f.filePath,
filename: f.filename,
parentFolderId: folderId,
mimeType: f.mimeType,
},
3
);
logInfo("File uploaded", { chatId, name: uploaded.name, link: uploaded.webViewLink });
} catch (err) {
logError("File upload failed", { chatId, filename: f.filename, error: err?.message });
await msg.reply(`❌ Failed to upload: ${f.filename}. Try bye mcb again.`);
continue;
}
try {
uploadedBytes += fs.statSync(f.filePath).size;
} catch { }
const done = i + 1;
const elapsedMs = Date.now() - startMs;
const elapsedSec = Math.round(elapsedMs / 1000);
const speedMbps = elapsedMs > 0 ? bytesToMb(uploadedBytes) / (elapsedMs / 1000) : 0;
const speedBytesPerSec = elapsedMs > 0 ? uploadedBytes / (elapsedMs / 1000) : 0;
const remainingBytes = Math.max(0, totalBytes - uploadedBytes);
const etaSec = speedBytesPerSec > 0 ? Math.round(remainingBytes / speedBytesPerSec) : 0;
try {
await progressMsg.edit(
`⏫ Uploading *${total}* file(s) to *${folderLabel}*...\n${formatProgressBar(uploadedBytes, totalBytes)}\n${done}/${total} | Elapsed: ${elapsedSec}s | ETA: ${etaSec}s\nUploaded: ${bytesToMb(uploadedBytes)}/${bytesToMb(totalBytes)} MB | Speed: ${speedMbps.toFixed(2)} MB/s`
);
} catch { }
}
await setFolderPublicView(drive, folderId);
const folderMeta = await drive.files.get({
fileId: folderId,
fields: "id, name, webViewLink",
});
clearBatch(chatId);
await msg.reply(
`✅ Uploaded all files to *${folderLabel}*\n📁 Folder link: ${folderMeta.data.webViewLink}`
);
logInfo("Batch upload complete", { chatId, count: batch.files.length, folderLabel });
return;
}
await msg.reply("Unknown MCB command. Send mcb-help");
logWarn("Unknown MCB command", { chatId, text });
return;
}
// ---------- Non-command messages: collect files if batch is active ----------
const batch = getBatch(chatId);
if (!batch) return;
if (msg.hasMedia) {
let statusMsg = null;
let statusTimer = null;
const statusStart = Date.now();
const statusFrames = ["⏳", "⌛", "🔄", "⏬"];
let statusIndex = 0;
try {
statusMsg = await msg.reply("⏳ Downloading... 0m 0s");
logInfo("Media download started", { chatId });
statusTimer = setInterval(async () => {
if (!statusMsg) return;
const elapsed = formatElapsedSec(Date.now() - statusStart);
const icon = statusFrames[statusIndex % statusFrames.length];
statusIndex += 1;
try {
await statusMsg.edit(`${icon} Downloading... ${elapsed}`);
} catch {}
logInfo("Media download heartbeat", { chatId, elapsed });
}, 5000);
} catch {}
let media = null;
let lastErr = null;
for (let attempt = 1; attempt <= DOWNLOAD_MAX_RETRIES + 1; attempt += 1) {
try {
if (attempt > 1 && statusMsg) {
try {
await statusMsg.edit(`🔁 Retrying download (${attempt}/${DOWNLOAD_MAX_RETRIES + 1})...`);
} catch {}
}
media = await downloadMediaWithTimeout(msg, DOWNLOAD_TIMEOUT_MS);
if (media && media.data) break;
lastErr = new Error("empty media data");
} catch (err) {
lastErr = err;
}
logWarn("Media download retry", { chatId, attempt, error: lastErr?.message });
if (attempt <= DOWNLOAD_MAX_RETRIES) await sleep(DOWNLOAD_RETRY_DELAY_MS);
}
if (!media || !media.data) {
if (statusTimer) clearInterval(statusTimer);
if (statusMsg) {
try {
await statusMsg.edit("❌ Download failed. Try sending as document.");
} catch {}
}
await msg.reply("❌ Could not download the attachment.");
logWarn("Media download failed", { chatId, error: lastErr?.message });
return;
}
const filename = buildSafeFileName(media, batch.files.length + 1);
const filePath = path.join(batch.dir, filename);
fs.writeFileSync(filePath, Buffer.from(media.data, "base64"));
const downloadElapsedMs = Date.now() - statusStart;
const fileSizeBytes = fs.statSync(filePath).size;
logInfo("Media download completed", {
chatId,
filename,
sizeMb: bytesToMb(fileSizeBytes),
elapsed: formatElapsedSec(downloadElapsedMs),
});
batch.files.push({ filePath, filename, mimeType: media.mimetype });
touchBatch(chatId);
if (statusTimer) clearInterval(statusTimer);
if (statusMsg) {
try {
await statusMsg.edit(`✅ File queued: ${filename}`);
} catch {}
}
try {
await msg.react("✅");
} catch { }
logInfo("File queued", { chatId, filename, batchCount: batch.files.length });
}
} catch (err) {
logError("Message handler error", { error: err?.message });
try {
await msg.reply("❌ Error while processing. Send mcb-help.");
} catch { }
}
});
client.initialize();