package services import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) var umindWhatsappHTTPClient = &http.Client{Timeout: 20 * time.Second} const whatsappGraphAPIVersion = "v21.0" // ValidarFirmaWhatsApp valida X-Hub-Signature-256 — es la única autenticación // real del webhook de WhatsApp (a diferencia del widget, que solo valida // Origin/Referer). Meta firma el body crudo con HMAC-SHA256 usando el App // Secret; sin validar esto, cualquiera que adivine la URL del webhook podría // mandar mensajes falsos a nombre de un visitante. func ValidarFirmaWhatsApp(appSecret string, body []byte, signatureHeader string) bool { const prefix = "sha256=" if !strings.HasPrefix(signatureHeader, prefix) { return false } esperada, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, prefix)) if err != nil { return false } mac := hmac.New(sha256.New, []byte(appSecret)) mac.Write(body) return hmac.Equal(mac.Sum(nil), esperada) } // ProcesarMensajeWhatsAppUmind adapta un mensaje entrante de WhatsApp Business // Cloud API al mismo motor que atiende el widget web y Telegram. func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string) error { agente, err := models.GetUmindAgenteByID(canal.AgenteID) if err != nil || !agente.Activo { return fmt.Errorf("agente no encontrado o inactivo: %w", err) } credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc) if err != nil { return fmt.Errorf("credenciales del canal corruptas: %w", err) } return responderWhatsApp(agente, credenciales, from, texto) } // ProcesarMediaWhatsAppUmind atiende audios/imágenes entrantes: si el canal // tiene la conversión habilitada, descarga el archivo desde la Graph API, // lo pasa por Whisper/OCR y sigue el mismo camino que un mensaje de texto. // Si la conversión no está habilitada para ese tipo, se ignora en silencio // (mismo comportamiento de antes de que existiera esta función). func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error { return ProcesarMediaWhatsAppUmindConNombre(canal, from, mediaID, tipo, "", "") } // ProcesarMediaWhatsAppUmindConNombre es la versión completa: los documentos // traen nombre de archivo y, a veces, un texto que los acompaña (caption). func ProcesarMediaWhatsAppUmindConNombre(canal *models.UmindCanal, from, mediaID, tipo, nombreArchivo, caption string) error { agente, err := models.GetUmindAgenteByID(canal.AgenteID) if err != nil || !agente.Activo { return fmt.Errorf("agente no encontrado o inactivo: %w", err) } credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc) if err != nil { return fmt.Errorf("credenciales del canal corruptas: %w", err) } var texto string switch tipo { case "audio": if !canal.UsarWhisperAudio { return nil } data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID) if err != nil { return fmt.Errorf("no se pudo descargar el audio de WhatsApp: %w", err) } texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg") if err != nil { return err } case "image": if !canal.UsarOcrImagenes { return nil } data, mimeType, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID) if err != nil { return fmt.Errorf("no se pudo descargar la imagen de WhatsApp: %w", err) } if mimeType == "" { mimeType = "image/jpeg" } texto, err = ExtraerTextoOCR(canal.AgenteID, data, mimeType) if err != nil { return err } case "document": if !canal.UsarArchivosDocs { return nil } data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID) if err != nil { return fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err) } texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data) if err != nil { return err } texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto) default: return nil } if strings.TrimSpace(texto) == "" { return fmt.Errorf("no se pudo extraer texto del %s recibido", tipo) } return responderWhatsApp(agente, credenciales, from, texto) } func responderWhatsApp(agente *models.UmindAgente, credenciales map[string]string, from, texto string) error { phoneNumberID := credenciales["phone_number_id"] accessToken := credenciales["access_token"] if phoneNumberID == "" || accessToken == "" { return fmt.Errorf("el canal no tiene phone_number_id/access_token configurados") } sessionID := fmt.Sprintf("wa:%s", from) respuesta, err := ProcessWidgetMessage(agente, sessionID, texto) if err != nil { return fmt.Errorf("error del agente: %w", err) } return enviarMensajeWhatsApp(phoneNumberID, accessToken, from, respuesta) } // descargarMediaWhatsApp resuelve la URL temporal de un media_id (paso 1) y // descarga el archivo (paso 2) — la Graph API de WhatsApp requiere ambos, y // las dos llamadas necesitan el mismo Bearer token del canal. func descargarMediaWhatsApp(accessToken, mediaID string) ([]byte, string, error) { if accessToken == "" || mediaID == "" { return nil, "", fmt.Errorf("access_token/media_id vacíos") } metaURL := fmt.Sprintf("https://graph.facebook.com/%s/%s", whatsappGraphAPIVersion, mediaID) req, err := http.NewRequest(http.MethodGet, metaURL, nil) if err != nil { return nil, "", err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := umindWhatsappHTTPClient.Do(req) if err != nil { return nil, "", fmt.Errorf("no se pudo consultar el media en WhatsApp: %w", err) } defer resp.Body.Close() var meta struct { URL string `json:"url"` MimeType string `json:"mime_type"` } if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil || meta.URL == "" { return nil, "", fmt.Errorf("respuesta inesperada al consultar el media de WhatsApp") } req2, err := http.NewRequest(http.MethodGet, meta.URL, nil) if err != nil { return nil, "", err } req2.Header.Set("Authorization", "Bearer "+accessToken) resp2, err := umindWhatsappHTTPClient.Do(req2) if err != nil { return nil, "", fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err) } defer resp2.Body.Close() data, err := io.ReadAll(io.LimitReader(resp2.Body, 20*1024*1024)) if err != nil { return nil, "", err } return data, meta.MimeType, nil } func enviarMensajeWhatsApp(phoneNumberID, accessToken, to, texto string) error { payload := map[string]interface{}{ "messaging_product": "whatsapp", "to": to, "type": "text", // preview_url hace que WhatsApp renderice el primer enlace del mensaje // como tarjeta clickeable con título e imagen, en vez de dejarlo como // texto pelado. Sin esto los enlaces que manda el agente se ven crudos. "text": map[string]interface{}{"body": texto, "preview_url": true}, } body, err := json.Marshal(payload) if err != nil { return err } url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", whatsappGraphAPIVersion, phoneNumberID) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := umindWhatsappHTTPClient.Do(req) if err != nil { return fmt.Errorf("no se pudo contactar la API de WhatsApp: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("WhatsApp respondió %d", resp.StatusCode) } return nil }