diff --git a/BAK/state.json b/BAK/state.json new file mode 100644 index 0000000..2131448 --- /dev/null +++ b/BAK/state.json @@ -0,0 +1,50 @@ +{ + "users": { + "15192773600@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "2 tier chocolate vegan Star Wars cake", + "lastBatchFolderId": "1oJufpdl2yuyZTOEejVv22rKMEf7o6LAV" + }, + "14374329515@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "social currency", + "lastBatchFolderId": "1iUdfbHOCg3Cambad7DhmDla9wCBPtk_0" + }, + "918220348218@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "mohan", + "lastBatchFolderId": "1MxGKRFjb60x2pcCySc6DBTC_c2qXzG0L" + }, + "16476796537@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "Manesh", + "lastBatchFolderId": "1VylDIP7pvKtNa2WV-lAK6f6gNaU9pZ0V" + }, + "919585967575@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "Vijay testing", + "lastBatchFolderId": "12zRnuoeIhudGLbKckMciYLDxd7zLJpVA" + }, + "16476229848@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "Clickstogarlands", + "lastBatchFolderId": "1fs-6-2FA6RkioHR2Ybo7Q1xGp6ftCU7B" + }, + "15198976975@c.us": { + "projectName": null, + "folderId": null, + "lastBatchFolderName": "TCA _sportsday", + "lastBatchFolderId": "1JVzN_9KHMIm0jDQZL0UthM_jCJDkV8Dz" + } + }, + "restartStats": { + "date": "2026-04-17", + "count": 1 + } +} \ No newline at end of file diff --git a/BAK/token_mohan.json b/BAK/token_mohan.json new file mode 100644 index 0000000..1669785 --- /dev/null +++ b/BAK/token_mohan.json @@ -0,0 +1,7 @@ +{ + "access_token": "ya29.a0Aa7MYiqoT1k7-HDBeAYAzTWBp-kS3WvVmeXjdmRmCb6xWxLbT5EVSxCFQCqJJh8_oFxsgNyNqicpy-hBALL3qdTZfeCIMrLiNevV8cTKHgnf4aPfAH0uLRJzybRtp_8Ug62mSrhTopZ3-atQwtJSFsFMbv-xIm5VneLzXswLUrk5JeE_YsQquDfq2b3fCntMJunjUg8aCgYKAbESARUSFQHGX2MiuCZNTXNB8oN5EMVBdHffEQ0206", + "refresh_token": "1//0g-1DMMQieY-iCgYIARAAGBASNwF-L9IrbLZtEY9b5aXmbpTJ921vq4lIQ-8KHA-erzu231SNSqwseN4y6L8uQ-MrJ25yAANEGz0", + "scope": "https://www.googleapis.com/auth/drive.file", + "token_type": "Bearer", + "expiry_date": 1776540291131 +} \ No newline at end of file diff --git a/README.md b/README.md index e12a281..2bfbc4c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,83 @@ -# WhatsApp → Google Drive batch uploader (WD- commands) - -Phase 1: Upload user documents into your single Google Drive. - -## Commands -- WD-START → start a batch -- WD-END → upload all collected files -- WD-CANCEL → cancel batch -- WD-STATUS → show count + time left -- WD-PROJECT → set project folder -- WD-HELP → help - -## Setup -1) Install deps -``` -npm install -``` - -2) Copy env -``` -copy .env.example .env -``` - -3) Put your Google OAuth client JSON at `credentials.json` - -4) Generate token -``` -npm run auth -``` - -5) Run bot -``` -npm start -``` - -## Notes -- Bot only responds to messages that start with `WD-`. -- Files are stored temporarily on disk under your system temp folder. -# WhatsDrive-Whatsapp-To-Drive-Manager +# WhatsApp -> Google Drive batch uploader (`mcb` commands) + +Uploads files from WhatsApp chats into structured Google Drive folders using batch commands. + +## Commands +- User-friendly (case-insensitive): `start`, `done`, `cancel`, `status`, `help` +- Also supported fallback: `mcb-*` and legacy `hi mcb` / `bye mcb` +- Batch management: + - `rename ` -> change active batch target folder + - `list` -> show queued file names and sizes + - `undo` -> remove last queued file + - `retry-failed` -> retry previously failed uploads +- Project/admin: + - `mcb-project ` -> set/save a project folder + - `admin-stats` -> today + lifetime operational stats + - `admin-stats 7d` -> rolling summary for last N days (example: `7d`) + - `admin-stats YYYY-MM-DD` -> stats for a specific date + - `admin-health` -> live ops snapshot (heartbeat, queues, alerts) + +## Local setup (Windows/Linux) +1) Install dependencies: +```bash +npm install +``` + +2) Create `.env` (example values): +```env +GOOGLE_CREDENTIALS=./credentials.json +GOOGLE_TOKEN=./token.json +DEFAULT_ROOT_FOLDER=Whatsapp-Drive +WA_CLIENT_ID=MCB-bot +OWNER_NUMBER= +BATCH_TTL_MIN=30 +IDLE_RESTART_CHECK_MIN=60 +IDLE_RESTART_MIN=240 +IDLE_RESTART_MAX_PER_DAY=6 +DOWNLOAD_TIMEOUT_MS=900000 +DOWNLOAD_MAX_RETRIES=2 +DOWNLOAD_RETRY_DELAY_MS=5000 +MAX_MEDIA_MB=95 +WA_PROTOCOL_TIMEOUT=9000000 +WA_INIT_RETRIES=3 +WA_INIT_RETRY_DELAY_MS=5000 +WA_HEADLESS=true +LOG_DIR=./logs +HEARTBEAT_INTERVAL_SEC=60 +RETRY_MAX_ITEMS=200 +SMART_SUBFOLDERS=true +FAIL_ALERT_WINDOW=10 +FAIL_ALERT_THRESHOLD_PCT=20 +FAIL_ALERT_COOLDOWN_MIN=30 +# Optional on Linux VPS if Chromium is not auto-detected: +# WA_EXECUTABLE_PATH=/usr/bin/chromium-browser +``` + +3) Put Google OAuth client JSON at `credentials.json`. + +4) Generate Drive token: +```bash +npm run auth +``` + +5) Start bot: +```bash +npm start +``` + +## Linux VPS notes +- Install Chromium/Chrome and required libraries for Puppeteer. +- If Chromium path is custom, set `WA_EXECUTABLE_PATH`. +- Use a process manager (for example PM2/systemd) for auto-restart. + +## Runtime notes +- Temporary files are written under your OS temp directory (`MCB-batches`). +- Upload target folders are auto-created in Drive. +- Uploaded folder gets read-only public link (`anyone` with `reader` role). +- Duplicate protection is enabled per batch (same filename + size is skipped). +- Failed uploads are moved to retry queue and can be retried with `retry-failed`. +- Structured rotating logs are written to `logs/app-YYYY-MM-DD.jsonl`. +- Heartbeat logs are emitted periodically for VPS monitoring. +- Uploads are auto-organized into Drive subfolders by date + event type (`Invoices`, `Photos`, `Documents`). +- Active upload sessions are restored from disk after process restart (within batch TTL). +- Owner receives failure alerts when recent upload failures exceed configured threshold. diff --git a/index.js b/index.js index 6bccdba..9ad9a75 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,10 @@ -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 fs = require("fs"); +const path = require("path"); +const os = require("os"); +const readline = require("readline"); +const mime = require("mime-types"); +const qrcode = require("qrcode-terminal"); +require("dotenv").config(); const { Client, LocalAuth } = require("whatsapp-web.js"); const { google } = require("googleapis"); @@ -16,21 +17,122 @@ 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 MAX_MEDIA_MB = Number(process.env.MAX_MEDIA_MB || "95"); -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 { +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 MAX_MEDIA_MB = Number(process.env.MAX_MEDIA_MB || "95"); +const WA_INIT_RETRIES = Number(process.env.WA_INIT_RETRIES || "3"); +const WA_INIT_RETRY_DELAY_MS = Number(process.env.WA_INIT_RETRY_DELAY_MS || "5000"); +const WA_HEADLESS = process.env.WA_HEADLESS !== "false"; +const WA_EXECUTABLE_PATH = process.env.WA_EXECUTABLE_PATH || ""; +const AUTO_AUTH_ON_MISSING_TOKEN = process.env.AUTO_AUTH_ON_MISSING_TOKEN !== "false"; +const CMD_PREFIX = "mcb"; +const IS_WINDOWS = process.platform === "win32"; +const PUPPETEER_ARGS = IS_WINDOWS + ? ["--disable-gpu", "--disable-extensions"] + : ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-extensions", "--no-zygote"]; +const LOG_DIR = process.env.LOG_DIR || "./logs"; +const HEARTBEAT_INTERVAL_SEC = Number(process.env.HEARTBEAT_INTERVAL_SEC || "60"); +const RETRY_MAX_ITEMS = Number(process.env.RETRY_MAX_ITEMS || "200"); +const SMART_SUBFOLDERS = process.env.SMART_SUBFOLDERS !== "false"; +const FAIL_ALERT_WINDOW = Number(process.env.FAIL_ALERT_WINDOW || "10"); +const FAIL_ALERT_THRESHOLD_PCT = Number(process.env.FAIL_ALERT_THRESHOLD_PCT || "20"); +const FAIL_ALERT_COOLDOWN_MIN = Number(process.env.FAIL_ALERT_COOLDOWN_MIN || "30"); +const BATCH_META_FILE = ".batch.json"; + +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 +) +const ROOT_CACHE = { + rootFolderId: null, + chatFolderByChatId: new Map(), +}; +let DRIVE_CLIENT = null; + +function getDateKey(ts = Date.now()) { + const d = new Date(ts); + return d.toISOString().slice(0, 10); +} + +function normalizeState(state) { + const base = state && typeof state === "object" ? state : {}; + if (!base.users || typeof base.users !== "object") base.users = {}; + if (!base.analytics || typeof base.analytics !== "object") base.analytics = {}; + if (!base.analytics.daily || typeof base.analytics.daily !== "object") base.analytics.daily = {}; + if (!base.analytics.lifetime || typeof base.analytics.lifetime !== "object") { + base.analytics.lifetime = { + batchesStarted: 0, + batchesCompleted: 0, + filesQueued: 0, + filesUploaded: 0, + filesFailed: 0, + uploadBytes: 0, + completedBatchFilesTotal: 0, + }; + } + if (!Array.isArray(base.analytics.uploadOutcomes)) base.analytics.uploadOutcomes = []; + if (!base.analytics.alerts || typeof base.analytics.alerts !== "object") { + base.analytics.alerts = { + lastFailureAlertAt: 0, + lastFailureRate: 0, + }; + } + return base; +} + +function ensureUserState(state, chatId) { + if (!state.users[chatId]) { + state.users[chatId] = { + projectName: null, + folderId: null, + lastBatchFolderName: null, + lastBatchFolderId: null, + retryQueue: [], + }; + } + if (!Array.isArray(state.users[chatId].retryQueue)) { + state.users[chatId].retryQueue = []; + } + return state.users[chatId]; +} + +function getDailyStats(state, dateKey = getDateKey()) { + if (!state.analytics.daily[dateKey] || typeof state.analytics.daily[dateKey] !== "object") { + state.analytics.daily[dateKey] = { + uniqueUsers: {}, + messages: 0, + batchesStarted: 0, + batchesCompleted: 0, + filesQueued: 0, + filesUploaded: 0, + filesFailed: 0, + uploadBytes: 0, + completedBatchFilesTotal: 0, + }; + } + return state.analytics.daily[dateKey]; +} + +function markUserActive(state, chatId) { + const today = getDailyStats(state); + today.uniqueUsers[chatId] = 1; +} + +function incrementStats(state, updates) { + const today = getDailyStats(state); + const lifetime = state.analytics.lifetime; + for (const [k, v] of Object.entries(updates || {})) { + if (typeof v !== "number") continue; + if (typeof today[k] === "number") today[k] += v; + if (typeof lifetime[k] === "number") lifetime[k] += v; + } +} + +function loadJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { return fallback; } } @@ -39,15 +141,18 @@ 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 logLine(level, message, meta) { + const ts = new Date().toISOString(); + const base = `[${ts}] [${level}] ${message}`; + const line = meta ? `${base} ${JSON.stringify(meta)}` : base; + const entry = { ts, level, message, ...(meta || {}) }; + console.log(line); + try { + fs.mkdirSync(LOG_DIR, { recursive: true }); + fs.appendFileSync(LOG_FILE, line + "\n"); + fs.appendFileSync(path.join(LOG_DIR, `app-${getDateKey()}.jsonl`), JSON.stringify(entry) + "\n"); + } catch { } +} function logInfo(message, meta) { logLine("INFO", message, meta); @@ -74,10 +179,11 @@ function loadCredentials() { 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."); +function getDriveClient() { + if (DRIVE_CLIENT) return DRIVE_CLIENT; + 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, @@ -87,15 +193,65 @@ function getDriveClient() { oAuth2Client.setCredentials(tokens); - oAuth2Client.on("tokens", (newTokens) => { - const current = loadJson(TOKEN_PATH, {}); - saveJson(TOKEN_PATH, { ...current, ...newTokens }); - }); + oAuth2Client.on("tokens", (newTokens) => { + const current = loadJson(TOKEN_PATH, {}); + saveJson(TOKEN_PATH, { ...current, ...newTokens }); + }); + + DRIVE_CLIENT = google.drive({ version: "v3", auth: oAuth2Client }); + return DRIVE_CLIENT; +} + +function hasUsableToken() { + const tokens = loadJson(TOKEN_PATH, null); + return Boolean(tokens && (tokens.refresh_token || tokens.access_token)); +} + +function askInput(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(String(answer || "").trim()); + }); + }); +} + +async function ensureGoogleToken() { + if (hasUsableToken()) return; + + if (!AUTO_AUTH_ON_MISSING_TOKEN) { + throw new Error("Missing token.json. Run `node auth.js` first."); + } + + if (!process.stdin.isTTY) { + throw new Error("Missing token.json and no interactive terminal. Run `node auth.js` manually."); + } + + const cfg = loadCredentials(); + const oAuth2Client = new google.auth.OAuth2( + cfg.client_id, + cfg.client_secret, + (cfg.redirect_uris || [])[0] + ); + + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: ["https://www.googleapis.com/auth/drive.file"], + prompt: "consent", + }); + + logWarn("token.json not found. Starting interactive Google OAuth flow."); + console.log("\nAuthorize this app by visiting this URL:\n", authUrl, "\n"); + const code = await askInput("Paste the code from Google here: "); + if (!code) throw new Error("No authorization code entered."); + + const { tokens } = await oAuth2Client.getToken(code); + saveJson(TOKEN_PATH, tokens); + logInfo("Google token saved", { tokenPath: path.resolve(TOKEN_PATH) }); +} - return google.drive({ version: "v3", auth: oAuth2Client }); -} - -async function findOrCreateFolder(drive, folderName, parentId = null) { +async function findOrCreateFolder(drive, folderName, parentId = null) { const qParts = [ `mimeType='application/vnd.google-apps.folder'`, `name='${folderName.replace(/'/g, "\\'")}'`, @@ -121,9 +277,28 @@ async function findOrCreateFolder(drive, folderName, parentId = null) { }); return createRes.data.id; -} - -async function getClientFolderName(msg) { +} + +async function getOrCreateRootFolderId(drive) { + if (ROOT_CACHE.rootFolderId) return ROOT_CACHE.rootFolderId; + ROOT_CACHE.rootFolderId = await findOrCreateFolder(drive, DEFAULT_ROOT_FOLDER); + return ROOT_CACHE.rootFolderId; +} + +async function getOrCreateClientFolder(drive, msg) { + const chatId = msg.from; + if (ROOT_CACHE.chatFolderByChatId.has(chatId)) { + return ROOT_CACHE.chatFolderByChatId.get(chatId); + } + + const rootFolderId = await getOrCreateRootFolderId(drive); + const clientFolderName = await getClientFolderName(msg); + const clientFolderId = await findOrCreateFolder(drive, clientFolderName, rootFolderId); + ROOT_CACHE.chatFolderByChatId.set(chatId, clientFolderId); + return clientFolderId; +} + +async function getClientFolderName(msg) { try { const contact = await msg.getContact(); const name = (contact?.name || contact?.pushname || "").trim(); @@ -198,31 +373,47 @@ function getBatch(chatId) { 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 startBatch(chatId, folderId, folderName) { + clearBatch(chatId); + const dir = path.join(TMP_BASE, sanitizeId(chatId), sanitizeId(folderName)); + fs.mkdirSync(dir, { recursive: true }); + BATCH.set(chatId, { + startedAt: now(), + lastActivityAt: now(), + dir, + files: [], + fileSignatures: new Set(), + failedFiles: [], + activeDownloads: 0, + doneRequested: false, + uploadInProgress: false, + pendingDoneMsg: null, + pendingDoneTimer: null, + doneTriggerMsg: null, + folderId, + folderName, + }); + persistBatch(chatId); +} + +function touchBatch(chatId) { + const b = BATCH.get(chatId); + if (b) { + b.lastActivityAt = now(); + persistBatch(chatId); + } +} -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 clearBatch(chatId) { + const b = BATCH.get(chatId); + if (b?.pendingDoneTimer) { + clearInterval(b.pendingDoneTimer); + } + 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, "_"); @@ -261,14 +452,573 @@ 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`; -} +function formatElapsedSec(ms) { + const totalSec = Math.floor(ms / 1000); + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}m ${sec}s`; +} + +function bytesToDisplay(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytesToMb(bytes)} MB`; +} + +function buildFileSignature(sourceName, sizeBytes) { + return `${String(sourceName || "").toLowerCase()}|${Number(sizeBytes || 0)}`; +} + +function getSourceNameFromMedia(media) { + const original = media?.filename ? path.basename(media.filename) : ""; + if (original) return sanitizeFileName(original); + const ext = mime.extension(media?.mimetype || "") || "bin"; + return `unnamed.${ext}`; +} + +function getRetryDir(chatId) { + return path.join(TMP_BASE, "retry", sanitizeId(chatId)); +} + +function enqueueRetryFiles(chatId, folderName, failedFiles) { + if (!Array.isArray(failedFiles) || failedFiles.length === 0) return []; + const retryDir = getRetryDir(chatId); + fs.mkdirSync(retryDir, { recursive: true }); + const queued = []; + + for (const file of failedFiles) { + try { + const suffix = `${Date.now()}_${Math.floor(Math.random() * 10000)}`; + const destName = `${path.parse(file.filename).name}_${suffix}${path.extname(file.filename)}`; + const destPath = path.join(retryDir, sanitizeFileName(destName)); + fs.renameSync(file.filePath, destPath); + queued.push({ + filePath: destPath, + filename: file.filename, + mimeType: file.mimeType, + sizeBytes: Number(file.sizeBytes || 0), + folderId: file.parentFolderId, + folderName, + failedAt: new Date().toISOString(), + sourceName: file.sourceName || file.filename, + receivedAt: Number(file.receivedAt || Date.now()), + }); + } catch (err) { + logWarn("Failed to enqueue retry file", { chatId, file: file?.filename, error: err?.message }); + } + } + + return queued; +} + +function summarizeAdminStats(state) { + const today = getDailyStats(state); + const lifetime = state.analytics.lifetime; + const dailyActiveUsers = Object.keys(today.uniqueUsers || {}).length; + const avgBatchToday = today.batchesCompleted > 0 + ? (today.completedBatchFilesTotal / today.batchesCompleted).toFixed(2) + : "0.00"; + const avgBatchLifetime = lifetime.batchesCompleted > 0 + ? (lifetime.completedBatchFilesTotal / lifetime.batchesCompleted).toFixed(2) + : "0.00"; + + return [ + `📊 Admin stats (${getDateKey()})`, + `Daily active users: *${dailyActiveUsers}*`, + `Files uploaded today: *${today.filesUploaded}*`, + `Failures today: *${today.filesFailed}*`, + `Avg batch size today: *${avgBatchToday}* files`, + "", + `Lifetime uploaded: *${lifetime.filesUploaded}*`, + `Lifetime failures: *${lifetime.filesFailed}*`, + `Avg batch size lifetime: *${avgBatchLifetime}* files`, + ].join("\n"); +} + +function summarizeAdminStatsForDate(state, dateKey) { + const day = state?.analytics?.daily?.[dateKey]; + if (!day) return `📊 Admin stats (${dateKey})\nNo data found for this date.`; + const dailyActiveUsers = Object.keys(day.uniqueUsers || {}).length; + const avgBatch = day.batchesCompleted > 0 + ? (day.completedBatchFilesTotal / day.batchesCompleted).toFixed(2) + : "0.00"; + + return [ + `📊 Admin stats (${dateKey})`, + `Daily active users: *${dailyActiveUsers}*`, + `Files uploaded: *${day.filesUploaded || 0}*`, + `Failures: *${day.filesFailed || 0}*`, + `Avg batch size: *${avgBatch}* files`, + ].join("\n"); +} + +function summarizeAdminStatsForLastDays(state, days) { + const safeDays = Math.max(1, Math.min(365, Number(days || 1))); + let activeUsers = new Set(); + let filesUploaded = 0; + let filesFailed = 0; + let batchesCompleted = 0; + let completedBatchFilesTotal = 0; + const today = new Date(); + + for (let i = 0; i < safeDays; i++) { + const d = new Date(today); + d.setUTCDate(today.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + const day = state?.analytics?.daily?.[key]; + if (!day) continue; + Object.keys(day.uniqueUsers || {}).forEach((u) => activeUsers.add(u)); + filesUploaded += Number(day.filesUploaded || 0); + filesFailed += Number(day.filesFailed || 0); + batchesCompleted += Number(day.batchesCompleted || 0); + completedBatchFilesTotal += Number(day.completedBatchFilesTotal || 0); + } + + const avgBatch = batchesCompleted > 0 + ? (completedBatchFilesTotal / batchesCompleted).toFixed(2) + : "0.00"; + + return [ + `📊 Admin stats (last ${safeDays} day${safeDays > 1 ? "s" : ""})`, + `Active users: *${activeUsers.size}*`, + `Files uploaded: *${filesUploaded}*`, + `Failures: *${filesFailed}*`, + `Avg batch size: *${avgBatch}* files`, + ].join("\n"); +} + +function summarizeAdminHealth(state) { + const uptimeSec = Math.round(process.uptime()); + const lastMsgAgeSec = Math.round((Date.now() - lastMessageAt) / 1000); + const memRssMb = Math.round(process.memoryUsage().rss / (1024 * 1024)); + const activeBatches = BATCH.size; + + const users = state?.users || {}; + let retryQueueTotal = 0; + for (const user of Object.values(users)) { + retryQueueTotal += Array.isArray(user?.retryQueue) ? user.retryQueue.length : 0; + } + + let activeDownloads = 0; + let uploadsInProgress = 0; + for (const b of BATCH.values()) { + activeDownloads += Number(b?.activeDownloads || 0); + uploadsInProgress += b?.uploadInProgress ? 1 : 0; + } + + const outcomes = state?.analytics?.uploadOutcomes || []; + const windowSize = Math.max(1, FAIL_ALERT_WINDOW); + const recent = outcomes.slice(-windowSize); + const recentFails = recent.filter((x) => x === 0).length; + const recentFailPct = recent.length ? ((recentFails / recent.length) * 100).toFixed(1) : "0.0"; + const alerts = state?.analytics?.alerts || {}; + const lastAlertAt = alerts.lastFailureAlertAt + ? new Date(alerts.lastFailureAlertAt).toISOString() + : "never"; + + return [ + "🩺 Admin health", + `Uptime: *${uptimeSec}s*`, + `Last message age: *${lastMsgAgeSec}s*`, + `Memory RSS: *${memRssMb} MB*`, + `Active batches: *${activeBatches}*`, + `Active downloads: *${activeDownloads}*`, + `Uploads in progress: *${uploadsInProgress}*`, + `Retry queue total: *${retryQueueTotal}*`, + `Recent failure rate (${recent.length || 0}/${windowSize}): *${recentFailPct}%*`, + `Alert threshold: *${FAIL_ALERT_THRESHOLD_PCT}%*`, + `Last failure alert: *${lastAlertAt}*`, + ].join("\n"); +} + +function getBatchMetaPath(dir) { + return path.join(dir, BATCH_META_FILE); +} + +function persistBatch(chatId) { + const batch = BATCH.get(chatId); + if (!batch || !batch.dir) return; + try { + const files = (batch.files || []).map((f) => ({ + filePath: f.filePath, + filename: f.filename, + mimeType: f.mimeType, + sizeBytes: Number(f.sizeBytes || 0), + sourceName: f.sourceName || "", + signature: f.signature || "", + receivedAt: Number(f.receivedAt || Date.now()), + })); + const meta = { + chatId, + startedAt: Number(batch.startedAt || Date.now()), + lastActivityAt: Number(batch.lastActivityAt || Date.now()), + dir: batch.dir, + folderId: batch.folderId, + folderName: batch.folderName, + files, + }; + fs.writeFileSync(getBatchMetaPath(batch.dir), JSON.stringify(meta, null, 2)); + } catch (err) { + logWarn("Failed to persist batch meta", { chatId, error: err?.message }); + } +} + +function classifyEventFolder(file) { + const name = String(file?.sourceName || file?.filename || "").toLowerCase(); + const mimeType = String(file?.mimeType || "").toLowerCase(); + if (/(invoice|receipt|bill|payment|po|estimate)/.test(name)) return "Invoices"; + if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) return "Photos"; + return "Documents"; +} + +function getDateFolderForFile(file) { + const ts = Number(file?.receivedAt || Date.now()); + return getDateKey(ts); +} + +async function resolveUploadParentFolderId(drive, rootFolderId, file, folderCache) { + if (!SMART_SUBFOLDERS) return rootFolderId; + const dateFolder = getDateFolderForFile(file); + const eventFolder = classifyEventFolder(file); + const cacheKey = `${rootFolderId}|${dateFolder}|${eventFolder}`; + if (folderCache.has(cacheKey)) return folderCache.get(cacheKey); + + const dateFolderId = await findOrCreateFolder(drive, dateFolder, rootFolderId); + const eventFolderId = await findOrCreateFolder(drive, eventFolder, dateFolderId); + folderCache.set(cacheKey, eventFolderId); + return eventFolderId; +} + +function recordUploadOutcomes(state, successCount, failedCount) { + const outcomes = state.analytics.uploadOutcomes; + for (let i = 0; i < Number(successCount || 0); i++) outcomes.push(1); + for (let i = 0; i < Number(failedCount || 0); i++) outcomes.push(0); + const maxKeep = Math.max(FAIL_ALERT_WINDOW * 5, 100); + if (outcomes.length > maxKeep) { + state.analytics.uploadOutcomes = outcomes.slice(-maxKeep); + } +} + +async function maybeSendFailureAlert(state) { + const ownerChatId = getOwnerChatId(); + if (!ownerChatId) return; + + const outcomes = state.analytics.uploadOutcomes || []; + const windowSize = Math.max(1, FAIL_ALERT_WINDOW); + const recent = outcomes.slice(-windowSize); + if (recent.length < windowSize) return; + + const failCount = recent.filter((x) => x === 0).length; + const failRate = (failCount / recent.length) * 100; + if (failRate < FAIL_ALERT_THRESHOLD_PCT) return; + + const nowTs = Date.now(); + const cooldownMs = Math.max(1, FAIL_ALERT_COOLDOWN_MIN) * 60 * 1000; + const alerts = state.analytics.alerts || { lastFailureAlertAt: 0, lastFailureRate: 0 }; + if (alerts.lastFailureAlertAt && nowTs - alerts.lastFailureAlertAt < cooldownMs) return; + + alerts.lastFailureAlertAt = nowTs; + alerts.lastFailureRate = failRate; + state.analytics.alerts = alerts; + saveJson(STATE_FILE, state); + + try { + await client.sendMessage( + ownerChatId, + [ + "⚠️ Upload failure alert", + `Recent window: ${recent.length} files`, + `Failures: ${failCount} (${failRate.toFixed(1)}%)`, + `Threshold: ${FAIL_ALERT_THRESHOLD_PCT}%`, + ].join("\n") + ); + } catch (err) { + logWarn("Failed to send owner failure alert", { error: err?.message }); + } +} + +function restoreBatchesFromDisk() { + if (!fs.existsSync(TMP_BASE)) return; + + let restored = 0; + const seenChat = new Set(); + const chatDirs = fs.readdirSync(TMP_BASE, { withFileTypes: true }) + .filter((d) => d.isDirectory() && d.name !== "retry"); + + for (const chatDir of chatDirs) { + const chatRoot = path.join(TMP_BASE, chatDir.name); + const subdirs = fs.readdirSync(chatRoot, { withFileTypes: true }).filter((d) => d.isDirectory()); + let best = null; + + for (const sd of subdirs) { + const dir = path.join(chatRoot, sd.name); + const metaPath = getBatchMetaPath(dir); + if (!fs.existsSync(metaPath)) continue; + const meta = loadJson(metaPath, null); + if (!meta || !meta.chatId || !meta.folderId || !meta.folderName) continue; + if (!best || Number(meta.lastActivityAt || 0) > Number(best.lastActivityAt || 0)) best = meta; + } + + if (!best) continue; + const chatId = best.chatId; + if (seenChat.has(chatId)) continue; + + const expired = now() - Number(best.lastActivityAt || 0) > BATCH_TTL_MS; + if (expired) { + try { + if (best.dir && fs.existsSync(best.dir)) fs.rmSync(best.dir, { recursive: true, force: true }); + } catch { } + continue; + } + + const files = Array.isArray(best.files) ? best.files.filter((f) => f.filePath && fs.existsSync(f.filePath)) : []; + const fileSignatures = new Set(files.map((f) => f.signature || buildFileSignature(f.sourceName || f.filename, f.sizeBytes || 0))); + BATCH.set(chatId, { + startedAt: Number(best.startedAt || now()), + lastActivityAt: Number(best.lastActivityAt || now()), + dir: best.dir, + files, + fileSignatures, + failedFiles: [], + activeDownloads: 0, + doneRequested: false, + uploadInProgress: false, + pendingDoneMsg: null, + pendingDoneTimer: null, + doneTriggerMsg: null, + folderId: best.folderId, + folderName: best.folderName, + }); + persistBatch(chatId); + seenChat.add(chatId); + restored += 1; + } + + if (restored > 0) { + logInfo("Restored batches from disk", { restored }); + } +} + +async function sendChatOrReply(chatId, text, triggerMsg = null) { + if (triggerMsg) { + try { + return await triggerMsg.reply(text); + } catch { } + } + try { + return await client.sendMessage(chatId, text); + } catch { + return null; + } +} + +function clearPendingDoneStatus(batch) { + if (!batch) return; + if (batch.pendingDoneTimer) { + clearInterval(batch.pendingDoneTimer); + batch.pendingDoneTimer = null; + } + batch.pendingDoneMsg = null; +} + +async function startPendingDoneStatus(chatId, triggerMsg = null) { + const batch = getBatch(chatId); + if (!batch) return; + if (batch.pendingDoneTimer) return; + + const initial = [ + "⏳ Received done command.", + "Some files are still being received.", + "I will auto-start upload once receiving is complete.", + ].join("\n"); + batch.pendingDoneMsg = await sendChatOrReply(chatId, initial, triggerMsg); + + batch.pendingDoneTimer = setInterval(async () => { + const b = getBatch(chatId); + if (!b || !b.doneRequested || b.uploadInProgress) { + if (b) clearPendingDoneStatus(b); + return; + } + + const text = [ + "⏳ Waiting for incoming files...", + `Receiving now: *${b.activeDownloads || 0}*`, + `Queued: *${b.files.length}*`, + "Refresh: every 5s. Upload starts automatically.", + ].join("\n"); + + try { + if (b.pendingDoneMsg?.edit) { + await b.pendingDoneMsg.edit(text); + } else { + await client.sendMessage(chatId, text); + } + } catch { } + }, 5000); +} + +async function runBatchUpload(chatId, triggerMsg = null) { + const batch = getBatch(chatId); + if (!batch) { + await sendChatOrReply(chatId, "No active batch or no files received. Type start.", triggerMsg); + return; + } + if (batch.uploadInProgress) { + await sendChatOrReply(chatId, "⏫ Upload already in progress. I will update status shortly.", triggerMsg); + return; + } + + batch.uploadInProgress = true; + batch.doneRequested = false; + clearPendingDoneStatus(batch); + + try { + const drive = getDriveClient(); + const state = normalizeState(loadJson(STATE_FILE, { users: {} })); + const userState = ensureUserState(state, chatId); + const folderId = batch.folderId || userState.folderId; + const folderLabel = batch.folderName || userState.projectName || "Default"; + + if (!folderId) { + batch.uploadInProgress = false; + await sendChatOrReply(chatId, "❌ Could not resolve upload folder. Type start to create a new batch.", triggerMsg); + return; + } + if (!batch.files.length) { + batch.uploadInProgress = false; + await sendChatOrReply(chatId, "No files queued yet.", triggerMsg); + return; + } + + const total = batch.files.length; + const startMs = Date.now(); + let uploadedBytes = 0; + let uploadedCount = 0; + let failedCount = 0; + const failedFiles = []; + const folderCache = new Map(); + + const totalBytes = batch.files.reduce((sum, f) => { + try { + return sum + fs.statSync(f.filePath).size; + } catch { + return sum; + } + }, 0); + + const progressMsg = await sendChatOrReply( + chatId, + `⏫ 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`, + triggerMsg + ); + logInfo("Batch upload started", { chatId, count: total, folderLabel }); + + for (let i = 0; i < batch.files.length; i++) { + const f = batch.files[i]; + let parentFolderId = folderId; + try { + parentFolderId = await resolveUploadParentFolderId(drive, folderId, f, folderCache); + const uploaded = await uploadWithRetry( + drive, + f, + { + filePath: f.filePath, + filename: f.filename, + parentFolderId, + mimeType: f.mimeType, + }, + 3 + ); + logInfo("File uploaded", { chatId, name: uploaded.name, link: uploaded.webViewLink }); + uploadedCount += 1; + try { + const sz = fs.statSync(f.filePath).size; + uploadedBytes += sz; + } catch { } + } catch (err) { + failedCount += 1; + failedFiles.push({ ...f, parentFolderId }); + logError("File upload failed", { chatId, filename: f.filename, error: err?.message }); + } + + const done = i + 1; + const elapsedMs = Date.now() - startMs; + const elapsedSec = Math.round(elapsedMs / 1000); + const speedMbps = elapsedMs > 0 ? Number(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 { + if (progressMsg?.edit) { + 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 { } + } + + let folderLink = "Unavailable"; + if (uploadedCount > 0) { + await setFolderPublicView(drive, folderId); + const folderMeta = await drive.files.get({ + fileId: folderId, + fields: "id, name, webViewLink", + }); + folderLink = folderMeta?.data?.webViewLink || folderLink; + } + + const retryItems = enqueueRetryFiles(chatId, folderLabel, failedFiles); + userState.retryQueue = [...(userState.retryQueue || []), ...retryItems].slice(-RETRY_MAX_ITEMS); + incrementStats(state, { + batchesCompleted: 1, + filesUploaded: uploadedCount, + filesFailed: failedCount, + uploadBytes: uploadedBytes, + completedBatchFilesTotal: total, + }); + recordUploadOutcomes(state, uploadedCount, failedCount); + saveJson(STATE_FILE, state); + await maybeSendFailureAlert(state); + + clearBatch(chatId); + await sendChatOrReply( + chatId, + [ + "✅ Upload complete", + `Batch: *${folderLabel}*`, + `Total files: *${total}*`, + `Uploaded: *${uploadedCount}*`, + `Failed: *${failedCount}*`, + `Retry queued: *${retryItems.length}*`, + `Total size: *${bytesToMb(totalBytes)} MB*`, + `📁 Drive link: ${folderLink}`, + "", + "Next: send more files directly, or type start | list | done", + ].join("\n"), + null + ); + logInfo("Batch upload complete", { chatId, folderLabel, uploadedCount, failedCount, totalBytes }); + } catch (err) { + const b = BATCH.get(chatId); + if (b) { + b.uploadInProgress = false; + b.doneRequested = false; + clearPendingDoneStatus(b); + } + logError("runBatchUpload failed", { chatId, error: err?.message }); + await sendChatOrReply(chatId, "❌ Upload failed unexpectedly. Please type done again."); + } +} + +async function tryAutoRunDone(chatId) { + const batch = getBatch(chatId); + if (!batch) return; + if (!batch.doneRequested || batch.uploadInProgress) return; + if ((batch.activeDownloads || 0) > 0) return; + await runBatchUpload(chatId, batch.doneTriggerMsg || null); +} -async function downloadMediaWithTimeout(msg, timeoutMs) { +async function downloadMediaWithTimeout(msg, timeoutMs) { let timer; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("download timeout")), timeoutMs); @@ -281,14 +1031,9 @@ async function downloadMediaWithTimeout(msg, timeoutMs) { } } -// ---------- 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); -} +// ---------- Idle restart ---------- +let lastMessageAt = Date.now(); +let isRestarting = false; function getRestartStats(state) { if (!state.restartStats || state.restartStats.date !== getDateKey()) { @@ -327,32 +1072,39 @@ async function idleRestartCheck() { 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); -} +function startIdleMonitor() { + const checkMs = IDLE_RESTART_CHECK_MIN * 60 * 1000; + setInterval(() => { + idleRestartCheck().catch((err) => + logWarn("Idle restart check failed", { error: err?.message }) + ); + }, checkMs); +} + +function startHealthMonitor() { + const intervalMs = Math.max(15, HEARTBEAT_INTERVAL_SEC) * 1000; + setInterval(() => { + const uptimeSec = Math.round(process.uptime()); + const lastMsgAgeSec = Math.round((Date.now() - lastMessageAt) / 1000); + logInfo("HEARTBEAT", { + uptimeSec, + lastMsgAgeSec, + activeBatches: BATCH.size, + memoryRssMb: Math.round(process.memoryUsage().rss / (1024 * 1024)), + }); + }, intervalMs); +} // ---------- WhatsApp ---------- -const client = new Client({ - authStrategy: new LocalAuth({ clientId: WA_CLIENT_ID }), - puppeteer: { - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--disable-gpu', - '--single-process', - '--no-zygote', - '--disable-extensions' - ], - protocolTimeout: Number(process.env.WA_PROTOCOL_TIMEOUT || "9000000"), - }, -}); +const client = new Client({ + authStrategy: new LocalAuth({ clientId: WA_CLIENT_ID }), + puppeteer: { + headless: WA_HEADLESS, + args: PUPPETEER_ARGS, + executablePath: WA_EXECUTABLE_PATH || undefined, + protocolTimeout: Number(process.env.WA_PROTOCOL_TIMEOUT || "9000000"), + }, +}); client.on("qr", (qr) => { qrcode.generate(qr, { small: true }); @@ -371,328 +1123,573 @@ client.on("ready", async () => { } catch (err) { logWarn("Failed to send ready message to owner", { error: err?.message }); } - } - startIdleMonitor(); -}); + } + startIdleMonitor(); + startHealthMonitor(); +}); 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), +client.on("message", async (msg) => { + try { + lastMessageAt = Date.now(); + const text = (msg.body || "").trim(); + const chatId = msg.from; + const cmd = parseCommand(text); + + 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 drive = getDriveClient(); + const state = normalizeState(loadJson(STATE_FILE, { users: {} })); + const userState = ensureUserState(state, chatId); + markUserActive(state, chatId); + incrementStats(state, { messages: 1 }); + saveJson(STATE_FILE, state); + + const clientFolderId = await getOrCreateClientFolder(drive, msg); + if (cmd) { + if (cmd.type === "help") { + await msg.reply( + [ + "start -> begin collecting files", + "Send files now. When finished, type done.", + "Use list/status/undo/rename/cancel anytime.", + ].join("\n") + ); + return; + } + + if (cmd.type === "adminStats") { + const ownerChatId = getOwnerChatId(); + if (ownerChatId && chatId !== ownerChatId) { + await msg.reply("❌ admin-stats is restricted."); + return; + } + const arg = String(cmd.arg || "").toLowerCase(); + if (!arg) { + await msg.reply(summarizeAdminStats(state)); + return; + } + if (/^\d+d$/.test(arg)) { + const days = Number(arg.slice(0, -1)); + await msg.reply(summarizeAdminStatsForLastDays(state, days)); + return; + } + if (/^\d{4}-\d{2}-\d{2}$/.test(arg)) { + await msg.reply(summarizeAdminStatsForDate(state, arg)); + return; + } + await msg.reply("Use: admin-stats | admin-stats 7d | admin-stats YYYY-MM-DD"); + return; + } + + if (cmd.type === "adminHealth") { + const ownerChatId = getOwnerChatId(); + if (ownerChatId && chatId !== ownerChatId) { + await msg.reply("❌ admin-health is restricted."); + return; + } + await msg.reply(summarizeAdminHealth(state)); + return; + } + + if (cmd.type === "project") { + if (!cmd.name) { + await msg.reply("Send like: mcb-project MyProjectName"); + return; + } + const folderId = await findOrCreateFolder(drive, cmd.name, clientFolderId); + userState.projectName = cmd.name; + userState.folderId = folderId; + saveJson(STATE_FILE, state); + await msg.reply(`✅ Project set: *${cmd.name}*`); + logInfo("Project set", { chatId, projectName: cmd.name, folderId }); + return; + } + + if (cmd.type === "rename") { + const batch = getBatch(chatId); + if (!batch) { + await msg.reply("No active batch. Type start first."); + return; + } + if (!cmd.name) { + await msg.reply("Use: rename MyFolderName"); + return; + } + const folderId = await findOrCreateFolder(drive, cmd.name, clientFolderId); + batch.folderId = folderId; + batch.folderName = cmd.name; + touchBatch(chatId); + + userState.lastBatchFolderName = cmd.name; + userState.lastBatchFolderId = folderId; + saveJson(STATE_FILE, state); + await msg.reply(`✅ Batch folder renamed to *${cmd.name}*`); + logInfo("Batch folder renamed", { chatId, folderName: cmd.name, folderId }); + return; + } + + if (cmd.type === "start") { + const target = await resolveBatchTarget(drive, userState, clientFolderId, cmd.name); + startBatch(chatId, target.folderId, target.folderName); + userState.lastBatchFolderName = target.folderName; + userState.lastBatchFolderId = target.folderId; + incrementStats(state, { batchesStarted: 1 }); + saveJson(STATE_FILE, state); + await msg.reply( + `✅ Started upload session in *${target.folderName}*.\nSend more files, then type done.` + ); + logInfo("Batch started", { chatId, folderName: target.folderName, folderId: target.folderId }); + return; + } + + if (cmd.type === "list") { + const batch = getBatch(chatId); + if (!batch || batch.files.length === 0) { + await msg.reply("No files queued yet."); + return; + } + const lines = batch.files.slice(0, 15).map((f, i) => { + const size = Number(f.sizeBytes || 0); + return `${i + 1}. ${f.filename} (${bytesToDisplay(size)})`; + }); + const extra = batch.files.length > 15 ? `\n...and ${batch.files.length - 15} more` : ""; + await msg.reply( + `📋 Queued files (${batch.files.length}):\n${lines.join("\n")}${extra}` + ); + return; + } + + if (cmd.type === "undo") { + const batch = getBatch(chatId); + if (!batch || batch.files.length === 0) { + await msg.reply("Nothing to undo."); + return; + } + const lastFile = batch.files.pop(); + if (lastFile?.signature && batch.fileSignatures) { + batch.fileSignatures.delete(lastFile.signature); + } + try { + if (lastFile?.filePath && fs.existsSync(lastFile.filePath)) { + fs.rmSync(lastFile.filePath, { force: true }); + } + } catch (err) { + logWarn("Undo failed to remove local file", { chatId, file: lastFile?.filename, error: err?.message }); + } + touchBatch(chatId); + await msg.reply(`↩️ Removed last queued file: ${lastFile?.filename || "unknown"}`); + return; + } + + if (cmd.type === "cancel") { + clearBatch(chatId); + await msg.reply("🗑️ Batch cancelled and cleared."); + logInfo("Batch cancelled", { chatId }); + return; + } + + if (cmd.type === "status") { + const batch = getBatch(chatId); + if (!batch) { + await msg.reply("No active batch. Type start."); + 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 (cmd.type === "retryFailed") { + const retryQueue = Array.isArray(userState.retryQueue) ? userState.retryQueue : []; + if (retryQueue.length === 0) { + await msg.reply("No failed files waiting for retry."); + return; + } + + const total = retryQueue.length; + let uploadedCount = 0; + let failedCount = 0; + let uploadedBytes = 0; + const kept = []; + const folderLabel = retryQueue[0].folderName || "RetryFolder"; + const progressMsg = await msg.reply(`🔁 Retrying ${total} failed file(s)...`); + + for (const item of retryQueue) { + try { + await uploadWithRetry( + drive, + item, + { + filePath: item.filePath, + filename: item.filename, + parentFolderId: item.folderId || userState.folderId || clientFolderId, + mimeType: item.mimeType, + }, + 3 + ); + uploadedCount += 1; + uploadedBytes += Number(item.sizeBytes || 0); + try { + if (item.filePath && fs.existsSync(item.filePath)) fs.rmSync(item.filePath, { force: true }); + } catch { } + } catch (err) { + failedCount += 1; + kept.push(item); + logWarn("Retry upload failed", { chatId, file: item.filename, error: err?.message }); + } + } + + userState.retryQueue = kept.slice(0, RETRY_MAX_ITEMS); + incrementStats(state, { + filesUploaded: uploadedCount, + filesFailed: failedCount, + uploadBytes: uploadedBytes, + }); + recordUploadOutcomes(state, uploadedCount, failedCount); + saveJson(STATE_FILE, state); + await maybeSendFailureAlert(state); + + try { + await progressMsg.edit( + `🔁 Retry done for *${folderLabel}*\nUploaded: *${uploadedCount}*\nStill failed: *${failedCount}*` + ); + } catch { } + return; + } + + if (cmd.type === "done") { + const batch = getBatch(chatId); + if (!batch) { + await msg.reply("No active batch or no files received. Type start."); + return; + } + if (batch.uploadInProgress) { + await msg.reply("⏫ Upload already in progress. I will update you once done."); + return; + } + if ((batch.activeDownloads || 0) > 0) { + batch.doneRequested = true; + batch.doneTriggerMsg = msg; + touchBatch(chatId); + await startPendingDoneStatus(chatId, msg); + await msg.reply("⏳ Still receiving files. I will start upload automatically once receiving finishes."); + return; + } + if (batch.files.length === 0) { + await msg.reply("No active batch or no files received. Type start."); + return; + } + await runBatchUpload(chatId, msg); + return; + } + + if (cmd.type === "unknown") { + await msg.reply("Unknown command. Type help."); + logWarn("Unknown command", { chatId, text }); + return; + } + } + + // ---------- Non-command messages: collect files if batch is active ---------- + let batch = getBatch(chatId); + if (!batch && msg.hasMedia) { + const target = await resolveBatchTarget(drive, userState, clientFolderId, ""); + startBatch(chatId, target.folderId, target.folderName); + userState.lastBatchFolderName = target.folderName; + userState.lastBatchFolderId = target.folderId; + incrementStats(state, { batchesStarted: 1 }); + saveJson(STATE_FILE, state); + + await msg.reply( + `✅ Started upload session in *${target.folderName}*.\n` + + "Send more files, then type done." + ); + logInfo("Batch auto-started from first media", { + chatId, + folderName: target.folderName, + folderId: target.folderId, + }); + batch = getBatch(chatId); + } + + if (!batch) return; + + if (msg.hasMedia) { + batch.activeDownloads = (batch.activeDownloads || 0) + 1; + touchBatch(chatId); + let statusMsg = null; + let statusTimer = null; + try { + const mediaSizeBytes = Number(msg?._data?.size || msg?._data?.fileSize || 0); + const maxBytes = MAX_MEDIA_MB > 0 ? MAX_MEDIA_MB * 1024 * 1024 : 0; + if (maxBytes && mediaSizeBytes > maxBytes) { + const sizeMb = bytesToMb(mediaSizeBytes); + const maxMb = MAX_MEDIA_MB.toFixed(0); + await msg.reply( + `❌ File too large for WhatsApp Web download (${sizeMb} MB). Limit set to ${maxMb} MB.\n` + + "Please share a Drive link or split the file into smaller parts." + ); + logWarn("Media download blocked by size limit", { chatId, sizeMb, maxMb }); + return; + } + + 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 (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; + const sourceName = getSourceNameFromMedia(media); + const signature = buildFileSignature(sourceName, fileSizeBytes); + if (batch.fileSignatures && batch.fileSignatures.has(signature)) { + try { + fs.rmSync(filePath, { force: true }); + } catch { } + if (statusMsg) { + try { + await statusMsg.edit(`⚠️ Duplicate skipped: ${sourceName} (${bytesToDisplay(fileSizeBytes)})`); + } catch { } + } + await msg.reply("⚠️ Duplicate file skipped (same filename + size in this batch)."); + logInfo("Duplicate skipped", { chatId, sourceName, sizeBytes: fileSizeBytes }); + return; + } + logInfo("Media download completed", { + chatId, + filename, + sizeMb: bytesToMb(fileSizeBytes), + elapsed: formatElapsedSec(downloadElapsedMs), + }); + + batch.fileSignatures.add(signature); + batch.files.push({ + filePath, + filename, + mimeType: media.mimetype, + sizeBytes: fileSizeBytes, + sourceName, + signature, + receivedAt: Date.now(), + }); + touchBatch(chatId); + incrementStats(state, { filesQueued: 1 }); + saveJson(STATE_FILE, state); + + if (statusMsg) { + try { + await statusMsg.edit(`✅ ${batch.files.length} file(s) queued`); + } catch { } + } + try { + await msg.react("✅"); + } catch { } + logInfo("File queued", { chatId, filename, batchCount: batch.files.length }); + } finally { + if (statusTimer) clearInterval(statusTimer); + const b = BATCH.get(chatId); + if (b) { + b.activeDownloads = Math.max(0, (b.activeDownloads || 0) - 1); + touchBatch(chatId); + if (b.doneRequested && !b.uploadInProgress && (b.activeDownloads || 0) === 0) { + tryAutoRunDone(chatId).catch((err) => + logWarn("Auto-run done failed", { chatId, error: err?.message }) + ); + } + } + } + } + } catch (err) { + logError("Message handler error", { error: err?.message }); + try { + await msg.reply("❌ Error while processing. Type help."); + } catch { } + } +}); - 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) { - const mediaSizeBytes = Number(msg?._data?.size || msg?._data?.fileSize || 0); - const maxBytes = MAX_MEDIA_MB > 0 ? MAX_MEDIA_MB * 1024 * 1024 : 0; - if (maxBytes && mediaSizeBytes > maxBytes) { - const sizeMb = bytesToMb(mediaSizeBytes); - const maxMb = MAX_MEDIA_MB.toFixed(0); - await msg.reply( - `❌ File too large for WhatsApp Web download (${sizeMb} MB). Limit set to ${maxMb} MB.\n` + - "Please share a Drive link or split the file into smaller parts." - ); - logWarn("Media download blocked by size limit", { chatId, sizeMb, maxMb }); - return; - } - - 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(); +async function initializeClientWithRetry() { + for (let attempt = 1; attempt <= WA_INIT_RETRIES; attempt += 1) { + try { + await client.initialize(); + return; + } catch (err) { + const isLast = attempt >= WA_INIT_RETRIES; + logError("WhatsApp initialize failed", { + attempt, + maxRetries: WA_INIT_RETRIES, + error: err?.message, + }); + if (isLast) throw err; + await sleep(WA_INIT_RETRY_DELAY_MS); + } + } +} + +function parseCommand(rawText) { + const normalized = String(rawText || "").trim().replace(/\s+/g, " "); + const lower = normalized.toLowerCase(); + const adminMatch = normalized.match(/^(?:mcb-)?admin-stats(?:\s+(.+))?$/i); + if (adminMatch) { + return { type: "adminStats", arg: String(adminMatch[1] || "").trim() }; + } + if (/^(?:mcb-)?admin-health$/i.test(normalized)) { + return { type: "adminHealth" }; + } + + if (lower === "help" || lower === "mcb-help") return { type: "help" }; + if (lower === "list" || lower === "mcb-list") return { type: "list" }; + if (lower === "undo" || lower === "mcb-undo") return { type: "undo" }; + if (lower === "retry-failed" || lower === "mcb-retry-failed") return { type: "retryFailed" }; + if (lower === "start" || lower === "mcb-start" || lower === "hi mcb") { + return { type: "start", name: "" }; + } + if (lower.startsWith("hi mcb ")) { + return { type: "start", name: normalized.slice(7).trim() }; + } + if (lower.startsWith("rename ")) { + return { type: "rename", name: normalized.slice(7).trim() }; + } + if (lower.startsWith("mcb-rename ")) { + return { type: "rename", name: normalized.slice(11).trim() }; + } + if (lower === "done" || lower === "mcb-done" || lower === "bye mcb" || lower === "by mcb") { + return { type: "done" }; + } + if (lower === "cancel" || lower === "mcb-cancel") return { type: "cancel" }; + if (lower === "status" || lower === "mcb-status") return { type: "status" }; + + if (lower.startsWith("mcb-project")) { + const name = normalized.replace(/^mcb-project/i, "").trim(); + return { type: "project", name }; + } + + if (lower.startsWith("mcb-")) return { type: "unknown" }; + return null; +} + +function buildAutoBatchFolderName() { + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + return `Batch_${stamp}`; +} + +async function resolveBatchTarget(drive, userState, clientFolderId, requestedName = "") { + const explicitName = String(requestedName || "").trim(); + if (explicitName) { + const folderId = await findOrCreateFolder(drive, explicitName, clientFolderId); + return { folderId, folderName: explicitName }; + } + + if (userState.lastBatchFolderName && userState.lastBatchFolderId) { + return { + folderId: userState.lastBatchFolderId, + folderName: userState.lastBatchFolderName, + }; + } + + if (userState.projectName && userState.folderId) { + return { + folderId: userState.folderId, + folderName: userState.projectName, + }; + } + + const autoName = buildAutoBatchFolderName(); + const autoFolderId = await findOrCreateFolder(drive, autoName, clientFolderId); + return { folderId: autoFolderId, folderName: autoName }; +} + +function clearAllBatches() { + for (const chatId of BATCH.keys()) { + clearBatch(chatId); + } +} + +process.on("SIGINT", () => { + logWarn("SIGINT received, cleaning up."); + clearAllBatches(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + logWarn("SIGTERM received, cleaning up."); + clearAllBatches(); + process.exit(0); +}); + +process.on("unhandledRejection", (reason) => { + logError("Unhandled promise rejection", { reason: String(reason) }); +}); + +process.on("uncaughtException", (err) => { + logError("Uncaught exception", { error: err?.message, stack: err?.stack }); +}); + +async function boot() { + restoreBatchesFromDisk(); + await ensureGoogleToken(); + getDriveClient(); + await initializeClientWithRetry(); +} + +boot().catch((err) => { + logError("Fatal startup error", { error: err?.message }); + process.exit(1); +});