Files
soft_usite/rest/controllers/umind_admin_controller.go
T
Lizandro GuarnizoandClaude Opus 5 5086087e1b feat(umind): duplicar un agente para usarlo como plantilla del siguiente
El catálogo de rubros da un arranque genérico, pero el mejor punto de partida
para el segundo restaurante es el primero — el que ya tiene el conocimiento
real, el tono ajustado y las herramientas andando. Ahora se puede copiar: se
lleva conocimiento, tono, bienvenida, color, config de IA y herramientas.

Tres cosas no se copian, y es a propósito:

Los canales. Llevan las credenciales de una cuenta concreta de WhatsApp o
Telegram; copiarlas haría que dos agentes contesten por el mismo número.

Las claves de las herramientas. Son secretos de un tercero, atados a una
cuenta. La copia llega sin ellas y, si la herramienta las necesitaba, llega
desactivada — para que la falta se note al configurarla y no cuando un cliente
recibe un error.

La site_key. Es la identidad pública del widget y tiene índice único:
compartirla sería servir dos agentes distintos bajo el mismo nombre.

El conocimiento se rehace desde cero en vez de copiar los vectores. Es más
lento, pero los chunks viejos pueden venir de otro modelo de embeddings, y
mezclar vectores de modelos distintos rompe la comparación por similitud —
la búsqueda devolvería cualquier cosa.

Permisos: se verifica el acceso al agente de origen y también al tenant
destino. Copiar es crear, y crear en un tenant ajeno tampoco corresponde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:28:15 -05:00

1091 lines
41 KiB
Go

package controllers
import (
"bytes"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// UmindIndex renderiza el panel de administración de uMind.
func UmindIndex(c *fiber.Ctx) error {
return c.Render("umind", fiber.Map{
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}, "layouts/main")
}
var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// umindColor valida el hex del color de marca del widget; si viene vacío o
// inválido cae al verde por defecto en vez de guardar basura (el valor se
// aplica tal cual como CSS custom property en el widget embebido).
func umindColor(color string) string {
color = strings.TrimSpace(color)
if umindColorHexRegex.MatchString(color) {
return color
}
return "#8eb02f"
}
// ─── Tenants ─────────────────────────────────────────────────────────────────
// Un tenant es el negocio/sitio dueño de los dominios permitidos — la config
// de IA, tono, tools, etc. viven en sus UmindAgente (ver más abajo).
func GetUmindTenants(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
items, total, err := models.GetAllUmindTenants(limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items = tenantsVisibles(c, items)
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
// Para el cliente la paginación global no tiene sentido (ve pocos
// tenants); el total pasa a ser el de lo que realmente puede ver.
total = int64(len(items))
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
type umindTenantReq struct {
Nombre string `json:"nombre"`
DominiosPermitidos []string `json:"dominios_permitidos"`
Activo bool `json:"activo"`
ClienteID *uint `json:"cliente_id"`
PlanID *uint `json:"plan_id"`
}
func (r umindTenantReq) dominiosLimpios() []string {
var out []string
for _, d := range r.DominiosPermitidos {
d = strings.ToLower(strings.TrimSpace(d))
if d != "" {
out = append(out, d)
}
}
return out
}
func CreateUmindTenantHandler(c *fiber.Ctx) error {
var req umindTenantReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
dominios := req.dominiosLimpios()
if len(dominios) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agrega al menos un dominio permitido"})
}
tenant := &models.UmindTenant{
Nombre: strings.TrimSpace(req.Nombre),
DominiosPermitidos: strings.Join(dominios, ","),
Activo: true,
CreadoPorID: extraerUserID(c),
ClienteID: req.ClienteID,
PlanID: req.PlanID,
}
if err := models.CreateUmindTenant(tenant); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": tenant.ID})
}
func UpdateUmindTenantHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
var req umindTenantReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
dominios := req.dominiosLimpios()
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"dominios_permitidos": strings.Join(dominios, ","),
"activo": req.Activo,
"cliente_id": req.ClienteID,
"plan_id": req.PlanID,
}
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindTenantHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoTenant(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindTenant(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Agentes ─────────────────────────────────────────────────────────────────
// Un tenant puede tener varios agentes independientes (ej. "Ventas",
// "Soporte"), cada uno con su propia config de IA, base de conocimiento,
// tools, canales y conexión de correo.
func GetUmindAgentesHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// El cupo viaja con la lista para que la pantalla pueda mostrar "1 de 1"
// en vez de dejar que el límite aparezca recién como un error al guardar.
// Sin plan no hay límite (ver verificarCupoAgentes), y eso también hay que
// poder verlo: si no, un tenant sin plan parece un límite que no funciona.
ids := make([]uint, len(items))
for i, a := range items {
ids[i] = a.ID
}
resp := fiber.Map{
"items": items,
"usados": len(items),
"resumen": models.GetResumenAgentes(ids),
}
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
resp["plan"] = fiber.Map{"nombre": plan.Nombre, "max_agentes": plan.MaxAgentes}
}
return c.JSON(resp)
}
type umindAgenteReq struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
AiConfigID *uint `json:"ai_config_id"`
Tono string `json:"tono"`
MensajeBienvenida string `json:"mensaje_bienvenida"`
Color string `json:"color"`
Activo bool `json:"activo"`
// PlantillaRubro precarga el conocimiento base de un rubro al crear el
// agente. Vacío = agente en blanco, como era antes.
PlantillaRubro string `json:"plantilla_rubro"`
}
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
var req umindAgenteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.TenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
}
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
return err
}
agente := &models.UmindAgente{
TenantID: req.TenantID,
Nombre: strings.TrimSpace(req.Nombre),
AiConfigID: req.AiConfigID,
Tono: req.Tono,
MensajeBienvenida: req.MensajeBienvenida,
Color: umindColor(req.Color),
Activo: true,
}
if err := models.CreateUmindAgente(agente); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// El conocimiento de la plantilla se carga en segundo plano: cada nota
// necesita sus embeddings y el agente ya existe. Hacer esperar el alta por
// esto sería castigar justo al que eligió una plantilla.
if clave := strings.TrimSpace(req.PlantillaRubro); clave != "" {
go services.AplicarPlantillaRubro(agente.ID, clave)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
}
func UpdateUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
var req umindAgenteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"ai_config_id": req.AiConfigID,
"tono": req.Tono,
"mensaje_bienvenida": req.MensajeBienvenida,
"color": umindColor(req.Color),
"activo": req.Activo,
}
if err := models.UpdateUmindAgente(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindAgente(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Documentos / ingesta ────────────────────────────────────────────────────
func GetUmindDocumentosHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// CreateUmindDocumentoHandler registra una nueva fuente (por ahora solo URL a
// crawlear) y dispara la ingesta en segundo plano — puede tardar varios
// segundos/minutos según cuántas páginas tenga el sitio.
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
URL string `json:"url"`
MaxPaginas int `json:"max_paginas"`
AutoActualizar bool `json:"auto_actualizar"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if strings.TrimSpace(req.URL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
}
if _, err := models.GetUmindAgenteByID(req.AgenteID); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
}
doc := &models.UmindDocumento{
AgenteID: req.AgenteID,
Tipo: "url",
Origen: strings.TrimSpace(req.URL),
Estado: "procesando",
MaxPaginas: req.MaxPaginas,
AutoActualizar: req.AutoActualizar,
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarAgente(req.AgenteID, doc.ID, doc.Origen, req.MaxPaginas)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
func DeleteUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Conversaciones ───────────────────────────────────────────────────────────
func GetUmindSesionesHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindSesiones(uint(agenteID), 50)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
func GetUmindHistorialHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
sessionID := c.Query("session_id")
if sessionID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
}
items, err := models.GetUmindHistorial(uint(agenteID), sessionID, 200)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// ─── Tools custom (webhooks) ───────────────────────────────────────────────
var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
func GetUmindHerramientasHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// No se devuelve el secreto cifrado ni el descifrado — solo si hay uno configurado.
out := make([]fiber.Map, len(items))
for i, h := range items {
out[i] = fiber.Map{
"ID": h.ID, "agente_id": h.AgenteID, "nombre": h.Nombre, "descripcion": h.Descripcion,
"parametros_json": h.ParametrosJSON, "url": h.URL, "auth_header_nombre": h.AuthHeaderNombre,
"auth_configurado": h.AuthHeaderValorEnc != "", "activa": h.Activa,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindHerramientaReq struct {
AgenteID uint `json:"agente_id"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Parametros []models.UmindHerramientaParametro `json:"parametros"`
URL string `json:"url"`
AuthHeaderNombre string `json:"auth_header_nombre"`
AuthHeaderValor *string `json:"auth_header_valor"` // nil = no tocar (en updates)
Activa bool `json:"activa"`
}
func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
var authEnc string
if req.AuthHeaderValor != nil && *req.AuthHeaderValor != "" {
authEnc, err = services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
h := &models.UmindHerramienta{
AgenteID: req.AgenteID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion),
ParametrosJSON: parametrosJSON, URL: strings.TrimSpace(req.URL),
AuthHeaderNombre: strings.TrimSpace(req.AuthHeaderNombre), AuthHeaderValorEnc: authEnc, Activa: true,
}
if err := models.CreateUmindHerramienta(h); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": h.ID})
}
func UpdateUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
var req umindHerramientaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if !umindNombreToolRegex.MatchString(req.Nombre) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
}
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
}
updates := map[string]interface{}{
"nombre": req.Nombre, "descripcion": strings.TrimSpace(req.Descripcion),
"parametros_json": parametrosJSON, "url": strings.TrimSpace(req.URL),
"auth_header_nombre": strings.TrimSpace(req.AuthHeaderNombre), "activa": req.Activa,
}
if req.AuthHeaderValor != nil {
if *req.AuthHeaderValor == "" {
updates["auth_header_valor_enc"] = ""
} else {
enc, err := services.CifrarSecretoUmind(*req.AuthHeaderValor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["auth_header_valor_enc"] = enc
}
}
if err := models.UpdateUmindHerramienta(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindHerramientaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoHerramienta(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Canales (Telegram / WhatsApp) ─────────────────────────────────────────
func GetUmindCanalesHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, canal := range items {
webhookURL := ""
if canal.Tipo == "telegram" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
} else if canal.Tipo == "whatsapp" {
webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret)
}
out[i] = fiber.Map{
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
"usar_archivos_docs": canal.UsarArchivosDocs,
}
}
return c.JSON(fiber.Map{"items": out})
}
type umindCanalReq struct {
AgenteID uint `json:"agente_id"`
Tipo string `json:"tipo"`
Credenciales map[string]string `json:"credenciales"`
Activo bool `json:"activo"`
UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
UsarArchivosDocs bool `json:"usar_archivos_docs"`
}
func CreateUmindCanalHandler(c *fiber.Ctx) error {
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if req.AgenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
switch req.Tipo {
case "telegram":
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bot_token requerido"})
}
case "whatsapp":
for _, k := range []string{"phone_number_id", "access_token", "app_secret", "verify_token"} {
if strings.TrimSpace(req.Credenciales[k]) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
}
}
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"})
}
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
UsarArchivosDocs: req.UsarArchivosDocs,
}
if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if req.Tipo == "telegram" {
webhookURL := fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
if err := services.RegistrarWebhookTelegram(req.Credenciales["bot_token"], webhookURL); err != nil {
models.UpdateUmindCanal(canal.ID, map[string]interface{}{"ultimo_error": err.Error()})
}
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": canal.ID, "webhook_secret": canal.WebhookSecret})
}
func UpdateUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
var req umindCanalReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
updates["credenciales_enc"] = enc
updates["ultimo_error"] = ""
}
if err := models.UpdateUmindCanal(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
func DeleteUmindCanalHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoCanal(c, uint(id)); err != nil {
return err
}
if err := models.DeleteUmindCanal(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ─── Chat de prueba ─────────────────────────────────────────────────────────
// UmindChatPruebaHandler deja que el staff pruebe un agente puntual directo
// desde el panel, sin pasar por site_key/dominio (ya está gateado por la
// sesión con la que se llega acá).
func UmindChatPruebaHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
SessionID string `json:"session_id"`
Mensaje string `json:"mensaje"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
}
agente, errAcceso := accesoAgente(c, req.AgenteID)
if errAcceso != nil {
return errAcceso
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10)
}
respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
}
// ─── Auditoría ──────────────────────────────────────────────────────────────
// GetUmindEventosHandler expone el log de auditoría técnica de un agente —
// errores/eventos que antes solo se podían ver pidiendo los logs del
// servidor (fallos de AI, tools, correo, canales).
func GetUmindEventosHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
}
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"items": items})
}
// ─── Consumo ────────────────────────────────────────────────────────────────
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
// Sin fechas, el rango es el mes en curso.
func GetUmindUsoHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
total := 0.0
for _, r := range resumen {
total += r.Costo
}
out := fiber.Map{
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
"resumen": resumen, "detalle": detalle,
"total_periodo": total, "pendiente_facturar": pendiente,
}
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
out["plan"] = fiber.Map{
"nombre": plan.Nombre, "moneda": plan.Moneda,
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
"max_agentes": plan.MaxAgentes,
}
}
return c.JSON(out)
}
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
// romperle la pantalla al usuario por un query param mal escrito.
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
ahora := time.Now()
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
hasta := desde.AddDate(0, 1, 0)
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
desde = d
}
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
}
if !hasta.After(desde) {
hasta = desde.AddDate(0, 1, 0)
}
return desde, hasta
}
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
// al portal no sirve para eso; un archivo que se reenvía, sí.
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
func GetUmindReporteHandler(c *fiber.Ctx) error {
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
if err != nil || agenteID == 0 {
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
}
agente, errAcceso := accesoAgente(c, uint(agenteID))
if errAcceso != nil {
return errAcceso
}
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
negocio := ""
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
negocio = t.Nombre
}
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
filas := [][]string{
{"Reporte de actividad"},
{"Negocio", negocio},
{"Agente", agente.Nombre},
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
{},
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
{},
{"Consumo del período"},
{"Tipo", "Cantidad", "Unidad", "Usos"},
}
etiquetas := map[string]string{
models.UsoTipoIA: "Inteligencia artificial",
models.UsoTipoOCR: "Lectura de imágenes",
models.UsoTipoWhisper: "Transcripción de audio",
}
for _, r := range resumen {
nombre := etiquetas[r.Tipo]
if nombre == "" {
nombre = r.Tipo
}
filas = append(filas, []string{
nombre,
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
r.Unidad,
strconv.FormatInt(r.Eventos, 10),
})
}
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
for _, s := range sesiones {
filas = append(filas, []string{
s.Inicio.Format("02/01/2006 15:04"),
strconv.FormatInt(s.Mensajes, 10),
s.PrimerMensaje,
})
}
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
var buf bytes.Buffer
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
return c.Status(500).SendString("no se pudo generar el reporte")
}
return c.Send(buf.Bytes())
}
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
// acentos o espacios rompe el filename en algunos navegadores.
func slugSimple(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "agente"
}
return out
}
// ─── Fuentes de conocimiento que no son una URL ─────────────────────────────
// CreateUmindTextoHandler guarda como conocimiento un texto escrito a mano.
// Es la fuente más valiosa y la que no está en ningún lado: horarios, qué no
// hacen, la respuesta a la pregunta que les hacen todos los días.
// POST /umind/documentos/texto
func CreateUmindTextoHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if strings.TrimSpace(req.Contenido) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "escribí algo para que el agente lo aprenda"})
}
titulo := strings.TrimSpace(req.Titulo)
if titulo == "" {
titulo = "Nota"
}
doc := &models.UmindDocumento{
AgenteID: req.AgenteID,
Tipo: "texto",
Origen: titulo,
Contenido: req.Contenido,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarTexto(req.AgenteID, doc.ID, req.Contenido)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
// CreateUmindArchivoHandler carga un archivo como conocimiento. Usa el mismo
// extractor que los adjuntos de los canales: PDF, Word, texto e imágenes.
// POST /umind/documentos/archivo (multipart: agente_id, archivo)
func CreateUmindArchivoHandler(c *fiber.Ctx) error {
agenteID, _ := strconv.ParseUint(c.FormValue("agente_id"), 10, 64)
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
archivo, err := c.FormFile("archivo")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
}
if archivo.Size > 20<<20 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 20 MB"})
}
f, err := archivo.Open()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
defer f.Close()
datos, err := io.ReadAll(io.LimitReader(f, 20<<20))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
// El texto se extrae acá y no en segundo plano: si el archivo no se puede
// leer, conviene decirlo mientras la persona está mirando la pantalla.
texto, err := services.ExtraerTextoDeArchivo(uint(agenteID), archivo.Filename, datos)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
doc := &models.UmindDocumento{
AgenteID: uint(agenteID),
Tipo: "archivo",
Origen: archivo.Filename,
Contenido: texto,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarTexto(uint(agenteID), doc.ID, texto)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
// ReprocesarUmindDocumentoHandler vuelve a leer una fuente. Para una URL la
// crawlea de nuevo —es la única forma de que el agente deje de contestar los
// precios del año pasado— y para el resto rearma los fragmentos del texto
// guardado.
// POST /umind/documentos/:id/reprocesar
func ReprocesarUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
doc, err := models.GetUmindDocumentoByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
}
if doc.Estado == "procesando" {
return c.Status(409).JSON(fiber.Map{"error": "esta fuente ya se está procesando"})
}
services.ReprocesarDocumento(*doc)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "estado": "procesando"})
}
// ActualizarUmindDocumentoHandler edita el texto de una fuente escrita a mano
// y la vuelve a procesar.
// PUT /umind/documentos/:id
func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
doc, err := models.GetUmindDocumentoByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
}
var req struct {
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
AutoActualizar *bool `json:"auto_actualizar"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{}
if req.AutoActualizar != nil {
updates["auto_actualizar"] = *req.AutoActualizar
}
reprocesar := false
if doc.Tipo == "texto" && strings.TrimSpace(req.Contenido) != "" {
updates["contenido"] = req.Contenido
if t := strings.TrimSpace(req.Titulo); t != "" {
updates["origen"] = t
}
doc.Contenido = req.Contenido
reprocesar = true
}
if len(updates) > 0 {
if err := models.UpdateUmindDocumento(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
if reprocesar {
services.ReprocesarDocumento(*doc)
}
return c.JSON(fiber.Map{"ok": true})
}
// GetPlantillasRubroHandler lista los puntos de partida disponibles para un
// agente nuevo.
// GET /umind/plantillas-rubro
func GetPlantillasRubroHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"items": services.ResumenPlantillasRubro()})
}
// DuplicarUmindAgenteHandler copia un agente configurado a otro tenant (o al
// mismo), con su conocimiento y sus herramientas. Es lo que permite usar un
// agente que ya funciona como plantilla del siguiente.
// POST /umind/agentes/:id/duplicar {tenant_id, nombre}
func DuplicarUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
// Acceso al origen: sin esto un cliente podría copiarse el agente de otro.
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
var req struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
destino := req.TenantID
if destino == 0 {
origen, err := models.GetUmindAgenteByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
}
destino = origen.TenantID
}
// Y acceso al destino: copiar es crear, y crear en un tenant ajeno tampoco.
if err := accesoTenant(c, destino); err != nil {
return err
}
if err := verificarCupoAgentes(c, destino); err != nil {
return err
}
copia, err := services.DuplicarAgente(uint(id), destino, strings.TrimSpace(req.Nombre))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"ok": true, "id": copia.ID, "site_key": copia.SiteKey,
"aviso": "El conocimiento se está copiando en segundo plano. Los canales y las claves de las herramientas no se copian: hay que configurarlos en la copia.",
})
}