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>
267 lines
8.7 KiB
Go
267 lines
8.7 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/google"
|
|
"golang.org/x/oauth2/microsoft"
|
|
)
|
|
|
|
const (
|
|
UmindOAuthGoogle = "google"
|
|
UmindOAuthMicrosoft = "microsoft"
|
|
)
|
|
|
|
// CorreoResumen es el formato común en el que enviar_correo/leer_bandeja
|
|
// devuelven un mensaje al agente, sin importar el proveedor real detrás.
|
|
type CorreoResumen struct {
|
|
De string `json:"de"`
|
|
Asunto string `json:"asunto"`
|
|
Fecha string `json:"fecha"`
|
|
Extracto string `json:"extracto"`
|
|
}
|
|
|
|
var umindOAuthHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
// firmarState / verificarState arman el parámetro state del flujo OAuth
|
|
// autoverificable (agenteID + nonce + HMAC con APP_KEY) — evita necesitar una
|
|
// tabla de "estados pendientes": si la firma es válida, el state no fue
|
|
// alterado desde que lo generamos nosotros.
|
|
func firmarState(agenteID uint) (string, error) {
|
|
nonce := make([]byte, 8)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
payload := fmt.Sprintf("%d.%s", agenteID, hex.EncodeToString(nonce))
|
|
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
|
mac.Write([]byte(payload))
|
|
return payload + "." + hex.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
func verificarState(state string) (uint, error) {
|
|
partes := strings.Split(state, ".")
|
|
if len(partes) != 3 {
|
|
return 0, fmt.Errorf("formato de state inválido")
|
|
}
|
|
payload := partes[0] + "." + partes[1]
|
|
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
|
|
mac.Write([]byte(payload))
|
|
esperada := hex.EncodeToString(mac.Sum(nil))
|
|
if !hmac.Equal([]byte(esperada), []byte(partes[2])) {
|
|
return 0, fmt.Errorf("firma de state inválida")
|
|
}
|
|
agenteID, err := strconv.ParseUint(partes[0], 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("agente_id inválido en state: %w", err)
|
|
}
|
|
return uint(agenteID), nil
|
|
}
|
|
|
|
func redirectURLOAuth(proveedor string) string {
|
|
return strings.TrimRight(app.Http.Server.Url, "/") + "/app/umind/conexiones/callback/" + proveedor
|
|
}
|
|
|
|
func oauth2ConfigPara(proveedor string) (*oauth2.Config, error) {
|
|
switch proveedor {
|
|
case UmindOAuthGoogle:
|
|
if app.Http.OAuth.GoogleClientID == "" || app.Http.OAuth.GoogleClientSecret == "" {
|
|
return nil, fmt.Errorf("Google OAuth no está configurado en el servidor (GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET)")
|
|
}
|
|
return &oauth2.Config{
|
|
ClientID: app.Http.OAuth.GoogleClientID,
|
|
ClientSecret: app.Http.OAuth.GoogleClientSecret,
|
|
RedirectURL: redirectURLOAuth(proveedor),
|
|
Scopes: []string{
|
|
"https://www.googleapis.com/auth/gmail.send",
|
|
"https://www.googleapis.com/auth/gmail.readonly",
|
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
},
|
|
Endpoint: google.Endpoint,
|
|
}, nil
|
|
case UmindOAuthMicrosoft:
|
|
if app.Http.OAuth.MSClientID == "" || app.Http.OAuth.MSClientSecret == "" {
|
|
return nil, fmt.Errorf("Microsoft OAuth no está configurado en el servidor (MS_OAUTH_CLIENT_ID/MS_OAUTH_CLIENT_SECRET)")
|
|
}
|
|
return &oauth2.Config{
|
|
ClientID: app.Http.OAuth.MSClientID,
|
|
ClientSecret: app.Http.OAuth.MSClientSecret,
|
|
RedirectURL: redirectURLOAuth(proveedor),
|
|
Scopes: []string{
|
|
"offline_access", "openid", "email",
|
|
"https://graph.microsoft.com/Mail.Send",
|
|
"https://graph.microsoft.com/Mail.Read",
|
|
},
|
|
Endpoint: microsoft.AzureADEndpoint("common"),
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("proveedor desconocido: %s", proveedor)
|
|
}
|
|
}
|
|
|
|
// IniciarConexionOAuth arma la URL de autorización a la que hay que
|
|
// redirigir al staff. prompt=consent en Google fuerza a que siempre vuelva
|
|
// un refresh_token (si no, Google solo lo manda la primera vez que el
|
|
// usuario autoriza la app, nunca más).
|
|
func IniciarConexionOAuth(proveedor string, agenteID uint) (string, error) {
|
|
cfg, err := oauth2ConfigPara(proveedor)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
state, err := firmarState(agenteID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
|
|
if proveedor == UmindOAuthGoogle {
|
|
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
|
|
}
|
|
return cfg.AuthCodeURL(state, opts...), nil
|
|
}
|
|
|
|
// CompletarConexionOAuth intercambia el code por tokens, identifica la
|
|
// cuenta conectada y guarda la conexión cifrada. Reemplaza cualquier
|
|
// conexión previa activa del agente (una cuenta de correo a la vez).
|
|
func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) {
|
|
agenteID, err := verificarState(state)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("state inválido: %w", err)
|
|
}
|
|
cfg, err := oauth2ConfigPara(proveedor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tok, err := cfg.Exchange(context.Background(), code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo intercambiar el código de autorización: %w", err)
|
|
}
|
|
if tok.RefreshToken == "" {
|
|
return nil, fmt.Errorf("el proveedor no devolvió un refresh_token — revocá el acceso de esta app en tu cuenta y volvé a conectar")
|
|
}
|
|
|
|
email, err := obtenerEmailDeCuenta(proveedor, tok.AccessToken)
|
|
if err != nil {
|
|
log.Printf("[UMIND_OAUTH] no se pudo obtener el email de la cuenta conectada (%s): %v", proveedor, err)
|
|
}
|
|
|
|
accessEnc, err := CifrarSecretoUmind(tok.AccessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refreshEnc, err := CifrarSecretoUmind(tok.RefreshToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := models.DesactivarConexionesDelAgente(agenteID); err != nil {
|
|
log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del agente %d: %v", agenteID, err)
|
|
}
|
|
conexion := &models.UmindConexion{
|
|
AgenteID: agenteID,
|
|
Proveedor: proveedor,
|
|
Email: email,
|
|
AccessTokenEnc: accessEnc,
|
|
RefreshTokenEnc: refreshEnc,
|
|
ExpiraEn: tok.Expiry,
|
|
Scopes: strings.Join(cfg.Scopes, " "),
|
|
Activo: true,
|
|
}
|
|
if err := models.CreateUmindConexion(conexion); err != nil {
|
|
return nil, err
|
|
}
|
|
return conexion, nil
|
|
}
|
|
|
|
func obtenerEmailDeCuenta(proveedor, accessToken string) (string, error) {
|
|
var url string
|
|
switch proveedor {
|
|
case UmindOAuthGoogle:
|
|
url = "https://www.googleapis.com/oauth2/v2/userinfo"
|
|
case UmindOAuthMicrosoft:
|
|
url = "https://graph.microsoft.com/v1.0/me"
|
|
default:
|
|
return "", fmt.Errorf("proveedor desconocido: %s", proveedor)
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
resp, err := umindOAuthHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var out struct {
|
|
Email string `json:"email"` // Google
|
|
Mail string `json:"mail"` // Microsoft
|
|
UserPrincipalName string `json:"userPrincipalName"` // Microsoft, fallback si "mail" viene vacío
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
if out.Email != "" {
|
|
return out.Email, nil
|
|
}
|
|
if out.Mail != "" {
|
|
return out.Mail, nil
|
|
}
|
|
return out.UserPrincipalName, nil
|
|
}
|
|
|
|
// RefrescarSiVence renueva el access token si está vencido o a menos de 2
|
|
// minutos de vencer, y persiste el nuevo valor cifrado. Se llama justo antes
|
|
// de usar la conexión (enviar/leer correo), no por un cron aparte — ver nota
|
|
// de alcance en el plan: si en la práctica hace falta refresco proactivo, se
|
|
// agrega por pkg/services/cron_service.go sin tocar esta función.
|
|
func RefrescarSiVence(conexion *models.UmindConexion) error {
|
|
if time.Now().Add(2 * time.Minute).Before(conexion.ExpiraEn) {
|
|
return nil
|
|
}
|
|
cfg, err := oauth2ConfigPara(conexion.Proveedor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
refreshToken, err := DescifrarSecretoUmind(conexion.RefreshTokenEnc)
|
|
if err != nil {
|
|
return fmt.Errorf("no se pudo descifrar el refresh_token: %w", err)
|
|
}
|
|
|
|
nuevo, err := cfg.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}).Token()
|
|
if err != nil {
|
|
return fmt.Errorf("no se pudo refrescar el token: %w", err)
|
|
}
|
|
|
|
accessEnc, err := CifrarSecretoUmind(nuevo.AccessToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]interface{}{"access_token_enc": accessEnc, "expira_en": nuevo.Expiry}
|
|
if nuevo.RefreshToken != "" && nuevo.RefreshToken != refreshToken {
|
|
if refreshEnc, err := CifrarSecretoUmind(nuevo.RefreshToken); err == nil {
|
|
updates["refresh_token_enc"] = refreshEnc
|
|
conexion.RefreshTokenEnc = refreshEnc
|
|
}
|
|
}
|
|
if err := models.UpdateUmindConexion(conexion.ID, updates); err != nil {
|
|
log.Printf("[UMIND_OAUTH] no se pudo persistir el refresh del token (conexión %d): %v", conexion.ID, err)
|
|
}
|
|
conexion.AccessTokenEnc = accessEnc
|
|
conexion.ExpiraEn = nuevo.Expiry
|
|
return nil
|
|
}
|