Cuatro cosas que ya estaban a medio construir y no se usaban como canal. 1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te estaba dando exposición en su sitio y no se capitalizaba. El host sale de la URL del propio script, así funciona igual en cualquier entorno. 2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué las abrió el visitante, y el consumo desglosado. Es lo que el cliente necesita para justificar el gasto puertas adentro — un panel al que hay que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el generador de xlsx del cronograma. 3. Aviso automático de agentes sin base de conocimiento (cron diario). Un agente sin fuentes responde de memoria e inventa datos, el cliente concluye que el producto no sirve y se va. Es el punto donde más gente se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de auditoría como registro de envío para no necesitar tabla nueva ni convertir el recordatorio en spam. 4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo tiene, oferta si no. Es el punto de contacto más barato que hay — ya entró, ya confía, y el cobro se suma a la factura que ya recibe. El mensaje lidera con notas de voz y fotos, que es lo más difícil de copiar de lo que tenemos. La consulta de conversaciones agrupa en SQL en vez de traerse el historial entero para agrupar en Go. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
498 lines
15 KiB
Go
498 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
type cronProcInfo struct {
|
|
Nombre string `json:"nombre"`
|
|
CPU float64 `json:"cpu"`
|
|
RAMMB float64 `json:"ram_mb"`
|
|
}
|
|
|
|
var cronScheduler *cron.Cron
|
|
|
|
// IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go.
|
|
func IniciarCron() {
|
|
cronScheduler = cron.New()
|
|
|
|
// Vencimientos próximos — todos los días a las 8:00 AM
|
|
if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err)
|
|
return
|
|
}
|
|
|
|
// Contratos ya vencidos — todos los días a las 9:00 AM
|
|
if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err)
|
|
return
|
|
}
|
|
|
|
// Polling Bold — cada 15 minutos para detectar pagos sin webhook
|
|
if _, err := cronScheduler.AddFunc("*/15 * * * *", VerificarPagosBoldPendientes); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea bold_polling: %v", err)
|
|
return
|
|
}
|
|
|
|
// Salud de servidores — cada 5 minutos
|
|
if _, err := cronScheduler.AddFunc("*/5 * * * *", VerificarSaludServidores); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea salud_servidores: %v", err)
|
|
return
|
|
}
|
|
|
|
// Agentes de uMind creados pero sin base de conocimiento — una vez al día
|
|
// a las 10:00. Es el punto donde más clientes se quedan a mitad de camino,
|
|
// y se detecta solo.
|
|
if _, err := cronScheduler.AddFunc("0 10 * * *", AvisarAgentesSinConocimiento); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea umind_activacion: %v", err)
|
|
return
|
|
}
|
|
|
|
// Purga de historial de métricas — cada noche a las 3 AM (retención 7 días)
|
|
if _, err := cronScheduler.AddFunc("0 3 * * *", func() {
|
|
if err := models.PurgarMetricasHistory(7); err != nil {
|
|
log.Printf("[CRON] Error purgando historial métricas: %v", err)
|
|
} else {
|
|
log.Println("[CRON] Historial de métricas purgado (>7 días)")
|
|
}
|
|
if err := models.PurgarUrlMonitorLogs(7); err != nil {
|
|
log.Printf("[CRON] Error purgando logs URL monitor: %v", err)
|
|
}
|
|
}); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
|
|
return
|
|
}
|
|
|
|
// Monitor de URLs — cada minuto
|
|
if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea url_monitor: %v", err)
|
|
return
|
|
}
|
|
|
|
cronScheduler.Start()
|
|
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
|
}
|
|
|
|
// DetenerCron para graceful shutdown
|
|
func DetenerCron() {
|
|
if cronScheduler != nil {
|
|
cronScheduler.Stop()
|
|
}
|
|
}
|
|
|
|
// ProcesarVencimientos mantiene compatibilidad para llamadas manuales
|
|
func ProcesarVencimientos() {
|
|
ProcesarVencimientosProximos()
|
|
ProcesarYaVencidos()
|
|
}
|
|
|
|
// ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo"
|
|
func ProcesarVencimientosProximos() {
|
|
log.Println("[CRON] Procesando vencimientos próximos...")
|
|
|
|
reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo")
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, regla := range reglas {
|
|
contratos, err := models.GetContratosProximosVencer(regla.DiasAntes)
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err)
|
|
continue
|
|
}
|
|
procesarContratos(®la, contratos)
|
|
}
|
|
|
|
log.Println("[CRON] Vencimientos próximos finalizado")
|
|
}
|
|
|
|
// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya
|
|
// vencido antes de darlo por perdido y dejar de notificar.
|
|
const diasGraciaVencimiento = 30
|
|
|
|
// ProcesarYaVencidos procesa reglas de tipo "ya_vencido"
|
|
func ProcesarYaVencidos() {
|
|
log.Println("[CRON] Procesando contratos ya vencidos...")
|
|
|
|
// Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que
|
|
// dejen de recibir el correo diario de forma indefinida.
|
|
if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil {
|
|
log.Printf("[CRON] Error marcando contratos vencidos: %v", err)
|
|
}
|
|
|
|
reglas, err := models.GetReglasByTipoEvento("ya_vencido")
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err)
|
|
return
|
|
}
|
|
if len(reglas) == 0 {
|
|
return
|
|
}
|
|
|
|
contratos, err := models.GetContratosYaVencidos()
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, regla := range reglas {
|
|
procesarContratos(®la, contratos)
|
|
}
|
|
|
|
log.Println("[CRON] Ya vencidos finalizado")
|
|
}
|
|
|
|
// procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos
|
|
func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) {
|
|
if len(contratos) == 0 {
|
|
return
|
|
}
|
|
|
|
// Filtrar por AplicaA
|
|
var filtrados []models.Contrato
|
|
for _, c := range contratos {
|
|
switch regla.AplicaA {
|
|
case "renovable":
|
|
for _, s := range c.Servicios {
|
|
if s.Tipo == "renovable" {
|
|
filtrados = append(filtrados, c)
|
|
break
|
|
}
|
|
}
|
|
case "unico":
|
|
for _, s := range c.Servicios {
|
|
if s.Tipo == "unico" {
|
|
filtrados = append(filtrados, c)
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
filtrados = append(filtrados, c)
|
|
}
|
|
}
|
|
if len(filtrados) == 0 {
|
|
return
|
|
}
|
|
|
|
// Agrupar por cliente
|
|
porCliente := make(map[uint][]models.Contrato)
|
|
for _, c := range filtrados {
|
|
porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c)
|
|
}
|
|
|
|
for clienteID, grupoContratos := range porCliente {
|
|
if models.YaEnviadoHoy(clienteID, regla.ID) {
|
|
log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID)
|
|
continue
|
|
}
|
|
cliente := &grupoContratos[0].Cliente
|
|
if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos, ""); err != nil {
|
|
log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err)
|
|
} else {
|
|
log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos))
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerificarPagosBoldPendientes consulta la API de Bold para cada contrato que tiene un
|
|
// enlace de pago generado pero aún no confirmado. Se ejecuta cada 15 minutos via cron.
|
|
// Esto compensa la ausencia de webhooks de Bold.
|
|
func VerificarPagosBoldPendientes() {
|
|
contratos, err := models.GetContratosConEnlacePendiente()
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Error obteniendo contratos pendientes: %v", err)
|
|
return
|
|
}
|
|
if len(contratos) == 0 {
|
|
return
|
|
}
|
|
|
|
boldCfg, err := models.GetBoldConfig()
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Sin configuración Bold activa: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("[BOLD-POLL] Verificando %d contrato(s) con enlace pendiente...", len(contratos))
|
|
|
|
for _, c := range contratos {
|
|
paid, paymentID, monto, err := CheckBoldLinkPaid(boldCfg, c.EnlacePagoLinkID)
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Error consultando link %s (contrato %d): %v", c.EnlacePagoLinkID, c.ID, err)
|
|
continue
|
|
}
|
|
if !paid {
|
|
continue
|
|
}
|
|
|
|
ref := fmt.Sprintf("contrato-%d", c.ID)
|
|
log.Printf("[BOLD-POLL] Pago detectado para %s (payment_id=%s, monto=%d)", ref, paymentID, monto)
|
|
|
|
if ok, _ := models.MarcarContratoPagado(c.ID); ok {
|
|
go EnviarCorreoConfirmacionPago(c.ID, "bold")
|
|
log.Printf("[BOLD-POLL] Contrato %d marcado como pagado", c.ID)
|
|
}
|
|
|
|
// Enriquecer callback log si existe
|
|
go models.EnrichBoldCallbackLog(ref, c.Cliente.Email, monto)
|
|
|
|
// Guardar en webhook log como API_CHECK para trazabilidad
|
|
notifID := "api-check-" + paymentID
|
|
if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) {
|
|
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
|
|
NotificationID: notifID,
|
|
Tipo: "API_CHECK",
|
|
PaymentID: paymentID,
|
|
Referencia: ref,
|
|
PayerEmail: c.Cliente.Email,
|
|
Monto: monto,
|
|
Procesado: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
log.Println("[BOLD-POLL] Verificación completada")
|
|
}
|
|
|
|
// ─── Salud de Servidores ─────────────────────────────────────────────────────
|
|
|
|
// VerificarSaludServidores corre cada 5 min y genera notificaciones del sistema
|
|
// cuando detecta: agente caído, recurso al límite o VPS próximo a vencer.
|
|
func VerificarSaludServidores() {
|
|
cfg := models.GetServidorAlertaUmbral()
|
|
if !cfg.Activo {
|
|
return
|
|
}
|
|
|
|
servidores, _, err := models.GetAllServidores(1000, 0, "")
|
|
if err != nil {
|
|
log.Printf("[CRON-SRV] Error obteniendo servidores: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, srv := range servidores {
|
|
// 1. Agente caído (sin ping en X minutos)
|
|
if srv.AgentToken != "" && srv.AgentLastSeen != nil {
|
|
minutos := time.Since(*srv.AgentLastSeen).Minutes()
|
|
if minutos > float64(cfg.MinutosSinPing) {
|
|
titulo := fmt.Sprintf("🔴 Sin señal: %s", srv.Nombre)
|
|
if !models.YaExisteAlertaServidor(titulo, 1) {
|
|
crearNotifServidor(titulo,
|
|
fmt.Sprintf("El agente no reporta hace %.0f minutos (umbral: %d min)", minutos, cfg.MinutosSinPing),
|
|
"servidor_caido")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Recursos (CPU / RAM / Disco) al límite
|
|
if srv.MetricasJson != "" {
|
|
var m struct {
|
|
CPU struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
} `json:"cpu"`
|
|
RAM struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
} `json:"ram"`
|
|
Discos []struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
Ruta string `json:"ruta"`
|
|
} `json:"discos"`
|
|
TopProcs []cronProcInfo `json:"top_procs"`
|
|
}
|
|
if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil {
|
|
checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU, m.TopProcs, "cpu")
|
|
checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM, m.TopProcs, "ram")
|
|
for _, d := range m.Discos {
|
|
label := "Disco"
|
|
if d.Ruta != "" && d.Ruta != "/" {
|
|
label = "Disco " + d.Ruta
|
|
}
|
|
checkRecurso(srv.Nombre, label, d.Porcentaje, cfg.UmbralDisco, nil, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Vencimiento próximo
|
|
if srv.Vencimiento != "" && cfg.DiasAnteVencimiento > 0 {
|
|
vence, parseErr := time.Parse("2006-01-02", srv.Vencimiento)
|
|
if parseErr == nil {
|
|
dias := int(time.Until(vence).Hours() / 24)
|
|
if dias >= 0 && dias <= cfg.DiasAnteVencimiento {
|
|
titulo := fmt.Sprintf("📅 %s vence en %s", srv.Nombre, strconv.Itoa(dias)+" días")
|
|
if dias == 0 {
|
|
titulo = fmt.Sprintf("📅 %s vence HOY", srv.Nombre)
|
|
}
|
|
if !models.YaExisteAlertaServidor(titulo, 12) {
|
|
crearNotifServidor(titulo,
|
|
fmt.Sprintf("Fecha de vencimiento: %s. Renueva a tiempo.", srv.Vencimiento),
|
|
"servidor_vence_pronto")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkRecurso(nombre, recurso string, valor float64, umbral int, procs []cronProcInfo, ordenar string) {
|
|
if valor < float64(umbral) {
|
|
return
|
|
}
|
|
titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor)
|
|
if !models.YaExisteAlertaServidor(titulo, 1) {
|
|
cuerpo := fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral)
|
|
if len(procs) > 0 {
|
|
cp := make([]cronProcInfo, len(procs))
|
|
copy(cp, procs)
|
|
if ordenar == "cpu" {
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i].CPU > cp[j].CPU })
|
|
} else {
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i].RAMMB > cp[j].RAMMB })
|
|
}
|
|
if len(cp) > 3 {
|
|
cp = cp[:3]
|
|
}
|
|
cuerpo += "\n\nTop procesos:"
|
|
for i, p := range cp {
|
|
if ordenar == "cpu" {
|
|
cuerpo += fmt.Sprintf("\n%d. %s — CPU: %.1f%%, RAM: %.0f MB", i+1, p.Nombre, p.CPU, p.RAMMB)
|
|
} else {
|
|
cuerpo += fmt.Sprintf("\n%d. %s — RAM: %.0f MB, CPU: %.1f%%", i+1, p.Nombre, p.RAMMB, p.CPU)
|
|
}
|
|
}
|
|
}
|
|
crearNotifServidor(titulo, cuerpo, "servidor_recurso_alto")
|
|
}
|
|
}
|
|
|
|
func crearNotifServidor(titulo, cuerpo, evento string) {
|
|
// Verificar si la notificación del sistema está habilitada para este evento
|
|
evtCfg := models.GetNotifConfig(evento, "admin")
|
|
// Por defecto, si no hay config, crear notificación de sistema igualmente
|
|
if evtCfg == nil || evtCfg.CanalSistema {
|
|
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
|
|
TipoUsuario: "admin",
|
|
Titulo: titulo,
|
|
Cuerpo: cuerpo,
|
|
Url: "/app/servidor-dashboard",
|
|
Icono: "🖥️",
|
|
})
|
|
}
|
|
// Telegram (si está configurado para este evento)
|
|
if evtCfg != nil && evtCfg.CanalTelegram {
|
|
msg := fmt.Sprintf("<b>%s</b>\n%s", titulo, cuerpo)
|
|
sendTelegramAdmin(msg)
|
|
}
|
|
}
|
|
|
|
// ─── Monitor de URLs ──────────────────────────────────────────────────────────
|
|
|
|
func VerificarUrlMonitors() {
|
|
monitors, err := models.GetActiveUrlMonitors()
|
|
if err != nil || len(monitors) == 0 {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
for _, m := range monitors {
|
|
m := m
|
|
if m.UltimoCheckAt != nil {
|
|
if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) {
|
|
continue
|
|
}
|
|
}
|
|
go ejecutarChequeoURL(m)
|
|
}
|
|
}
|
|
|
|
func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) }
|
|
|
|
func ejecutarChequeoURL(m models.UrlMonitor) {
|
|
statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg)
|
|
ok := err == nil && statusCode >= 200 && statusCode < 400
|
|
errStr := ""
|
|
if err != nil {
|
|
errStr = err.Error()
|
|
}
|
|
|
|
anteriorOk := m.UltimoOk == nil || *m.UltimoOk
|
|
|
|
models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok)
|
|
models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr)
|
|
|
|
if m.UltimoOk == nil {
|
|
return // primer chequeo, no alertar
|
|
}
|
|
if !ok && anteriorOk {
|
|
detalle := errStr
|
|
if detalle == "" {
|
|
detalle = fmt.Sprintf("HTTP %d", statusCode)
|
|
}
|
|
msg := fmt.Sprintf("🔴 <b>Sitio caído</b>\n<b>%s</b>\n%s\n\nError: %s",
|
|
escapeTelegramHTML(m.Nombre),
|
|
escapeTelegramHTML(m.URL),
|
|
escapeTelegramHTML(detalle))
|
|
sendTelegramAdmin(msg)
|
|
log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle)
|
|
} else if ok && !anteriorOk {
|
|
msg := fmt.Sprintf("✅ <b>Sitio recuperado</b>\n<b>%s</b>\n%s\nLatencia: <b>%dms</b>",
|
|
escapeTelegramHTML(m.Nombre),
|
|
escapeTelegramHTML(m.URL),
|
|
latMs)
|
|
sendTelegramAdmin(msg)
|
|
log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs)
|
|
}
|
|
}
|
|
|
|
func doHttpCheck(url string, timeoutSeg int) (int, int64, error) {
|
|
if timeoutSeg <= 0 {
|
|
timeoutSeg = 10
|
|
}
|
|
client := &http.Client{
|
|
Timeout: time.Duration(timeoutSeg) * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return fmt.Errorf("demasiadas redirecciones")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
start := time.Now()
|
|
req, err := http.NewRequest(http.MethodHead, url, nil)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
req.Header.Set("User-Agent", "usite-monitor/1.0")
|
|
|
|
resp, err := client.Do(req)
|
|
latMs := time.Since(start).Milliseconds()
|
|
if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) {
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
// Reintentar con GET
|
|
start = time.Now()
|
|
req2, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
req2.Header.Set("User-Agent", "usite-monitor/1.0")
|
|
resp2, err2 := client.Do(req2)
|
|
latMs = time.Since(start).Milliseconds()
|
|
if err2 != nil {
|
|
return 0, latMs, err2
|
|
}
|
|
resp2.Body.Close()
|
|
return resp2.StatusCode, latMs, nil
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, latMs, nil
|
|
}
|