package services import ( "fmt" "log" "strconv" "strings" "sync" "time" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) // Los avisos programados: el dueño le pide al agente que le recuerde algo y // el aviso vuelve por donde se pidió. var avisosEnCurso sync.Mutex // DespacharAvisos corre cada minuto y manda los que ya vencieron. func DespacharAvisos() { if !avisosEnCurso.TryLock() { return } defer avisosEnCurso.Unlock() avisos, err := models.GetUmindAvisosVencidos() if err != nil { log.Printf("[UMIND_AVISO] no se pudieron listar: %v", err) return } for _, a := range avisos { // El reclamo es el lock: si otra instancia lo tomó, este ciclo lo salta. if !models.ReclamarAviso(a.ID) { continue } if err := entregarAviso(a); err != nil { log.Printf("[UMIND_AVISO] aviso %d no se pudo entregar: %v", a.ID, err) models.RegistrarEventoUmind(a.AgenteID, "error", "aviso", "No se pudo entregar un aviso programado: "+a.Titulo, err.Error()) models.MarcarAvisoFallido(a.ID, err.Error()) continue } if err := models.ReprogramarAviso(&a); err != nil { log.Printf("[UMIND_AVISO] aviso %d entregado pero no se pudo reprogramar: %v", a.ID, err) } } } // entregarAviso manda el aviso por el canal de donde salió el pedido. El // destino se fijó cuando se programó — nunca lo elige el texto del aviso. func entregarAviso(a models.UmindAviso) error { texto := "⏰ " + a.Titulo if strings.TrimSpace(a.Detalle) != "" { texto += "\n\n" + a.Detalle } switch { case strings.HasPrefix(a.Destino, "tg:"): chatID, err := strconv.ParseInt(strings.TrimPrefix(a.Destino, "tg:"), 10, 64) if err != nil { return fmt.Errorf("destino de telegram inválido: %w", err) } canal, err := models.GetUmindCanalPorTipo(a.AgenteID, "telegram") if err != nil { return fmt.Errorf("el canal de telegram ya no existe") } creds, err := DescifrarCredencialesCanal(canal.CredencialesEnc) if err != nil { return err } return (&TelegramService{}).SendMessageWithToken(chatID, texto, creds["bot_token"]) case strings.HasPrefix(a.Destino, "mail:"): para := strings.TrimPrefix(a.Destino, "mail:") canal, err := models.GetUmindCanalPorTipo(a.AgenteID, "correo") if err != nil { // Sin canal de correo propio se manda por el SMTP del sistema: // perder el aviso sería peor que mandarlo desde otra dirección. return app.Http.Mail.Send(para, a.Titulo, cuerpoHTMLAviso(a)) } creds, err := DescifrarCredencialesCanal(canal.CredencialesEnc) if err != nil { return err } return EnviarPorSMTP(DatosSMTP{ Host: creds["smtp_host"], Encriptado: creds["smtp_encriptado"], Usuario: creds["usuario"], Password: creds["password"], Desde: creds["usuario"], DesdeNombre: "Recordatorio", Puerto: puertoDe(creds["smtp_puerto"], 587), }, para, a.Titulo, cuerpoHTMLAviso(a)) default: return avisarAlDuenoDelEspacio(a) } } // avisarAlDuenoDelEspacio es el destino por defecto: los usuarios del portal // del cliente dueño del espacio, por correo y por la campanita. func avisarAlDuenoDelEspacio(a models.UmindAviso) error { agente, err := models.GetUmindAgenteByID(a.AgenteID) if err != nil { return err } tenant, err := models.GetUmindTenantByID(agente.TenantID) if err != nil || tenant.ClienteID == nil { return fmt.Errorf("el espacio no tiene un cliente asignado a quién avisarle") } usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID) if err != nil || len(usuarios) == 0 { return fmt.Errorf("el espacio no tiene usuarios a quién avisarle") } enviadoAlguno := false for _, u := range usuarios { models.CreateSistemaNotif(&models.SistemaNotificacion{ TipoUsuario: "portal_user", UsuarioID: u.ID, Titulo: a.Titulo, Cuerpo: a.Detalle, Icono: "⏰", Url: GetPublicURL() + "/portal/studio", }) if u.Email == "" { continue } if err := app.Http.Mail.Send(u.Email, "⏰ "+a.Titulo, cuerpoHTMLAviso(a)); err != nil { log.Printf("[UMIND_AVISO] no se pudo avisar a %s: %v", u.Email, err) continue } enviadoAlguno = true } if !enviadoAlguno { return fmt.Errorf("no se pudo entregar a ningún destinatario") } return nil } func cuerpoHTMLAviso(a models.UmindAviso) string { detalle := "" if strings.TrimSpace(a.Detalle) != "" { detalle = fmt.Sprintf(`

%s

`, a.Detalle) } return fmt.Sprintf(`

Recordatorio programado

%s

%s

Se lo pediste a tu asistente en uMind.

`, a.Titulo, detalle) } // ─── Interpretación de la fecha ───────────────────────────────────────────── // ParsearFechaAviso acepta lo que devuelve el modelo. Se le pide ISO, pero // los modelos a veces mandan solo la fecha o con espacio en vez de T, y // rechazar eso significa que el recordatorio del cliente no se programa. func ParsearFechaAviso(s string) (time.Time, error) { s = strings.TrimSpace(s) formatos := []string{ time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", } for _, f := range formatos { if t, err := time.ParseInLocation(f, s, time.Local); err == nil { // Una fecha sin hora se entrega a las 9 de la mañana y no a // medianoche, que es cuando nadie mira el teléfono. if f == "2006-01-02" { t = t.Add(9 * time.Hour) } return t, nil } } return time.Time{}, fmt.Errorf("no entendí la fecha %q, usá el formato 2026-03-15 09:00", s) }