699 lines
23 KiB
JavaScript
699 lines
23 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 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 {
|
|
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',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-gpu',
|
|
'--single-process',
|
|
'--no-zygote',
|
|
'--disable-extensions'
|
|
],
|
|
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) {
|
|
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();
|