En cada ticket, "✨ Borrador" propone una respuesta usando los fragmentos más parecidos de la base de conocimiento del agente de uMind que se elija en la configuración de soporte, más el hilo de la conversación. El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que nadie lo lea, es la forma más rápida de perderlo. Sin agente configurado el borrador se arma igual, solo con la conversación — peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o pasos que no estén en la documentación, y le pide decir qué falta preguntar cuando no alcanza para resolver. El cuadro de respuesta pasa a textarea: un borrador de varios renglones no entraba en un input de una línea. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
13 KiB
Go
377 lines
13 KiB
Go
package controllers
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"log"
|
|
"regexp"
|
|
"strconv"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// ─── Webhook de correo entrante ───────────────────────────────────────────────
|
|
// Recibe notificaciones de SendGrid, Mailgun, etc.
|
|
// POST /webhooks/soporte/:provider?key=<ApiKey de la config activa>
|
|
// provider: sendgrid, mailgun, generic
|
|
|
|
type soporteEmailIn struct {
|
|
From string `json:"from" form:"from"`
|
|
Subject string `json:"subject" form:"subject"`
|
|
Text string `json:"text" form:"text"`
|
|
Html string `json:"html" form:"html"`
|
|
Sender string `json:"sender" form:"sender"`
|
|
FromName string `json:"from_name" form:"from_name"`
|
|
MessageID string `json:"message_id" form:"message_id"`
|
|
}
|
|
|
|
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
|
|
|
|
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
|
|
// X-Api-Key) contra el ApiKey guardado en la config activa, en tiempo constante.
|
|
// Si la config no tiene ApiKey configurado, se rechaza toda petición: hay que
|
|
// definirlo en /app/soporte-webhook y usar esa misma URL con ?key=... en el proveedor.
|
|
func validarWebhookKey(c *fiber.Ctx, cfg *models.SoporteWebhookConfig) bool {
|
|
if cfg.ApiKey == "" {
|
|
return false
|
|
}
|
|
key := c.Query("key")
|
|
if key == "" {
|
|
key = c.Get("X-Webhook-Key")
|
|
}
|
|
if key == "" {
|
|
key = c.Get("X-Api-Key")
|
|
}
|
|
if key == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(key), []byte(cfg.ApiKey)) == 1
|
|
}
|
|
|
|
func SoporteWebhook(c *fiber.Ctx) error {
|
|
provider := c.Params("provider", "generic")
|
|
|
|
cfg, err := models.GetSoporteWebhookActivo()
|
|
if err != nil {
|
|
log.Printf("[SoporteWebhook] Sin config activa: %v", err)
|
|
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "sin config"})
|
|
}
|
|
|
|
if !validarWebhookKey(c, cfg) {
|
|
log.Printf("[SoporteWebhook] Petición rechazada (key inválida o ausente) desde IP %s", c.IP())
|
|
return c.Status(401).JSON(fiber.Map{"ok": false, "error": "no autorizado"})
|
|
}
|
|
|
|
var emails []soporteEmailIn
|
|
|
|
switch provider {
|
|
case "sendgrid":
|
|
var sg struct {
|
|
From string `json:"from" form:"from"`
|
|
Subject string `json:"subject" form:"subject"`
|
|
Text string `json:"text" form:"text"`
|
|
Html string `json:"html" form:"html"`
|
|
Sender string `json:"sender" form:"sender"`
|
|
FromName string `json:"from_name" form:"from_name"`
|
|
Headers string `json:"headers" form:"headers"`
|
|
}
|
|
if err := c.BodyParser(&sg); err != nil {
|
|
return c.Status(200).JSON(fiber.Map{"ok": false, "error": "body inválido"})
|
|
}
|
|
emails = append(emails, soporteEmailIn{
|
|
From: sg.From, Subject: sg.Subject, Text: sg.Text, Html: sg.Html,
|
|
Sender: sg.Sender, FromName: sg.FromName, MessageID: extractMessageIDFromHeaders(sg.Headers),
|
|
})
|
|
case "mailgun":
|
|
var mg struct {
|
|
From string `form:"from"`
|
|
Subject string `form:"subject"`
|
|
Text string `form:"body-plain"`
|
|
Html string `form:"body-html"`
|
|
Sender string `form:"sender"`
|
|
FromName string `form:"from_name"`
|
|
MessageID string `form:"Message-Id"`
|
|
}
|
|
if err := c.BodyParser(&mg); err != nil {
|
|
return c.Status(200).JSON(fiber.Map{"ok": false})
|
|
}
|
|
emails = append(emails, soporteEmailIn{
|
|
From: mg.From, Subject: mg.Subject, Text: mg.Text,
|
|
Html: mg.Html, Sender: mg.Sender, FromName: mg.FromName, MessageID: mg.MessageID,
|
|
})
|
|
default:
|
|
var generic struct {
|
|
From string `json:"from"`
|
|
Subject string `json:"subject"`
|
|
Text string `json:"text"`
|
|
Html string `json:"html"`
|
|
MessageID string `json:"message_id"`
|
|
}
|
|
if err := c.BodyParser(&generic); err != nil {
|
|
return c.Status(200).JSON(fiber.Map{"ok": false})
|
|
}
|
|
emails = append(emails, soporteEmailIn{
|
|
From: generic.From, Subject: generic.Subject, Text: generic.Text, Html: generic.Html,
|
|
MessageID: generic.MessageID,
|
|
})
|
|
}
|
|
|
|
for _, e := range emails {
|
|
contenido := e.Text
|
|
if contenido == "" {
|
|
contenido = e.Html
|
|
}
|
|
services.IngestarCorreoSoporte(cfg, services.CorreoSoporte{
|
|
From: e.From, FromName: e.FromName, Subject: e.Subject,
|
|
Texto: contenido, MessageID: e.MessageID,
|
|
})
|
|
}
|
|
|
|
return c.Status(200).JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func extractMessageIDFromHeaders(headers string) string {
|
|
if headers == "" {
|
|
return ""
|
|
}
|
|
if m := messageIDHeaderRe.FindStringSubmatch(headers); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ─── Asignar ticket a usuario ─────────────────────────────────────────────────
|
|
// PUT /app/tickets/:ticketID/asignar
|
|
|
|
func AsignarTicket(c *fiber.Ctx) error {
|
|
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
|
type Req struct {
|
|
AsignadoID uint `json:"asignado_id"`
|
|
}
|
|
var req Req
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if err := app.Http.Database.DB.Model(&models.ProyectoTicket{}).
|
|
Where("id = ?", ticketID).
|
|
Update("asignado_a", req.AsignadoID).Error; err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Listar admins para asignación ────────────────────────────────────────────
|
|
// GET /app/tickets/admins
|
|
|
|
func GetSoporteAdmins(c *fiber.Ctx) error {
|
|
var admins []models.Users
|
|
app.Http.Database.DB.Where("is_admin = ?", true).
|
|
Or("role_id IN (SELECT id FROM roles WHERE name = 'soporte' OR name = 'admin')").
|
|
Find(&admins)
|
|
type item struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
result := make([]item, 0)
|
|
for _, a := range admins {
|
|
result = append(result, item{ID: a.ID, Name: a.Name})
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
// ─── Configuración del webhook ────────────────────────────────────────────────
|
|
|
|
func SoporteWebhookConfigPage(c *fiber.Ctx) error {
|
|
cfg, _ := models.GetSoporteWebhookActivo()
|
|
return c.Render("soporte_webhook", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
"cfg": cfg,
|
|
}, "layouts/main")
|
|
}
|
|
|
|
func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
|
cfg, err := models.GetSoporteWebhookActivo()
|
|
if err != nil {
|
|
return c.JSON(fiber.Map{"data": nil})
|
|
}
|
|
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
|
|
return c.JSON(fiber.Map{"data": cfg})
|
|
}
|
|
|
|
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
|
type body struct {
|
|
ID uint `json:"id"`
|
|
Nombre string `json:"nombre"`
|
|
Provider string `json:"provider"`
|
|
ApiKey string `json:"api_key"`
|
|
EmailDestino string `json:"email_destino"`
|
|
ResponderAuto bool `json:"responder_auto"`
|
|
MensajeAuto string `json:"mensaje_auto"`
|
|
AsignarA *uint `json:"asignar_a"`
|
|
SmtpHost string `json:"smtp_host"`
|
|
SmtpPort int `json:"smtp_port"`
|
|
SmtpUsername string `json:"smtp_username"`
|
|
SmtpPassword string `json:"smtp_password"`
|
|
SmtpEncryption string `json:"smtp_encryption"`
|
|
SmtpFromAddr string `json:"smtp_from_addr"`
|
|
SmtpFromName string `json:"smtp_from_name"`
|
|
ImapActivo bool `json:"imap_activo"`
|
|
ImapHost string `json:"imap_host"`
|
|
ImapPort int `json:"imap_port"`
|
|
ImapUsername string `json:"imap_username"`
|
|
ImapPassword string `json:"imap_password"`
|
|
ImapEncryption string `json:"imap_encryption"`
|
|
ImapCarpeta string `json:"imap_carpeta"`
|
|
ImapHorasAtras int `json:"imap_horas_atras"`
|
|
AgenteBorradorID *uint `json:"agente_borrador_id"`
|
|
ClasificarConIA bool `json:"clasificar_con_ia"`
|
|
ContextoNegocio string `json:"contexto_negocio"`
|
|
}
|
|
var b body
|
|
if err := c.BodyParser(&b); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
port := b.SmtpPort
|
|
if port == 0 {
|
|
port = 587
|
|
}
|
|
enc := b.SmtpEncryption
|
|
if enc == "" {
|
|
enc = "starttls"
|
|
}
|
|
imapPort := b.ImapPort
|
|
if imapPort == 0 {
|
|
imapPort = 993
|
|
}
|
|
imapEnc := b.ImapEncryption
|
|
if imapEnc == "" {
|
|
imapEnc = "ssl"
|
|
}
|
|
carpeta := b.ImapCarpeta
|
|
if carpeta == "" {
|
|
carpeta = "INBOX"
|
|
}
|
|
// La contraseña IMAP solo viaja cuando el admin la escribe de nuevo: si el
|
|
// formulario la manda vacía, se conserva la que ya estaba guardada.
|
|
passEnc := ""
|
|
if b.ImapPassword != "" {
|
|
enc, err := services.CifrarSecretoUmind(b.ImapPassword)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
passEnc = enc
|
|
} else if b.ID > 0 {
|
|
if actual, err := models.GetSoporteWebhookActivo(); err == nil && actual != nil && actual.ID == b.ID {
|
|
passEnc = actual.ImapPasswordEnc
|
|
}
|
|
}
|
|
|
|
cfg := &models.SoporteWebhookConfig{
|
|
Nombre: b.Nombre,
|
|
Provider: b.Provider,
|
|
ApiKey: b.ApiKey,
|
|
EmailDestino: b.EmailDestino,
|
|
ResponderAuto: b.ResponderAuto,
|
|
MensajeAuto: b.MensajeAuto,
|
|
AsignarA: b.AsignarA,
|
|
SmtpHost: b.SmtpHost,
|
|
SmtpPort: port,
|
|
SmtpUsername: b.SmtpUsername,
|
|
SmtpPassword: b.SmtpPassword,
|
|
SmtpEncryption: enc,
|
|
SmtpFromAddr: b.SmtpFromAddr,
|
|
SmtpFromName: b.SmtpFromName,
|
|
ImapActivo: b.ImapActivo,
|
|
ImapHost: b.ImapHost,
|
|
ImapPort: imapPort,
|
|
ImapUsername: b.ImapUsername,
|
|
ImapPasswordEnc: passEnc,
|
|
ImapEncryption: imapEnc,
|
|
ImapCarpeta: carpeta,
|
|
ImapHorasAtras: b.ImapHorasAtras,
|
|
ClasificarConIA: b.ClasificarConIA,
|
|
ContextoNegocio: b.ContextoNegocio,
|
|
AgenteBorradorID: b.AgenteBorradorID,
|
|
Activo: true,
|
|
}
|
|
cfg.ID = b.ID
|
|
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// ProbarImapSoporte valida las credenciales del buzón contra el servidor.
|
|
// POST /app/api/soporte-webhook/probar-imap
|
|
func ProbarImapSoporte(c *fiber.Ctx) error {
|
|
cfg, err := models.GetSoporteWebhookActivo()
|
|
if err != nil || cfg == nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
|
|
}
|
|
detalle, err := services.ProbarConexionImap(cfg)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true, "message": detalle})
|
|
}
|
|
|
|
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
|
|
// POST /app/api/soporte-webhook/revisar-buzon
|
|
func RevisarBuzonAhora(c *fiber.Ctx) error {
|
|
encontrados, procesados, err := services.RevisarBuzonSoporteConDetalle()
|
|
if err != nil {
|
|
if encontrados > 0 {
|
|
// Se hizo trabajo igual: decir las dos cosas, no solo el fallo.
|
|
return c.Status(200).JSON(fiber.Map{"ok": false, "message": fmt.Sprintf(
|
|
"%d correo(s) encontrados, %d convertido(s) en ticket — %s", encontrados, procesados, err.Error())})
|
|
}
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if encontrados == 0 {
|
|
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
|
|
"%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)})
|
|
}
|
|
|
|
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
|
|
// No envía nada: el borrador vuelve al cuadro de respuesta para que lo revise
|
|
// una persona.
|
|
// POST /app/tickets/:ticketID/borrador
|
|
func BorradorRespuestaTicket(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
|
}
|
|
ticket, err := models.GetTicketByID(uint(id))
|
|
if err != nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
|
|
}
|
|
borrador, err := services.RedactarBorradorTicket(ticket)
|
|
if err != nil {
|
|
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"borrador": borrador})
|
|
}
|
|
|
|
// GetAgentesParaBorrador lista los agentes de uMind para elegir de cuál sale la
|
|
// documentación de los borradores.
|
|
// GET /app/soporte/agentes
|
|
func GetAgentesParaBorrador(c *fiber.Ctx) error {
|
|
items, err := models.GetTodosLosUmindAgentes()
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
salida := make([]fiber.Map, 0, len(items))
|
|
for _, a := range items {
|
|
salida = append(salida, fiber.Map{"id": a.ID, "nombre": a.Nombre})
|
|
}
|
|
return c.JSON(salida)
|
|
}
|