Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
6.5 KiB
Go
197 lines
6.5 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",
|
|
"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
|
|
}
|