Feat: UI/UX y Procesos
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
_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) {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
639
src/Frontend/WhatsappPromo.Dashboard/package-lock.json
generated
639
src/Frontend/WhatsappPromo.Dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user