feat(umind): el agente programa recordatorios, y solo para su dueño

"Avisame el 15 de marzo que vence la póliza de Acme, y todos los años" queda
programado desde la conversación, visible y cancelable en el panel. Tres tools:
programar, listar, cancelar.

El aviso vuelve por donde se pidió — Telegram a ese chat, correo a esa casilla
— o al dueño del espacio por correo y campanita. Nunca a una dirección que
dicte la conversación: eso sería un cañón de spam con destinatario libre.

Y las tools de aviso solo se le OFRECEN al modelo cuando la sesión es interna:
el chat de prueba del panel (que corre autenticado) o un canal marcado como
línea privada del dueño. En el widget público escribe cualquiera, y cualquiera
no puede programarle recordatorios ni gastarle el plan a otro. El gate está en
dos capas: la tool no se declara, y si igual la pide, el ejecutor la rechaza.

Tres detalles que se pagan una sola vez:
- El cron corre en memoria del proceso, sin lock distribuido: con dos
  instancias cada aviso saldría dos veces. El reclamo es un UPDATE condicional
  — la base ya es el árbitro, no hace falta traer otro.
- Si el servidor estuvo caído, una repetición diaria se saltea los ciclos
  perdidos en vez de disparar diez avisos viejos de golpe.
- Un fallo de SMTP devuelve el aviso a pendiente: una caída de correo no puede
  perder un vencimiento de póliza.

Una fecha sin hora se entrega a las 9, no a medianoche, que es cuando nadie
mira el teléfono.

De paso: los archivos de los espacios uMind nunca se sirven por el estático de
/uploads. El guard genérico solo sabe si hay sesión de panel, no de quién es el
archivo — salen por su endpoint, que sí valida propiedad. Cerrado por
construcción y no por acordarse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:32:48 -05:00
co-authored by Claude Opus 5
parent 9da2a057f2
commit b2e2e83fe8
30 changed files with 757 additions and 144 deletions
+172
View File
@@ -0,0 +1,172 @@
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(`<p style="color:#334155;font-size:14px">%s</p>`, a.Detalle)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html><body style="font-family:Inter,sans-serif;background:#f1f5f9;padding:32px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;border:1px solid #e2e8f0">
<p style="color:#64748b;font-size:13px;margin:0 0 8px">Recordatorio programado</p>
<h2 style="margin:0 0 12px;color:#1e293b;font-size:20px">%s</h2>
%s
<p style="color:#94a3b8;font-size:12px;margin-top:24px">Se lo pediste a tu asistente en uMind.</p>
</div></body></html>`, 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)
}