feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador

Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
  (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
  en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
  /callback/:proveedor intercambia el code (state autoverificable por HMAC,
  sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
  solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
  Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
  Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
  MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
  mensaje claro, no en silencio.

Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 10:25:13 -05:00
co-authored by Claude Sonnet 5
parent b2f6b518b6
commit 5ba41786d6
27 changed files with 1254 additions and 396 deletions
+100
View File
@@ -82,9 +82,51 @@ func umindTools(tenantID uint) []agentTool {
},
})
}
if conexion, err := models.GetUmindConexionActiva(tenantID); err == nil && conexion != nil {
tools = append(tools, umindEmailTools()...)
}
return tools
}
// umindEmailTools son las tools de correo, disponibles solo cuando el
// tenant tiene una cuenta conectada (UmindConexion activa) — nombres
// genéricos porque al modelo no le importa si detrás hay Gmail u Outlook.
func umindEmailTools() []agentTool {
return []agentTool{
{
Type: "function",
Function: agentToolFunc{
Name: "enviar_correo",
Description: "Envía un correo electrónico desde la cuenta de correo conectada del negocio.",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"destinatario": {Type: "string", Description: "Email del destinatario"},
"asunto": {Type: "string", Description: "Asunto del correo"},
"cuerpo": {Type: "string", Description: "Cuerpo del correo en texto plano"},
},
Required: []string{"destinatario", "asunto", "cuerpo"},
},
},
},
{
Type: "function",
Function: agentToolFunc{
Name: "leer_bandeja",
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
},
Required: []string{"consulta"},
},
},
},
}
}
// executeUmindTool ejecuta buscar_conocimiento (RAG interno) o, si el nombre
// no matchea, busca una UmindHerramienta custom del tenant y hace el POST al
// webhook configurado. Devuelve el resultado ya serializado, en el mismo
@@ -111,6 +153,10 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return string(b)
}
if name == "enviar_correo" || name == "leer_bandeja" {
return executeUmindEmailTool(tenantID, name, args)
}
herramienta, err := models.GetUmindHerramientaByNombre(tenantID, name)
if err != nil {
return fmt.Sprintf(`{"error": "herramienta desconocida: %s"}`, name)
@@ -131,6 +177,60 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return resultado
}
// executeUmindEmailTool despacha enviar_correo/leer_bandeja a Gmail o
// Microsoft Graph según el proveedor de la conexión activa del tenant,
// refrescando el token primero si hace falta.
func executeUmindEmailTool(tenantID uint, name string, args map[string]interface{}) string {
conexion, err := models.GetUmindConexionActiva(tenantID)
if err != nil {
return `{"error": "no hay ninguna cuenta de correo conectada"}`
}
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
switch name {
case "enviar_correo":
destinatario, _ := args["destinatario"].(string)
asunto, _ := args["asunto"].(string)
cuerpo, _ := args["cuerpo"].(string)
if strings.TrimSpace(destinatario) == "" || strings.TrimSpace(cuerpo) == "" {
return `{"error": "destinatario y cuerpo son requeridos"}`
}
var envErr error
if conexion.Proveedor == UmindOAuthGoogle {
envErr = EnviarCorreoGoogle(conexion, destinatario, asunto, cuerpo)
} else {
envErr = EnviarCorreoMicrosoft(conexion, destinatario, asunto, cuerpo)
}
if envErr != nil {
log.Printf("[UMIND] Error enviando correo (tenant %d): %v", tenantID, envErr)
return `{"error": "no se pudo enviar el correo"}`
}
return `{"ok": true}`
case "leer_bandeja":
consulta, _ := args["consulta"].(string)
var resultados []CorreoResumen
var lecErr error
if conexion.Proveedor == UmindOAuthGoogle {
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
} else {
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
}
if lecErr != nil {
log.Printf("[UMIND] Error leyendo bandeja (tenant %d): %v", tenantID, lecErr)
return `{"error": "no se pudo leer la bandeja"}`
}
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
return string(b)
default:
return `{"error": "herramienta desconocida"}`
}
}
// ProcessWidgetMessage procesa un mensaje del widget de uMind y devuelve la
// respuesta del agente. Es el equivalente de ProcessAgentMessage pero
// multi-tenant y con un toolset acotado a RAG (sin herramientas internas).