Los tickets del portal ya avisaban por Telegram; los que entran por correo solo mandaban un mail al admin, que es justo el canal que nadie mira a tiempo. Ahora pasan por el mismo despachador de notificaciones, así que se prenden y apagan desde /app/notif-config como cualquier otro evento — nuevo ticket y respuesta del cliente por separado. El cuerpo del aviso es un resumen cuando el correo pasa los 700 caracteres (hilos reenviados, texto pegado); abajo de eso va tal cual, porque resumir tres renglones es gastar una llamada de IA para decir lo mismo. Si la IA falla, se manda recortado. El ticket guarda siempre el texto completo. DispatchTicketNuevo ahora acepta portalUser nil: un correo no tiene usuario de portal detrás y no por eso hay que dejar de avisar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
6.5 KiB
Go
203 lines
6.5 KiB
Go
package services
|
|
|
|
import (
|
|
"log"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde
|
|
// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan
|
|
// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual.
|
|
type CorreoSoporte struct {
|
|
From string // puede venir como "Nombre <mail@x.com>"
|
|
FromName string
|
|
Subject string
|
|
Texto string
|
|
MessageID string
|
|
}
|
|
|
|
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
|
|
|
// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un
|
|
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
|
|
// duplicado o por venir vacío).
|
|
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
|
|
if e.From == "" || e.Subject == "" {
|
|
return false
|
|
}
|
|
|
|
// Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP
|
|
// puede releer un correo si falló el marcado como leído.
|
|
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
|
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
|
return false
|
|
}
|
|
|
|
fromEmail := ExtraerEmail(e.From)
|
|
fromName := e.FromName
|
|
if fromName == "" {
|
|
fromName = ExtraerNombre(e.From)
|
|
}
|
|
if fromName == "" {
|
|
fromName = fromEmail
|
|
}
|
|
|
|
contenido := strings.TrimSpace(e.Texto)
|
|
if len(contenido) > 5000 {
|
|
contenido = contenido[:5000]
|
|
}
|
|
|
|
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
|
|
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
|
|
// agrega como mensaje en vez de abrir un ticket nuevo.
|
|
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
|
|
msg := &models.TicketMensaje{
|
|
TicketID: hilo.ID,
|
|
Contenido: contenido,
|
|
EsAdmin: false,
|
|
AutorNombre: fromName,
|
|
MessageID: e.MessageID,
|
|
}
|
|
if err := models.CreateTicketMensaje(msg); err != nil {
|
|
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
|
return false
|
|
}
|
|
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
|
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
|
}
|
|
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
|
notificarTicketDeCorreo(hilo, contenido, true)
|
|
return true
|
|
}
|
|
|
|
ticket := &models.ProyectoTicket{
|
|
AutorNombre: fromName,
|
|
EmailFrom: fromEmail,
|
|
Titulo: e.Subject,
|
|
Descripcion: contenido,
|
|
Estado: "abierto",
|
|
Origen: "email",
|
|
MessageID: e.MessageID,
|
|
}
|
|
if cfg != nil && cfg.AsignarA != nil {
|
|
ticket.AsignadoA = cfg.AsignarA
|
|
}
|
|
if err := models.CreateProyectoTicket(ticket); err != nil {
|
|
log.Printf("[Soporte] Error creando ticket: %v", err)
|
|
return false
|
|
}
|
|
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
|
|
|
notificarTicketDeCorreo(ticket, contenido, false)
|
|
if cfg != nil && cfg.ResponderAuto {
|
|
SendSoporteAutoRespuesta(ticket)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
|
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
|
|
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
|
|
// adivinando el número), y si no hay token, por el último ticket abierto de ese
|
|
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
|
|
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
|
|
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
|
|
id, _ := strconv.ParseUint(m[1], 10, 32)
|
|
if id > 0 {
|
|
t, err := models.GetTicketByID(uint(id))
|
|
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
lower := strings.ToLower(strings.TrimSpace(subject))
|
|
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
|
|
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExtraerEmail saca la dirección de un "Nombre <mail@x.com>".
|
|
func ExtraerEmail(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
|
s = s[idx+1:]
|
|
}
|
|
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
|
s = s[:idx]
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// ExtraerNombre saca el nombre de un "Nombre <mail@x.com>" ("" si no trae).
|
|
func ExtraerNombre(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if idx := strings.Index(s, "<"); idx >= 0 {
|
|
return strings.TrimSpace(s[:idx])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// notificarTicketDeCorreo avisa al staff de un correo entrante por los canales
|
|
// que estén prendidos en /app/notif-config (in-app, correo y Telegram), con el
|
|
// mismo formato que los tickets del portal.
|
|
//
|
|
// El cuerpo que viaja en la notificación es el resumen, no el correo entero:
|
|
// el ticket guarda el texto completo igual.
|
|
func notificarTicketDeCorreo(ticket *models.ProyectoTicket, contenido string, esRespuesta bool) {
|
|
resumen := ResumirTextoSoporte(contenido)
|
|
origen := "Correo"
|
|
if ticket.EmailFrom != "" {
|
|
origen = "Correo · " + ticket.EmailFrom
|
|
}
|
|
|
|
if esRespuesta {
|
|
DispatchTicketRespuestaCliente(ticket, resumen, origen)
|
|
return
|
|
}
|
|
|
|
// Copia para no pisar el texto completo que ya se guardó en el ticket.
|
|
paraNotificar := *ticket
|
|
paraNotificar.Descripcion = resumen
|
|
|
|
if models.GetNotifConfig("ticket_nuevo", "admin") == nil {
|
|
// Sin configuración de notificaciones se mantiene lo de siempre: correo
|
|
// al admin. Si no, activar el módulo dejaría a alguien sin avisos.
|
|
SendSoporteNotifAdmin(¶Notificar)
|
|
return
|
|
}
|
|
DispatchTicketNuevo(¶Notificar, nil, origen)
|
|
}
|
|
|
|
// ResumirTextoSoporte deja el correo en algo que se pueda leer de un vistazo en
|
|
// Telegram. Los correos cortos van tal cual: pedirle a la IA que resuma tres
|
|
// renglones es gastar una llamada para decir lo mismo. Los largos —hilos
|
|
// reenviados, capturas pegadas— sí se resumen, y si la IA falla se recorta.
|
|
func ResumirTextoSoporte(texto string) string {
|
|
t := strings.TrimSpace(texto)
|
|
if len([]rune(t)) <= 700 {
|
|
return t
|
|
}
|
|
|
|
resumen, err := CompletarTextoIA("ia",
|
|
"Resumís correos de soporte para avisarle al equipo por Telegram. "+
|
|
"Máximo 3 renglones. Decí qué pide o reporta la persona y, si los hay, "+
|
|
"incluí datos concretos (número de factura, pedido, fecha, monto). "+
|
|
"Sin saludos, sin despedidas, sin repetir el asunto, sin inventar nada.",
|
|
t)
|
|
if err != nil {
|
|
log.Printf("[Soporte] No se pudo resumir el correo, se manda recortado: %v", err)
|
|
return string([]rune(t)[:700]) + "…"
|
|
}
|
|
if r := strings.TrimSpace(resumen); r != "" {
|
|
return r
|
|
}
|
|
return string([]rune(t)[:700]) + "…"
|
|
}
|