SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS), reemplaza al panel Alpine.js como punto de entrada del menú. Backend, todo aditivo sobre el motor de uMind ya existente: - UmindHerramienta: tools custom por tenant que llaman un webhook HTTP, integradas al loop de function-calling existente. Cliente HTTP con guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el momento de conectar, no antes, para cerrar la ventana de DNS rebinding) que no existían en el proyecto. - UmindCanal: Telegram y WhatsApp Business Cloud API como canales adicionales del mismo agente que ya atiende el widget web, ambos reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256. Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa el proyecto para la contraseña SMTP (primer uso para secretos de uMind). - Se conecta middlewares.Limit() (rate limiter que existía pero no se usaba en ningún lado) al widget público y a los webhooks nuevos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
97 lines
3.0 KiB
Go
97 lines
3.0 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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 {
|
|
tenant, err := models.GetUmindTenantByID(canal.TenantID)
|
|
if err != nil || !tenant.Activo {
|
|
return fmt.Errorf("tenant no encontrado o inactivo: %w", err)
|
|
}
|
|
|
|
credenciales, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
|
|
if err != nil {
|
|
return fmt.Errorf("credenciales del canal corruptas: %w", err)
|
|
}
|
|
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(tenant, sessionID, texto)
|
|
if err != nil {
|
|
return fmt.Errorf("error del agente: %w", err)
|
|
}
|
|
|
|
return enviarMensajeWhatsApp(phoneNumberID, accessToken, from, respuesta)
|
|
}
|
|
|
|
func enviarMensajeWhatsApp(phoneNumberID, accessToken, to, texto string) error {
|
|
payload := map[string]interface{}{
|
|
"messaging_product": "whatsapp",
|
|
"to": to,
|
|
"type": "text",
|
|
"text": map[string]string{"body": texto},
|
|
}
|
|
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
|
|
}
|