Faltaba la tercera pata: audio pasaba por Whisper, imagen por OCR, y un PDF o
un Word adjunto se ignoraba en silencio. Ahora hay un interruptor por canal
—"Leer archivos adjuntos"— y los documentos que llegan por Telegram o WhatsApp
se convierten a texto antes de pasar al agente.
PDF se lee con stdlib cuando el documento es digital (facturas, cotizaciones,
lo exportado por cualquier programa) y cae al OCR si es un escaneo. Word .docx
es un zip con XML adentro; texto plano, CSV y JSON van directo.
El agente recibe el contenido con contexto ("el cliente adjuntó X, y escribió
Y"), no el chorizo pelado: sin eso el modelo contesta como si el cliente
hubiera tipeado una factura.
De paso la importación de plantillas gana PDF, que usa el mismo extractor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
6.9 KiB
Go
202 lines
6.9 KiB
Go
package controllers
|
|
|
|
import (
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// ─── Telegram ────────────────────────────────────────────────────────────────
|
|
|
|
type umindTgChat struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
type umindTgFileRef struct {
|
|
FileID string `json:"file_id"`
|
|
FileName string `json:"file_name"` // solo lo traen los documentos
|
|
}
|
|
|
|
type umindTgMessage struct {
|
|
Chat umindTgChat `json:"chat"`
|
|
Text string `json:"text"`
|
|
Voice *umindTgFileRef `json:"voice"`
|
|
Audio *umindTgFileRef `json:"audio"`
|
|
Photo []umindTgFileRef `json:"photo"`
|
|
Document *umindTgFileRef `json:"document"`
|
|
Caption string `json:"caption"`
|
|
}
|
|
|
|
type umindTgUpdate struct {
|
|
Message *umindTgMessage `json:"message"`
|
|
}
|
|
|
|
// UmindTelegramWebhook recibe updates del bot de Telegram de un agente.
|
|
// Ruta: POST /webhooks/umind-telegram/:webhook_secret
|
|
// El webhook_secret es un identificador nuestro (no el bot token real) —
|
|
// ver el comentario en models.UmindCanal.
|
|
func UmindTelegramWebhook(c *fiber.Ctx) error {
|
|
secret := c.Params("webhook_secret")
|
|
canal, err := models.GetUmindCanalByWebhookSecret("telegram", secret)
|
|
if err != nil {
|
|
return c.SendStatus(fiber.StatusOK) // siempre 200 a Telegram, aunque no matchee
|
|
}
|
|
|
|
var update umindTgUpdate
|
|
if err := c.BodyParser(&update); err != nil || update.Message == nil {
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
msg := update.Message
|
|
|
|
switch {
|
|
case msg.Voice != nil && msg.Voice.FileID != "":
|
|
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Voice.FileID, "audio")
|
|
case msg.Audio != nil && msg.Audio.FileID != "":
|
|
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
|
|
case len(msg.Photo) > 0:
|
|
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
|
|
case msg.Document != nil && msg.Document.FileID != "":
|
|
err = services.ProcesarMediaTelegramUmindConNombre(canal, msg.Chat.ID, msg.Document.FileID, "document", msg.Document.FileName, msg.Caption)
|
|
default:
|
|
texto := strings.TrimSpace(msg.Text)
|
|
if texto == "" {
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
err = services.ProcesarMensajeTelegramUmind(canal, msg.Chat.ID, texto)
|
|
}
|
|
if err != nil {
|
|
log.Printf("[UMIND_TELEGRAM] canal %d: %v", canal.ID, err)
|
|
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_telegram", "Error procesando un mensaje de Telegram", err.Error())
|
|
}
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
|
|
// ─── WhatsApp ────────────────────────────────────────────────────────────────
|
|
|
|
type umindWaMediaRef struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type umindWaMessage struct {
|
|
From string `json:"from"`
|
|
Type string `json:"type"`
|
|
Text struct {
|
|
Body string `json:"body"`
|
|
} `json:"text"`
|
|
Image umindWaMediaRef `json:"image"`
|
|
Audio umindWaMediaRef `json:"audio"`
|
|
Document struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
Caption string `json:"caption"`
|
|
} `json:"document"`
|
|
}
|
|
|
|
type umindWaValue struct {
|
|
Metadata struct {
|
|
PhoneNumberID string `json:"phone_number_id"`
|
|
} `json:"metadata"`
|
|
Messages []umindWaMessage `json:"messages"`
|
|
}
|
|
|
|
type umindWaChange struct {
|
|
Value umindWaValue `json:"value"`
|
|
}
|
|
|
|
type umindWaEntry struct {
|
|
Changes []umindWaChange `json:"changes"`
|
|
}
|
|
|
|
type umindWaPayload struct {
|
|
Entry []umindWaEntry `json:"entry"`
|
|
}
|
|
|
|
// UmindWhatsAppVerify atiende el handshake de verificación que Meta hace al
|
|
// configurar el webhook (hub.mode/hub.verify_token/hub.challenge).
|
|
// Ruta: GET /webhooks/umind-whatsapp/:webhook_secret
|
|
func UmindWhatsAppVerify(c *fiber.Ctx) error {
|
|
secret := c.Params("webhook_secret")
|
|
canal, err := models.GetUmindCanalByWebhookSecret("whatsapp", secret)
|
|
if err != nil {
|
|
return c.SendStatus(fiber.StatusForbidden)
|
|
}
|
|
credenciales, err := services.DescifrarCredencialesCanal(canal.CredencialesEnc)
|
|
if err != nil {
|
|
return c.SendStatus(fiber.StatusForbidden)
|
|
}
|
|
|
|
if c.Query("hub.mode") != "subscribe" || c.Query("hub.verify_token") != credenciales["verify_token"] || credenciales["verify_token"] == "" {
|
|
return c.SendStatus(fiber.StatusForbidden)
|
|
}
|
|
return c.SendString(c.Query("hub.challenge"))
|
|
}
|
|
|
|
// UmindWhatsAppWebhook recibe mensajes entrantes de WhatsApp Business Cloud
|
|
// API. La única autenticación real acá es la firma HMAC del body — el
|
|
// webhook_secret en la URL identifica el canal, pero no alcanza solo.
|
|
// Ruta: POST /webhooks/umind-whatsapp/:webhook_secret
|
|
func UmindWhatsAppWebhook(c *fiber.Ctx) error {
|
|
secret := c.Params("webhook_secret")
|
|
canal, err := models.GetUmindCanalByWebhookSecret("whatsapp", secret)
|
|
if err != nil {
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
credenciales, err := services.DescifrarCredencialesCanal(canal.CredencialesEnc)
|
|
if err != nil {
|
|
log.Printf("[UMIND_WHATSAPP] canal %d: credenciales corruptas: %v", canal.ID, err)
|
|
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Credenciales del canal corruptas", err.Error())
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
|
|
if !services.ValidarFirmaWhatsApp(credenciales["app_secret"], c.Body(), c.Get("X-Hub-Signature-256")) {
|
|
log.Printf("[UMIND_WHATSAPP] canal %d: firma inválida", canal.ID)
|
|
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Firma X-Hub-Signature-256 inválida en un webhook entrante", "")
|
|
return c.SendStatus(fiber.StatusUnauthorized)
|
|
}
|
|
|
|
var payload umindWaPayload
|
|
if err := c.BodyParser(&payload); err != nil {
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|
|
for _, entry := range payload.Entry {
|
|
for _, change := range entry.Changes {
|
|
for _, msg := range change.Value.Messages {
|
|
var err error
|
|
switch msg.Type {
|
|
case "text":
|
|
texto := strings.TrimSpace(msg.Text.Body)
|
|
if texto == "" {
|
|
continue
|
|
}
|
|
err = services.ProcesarMensajeWhatsAppUmind(canal, msg.From, texto)
|
|
case "audio":
|
|
if msg.Audio.ID == "" {
|
|
continue
|
|
}
|
|
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Audio.ID, "audio")
|
|
case "image":
|
|
if msg.Image.ID == "" {
|
|
continue
|
|
}
|
|
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
|
|
case "document":
|
|
if msg.Document.ID == "" {
|
|
continue
|
|
}
|
|
err = services.ProcesarMediaWhatsAppUmindConNombre(canal, msg.From, msg.Document.ID, "document", msg.Document.Filename, msg.Document.Caption)
|
|
default:
|
|
continue
|
|
}
|
|
if err != nil {
|
|
log.Printf("[UMIND_WHATSAPP] canal %d: %v", canal.ID, err)
|
|
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_whatsapp", "Error procesando un mensaje de WhatsApp", err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return c.SendStatus(fiber.StatusOK)
|
|
}
|