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