"Responder a: todos los que escriban / solo a estos remitentes" — direcciones completas o dominios, separados por coma. Un dominio (@miempresa.com) habilita a toda esa empresa; lo que no pasa el filtro se marca leído y no se contesta: decisión tomada, no correo pendiente que se relee en cada revisión. El match de dominio es contra el dominio entero, nunca por sufijo: "empresa.com" no habilita a alguien de "malaempresa.com" ni de un subdominio. Ese es el clásico filtro que parece cerrado y no lo está, y acá decide a quién le habla el agente en nombre del negocio — hay un test por cada agujero. El filtro y el intervalo también se pueden cambiar por el update del canal sin recrearlo, porque recrearlo pide la contraseña de la casilla de nuevo. De paso quedó verificado que el módulo de soporte sigue intacto tras la unificación del SMTP: los catorce tests de soporte (auto-respuesta, parseo IMAP, clasificador, resúmenes) pasan en verde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1156 lines
44 KiB
Go
1156 lines
44 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"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,
|
|
"intervalo_minutos": canal.IntervaloMinutos,
|
|
"filtro_remitentes": canal.FiltroRemitentes,
|
|
}
|
|
}
|
|
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"`
|
|
IntervaloMinutos int `json:"intervalo_minutos"`
|
|
FiltroRemitentes string `json:"filtro_remitentes"`
|
|
}
|
|
|
|
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"})
|
|
}
|
|
}
|
|
case "correo":
|
|
for _, k := range []string{"imap_host", "smtp_host", "usuario", "password"} {
|
|
if strings.TrimSpace(req.Credenciales[k]) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
|
|
}
|
|
}
|
|
// Probar ANTES de guardar: un canal de correo con la contraseña mal no
|
|
// falla acá adelante sino en el cron, donde nadie lo está mirando.
|
|
cred := services.CredencialesIMAP{
|
|
Host: req.Credenciales["imap_host"], Usuario: req.Credenciales["usuario"],
|
|
Password: req.Credenciales["password"], Encriptado: req.Credenciales["imap_encriptado"],
|
|
}
|
|
fmt.Sscanf(req.Credenciales["imap_puerto"], "%d", &cred.Puerto)
|
|
if err := services.ProbarConexionIMAPGenerica(cred); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la casilla rechazó la conexión: " + err.Error()})
|
|
}
|
|
default:
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram, whatsapp o correo"})
|
|
}
|
|
|
|
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
intervalo := req.IntervaloMinutos
|
|
if intervalo < 2 {
|
|
// Menos de 2 minutos es martillar el servidor de correo del cliente
|
|
// para ganar segundos que un correo no necesita.
|
|
intervalo = 5
|
|
}
|
|
canal := &models.UmindCanal{
|
|
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
|
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
|
UsarArchivosDocs: req.UsarArchivosDocs, IntervaloMinutos: intervalo,
|
|
FiltroRemitentes: strings.TrimSpace(req.FiltroRemitentes),
|
|
}
|
|
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}
|
|
// Solo si vinieron en el body: ajustar el filtro o el intervalo no obliga
|
|
// a recrear el canal, que pediría la contraseña de nuevo.
|
|
var presentes map[string]json.RawMessage
|
|
_ = json.Unmarshal(c.Body(), &presentes)
|
|
if _, ok := presentes["filtro_remitentes"]; ok {
|
|
updates["filtro_remitentes"] = strings.TrimSpace(req.FiltroRemitentes)
|
|
}
|
|
if _, ok := presentes["intervalo_minutos"]; ok && req.IntervaloMinutos >= 2 {
|
|
updates["intervalo_minutos"] = req.IntervaloMinutos
|
|
}
|
|
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.",
|
|
})
|
|
}
|
|
|
|
// GetFragmentosDocumentoHandler muestra lo que se leyó de una fuente, fragmento
|
|
// por fragmento. Es la única ventana a lo que el agente tiene de verdad: el
|
|
// contenido nunca se muestra entero en ningún otro lado.
|
|
// GET /umind/documentos/:id/fragmentos
|
|
func GetFragmentosDocumentoHandler(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
|
|
}
|
|
chunks, err := models.GetUmindChunksByDocumento(uint(id))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
out := make([]fiber.Map, len(chunks))
|
|
for i, ch := range chunks {
|
|
out[i] = fiber.Map{"ID": ch.ID, "contenido": ch.Contenido}
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|