Feat: UI/UX y Procesos

This commit is contained in:
2026-02-09 19:00:18 -03:00
parent 7222728591
commit f6d8b34520
13 changed files with 925 additions and 359 deletions

View File

@@ -17,7 +17,7 @@ namespace WhatsappPromo.Core.Services
{
private readonly string _configPath;
private readonly ILogger<ConfigService> _logger;
private SystemConfig _cachedConfig;
private SystemConfig? _cachedConfig;
public ConfigService(ILogger<ConfigService> logger)
{

View File

@@ -7,8 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -13,7 +13,7 @@ namespace WhatsappPromo.Engine.Services
Task InitializeAsync();
Task<Page> GetPageAsync();
Task CloseAsync();
Task<string> GetQrCodeAsync();
Task<string?> GetQrCodeAsync();
Task PerformHumanIdleActionsAsync();
}
@@ -32,185 +32,103 @@ namespace WhatsappPromo.Engine.Services
public async Task InitializeAsync()
{
if (_browser != null && !_browser.IsClosed) return;
_logger.LogInformation("Descargando navegador si es necesario...");
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();
var options = new LaunchOptions
{
Headless = false, // Headless mode aumenta riesgo de detección
Headless = false,
UserDataDir = _userDataDir,
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-infobars",
"--start-minimized",
"--window-position=0,0",
"--ignore-certificate-errors",
"--ignore-certificate-errors-spki-list",
// ANTI-DETECCIÓN CRÍTICA
"--disable-blink-features=AutomationControlled",
// Evitar detección de DevTools
"--disable-dev-shm-usage",
// Hacer que el navegador parezca más "normal"
"--disable-extensions-except",
"--disable-extensions",
// GPU y WebGL realistas (importante para huellas digitales)
"--enable-webgl",
"--use-gl=swiftshader",
"--disable-features=IsolateOrigins,site-per-process,CalculateNativeWinOcclusion",
"--disable-default-apps",
// Deshabilitar características que revelan automatización
"--disable-features=IsolateOrigins,site-per-process",
// Evitar leaks de automatización
"--disable-default-apps"
// Flags para evitar que el navegador se "duerma" al estar minimizado
"--disable-backgrounding-occluded-windows",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding"
},
IgnoredDefaultArgs = new[] { "--enable-automation" }, // Quita "Chrome is being controlled"
DefaultViewport = null // Permite que el viewport sea controlado por window size
IgnoredDefaultArgs = new[] { "--enable-automation" },
DefaultViewport = null
};
_logger.LogInformation("Iniciando navegador Chrome...");
_browser = await Puppeteer.LaunchAsync(options);
var pages = await _browser.PagesAsync();
_page = pages.Length > 0 ? pages[0] : await _browser.NewPageAsync();
// Forzar minimizado mediante el proceso si es posible
try
{
var pages = await _browser.PagesAsync();
_page = pages.Length > 0 ? pages[0] : await _browser.NewPageAsync();
}
catch (Exception ex)
{
_logger.LogWarning("Error inicializando página, reintentando... {Msg}", ex.Message);
await Task.Delay(1000);
var pages = await _browser.PagesAsync();
_page = pages.Length > 0 ? pages[0] : await _browser.NewPageAsync();
}
if (_page == null) throw new Exception("No se pudo crear la página del navegador.");
// User-Agent más actualizado y realista (Chrome 131)
await _page.SetUserAgentAsync("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
// Viewport aleatorio pero realista
var random = new Random();
await _page.SetViewportAsync(new ViewPortOptions
{
Width = 1366 + random.Next(-50, 150), // 1316-1516
Height = 768 + random.Next(-50, 150), // 718-918
Width = 1366 + Random.Shared.Next(-50, 150),
Height = 768 + Random.Shared.Next(-50, 150),
DeviceScaleFactor = 1
});
// INYECCIÓN MASIVA DE ANTI-DETECCIÓN
// Anti-detección
await _page.EvaluateExpressionOnNewDocumentAsync(@"
// 1. Ocultar navigator.webdriver
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
});
// 2. Sobrescribir chrome property (PuppeteerSharp deja rastros)
window.chrome = {
runtime: {},
loadTimes: function() {},
csi: function() {},
app: {}
};
// 3. Modificar permissions API para evitar detección
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
window.chrome = { runtime: {}, loadTimes: function() {}, csi: function() {}, app: {} };
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// 4. Sobrescribir plugins y mimeTypes (navegadores automatizados tienen 0)
Object.defineProperty(navigator, 'plugins', {
get: () => [
{
0: {type: 'application/x-google-chrome-pdf', suffixes: 'pdf'},
description: 'Portable Document Format',
filename: 'internal-pdf-viewer',
length: 1,
name: 'Chrome PDF Plugin'
},
{
0: {type: 'application/pdf', suffixes: 'pdf'},
description: 'Portable Document Format',
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
length: 1,
name: 'Chrome PDF Viewer'
}
{0: {type: 'application/x-google-chrome-pdf', suffixes: 'pdf'}, description: 'Portable Document Format', filename: 'internal-pdf-viewer', length: 1, name: 'Chrome PDF Plugin'},
{0: {type: 'application/pdf', suffixes: 'pdf'}, description: 'Portable Document Format', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', length: 1, name: 'Chrome PDF Viewer'}
],
});
Object.defineProperty(navigator, 'mimeTypes', {
get: () => [
{type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format'},
{type: 'application/x-google-chrome-pdf', suffixes: 'pdf', description: ''}
],
});
// 5. languages debe ser array (no solo string)
Object.defineProperty(navigator, 'languages', {
get: () => ['es-ES', 'es', 'en-US', 'en'],
});
// 6. WebGL vendor info (importante para fingerprinting)
Object.defineProperty(navigator, 'languages', { get: () => ['es-ES', 'es', 'en-US', 'en'] });
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
if (parameter === 37445) {
return 'Intel Inc.'; // UNMASKED_VENDOR_WEBGL
}
if (parameter === 37446) {
return 'Intel(R) UHD Graphics'; // UNMASKED_RENDERER_WEBGL
}
if (parameter === 37445) return 'Intel Inc.';
if (parameter === 37446) return 'Intel(R) UHD Graphics';
return getParameter.apply(this, [parameter]);
};
// 7. Ocultar características de headless Chrome
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 4 + Math.floor(Math.random() * 5) // 4-8 cores (realista)
});
Object.defineProperty(navigator, 'deviceMemory', {
get: () => 8 // 8GB RAM
});
// 8. Sobrescribir Notification.permission
Object.defineProperty(Notification, 'permission', {
get: () => 'default'
});
// 9. Battery API (navegadores automatizados no tienen)
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 + Math.floor(Math.random() * 5) });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(navigator, 'getBattery', {
get: () => () => Promise.resolve({
charging: true,
chargingTime: 0,
dischargingTime: Infinity,
level: 0.97
})
get: () => () => Promise.resolve({ charging: true, chargingTime: 0, dischargingTime: Infinity, level: 0.97 })
});
// 10. Connection API
Object.defineProperty(navigator, 'connection', {
get: () => ({
effectiveType: '4g',
downlink: 10,
rtt: 50,
saveData: false
})
get: () => ({ effectiveType: '4g', downlink: 10, rtt: 50, saveData: false })
});
// 11. Evitar detección por timing attacks
const originalDate = Date;
Date = class extends originalDate {
constructor(...args) {
if (args.length === 0) {
super();
// Añadir jitter aleatorio de 0-5ms
const jitter = Math.floor(Math.random() * 5);
return new originalDate(super.getTime() + jitter);
}
return new originalDate(...args);
}
};
// 12. Ocultar '__playwright' y '__puppeteer' si existen
delete window.__playwright;
delete window.__puppeteer;
console.log('✅ Anti-detección ejecutada exitosamente');
console.log('✅ Anti-detección ejecutada');
");
_logger.LogInformation("Navegando a WhatsApp Web...");
@@ -218,70 +136,75 @@ namespace WhatsappPromo.Engine.Services
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
_logger.LogInformation("WhatsApp Web cargado.");
}
public async Task<Page> GetPageAsync()
{
if (_page == null) await InitializeAsync();
return (Page)_page;
return (_page as Page) ?? throw new Exception("Error al obtener la página de Puppeteer");
}
public async Task CloseAsync()
{
if (_browser != null) await _browser.CloseAsync();
try
{
if (_browser != null && !_browser.IsClosed)
{
_logger.LogInformation("Cerrando navegador...");
await _browser.CloseAsync();
}
}
catch (Exception ex)
{
_logger.LogWarning("Error al cerrar el navegador: {Message}", ex.Message);
}
finally
{
_browser = null;
_page = null;
}
}
public async Task PerformHumanIdleActionsAsync()
{
if (_page == null) return;
if (_page == null || _page.IsClosed) return;
try
{
var rnd = new Random();
var action = rnd.Next(1, 4);
var action = Random.Shared.Next(1, 4);
switch (action)
{
case 1: // Random Mouse Move con curva Bezier (más humano)
var x = rnd.Next(100, 1000);
var y = rnd.Next(100, 600);
// Steps más alto = movimiento más suave y humano
await _page.Mouse.MoveAsync(x, y, new MoveOptions { Steps = rnd.Next(20, 60) });
case 1:
var x = Random.Shared.Next(100, 1000);
var y = Random.Shared.Next(100, 600);
await _page.Mouse.MoveAsync(x, y, new MoveOptions { Steps = Random.Shared.Next(20, 60) });
break;
case 2: // Small Scroll con variación
var delta = rnd.Next(-150, 150);
case 2:
var delta = Random.Shared.Next(-150, 150);
await _page.Mouse.WheelAsync(0, delta);
break;
case 3: // Pause con duración variable (simula lectura)
await Task.Delay(rnd.Next(1500, 4000));
case 3:
await Task.Delay(Random.Shared.Next(1500, 4000));
break;
}
}
catch { /* Ignore errors during idle actions */ }
catch { }
}
public async Task<string> GetQrCodeAsync()
public async Task<string?> GetQrCodeAsync()
{
if (_page == null) return null;
if (_page == null || _page.IsClosed) return null;
try
{
// Selector del canvas del QR
var qrSelector = "canvas";
await _page.WaitForSelectorAsync(qrSelector, new WaitForSelectorOptions { Timeout = 5000 });
var element = await _page.QuerySelectorAsync(qrSelector);
if (element != null)
{
// Devolver como base64 para el frontend
return await element.ScreenshotBase64Async();
}
}
catch
{
// No se encontró código QR (posiblemente ya logueado)
}
catch {}
return null;
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using WhatsappPromo.Core.Models;
using WhatsappPromo.Core.Services;
@@ -28,7 +30,6 @@ namespace WhatsappPromo.Worker.Controllers
[HttpPost]
public async Task<IActionResult> UpdateConfig([FromBody] SystemConfig config)
{
// Validar ruta
if (!string.IsNullOrEmpty(config.DownloadPath))
{
try
@@ -62,5 +63,64 @@ namespace WhatsappPromo.Worker.Controllers
await _configService.UpdateConfigAsync(config);
return Ok(new { status = "Stopping" });
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_SHOWWINDOW = 0x0040;
[HttpGet("browse")]
public IActionResult Browse()
{
string? selectedPath = null;
var thread = new Thread(() =>
{
using (var dummyForm = new Form())
{
dummyForm.TopMost = true;
// El form debe ser "real" para que Windows lo respete
dummyForm.Width = 1; dummyForm.Height = 1;
dummyForm.Opacity = 0.05;
dummyForm.ShowInTaskbar = false;
dummyForm.FormBorderStyle = FormBorderStyle.None;
dummyForm.StartPosition = FormStartPosition.CenterScreen;
dummyForm.Show();
// Forzamos la posición al frente de todo
SetWindowPos(dummyForm.Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetForegroundWindow(dummyForm.Handle);
dummyForm.Activate();
dummyForm.Focus();
using (var fbd = new FolderBrowserDialog())
{
fbd.Description = "Selecciona la carpeta para descargas de WhatsApp";
fbd.UseDescriptionForTitle = true;
fbd.ShowNewFolderButton = true;
if (fbd.ShowDialog(dummyForm) == DialogResult.OK)
{
selectedPath = fbd.SelectedPath;
}
}
dummyForm.Close();
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
return Ok(new { path = selectedPath });
}
}
}

View File

@@ -39,7 +39,10 @@ var app = builder.Build();
app.UseCors("AllowDashboard");
app.UseHttpsRedirection();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseAuthorization();
app.MapControllers();
app.MapHub<WhatsappHub>("/whatsappHub"); // SignalR Endpoint for Frontend

View File

@@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>

View File

@@ -18,7 +18,7 @@ namespace WhatsappPromo.Worker
{
private readonly ILogger<WhatsappWorker> _logger;
private readonly IBrowserService _browserService;
private readonly IConfigService _configService; // Check this dependency
private readonly IConfigService _configService;
private readonly Channel<ProcessedMedia> _mediaChannel;
private readonly IHubContext<WhatsappHub> _hubContext;
private readonly SemaphoreSlim _lock = new(1, 1);
@@ -42,29 +42,44 @@ namespace WhatsappPromo.Worker
while (!stoppingToken.IsCancellationRequested)
{
var config = await _configService.GetConfigAsync();
if (config.IsActive && !_isAutomationRunning)
try
{
_ = StartAutomationAsync(stoppingToken);
var config = await _configService.GetConfigAsync();
if (config.IsActive && !_isAutomationRunning)
{
_ = StartAutomationAsync(stoppingToken);
}
await Task.Delay(2000, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error en el bucle principal del Worker");
}
await Task.Delay(2000, stoppingToken);
}
_logger.LogInformation("Cerrando servicio worker...");
await _browserService.CloseAsync();
}
private async Task StartAutomationAsync(CancellationToken token)
private async Task StartAutomationAsync(CancellationToken stoppingToken)
{
if (_isAutomationRunning) return;
try
{
await _lock.WaitAsync(token);
await _lock.WaitAsync(stoppingToken);
if (_isAutomationRunning) return;
_isAutomationRunning = true;
_logger.LogInformation("Iniciando AUTOMATIZACIÓN...");
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Iniciando...", token);
await SafeSendAsync("StatusUpdate", "Iniciando...");
await _browserService.InitializeAsync();
var page = await _browserService.GetPageAsync();
@@ -72,7 +87,7 @@ namespace WhatsappPromo.Worker
// Configurar bindings
await page.ExposeFunctionAsync("onNewMessage", (Func<string, Task>)(async (string jsonMessage) =>
{
await _hubContext.Clients.All.SendAsync("LogUpdate", $"Mensaje recibido: {jsonMessage}");
await SafeSendAsync("LogUpdate", $"Mensaje recibido: {jsonMessage}");
}));
await page.ExposeFunctionAsync("onMediaDownloaded", (Func<string, string, string, Task>)(async (string phone, string base64, string mimeType) =>
@@ -81,13 +96,13 @@ namespace WhatsappPromo.Worker
{
var msg = "No se pudo detectar el número de teléfono. Archivo ignorado. Asegúrese de tener el chat abierto.";
_logger.LogWarning(msg);
await _hubContext.Clients.All.SendAsync("PersistentLog", "ERROR", msg);
await SafeSendAsync("PersistentLog", "ERROR", msg);
return;
}
var size = base64.Length;
_logger.LogInformation("Media recibido de {Phone}. Tamaño: {Size} bytes", phone, size);
await _hubContext.Clients.All.SendAsync("NewMediaReceived", new { Phone = phone, Base64 = base64, MimeType = mimeType });
await SafeSendAsync("NewMediaReceived", new { Phone = phone, Base64 = base64, MimeType = mimeType });
await _mediaChannel.Writer.WriteAsync(new ProcessedMedia
{
@@ -95,19 +110,19 @@ namespace WhatsappPromo.Worker
Base64Content = base64,
MimeType = mimeType,
Timestamp = DateTime.Now
});
}, stoppingToken);
}));
await page.EvaluateFunctionOnNewDocumentAsync(JsSnippets.MediaExtractor);
await page.ReloadAsync();
// Procesamiento en segundo plano
using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var config = await _configService.GetConfigAsync();
_ = ProcessMediaQueueAsync(config.DownloadPath, cts.Token);
var processTask = ProcessMediaQueueAsync(config.DownloadPath, cts.Token);
// Bucle de monitoreo
while (!token.IsCancellationRequested)
while (!stoppingToken.IsCancellationRequested)
{
var currentConfig = await _configService.GetConfigAsync();
if (!currentConfig.IsActive)
@@ -116,7 +131,7 @@ namespace WhatsappPromo.Worker
break;
}
if (new Random().Next(0, 10) > 6)
if (Random.Shared.Next(0, 10) > 6)
{
await _browserService.PerformHumanIdleActionsAsync();
}
@@ -126,42 +141,63 @@ namespace WhatsappPromo.Worker
var qrBase64 = await _browserService.GetQrCodeAsync();
if (!string.IsNullOrEmpty(qrBase64))
{
await _hubContext.Clients.All.SendAsync("QrCodeUpdate", qrBase64, token);
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Esperando Login (QR)", token);
await SafeSendAsync("QrCodeUpdate", qrBase64);
await SafeSendAsync("StatusUpdate", "Esperando Login (QR)");
}
else
{
await _hubContext.Clients.All.SendAsync("QrCodeUpdate", null, token);
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Conectado y Escaneando", token);
await SafeSendAsync("QrCodeUpdate", null);
await SafeSendAsync("StatusUpdate", "Conectado y Escaneando");
}
}
catch {}
if (page.IsClosed) break;
await Task.Delay(2000, token);
await Task.Delay(2000, stoppingToken);
}
cts.Cancel();
await processTask;
}
catch (OperationCanceledException) {}
catch (Exception ex)
{
_logger.LogError(ex, "Error en automatización");
await _hubContext.Clients.All.SendAsync("LogUpdate", $"ERROR: {ex.Message}");
await SafeSendAsync("LogUpdate", $"ERROR: {ex.Message}");
var conf = await _configService.GetConfigAsync();
conf.IsActive = false;
await _configService.UpdateConfigAsync(conf);
try
{
var conf = await _configService.GetConfigAsync();
conf.IsActive = false;
await _configService.UpdateConfigAsync(conf);
} catch {}
}
finally
{
await _browserService.CloseAsync();
_isAutomationRunning = false;
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Detenido");
await SafeSendAsync("QrCodeUpdate", null);
await SafeSendAsync("StatusUpdate", "Detenido");
_lock.Release();
}
}
private async Task SafeSendAsync(string method, object? arg1, object? arg2 = null)
{
try
{
if (arg2 != null)
await _hubContext.Clients.All.SendAsync(method, arg1, arg2);
else
await _hubContext.Clients.All.SendAsync(method, arg1);
}
catch (Exception ex)
{
_logger.LogDebug("SignalR Send skipped: {Msg}", ex.Message);
}
}
private int _processedToday = 0;
private int _processedThisHour = 0;
private int _currentDay = -1;
@@ -173,7 +209,8 @@ namespace WhatsappPromo.Worker
if (string.IsNullOrEmpty(storagePath))
storagePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ReceivedMedia");
Directory.CreateDirectory(storagePath);
if (!Directory.Exists(storagePath))
Directory.CreateDirectory(storagePath);
try
{
@@ -183,68 +220,35 @@ namespace WhatsappPromo.Worker
{
var config = await _configService.GetConfigAsync();
// 1. Verificar y resetear contadores de tiempo
var now = DateTime.Now;
if (_currentDay != now.Day)
{
_currentDay = now.Day;
_processedToday = 0;
}
if (_currentHour != now.Hour)
{
_currentHour = now.Hour;
_processedThisHour = 0;
}
if (_currentDay != now.Day) { _currentDay = now.Day; _processedToday = 0; }
if (_currentHour != now.Hour) { _currentHour = now.Hour; _processedThisHour = 0; }
// 2. Verificar CUOTAS
while (_processedToday >= config.MaxFilesPerDay || _processedThisHour >= config.MaxFilesPerHour)
{
var waitMsg = $"Cuota alcanzada ({_processedThisHour}/{config.MaxFilesPerHour}h, {_processedToday}/{config.MaxFilesPerDay}d). Pausando procesamiento...";
_logger.LogWarning(waitMsg);
await _hubContext.Clients.All.SendAsync("LogUpdate", $"[CUOTA] {waitMsg}", token);
// Esperar 1 minuto antes de volver a chequear
await SafeSendAsync("LogUpdate", $"[CUOTA] Límite alcanzado, esperando...");
await Task.Delay(60000, token);
now = DateTime.Now;
if (_currentDay != now.Day) { _currentDay = now.Day; _processedToday = 0; }
if (_currentHour != now.Hour) { _currentHour = now.Hour; _processedThisHour = 0; }
}
// 3. Humanizar tiempo de procesamiento (Delay aleatorio)
// Si estamos cerca del límite, aumentamos el delay para "suavizar" el pico
int baseDelay = Random.Shared.Next(5000, 15000); // 5-15s base
if (_processedThisHour > config.MaxFilesPerHour * 0.8)
{
baseDelay = Random.Shared.Next(30000, 60000); // Ralentizar al final de la hora
}
await Task.Delay(baseDelay, token);
var extension = media.MimeType switch
{
"image/jpeg" => ".jpg",
"image/png" => ".png",
"video/mp4" => ".mp4",
_ => ".dat"
};
int delay = Random.Shared.Next(5000, 15000);
if (_processedThisHour > config.MaxFilesPerHour * 0.8) delay = Random.Shared.Next(30000, 60000);
await Task.Delay(delay, token);
var extension = media.MimeType switch { "image/jpeg" => ".jpg", "image/png" => ".png", "video/mp4" => ".mp4", _ => ".dat" };
var fileName = $"{media.PhoneNumber}_{media.Timestamp:yyyyMMdd_HHmmss_fff}{extension}";
var fullPath = Path.Combine(storagePath, fileName);
var bytes = Convert.FromBase64String(media.Base64Content);
await File.WriteAllBytesAsync(fullPath, bytes, token);
await File.WriteAllBytesAsync(fullPath, Convert.FromBase64String(media.Base64Content), token);
_processedToday++;
_processedThisHour++;
_logger.LogInformation("Archivo guardado: {Path}. Cuota: {H}/{D}", fullPath, _processedThisHour, _processedToday);
await _hubContext.Clients.All.SendAsync("LogUpdate", $"Archivo guardado: {fileName} (Cuota: {_processedThisHour}/{config.MaxFilesPerHour}h)", token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error procesando media");
await SafeSendAsync("LogUpdate", $"Archivo guardado: {fileName}");
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "Error en cola"); }
}
}
catch (OperationCanceledException) {}

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>whatsapppromo-dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>whatsapppromo-dashboard</title>
</head>
<body style="background-color: #0f172a; margin: 0;">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"@tailwindcss/vite": "^4.1.18",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

View File

@@ -28,6 +28,7 @@ function App() {
maxFilesPerDay: 50
});
const [loading, setLoading] = useState(false);
const [isBrowsing, setIsBrowsing] = useState(false);
const [alert, setAlert] = useState<{ type: 'error' | 'info', message: string } | null>(null);
useEffect(() => {
@@ -83,6 +84,7 @@ function App() {
const toggleAutomation = async () => {
setLoading(true);
const endpoint = config.isActive ? 'stop' : 'start';
if (config.isActive) setQrCode(null); // Limpieza inmediata local
try {
await fetch(`http://localhost:5067/api/config/${endpoint}`, { method: 'POST' });
setConfig((prev: SystemConfig) => ({ ...prev, isActive: !prev.isActive }));
@@ -108,8 +110,34 @@ function App() {
}
};
const handleBrowse = async () => {
try {
setIsBrowsing(true);
const res = await fetch('http://localhost:5067/api/config/browse');
const data = await res.json();
if (data.path) {
setConfig(prev => ({ ...prev, downloadPath: data.path }));
}
} catch (err) {
console.error("Error browsing directory", err);
} finally {
setIsBrowsing(false);
}
};
return (
<div className="min-h-screen bg-gray-900 text-white p-8 font-sans">
<div className="min-h-screen bg-slate-900 text-white p-8 font-sans relative">
{/* BLOQUEO DE INTERACCIÓN DURANTE SELECCIÓN DE CARPETA */}
{isBrowsing && (
<div className="fixed inset-0 bg-black/60 z-[100] flex flex-col items-center justify-center backdrop-blur-sm cursor-wait">
<div className="bg-gray-800 p-8 rounded-2xl shadow-2xl border border-gray-700 flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-green-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-xl font-bold">Seleccionando Carpeta...</p>
<p className="text-gray-400 text-sm">Por favor, usa la ventana de Windows que acaba de aparecer.</p>
</div>
</div>
)}
{alert && (
<div className={`fixed top-4 left-1/2 transform -translate-x-1/2 z-50 px-6 py-4 rounded shadow-2xl flex items-center gap-4 ${alert.type === 'error' ? 'bg-red-800 text-white border border-red-500' : 'bg-blue-800 text-white border border-blue-500'
@@ -123,56 +151,68 @@ function App() {
</div>
)}
<div className="flex flex-col lg:flex-row justify-between lg:items-center mb-8 gap-4">
<h1 className="text-4xl font-bold text-green-400">WhatsApp Promo Monitor</h1>
<div className="flex flex-col lg:flex-row justify-between lg:items-center mb-10 gap-6">
<h1 className="text-4xl font-black text-green-500 tracking-tight drop-shadow-md">
WhatsApp Promo Monitor
</h1>
<div className="flex flex-wrap gap-6 items-center bg-gray-800 p-4 rounded-lg shadow-xl border border-gray-700">
<div className="flex flex-col">
<label className="text-xs text-gray-400 mb-1 uppercase tracking-wider font-bold">Ruta de Descarga</label>
<input
type="text"
value={config.downloadPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, downloadPath: e.target.value })}
className="bg-gray-700 text-sm px-2 py-1.5 rounded w-48 border border-gray-600 focus:border-green-500 outline-none transition-colors"
placeholder="C:\Downloads..."
/>
<div className="flex flex-col md:flex-row items-stretch md:items-end gap-4 bg-gray-800 p-5 rounded-2xl shadow-2xl border border-gray-700">
<div className="flex flex-col flex-1 min-w-[200px]">
<label className="text-[10px] text-gray-400 mb-1.5 uppercase tracking-widest font-extrabold">Ruta de Descarga</label>
<div className="flex gap-2">
<input
type="text"
value={config.downloadPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, downloadPath: e.target.value })}
className="flex-1 bg-gray-900 text-sm px-3 py-2.5 rounded-lg border border-gray-600 focus:border-green-500 outline-none transition-all"
placeholder="Seleccionar carpeta..."
/>
<button
onClick={handleBrowse}
className="bg-gray-700 hover:bg-gray-600 px-3 rounded-lg border border-gray-600 transition-all flex items-center justify-center"
title="Seleccionar Carpeta"
>
<span className="text-lg">📂</span>
</button>
</div>
</div>
<div className="flex flex-col">
<label className="text-xs text-gray-400 mb-1 uppercase tracking-wider font-bold">Máx/Hora</label>
<input
type="number"
value={config.maxFilesPerHour}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, maxFilesPerHour: parseInt(e.target.value) || 0 })}
className="bg-gray-700 text-sm px-2 py-1.5 rounded w-20 border border-gray-600 focus:border-green-500 outline-none transition-colors"
/>
<div className="flex gap-3">
<div className="flex flex-col">
<label className="text-[10px] text-gray-400 mb-1.5 uppercase tracking-widest font-extrabold">Máx/Hora</label>
<input
type="number"
value={config.maxFilesPerHour}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, maxFilesPerHour: parseInt(e.target.value) || 0 })}
className="bg-gray-900 text-sm px-2 py-2.5 rounded-lg border border-gray-600 focus:border-green-500 outline-none transition-all w-16 text-center font-bold"
/>
</div>
<div className="flex flex-col">
<label className="text-[10px] text-gray-400 mb-1.5 uppercase tracking-widest font-extrabold">Máx/Día</label>
<input
type="number"
value={config.maxFilesPerDay}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, maxFilesPerDay: parseInt(e.target.value) || 0 })}
className="bg-gray-900 text-sm px-2 py-2.5 rounded-lg border border-gray-600 focus:border-green-500 outline-none transition-all w-16 text-center font-bold"
/>
</div>
</div>
<div className="flex flex-col">
<label className="text-xs text-gray-400 mb-1 uppercase tracking-wider font-bold">Máx/Día</label>
<input
type="number"
value={config.maxFilesPerDay}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig({ ...config, maxFilesPerDay: parseInt(e.target.value) || 0 })}
className="bg-gray-700 text-sm px-2 py-1.5 rounded w-20 border border-gray-600 focus:border-green-500 outline-none transition-colors"
/>
</div>
<div className="flex gap-2 self-end">
<div className="flex gap-2 items-end">
<button
onClick={updateConfig}
className="bg-gray-700 hover:bg-gray-600 p-2 rounded border border-gray-600 transition-colors"
className="bg-slate-700 hover:bg-slate-600 p-3 rounded-lg border border-slate-600 transition-all shadow-md"
title="Guardar Configuración"
>
💾
<span className="text-lg leading-none">💾</span>
</button>
<button
onClick={toggleAutomation}
disabled={loading}
className={`px-6 py-2 rounded font-bold transition-all shadow-lg ${config.isActive
? 'bg-red-600 hover:bg-red-700 shadow-red-900/40 border border-red-500'
: 'bg-green-600 hover:bg-green-700 shadow-green-900/40 border border-green-500'
className={`min-w-[140px] py-2.5 px-4 rounded-lg font-black transition-all shadow-lg text-sm border-b-4 active:border-b-0 active:translate-y-1 ${config.isActive
? 'bg-red-600 hover:bg-red-500 border-red-800 text-white'
: 'bg-green-600 hover:bg-green-500 border-green-800 text-white shadow-green-900/20'
}`}
>
{loading ? '...' : (config.isActive ? '🛑 DETENER' : '▶ INICIAR')}
@@ -216,8 +256,8 @@ function App() {
{logs.length === 0 && <p className="text-gray-600 italic">Esperando eventos...</p>}
{logs.map((log: string, index: number) => (
<li key={index} className={`list-none border-l-2 pl-3 py-1 ${log.includes('[CUOTA]') ? 'border-yellow-500 bg-yellow-900/10 text-yellow-200' :
log.includes('ERROR') ? 'border-red-500 bg-red-900/10 text-red-300' :
'border-green-500 bg-green-900/5 text-green-300'
log.includes('ERROR') ? 'border-red-500 bg-red-900/10 text-red-300' :
'border-green-500 bg-green-900/5 text-green-300'
}`}>
{log}
</li>

View File

@@ -1,8 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
/* Custom scrollbar if needed */
.scrollbar-hide::-webkit-scrollbar {
display: none;
:root {
color-scheme: dark;
}
html,
body {
margin: 0;
padding: 0;
background-color: #0f172a !important;
/* bg-slate-900 */
color: #f1f5f9;
height: 100%;
}
#root {
min-height: 100vh;
background-color: #0f172a !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
tailwindcss(),
],
})