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 → start batch in folder (send multiple files)", "bye mcb → upload all received files in this batch", "mcb-cancel → cancel batch session", "mcb-project → 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();