Files
soft_usite/pkg/services/umind_oauth_service.go
Lizandro GuarnizoandClaude Sonnet 5 f3f2f421d6 feat: uMind pasa a multi-agente por tenant
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>
2026-08-13 09:30:06 -05:00

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
}