Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con su propia config de IA, tono, base de conocimiento, tools, canales y conexión de correo. El site_key también pasa a ser por agente, así cada uno tiene su propio <script> de widget embebible y su propio color. Backend: - Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey, AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin usar, no se hace DROP COLUMN). - UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya tienen filas (ej. emetropolitana). - migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal" por cada tenant existente heredando lo que ya tenía configurado, y mueve sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo criterio que los Seed* — nada se rompe para los tenants ya en producción. - Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo ahora operan sobre UmindAgente; el tenant solo se consulta para el chequeo de dominio permitido y el nombre del negocio que ve el visitante. Frontend: nueva jerarquía de navegación tenant → lista de agentes (TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El modal de tenant en el sidebar se achica a nombre/dominios/activo. 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 {
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|