El agente estaba dando https://escuelametropolitana.com/login.php, una ruta que no existe: el prompt decía "no inventes datos" pero no decía nada de URLs, teléfonos ni correos, que es justo lo que un modelo confabula con más confianza porque "sabe" que un login suele estar en /login.php. Un enlace inventado deja a la persona sin poder completar el trámite, peor que no responder. - Regla explícita: URLs, teléfonos, WhatsApp y correos solo si aparecen LITERALMENTE en los resultados de buscar_conocimiento, copiados carácter por carácter, sin completar rutas ni cambiar el dominio. - Widget: los enlaces se vuelven clickeables. El contenido sale de páginas crawleadas, así que NO se inserta como HTML: se parte por URL y cada trozo va con createTextNode o un <a> con rel=noopener noreferrer nofollow. Verificado que un <img onerror> o un <script> en la respuesta quedan escapados como texto inerte. - WhatsApp: preview_url=true, así el primer enlace se ve como tarjeta clickeable en vez de texto pelado. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
6.7 KiB
Go
200 lines
6.7 KiB
Go
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 {
|
|
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
|
|
}
|
|
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
|
|
}
|