Files
soft_usite/rest/controllers/umind_admin_controller.go
T
Lizandro GuarnizoandClaude Opus 5 be01d95631 feat(studio): la puesta en marcha guiada y una bandeja de conversaciones que informa
Dos mitades de la misma experiencia: el primer día y todos los días después.

El primer día. Un agente recién creado mostraba tres avisos ámbar sueltos —"no
atiende", "no sabe nada"— sin ningún orden: el dueño sabía que faltaban cosas
pero no cuál iba primero. Ahora hay una guía de tres pasos (contale de tu
negocio → hacele una pregunta → ponelo a atender) donde solo el siguiente paso
pendiente se resalta, porque una guía donde todo grita a la vez no guía nada.
Mientras falte algo, la guía reemplaza al estado — una sola voz por etapa; con
los pasos hechos desaparece para siempre y el estado operativo toma su lugar.

Todos los días. La bandeja de conversaciones mostraba el último mensaje de cada
sesión —casi siempre la respuesta del bot, "¡Hola! ¿En qué te ayudo?" cincuenta
veces— y el session_id crudo (tg:8812). Ahora cada conversación muestra lo que
el visitante preguntó al abrir, que es el dato que le importa al dueño: qué le
preguntan de verdad a su negocio. Con canal deducido del prefijo de la sesión
(WhatsApp, Telegram, Tu web, Prueba), cantidad de mensajes, y fecha relativa.

Las pruebas propias se separan detrás de un filtro: mezcladas inflan la lista y
el dueño no distingue cuáles son clientes reales. El contador del estado cuenta
solo las reales, por lo mismo — si arriba dice 4 y la lista muestra 3, el
número parece roto.

El hilo muestra la hora de cada mensaje, y el backend gana una consulta que
agrupa por sesión con el primer mensaje del visitante en vez de mandar filas
crudas; la vieja quedó sin usos y se fue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:40:39 -05:00

1095 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.GetUmindSesionesPanel(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
// "texto" son las notas escritas a mano; "archivo" guarda lo que se le
// extrajo al PDF o al Word, y corregir una extracción torcida a mano es
// tan válido como escribir la nota. Una URL no: su contenido se rehace
// crawleando, editarlo se perdería en la próxima actualización.
if (doc.Tipo == "texto" || doc.Tipo == "archivo") && 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.",
})
}