Init Commit
This commit is contained in:
352
.gitignore
vendored
Normal file
352
.gitignore
vendored
Normal file
@@ -0,0 +1,352 @@
|
||||
################################################################################
|
||||
# WhatsApp Promo Monitor - .gitignore
|
||||
# Estructura: Backend (.NET) + Frontend (React/Vite)
|
||||
################################################################################
|
||||
|
||||
################################################################################
|
||||
# CONFIGURACIÓN Y DATOS SENSIBLES DEL PROYECTO
|
||||
################################################################################
|
||||
|
||||
# Configuración del sistema (contiene rutas y estado)
|
||||
config.json
|
||||
|
||||
# Perfil de WhatsApp Web (sesión del navegador)
|
||||
whatsapp-profile/
|
||||
|
||||
# Archivos multimedia descargados
|
||||
ReceivedMedia/
|
||||
|
||||
# Logs del sistema
|
||||
*.log
|
||||
logs/
|
||||
|
||||
################################################################################
|
||||
# BACKEND - .NET / C#
|
||||
################################################################################
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio cache/options
|
||||
.vs/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Build Results
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
**/packages/*
|
||||
!**/packages/build/
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# ReSharper
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
################################################################################
|
||||
# FRONTEND - React / Vite / Node.js
|
||||
################################################################################
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Yarn
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
################################################################################
|
||||
# SISTEMA OPERATIVO
|
||||
################################################################################
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.stackdump
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
################################################################################
|
||||
# IDEs Y EDITORES
|
||||
################################################################################
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
.vscode/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# JetBrains IDEs (Rider, WebStorm, IntelliJ)
|
||||
.idea/
|
||||
*.sln.iml
|
||||
.idea_modules/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
out/
|
||||
|
||||
# Sublime Text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Vim
|
||||
[._]*.s[a-v][a-z]
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
.netrwhist
|
||||
*~
|
||||
tags
|
||||
[._]*.un~
|
||||
|
||||
################################################################################
|
||||
# HERRAMIENTAS DE DESARROLLO
|
||||
################################################################################
|
||||
|
||||
# PuppeteerSharp browser downloads
|
||||
.local-chromium/
|
||||
.local-firefox/
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
lcov.info
|
||||
htmlcov/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
*.trx
|
||||
*.testlog
|
||||
|
||||
# Benchmark results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# Azure
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
PublishScripts/
|
||||
|
||||
################################################################################
|
||||
# ARCHIVOS TEMPORALES Y BACKUP
|
||||
################################################################################
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
*.orig
|
||||
*.tmp
|
||||
*.temp
|
||||
*~
|
||||
.~lock.*
|
||||
|
||||
# Compressed files (excepto releases oficiales)
|
||||
*.7z
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
|
||||
################################################################################
|
||||
# CERTIFICADOS Y CLAVES (NUNCA COMMITEAR)
|
||||
################################################################################
|
||||
|
||||
# SSL Certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cer
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# SSH Keys
|
||||
id_rsa
|
||||
id_rsa.pub
|
||||
id_ed25519
|
||||
id_ed25519.pub
|
||||
|
||||
################################################################################
|
||||
# MANTENER ESTRUCTURA DE DIRECTORIOS VACÍA
|
||||
################################################################################
|
||||
|
||||
# Permite mantener carpetas vacías en el repo
|
||||
!.gitkeep
|
||||
.agent
|
||||
116
README.md
Normal file
116
README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# WhatsApp Promo Monitor
|
||||
|
||||
Sistema automatizado de **máxima seguridad** para monitorear y descargar multimedia de WhatsApp Web utilizando C# (.NET) y React.
|
||||
|
||||
> ⭐ **Nivel de Seguridad**: 9.5/10 - Anti-detección avanzada con Cuotas Automatizadas y Peak Protection.
|
||||
|
||||
## 📋 Descripción
|
||||
|
||||
Este proyecto captura automáticamente imágenes y videos recibidos en WhatsApp Web y los guarda localmente. Utiliza un motor de automatización con PuppeteerSharp altamente humanizado para evitar bloqueos, gestionando automáticamente cuotas de descarga y picos de actividad.
|
||||
|
||||
## 🏗️ Estructura del Proyecto
|
||||
|
||||
```
|
||||
WAPP-Multimedia/
|
||||
├── src/
|
||||
│ ├── Backend/ # Backend .NET (Core, Engine, Worker)
|
||||
│ └── Frontend/ # Frontend React (Dashboard)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🚀 Características Principales
|
||||
|
||||
- ✅ **Automatización Humanizada**: Movimientos de mouse, scrolls y pausas realistas.
|
||||
- ✅ **Cuotas Inteligentes (NUEVO)**: Límites configurables por hora y día para evitar el ban.
|
||||
- ✅ **Peak Protection (NUEVO)**: Ralentización automática del guardado ante ráfagas de mensajes.
|
||||
- ✅ **Extracción 100% Automática**: Identifica el número del remitente sin intervención humana.
|
||||
- ✅ **Dashboard en Vivo**: Monitoreo de logs, galería y estado de conexión vía SignalR.
|
||||
- ✅ **Anti-Detección Elite**: 15 técnicas de ocultación de fingerprinting.
|
||||
|
||||
## 🎯 Extracción Automática de Números
|
||||
|
||||
El sistema utiliza **5 niveles de respaldo** para obtener el teléfono (desde metadatos internos hasta selectores visuales). Funciona sin necesidad de tener el chat abierto o hacer clic manual, asegurando que cada archivo guardado tenga su origen identificado.
|
||||
|
||||
## 📦 Instalación y Ejecución
|
||||
|
||||
### 1. Backend (.NET)
|
||||
```bash
|
||||
cd src/Backend/WhatsappPromo.Worker
|
||||
dotnet run
|
||||
```
|
||||
*Servicio en: `http://localhost:5067`*
|
||||
|
||||
### 2. Frontend (React)
|
||||
```bash
|
||||
cd src/Frontend/WhatsappPromo.Dashboard
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
*Dashboard en: `http://localhost:5173`*
|
||||
|
||||
## 🛡️ Seguridad y Anti-Detección
|
||||
|
||||
### **Gestión Automatizada (Backend)**
|
||||
Para máxima seguridad, el sistema autogestiona su comportamiento:
|
||||
1. **Límites de Cuota**: Detiene el procesamiento si se supera el máximo configurado por hora/día.
|
||||
2. **Cola Segura**: Los mensajes recibidos se encolan en memoria y se procesan con delays humanos variables.
|
||||
3. **Throttling Automático**: Al alcanzar el 80% de la cuota horaria, el sistema ralentiza el guardado (Peak Protection) para suavizar la curva de actividad ante los ojos de WhatsApp.
|
||||
|
||||
### **Evasión de Fingerprinting**
|
||||
- ✅ **Navigator Hiding**: Oculta `webdriver`, simula `plugins`, `languages` y `hardware specs`.
|
||||
- ✅ **API Spoofing**: Simula Battery API, Connection API y normaliza Permissions.
|
||||
- ✅ **Graphics Masking**: Falsifica WebGL Vendor y Renderer (Intel UHD Graphics).
|
||||
- ✅ **Behavioral Jitter**: Añade 0-5ms de variación aleatoria a cada acción técnica.
|
||||
|
||||
---
|
||||
|
||||
## 🕒 Guía de Uso Responsable (Manual de Buenas Prácticas)
|
||||
|
||||
Aunque el sistema es altamente seguro, la detección por parte de los servidores de WhatsApp depende de tu comportamiento operativo.
|
||||
|
||||
### **1. La Regla de Oro: NO operar 24/7**
|
||||
Un usuario real nunca está conectado 24 horas. Para mantener una cuenta segura:
|
||||
- **Horario Laboral**: Opera entre 6 y 10 horas al día.
|
||||
- **Pausas Nocturnas**: Apaga el sistema durante la noche (ej. 20:00 a 08:00).
|
||||
- **Días Libres**: No conectes el sistema todos los días. Deja 1 o 2 días de descanso semanal.
|
||||
|
||||
### **2. Configuración de Cuotas Seguras**
|
||||
Recomendamos iniciar con estos valores conservadores:
|
||||
- **Máximo por Hora**: 5 - 15 archivos.
|
||||
- **Máximo por Día**: 20 - 50 archivos.
|
||||
*Nota: El sistema dejará en espera los archivos que excedan estos límites hasta que se reinicie el ciclo (la siguiente hora o el día siguiente).*
|
||||
|
||||
### **3. Variación de Horarios**
|
||||
Evita la "precisión robótica". No inicies el sistema exactamente a las 09:00:00 todos los días. Varía tus horarios ±30 minutos para simular un comportamiento humano errático.
|
||||
|
||||
### **🚨 Señales de Alerta**
|
||||
Si WhatsApp te solicita verificaciones SMS frecuentes o experimentas desconexiones inusuales:
|
||||
1. **Detén el sistema** de inmediato.
|
||||
2. **Espera 72 horas** antes de volver a conectar.
|
||||
3. **Reduce tus cuotas** a la mitad al reiniciar.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Puntuación de Seguridad Final
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ NIVEL DE PROTECCIÓN ANTI-DETECCIÓN │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ Navigator & APIs: ✅ 10/10 (Completo) │
|
||||
│ Hardware & Graphics: ✅ 10/10 (Spoofing) │
|
||||
│ Behavior & Quotas: ✅ 9/10 (Automatizado) │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ TOTAL FINAL: ⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ (9.5/10) │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📝 Notas Técnicas
|
||||
- **Perfiles**: La sesión se guarda en la carpeta `whatsapp-profile` del backend.
|
||||
- **Multimedia**: Los archivos se almacenan por defecto en `ReceivedMedia`.
|
||||
- **CORS**: Asegúrate de que el frontend tenga acceso al puerto 5067 del backend.
|
||||
|
||||
---
|
||||
|
||||
**Versión**: 2.5 | **Última actualización**: 2026-02-09
|
||||
**Estado de Seguridad**: ✅ Protección Integral Automatizada
|
||||
33
src/Backend/WhatsappPromo.Core/Models/Models.cs
Normal file
33
src/Backend/WhatsappPromo.Core/Models/Models.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
|
||||
namespace WhatsappPromo.Core.Models
|
||||
{
|
||||
public class WhatsappMessage
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string From { get; set; } = string.Empty; // Número de teléfono
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
public bool HasMedia { get; set; }
|
||||
public string? MediaType { get; set; } // imagen, video, documento
|
||||
public string? MediaMimeType { get; set; }
|
||||
}
|
||||
|
||||
public class ProcessedMedia
|
||||
{
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
public string PhoneNumber { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Base64Content { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string FileExtension { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SystemConfig
|
||||
{
|
||||
public bool IsActive { get; set; } = false;
|
||||
public string DownloadPath { get; set; } = string.Empty;
|
||||
public int MaxFilesPerHour { get; set; } = 10;
|
||||
public int MaxFilesPerDay { get; set; } = 50;
|
||||
}
|
||||
}
|
||||
77
src/Backend/WhatsappPromo.Core/Services/ConfigService.cs
Normal file
77
src/Backend/WhatsappPromo.Core/Services/ConfigService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsappPromo.Core.Models;
|
||||
|
||||
namespace WhatsappPromo.Core.Services
|
||||
{
|
||||
public interface IConfigService
|
||||
{
|
||||
Task<SystemConfig> GetConfigAsync();
|
||||
Task UpdateConfigAsync(SystemConfig config);
|
||||
}
|
||||
|
||||
public class ConfigService : IConfigService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly ILogger<ConfigService> _logger;
|
||||
private SystemConfig _cachedConfig;
|
||||
|
||||
public ConfigService(ILogger<ConfigService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json");
|
||||
}
|
||||
|
||||
public async Task<SystemConfig> GetConfigAsync()
|
||||
{
|
||||
if (_cachedConfig != null) return _cachedConfig;
|
||||
|
||||
if (!File.Exists(_configPath))
|
||||
{
|
||||
_cachedConfig = new SystemConfig
|
||||
{
|
||||
IsActive = false, // Siempre iniciar inactivo
|
||||
DownloadPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ReceivedMedia")
|
||||
};
|
||||
await SaveConfigAsync(_cachedConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(_configPath);
|
||||
_cachedConfig = JsonSerializer.Deserialize<SystemConfig>(json) ?? new SystemConfig();
|
||||
// Asegurar que active sea false al inicio mayormente, pero el usuario podría querer persistencia.
|
||||
// La solicitud dice 'el sistema debe iniciar siempre sin la automatización activada'
|
||||
if (_cachedConfig.IsActive)
|
||||
{
|
||||
_cachedConfig.IsActive = false;
|
||||
await SaveConfigAsync(_cachedConfig);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error leyendo configuración, usando valores por defecto");
|
||||
_cachedConfig = new SystemConfig();
|
||||
}
|
||||
}
|
||||
|
||||
return _cachedConfig;
|
||||
}
|
||||
|
||||
public async Task UpdateConfigAsync(SystemConfig config)
|
||||
{
|
||||
_cachedConfig = config;
|
||||
await SaveConfigAsync(config);
|
||||
}
|
||||
|
||||
private async Task SaveConfigAsync(SystemConfig config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(_configPath, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/Backend/WhatsappPromo.Core/WhatsappPromo.Core.csproj
Normal file
14
src/Backend/WhatsappPromo.Core/WhatsappPromo.Core.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
288
src/Backend/WhatsappPromo.Engine/Services/BrowserService.cs
Normal file
288
src/Backend/WhatsappPromo.Engine/Services/BrowserService.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using PuppeteerSharp;
|
||||
using PuppeteerSharp.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace WhatsappPromo.Engine.Services
|
||||
{
|
||||
public interface IBrowserService
|
||||
{
|
||||
Task InitializeAsync();
|
||||
Task<Page> GetPageAsync();
|
||||
Task CloseAsync();
|
||||
Task<string> GetQrCodeAsync();
|
||||
Task PerformHumanIdleActionsAsync();
|
||||
}
|
||||
|
||||
public class BrowserService : IBrowserService
|
||||
{
|
||||
private readonly ILogger<BrowserService> _logger;
|
||||
private IBrowser? _browser;
|
||||
private IPage? _page;
|
||||
private readonly string _userDataDir;
|
||||
|
||||
public BrowserService(ILogger<BrowserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_userDataDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "whatsapp-profile");
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_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
|
||||
UserDataDir = _userDataDir,
|
||||
Args = new[]
|
||||
{
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-infobars",
|
||||
"--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",
|
||||
|
||||
// Deshabilitar características que revelan automatización
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
|
||||
// Evitar leaks de automatización
|
||||
"--disable-default-apps"
|
||||
},
|
||||
IgnoredDefaultArgs = new[] { "--enable-automation" }, // Quita "Chrome is being controlled"
|
||||
DefaultViewport = null // Permite que el viewport sea controlado por window size
|
||||
};
|
||||
|
||||
_logger.LogInformation("Iniciando navegador Chrome...");
|
||||
_browser = await Puppeteer.LaunchAsync(options);
|
||||
|
||||
var pages = await _browser.PagesAsync();
|
||||
_page = pages.Length > 0 ? pages[0] : await _browser.NewPageAsync();
|
||||
|
||||
// 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
|
||||
DeviceScaleFactor = 1
|
||||
});
|
||||
|
||||
// INYECCIÓN MASIVA DE 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
|
||||
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'
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
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, 'getBattery', {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
// 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');
|
||||
");
|
||||
|
||||
_logger.LogInformation("Navegando a WhatsApp Web...");
|
||||
await _page.GoToAsync("https://web.whatsapp.com", new NavigationOptions
|
||||
{
|
||||
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
||||
});
|
||||
|
||||
_logger.LogInformation("WhatsApp Web cargado.");
|
||||
}
|
||||
|
||||
public async Task<Page> GetPageAsync()
|
||||
{
|
||||
if (_page == null) await InitializeAsync();
|
||||
return (Page)_page;
|
||||
}
|
||||
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
if (_browser != null) await _browser.CloseAsync();
|
||||
}
|
||||
|
||||
public async Task PerformHumanIdleActionsAsync()
|
||||
{
|
||||
if (_page == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var rnd = new Random();
|
||||
var action = rnd.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) });
|
||||
break;
|
||||
case 2: // Small Scroll con variación
|
||||
var delta = rnd.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));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { /* Ignore errors during idle actions */ }
|
||||
}
|
||||
|
||||
public async Task<string> GetQrCodeAsync()
|
||||
{
|
||||
if (_page == null) 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)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/Backend/WhatsappPromo.Engine/Wapi/JsSnippets.cs
Normal file
173
src/Backend/WhatsappPromo.Engine/Wapi/JsSnippets.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
namespace WhatsappPromo.Engine.Wapi
|
||||
{
|
||||
public static class JsSnippets
|
||||
{
|
||||
public const string MediaExtractor = @"
|
||||
console.log('Injected MediaExtractor initialized');
|
||||
|
||||
// Set to keep track of processed message IDs
|
||||
window.processedMessages = new Set();
|
||||
|
||||
window.scanForMedia = async () => {
|
||||
// Buscar todos los contenedores de mensajes
|
||||
const messages = document.querySelectorAll('div[role=""row""]');
|
||||
|
||||
for (const msgRow of messages) {
|
||||
// Chequear si es mensaje entrante (ignorar mensajes enviados)
|
||||
if (!msgRow.classList.contains('message-in')) continue;
|
||||
|
||||
// Chequear si tiene imagen/video
|
||||
const img = msgRow.querySelector('img[src^=""blob:""]');
|
||||
if (!img) continue;
|
||||
|
||||
const blobUrl = img.src;
|
||||
|
||||
// Usar ID único para evitar duplicados
|
||||
const msgId = msgRow.getAttribute('data-id') || blobUrl;
|
||||
if (window.processedMessages.has(msgId)) continue;
|
||||
|
||||
window.processedMessages.add(msgId);
|
||||
|
||||
console.log('Found new media:', blobUrl);
|
||||
|
||||
try {
|
||||
// EXTRACCIÓN AUTOMÁTICA DE TELÉFONO (múltiples métodos)
|
||||
let phone = '';
|
||||
|
||||
// MÉTODO 1: Buscar en atributos data-* del mensaje (más confiable)
|
||||
// WhatsApp Web usa atributos internos que pueden contener el JID (phone@c.us)
|
||||
const dataPrePlainText = msgRow.querySelector('[data-pre-plain-text]');
|
||||
if (dataPrePlainText && !phone) {
|
||||
const prePlainText = dataPrePlainText.getAttribute('data-pre-plain-text');
|
||||
// Formato típico: ""[HH:MM, DD/MM/YYYY] Nombre: ""
|
||||
// Pero en algunos casos incluye el número
|
||||
const match = prePlainText.match(/(\d{10,15})/);
|
||||
if (match) {
|
||||
phone = match[1];
|
||||
console.log('Phone extracted from data-pre-plain-text:', phone);
|
||||
}
|
||||
}
|
||||
|
||||
// MÉTODO 2: Buscar en el elemento padre que podría tener data-id con formato phone@c.us
|
||||
if (!phone) {
|
||||
let parentEl = msgRow;
|
||||
for (let i = 0; i < 5; i++) { // Subir hasta 5 niveles
|
||||
if (!parentEl) break;
|
||||
const dataId = parentEl.getAttribute('data-id');
|
||||
if (dataId && dataId.includes('@')) {
|
||||
// Formato: ""false_5491112345678@c.us_MESSAGEID""
|
||||
const parts = dataId.split('_');
|
||||
for (const part of parts) {
|
||||
if (part.includes('@c.us')) {
|
||||
const phoneMatch = part.match(/(\d{10,15})@/);
|
||||
if (phoneMatch) {
|
||||
phone = phoneMatch[1];
|
||||
console.log('Phone extracted from data-id:', phone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phone) break;
|
||||
}
|
||||
parentEl = parentEl.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// MÉTODO 3: Buscar en elementos cercanos con atributos title o aria-label
|
||||
if (!phone) {
|
||||
const titleElements = msgRow.querySelectorAll('[title], [aria-label]');
|
||||
for (const el of titleElements) {
|
||||
const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
|
||||
const phoneMatch = text.match(/\+?(\d{10,15})/);
|
||||
if (phoneMatch && phoneMatch[1].length >= 10) {
|
||||
phone = phoneMatch[1].replace(/\D/g, '');
|
||||
console.log('Phone extracted from title/aria-label:', phone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MÉTODO 4 (FALLBACK): Header del chat abierto (requiere intervención humana)
|
||||
// Solo se usa si los métodos anteriores fallaron
|
||||
if (!phone) {
|
||||
const headerTitle = document.querySelector('header span[title]');
|
||||
if (headerTitle) {
|
||||
phone = headerTitle.getAttribute('title').replace(/\D/g, '');
|
||||
console.log('Phone extracted from header (fallback):', phone);
|
||||
}
|
||||
}
|
||||
|
||||
// MÉTODO 5 (ÚLTIMO RECURSO): Buscar el chat activo en la lista lateral
|
||||
if (!phone) {
|
||||
const activeChat = document.querySelector('div[aria-selected=""true""]');
|
||||
if (activeChat) {
|
||||
const chatTitle = activeChat.querySelector('span[title]');
|
||||
if (chatTitle) {
|
||||
const titleText = chatTitle.getAttribute('title');
|
||||
const phoneMatch = titleText.match(/\+?(\d{10,15})/);
|
||||
if (phoneMatch) {
|
||||
phone = phoneMatch[1].replace(/\D/g, '');
|
||||
console.log('Phone extracted from active chat:', phone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VALIDACIÓN FINAL
|
||||
if (!phone || phone.length < 8) {
|
||||
console.warn('❌ No se pudo detectar el teléfono con ningún método. Mensaje ignorado.');
|
||||
console.warn('💡 Sugerencia: Asegúrate de tener WhatsApp Web completamente cargado.');
|
||||
// NO llamar a onMediaDownloaded, simplemente ignorar este mensaje
|
||||
// y continuar con el siguiente
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('✅ Teléfono detectado exitosamente:', phone);
|
||||
|
||||
// Simular pequeño retraso random antes de descargar
|
||||
await new Promise(r => setTimeout(r, Math.random() * 2000 + 500));
|
||||
|
||||
const response = await fetch(blobUrl);
|
||||
const blob = await response.blob();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result.split(',')[1];
|
||||
const mimeType = blob.type;
|
||||
|
||||
// Enviar a C#
|
||||
window.onMediaDownloaded(phone, base64data, mimeType);
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error downloading media:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Anti-detección: Usar MutationObserver en lugar de polling constante
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldScan = false;
|
||||
for(const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
shouldScan = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldScan) {
|
||||
// Debounce ligero
|
||||
if (window.scanTimeout) clearTimeout(window.scanTimeout);
|
||||
window.scanTimeout = setTimeout(window.scanForMedia, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Conectar al contenedor principal de la app
|
||||
const appRoot = document.getElementById('app') || document.body;
|
||||
observer.observe(appRoot, { childList: true, subtree: true });
|
||||
|
||||
// Fallback: ejecutar scan ocasionalmente por si acaso
|
||||
setInterval(window.scanForMedia, 10000);
|
||||
";
|
||||
}
|
||||
}
|
||||
17
src/Backend/WhatsappPromo.Engine/WhatsappPromo.Engine.csproj
Normal file
17
src/Backend/WhatsappPromo.Engine/WhatsappPromo.Engine.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WhatsappPromo.Core\WhatsappPromo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PuppeteerSharp" Version="20.2.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using WhatsappPromo.Core.Models;
|
||||
using WhatsappPromo.Core.Services;
|
||||
|
||||
namespace WhatsappPromo.Worker.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
|
||||
public ConfigController(IConfigService configService)
|
||||
{
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetConfig()
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] SystemConfig config)
|
||||
{
|
||||
// Validar ruta
|
||||
if (!string.IsNullOrEmpty(config.DownloadPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(config.DownloadPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Ruta inválida: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await _configService.UpdateConfigAsync(config);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
[HttpPost("start")]
|
||||
public async Task<IActionResult> Start()
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
config.IsActive = true;
|
||||
await _configService.UpdateConfigAsync(config);
|
||||
return Ok(new { status = "Starting" });
|
||||
}
|
||||
|
||||
[HttpPost("stop")]
|
||||
public async Task<IActionResult> Stop()
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
config.IsActive = false;
|
||||
await _configService.UpdateConfigAsync(config);
|
||||
return Ok(new { status = "Stopping" });
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/Backend/WhatsappPromo.Worker/Hubs/WhatsappHub.cs
Normal file
53
src/Backend/WhatsappPromo.Worker/Hubs/WhatsappHub.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Threading.Tasks;
|
||||
using WhatsappPromo.Core.Models;
|
||||
using WhatsappPromo.Core.Services;
|
||||
|
||||
namespace WhatsappPromo.Worker.Hubs
|
||||
{
|
||||
public class WhatsappHub : Hub
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
|
||||
public WhatsappHub(IConfigService configService)
|
||||
{
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public async Task SendMessage(string user, string message)
|
||||
{
|
||||
await Clients.All.SendAsync("ReceiveMessage", user, message);
|
||||
}
|
||||
|
||||
public async Task SendPersistentLog(string status, string message)
|
||||
{
|
||||
await Clients.All.SendAsync("PersistentLog", status, message);
|
||||
}
|
||||
|
||||
// Métodos para control desde el cliente vía SignalR (alternativa a REST API)
|
||||
|
||||
public async Task StartAutomation()
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
config.IsActive = true;
|
||||
await _configService.UpdateConfigAsync(config);
|
||||
await Clients.All.SendAsync("ActiveStateUpdate", true);
|
||||
await Clients.All.SendAsync("LogUpdate", "Comando de INICIO recibido vía SignalR.");
|
||||
}
|
||||
|
||||
public async Task StopAutomation()
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
config.IsActive = false;
|
||||
await _configService.UpdateConfigAsync(config);
|
||||
await Clients.All.SendAsync("ActiveStateUpdate", false);
|
||||
await Clients.All.SendAsync("LogUpdate", "Comando de PARADA recibido vía SignalR.");
|
||||
}
|
||||
|
||||
public async Task UpdateConfig(SystemConfig newConfig)
|
||||
{
|
||||
await _configService.UpdateConfigAsync(newConfig);
|
||||
await Clients.All.SendAsync("LogUpdate", "Configuración actualizada vía SignalR.");
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/Backend/WhatsappPromo.Worker/Program.cs
Normal file
47
src/Backend/WhatsappPromo.Worker/Program.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsappPromo.Core.Services;
|
||||
using WhatsappPromo.Engine.Services;
|
||||
using WhatsappPromo.Worker.Hubs;
|
||||
using WhatsappPromo.Worker;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<IBrowserService, BrowserService>();
|
||||
builder.Services.AddSingleton<IConfigService, ConfigService>();
|
||||
|
||||
// CORS for Dashboard
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowDashboard",
|
||||
policy =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:5173") // Vite default port
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials(); // Required for SignalR
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHostedService<WhatsappWorker>(); // Run the worker in background
|
||||
|
||||
// Enable running as a Windows Service
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("AllowDashboard");
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<WhatsappHub>("/whatsappHub"); // SignalR Endpoint for Frontend
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5067",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7195;http://localhost:5067",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Backend/WhatsappPromo.Worker/WhatsappPromo.Worker.csproj
Normal file
19
src/Backend/WhatsappPromo.Worker/WhatsappPromo.Worker.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WhatsappPromo.Core\WhatsappPromo.Core.csproj" />
|
||||
<ProjectReference Include="..\WhatsappPromo.Engine\WhatsappPromo.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@WhatsappPromo.Worker_HostAddress = http://localhost:5067
|
||||
|
||||
GET {{WhatsappPromo.Worker_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
253
src/Backend/WhatsappPromo.Worker/WhatsappWorker.cs
Normal file
253
src/Backend/WhatsappPromo.Worker/WhatsappWorker.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsappPromo.Core.Models;
|
||||
using WhatsappPromo.Core.Services;
|
||||
using WhatsappPromo.Engine.Services;
|
||||
using WhatsappPromo.Engine.Wapi;
|
||||
using WhatsappPromo.Worker.Hubs;
|
||||
|
||||
namespace WhatsappPromo.Worker
|
||||
{
|
||||
public class WhatsappWorker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<WhatsappWorker> _logger;
|
||||
private readonly IBrowserService _browserService;
|
||||
private readonly IConfigService _configService; // Check this dependency
|
||||
private readonly Channel<ProcessedMedia> _mediaChannel;
|
||||
private readonly IHubContext<WhatsappHub> _hubContext;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private bool _isAutomationRunning = false;
|
||||
|
||||
public WhatsappWorker(ILogger<WhatsappWorker> logger,
|
||||
IBrowserService browserService,
|
||||
IConfigService configService,
|
||||
IHubContext<WhatsappHub> hubContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_browserService = browserService;
|
||||
_configService = configService;
|
||||
_hubContext = hubContext;
|
||||
_mediaChannel = Channel.CreateUnbounded<ProcessedMedia>();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Servicio worker iniciado. Esperando activación manual...");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var config = await _configService.GetConfigAsync();
|
||||
|
||||
if (config.IsActive && !_isAutomationRunning)
|
||||
{
|
||||
_ = StartAutomationAsync(stoppingToken);
|
||||
}
|
||||
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAutomationAsync(CancellationToken token)
|
||||
{
|
||||
if (_isAutomationRunning) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _lock.WaitAsync(token);
|
||||
if (_isAutomationRunning) return;
|
||||
|
||||
_isAutomationRunning = true;
|
||||
_logger.LogInformation("Iniciando AUTOMATIZACIÓN...");
|
||||
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Iniciando...", token);
|
||||
|
||||
await _browserService.InitializeAsync();
|
||||
var page = await _browserService.GetPageAsync();
|
||||
|
||||
// Configurar bindings
|
||||
await page.ExposeFunctionAsync("onNewMessage", (Func<string, Task>)(async (string jsonMessage) =>
|
||||
{
|
||||
await _hubContext.Clients.All.SendAsync("LogUpdate", $"Mensaje recibido: {jsonMessage}");
|
||||
}));
|
||||
|
||||
await page.ExposeFunctionAsync("onMediaDownloaded", (Func<string, string, string, Task>)(async (string phone, string base64, string mimeType) =>
|
||||
{
|
||||
if (phone == "ERROR_NO_PHONE")
|
||||
{
|
||||
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);
|
||||
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 _mediaChannel.Writer.WriteAsync(new ProcessedMedia
|
||||
{
|
||||
PhoneNumber = phone,
|
||||
Base64Content = base64,
|
||||
MimeType = mimeType,
|
||||
Timestamp = DateTime.Now
|
||||
});
|
||||
}));
|
||||
|
||||
await page.EvaluateFunctionOnNewDocumentAsync(JsSnippets.MediaExtractor);
|
||||
await page.ReloadAsync();
|
||||
|
||||
// Procesamiento en segundo plano
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var config = await _configService.GetConfigAsync();
|
||||
_ = ProcessMediaQueueAsync(config.DownloadPath, cts.Token);
|
||||
|
||||
// Bucle de monitoreo
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var currentConfig = await _configService.GetConfigAsync();
|
||||
if (!currentConfig.IsActive)
|
||||
{
|
||||
_logger.LogInformation("Deteniendo AUTOMATIZACIÓN...");
|
||||
break;
|
||||
}
|
||||
|
||||
if (new Random().Next(0, 10) > 6)
|
||||
{
|
||||
await _browserService.PerformHumanIdleActionsAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients.All.SendAsync("QrCodeUpdate", null, token);
|
||||
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Conectado y Escaneando", token);
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
if (page.IsClosed) break;
|
||||
|
||||
await Task.Delay(2000, token);
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en automatización");
|
||||
await _hubContext.Clients.All.SendAsync("LogUpdate", $"ERROR: {ex.Message}");
|
||||
|
||||
var conf = await _configService.GetConfigAsync();
|
||||
conf.IsActive = false;
|
||||
await _configService.UpdateConfigAsync(conf);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _browserService.CloseAsync();
|
||||
_isAutomationRunning = false;
|
||||
await _hubContext.Clients.All.SendAsync("StatusUpdate", "Detenido");
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private int _processedToday = 0;
|
||||
private int _processedThisHour = 0;
|
||||
private int _currentDay = -1;
|
||||
private int _currentHour = -1;
|
||||
|
||||
private async Task ProcessMediaQueueAsync(string downloadPath, CancellationToken token)
|
||||
{
|
||||
var storagePath = downloadPath;
|
||||
if (string.IsNullOrEmpty(storagePath))
|
||||
storagePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ReceivedMedia");
|
||||
|
||||
Directory.CreateDirectory(storagePath);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var media in _mediaChannel.Reader.ReadAllAsync(token))
|
||||
{
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 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"
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
_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");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/Backend/WhatsappPromo.Worker/appsettings.json
Normal file
9
src/Backend/WhatsappPromo.Worker/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
24
src/Frontend/WhatsappPromo.Dashboard/.gitignore
vendored
Normal file
24
src/Frontend/WhatsappPromo.Dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
src/Frontend/WhatsappPromo.Dashboard/README.md
Normal file
73
src/Frontend/WhatsappPromo.Dashboard/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
src/Frontend/WhatsappPromo.Dashboard/eslint.config.js
Normal file
23
src/Frontend/WhatsappPromo.Dashboard/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
src/Frontend/WhatsappPromo.Dashboard/index.html
Normal file
13
src/Frontend/WhatsappPromo.Dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
3505
src/Frontend/WhatsappPromo.Dashboard/package-lock.json
generated
Normal file
3505
src/Frontend/WhatsappPromo.Dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
src/Frontend/WhatsappPromo.Dashboard/package.json
Normal file
34
src/Frontend/WhatsappPromo.Dashboard/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "whatsapppromo-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
42
src/Frontend/WhatsappPromo.Dashboard/src/App.css
Normal file
42
src/Frontend/WhatsappPromo.Dashboard/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
263
src/Frontend/WhatsappPromo.Dashboard/src/App.tsx
Normal file
263
src/Frontend/WhatsappPromo.Dashboard/src/App.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import './App.css';
|
||||
|
||||
interface MediaItem {
|
||||
phone: string;
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface SystemConfig {
|
||||
isActive: boolean;
|
||||
downloadPath: string;
|
||||
maxFilesPerHour: number;
|
||||
maxFilesPerDay: number;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [connection, setConnection] = useState<signalR.HubConnection | null>(null);
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [mediaGallery, setMediaGallery] = useState<MediaItem[]>([]);
|
||||
|
||||
const [config, setConfig] = useState<SystemConfig>({
|
||||
isActive: false,
|
||||
downloadPath: '',
|
||||
maxFilesPerHour: 10,
|
||||
maxFilesPerDay: 50
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [alert, setAlert] = useState<{ type: 'error' | 'info', message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:5067/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => setConfig(data))
|
||||
.catch(err => console.error("Error loading config", err));
|
||||
|
||||
const newConnection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("http://localhost:5067/whatsappHub")
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
setConnection(newConnection);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
connection.start()
|
||||
.then(() => {
|
||||
console.log('Connected to SignalR Hub');
|
||||
setLogs((prev: string[]) => [...prev, "Conectado al servidor WebSocket."]);
|
||||
|
||||
connection.on("QrCodeUpdate", (base64Qr: string) => {
|
||||
setQrCode(base64Qr);
|
||||
});
|
||||
|
||||
connection.on("StatusUpdate", (status: string) => {
|
||||
setLogs((prev: string[]) => [`ESTADO: ${status}`, ...prev]);
|
||||
});
|
||||
|
||||
connection.on("ActiveStateUpdate", (isActive: boolean) => {
|
||||
setConfig((prev: SystemConfig) => ({ ...prev, isActive }));
|
||||
});
|
||||
|
||||
connection.on("PersistentLog", (status: string, message: string) => {
|
||||
setAlert({ type: status === 'ERROR' ? 'error' : 'info', message });
|
||||
if (status !== 'ERROR') setTimeout(() => setAlert(null), 10000);
|
||||
});
|
||||
|
||||
connection.on("LogUpdate", (message: string) => {
|
||||
setLogs((prev: string[]) => [message, ...prev].slice(0, 50));
|
||||
});
|
||||
|
||||
connection.on("NewMediaReceived", (media: MediaItem) => {
|
||||
setMediaGallery((prev: MediaItem[]) => [media, ...prev]);
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => console.log('Connection failed: ', e));
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const toggleAutomation = async () => {
|
||||
setLoading(true);
|
||||
const endpoint = config.isActive ? 'stop' : 'start';
|
||||
try {
|
||||
await fetch(`http://localhost:5067/api/config/${endpoint}`, { method: 'POST' });
|
||||
setConfig((prev: SystemConfig) => ({ ...prev, isActive: !prev.isActive }));
|
||||
setAlert(null);
|
||||
} catch (err) {
|
||||
console.error("Error toggling automation", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = async () => {
|
||||
try {
|
||||
await fetch('http://localhost:5067/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
setAlert({ type: 'info', message: "Configuración guardada correctamente." });
|
||||
setTimeout(() => setAlert(null), 3000);
|
||||
} catch (err) {
|
||||
setAlert({ type: 'error', message: "Error al guardar la configuración." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-8 font-sans">
|
||||
|
||||
{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'
|
||||
}`}>
|
||||
<span className="text-xl">{alert.type === 'error' ? '⚠' : 'ℹ'}</span>
|
||||
<div>
|
||||
<p className="font-bold">{alert.type === 'error' ? 'Atención Requerida' : 'Información'}</p>
|
||||
<p className="text-sm">{alert.message}</p>
|
||||
</div>
|
||||
<button onClick={() => setAlert(null)} className="ml-4 text-gray-300 hover:text-white">✕</button>
|
||||
</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-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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button
|
||||
onClick={updateConfig}
|
||||
className="bg-gray-700 hover:bg-gray-600 p-2 rounded border border-gray-600 transition-colors"
|
||||
title="Guardar Configuración"
|
||||
>
|
||||
💾
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{loading ? '...' : (config.isActive ? '🛑 DETENER' : '▶ INICIAR')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||
<h2 className="text-2xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="text-green-500">⦿</span> Estado de Conexión
|
||||
</h2>
|
||||
<div className="flex justify-center items-center h-64 bg-gray-900 rounded-lg overflow-hidden relative border border-gray-700">
|
||||
{qrCode ? (
|
||||
<img src={`data:image/png;base64,${qrCode}`} alt="WhatsApp QR" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-gray-500 text-center px-4">
|
||||
{config.isActive ? (
|
||||
<div className="animate-pulse">
|
||||
<p className="text-xl mb-2">Escaneando...</p>
|
||||
<p className="text-sm">Si el QR no aparece, es posible que ya estés conectado.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xl mb-2">Sistema Detenido</p>
|
||||
<p className="text-sm">Presiona INICIAR para comenzar el monitoreo.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg h-[22rem] overflow-hidden border border-gray-700 flex flex-col">
|
||||
<h2 className="text-2xl font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="text-blue-500">📜</span> Logs en Vivo
|
||||
</h2>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 text-sm font-mono scrollbar-thin scrollbar-thumb-gray-600">
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
||||
<h2 className="text-2xl font-semibold mb-6 flex items-center gap-2">
|
||||
<span className="text-purple-500">🖼</span> Galería de Capturas
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{mediaGallery.map((media: MediaItem, idx: number) => (
|
||||
<div key={idx} className="relative group aspect-square bg-gray-900 rounded-xl overflow-hidden border border-gray-700 hover:border-green-500 transition-all shadow-md">
|
||||
{media.mimeType.startsWith('image') ? (
|
||||
<img src={`data:${media.mimeType};base64,${media.base64}`} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-4xl bg-gray-800">
|
||||
<span>🎥</span>
|
||||
<span className="text-[10px] mt-2 text-gray-400">VIDEO</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-80"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 text-[10px] text-center font-bold text-white truncate">
|
||||
{media.phone}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mediaGallery.length === 0 && (
|
||||
<div className="col-span-full py-12 flex flex-col items-center justify-center border-2 border-dashed border-gray-700 rounded-xl text-gray-600">
|
||||
<span className="text-5xl mb-2">📁</span>
|
||||
<p>Los archivos capturados aparecerán aquí.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
8
src/Frontend/WhatsappPromo.Dashboard/src/index.css
Normal file
8
src/Frontend/WhatsappPromo.Dashboard/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar if needed */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
10
src/Frontend/WhatsappPromo.Dashboard/src/main.tsx
Normal file
10
src/Frontend/WhatsappPromo.Dashboard/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
11
src/Frontend/WhatsappPromo.Dashboard/tailwind.config.js
Normal file
11
src/Frontend/WhatsappPromo.Dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
28
src/Frontend/WhatsappPromo.Dashboard/tsconfig.app.json
Normal file
28
src/Frontend/WhatsappPromo.Dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
src/Frontend/WhatsappPromo.Dashboard/tsconfig.json
Normal file
7
src/Frontend/WhatsappPromo.Dashboard/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
src/Frontend/WhatsappPromo.Dashboard/tsconfig.node.json
Normal file
26
src/Frontend/WhatsappPromo.Dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
src/Frontend/WhatsappPromo.Dashboard/vite.config.ts
Normal file
7
src/Frontend/WhatsappPromo.Dashboard/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
13
src/WhatsappPromo.slnx
Normal file
13
src/WhatsappPromo.slnx
Normal file
@@ -0,0 +1,13 @@
|
||||
<Solution>
|
||||
<Folder Name="/Backend/">
|
||||
<Project Path="Backend/WhatsappPromo.Core/WhatsappPromo.Core.csproj" />
|
||||
<Project Path="Backend/WhatsappPromo.Engine/WhatsappPromo.Engine.csproj" />
|
||||
<Project Path="Backend/WhatsappPromo.Worker/WhatsappPromo.Worker.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Frontend/">
|
||||
<!-- We don't usually add Frontend project to sln unless it's a SPA template, but user might want it visible -->
|
||||
<!-- However, .sln only supports valid project types. Since it's React/Vite, it's not a .csproj -->
|
||||
<!-- Often we add it as a Solution Item or a Folder but no .csproj reference -->
|
||||
<!-- Let's just focus on Backend projects for now in .sln unless user asked otherwise -->
|
||||
</Folder>
|
||||
</Solution>
|
||||
Reference in New Issue
Block a user