Compare commits
40
Commits
1ebae791f6
...
main
@@ -44,6 +44,7 @@ require (
|
|||||||
github.com/chai2010/webp v1.4.0
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
||||||
github.com/chromedp/chromedp v0.16.0
|
github.com/chromedp/chromedp v0.16.0
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
@@ -55,6 +56,7 @@ require (
|
|||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
|
github.com/valyala/fasthttp v1.56.0
|
||||||
go.mongodb.org/mongo-driver v1.17.9
|
go.mongodb.org/mongo-driver v1.17.9
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/oauth2 v0.23.0
|
golang.org/x/oauth2 v0.23.0
|
||||||
@@ -75,6 +77,8 @@ require (
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
@@ -134,7 +138,6 @@ require (
|
|||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
|
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
|
||||||
github.com/valyala/fasthttp v1.56.0 // indirect
|
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||||
|
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ func main() {
|
|||||||
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
|
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
|
||||||
// uMind (ver el comentario de la función).
|
// uMind (ver el comentario de la función).
|
||||||
migrations.LiberarColumnasHuerfanasUmind()
|
migrations.LiberarColumnasHuerfanasUmind()
|
||||||
|
migrations.IndicesUnicosMessageID()
|
||||||
migrations.MigrarUmindAgentes()
|
migrations.MigrarUmindAgentes()
|
||||||
migrations.SeedApiKeys()
|
migrations.SeedApiKeys()
|
||||||
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
|
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
|
||||||
|
|||||||
@@ -1622,3 +1622,37 @@ func SeedDocumentacion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de
|
||||||
|
// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir
|
||||||
|
// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de
|
||||||
|
// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base
|
||||||
|
// rechaza el segundo.
|
||||||
|
//
|
||||||
|
// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen
|
||||||
|
// Message-Id y todos comparten la cadena vacía.
|
||||||
|
func IndicesUnicosMessageID() {
|
||||||
|
db := app.Http.Database.DB
|
||||||
|
indices := []struct{ nombre, tabla string }{
|
||||||
|
{"idx_tickets_message_id_unico", "proyecto_tickets"},
|
||||||
|
{"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"},
|
||||||
|
}
|
||||||
|
for _, ix := range indices {
|
||||||
|
var existe bool
|
||||||
|
if err := db.Raw(
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre,
|
||||||
|
).Scan(&existe).Error; err != nil || existe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y
|
||||||
|
// se sigue: el chequeo previo en código igual filtra la mayoría.
|
||||||
|
sql := fmt.Sprintf(
|
||||||
|
`CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`,
|
||||||
|
ix.nombre, ix.tabla)
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[MIGRATE] Índice %s creado.", ix.nombre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||||
<title>uMind Studio</title>
|
<title>uMind Studio</title>
|
||||||
</head>
|
</head>
|
||||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import { tema, alternarTema } from './lib/tema.js'
|
import { tema, alternarTema } from './lib/tema.js'
|
||||||
import { menuAbierto } from './lib/ui.js'
|
import { menuAbierto } from './lib/ui.js'
|
||||||
|
import DespertarUmind from './components/DespertarUmind.vue'
|
||||||
|
import UiIcono from './components/ui/UiIcono.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex">
|
<div class="min-h-screen flex">
|
||||||
|
<DespertarUmind />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="flex-1 min-w-0 flex flex-col">
|
<main class="flex-1 min-w-0 flex flex-col">
|
||||||
<header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
|
<header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
|
||||||
@@ -25,7 +28,7 @@ import { menuAbierto } from './lib/ui.js'
|
|||||||
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
|
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
|
||||||
@click="alternarTema"
|
@click="alternarTema"
|
||||||
>
|
>
|
||||||
<span class="text-base leading-none">{{ tema === 'oscuro' ? '☀️' : '🌙' }}</span>
|
<UiIcono :nombre="tema === 'oscuro' ? 'sol' : 'luna'" :tam="17" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
|
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
|
// El campo de puntos conectándose, una sola vez al entrar. Es el mismo lenguaje
|
||||||
|
// visual de la página pública —puntos que se enlazan, como el espacio vectorial
|
||||||
|
// donde vive el conocimiento del agente— así que refuerza la marca en vez de
|
||||||
|
// inventar algo nuevo para adentro.
|
||||||
|
//
|
||||||
|
// Dura poco más de un segundo y se destruye. Un fondo animado permanente en una
|
||||||
|
// herramienta de trabajo se ve bien diez segundos y molesta las ocho horas
|
||||||
|
// siguientes, además de tener un canvas comiendo batería todo el día.
|
||||||
|
const CLAVE = 'umind_despertar_visto'
|
||||||
|
const DURACION = 1250
|
||||||
|
|
||||||
|
const lienzo = ref(null)
|
||||||
|
const visible = ref(false)
|
||||||
|
let raf = null
|
||||||
|
let apagar = null
|
||||||
|
|
||||||
|
function quieto() {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Una vez por sesión: verlo en cada navegación del SPA sería exactamente el
|
||||||
|
// fondo permanente que se quiere evitar.
|
||||||
|
if (quieto() || sessionStorage.getItem(CLAVE)) return
|
||||||
|
sessionStorage.setItem(CLAVE, '1')
|
||||||
|
visible.value = true
|
||||||
|
|
||||||
|
// El apagado se programa ACÁ y no dentro del requestAnimationFrame: en una
|
||||||
|
// pestaña de fondo el navegador no corre rAF, así que el timeout nunca
|
||||||
|
// llegaba a programarse y el canvas quedaba puesto para siempre. Abrir el
|
||||||
|
// Studio en una pestaña que no se está mirando alcanzaba para dejarlo tapado
|
||||||
|
// con una capa invisible.
|
||||||
|
apagar = setTimeout(() => (visible.value = false), DURACION)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cv = lienzo.value
|
||||||
|
if (!cv) return
|
||||||
|
const ctx = cv.getContext('2d')
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
const w = window.innerWidth
|
||||||
|
const h = window.innerHeight
|
||||||
|
cv.width = Math.round(w * dpr)
|
||||||
|
cv.height = Math.round(h * dpr)
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
|
||||||
|
// Menos puntos en pantallas chicas: en un teléfono la misma densidad se ve
|
||||||
|
// sucia y cuesta más de dibujar.
|
||||||
|
const cantidad = w < 640 ? 22 : 46
|
||||||
|
const puntos = Array.from({ length: cantidad }, () => ({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
r: 1 + Math.random() * 1.6,
|
||||||
|
demora: Math.random() * 0.45,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const inicio = performance.now()
|
||||||
|
|
||||||
|
const pintar = (ahora) => {
|
||||||
|
const t = Math.min(1, (ahora - inicio) / DURACION)
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// Entra y sale en la misma curva: sin el desvanecido final el corte se
|
||||||
|
// nota como un parpadeo.
|
||||||
|
const opacidadGlobal = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25
|
||||||
|
|
||||||
|
for (let i = 0; i < puntos.length; i++) {
|
||||||
|
const p = puntos[i]
|
||||||
|
const avance = Math.max(0, Math.min(1, (t - p.demora) / 0.4))
|
||||||
|
if (avance <= 0) continue
|
||||||
|
|
||||||
|
for (let j = i + 1; j < puntos.length; j++) {
|
||||||
|
const q = puntos[j]
|
||||||
|
const dx = p.x - q.x
|
||||||
|
const dy = p.y - q.y
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (d > 170) continue
|
||||||
|
ctx.strokeStyle = `rgba(142, 176, 47, ${0.16 * (1 - d / 170) * avance * opacidadGlobal})`
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(p.x, p.y)
|
||||||
|
ctx.lineTo(q.x, q.y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, p.r * avance, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(142, 176, 47, ${0.5 * avance * opacidadGlobal})`
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t < 1) raf = requestAnimationFrame(pintar)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(pintar)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Si alguien navega antes de que termine, no queda un rAF girando ni un
|
||||||
|
// timeout tocando un componente que ya no existe.
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (raf) cancelAnimationFrame(raf)
|
||||||
|
if (apagar) clearTimeout(apagar)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- pointer-events-none: es decorativo y no puede robarle un click a nada. -->
|
||||||
|
<canvas
|
||||||
|
v-if="visible"
|
||||||
|
ref="lienzo"
|
||||||
|
class="fixed inset-0 w-full h-full pointer-events-none z-50"
|
||||||
|
aria-hidden="true"
|
||||||
|
></canvas>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import UiIcono from './ui/UiIcono.vue'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
@@ -112,7 +113,7 @@ async function guardar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function eliminar(t) {
|
async function eliminar(t) {
|
||||||
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
|
if (!confirm(`¿Eliminar el espacio "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
|
||||||
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
|
await api.del(apiUmind(`/umind/tenants/${t.ID}`))
|
||||||
if (tenantActivoId.value === String(t.ID)) router.push('/')
|
if (tenantActivoId.value === String(t.ID)) router.push('/')
|
||||||
await cargar()
|
await cargar()
|
||||||
@@ -140,9 +141,15 @@ onMounted(() => {
|
|||||||
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
|
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
|
||||||
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
|
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
|
||||||
>
|
>
|
||||||
<div class="h-14 px-4 flex items-center border-b border-borde">
|
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
|
||||||
<router-link to="/" class="text-base font-semibold text-texto">
|
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
|
||||||
uMind <span class="text-brand">Studio</span>
|
<svg viewBox="0 0 96 96" class="w-6 h-6 shrink-0" aria-hidden="true">
|
||||||
|
<rect width="96" height="96" rx="22" fill="#8eb02f" />
|
||||||
|
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" />
|
||||||
|
<circle cx="60" cy="28" r="7" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
<span>uMind <span class="text-brand">Studio</span></span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +158,7 @@ onMounted(() => {
|
|||||||
class="btn-primary w-full"
|
class="btn-primary w-full"
|
||||||
@click="nuevoTenant"
|
@click="nuevoTenant"
|
||||||
>
|
>
|
||||||
+ Nuevo tenant
|
+ Nuevo espacio
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +167,7 @@ onMounted(() => {
|
|||||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||||
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
|
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
|
||||||
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
|
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
|
||||||
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin tenants todavía.' }}
|
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin espacios todavía.' }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="t in tenants"
|
v-for="t in tenants"
|
||||||
@@ -185,14 +192,14 @@ onMounted(() => {
|
|||||||
title="Editar"
|
title="Editar"
|
||||||
@click="editarTenant(t)"
|
@click="editarTenant(t)"
|
||||||
>
|
>
|
||||||
✎
|
<UiIcono nombre="lapiz" :tam="14" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-1 text-tenue hover:text-red-600"
|
class="p-1 text-tenue hover:text-red-600"
|
||||||
title="Eliminar"
|
title="Eliminar"
|
||||||
@click="eliminar(t)"
|
@click="eliminar(t)"
|
||||||
>
|
>
|
||||||
✕
|
<UiIcono nombre="cerrar" :tam="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,10 +214,10 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
|
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
|
||||||
<h2 class="font-semibold text-texto mb-4">
|
<h2 class="font-semibold text-texto mb-4">
|
||||||
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
{{ editing ? 'Editar espacio' : 'Nuevo espacio' }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-tenue mb-3">
|
<p class="text-xs text-tenue mb-3">
|
||||||
Un tenant es el negocio/sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
|
Un espacio es el negocio o sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
|
||||||
</p>
|
</p>
|
||||||
<form class="space-y-3" @submit.prevent="guardar">
|
<form class="space-y-3" @submit.prevent="guardar">
|
||||||
<div>
|
<div>
|
||||||
@@ -241,7 +248,7 @@ onMounted(() => {
|
|||||||
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
|
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-[11px] text-tenue mt-1">
|
<p class="text-[11px] text-tenue mt-1">
|
||||||
{{ clientes.length ? 'Define quién ve este tenant desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
|
{{ clientes.length ? 'Define quién ve este espacio desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({ icono: { type: String, default: '' }, titulo: String, detalle: String })
|
import UiMascota from './UiMascota.vue'
|
||||||
|
|
||||||
|
// Una pantalla vacía es la primera que ve alguien que recién empieza, y la que
|
||||||
|
// más veces ve el que todavía no configuró algo. Decir "Sin canales" y nada
|
||||||
|
// más deja al usuario resolviendo solo qué significa eso y qué hacer.
|
||||||
|
//
|
||||||
|
// La mascota lleva el estado: dormida cuando no pasó nada todavía, buscando
|
||||||
|
// cuando falta cargar información, alerta cuando algo se rompió. Se lee antes
|
||||||
|
// que el texto.
|
||||||
|
defineProps({
|
||||||
|
// Estado de Umi. Sin él, cae en 'durmiendo', que es el caso más común.
|
||||||
|
estado: { type: String, default: 'durmiendo' },
|
||||||
|
titulo: String,
|
||||||
|
detalle: String,
|
||||||
|
// Escape para los pocos casos donde un emoji dice más que la mascota.
|
||||||
|
icono: { type: String, default: '' },
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
|
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
|
||||||
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
|
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
|
||||||
|
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
|
||||||
|
|
||||||
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
|
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
|
||||||
<p v-if="detalle" class="text-xs text-tenue mt-1 max-w-sm">{{ detalle }}</p>
|
<p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
|
||||||
<div class="mt-4"><slot /></div>
|
<div v-if="$slots.default" class="mt-4"><slot /></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup>
|
||||||
|
// Un solo set de iconos para todo uMind.
|
||||||
|
//
|
||||||
|
// Antes convivían emojis a color (🌙 📊 🧠 ✍️) con símbolos tipográficos
|
||||||
|
// monocromos (✎ ✕ ← ↻). Dos lenguajes distintos en la misma pantalla se leen
|
||||||
|
// como descuido, y encima los emojis los dibuja el sistema operativo: el mismo
|
||||||
|
// panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer
|
||||||
|
// nada.
|
||||||
|
//
|
||||||
|
// Estos son SVG de trazo que heredan currentColor, así que toman el color del
|
||||||
|
// texto que los rodea y funcionan igual en tema claro y oscuro. Mismo grosor de
|
||||||
|
// línea y misma caja en todos: al lado uno de otro tienen el mismo peso visual.
|
||||||
|
defineProps({
|
||||||
|
nombre: { type: String, required: true },
|
||||||
|
tam: { type: [Number, String], default: 16 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cada icono es una lista de [etiqueta, atributos]. No es una cadena de HTML a
|
||||||
|
// propósito: v-html dentro de un <svg> parsea como HTML y los <path> quedan sin
|
||||||
|
// el namespace de SVG — se insertan en el DOM pero no dibujan nada. Declarados
|
||||||
|
// así, Vue los crea con el namespace correcto porque ve el <svg> padre.
|
||||||
|
const ICONOS = {
|
||||||
|
luna: [['path', { d: 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z' }]],
|
||||||
|
sol: [
|
||||||
|
['circle', { cx: 12, cy: 12, r: 4 }],
|
||||||
|
['path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' }],
|
||||||
|
],
|
||||||
|
lapiz: [
|
||||||
|
['path', { d: 'M12 20h9' }],
|
||||||
|
['path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z' }],
|
||||||
|
],
|
||||||
|
cerrar: [['path', { d: 'M18 6 6 18M6 6l12 12' }]],
|
||||||
|
atras: [['path', { d: 'M19 12H5M12 19l-7-7 7-7' }]],
|
||||||
|
grafico: [
|
||||||
|
['path', { d: 'M3 3v18h18' }],
|
||||||
|
['path', { d: 'M7 15v3M12 10v8M17 6v12' }],
|
||||||
|
],
|
||||||
|
cerebro: [
|
||||||
|
['path', { d: 'M9.5 3A2.5 2.5 0 0 0 7 5.5v.2A2.7 2.7 0 0 0 5 8.3c0 .8.3 1.5.9 2A2.8 2.8 0 0 0 5 12.5c0 1 .5 1.9 1.3 2.4-.2.4-.3.8-.3 1.2A2.9 2.9 0 0 0 9 19a2.5 2.5 0 0 0 3 2V3.8A2.5 2.5 0 0 0 9.5 3Z' }],
|
||||||
|
['path', { d: 'M14.5 3A2.5 2.5 0 0 1 17 5.5v.2a2.7 2.7 0 0 1 2 2.6c0 .8-.3 1.5-.9 2A2.8 2.8 0 0 1 19 12.5c0 1-.5 1.9-1.3 2.4.2.4.3.8.3 1.2A2.9 2.9 0 0 1 15 19a2.5 2.5 0 0 1-3 2' }],
|
||||||
|
],
|
||||||
|
imagen: [
|
||||||
|
['rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }],
|
||||||
|
['circle', { cx: 8.5, cy: 8.5, r: 1.5 }],
|
||||||
|
['path', { d: 'm21 15-5-5L5 21' }],
|
||||||
|
],
|
||||||
|
microfono: [
|
||||||
|
['rect', { x: 9, y: 2, width: 6, height: 12, rx: 3 }],
|
||||||
|
['path', { d: 'M5 10a7 7 0 0 0 14 0M12 17v5' }],
|
||||||
|
],
|
||||||
|
escribir: [
|
||||||
|
['path', { d: 'M3 21h18' }],
|
||||||
|
['path', { d: 'M5 17 17.5 4.5a2.1 2.1 0 0 1 3 3L8 20l-4 1Z' }],
|
||||||
|
],
|
||||||
|
archivo: [
|
||||||
|
['path', { d: 'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z' }],
|
||||||
|
['path', { d: 'M14 2v5h5' }],
|
||||||
|
],
|
||||||
|
sitio: [
|
||||||
|
['circle', { cx: 12, cy: 12, r: 9 }],
|
||||||
|
['path', { d: 'M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z' }],
|
||||||
|
],
|
||||||
|
refrescar: [
|
||||||
|
['path', { d: 'M21 12a9 9 0 1 1-3-6.7' }],
|
||||||
|
['path', { d: 'M21 4v5h-5' }],
|
||||||
|
],
|
||||||
|
descargar: [
|
||||||
|
['path', { d: 'M12 3v12' }],
|
||||||
|
['path', { d: 'm7 11 5 5 5-5' }],
|
||||||
|
['path', { d: 'M4 21h16' }],
|
||||||
|
],
|
||||||
|
alerta: [
|
||||||
|
['path', { d: 'M12 9v4M12 17h.01' }],
|
||||||
|
['path', { d: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z' }],
|
||||||
|
],
|
||||||
|
ok: [['path', { d: 'M20 6 9 17l-5-5' }]],
|
||||||
|
copiar: [
|
||||||
|
['rect', { x: 9, y: 9, width: 12, height: 12, rx: 2 }],
|
||||||
|
['path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }],
|
||||||
|
],
|
||||||
|
mensaje: [['path', { d: 'M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.1A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 8.5-8.5A8.4 8.4 0 0 1 21 11.5Z' }]],
|
||||||
|
auto: [
|
||||||
|
['path', { d: 'M17 2.1 21 6l-4 3.9' }],
|
||||||
|
['path', { d: 'M3 11V9a4 4 0 0 1 4-4h14' }],
|
||||||
|
['path', { d: 'M7 21.9 3 18l4-3.9' }],
|
||||||
|
['path', { d: 'M21 13v2a4 4 0 0 1-4 4H3' }],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="tam"
|
||||||
|
:height="tam"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="inline-block shrink-0 align-[-0.15em]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-for="(el, i) in ICONOS[nombre] || []"
|
||||||
|
:key="i"
|
||||||
|
:is="el[0]"
|
||||||
|
v-bind="el[1]"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup>
|
||||||
|
// Umi, la mascota de uMind.
|
||||||
|
//
|
||||||
|
// No es un dibujo suelto: sale del logo. El cuerpo es la "u" del monograma y
|
||||||
|
// el punto del logo pasa a ser su antena, así que el símbolo de la marca y el
|
||||||
|
// personaje son la misma forma vista dos veces. Por eso funciona al lado del
|
||||||
|
// logo sin competirle.
|
||||||
|
//
|
||||||
|
// El estado no es decoración: cada pantalla vacía dice algo distinto, y la
|
||||||
|
// cara lo dice antes que el texto. Dormida cuando no pasó nada todavía,
|
||||||
|
// buscando cuando falta cargarle información, alerta cuando algo se rompió.
|
||||||
|
defineProps({
|
||||||
|
estado: {
|
||||||
|
type: String,
|
||||||
|
default: 'normal', // normal | durmiendo | contenta | pensando | buscando | alerta
|
||||||
|
},
|
||||||
|
tam: { type: [Number, String], default: 72 },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="tam"
|
||||||
|
:height="tam"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
class="shrink-0"
|
||||||
|
role="img"
|
||||||
|
:aria-label="`Umi, la mascota de uMind (${estado})`"
|
||||||
|
>
|
||||||
|
<!-- Antena: el punto del logo. Cambia de color solo cuando algo anda mal,
|
||||||
|
para que el rojo signifique algo. -->
|
||||||
|
<circle
|
||||||
|
cx="60"
|
||||||
|
cy="26"
|
||||||
|
r="6"
|
||||||
|
:fill="estado === 'alerta' ? '#dc2626' : 'currentColor'"
|
||||||
|
:class="estado === 'pensando' ? 'umi-late' : ''"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="60" y1="32" x2="60" y2="44"
|
||||||
|
:stroke="estado === 'alerta' ? '#dc2626' : 'currentColor'"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cuerpo: la "u" del logo, cerrada. -->
|
||||||
|
<path d="M30,46 V76 A30,30 0 0 0 90,76 V46 Z" fill="currentColor" />
|
||||||
|
|
||||||
|
<!-- Ojos abiertos -->
|
||||||
|
<template v-if="estado === 'normal' || estado === 'contenta' || estado === 'alerta'">
|
||||||
|
<circle cx="47" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
|
||||||
|
<circle cx="73" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
|
||||||
|
<circle cx="48" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
|
||||||
|
<circle cx="74" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Ojos cerrados: no pasó nada todavía, no hay nada roto -->
|
||||||
|
<template v-else-if="estado === 'durmiendo'">
|
||||||
|
<path d="M41,66 h12 M67,66 h12" class="umi-linea" stroke-width="5" stroke-linecap="round" fill="none" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Pensando: los tres puntos de "escribiendo…" que ya conoce cualquiera -->
|
||||||
|
<template v-else-if="estado === 'pensando'">
|
||||||
|
<circle cx="45" cy="66" r="4.5" class="umi-ojo umi-p1" />
|
||||||
|
<circle cx="60" cy="66" r="4.5" class="umi-ojo umi-p2" />
|
||||||
|
<circle cx="75" cy="66" r="4.5" class="umi-ojo umi-p3" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Buscando: mira de costado, como quien revisa algo -->
|
||||||
|
<template v-else-if="estado === 'buscando'">
|
||||||
|
<circle cx="47" cy="66" r="6" class="umi-ojo" />
|
||||||
|
<circle cx="73" cy="66" r="6" class="umi-ojo" />
|
||||||
|
<circle cx="50" cy="66" r="3" class="umi-pupila" />
|
||||||
|
<circle cx="76" cy="66" r="3" class="umi-pupila" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sonrisa solo cuando hay algo que celebrar -->
|
||||||
|
<path
|
||||||
|
v-if="estado === 'contenta'"
|
||||||
|
d="M50,78 q10,8 20,0"
|
||||||
|
class="umi-linea"
|
||||||
|
stroke-width="4"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Los ojos toman el color de la superficie que tienen detrás, no blanco fijo:
|
||||||
|
así la mascota se apoya sobre cualquier tarjeta sin un halo alrededor de
|
||||||
|
cada ojo, y en modo oscuro no quedan dos puntos blancos flotando.
|
||||||
|
Los tokens del proyecto son tripletes RGB, de ahí el rgb(). */
|
||||||
|
.umi-ojo { fill: rgb(var(--superficie)); }
|
||||||
|
.umi-linea { stroke: rgb(var(--superficie)); }
|
||||||
|
|
||||||
|
/* La pupila sí es fija: es lo único que da la sensación de mirada, y tiene
|
||||||
|
que leerse igual sobre el verde en los dos temas. */
|
||||||
|
.umi-pupila { fill: #11150F; }
|
||||||
|
|
||||||
|
.umi-late { animation: umi-latido 2s ease-in-out infinite; }
|
||||||
|
@keyframes umi-latido {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.umi-p1, .umi-p2, .umi-p3 { animation: umi-punto 1.4s ease-in-out infinite; }
|
||||||
|
.umi-p2 { animation-delay: 0.18s; }
|
||||||
|
.umi-p3 { animation-delay: 0.36s; }
|
||||||
|
@keyframes umi-punto {
|
||||||
|
0%, 60%, 100% { opacity: 0.35; }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.umi-late, .umi-p1, .umi-p2, .umi-p3 { animation: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import UiIcono from './UiIcono.vue'
|
||||||
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
|
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
|
||||||
const emit = defineEmits(['cerrar'])
|
const emit = defineEmits(['cerrar'])
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +13,7 @@ const emit = defineEmits(['cerrar'])
|
|||||||
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
|
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
|
||||||
<div v-if="titulo" class="flex items-center justify-between mb-4">
|
<div v-if="titulo" class="flex items-center justify-between mb-4">
|
||||||
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
|
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
|
||||||
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')">✕</button>
|
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"><UiIcono nombre="cerrar" :tam="15" /></button>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Home from './views/Home.vue'
|
|||||||
import TenantAgentes from './views/TenantAgentes.vue'
|
import TenantAgentes from './views/TenantAgentes.vue'
|
||||||
import AgenteDetail from './views/AgenteDetail.vue'
|
import AgenteDetail from './views/AgenteDetail.vue'
|
||||||
import Uso from './views/Uso.vue'
|
import Uso from './views/Uso.vue'
|
||||||
|
import AiPropia from './views/AiPropia.vue'
|
||||||
import { contexto } from './lib/contexto.js'
|
import { contexto } from './lib/contexto.js'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -11,6 +12,7 @@ const router = createRouter({
|
|||||||
{ path: '/', name: 'home', component: Home },
|
{ path: '/', name: 'home', component: Home },
|
||||||
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
|
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
|
||||||
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
|
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
|
||||||
|
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
|
||||||
{
|
{
|
||||||
path: '/tenants/:tenantId/agentes/:agenteId',
|
path: '/tenants/:tenantId/agentes/:agenteId',
|
||||||
name: 'agente-detail',
|
name: 'agente-detail',
|
||||||
|
|||||||
@@ -29,6 +29,28 @@
|
|||||||
color: rgb(var(--texto));
|
color: rgb(var(--texto));
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Retícula de plano técnico, muy tenue y ESTÁTICA. Da profundidad sin pedir
|
||||||
|
atención ni gastar un canvas corriendo todo el día. Se desvanece hacia
|
||||||
|
abajo para no competir con las tablas y los formularios, que es donde de
|
||||||
|
verdad se trabaja. */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(var(--borde) / 0.5) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(var(--borde) / 0.5) 1px, transparent 1px);
|
||||||
|
background-size: 56px 56px;
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||||
|
mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* El contenido va por encima de la retícula. */
|
||||||
|
#app { position: relative; z-index: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@@ -86,3 +108,38 @@
|
|||||||
@apply tab text-tenue hover:text-texto hover:bg-elevado;
|
@apply tab text-tenue hover:text-texto hover:bg-elevado;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Deslizable con el dedo, sin la barra gris encima del contenido. El borde
|
||||||
|
cortado de la última pestaña ya avisa que hay más hacia el costado. */
|
||||||
|
.sin-barra {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sin-barra::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Un punto que late para lo que está trabajando de verdad: una fuente en
|
||||||
|
"procesando" con la etiqueta quieta no dice si sigue avanzando o si se
|
||||||
|
colgó. Se usa sólo donde hay trabajo real en curso, nunca de adorno. */
|
||||||
|
.latido {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: latido 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes latido {
|
||||||
|
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.latido { animation: none; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../lib/api.js'
|
||||||
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const cargando = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editando = ref(null)
|
||||||
|
const guardando = ref(false)
|
||||||
|
const probando = ref(null)
|
||||||
|
const resultado = ref({})
|
||||||
|
|
||||||
|
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
|
||||||
|
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
|
||||||
|
const proveedores = [
|
||||||
|
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
|
||||||
|
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
|
||||||
|
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
|
||||||
|
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
|
||||||
|
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
|
||||||
|
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
|
||||||
|
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function vacio() {
|
||||||
|
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref(vacio())
|
||||||
|
|
||||||
|
function proveedorDe(valor) {
|
||||||
|
return proveedores.find((p) => p.valor === valor) || proveedores[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
cargando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
|
||||||
|
items.value = r.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
cargando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, cargar, { immediate: true })
|
||||||
|
|
||||||
|
function nueva() {
|
||||||
|
editando.value = null
|
||||||
|
form.value = vacio()
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editar(c) {
|
||||||
|
editando.value = c
|
||||||
|
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
|
||||||
|
// persona escribe una nueva.
|
||||||
|
form.value = {
|
||||||
|
nombre: c.nombre,
|
||||||
|
provider: c.provider,
|
||||||
|
api_key: '',
|
||||||
|
base_url: c.base_url || '',
|
||||||
|
model_name: c.model_name || '',
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
guardando.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const cuerpo = { tenant_id: Number(props.id), ...form.value }
|
||||||
|
if (editando.value) {
|
||||||
|
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
|
||||||
|
} else {
|
||||||
|
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
guardando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
|
||||||
|
// alguien escribe: esto lo adelanta.
|
||||||
|
async function probar(c) {
|
||||||
|
probando.value = c.ID
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: null }
|
||||||
|
try {
|
||||||
|
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
|
||||||
|
} catch (e) {
|
||||||
|
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
|
||||||
|
} finally {
|
||||||
|
probando.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function borrar(c) {
|
||||||
|
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
|
||||||
|
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
|
||||||
|
<p class="text-sm text-tenue mt-1 max-w-xl">
|
||||||
|
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
|
||||||
|
El consumo se factura directo con el proveedor y no pasa por tu plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="cargando" class="text-sm text-tenue">Cargando…</div>
|
||||||
|
|
||||||
|
<UiEmptyState
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
class="card"
|
||||||
|
titulo="Estás usando la IA de uMind"
|
||||||
|
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
|
||||||
|
>
|
||||||
|
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
|
||||||
|
</UiEmptyState>
|
||||||
|
|
||||||
|
<div v-else class="card divide-y divide-borde">
|
||||||
|
<div v-for="c in items" :key="c.ID" class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
|
||||||
|
<div class="text-xs text-tenue mt-0.5">
|
||||||
|
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
|
||||||
|
<span class="font-mono">{{ c.api_key_pista }}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="resultado[c.ID]"
|
||||||
|
class="text-xs mt-1.5"
|
||||||
|
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||||
|
<button
|
||||||
|
class="text-brand hover:underline disabled:opacity-40"
|
||||||
|
:disabled="probando === c.ID"
|
||||||
|
@click="probar(c)"
|
||||||
|
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
|
||||||
|
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
|
||||||
|
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="items.length" class="text-xs text-tenue mt-3">
|
||||||
|
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Alta / edición -->
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||||
|
@click.self="showForm = false"
|
||||||
|
>
|
||||||
|
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
|
<h2 class="font-semibold text-texto mb-4">
|
||||||
|
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="space-y-3" @submit.prevent="guardar">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nombre</label>
|
||||||
|
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Proveedor</label>
|
||||||
|
<select v-model="form.provider" class="input">
|
||||||
|
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
|
||||||
|
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Modelo</label>
|
||||||
|
<input
|
||||||
|
v-model="form.model_name"
|
||||||
|
required
|
||||||
|
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
|
||||||
|
class="input font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.provider !== 'ollama'">
|
||||||
|
<label class="label">
|
||||||
|
Clave de API
|
||||||
|
<span v-if="editando" class="text-tenue font-normal">— dejala vacía para no cambiarla</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.api_key"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:required="!editando"
|
||||||
|
placeholder="sk-..."
|
||||||
|
class="input font-mono"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.provider === 'ollama'">
|
||||||
|
<label class="label">URL de tu servidor</label>
|
||||||
|
<input
|
||||||
|
v-model="form.base_url"
|
||||||
|
required
|
||||||
|
placeholder="https://ollama.tudominio.com/v1"
|
||||||
|
class="input font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||||
|
{{ guardando ? 'Guardando…' : 'Guardar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,9 +3,11 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||||
|
import UiMascota from '../components/ui/UiMascota.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cargando = ref(contexto.esPortal)
|
const cargando = ref(contexto.esPortal)
|
||||||
|
const sinEspacios = ref(false)
|
||||||
|
|
||||||
// El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio
|
// El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio
|
||||||
// con un agente —el caso normal— entrar obligándolo a elegir dos veces es
|
// con un agente —el caso normal— entrar obligándolo a elegir dos veces es
|
||||||
@@ -18,6 +20,10 @@ async function irAlAgenteSiEsUnoSolo() {
|
|||||||
try {
|
try {
|
||||||
const t = await api.get(apiUmind('/umind/tenants'))
|
const t = await api.get(apiUmind('/umind/tenants'))
|
||||||
const tenants = t.items || []
|
const tenants = t.items || []
|
||||||
|
if (tenants.length === 0) {
|
||||||
|
sinEspacios.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (tenants.length !== 1) return
|
if (tenants.length !== 1) return
|
||||||
|
|
||||||
const tenantId = tenants[0].ID
|
const tenantId = tenants[0].ID
|
||||||
@@ -43,10 +49,28 @@ onMounted(irAlAgenteSiEsUnoSolo)
|
|||||||
Abriendo tu asistente…
|
Abriendo tu asistente…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sin espacios no hay nada que elegir ni forma de crearlo: mandarlo a
|
||||||
|
"elegí de la izquierda" cuando la izquierda está vacía es un callejón. -->
|
||||||
|
<div v-else-if="sinEspacios" class="max-w-md mx-auto text-center py-20">
|
||||||
|
<UiMascota estado="durmiendo" :tam="80" class="mx-auto mb-4 text-brand" />
|
||||||
|
<h1 class="text-lg font-medium text-texto">Todavía no tenés un asistente activo</h1>
|
||||||
|
<p class="text-sm text-tenue mt-2">
|
||||||
|
uMind contesta por vos en WhatsApp y en tu sitio, con la información de tu negocio.
|
||||||
|
Entiende las notas de voz y lee las fotos y archivos que te mandan tus clientes.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
class="btn-primary inline-block mt-5"
|
||||||
|
href="mailto:soporte@u-site.app?subject=Quiero%20activar%20uMind"
|
||||||
|
>Quiero activarlo</a>
|
||||||
|
<p class="text-xs text-tenue mt-4">
|
||||||
|
<a href="/portal/dashboard" class="underline">Volver al portal</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center text-center py-24">
|
<div v-else class="flex flex-col items-center justify-center text-center py-24">
|
||||||
<div class="text-4xl mb-4">💬</div>
|
<UiMascota estado="normal" :tam="64" class="mx-auto mb-4 text-brand" />
|
||||||
<h1 class="text-lg font-medium text-texto">
|
<h1 class="text-lg font-medium text-texto">
|
||||||
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un tenant de la izquierda' }}
|
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un espacio de la izquierda' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-tenue mt-1">
|
<p class="text-sm text-tenue mt-1">
|
||||||
{{ contexto.esPortal
|
{{ contexto.esPortal
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
const props = defineProps({ id: { type: String, required: true } })
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
const tenantId = computed(() => Number(props.id))
|
const tenantId = computed(() => Number(props.id))
|
||||||
@@ -62,23 +63,30 @@ const cupo = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function vacio() {
|
function vacio() {
|
||||||
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true }
|
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true, plantilla_rubro: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Puntos de partida por rubro. Un agente vacío no sirve el primer día, y
|
||||||
|
// escribir el conocimiento desde cero frente a un campo en blanco es donde la
|
||||||
|
// mayoría abandona.
|
||||||
|
const plantillas = ref([])
|
||||||
|
|
||||||
async function cargar() {
|
async function cargar() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
cargando.value = true
|
cargando.value = true
|
||||||
try {
|
try {
|
||||||
const [t, a, ai] = await Promise.all([
|
const [t, a, ai, pl] = await Promise.all([
|
||||||
api.get(apiUmind('/umind/tenants')),
|
api.get(apiUmind('/umind/tenants')),
|
||||||
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
|
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
|
||||||
api.get(apiUmind('/umind/ai-configs')),
|
api.get(apiUmind('/umind/ai-configs')),
|
||||||
|
api.get(apiUmind('/umind/plantillas-rubro')),
|
||||||
])
|
])
|
||||||
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
|
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
|
||||||
agentes.value = a.items || []
|
agentes.value = a.items || []
|
||||||
plan.value = a.plan || null
|
plan.value = a.plan || null
|
||||||
resumen.value = a.resumen || {}
|
resumen.value = a.resumen || {}
|
||||||
aiConfigs.value = ai.items || []
|
aiConfigs.value = ai.items || []
|
||||||
|
plantillas.value = pl.items || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,6 +94,24 @@ async function cargar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un agente que ya funciona es la mejor plantilla del siguiente: el catálogo de
|
||||||
|
// rubros da un arranque genérico, esto copia uno real con su conocimiento.
|
||||||
|
async function duplicarAgente(a) {
|
||||||
|
const nombre = prompt(`Nombre de la copia de "${a.nombre}":`, `${a.nombre} (copia)`)
|
||||||
|
if (nombre === null) return
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.post(apiUmind(`/umind/agentes/${a.ID}/duplicar`), {
|
||||||
|
tenant_id: Number(tenantId.value),
|
||||||
|
nombre: nombre.trim(),
|
||||||
|
})
|
||||||
|
await cargar()
|
||||||
|
if (r.aviso) alert(r.aviso)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function nuevoAgente() {
|
function nuevoAgente() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
form.value = vacio()
|
form.value = vacio()
|
||||||
@@ -142,7 +168,8 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
|
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
|
||||||
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
|
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
|
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
|
||||||
|
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,7 +187,7 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
|
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4">
|
<p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4">
|
||||||
Este tenant no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del tenant en la barra izquierda.
|
Este espacio no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del espacio, en la barra izquierda.
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
|
<p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
|
||||||
Alcanzaste el máximo de agentes de tu plan.
|
Alcanzaste el máximo de agentes de tu plan.
|
||||||
@@ -184,7 +211,7 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
v-else-if="agentes.length === 0"
|
v-else-if="agentes.length === 0"
|
||||||
class="card"
|
class="card"
|
||||||
icono="🤖"
|
estado="durmiendo"
|
||||||
titulo="Todavía no hay agentes"
|
titulo="Todavía no hay agentes"
|
||||||
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
|
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
|
||||||
>
|
>
|
||||||
@@ -217,8 +244,13 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)">✎</button>
|
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"><UiIcono nombre="lapiz" :tam="14" /></button>
|
||||||
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)">✕</button>
|
<button
|
||||||
|
class="p-1 text-tenue hover:text-texto"
|
||||||
|
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
|
||||||
|
@click.prevent.stop="duplicarAgente(a)"
|
||||||
|
><UiIcono nombre="copiar" :tam="14" /></button>
|
||||||
|
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"><UiIcono nombre="cerrar" :tam="14" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,13 +263,44 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false">
|
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false">
|
||||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg">
|
<!-- max-h + scroll propio: con las opciones de plantilla el formulario
|
||||||
|
pasa de largo la pantalla, y sin esto el botón de guardar queda
|
||||||
|
abajo del borde inferior, inalcanzable. -->
|
||||||
|
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
|
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
|
||||||
<form class="space-y-3" @submit.prevent="guardar">
|
<form class="space-y-3" @submit.prevent="guardar">
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Nombre</label>
|
<label class="label">Nombre</label>
|
||||||
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
|
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Solo al crear: en un agente que ya existe, precargar notas
|
||||||
|
pisaría el conocimiento que el dueño ya escribió. -->
|
||||||
|
<div v-if="!editing && plantillas.length">
|
||||||
|
<label class="label">Arrancar con</label>
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<label
|
||||||
|
v-for="p in [{ clave: '', nombre: 'Agente en blanco', descripcion: 'Sin conocimiento cargado. Lo escribís vos desde cero.' }, ...plantillas]"
|
||||||
|
:key="p.clave"
|
||||||
|
class="flex gap-2.5 p-2.5 rounded-lg border cursor-pointer transition-colors"
|
||||||
|
:class="form.plantilla_rubro === p.clave
|
||||||
|
? 'border-brand bg-brand/5'
|
||||||
|
: 'border-borde hover:border-brand/40'"
|
||||||
|
>
|
||||||
|
<input v-model="form.plantilla_rubro" type="radio" :value="p.clave" class="mt-1 text-brand focus:ring-brand" />
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-sm text-texto">{{ p.nombre }}</span>
|
||||||
|
<span class="block text-xs text-tenue">{{ p.descripcion }}</span>
|
||||||
|
<span v-if="p.notas" class="block text-xs text-tenue mt-0.5">
|
||||||
|
{{ p.notas }} notas listas para editar · {{ p.resumen }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-tenue mt-1.5">
|
||||||
|
Las notas vienen con ejemplos entre corchetes — abrilas y reemplazalas por tus datos reales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Config de IA</label>
|
<label class="label">Config de IA</label>
|
||||||
<select v-model="form.ai_config_id" class="input">
|
<select v-model="form.ai_config_id" class="input">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { apiUmind } from '../lib/contexto.js'
|
import { apiUmind } from '../lib/contexto.js'
|
||||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
const props = defineProps({ id: { type: String, required: true } })
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
|
||||||
@@ -16,11 +17,11 @@ const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().s
|
|||||||
const hasta = ref(hoy.toISOString().slice(0, 10))
|
const hasta = ref(hoy.toISOString().slice(0, 10))
|
||||||
|
|
||||||
const ETIQUETAS = {
|
const ETIQUETAS = {
|
||||||
ia: { nombre: 'Inteligencia artificial', icono: '🧠', color: '#8eb02f' },
|
ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
|
||||||
ocr: { nombre: 'Lectura de imágenes', icono: '🖼️', color: '#2f7fb0' },
|
ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
|
||||||
whisper: { nombre: 'Transcripción de audio', icono: '🎙️', color: '#b0752f' },
|
whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
|
||||||
}
|
}
|
||||||
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: '•', color: '#94a3b8' }
|
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
|
||||||
|
|
||||||
async function cargar() {
|
async function cargar() {
|
||||||
cargando.value = true
|
cargando.value = true
|
||||||
@@ -88,7 +89,7 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
|
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
|
||||||
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
|
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="`/tenants/${id}`" class="btn-ghost">← Agentes</router-link>
|
<router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
@@ -162,19 +163,24 @@ watch(() => props.id, cargar, { immediate: true })
|
|||||||
</div>
|
</div>
|
||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
v-if="resumen.length === 0"
|
v-if="resumen.length === 0"
|
||||||
icono="📊"
|
estado="durmiendo"
|
||||||
titulo="Sin consumo en este período"
|
titulo="Sin consumo en este período"
|
||||||
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
|
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
|
||||||
/>
|
/>
|
||||||
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
|
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
|
||||||
<span class="text-lg">{{ etiqueta(r.tipo).icono }}</span>
|
<UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
|
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
|
||||||
<p class="text-xs text-tenue tabular-nums">
|
<p class="text-xs text-tenue tabular-nums">
|
||||||
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
|
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
|
<!-- Costo cero con consumo real quiere decir cuenta propia: lo
|
||||||
|
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
|
||||||
|
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
|
||||||
|
<span v-else class="text-xs text-tenue text-right leading-tight">
|
||||||
|
va por tu<br />cuenta de IA
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
// Responde "¿está bien mi asistente?" sin hacer un solo clic.
|
||||||
|
//
|
||||||
|
// Antes había que entrar pestaña por pestaña para saberlo: si estaba atendiendo
|
||||||
|
// en algún lado, si sabía algo, si alguien le había escrito. Tres datos que el
|
||||||
|
// dueño quiere de un vistazo y que estaban repartidos en tres pantallas.
|
||||||
|
const props = defineProps({
|
||||||
|
canales: { type: Array, default: () => [] },
|
||||||
|
documentos: { type: Array, default: () => [] },
|
||||||
|
sesiones: { type: Array, default: () => [] },
|
||||||
|
eventos: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['ir'])
|
||||||
|
|
||||||
|
const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
|
||||||
|
|
||||||
|
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
|
||||||
|
// como un error de tipeo.
|
||||||
|
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
|
||||||
|
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
|
||||||
|
|
||||||
|
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
|
||||||
|
|
||||||
|
// Dos meses es cuando una fuente empieza a ser sospechosa de estar vieja: es el
|
||||||
|
// mismo corte que usa la lista de conocimiento, para que no digan cosas
|
||||||
|
// distintas.
|
||||||
|
const fuentesViejas = computed(() =>
|
||||||
|
fuentesListas.value.filter(
|
||||||
|
(d) => d.procesado_at && Date.now() - new Date(d.procesado_at) > 60 * 86400000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const problemas = computed(() => props.eventos.filter((e) => e.nivel === 'error').length)
|
||||||
|
|
||||||
|
// Solo conversaciones de clientes: la bandeja separa las pruebas propias, y si
|
||||||
|
// este número las incluyera diría una cosa distinta que la lista de al lado.
|
||||||
|
const reales = computed(() =>
|
||||||
|
props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cada señal dice qué pasa y, cuando algo anda mal, a dónde ir a arreglarlo.
|
||||||
|
const señales = computed(() => [
|
||||||
|
{
|
||||||
|
clave: 'canales',
|
||||||
|
icono: 'mensaje',
|
||||||
|
bien: canalesActivos.value.length > 0,
|
||||||
|
texto: canalesActivos.value.length
|
||||||
|
? `Atendiendo en ${canalesActivos.value.map((c) => nombreCanal(c.tipo)).join(' y ')}`
|
||||||
|
: 'No está atendiendo en ningún lado',
|
||||||
|
destino: 'canales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: 'conocimiento',
|
||||||
|
icono: 'escribir',
|
||||||
|
bien: fuentesListas.value.length > 0 && fuentesViejas.value.length === 0,
|
||||||
|
texto: !fuentesListas.value.length
|
||||||
|
? 'Todavía no sabe nada de tu negocio'
|
||||||
|
: fuentesViejas.value.length
|
||||||
|
? `${fuentesViejas.value.length} de ${fuentesListas.value.length} fuentes están viejas`
|
||||||
|
: `Sabe de ${fuentesListas.value.length} ${fuentesListas.value.length === 1 ? 'cosa' : 'cosas'}`,
|
||||||
|
destino: 'conocimiento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: 'conversaciones',
|
||||||
|
icono: 'grafico',
|
||||||
|
// Que nadie haya escrito todavía no es un problema: es un dato.
|
||||||
|
bien: true,
|
||||||
|
neutro: reales.value.length === 0,
|
||||||
|
texto: reales.value.length
|
||||||
|
? `${reales.value.length} ${reales.value.length === 1 ? 'conversación' : 'conversaciones'}`
|
||||||
|
: 'Nadie escribió todavía',
|
||||||
|
destino: 'conversaciones',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card p-1 mb-6 flex flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="s in señales"
|
||||||
|
:key="s.clave"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors text-left"
|
||||||
|
@click="emit('ir', s.destino)"
|
||||||
|
>
|
||||||
|
<UiIcono
|
||||||
|
:nombre="s.bien ? s.icono : 'alerta'"
|
||||||
|
:tam="16"
|
||||||
|
:class="s.neutro ? 'text-tenue' : s.bien ? 'text-brand' : 'text-amber-600 dark:text-amber-400'"
|
||||||
|
/>
|
||||||
|
<span :class="s.neutro ? 'text-tenue' : s.bien ? 'text-texto' : 'text-amber-700 dark:text-amber-400 font-medium'">
|
||||||
|
{{ s.texto }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="problemas"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors ml-auto"
|
||||||
|
@click="emit('ir', 'auditoria')"
|
||||||
|
>
|
||||||
|
<UiIcono nombre="alerta" :tam="16" class="text-red-600 dark:text-red-400" />
|
||||||
|
<span class="text-red-700 dark:text-red-400 font-medium">
|
||||||
|
{{ problemas }} {{ problemas === 1 ? 'problema' : 'problemas' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
import UiMascota from '../../components/ui/UiMascota.vue'
|
||||||
|
|
||||||
|
// El camino de puesta en marcha, en orden. Existe porque un agente recién
|
||||||
|
// creado mostraba tres avisos ámbar sueltos ("no atiende", "no sabe nada") y
|
||||||
|
// ningún orden: el dueño sabía que faltaban cosas pero no cuál iba primero.
|
||||||
|
//
|
||||||
|
// Mientras falte algo, esta guía reemplaza al estado de arriba — una sola voz
|
||||||
|
// por etapa. Cuando los tres pasos están hechos, desaparece para siempre y el
|
||||||
|
// estado operativo toma su lugar. La puesta en marcha se hace una vez; el
|
||||||
|
// estado se mira todos los días.
|
||||||
|
const props = defineProps({
|
||||||
|
documentos: { type: Array, default: () => [] },
|
||||||
|
canales: { type: Array, default: () => [] },
|
||||||
|
sesiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['ir'])
|
||||||
|
|
||||||
|
const sabe = computed(() => props.documentos.some((d) => d.estado === 'listo'))
|
||||||
|
const probado = computed(() =>
|
||||||
|
props.sesiones.some((s) => String(s.session_id).startsWith('staff-preview')),
|
||||||
|
)
|
||||||
|
const atiende = computed(() => props.canales.some((c) => c.activo))
|
||||||
|
|
||||||
|
const pasos = computed(() => [
|
||||||
|
{
|
||||||
|
n: 1,
|
||||||
|
hecho: sabe.value,
|
||||||
|
titulo: 'Contale de tu negocio',
|
||||||
|
detalle: 'Escribí tus horarios, precios y lo que te preguntan siempre — o subí tu lista de precios, o dale tu sitio web.',
|
||||||
|
accion: 'Cargar información',
|
||||||
|
destino: 'conocimiento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 2,
|
||||||
|
hecho: probado.value,
|
||||||
|
titulo: 'Hacele una pregunta',
|
||||||
|
detalle: 'Preguntale algo como lo haría un cliente. Si contesta mal, corregí su información y probá de nuevo.',
|
||||||
|
accion: 'Probarlo',
|
||||||
|
destino: 'conocimiento',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3,
|
||||||
|
hecho: atiende.value,
|
||||||
|
titulo: 'Ponelo a atender',
|
||||||
|
detalle: 'Conectá tu WhatsApp o Telegram, o pegá el widget en tu web. Desde ese momento responde solo.',
|
||||||
|
accion: 'Elegir dónde',
|
||||||
|
destino: 'canales',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const hechos = computed(() => pasos.value.filter((p) => p.hecho).length)
|
||||||
|
|
||||||
|
// El siguiente paso pendiente es el único que se resalta: una guía donde todo
|
||||||
|
// grita a la vez no guía nada.
|
||||||
|
const siguiente = computed(() => pasos.value.find((p) => !p.hecho)?.n ?? null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card p-5 mb-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<UiMascota :estado="hechos === 0 ? 'durmiendo' : 'contenta'" :tam="52" class="text-brand shrink-0 hidden sm:block" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<h2 class="font-semibold text-texto">Pongamos a andar tu asistente</h2>
|
||||||
|
<span class="text-xs text-tenue tabular-nums shrink-0">{{ hechos }} de {{ pasos.length }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-tenue mt-0.5 mb-4">
|
||||||
|
Tres pasos y empieza a responderle a tus clientes. Diez minutos, sin nada técnico.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="space-y-2.5">
|
||||||
|
<li
|
||||||
|
v-for="p in pasos"
|
||||||
|
:key="p.n"
|
||||||
|
class="flex items-start gap-3 rounded-lg p-2.5 -m-0.5 transition-colors"
|
||||||
|
:class="p.n === siguiente ? 'bg-brand/5 border border-brand/30' : ''"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5"
|
||||||
|
:class="p.hecho ? 'bg-brand text-white' : p.n === siguiente ? 'border-2 border-brand text-brand' : 'border border-borde text-tenue'"
|
||||||
|
>
|
||||||
|
<UiIcono v-if="p.hecho" nombre="ok" :tam="13" />
|
||||||
|
<template v-else>{{ p.n }}</template>
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium" :class="p.hecho ? 'text-tenue line-through' : 'text-texto'">
|
||||||
|
{{ p.titulo }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!p.hecho" class="text-xs text-tenue mt-0.5">{{ p.detalle }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!p.hecho && p.n === siguiente"
|
||||||
|
class="btn-primary !py-1.5 !px-3 text-sm shrink-0"
|
||||||
|
@click="emit('ir', p.destino)"
|
||||||
|
>
|
||||||
|
{{ p.accion }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
// Los eventos llegan del padre, que ya los carga junto con el resto del
|
||||||
|
// agente: pedirlos otra vez acá duplicaría la llamada cada vez que alguien
|
||||||
|
// entra a la pestaña.
|
||||||
|
defineProps({
|
||||||
|
eventos: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const nivelColor = computed(() => (nivel) => ({
|
||||||
|
error: 'badge-error',
|
||||||
|
warn: 'badge-alerta',
|
||||||
|
}[nivel] || 'badge-neutro'))
|
||||||
|
|
||||||
|
function formatearFecha(iso) {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="label mb-4">
|
||||||
|
Errores y eventos técnicos de este agente — fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
|
||||||
|
</p>
|
||||||
|
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="eventos.length === 0"
|
||||||
|
estado="contenta"
|
||||||
|
titulo="Ningún problema registrado"
|
||||||
|
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
|
||||||
|
/>
|
||||||
|
<details v-for="e in eventos" :key="e.ID" class="p-3">
|
||||||
|
<summary class="cursor-pointer flex items-center gap-2 text-sm">
|
||||||
|
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
|
||||||
|
<span class="text-tenue text-xs shrink-0">{{ e.origen }}</span>
|
||||||
|
<span class="text-texto truncate">{{ e.mensaje }}</span>
|
||||||
|
<span class="text-tenue text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
|
||||||
|
</summary>
|
||||||
|
<pre v-if="e.detalle" class="mt-2 bg-elevado border border-borde rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
siteKey: { type: String, default: '' },
|
||||||
|
canales: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['recargar', 'error'])
|
||||||
|
|
||||||
|
const showCanalForm = ref(false)
|
||||||
|
const canalForm = ref(canalVacio())
|
||||||
|
const widgetCopiado = ref(false)
|
||||||
|
|
||||||
|
// El reporte se descarga con una navegación normal (no fetch): así el
|
||||||
|
// navegador maneja el archivo y la cookie de sesión viaja sola.
|
||||||
|
const widgetSnippet = computed(() => {
|
||||||
|
const siteKey = props.siteKey || 'TU_SITE_KEY'
|
||||||
|
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function copiarWidget() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(widgetSnippet.value)
|
||||||
|
widgetCopiado.value = true
|
||||||
|
setTimeout(() => (widgetCopiado.value = false), 2000)
|
||||||
|
} catch {
|
||||||
|
error.value = 'No se pudo copiar automáticamente — seleccioná el texto y copialo a mano.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canalVacio() {
|
||||||
|
return {
|
||||||
|
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||||
|
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuevoCanal() {
|
||||||
|
canalForm.value = canalVacio()
|
||||||
|
showCanalForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarCanal() {
|
||||||
|
const credenciales =
|
||||||
|
canalForm.value.tipo === 'telegram'
|
||||||
|
? { bot_token: canalForm.value.bot_token }
|
||||||
|
: {
|
||||||
|
phone_number_id: canalForm.value.phone_number_id,
|
||||||
|
access_token: canalForm.value.access_token,
|
||||||
|
app_secret: canalForm.value.app_secret,
|
||||||
|
verify_token: canalForm.value.verify_token,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(apiUmind('/umind/canales'), {
|
||||||
|
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
|
||||||
|
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
|
||||||
|
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
||||||
|
})
|
||||||
|
showCanalForm.value = false
|
||||||
|
emit('recargar')
|
||||||
|
} catch (e) {
|
||||||
|
emit('error', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
|
||||||
|
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
|
||||||
|
async function guardarInterruptores(c, cambios) {
|
||||||
|
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
|
||||||
|
activo: c.activo,
|
||||||
|
credenciales: {},
|
||||||
|
usar_whisper_audio: c.usar_whisper_audio,
|
||||||
|
usar_ocr_imagenes: c.usar_ocr_imagenes,
|
||||||
|
usar_archivos_docs: c.usar_archivos_docs,
|
||||||
|
...cambios,
|
||||||
|
})
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
|
||||||
|
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
|
||||||
|
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
|
||||||
|
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
|
||||||
|
|
||||||
|
async function eliminarCanal(c) {
|
||||||
|
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/canales/${c.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-texto">Web (widget)</span>
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">
|
||||||
|
siempre activo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
|
||||||
|
@click="copiarWidget"
|
||||||
|
>
|
||||||
|
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
|
||||||
|
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="label mb-2">
|
||||||
|
Pegá esto antes de <code class="bg-elevado px-1 rounded"></body></code> en las páginas de tu sitio.
|
||||||
|
</p>
|
||||||
|
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mb-4">
|
||||||
|
<button class="btn-primary" @click="nuevoCanal">
|
||||||
|
+ Nuevo canal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="canales.length === 0"
|
||||||
|
titulo="No está atendiendo en ningún lado"
|
||||||
|
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
|
||||||
|
/>
|
||||||
|
<div v-for="c in canales" :key="c.ID" class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||||
|
{{ c.activo ? 'activo' : 'inactivo' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-sm">
|
||||||
|
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
|
||||||
|
{{ c.activo ? 'Desactivar' : 'Activar' }}
|
||||||
|
</button>
|
||||||
|
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-2 text-xs">
|
||||||
|
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Transcribir audios (Whisper)
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer texto de imágenes (OCR)
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer archivos adjuntos (PDF, Word, texto)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="label mt-1 break-all">
|
||||||
|
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
|
||||||
|
</p>
|
||||||
|
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1">
|
||||||
|
Registrá esta URL como "Callback URL" en Meta for Developers → WhatsApp → Configuration, con el mismo verify_token que pusiste acá.
|
||||||
|
</p>
|
||||||
|
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCanalForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
|
||||||
|
<div class="card p-6 w-full max-w-md">
|
||||||
|
<h2 class="font-semibold text-texto mb-4">Nuevo canal</h2>
|
||||||
|
<form class="space-y-3" @submit.prevent="guardarCanal">
|
||||||
|
<div>
|
||||||
|
<label class="label">Tipo</label>
|
||||||
|
<select v-model="canalForm.tipo" class="input">
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="whatsapp">WhatsApp Business</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<template v-if="canalForm.tipo === 'telegram'">
|
||||||
|
<div>
|
||||||
|
<label class="label">Bot token (de @BotFather)</label>
|
||||||
|
<input v-model="canalForm.bot_token" type="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<label class="label">Phone Number ID</label>
|
||||||
|
<input v-model="canalForm.phone_number_id" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Access Token</label>
|
||||||
|
<input v-model="canalForm.access_token" type="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">App Secret</label>
|
||||||
|
<input v-model="canalForm.app_secret" type="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
|
||||||
|
<input v-model="canalForm.verify_token" required class="input" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2 pt-1">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Transcribir audios con Whisper
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer texto de imágenes con OCR
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer archivos adjuntos (PDF, Word, texto)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-primary">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conexiones (correo, OAuth) -->
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiMascota from '../../components/ui/UiMascota.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// La sesión se arma una vez por montaje: cada prueba arranca con el historial
|
||||||
|
// limpio, que es lo que se quiere al probar un cambio de conocimiento.
|
||||||
|
const sessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
|
||||||
|
const mensajes = ref([])
|
||||||
|
const entrada = ref('')
|
||||||
|
const enviando = ref(false)
|
||||||
|
|
||||||
|
async function enviar() {
|
||||||
|
const texto = entrada.value.trim()
|
||||||
|
if (!texto || enviando.value) return
|
||||||
|
entrada.value = ''
|
||||||
|
mensajes.value.push({ role: 'user', content: texto })
|
||||||
|
enviando.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.post(apiUmind('/umind/chat'), {
|
||||||
|
agente_id: props.agenteId,
|
||||||
|
session_id: sessionId,
|
||||||
|
mensaje: texto,
|
||||||
|
})
|
||||||
|
mensajes.value.push({ role: 'assistant', content: r.respuesta })
|
||||||
|
} catch (e) {
|
||||||
|
mensajes.value.push({ role: 'assistant', content: `⚠ ${e.message}` })
|
||||||
|
} finally {
|
||||||
|
enviando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card p-4 flex flex-col h-[28rem]">
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||||
|
<p v-if="mensajes.length === 0" class="text-sm text-tenue">
|
||||||
|
Probá este agente tal cual lo va a ver un visitante — usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in mensajes"
|
||||||
|
:key="i"
|
||||||
|
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
|
||||||
|
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
|
||||||
|
>
|
||||||
|
{{ m.content }}
|
||||||
|
</div>
|
||||||
|
<div v-if="enviando" class="flex items-center gap-2">
|
||||||
|
<UiMascota estado="pensando" :tam="30" class="text-brand" />
|
||||||
|
<span class="text-xs text-tenue">Pensando…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="flex gap-2" @submit.prevent="enviar">
|
||||||
|
<input v-model="entrada" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
|
||||||
|
<button type="submit" :disabled="enviando" class="btn-primary disabled:opacity-50 transition-colors">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
conexiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
|
||||||
|
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
|
||||||
|
// mostraría estados distintos de lo mismo.
|
||||||
|
const emit = defineEmits(['recargar'])
|
||||||
|
|
||||||
|
function conectar(proveedor) {
|
||||||
|
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
|
||||||
|
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function desconectar(c) {
|
||||||
|
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="label mb-4">
|
||||||
|
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
|
||||||
|
Se soporta una cuenta activa a la vez.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
|
||||||
|
Conectar Google
|
||||||
|
</button>
|
||||||
|
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
|
||||||
|
Conectar Outlook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="conexiones.length === 0"
|
||||||
|
titulo="Sin cuentas conectadas"
|
||||||
|
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
|
||||||
|
/>
|
||||||
|
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
|
||||||
|
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
|
||||||
|
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
sesiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El historial se pide al abrir cada conversación, no de entrada: cargar todas
|
||||||
|
// las conversaciones completas para mostrar una lista sería traer de más.
|
||||||
|
const historial = ref([])
|
||||||
|
const sesionActiva = ref(null)
|
||||||
|
const cargandoHistorial = ref(false)
|
||||||
|
|
||||||
|
// El canal se deduce del prefijo de la sesión — es como los generan los
|
||||||
|
// canales (wa:, tg:) y el chat de prueba (staff-preview-). Lo demás es el
|
||||||
|
// widget de la web.
|
||||||
|
function canalDe(sessionId) {
|
||||||
|
const id = String(sessionId || '')
|
||||||
|
if (id.startsWith('wa:')) return { nombre: 'WhatsApp', icono: 'mensaje', clase: 'badge-ok' }
|
||||||
|
if (id.startsWith('tg:')) return { nombre: 'Telegram', icono: 'mensaje', clase: 'badge-neutro' }
|
||||||
|
if (id.startsWith('staff-preview')) return { nombre: 'Prueba', icono: 'escribir', clase: 'badge-alerta' }
|
||||||
|
return { nombre: 'Tu web', icono: 'sitio', clase: 'badge-neutro' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las pruebas propias se separan de las conversaciones reales: mezcladas,
|
||||||
|
// inflan la lista y el dueño no sabe cuáles son clientes de verdad.
|
||||||
|
const verPruebas = ref(false)
|
||||||
|
const reales = computed(() => props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')))
|
||||||
|
const pruebas = computed(() => props.sesiones.filter((s) => String(s.session_id).startsWith('staff-preview')))
|
||||||
|
const visibles = computed(() => (verPruebas.value ? props.sesiones : reales.value))
|
||||||
|
|
||||||
|
function cuando(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const hoy = new Date()
|
||||||
|
const dias = Math.floor((hoy.setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / 86400000)
|
||||||
|
const hora = new Date(iso).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (dias <= 0) return `hoy ${hora}`
|
||||||
|
if (dias === 1) return `ayer ${hora}`
|
||||||
|
if (dias < 7) return `hace ${dias} días`
|
||||||
|
return new Date(iso).toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verHistorial(sessionId) {
|
||||||
|
sesionActiva.value = sessionId
|
||||||
|
cargandoHistorial.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
|
||||||
|
historial.value = r.items || []
|
||||||
|
} finally {
|
||||||
|
cargandoHistorial.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid lg:grid-cols-[minmax(16rem,22rem)_1fr] gap-4 items-start">
|
||||||
|
<div class="card divide-y divide-borde max-h-[30rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="visibles.length === 0 && pruebas.length === 0"
|
||||||
|
titulo="Nadie escribió todavía"
|
||||||
|
detalle="Cuando alguien le hable a tu asistente —por WhatsApp, Telegram o tu web— la conversación aparece acá, con lo que preguntó y lo que se le respondió."
|
||||||
|
/>
|
||||||
|
<UiEmptyState
|
||||||
|
v-else-if="visibles.length === 0"
|
||||||
|
estado="durmiendo"
|
||||||
|
titulo="Ningún cliente escribió todavía"
|
||||||
|
:detalle="`Hay ${pruebas.length} ${pruebas.length === 1 ? 'conversación de prueba tuya' : 'conversaciones de prueba tuyas'} — activá el filtro de abajo para verlas.`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="s in visibles"
|
||||||
|
:key="s.session_id"
|
||||||
|
class="w-full text-left p-3 hover:bg-elevado transition-colors"
|
||||||
|
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
|
||||||
|
@click="verHistorial(s.session_id)"
|
||||||
|
>
|
||||||
|
<!-- Lo que abrió la conversación es el dato que importa: muestra qué
|
||||||
|
le preguntan de verdad al negocio. -->
|
||||||
|
<div class="text-sm text-texto line-clamp-2">
|
||||||
|
{{ s.primer_mensaje || '(sin mensaje)' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-1.5 text-xs text-tenue">
|
||||||
|
<span class="px-1.5 py-0.5 rounded" :class="canalDe(s.session_id).clase">
|
||||||
|
{{ canalDe(s.session_id).nombre }}
|
||||||
|
</span>
|
||||||
|
<span class="tabular-nums">{{ s.mensajes }} msj</span>
|
||||||
|
<span class="ml-auto tabular-nums">{{ cuando(s.ultimo) }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="pruebas.length"
|
||||||
|
class="flex items-center gap-2 p-3 text-xs text-tenue cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<input v-model="verPruebas" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
{{ pruebas.length === 1 ? 'Mostrar mi prueba' : `Mostrar mis ${pruebas.length} pruebas` }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 max-h-[30rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!sesionActiva"
|
||||||
|
estado="normal"
|
||||||
|
titulo="Elegí una conversación"
|
||||||
|
detalle="Vas a ver el intercambio completo: lo que escribió la persona y lo que contestó tu asistente. Si una respuesta no te gusta, corregí su información en «Lo que sabe»."
|
||||||
|
/>
|
||||||
|
<p v-else-if="cargandoHistorial" class="text-sm text-tenue">Cargando…</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div v-for="m in historial" :key="m.ID" :class="m.role === 'user' ? 'text-right' : ''">
|
||||||
|
<div
|
||||||
|
class="inline-block max-w-[80%] px-3 py-2 rounded-lg text-sm text-left whitespace-pre-wrap"
|
||||||
|
:class="m.role === 'user' ? 'bg-brand text-white' : 'bg-elevado text-texto'"
|
||||||
|
>
|
||||||
|
{{ m.content }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-tenue mt-0.5 tabular-nums" :class="m.role === 'user' ? 'pr-1' : 'pl-1'">
|
||||||
|
{{ cuando(m.CreatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
tools: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
|
||||||
|
const emit = defineEmits(['recargar', 'error'])
|
||||||
|
|
||||||
|
const showToolForm = ref(false)
|
||||||
|
const editingTool = ref(null)
|
||||||
|
const toolForm = ref(toolVacio())
|
||||||
|
|
||||||
|
function toolVacio() {
|
||||||
|
return {
|
||||||
|
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
|
||||||
|
tocarAuth: false, parametros: [], activa: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuevaTool() {
|
||||||
|
editingTool.value = null
|
||||||
|
toolForm.value = toolVacio()
|
||||||
|
showToolForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editarTool(t) {
|
||||||
|
editingTool.value = t
|
||||||
|
let parametros = []
|
||||||
|
try {
|
||||||
|
parametros = JSON.parse(t.parametros_json || '[]') || []
|
||||||
|
} catch {
|
||||||
|
parametros = []
|
||||||
|
}
|
||||||
|
toolForm.value = {
|
||||||
|
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
|
||||||
|
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
|
||||||
|
parametros, activa: t.activa,
|
||||||
|
}
|
||||||
|
showToolForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function agregarParametro() {
|
||||||
|
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitarParametro(i) {
|
||||||
|
toolForm.value.parametros.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarTool() {
|
||||||
|
const payload = {
|
||||||
|
agente_id: props.agenteId,
|
||||||
|
nombre: toolForm.value.nombre.trim(),
|
||||||
|
descripcion: toolForm.value.descripcion,
|
||||||
|
url: toolForm.value.url.trim(),
|
||||||
|
auth_header_nombre: toolForm.value.auth_header_nombre,
|
||||||
|
parametros: toolForm.value.parametros,
|
||||||
|
activa: toolForm.value.activa,
|
||||||
|
}
|
||||||
|
if (toolForm.value.tocarAuth) {
|
||||||
|
payload.auth_header_valor = toolForm.value.auth_header_valor
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingTool.value) {
|
||||||
|
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
|
||||||
|
} else {
|
||||||
|
await api.post(apiUmind('/umind/tools'), payload)
|
||||||
|
}
|
||||||
|
showToolForm.value = false
|
||||||
|
emit('recargar')
|
||||||
|
} catch (e) {
|
||||||
|
emit('error', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminarTool(t) {
|
||||||
|
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/tools/${t.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<p class="label">Máximo 10 herramientas activas por agente.</p>
|
||||||
|
<button class="btn-primary" @click="nuevaTool">
|
||||||
|
+ Nueva herramienta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="tools.length === 0"
|
||||||
|
titulo="Sin herramientas conectadas"
|
||||||
|
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
|
||||||
|
/>
|
||||||
|
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
|
||||||
|
<div class="label mt-0.5">{{ t.descripcion }}</div>
|
||||||
|
<div class="text-xs text-tenue mt-0.5">
|
||||||
|
{{ t.url }}
|
||||||
|
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
|
||||||
|
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-sm shrink-0">
|
||||||
|
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
|
||||||
|
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
|
||||||
|
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
|
||||||
|
<form class="space-y-3" @submit.prevent="guardarTool">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
|
||||||
|
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
|
||||||
|
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">URL del webhook (https)</label>
|
||||||
|
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-borde rounded-lg p-3 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="label">Parámetros que completa el modelo</label>
|
||||||
|
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
|
||||||
|
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
|
||||||
|
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs">
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="number">number</option>
|
||||||
|
<option value="boolean">boolean</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
|
||||||
|
<label class="label flex items-center gap-1">
|
||||||
|
<input v-model="p.requerido" type="checkbox" /> req.
|
||||||
|
</label>
|
||||||
|
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
|
||||||
|
</div>
|
||||||
|
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-borde rounded-lg p-3 space-y-2">
|
||||||
|
<label class="label">Autenticación saliente (opcional)</label>
|
||||||
|
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
|
||||||
|
<label class="flex items-center gap-2 label">
|
||||||
|
<input v-model="toolForm.tocarAuth" type="checkbox" />
|
||||||
|
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="toolForm.tocarAuth"
|
||||||
|
v-model="toolForm.auth_header_valor"
|
||||||
|
type="password"
|
||||||
|
placeholder="Valor del header (ej: Bearer xxxx)"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto">
|
||||||
|
<input v-model="toolForm.activa" type="checkbox" /> Activa
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-primary">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canales -->
|
||||||
|
</template>
|
||||||
+91
-24
@@ -19,7 +19,7 @@ type AiConfig struct {
|
|||||||
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
|
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
|
||||||
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
|
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
|
||||||
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
|
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez
|
IsActive bool `gorm:"default:true" json:"is_active"` // varias pueden estar activas: una por módulo
|
||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
// Modulo indica a qué servicio pertenece esta config.
|
// Modulo indica a qué servicio pertenece esta config.
|
||||||
// "" = global (disponible para todos como fallback)
|
// "" = global (disponible para todos como fallback)
|
||||||
@@ -108,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error {
|
|||||||
return app.Http.Database.DB.First(out, id).Error
|
return app.Http.Database.DB.First(out, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveAiConfig retorna la primera configuración activa del provider indicado.
|
|
||||||
// Si provider está vacío, retorna cualquier config activa.
|
|
||||||
func GetActiveAiConfig(provider string) (*AiConfig, error) {
|
|
||||||
var item AiConfig
|
|
||||||
db := app.Http.Database.DB.Where("is_active = ?", true)
|
|
||||||
if provider != "" {
|
|
||||||
db = db.Where("provider = ?", provider)
|
|
||||||
}
|
|
||||||
if err := db.First(&item).Error; err != nil {
|
|
||||||
log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
|
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
|
||||||
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
||||||
func SplitModulos(modulo string) []string {
|
func SplitModulos(modulo string) []string {
|
||||||
@@ -171,10 +156,40 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAiConfigsPorTenant lista las configs que cargó un cliente para su espacio.
|
||||||
|
// No incluye las globales del staff a propósito: el cliente las puede usar,
|
||||||
|
// pero no administrarlas, y mezclarlas acá invitaría a intentarlo.
|
||||||
|
func GetAiConfigsPorTenant(tenantID uint) ([]AiConfig, error) {
|
||||||
|
var items []AiConfig
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("tenant_id = ?", tenantID).
|
||||||
|
Order("nombre ASC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContarAgentesConAiConfig dice cuántos agentes dependen de una config. Sin
|
||||||
|
// esto, borrarla los dejaría apuntando a algo inexistente y cayendo al
|
||||||
|
// proveedor global sin que nadie se entere.
|
||||||
|
func ContarAgentesConAiConfig(aiConfigID uint) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := app.Http.Database.DB.Model(&UmindAgente{}).
|
||||||
|
Where("ai_config_id = ?", aiConfigID).Count(&n).Error
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
|
||||||
|
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
|
||||||
|
// gana depende del orden de la tabla, que no es una forma de elegir nada.
|
||||||
|
func QuitarAgenteBotSalvo(id uint) {
|
||||||
|
app.Http.Database.DB.Model(&AiConfig{}).
|
||||||
|
Where("id <> ? AND es_agente_bot = ?", id, true).
|
||||||
|
Update("es_agente_bot", false)
|
||||||
|
}
|
||||||
|
|
||||||
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
|
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
|
||||||
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
||||||
var ai AiConfig
|
var ai AiConfig
|
||||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
|
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
|
||||||
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
|
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
|
||||||
}
|
}
|
||||||
if ai.TelegramConfigID == nil {
|
if ai.TelegramConfigID == nil {
|
||||||
@@ -192,7 +207,7 @@ func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
|||||||
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
|
// asignado. La usa el chat propio del dashboard para compartir el mismo motor.
|
||||||
func GetAgenteBotAiConfig() (*AiConfig, error) {
|
func GetAgenteBotAiConfig() (*AiConfig, error) {
|
||||||
var ai AiConfig
|
var ai AiConfig
|
||||||
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil {
|
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
|
||||||
return nil, fmt.Errorf("no hay agente configurado: %w", err)
|
return nil, fmt.Errorf("no hay agente configurado: %w", err)
|
||||||
}
|
}
|
||||||
return &ai, nil
|
return &ai, nil
|
||||||
@@ -205,7 +220,15 @@ func GetAgenteBotAiConfig() (*AiConfig, error) {
|
|||||||
// 3. Cualquier config activa (último recurso)
|
// 3. Cualquier config activa (último recurso)
|
||||||
func GetAiConfigForService(service string) (*AiConfig, error) {
|
func GetAiConfigForService(service string) (*AiConfig, error) {
|
||||||
var items []AiConfig
|
var items []AiConfig
|
||||||
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
|
// tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su
|
||||||
|
// agente. Sin este filtro, la del cliente que no tiene módulo asignado
|
||||||
|
// —ninguna lo tiene— caía en el fallback global y terminaba atendiendo
|
||||||
|
// tareas nuestras: clasificar correos de soporte, importar plantillas, la
|
||||||
|
// vCard. Es decir, su cuenta pagando nuestro trabajo, sin que se entere
|
||||||
|
// ninguno de los dos.
|
||||||
|
if err := app.Http.Database.DB.
|
||||||
|
Where("is_active = ? AND tenant_id IS NULL", true).
|
||||||
|
Order("id ASC").Find(&items).Error; err != nil {
|
||||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
@@ -230,9 +253,48 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Cualquier config activa como último recurso
|
// 3. Cualquier config activa como último recurso, pero nunca una que esté
|
||||||
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
|
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
|
||||||
return &items[0], nil
|
// vectores y la de Whisper transcribe audio. Caer ahí daba errores del
|
||||||
|
// proveedor imposibles de relacionar con esta elección.
|
||||||
|
for i := range items {
|
||||||
|
if esConfigDeUsoEspecial(items[i].Modulo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
|
||||||
|
return &items[i], nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
|
||||||
|
// no sirven como comodín para otra cosa.
|
||||||
|
func esConfigDeUsoEspecial(modulo string) bool {
|
||||||
|
for _, m := range SplitModulos(modulo) {
|
||||||
|
if m == "whisper" || m == "umind_embeddings" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
|
||||||
|
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
|
||||||
|
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
|
||||||
|
// tiene fallback a la global y a cualquier activa.
|
||||||
|
func HayAiConfigParaModulo(modulo string) bool {
|
||||||
|
var items []AiConfig
|
||||||
|
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
for _, m := range SplitModulos(items[i].Modulo) {
|
||||||
|
if m == modulo {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
|
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
|
||||||
@@ -242,7 +304,8 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
|
|||||||
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
|
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
|
||||||
func GetWhisperConfig() (*AiConfig, error) {
|
func GetWhisperConfig() (*AiConfig, error) {
|
||||||
var items []AiConfig
|
var items []AiConfig
|
||||||
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
|
// Solo configs del staff, por lo mismo que GetAiConfigForService.
|
||||||
|
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||||
}
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -265,7 +328,11 @@ func GetWhisperConfig() (*AiConfig, error) {
|
|||||||
// en vez de caer a cualquier config activa.
|
// en vez de caer a cualquier config activa.
|
||||||
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
|
func GetUmindEmbeddingsConfig() (*AiConfig, error) {
|
||||||
var items []AiConfig
|
var items []AiConfig
|
||||||
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
|
// Global obligatoriamente: los vectores de todos los agentes tienen que
|
||||||
|
// salir del mismo modelo o la similitud coseno entre ellos no significa
|
||||||
|
// nada. Un cliente con su propio modelo de embeddings rompería su propia
|
||||||
|
// búsqueda sin ningún error visible.
|
||||||
|
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
|
||||||
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
|
||||||
}
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El fallback de GetAiConfigForService puede terminar usando una config que no
|
||||||
|
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
|
||||||
|
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
|
||||||
|
// no se parece en nada a la causa real.
|
||||||
|
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
|
||||||
|
casos := map[string]bool{
|
||||||
|
"whisper": true,
|
||||||
|
"umind_embeddings": true,
|
||||||
|
"landing,umind_embeddings": true,
|
||||||
|
"": false,
|
||||||
|
"ia": false,
|
||||||
|
"landing,query_runner": false,
|
||||||
|
}
|
||||||
|
for modulo, want := range casos {
|
||||||
|
if got := esConfigDeUsoEspecial(modulo); got != want {
|
||||||
|
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La consulta de todo resolvedor global tiene que excluir las configs de
|
||||||
|
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
|
||||||
|
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
|
||||||
|
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
|
||||||
|
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
|
||||||
|
//
|
||||||
|
// Se verifica sobre el código porque son consultas a base: acá no hay una.
|
||||||
|
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
|
||||||
|
fuente, err := os.ReadFile("ai_config.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
texto := string(fuente)
|
||||||
|
|
||||||
|
// Toda función que resuelve una config para uso del sistema.
|
||||||
|
resolvedores := []string{
|
||||||
|
"func GetAiConfigForService(",
|
||||||
|
"func GetWhisperConfig(",
|
||||||
|
"func GetUmindEmbeddingsConfig(",
|
||||||
|
"func GetAgenteBotAiConfig(",
|
||||||
|
"func GetAgenteBotConfig(",
|
||||||
|
"func HayAiConfigParaModulo(",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, firma := range resolvedores {
|
||||||
|
i := strings.Index(texto, firma)
|
||||||
|
if i < 0 {
|
||||||
|
t.Errorf("no encontré %s — ¿se renombró?", firma)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// El cuerpo hasta la próxima función de nivel superior.
|
||||||
|
resto := texto[i+len(firma):]
|
||||||
|
if j := strings.Index(resto, "\nfunc "); j > 0 {
|
||||||
|
resto = resto[:j]
|
||||||
|
}
|
||||||
|
if !strings.Contains(resto, "tenant_id IS NULL") {
|
||||||
|
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,19 +9,26 @@ import (
|
|||||||
|
|
||||||
type ProyectoTicket struct {
|
type ProyectoTicket struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||||
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||||
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
||||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||||
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
|
||||||
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
|
||||||
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
||||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||||
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
|
||||||
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
|
||||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
// ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
|
||||||
|
// en nil el que escribió no está registrado: es un contacto externo, y eso
|
||||||
|
// también es información (no hay un campo aparte para "externo", es esto).
|
||||||
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||||
|
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
||||||
|
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
|
||||||
|
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
|
||||||
|
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
|
||||||
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
|||||||
|
|
||||||
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||||
var items []ProyectoTicket
|
var items []ProyectoTicket
|
||||||
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC")
|
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
|
||||||
if estado != "" && estado != "todos" {
|
if estado != "" && estado != "todos" {
|
||||||
db = db.Where("estado = ?", estado)
|
db = db.Where("estado = ?", estado)
|
||||||
}
|
}
|
||||||
@@ -142,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
|
|||||||
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
|
||||||
Update("leido_portal", true).Error
|
Update("leido_portal", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
|
||||||
|
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
|
||||||
|
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
|
||||||
|
// equivocado.
|
||||||
|
func GetClientePorEmail(email string) (*Cliente, error) {
|
||||||
|
var c Cliente
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
|
||||||
|
First(&c).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,23 +7,51 @@ import (
|
|||||||
|
|
||||||
type SoporteWebhookConfig struct {
|
type SoporteWebhookConfig struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||||
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
|
||||||
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
|
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
|
||||||
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
|
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
|
||||||
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
|
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
|
||||||
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
|
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
|
||||||
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
|
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
|
||||||
|
|
||||||
// SMTP salida para notificaciones y auto-respuesta
|
// SMTP salida para notificaciones y auto-respuesta
|
||||||
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
|
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
|
||||||
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
|
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
|
||||||
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
|
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
|
||||||
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
|
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
|
||||||
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
|
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
|
||||||
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
|
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
|
||||||
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
|
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
|
||||||
|
|
||||||
|
// IMAP entrante: leer el buzón directamente en vez de depender de que un
|
||||||
|
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
|
||||||
|
// cliente que escribe a soporte@ desde su correo de siempre.
|
||||||
|
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
|
||||||
|
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
|
||||||
|
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
|
||||||
|
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
|
||||||
|
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
|
||||||
|
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
|
||||||
|
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
|
||||||
|
// Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro
|
||||||
|
// de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la
|
||||||
|
// primera corrida se coma años de correo viejo.
|
||||||
|
ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"`
|
||||||
|
|
||||||
|
// Filtro con IA: no todo lo que llega al buzón es soporte (newsletters,
|
||||||
|
// notificaciones de bancos, facturas de proveedores). Si está prendido, se
|
||||||
|
// clasifica cada correo nuevo antes de abrir ticket.
|
||||||
|
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
|
||||||
|
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
|
||||||
|
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
|
||||||
|
// de respuesta. Sin agente el borrador se arma solo con la conversación.
|
||||||
|
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
|
||||||
|
|
||||||
|
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
|
||||||
|
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
|
||||||
|
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
|
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
|
||||||
@@ -42,7 +70,13 @@ func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
|
|||||||
|
|
||||||
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
|
||||||
if s.ID > 0 {
|
if s.ID > 0 {
|
||||||
return app.Http.Database.DB.Model(s).Updates(s).Error
|
// Select("*") para que los booleanos en false (activo, responder_auto,
|
||||||
|
// imap_activo) también se guarden: Updates con struct ignora los ceros,
|
||||||
|
// así que desactivar algo no tenía efecto.
|
||||||
|
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
|
||||||
|
Where("id = ?", s.ID).
|
||||||
|
Select("*").Omit("id", "created_at", "deleted_at").
|
||||||
|
Updates(s).Error
|
||||||
}
|
}
|
||||||
return app.Http.Database.DB.Create(s).Error
|
return app.Http.Database.DB.Create(s).Error
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-21
@@ -31,6 +31,23 @@ type UmindTenant struct {
|
|||||||
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
|
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
|
||||||
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
|
||||||
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
|
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
|
||||||
|
// UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se
|
||||||
|
// pasó el tope de consumo, para no repetirlo por cada mensaje. En la base
|
||||||
|
// y no en memoria: con despliegues varias veces al día, un flag en RAM se
|
||||||
|
// borra seguido y el cliente recibe el mismo aviso una y otra vez.
|
||||||
|
UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve
|
||||||
|
// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo.
|
||||||
|
//
|
||||||
|
// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes
|
||||||
|
// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa.
|
||||||
|
func MarcarAvisoTope(tenantID uint, periodo string) bool {
|
||||||
|
res := app.Http.Database.DB.Model(&UmindTenant{}).
|
||||||
|
Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo).
|
||||||
|
Update("ultimo_aviso_tope", periodo)
|
||||||
|
return res.Error == nil && res.RowsAffected > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UmindTenant) TableName() string { return "umind_tenants" }
|
func (UmindTenant) TableName() string { return "umind_tenants" }
|
||||||
@@ -131,11 +148,38 @@ func (t *UmindTenant) DominioPermitido(host string) bool {
|
|||||||
type UmindDocumento struct {
|
type UmindDocumento struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo
|
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
|
||||||
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo
|
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
|
||||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
||||||
Error string `json:"error" gorm:"column:error;type:text"`
|
Error string `json:"error" gorm:"column:error;type:text"`
|
||||||
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
||||||
|
// Contenido guarda el texto de las fuentes que no se pueden volver a
|
||||||
|
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
|
||||||
|
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
|
||||||
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
|
||||||
|
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
|
||||||
|
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
|
||||||
|
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
|
||||||
|
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
|
||||||
|
// que delata que la fuente quedó vieja.
|
||||||
|
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
|
||||||
|
// AutoActualizar deja que el cron la vuelva a procesar sola.
|
||||||
|
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
|
||||||
|
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
|
||||||
|
// pintar una lista entera.
|
||||||
|
func NombresDeTenantsUmind() map[uint]string {
|
||||||
|
var filas []UmindTenant
|
||||||
|
out := map[uint]string{}
|
||||||
|
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, f := range filas {
|
||||||
|
out[f.ID] = f.Nombre
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
||||||
@@ -159,11 +203,32 @@ func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
||||||
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"estado": estado,
|
"estado": estado,
|
||||||
"error": errMsg,
|
"error": errMsg,
|
||||||
"total_chunks": totalChunks,
|
"total_chunks": totalChunks,
|
||||||
}).Error
|
}
|
||||||
|
if estado == "listo" {
|
||||||
|
ahora := time.Now()
|
||||||
|
updates["procesado_at"] = &ahora
|
||||||
|
}
|
||||||
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
|
||||||
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
|
||||||
|
// se procesan desde hace más de los días indicados.
|
||||||
|
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
|
||||||
|
var items []UmindDocumento
|
||||||
|
corte := time.Now().AddDate(0, 0, -dias)
|
||||||
|
err := app.Http.Database.DB.
|
||||||
|
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
|
||||||
|
Where("procesado_at IS NULL OR procesado_at < ?", corte).
|
||||||
|
Find(&items).Error
|
||||||
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteUmindDocumento(id uint) error {
|
func DeleteUmindDocumento(id uint) error {
|
||||||
@@ -206,6 +271,13 @@ func EmbeddingFromJSON(s string) ([]float32, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
|
||||||
|
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
|
||||||
|
// en la búsqueda, y la vieja puede ganar.
|
||||||
|
func BorrarChunksDeDocumento(documentoID uint) error {
|
||||||
|
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func CreateUmindChunks(chunks []UmindChunk) error {
|
func CreateUmindChunks(chunks []UmindChunk) error {
|
||||||
if len(chunks) == 0 {
|
if len(chunks) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -254,23 +326,6 @@ func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje,
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUmindSesiones lista las sesiones de conversación recientes de un agente
|
|
||||||
// (para el panel admin), con el último mensaje como resumen.
|
|
||||||
func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
|
|
||||||
var items []UmindMensaje
|
|
||||||
err := app.Http.Database.DB.Raw(`
|
|
||||||
SELECT * FROM (
|
|
||||||
SELECT DISTINCT ON (session_id) *
|
|
||||||
FROM umind_mensajes
|
|
||||||
WHERE agente_id = ? AND deleted_at IS NULL
|
|
||||||
ORDER BY session_id, created_at DESC
|
|
||||||
) ultimos
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, agenteID, limit).Scan(&items).Error
|
|
||||||
return items, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SesionResumen es una conversación vista desde el reporte: cuándo empezó,
|
// SesionResumen es una conversación vista desde el reporte: cuándo empezó,
|
||||||
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
|
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
|
||||||
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
|
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
|
||||||
@@ -279,6 +334,36 @@ type SesionResumen struct {
|
|||||||
Inicio time.Time `json:"inicio"`
|
Inicio time.Time `json:"inicio"`
|
||||||
Mensajes int64 `json:"mensajes"`
|
Mensajes int64 `json:"mensajes"`
|
||||||
PrimerMensaje string `json:"primer_mensaje"`
|
PrimerMensaje string `json:"primer_mensaje"`
|
||||||
|
// Ultimo es la última actividad: es lo que ordena la bandeja del panel.
|
||||||
|
// El reporte mensual no lo usa (ordena por inicio) y ahí queda en cero.
|
||||||
|
Ultimo time.Time `json:"ultimo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUmindSesionesPanel es la bandeja de conversaciones del panel: las más
|
||||||
|
// recientes primero, con lo que el visitante preguntó al abrir — no con la
|
||||||
|
// última respuesta del bot, que era lo que se mostraba antes y no decía nada
|
||||||
|
// ("¡Hola! ¿En qué te ayudo?" cincuenta veces).
|
||||||
|
func GetUmindSesionesPanel(agenteID uint, limit int) ([]SesionResumen, error) {
|
||||||
|
var items []SesionResumen
|
||||||
|
err := app.Http.Database.DB.Raw(`
|
||||||
|
SELECT
|
||||||
|
m.session_id,
|
||||||
|
MIN(m.created_at) AS inicio,
|
||||||
|
MAX(m.created_at) AS ultimo,
|
||||||
|
COUNT(*) AS mensajes,
|
||||||
|
COALESCE((
|
||||||
|
SELECT p.content FROM umind_mensajes p
|
||||||
|
WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id
|
||||||
|
AND p.role = 'user' AND p.deleted_at IS NULL
|
||||||
|
ORDER BY p.created_at ASC LIMIT 1
|
||||||
|
), '') AS primer_mensaje
|
||||||
|
FROM umind_mensajes m
|
||||||
|
WHERE m.agente_id = ? AND m.deleted_at IS NULL
|
||||||
|
GROUP BY m.session_id, m.agente_id
|
||||||
|
ORDER BY ultimo DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, agenteID, limit).Scan(&items).Error
|
||||||
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
|
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
|
||||||
|
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
|
||||||
|
// conocimiento salen los borradores de soporte).
|
||||||
|
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
|
||||||
|
var items []UmindAgente
|
||||||
|
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
||||||
var a UmindAgente
|
var a UmindAgente
|
||||||
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ type UmindCanal struct {
|
|||||||
// antes de pasarlos al agente, en vez de ignorarse.
|
// antes de pasarlos al agente, en vez de ignorarse.
|
||||||
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
|
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
|
||||||
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
|
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
|
||||||
|
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
|
||||||
|
// le pasa al agente como si el cliente lo hubiera escrito.
|
||||||
|
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UmindCanal) TableName() string { return "umind_canales" }
|
func (UmindCanal) TableName() string { return "umind_canales" }
|
||||||
|
|||||||
+30
-2
@@ -33,6 +33,11 @@ type UmindUso struct {
|
|||||||
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
|
Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
|
||||||
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
|
// FacturadoAt null = pendiente de cobrar en el próximo ciclo.
|
||||||
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
|
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
|
||||||
|
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
|
||||||
|
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
|
||||||
|
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
|
||||||
|
// cobrar dos veces por lo mismo.
|
||||||
|
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UmindUso) TableName() string { return "umind_uso" }
|
func (UmindUso) TableName() string { return "umind_uso" }
|
||||||
@@ -55,12 +60,22 @@ func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad stri
|
|||||||
if plan != nil {
|
if plan != nil {
|
||||||
moneda = plan.Moneda
|
moneda = plan.Moneda
|
||||||
}
|
}
|
||||||
costo := costoDeUso(plan, tipo, cantidad)
|
|
||||||
|
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
|
||||||
|
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
|
||||||
|
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
|
||||||
|
// servicios, los use quien los use.
|
||||||
|
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
|
||||||
|
|
||||||
|
costo := 0.0
|
||||||
|
if !propia {
|
||||||
|
costo = costoDeUso(plan, tipo, cantidad)
|
||||||
|
}
|
||||||
|
|
||||||
uso := &UmindUso{
|
uso := &UmindUso{
|
||||||
TenantID: agente.TenantID, AgenteID: agenteID,
|
TenantID: agente.TenantID, AgenteID: agenteID,
|
||||||
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
|
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
|
||||||
Costo: costo, Moneda: moneda,
|
Costo: costo, Moneda: moneda, CuentaPropia: propia,
|
||||||
}
|
}
|
||||||
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
|
if err := app.Http.Database.DB.Create(uso).Error; err != nil {
|
||||||
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
|
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
|
||||||
@@ -204,3 +219,16 @@ func MarcarUsoFacturadoPorCliente(clienteID uint) {
|
|||||||
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
|
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
|
||||||
|
// el cliente (con tenant), en vez de a una nuestra.
|
||||||
|
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
|
||||||
|
if agente == nil || agente.AiConfigID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var cfg AiConfig
|
||||||
|
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return cfg.TenantID != nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtraerTextoDeArchivo saca el texto de un archivo cualquiera para dárselo al
|
||||||
|
// agente. Es el equivalente de Whisper para audio y OCR para imágenes, pero
|
||||||
|
// para documentos.
|
||||||
|
//
|
||||||
|
// agenteID identifica a quién cobrarle si hace falta pasar por OCR; 0 = no medir.
|
||||||
|
func ExtraerTextoDeArchivo(agenteID uint, nombreArchivo string, datos []byte) (string, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(nombreArchivo))
|
||||||
|
switch ext {
|
||||||
|
case ".txt", ".md", ".csv", ".json", ".xml", ".log", ".html", ".htm":
|
||||||
|
return string(datos), nil
|
||||||
|
case ".docx":
|
||||||
|
return textoDeDocx(datos)
|
||||||
|
case ".pdf":
|
||||||
|
return textoDePDF(agenteID, datos)
|
||||||
|
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
|
||||||
|
return ExtraerTextoOCR(agenteID, datos, mimeDeImagen(ext))
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("no sé leer archivos %s; probá con PDF, Word (.docx), texto o una imagen", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeDeImagen(ext string) string {
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".tif", ".tiff":
|
||||||
|
return "image/tiff"
|
||||||
|
default:
|
||||||
|
return "image/" + strings.TrimPrefix(ext, ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
|
||||||
|
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
|
||||||
|
// conservar el formato: el agente solo necesita el contenido y el orden.
|
||||||
|
func textoDeDocx(datos []byte) (string, error) {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name != "word/document.xml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := string(xmlBytes)
|
||||||
|
// Un párrafo, un salto de línea explícito y un fin de fila valen como
|
||||||
|
// salto; las celdas se separan con tab. El resto de etiquetas se tira.
|
||||||
|
s = strings.NewReplacer("</w:p>", "\n", "<w:br/>", "\n", "</w:tr>", "\n", "</w:tc>", "\t").Replace(s)
|
||||||
|
s = etiquetaXML.ReplaceAllString(s, "")
|
||||||
|
s = strings.NewReplacer("&", "&", "<", "<", ">", ">", """, `"`, "'", "'").Replace(s)
|
||||||
|
return strings.TrimSpace(s), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// textoDePDF saca el texto de un PDF digital (facturas, cotizaciones, cualquier
|
||||||
|
// cosa exportada por un programa) leyendo los operadores de texto de sus
|
||||||
|
// streams. Si el PDF es un escaneo no hay texto que leer, y ahí cae al OCR.
|
||||||
|
//
|
||||||
|
// ponytail: parser mínimo — entiende streams FlateDecode y los operadores Tj/TJ,
|
||||||
|
// que es lo que usan los PDFs generados por software. No maneja fuentes con
|
||||||
|
// codificaciones raras ni CID; para esos casos el fallback a OCR es la salida.
|
||||||
|
func textoDePDF(agenteID uint, datos []byte) (string, error) {
|
||||||
|
texto := strings.TrimSpace(textoDeStreamsPDF(datos))
|
||||||
|
// Un PDF escaneado devuelve nada o cuatro letras sueltas de un encabezado.
|
||||||
|
if len([]rune(texto)) >= 40 {
|
||||||
|
return texto, nil
|
||||||
|
}
|
||||||
|
ocrTexto, err := ExtraerTextoOCR(agenteID, datos, "application/pdf")
|
||||||
|
if err != nil {
|
||||||
|
if texto != "" {
|
||||||
|
return texto, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("el PDF no tiene texto legible y el OCR no pudo procesarlo: %w", err)
|
||||||
|
}
|
||||||
|
return ocrTexto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)endstream`)
|
||||||
|
|
||||||
|
func textoDeStreamsPDF(datos []byte) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for _, m := range streamRe.FindAllSubmatch(datos, -1) {
|
||||||
|
crudo := m[1]
|
||||||
|
contenido := crudo
|
||||||
|
if zr, err := zlib.NewReader(bytes.NewReader(crudo)); err == nil {
|
||||||
|
if inflado, err := io.ReadAll(io.LimitReader(zr, 16<<20)); err == nil {
|
||||||
|
contenido = inflado
|
||||||
|
}
|
||||||
|
zr.Close()
|
||||||
|
}
|
||||||
|
if !bytes.Contains(contenido, []byte("Tj")) && !bytes.Contains(contenido, []byte("TJ")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.WriteString(textoDeContenidoPDF(contenido))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// textoDeContenidoPDF junta las cadenas entre paréntesis de un content stream,
|
||||||
|
// que es donde vive el texto visible, y respeta los saltos de línea (T*, TD, Td).
|
||||||
|
func textoDeContenidoPDF(contenido []byte) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for i := 0; i < len(contenido); i++ {
|
||||||
|
switch contenido[i] {
|
||||||
|
case '(':
|
||||||
|
var s strings.Builder
|
||||||
|
for i++; i < len(contenido); i++ {
|
||||||
|
c := contenido[i]
|
||||||
|
if c == '\\' && i+1 < len(contenido) {
|
||||||
|
i++
|
||||||
|
switch contenido[i] {
|
||||||
|
case 'n':
|
||||||
|
s.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
s.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
default:
|
||||||
|
s.WriteByte(contenido[i])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ')' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.WriteByte(c)
|
||||||
|
}
|
||||||
|
out.WriteString(s.String())
|
||||||
|
case 'T':
|
||||||
|
// T* / Td / TD mueven el cursor a otra línea.
|
||||||
|
if i+1 < len(contenido) {
|
||||||
|
switch contenido[i+1] {
|
||||||
|
case '*', 'd', 'D':
|
||||||
|
out.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limpiarNoImprimibles(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func limpiarNoImprimibles(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\t' || unicode.IsPrint(r) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextoDeArchivoParaAgente arma el mensaje que ve el agente cuando alguien le
|
||||||
|
// manda un documento: el contenido solo, sin contexto, hace que el modelo
|
||||||
|
// conteste como si el cliente hubiera escrito una factura.
|
||||||
|
func TextoDeArchivoParaAgente(nombreArchivo, caption, contenido string) string {
|
||||||
|
if len(contenido) > 30000 {
|
||||||
|
contenido = contenido[:30000] + "\n…(archivo recortado)"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("El cliente adjuntó un archivo")
|
||||||
|
if nombreArchivo != "" {
|
||||||
|
b.WriteString(" llamado \"" + nombreArchivo + "\"")
|
||||||
|
}
|
||||||
|
b.WriteString(".")
|
||||||
|
if strings.TrimSpace(caption) != "" {
|
||||||
|
b.WriteString(" Escribió junto al archivo: " + strings.TrimSpace(caption))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nContenido del archivo:\n---\n" + strings.TrimSpace(contenido) + "\n---")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pdfDePrueba arma un PDF mínimo con el content stream comprimido, igual que
|
||||||
|
// los que genera cualquier programa que exporta a PDF.
|
||||||
|
func pdfDePrueba(t *testing.T, contenido string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var comp bytes.Buffer
|
||||||
|
zw := zlib.NewWriter(&comp)
|
||||||
|
if _, err := zw.Write([]byte(contenido)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
|
||||||
|
var pdf bytes.Buffer
|
||||||
|
pdf.WriteString("%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n")
|
||||||
|
pdf.WriteString("2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n")
|
||||||
|
pdf.WriteString("3 0 obj<</Type/Page/Parent 2 0 R/Contents 4 0 R>>endobj\n")
|
||||||
|
pdf.WriteString("4 0 obj<</Length " + strconv.Itoa(comp.Len()) + "/Filter/FlateDecode>>stream\n")
|
||||||
|
pdf.Write(comp.Bytes())
|
||||||
|
pdf.WriteString("\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF")
|
||||||
|
return pdf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraerTextoDeArchivoPDFDigital(t *testing.T) {
|
||||||
|
contenido := `BT /F1 12 Tf 72 720 Td (FACTURA DE VENTA No. 1042) Tj T* ` +
|
||||||
|
`(Cliente: Acme SAS NIT 900.123.456-7) Tj T* (Total: \$1.500.000 COP) Tj ET`
|
||||||
|
|
||||||
|
got, err := ExtraerTextoDeArchivo(0, "factura.pdf", pdfDePrueba(t, contenido))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtraerTextoDeArchivo: %v", err)
|
||||||
|
}
|
||||||
|
for _, quiero := range []string{"FACTURA DE VENTA No. 1042", "Acme SAS", "NIT 900.123.456-7", "$1.500.000 COP"} {
|
||||||
|
if !strings.Contains(got, quiero) {
|
||||||
|
t.Errorf("falta %q en el texto extraído:\n%s", quiero, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// T* separa renglones: sin eso la factura llega al agente como un chorizo.
|
||||||
|
if lineas := strings.Count(strings.TrimSpace(got), "\n"); lineas < 2 {
|
||||||
|
t.Errorf("esperaba al menos 3 renglones, hay %d:\n%s", lineas+1, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraerTextoDeArchivoTexto(t *testing.T) {
|
||||||
|
got, err := ExtraerTextoDeArchivo(0, "notas.txt", []byte("hola\nmundo"))
|
||||||
|
if err != nil || got != "hola\nmundo" {
|
||||||
|
t.Errorf("got %q, err %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := ExtraerTextoDeArchivo(0, "cosa.exe", []byte("x")); err == nil {
|
||||||
|
t.Error("una extensión desconocida debería devolver error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextoDeArchivoParaAgente(t *testing.T) {
|
||||||
|
got := TextoDeArchivoParaAgente("factura.pdf", "me cobraron de más", "Total: 1000")
|
||||||
|
for _, quiero := range []string{"factura.pdf", "me cobraron de más", "Total: 1000"} {
|
||||||
|
if !strings.Contains(got, quiero) {
|
||||||
|
t.Errorf("falta %q en:\n%s", quiero, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,22 @@ func IniciarCron() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conocimiento de los agentes que se actualiza solo — 4 AM, cuando nadie
|
||||||
|
// está mirando: recrawlear varios sitios no es gratis.
|
||||||
|
if _, err := cronScheduler.AddFunc("0 4 * * *", RefrescarConocimientoUmind); err != nil {
|
||||||
|
log.Printf("[CRON] Error registrando tarea refresco_conocimiento: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
|
||||||
|
// configurado, así que registrarlo siempre no cuesta.
|
||||||
|
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
|
||||||
|
log.Printf("[CRON] Error registrando tarea soporte_imap: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cronScheduler.Start()
|
cronScheduler.Start()
|
||||||
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min, buzón de soporte cada 2min")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetenerCron para graceful shutdown
|
// DetenerCron para graceful shutdown
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompletarTextoIA hace una llamada simple (sin tools, sin streaming) al
|
||||||
|
// proveedor configurado para el servicio dado y devuelve el texto.
|
||||||
|
//
|
||||||
|
// Reusa callAI, que es el mismo despachador del bot de Telegram: sabe hablar
|
||||||
|
// Anthropic y OpenAI-compatible, y completa la URL base cuando la config la
|
||||||
|
// tiene vacía. Escribir una segunda implementación acá fue un error: no
|
||||||
|
// soportaba Anthropic y armaba una URL relativa cuando faltaba la base, así
|
||||||
|
// que fallaba con la config que ya estaba en producción.
|
||||||
|
func CompletarTextoIA(servicio, sistema, usuario string) (string, error) {
|
||||||
|
config, err := models.GetAiConfigForService(servicio)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no hay configuración de IA activa; configurá una en /app/ai-config")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.ModelName) == "" {
|
||||||
|
return "", fmt.Errorf("la configuración de IA %q no tiene modelo definido (/app/ai-config)", config.Nombre)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, _, err := callAI(config, []agentMessage{
|
||||||
|
{Role: "system", Content: sistema},
|
||||||
|
{Role: "user", Content: usuario},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s (%s): %w", config.Nombre, config.Provider, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
texto, _ := msg.Content.(string)
|
||||||
|
if strings.TrimSpace(texto) == "" {
|
||||||
|
return "", fmt.Errorf("%s no devolvió texto", config.Nombre)
|
||||||
|
}
|
||||||
|
return texto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbarAiConfig manda una consulta mínima a una configuración concreta y
|
||||||
|
// devuelve lo que contestó.
|
||||||
|
//
|
||||||
|
// A diferencia de CompletarTextoIA, no resuelve la config por módulo: recibe la
|
||||||
|
// que hay que probar. Es lo que necesita alguien que acaba de cargar su cuenta
|
||||||
|
// y quiere saber si la clave sirve, antes de que lo descubra un cliente real.
|
||||||
|
func ProbarAiConfig(cfg *models.AiConfig) (string, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return "", fmt.Errorf("no hay configuración que probar")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ModelName) == "" {
|
||||||
|
return "", fmt.Errorf("falta el modelo en esta configuración")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, _, err := callAI(cfg, []agentMessage{
|
||||||
|
{Role: "system", Content: "Respondé con una sola palabra."},
|
||||||
|
{Role: "user", Content: "Decí \"listo\" si me estás leyendo."},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s rechazó la consulta: %w", cfg.Provider, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
texto, _ := msg.Content.(string)
|
||||||
|
if strings.TrimSpace(texto) == "" {
|
||||||
|
return "", fmt.Errorf("%s respondió, pero sin texto: revisá el modelo", cfg.Provider)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(texto), nil
|
||||||
|
}
|
||||||
@@ -10,10 +10,13 @@ import (
|
|||||||
|
|
||||||
// ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
|
// ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
|
||||||
// Notifica al admin cuando un portal user crea un ticket.
|
// Notifica al admin cuando un portal user crea un ticket.
|
||||||
|
// portalUser puede ser nil: los tickets que entran por correo no tienen usuario
|
||||||
|
// de portal detrás, y no por eso hay que dejar de avisar.
|
||||||
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
|
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
|
||||||
if ticket == nil || portalUser == nil {
|
if ticket == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = portalUser
|
||||||
cfg := models.GetNotifConfig("ticket_nuevo", "admin")
|
cfg := models.GetNotifConfig("ticket_nuevo", "admin")
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
@@ -409,10 +412,22 @@ func sendTelegramAdmin(mensaje string) {
|
|||||||
log.Printf("[Notif] Error obteniendo configs telegram: %v", err)
|
log.Printf("[Notif] Error obteniendo configs telegram: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Un mismo chat puede estar cargado en más de una configuración (dos bots
|
||||||
|
// apuntando al mismo grupo, o la misma config duplicada). Sin esto, cada
|
||||||
|
// aviso llega repetido tantas veces como filas haya.
|
||||||
|
yaEnviado := map[string]bool{}
|
||||||
|
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
if !cfg.Activo {
|
if !cfg.Activo {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
destino := cfg.BotToken + "→" + cfg.ChatID
|
||||||
|
if yaEnviado[destino] {
|
||||||
|
log.Printf("[Notif] Chat %s repetido en la config %d (%s), no se manda de nuevo", cfg.ChatID, cfg.ID, cfg.Nombre)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
yaEnviado[destino] = true
|
||||||
|
|
||||||
ts := &TelegramService{BotToken: cfg.BotToken}
|
ts := &TelegramService{BotToken: cfg.BotToken}
|
||||||
sendErr := ts.SendMessage(cfg.ChatID, mensaje)
|
sendErr := ts.SendMessage(cfg.ChatID, mensaje)
|
||||||
logEntry := &models.TelegramLog{
|
logEntry := &models.TelegramLog{
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// variablesPorTipo documenta, para la IA, qué campos recibe cada plantilla al
|
||||||
|
// renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador
|
||||||
|
// (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service).
|
||||||
|
var variablesPorTipo = map[string]string{
|
||||||
|
"cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
|
||||||
|
{{.Alcance}}, {{.TipoProyecto}}, {{.Total}},
|
||||||
|
{{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`,
|
||||||
|
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
|
||||||
|
"acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`,
|
||||||
|
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
|
||||||
|
|
||||||
|
// ExtraerTextoDePlantilla lee el archivo que subió el admin como referencia.
|
||||||
|
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
|
||||||
|
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
|
||||||
|
return ExtraerTextoDeArchivo(0, nombreArchivo, datos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
|
||||||
|
// HTML con las variables Go que usa el generador. Lo que devuelve va al editor
|
||||||
|
// para que el admin lo revise antes de guardar: no se guarda solo.
|
||||||
|
func ConvertirEnPlantilla(tipo, textoDocumento string) (string, error) {
|
||||||
|
if strings.TrimSpace(textoDocumento) == "" {
|
||||||
|
return "", fmt.Errorf("no se pudo leer texto del archivo")
|
||||||
|
}
|
||||||
|
if len(textoDocumento) > 20000 {
|
||||||
|
textoDocumento = textoDocumento[:20000]
|
||||||
|
}
|
||||||
|
vars := variablesPorTipo[tipo]
|
||||||
|
if vars == "" {
|
||||||
|
vars = variablesComunes
|
||||||
|
}
|
||||||
|
|
||||||
|
sistema := `Sos un asistente que convierte documentos en plantillas HTML para Go text/template.
|
||||||
|
Reglas:
|
||||||
|
- Devolvé SOLO el HTML de la plantilla, sin explicaciones y sin bloques de código markdown.
|
||||||
|
- Reemplazá los datos concretos del documento (nombres, NITs, fechas, montos, ítems) por las variables de la lista. Lo que sea texto fijo del formato se deja tal cual.
|
||||||
|
- Si el documento tiene una tabla de ítems, usá {{range .Items}}…{{end}} para las filas.
|
||||||
|
- Usá estilos inline (style="…"), sin CSS externo ni <script>: el HTML se convierte a PDF.
|
||||||
|
- No inventes variables que no estén en la lista.`
|
||||||
|
|
||||||
|
usuario := fmt.Sprintf(`Tipo de documento: %s
|
||||||
|
|
||||||
|
Variables disponibles:
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
|
||||||
|
Documento de referencia:
|
||||||
|
---
|
||||||
|
%s
|
||||||
|
---`, tipo, variablesComunes, vars, textoDocumento)
|
||||||
|
|
||||||
|
// Si el admin asignó una config al módulo "plantillas" se usa esa; si no,
|
||||||
|
// sigue saliendo por "ia" como hasta ahora. Convertir un documento entero
|
||||||
|
// pide un modelo más capaz que el resto de las tareas, y esto deja
|
||||||
|
// elegirlo sin tocar lo que ya usa la vCard.
|
||||||
|
modulo := "ia"
|
||||||
|
if models.HayAiConfigParaModulo("plantillas") {
|
||||||
|
modulo = "plantillas"
|
||||||
|
}
|
||||||
|
|
||||||
|
salida, err := CompletarTextoIA(modulo, sistema, usuario)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return limpiarCercaDeCodigo(salida), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limpiarCercaDeCodigo saca el ```html … ``` que los modelos agregan aunque se
|
||||||
|
// les pida que no lo hagan.
|
||||||
|
func limpiarCercaDeCodigo(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if !strings.HasPrefix(s, "```") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if i := strings.Index(s, "\n"); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(s, "```"); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func docxDePrueba(t *testing.T, documentXML string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
w, err := zw.Create("word/document.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(documentXML)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraerTextoDePlantillaDocx(t *testing.T) {
|
||||||
|
xml := `<?xml version="1.0"?><w:document><w:body>` +
|
||||||
|
`<w:p><w:r><w:t>COTIZACI&Oacute;N</w:t></w:r></w:p>` +
|
||||||
|
`<w:p><w:r><w:t>Cliente: </w:t></w:r><w:r><w:t>Acme SAS</w:t></w:r></w:p>` +
|
||||||
|
`<w:p><w:r><w:t>Total: $1.500.000</w:t></w:r></w:p>` +
|
||||||
|
`</w:body></w:document>`
|
||||||
|
|
||||||
|
got, err := ExtraerTextoDePlantilla("cotizacion.docx", docxDePrueba(t, xml))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtraerTextoDePlantilla: %v", err)
|
||||||
|
}
|
||||||
|
// Los runs de un mismo párrafo quedan pegados; los párrafos, en líneas.
|
||||||
|
if !strings.Contains(got, "Cliente: Acme SAS") {
|
||||||
|
t.Errorf("falta la línea del cliente en:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Total: $1.500.000") {
|
||||||
|
t.Errorf("falta el total en:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "<w:") {
|
||||||
|
t.Errorf("quedaron etiquetas XML en:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
|
||||||
|
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
|
||||||
|
t.Error("una extensión desconocida debería devolver error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimpiarCercaDeCodigo(t *testing.T) {
|
||||||
|
casos := map[string]string{
|
||||||
|
"```html\n<p>hola</p>\n```": "<p>hola</p>",
|
||||||
|
"```\n<p>hola</p>\n```": "<p>hola</p>",
|
||||||
|
"<p>hola</p>": "<p>hola</p>",
|
||||||
|
}
|
||||||
|
for in, want := range casos {
|
||||||
|
if got := limpiarCercaDeCodigo(in); got != want {
|
||||||
|
t.Errorf("limpiarCercaDeCodigo(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderizarPlantillaEjemplo ejecuta la plantilla con datos de muestra y
|
||||||
|
// devuelve el HTML resultante.
|
||||||
|
//
|
||||||
|
// Se ejecuta de verdad, no se hace un reemplazo de texto: es la única forma de
|
||||||
|
// que {{range .Items}} y los campos anidados se vean como van a salir, y de que
|
||||||
|
// un error de sintaxis aparezca mientras se edita y no al generar el documento.
|
||||||
|
func RenderizarPlantillaEjemplo(tipo, contenidoHTML string) (string, error) {
|
||||||
|
tmpl, err := template.New("preview").Parse(contenidoHTML)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sintaxis inválida: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, DatosDeEjemploPlantilla(tipo)); err != nil {
|
||||||
|
return "", fmt.Errorf("no se pudo renderizar: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatosDeEjemploPlantilla arma los mismos campos que le pasa cada generador
|
||||||
|
// real (ver CrearCotizacion, contrato_documento_service, cuenta_cobro), con
|
||||||
|
// valores inventados. Si esto se desincroniza de los generadores, la vista
|
||||||
|
// previa miente — por eso sale de DatosBaseDocumento, igual que en producción.
|
||||||
|
func DatosDeEjemploPlantilla(tipo string) map[string]interface{} {
|
||||||
|
cliente := &models.Cliente{
|
||||||
|
Nombre: "Acme S.A.S.",
|
||||||
|
Empresa: "Acme S.A.S.",
|
||||||
|
Documento: "900.123.456-7",
|
||||||
|
Email: "contacto@acme.com",
|
||||||
|
Telefono: "+57 300 123 4567",
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := map[string]interface{}{
|
||||||
|
"Cliente": cliente,
|
||||||
|
"Alcance": "Desarrollo del sitio web institucional, con panel de administración y tres integraciones.",
|
||||||
|
"TipoProyecto": "Sitio web",
|
||||||
|
"Total": 4500000.0,
|
||||||
|
"Items": []ItemCotizacion{
|
||||||
|
{Descripcion: "Diseño de interfaz", Cantidad: 1, ValorUnitario: 1500000, Unidad: "servicio"},
|
||||||
|
{Descripcion: "Desarrollo frontend", Cantidad: 40, ValorUnitario: 50000, Unidad: "hora"},
|
||||||
|
{Descripcion: "Integración con pasarela de pagos", Cantidad: 1, ValorUnitario: 1000000, Unidad: "servicio"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tipo {
|
||||||
|
case "contrato":
|
||||||
|
extra["Servicio"] = "Mantenimiento mensual del sitio"
|
||||||
|
extra["Periodicidad"] = "mensual"
|
||||||
|
case "acta":
|
||||||
|
extra["Proyecto"] = "Sitio web Acme"
|
||||||
|
extra["Entregables"] = "Sitio publicado, manual de uso y capacitación."
|
||||||
|
case "cuenta_cobro":
|
||||||
|
extra["Concepto"] = "Mantenimiento mensual — marzo"
|
||||||
|
extra["Numero"] = "CC-0042"
|
||||||
|
}
|
||||||
|
|
||||||
|
return DatosBaseDocumento(extra)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// La vista previa ejecuta la plantilla de verdad: es lo que hace que un
|
||||||
|
// {{range}} se vea como va a salir y que un error de sintaxis aparezca mientras
|
||||||
|
// se edita, y no recién al generar el documento.
|
||||||
|
func TestRenderizarPlantillaEjemplo(t *testing.T) {
|
||||||
|
html := `<h1>{{.Cliente.Nombre}} — {{.Cliente.Documento}}</h1>
|
||||||
|
<p>{{.Alcance}}</p>
|
||||||
|
<table>{{range .Items}}<tr><td>{{.Descripcion}}</td><td>{{.Cantidad}}</td></tr>{{end}}</table>
|
||||||
|
<p>Total: {{.Total}} — {{.Fecha}} — {{.EmpresaNombre}}</p>`
|
||||||
|
|
||||||
|
out, err := RenderizarPlantillaEjemplo("cotizacion", html)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderizarPlantillaEjemplo: %v", err)
|
||||||
|
}
|
||||||
|
for _, quiero := range []string{"Acme S.A.S.", "900.123.456-7", "Diseño de interfaz", "Integración con pasarela", "U-SITE"} {
|
||||||
|
if !strings.Contains(out, quiero) {
|
||||||
|
t.Errorf("falta %q en:\n%s", quiero, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "{{") {
|
||||||
|
t.Errorf("quedaron variables sin resolver:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderizarPlantillaEjemploAvisaDeErrores(t *testing.T) {
|
||||||
|
if _, err := RenderizarPlantillaEjemplo("cotizacion", "{{range .Items}}sin fin"); err == nil {
|
||||||
|
t.Error("un {{range}} sin {{end}} debería dar error")
|
||||||
|
}
|
||||||
|
// Una variable que no existe no rompe: se renderiza como "<no value>", igual
|
||||||
|
// que en la generación real. Eso se ve en la vista previa, que es el punto —
|
||||||
|
// hacerla más estricta que producción rechazaría plantillas que sí andan.
|
||||||
|
out, err := RenderizarPlantillaEjemplo("cotizacion", "Hola {{.NoExiste}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no debería fallar: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "no value") {
|
||||||
|
t.Errorf("una variable inexistente tendría que notarse en la vista previa: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cada tipo agrega sus propios campos; si esto se desincroniza de los
|
||||||
|
// generadores, la vista previa muestra una cosa y el PDF sale con otra.
|
||||||
|
func TestDatosDeEjemploPorTipo(t *testing.T) {
|
||||||
|
casos := map[string][]string{
|
||||||
|
"contrato": {"Servicio", "Periodicidad"},
|
||||||
|
"acta": {"Proyecto", "Entregables"},
|
||||||
|
"cuenta_cobro": {"Concepto", "Numero"},
|
||||||
|
}
|
||||||
|
for tipo, campos := range casos {
|
||||||
|
datos := DatosDeEjemploPlantilla(tipo)
|
||||||
|
for _, campo := range campos {
|
||||||
|
if _, ok := datos[campo]; !ok {
|
||||||
|
t.Errorf("faltan datos de ejemplo de %q para el tipo %q", campo, tipo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, comun := range []string{"Cliente", "Fecha", "EmpresaNombre", "Total"} {
|
||||||
|
if _, ok := datos[comun]; !ok {
|
||||||
|
t.Errorf("falta el campo común %q en el tipo %q", comun, tipo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedactarBorradorTicket propone una respuesta para un ticket, apoyándose en la
|
||||||
|
// base de conocimiento del agente de uMind que se haya elegido en la
|
||||||
|
// configuración de soporte.
|
||||||
|
//
|
||||||
|
// Devuelve un borrador, no una respuesta: no se manda a nadie. Contestarle solo
|
||||||
|
// a un cliente con lo que dijo un modelo es la forma más rápida de perderlo;
|
||||||
|
// acá lo lee una persona, lo corrige y aprieta enviar.
|
||||||
|
func RedactarBorradorTicket(ticket *models.ProyectoTicket) (string, error) {
|
||||||
|
if ticket == nil {
|
||||||
|
return "", fmt.Errorf("ticket no encontrado")
|
||||||
|
}
|
||||||
|
cfg, _ := models.GetSoporteWebhookActivo()
|
||||||
|
|
||||||
|
var conversacion strings.Builder
|
||||||
|
fmt.Fprintf(&conversacion, "Asunto: %s\n\n%s: %s\n", ticket.Titulo, ticket.AutorNombre, ticket.Descripcion)
|
||||||
|
for _, m := range ticket.Mensajes {
|
||||||
|
quien := ticket.AutorNombre
|
||||||
|
if m.EsAdmin {
|
||||||
|
quien = "Soporte"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&conversacion, "\n%s: %s\n", quien, m.Contenido)
|
||||||
|
}
|
||||||
|
|
||||||
|
sistema := `Redactás borradores de respuesta para un equipo de soporte. Los lee una
|
||||||
|
persona antes de enviarlos.
|
||||||
|
|
||||||
|
- Escribí en el mismo idioma que usó el cliente.
|
||||||
|
- Respondé lo que preguntó, directo, sin relleno ni frases de manual.
|
||||||
|
- Usá SOLO lo que esté en la documentación de referencia y en la conversación.
|
||||||
|
Si falta información para resolverlo, decilo y proponé qué preguntarle.
|
||||||
|
- Nunca inventes precios, plazos, funciones ni pasos que no estén escritos.
|
||||||
|
- Sin asunto, sin firma: solo el cuerpo del mensaje.`
|
||||||
|
|
||||||
|
if cfg != nil && strings.TrimSpace(cfg.ContextoNegocio) != "" {
|
||||||
|
sistema += "\n\nContexto del negocio:\n" + strings.TrimSpace(cfg.ContextoNegocio)
|
||||||
|
}
|
||||||
|
|
||||||
|
usuario := conversacion.String()
|
||||||
|
if doc := documentacionRelevante(cfg, ticket); doc != "" {
|
||||||
|
usuario = "Documentación de referencia:\n---\n" + doc + "\n---\n\nConversación:\n" + usuario
|
||||||
|
}
|
||||||
|
|
||||||
|
borrador, err := CompletarTextoIA("ia", sistema, usuario)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(borrador), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentacionRelevante busca en la base de conocimiento del agente elegido.
|
||||||
|
// Sin agente configurado devuelve vacío y el borrador se arma solo con la
|
||||||
|
// conversación — peor, pero mejor que un error.
|
||||||
|
func documentacionRelevante(cfg *models.SoporteWebhookConfig, ticket *models.ProyectoTicket) string {
|
||||||
|
if cfg == nil || cfg.AgenteBorradorID == nil || *cfg.AgenteBorradorID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
consulta := ticket.Titulo + "\n" + ticket.Descripcion
|
||||||
|
chunks, err := BuscarConocimiento(*cfg.AgenteBorradorID, consulta, 4)
|
||||||
|
if err != nil || len(chunks) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range chunks {
|
||||||
|
b.WriteString(strings.TrimSpace(ch.Contenido))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClasificacionCorreo es el veredicto sobre un correo entrante.
|
||||||
|
type ClasificacionCorreo struct {
|
||||||
|
EsSoporte bool `json:"es_soporte"`
|
||||||
|
Categoria string `json:"categoria"` // error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
|
||||||
|
Prioridad string `json:"prioridad"` // baja | media | alta | urgente
|
||||||
|
Motivo string `json:"motivo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClasificarCorreoSoporte decide si un correo entrante es una solicitud de
|
||||||
|
// soporte o ruido (newsletters, notificaciones automáticas, facturas de
|
||||||
|
// proveedores, spam).
|
||||||
|
//
|
||||||
|
// Falla abierto a propósito: si la IA no está configurada, se cae o devuelve
|
||||||
|
// cualquier cosa, el correo se trata como soporte. Perder el mensaje de un
|
||||||
|
// cliente porque el modelo estaba caído es mucho peor que abrir un ticket de
|
||||||
|
// más.
|
||||||
|
func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) ClasificacionCorreo {
|
||||||
|
// Los correos masivos se reconocen por sus propios encabezados; no hace
|
||||||
|
// falta gastar una llamada de IA para saber que un newsletter no es soporte.
|
||||||
|
if correo.Automatico {
|
||||||
|
return ClasificacionCorreo{
|
||||||
|
EsSoporte: false,
|
||||||
|
Categoria: "newsletter",
|
||||||
|
Prioridad: "baja",
|
||||||
|
Motivo: "el correo viene marcado como masivo o automático en sus encabezados",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cuerpo := strings.TrimSpace(correo.Texto)
|
||||||
|
if len([]rune(cuerpo)) > 3000 {
|
||||||
|
cuerpo = string([]rune(cuerpo)[:3000])
|
||||||
|
}
|
||||||
|
|
||||||
|
sistema := `Clasificás correos que llegan a la casilla de soporte de una empresa.
|
||||||
|
Respondé SOLO con este JSON, sin explicaciones ni bloques de código:
|
||||||
|
{"es_soporte": true|false, "categoria": "...", "prioridad": "...", "motivo": "una frase corta"}
|
||||||
|
|
||||||
|
es_soporte = true cuando una persona pide ayuda, reporta un problema, hace una
|
||||||
|
consulta sobre un servicio contratado o responde una conversación de soporte.
|
||||||
|
es_soporte = false para newsletters, promociones, notificaciones automáticas de
|
||||||
|
plataformas, facturas de proveedores y spam.
|
||||||
|
Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticket de más.
|
||||||
|
|
||||||
|
categoria: error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
|
||||||
|
prioridad: urgente si algo está caído, si hay plata o datos en riesgo, o si el
|
||||||
|
cliente dice que está bloqueado; alta si no puede trabajar pero tiene cómo
|
||||||
|
seguir; media para el resto; baja para consultas sin apuro y para lo que no es
|
||||||
|
soporte. La urgencia la da el problema, no el tono del mensaje.`
|
||||||
|
|
||||||
|
if c := strings.TrimSpace(contextoNegocio); c != "" {
|
||||||
|
sistema += "\n\nContexto del negocio:\n" + c
|
||||||
|
}
|
||||||
|
|
||||||
|
usuario := fmt.Sprintf("De: %s\nAsunto: %s\n\n%s", correo.From, correo.Subject, cuerpo)
|
||||||
|
|
||||||
|
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Soporte] No se pudo clasificar el correo, se trata como soporte: %v", err)
|
||||||
|
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "la clasificación falló"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out ClasificacionCorreo
|
||||||
|
if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil {
|
||||||
|
log.Printf("[Soporte] Clasificación ilegible (%q), se trata como soporte", recortar(salida, 120))
|
||||||
|
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "respuesta ilegible del modelo"}
|
||||||
|
}
|
||||||
|
if out.Categoria == "" {
|
||||||
|
out.Categoria = "otro"
|
||||||
|
}
|
||||||
|
switch out.Prioridad {
|
||||||
|
case "baja", "media", "alta", "urgente":
|
||||||
|
default:
|
||||||
|
out.Prioridad = "media"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// soloJSON recorta lo que rodea al objeto JSON: los modelos agregan cercas de
|
||||||
|
// código o una frase antes aunque se les pida que no.
|
||||||
|
func soloJSON(s string) string {
|
||||||
|
i := strings.Index(s, "{")
|
||||||
|
j := strings.LastIndex(s, "}")
|
||||||
|
if i < 0 || j <= i {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[i : j+1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Los correos masivos se descartan por sus propios encabezados: este test pasa
|
||||||
|
// sin base de datos ni IA, que es justamente la prueba de que no las usa.
|
||||||
|
func TestClasificarCorreoAutomaticoNoGastaIA(t *testing.T) {
|
||||||
|
cl := ClasificarCorreoSoporte(CorreoSoporte{
|
||||||
|
From: "news@marketing.com", Subject: "20% off", Texto: "Oferta", Automatico: true,
|
||||||
|
}, "")
|
||||||
|
if cl.EsSoporte {
|
||||||
|
t.Error("un correo masivo no debería contar como soporte")
|
||||||
|
}
|
||||||
|
if cl.Categoria != "newsletter" {
|
||||||
|
t.Errorf("Categoria = %q", cl.Categoria)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEsCorreoAutomatico(t *testing.T) {
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
cabeceras string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"newsletter", "List-Unsubscribe: <mailto:baja@x.com>\r\n", true},
|
||||||
|
{"lista", "List-Id: <avisos.x.com>\r\n", true},
|
||||||
|
{"bulk", "Precedence: bulk\r\n", true},
|
||||||
|
{"auto-generado", "Auto-Submitted: auto-generated\r\n", true},
|
||||||
|
{"auto-submitted no", "Auto-Submitted: no\r\n", false},
|
||||||
|
{"persona", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range casos {
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(
|
||||||
|
"From: a@b.com\r\nSubject: x\r\n" + c.cabeceras + "\r\ncuerpo\r\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.nombre, err)
|
||||||
|
}
|
||||||
|
if got := esCorreoAutomatico(msg.Header); got != c.want {
|
||||||
|
t.Errorf("%s: esCorreoAutomatico = %v, want %v", c.nombre, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoloJSON(t *testing.T) {
|
||||||
|
casos := map[string]string{
|
||||||
|
"```json\n{\"es_soporte\":true}\n```": `{"es_soporte":true}`,
|
||||||
|
"Claro:\n{\"es_soporte\":false}\nEspero...": `{"es_soporte":false}`,
|
||||||
|
`{"es_soporte":true}`: `{"es_soporte":true}`,
|
||||||
|
}
|
||||||
|
for in, want := range casos {
|
||||||
|
if got := soloJSON(in); got != want {
|
||||||
|
t.Errorf("soloJSON(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClasificarNormalizaPrioridad(t *testing.T) {
|
||||||
|
// El correo automático corta antes de llamar a la IA, así que es el único
|
||||||
|
// camino que se puede probar sin base de datos — y ahí la prioridad tiene
|
||||||
|
// que salir puesta igual.
|
||||||
|
cl := ClasificarCorreoSoporte(CorreoSoporte{
|
||||||
|
From: "news@x.com", Subject: "promo", Automatico: true,
|
||||||
|
}, "")
|
||||||
|
if cl.Prioridad != "baja" {
|
||||||
|
t.Errorf("Prioridad = %q, want baja", cl.Prioridad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
"github.com/emersion/go-imap/v2/imapserver"
|
||||||
|
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// servidorDePrueba levanta un IMAP en memoria con un correo sin leer en INBOX.
|
||||||
|
func servidorDePrueba(t *testing.T, mensajes ...string) (*imapclient.Client, io.Closer) {
|
||||||
|
t.Helper()
|
||||||
|
mem := imapmemserver.New()
|
||||||
|
user := imapmemserver.NewUser("soporte", "secreta")
|
||||||
|
if err := user.Create("INBOX", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mem.AddUser(user)
|
||||||
|
|
||||||
|
srv := imapserver.New(&imapserver.Options{
|
||||||
|
NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||||
|
return mem.NewSession(), nil, nil
|
||||||
|
},
|
||||||
|
InsecureAuth: true,
|
||||||
|
})
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go srv.Serve(ln)
|
||||||
|
|
||||||
|
c, err := imapclient.DialInsecure(ln.Addr().String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Login("soporte", "secreta").Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range mensajes {
|
||||||
|
ac := c.Append("INBOX", int64(len(m)), nil)
|
||||||
|
if _, err := ac.Write([]byte(m)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ac.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ac.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, ln
|
||||||
|
}
|
||||||
|
|
||||||
|
const correoCrudo = "From: Juan <juan@cliente.com>\r\n" +
|
||||||
|
"Subject: No puedo entrar\r\n" +
|
||||||
|
"Message-Id: <uno@cliente.com>\r\n" +
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"La contrasena no me sirve.\r\n"
|
||||||
|
|
||||||
|
// Este test existe por un bug concreto: la búsqueda usaba Search (números de
|
||||||
|
// secuencia) y después pedía UIDs, así que la lista salía siempre vacía y el
|
||||||
|
// buzón no se leía nunca, sin un solo error en el log.
|
||||||
|
func TestBajarNoLeidosDevuelveLosCorreos(t *testing.T) {
|
||||||
|
c, ln := servidorDePrueba(t, correoCrudo)
|
||||||
|
defer ln.Close()
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
correos, err := BajarNoLeidos(c, "INBOX", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BajarNoLeidos: %v", err)
|
||||||
|
}
|
||||||
|
if len(correos) != 1 {
|
||||||
|
t.Fatalf("esperaba 1 correo sin leer, hay %d", len(correos))
|
||||||
|
}
|
||||||
|
if correos[0].UID == 0 {
|
||||||
|
t.Error("el UID vino en cero: sin él no se puede marcar como leído")
|
||||||
|
}
|
||||||
|
if got := correos[0].Correo.Subject; got != "No puedo entrar" {
|
||||||
|
t.Errorf("Subject = %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(correos[0].Correo.Texto, "contrasena no me sirve") {
|
||||||
|
t.Errorf("Texto = %q", correos[0].Correo.Texto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y este, por el otro lado del mismo problema: si marcar como leído no funciona,
|
||||||
|
// el mismo correo abre un ticket nuevo cada 2 minutos para siempre.
|
||||||
|
func TestMarcarLeidoSacaElCorreoDeLaProximaCorrida(t *testing.T) {
|
||||||
|
c, ln := servidorDePrueba(t, correoCrudo)
|
||||||
|
defer ln.Close()
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
correos, err := BajarNoLeidos(c, "INBOX", 0)
|
||||||
|
if err != nil || len(correos) != 1 {
|
||||||
|
t.Fatalf("BajarNoLeidos: %v (%d correos)", err, len(correos))
|
||||||
|
}
|
||||||
|
if err := MarcarLeido(c, correos[0].UID); err != nil {
|
||||||
|
t.Fatalf("MarcarLeido: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otra, err := BajarNoLeidos(c, "INBOX", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("segunda pasada: %v", err)
|
||||||
|
}
|
||||||
|
if len(otra) != 0 {
|
||||||
|
t.Errorf("el correo ya leído volvió a aparecer (%d)", len(otra))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek: bajarlo sin procesarlo no lo debe marcar, o un fallo a mitad de camino
|
||||||
|
// perdería el correo para siempre.
|
||||||
|
func TestBajarNoLeidosNoMarcaSolo(t *testing.T) {
|
||||||
|
c, ln := servidorDePrueba(t, correoCrudo)
|
||||||
|
defer ln.Close()
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := BajarNoLeidos(c, "INBOX", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
otra, err := BajarNoLeidos(c, "INBOX", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(otra) != 1 {
|
||||||
|
t.Errorf("el correo debería seguir sin leer, hay %d", len(otra))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = imap.FlagSeen
|
||||||
|
|
||||||
|
func TestVentanaDeAntiguedad(t *testing.T) {
|
||||||
|
ahora := time.Now()
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
recibido time.Time
|
||||||
|
horas int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"de hace una hora, ventana 12", ahora.Add(-1 * time.Hour), 12, true},
|
||||||
|
{"de hace 13 horas, ventana 12", ahora.Add(-13 * time.Hour), 12, false},
|
||||||
|
{"viejísimo, sin ventana", ahora.AddDate(-2, 0, 0), 0, true},
|
||||||
|
{"sin fecha del servidor", time.Time{}, 12, true},
|
||||||
|
}
|
||||||
|
for _, c := range casos {
|
||||||
|
if got := dentroDeLaVentana(c.recibido, c.horas); got != c.want {
|
||||||
|
t.Errorf("%s: dentroDeLaVentana = %v, want %v", c.nombre, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El SINCE que se le pide al servidor tiene que ser MÁS amplio que la
|
||||||
|
// ventana real: IMAP compara solo la fecha, y afinar de más pierde correos.
|
||||||
|
c := criterioNoLeidos(12)
|
||||||
|
if !c.Since.Before(ahora.Add(-12 * time.Hour)) {
|
||||||
|
t.Errorf("Since = %v, debería ser anterior al corte real", c.Since)
|
||||||
|
}
|
||||||
|
if !criterioNoLeidos(0).Since.IsZero() {
|
||||||
|
t.Error("sin ventana no debería mandarse SINCE")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El poller corre por cron; este lock evita que dos corridas se pisen si una
|
||||||
|
// tarda más que el intervalo (buzón grande, servidor lento).
|
||||||
|
var imapEnCurso sync.Mutex
|
||||||
|
|
||||||
|
// RevisarBuzonSoporte lee los correos sin leer del buzón configurado y los
|
||||||
|
// convierte en tickets. Cada correo procesado se marca como \Seen, que es lo
|
||||||
|
// que evita volver a leerlo; la deduplicación por Message-Id es el segundo
|
||||||
|
// cinturón por si el marcado falla.
|
||||||
|
func RevisarBuzonSoporte() {
|
||||||
|
cfg, err := models.GetSoporteWebhookActivo()
|
||||||
|
if err != nil || cfg == nil || !cfg.ImapActivo || cfg.ImapHost == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !imapEnCurso.TryLock() {
|
||||||
|
log.Printf("[SoporteIMAP] corrida anterior todavía en curso, se salta esta")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer imapEnCurso.Unlock()
|
||||||
|
|
||||||
|
encontrados, procesados, err := revisarBuzon(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[SoporteIMAP] %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if encontrados > 0 {
|
||||||
|
log.Printf("[SoporteIMAP] %d sin leer, %d procesado(s)", encontrados, procesados)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevisarBuzonSoporteConDetalle es la versión que usa el botón "Revisar buzón
|
||||||
|
// ahora": devuelve los números para poder mostrarlos, en vez de solo loguearlos.
|
||||||
|
func RevisarBuzonSoporteConDetalle() (encontrados, procesados int, err error) {
|
||||||
|
cfg, err := models.GetSoporteWebhookActivo()
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return 0, 0, fmt.Errorf("no hay configuración de soporte guardada")
|
||||||
|
}
|
||||||
|
if !cfg.ImapActivo {
|
||||||
|
return 0, 0, fmt.Errorf("la lectura del buzón está desactivada: tildá \"Leer el buzón cada 2 minutos\" y guardá")
|
||||||
|
}
|
||||||
|
if cfg.ImapHost == "" {
|
||||||
|
return 0, 0, fmt.Errorf("falta el servidor IMAP en la configuración")
|
||||||
|
}
|
||||||
|
if !imapEnCurso.TryLock() {
|
||||||
|
return 0, 0, fmt.Errorf("hay una revisión en curso, probá en unos segundos")
|
||||||
|
}
|
||||||
|
defer imapEnCurso.Unlock()
|
||||||
|
return revisarBuzon(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón
|
||||||
|
// "Probar" de la vista de configuración.
|
||||||
|
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) {
|
||||||
|
c, err := conectarImap(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
carpeta := cfg.ImapCarpeta
|
||||||
|
if carpeta == "" {
|
||||||
|
carpeta = "INBOX"
|
||||||
|
}
|
||||||
|
datos, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||||
|
}
|
||||||
|
sinLeer := 0
|
||||||
|
if r, err := c.UIDSearch(criterioNoLeidos(cfg.ImapHorasAtras), &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil {
|
||||||
|
sinLeer = len(r.AllUIDs())
|
||||||
|
}
|
||||||
|
_ = c.Logout().Wait()
|
||||||
|
// El conteo del servidor es por día; el corte por hora se aplica al leer,
|
||||||
|
// así que este número puede ser un poco mayor que el que se va a procesar.
|
||||||
|
ventana := "sin leer"
|
||||||
|
if cfg.ImapHorasAtras > 0 {
|
||||||
|
ventana = fmt.Sprintf("sin leer de las últimas ~%d horas", cfg.ImapHorasAtras)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d %s.", carpeta, datos.NumMessages, sinLeer, ventana), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) {
|
||||||
|
pass, err := DescifrarSecretoUmind(cfg.ImapPasswordEnc)
|
||||||
|
if err != nil || pass == "" {
|
||||||
|
return nil, fmt.Errorf("no hay contraseña IMAP guardada")
|
||||||
|
}
|
||||||
|
port := cfg.ImapPort
|
||||||
|
if port == 0 {
|
||||||
|
port = 993
|
||||||
|
}
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.ImapHost, port)
|
||||||
|
|
||||||
|
var c *imapclient.Client
|
||||||
|
if strings.EqualFold(cfg.ImapEncryption, "starttls") {
|
||||||
|
c, err = imapclient.DialStartTLS(addr, nil)
|
||||||
|
} else {
|
||||||
|
c, err = imapclient.DialTLS(addr, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no se pudo conectar a %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
if err := c.Login(cfg.ImapUsername, pass).Wait(); err != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, fmt.Errorf("login IMAP rechazado para %s: %w", cfg.ImapUsername, err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
|
||||||
|
c, err := conectarImap(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
correos, err := BajarNoLeidos(c, cfg.ImapCarpeta, cfg.ImapHorasAtras)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(correos) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
fallosAlMarcar := 0
|
||||||
|
var descartes []string
|
||||||
|
|
||||||
|
procesados := 0
|
||||||
|
for _, correo := range correos {
|
||||||
|
ok, descarte := IngestarCorreoSoporteConDetalle(cfg, correo.Correo)
|
||||||
|
if ok {
|
||||||
|
procesados++
|
||||||
|
} else if descarte != "" {
|
||||||
|
descartes = append(descartes, descarte)
|
||||||
|
}
|
||||||
|
// Se marca leído aunque se haya ignorado por duplicado: si no, se
|
||||||
|
// vuelve a bajar en cada corrida para siempre.
|
||||||
|
if err := MarcarLeido(c, correo.UID); err != nil {
|
||||||
|
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err)
|
||||||
|
fallosAlMarcar++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ultimosDescartes.guardar(descartes)
|
||||||
|
if fallosAlMarcar > 0 {
|
||||||
|
return len(correos), procesados, fmt.Errorf(
|
||||||
|
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
|
||||||
|
procesados, fallosAlMarcar)
|
||||||
|
}
|
||||||
|
return len(correos), procesados, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ultimosDescartes guarda lo que el filtro dejó afuera en la última corrida,
|
||||||
|
// para poder mostrarlo en la pantalla de configuración. En memoria a propósito:
|
||||||
|
// es información de diagnóstico de hace un rato, no algo que valga una tabla.
|
||||||
|
var ultimosDescartes = &descartesRecientes{}
|
||||||
|
|
||||||
|
type descartesRecientes struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *descartesRecientes) guardar(items []string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if len(items) > 0 {
|
||||||
|
d.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UltimosCorreosDescartados devuelve los descartes de la última corrida que
|
||||||
|
// tuvo alguno.
|
||||||
|
func UltimosCorreosDescartados() []string {
|
||||||
|
ultimosDescartes.mu.Lock()
|
||||||
|
defer ultimosDescartes.mu.Unlock()
|
||||||
|
return append([]string(nil), ultimosDescartes.items...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
|
||||||
|
// recibidos dentro de ella.
|
||||||
|
//
|
||||||
|
// SINCE de IMAP compara solo la fecha, no la hora, así que acá se pide un día
|
||||||
|
// de más y el corte fino por hora se hace después contra la fecha real de cada
|
||||||
|
// mensaje (ver dentroDeLaVentana). Pedirle al servidor que filtre de más sería
|
||||||
|
// perder correos del borde.
|
||||||
|
func criterioNoLeidos(horasAtras int) *imap.SearchCriteria {
|
||||||
|
c := &imap.SearchCriteria{NotFlag: []imap.Flag{imap.FlagSeen}}
|
||||||
|
if horasAtras > 0 {
|
||||||
|
c.Since = time.Now().Add(-time.Duration(horasAtras)*time.Hour).AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// dentroDeLaVentana es el corte por hora que IMAP no puede hacer.
|
||||||
|
func dentroDeLaVentana(recibido time.Time, horasAtras int) bool {
|
||||||
|
if horasAtras <= 0 || recibido.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return recibido.After(time.Now().Add(-time.Duration(horasAtras) * time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorreoConUID es un correo del buzón junto con su UID, que es lo que hace
|
||||||
|
// falta después para marcarlo como leído.
|
||||||
|
type CorreoConUID struct {
|
||||||
|
UID imap.UID
|
||||||
|
Correo CorreoSoporte
|
||||||
|
}
|
||||||
|
|
||||||
|
// BajarNoLeidos abre la carpeta, busca los mensajes sin leer y los devuelve ya
|
||||||
|
// parseados. No los marca: eso pasa recién cuando se procesaron.
|
||||||
|
func BajarNoLeidos(c *imapclient.Client, carpeta string, horasAtras int) ([]CorreoConUID, error) {
|
||||||
|
if carpeta == "" {
|
||||||
|
carpeta = "INBOX"
|
||||||
|
}
|
||||||
|
if _, err := c.Select(carpeta, nil).Wait(); err != nil {
|
||||||
|
return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIDSearch y no Search: Search devuelve números de secuencia, y de ahí no
|
||||||
|
// salen UIDs — la búsqueda "encontraba" mensajes y la lista quedaba vacía.
|
||||||
|
buscados, err := c.UIDSearch(criterioNoLeidos(horasAtras), &imap.SearchOptions{ReturnAll: true}).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err)
|
||||||
|
}
|
||||||
|
uids := buscados.AllUIDs()
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// ponytail: tope por corrida para no atragantarse con un buzón que nunca
|
||||||
|
// se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente.
|
||||||
|
const maxPorCorrida = 50
|
||||||
|
if len(uids) > maxPorCorrida {
|
||||||
|
uids = uids[:maxPorCorrida]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek para que el correo quede leído solo si llegamos a procesarlo.
|
||||||
|
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
|
||||||
|
InternalDate: true,
|
||||||
|
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
||||||
|
}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("descarga de correos falló: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []CorreoConUID
|
||||||
|
for _, m := range msgs {
|
||||||
|
if !dentroDeLaVentana(m.InternalDate, horasAtras) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var crudo []byte
|
||||||
|
for _, b := range m.BodySection {
|
||||||
|
crudo = b.Bytes
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(crudo) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
correo, err := parsearCorreo(crudo)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, CorreoConUID{UID: m.UID, Correo: correo})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarcarLeido pone el flag \Seen, que es lo que evita releer el mismo correo.
|
||||||
|
func MarcarLeido(c *imapclient.Client, uid imap.UID) error {
|
||||||
|
return c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
|
||||||
|
Op: imap.StoreFlagsAdd,
|
||||||
|
Silent: true,
|
||||||
|
Flags: []imap.Flag{imap.FlagSeen},
|
||||||
|
}, nil).Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822.
|
||||||
|
func parsearCorreo(crudo []byte) (CorreoSoporte, error) {
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(string(crudo)))
|
||||||
|
if err != nil {
|
||||||
|
return CorreoSoporte{}, err
|
||||||
|
}
|
||||||
|
dec := new(mime.WordDecoder)
|
||||||
|
decodificar := func(s string) string {
|
||||||
|
if out, err := dec.DecodeHeader(s); err == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
from := decodificar(msg.Header.Get("From"))
|
||||||
|
nombre := ExtraerNombre(from)
|
||||||
|
if dir, err := mail.ParseAddress(from); err == nil && dir.Name != "" {
|
||||||
|
nombre = dir.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
cuerpo, err := cuerpoDeTexto(msg.Header.Get("Content-Type"), msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return CorreoSoporte{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return CorreoSoporte{
|
||||||
|
From: from,
|
||||||
|
FromName: nombre,
|
||||||
|
Subject: decodificar(msg.Header.Get("Subject")),
|
||||||
|
Texto: limpiarCitas(cuerpo),
|
||||||
|
MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")),
|
||||||
|
Automatico: esCorreoAutomatico(msg.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cuerpoDeTexto devuelve el text/plain del correo; si es multipart baja por las
|
||||||
|
// partes hasta encontrarlo y cae al HTML solo si no hay texto plano.
|
||||||
|
func cuerpoDeTexto(contentType string, cuerpo io.Reader) (string, error) {
|
||||||
|
medio, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
medio = "text/plain"
|
||||||
|
params = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(medio, "multipart/") {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(cuerpo, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
frontera := params["boundary"]
|
||||||
|
if frontera == "" {
|
||||||
|
return "", fmt.Errorf("multipart sin boundary")
|
||||||
|
}
|
||||||
|
lector := multipart.NewReader(cuerpo, frontera)
|
||||||
|
var html string
|
||||||
|
for {
|
||||||
|
parte, err := lector.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tipoParte, _, _ := mime.ParseMediaType(parte.Header.Get("Content-Type"))
|
||||||
|
if strings.HasPrefix(tipoParte, "multipart/") {
|
||||||
|
anidado, err := cuerpoDeTexto(parte.Header.Get("Content-Type"), parte)
|
||||||
|
if err == nil && anidado != "" {
|
||||||
|
return anidado, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tipoParte != "text/plain" && tipoParte != "text/html" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var lect io.Reader = io.LimitReader(parte, 1<<20)
|
||||||
|
switch strings.ToLower(parte.Header.Get("Content-Transfer-Encoding")) {
|
||||||
|
case "quoted-printable":
|
||||||
|
lect = quotedprintable.NewReader(lect)
|
||||||
|
case "base64":
|
||||||
|
lect = base64.NewDecoder(base64.StdEncoding, lect)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(lect)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tipoParte == "text/plain" {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
html = string(b)
|
||||||
|
}
|
||||||
|
return html, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limpiarCitas corta el correo en la primera línea citada o en el separador
|
||||||
|
// típico de respuesta, para que el ticket no repita todo el hilo anterior.
|
||||||
|
func limpiarCitas(texto string) string {
|
||||||
|
lineas := strings.Split(strings.ReplaceAll(texto, "\r\n", "\n"), "\n")
|
||||||
|
var out []string
|
||||||
|
for _, l := range lineas {
|
||||||
|
t := strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(t, ">") ||
|
||||||
|
strings.HasPrefix(t, "-----Original Message-----") ||
|
||||||
|
strings.HasPrefix(t, "-----Mensaje original-----") ||
|
||||||
|
(strings.HasPrefix(t, "El ") && strings.HasSuffix(t, "escribió:")) ||
|
||||||
|
(strings.HasPrefix(t, "On ") && strings.HasSuffix(t, "wrote:")) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// esCorreoAutomatico reconoce newsletters y notificaciones de máquina por sus
|
||||||
|
// encabezados estándar. Es gratis y no se equivoca, así que va antes que
|
||||||
|
// cualquier modelo.
|
||||||
|
func esCorreoAutomatico(h mail.Header) bool {
|
||||||
|
if h.Get("List-Unsubscribe") != "" || h.Get("List-Id") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
||||||
|
case "bulk", "list", "junk":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); a != "" && a != "no" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParsearCorreoMultipartQuotedPrintable(t *testing.T) {
|
||||||
|
crudo := "From: =?UTF-8?Q?Juan_P=C3=A9rez?= <juan@cliente.com>\r\n" +
|
||||||
|
"Subject: =?UTF-8?Q?No_puedo_entrar_a_la_plataforma?=\r\n" +
|
||||||
|
"Message-Id: <abc123@cliente.com>\r\n" +
|
||||||
|
"Content-Type: multipart/alternative; boundary=\"XX\"\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"--XX\r\n" +
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||||
|
"Content-Transfer-Encoding: quoted-printable\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"Hola, la contrase=C3=B1a no me sirve.\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"El 3 de marzo, Soporte escribi=C3=B3:\r\n" +
|
||||||
|
"> proba de nuevo\r\n" +
|
||||||
|
"--XX\r\n" +
|
||||||
|
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"<p>ignorame</p>\r\n" +
|
||||||
|
"--XX--\r\n"
|
||||||
|
|
||||||
|
c, err := parsearCorreo([]byte(crudo))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsearCorreo: %v", err)
|
||||||
|
}
|
||||||
|
if c.FromName != "Juan Pérez" {
|
||||||
|
t.Errorf("FromName = %q, want %q", c.FromName, "Juan Pérez")
|
||||||
|
}
|
||||||
|
if ExtraerEmail(c.From) != "juan@cliente.com" {
|
||||||
|
t.Errorf("From = %q", c.From)
|
||||||
|
}
|
||||||
|
if c.Subject != "No puedo entrar a la plataforma" {
|
||||||
|
t.Errorf("Subject = %q", c.Subject)
|
||||||
|
}
|
||||||
|
if c.MessageID != "<abc123@cliente.com>" {
|
||||||
|
t.Errorf("MessageID = %q", c.MessageID)
|
||||||
|
}
|
||||||
|
// Cuerpo decodificado, sin la cita del hilo anterior ni el HTML.
|
||||||
|
if c.Texto != "Hola, la contraseña no me sirve." {
|
||||||
|
t.Errorf("Texto = %q", c.Texto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsearCorreoPlano(t *testing.T) {
|
||||||
|
crudo := "From: ana@cliente.com\r\nSubject: Consulta\r\n\r\nHola\r\n"
|
||||||
|
c, err := parsearCorreo([]byte(crudo))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsearCorreo: %v", err)
|
||||||
|
}
|
||||||
|
if c.Texto != "Hola" || c.Subject != "Consulta" {
|
||||||
|
t.Errorf("got %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde
|
||||||
|
// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan
|
||||||
|
// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual.
|
||||||
|
type CorreoSoporte struct {
|
||||||
|
From string // puede venir como "Nombre <mail@x.com>"
|
||||||
|
FromName string
|
||||||
|
Subject string
|
||||||
|
Texto string
|
||||||
|
MessageID string
|
||||||
|
// Automatico marca los correos masivos o generados por una máquina, según
|
||||||
|
// sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted).
|
||||||
|
Automatico bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
||||||
|
|
||||||
|
// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un
|
||||||
|
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
|
||||||
|
// duplicado o por venir vacío).
|
||||||
|
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
|
||||||
|
ok, _ := IngestarCorreoSoporteConDetalle(cfg, e)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestarCorreoSoporteConDetalle es la misma ingesta, pero además dice por qué
|
||||||
|
// se descartó un correo. Un descarte que solo va al log es invisible: si el
|
||||||
|
// filtro se come el correo de un cliente, nadie se entera.
|
||||||
|
func IngestarCorreoSoporteConDetalle(cfg *models.SoporteWebhookConfig, e CorreoSoporte) (procesado bool, descarte string) {
|
||||||
|
if strings.TrimSpace(e.From) == "" {
|
||||||
|
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
// Un correo sin asunto es raro pero existe, y perderlo en silencio es peor
|
||||||
|
// que abrir un ticket con un título feo.
|
||||||
|
if strings.TrimSpace(e.Subject) == "" {
|
||||||
|
e.Subject = "(sin asunto)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP
|
||||||
|
// puede releer un correo si falló el marcado como leído.
|
||||||
|
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
||||||
|
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fromEmail := ExtraerEmail(e.From)
|
||||||
|
fromName := e.FromName
|
||||||
|
if fromName == "" {
|
||||||
|
fromName = ExtraerNombre(e.From)
|
||||||
|
}
|
||||||
|
if fromName == "" {
|
||||||
|
fromName = fromEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
contenido := strings.TrimSpace(e.Texto)
|
||||||
|
if len(contenido) > 5000 {
|
||||||
|
contenido = contenido[:5000]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
|
||||||
|
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
|
||||||
|
// agrega como mensaje en vez de abrir un ticket nuevo.
|
||||||
|
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
|
||||||
|
msg := &models.TicketMensaje{
|
||||||
|
TicketID: hilo.ID,
|
||||||
|
Contenido: contenido,
|
||||||
|
EsAdmin: false,
|
||||||
|
AutorNombre: fromName,
|
||||||
|
MessageID: e.MessageID,
|
||||||
|
}
|
||||||
|
if err := models.CreateTicketMensaje(msg); err != nil {
|
||||||
|
if esCorreoDuplicado(err) {
|
||||||
|
log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
||||||
|
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
||||||
|
}
|
||||||
|
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
||||||
|
notificarTicketDeCorreo(hilo, contenido, true)
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := &models.ProyectoTicket{
|
||||||
|
AutorNombre: fromName,
|
||||||
|
EmailFrom: fromEmail,
|
||||||
|
Titulo: e.Subject,
|
||||||
|
Descripcion: contenido,
|
||||||
|
Estado: "abierto",
|
||||||
|
Origen: "email",
|
||||||
|
MessageID: e.MessageID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// De quién es: sin esto un ticket de correo es un texto suelto que no se
|
||||||
|
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
|
||||||
|
// Va antes del filtro a propósito: saber quién escribe cambia si se filtra.
|
||||||
|
resolverRemitente(ticket, fromEmail)
|
||||||
|
|
||||||
|
// Filtro y triage con IA: solo para tickets nuevos y solo para desconocidos.
|
||||||
|
// Una respuesta a un hilo es soporte por definición, y a un cliente
|
||||||
|
// registrado no se le descarta el correo por lo que opine un modelo.
|
||||||
|
conocido := ticket.ClienteID != nil || ticket.PortalUserID != nil
|
||||||
|
if cfg != nil && cfg.ClasificarConIA && !conocido {
|
||||||
|
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
|
||||||
|
if !cl.EsSoporte {
|
||||||
|
motivo := fmt.Sprintf("%q de %s — %s (%s)", e.Subject, fromEmail, cl.Motivo, cl.Categoria)
|
||||||
|
log.Printf("[Soporte] Descartado por el filtro: %s", motivo)
|
||||||
|
return false, motivo
|
||||||
|
}
|
||||||
|
ticket.Categoria = cl.Categoria
|
||||||
|
ticket.Prioridad = cl.Prioridad
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil && cfg.AsignarA != nil {
|
||||||
|
ticket.AsignadoA = cfg.AsignarA
|
||||||
|
}
|
||||||
|
if err := models.CreateProyectoTicket(ticket); err != nil {
|
||||||
|
// El índice único sobre message_id es la última defensa contra el mismo
|
||||||
|
// correo entrando dos veces. Si salta, no es un error: es que ya estaba.
|
||||||
|
if esCorreoDuplicado(err) {
|
||||||
|
log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
log.Printf("[Soporte] Error creando ticket: %v", err)
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||||
|
|
||||||
|
notificarTicketDeCorreo(ticket, contenido, false)
|
||||||
|
if cfg != nil && cfg.ResponderAuto {
|
||||||
|
SendSoporteAutoRespuesta(ticket)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Soporte] Ticket #%d sin acuse al cliente: la respuesta automática está desactivada", ticket.ID)
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
||||||
|
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
|
||||||
|
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
|
||||||
|
// adivinando el número), y si no hay token, por el último ticket abierto de ese
|
||||||
|
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
|
||||||
|
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
|
||||||
|
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
|
||||||
|
id, _ := strconv.ParseUint(m[1], 10, 32)
|
||||||
|
if id > 0 {
|
||||||
|
t, err := models.GetTicketByID(uint(id))
|
||||||
|
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(subject))
|
||||||
|
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
|
||||||
|
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtraerEmail saca la dirección de un "Nombre <mail@x.com>".
|
||||||
|
func ExtraerEmail(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
||||||
|
s = s[idx+1:]
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtraerNombre saca el nombre de un "Nombre <mail@x.com>" ("" si no trae).
|
||||||
|
func ExtraerNombre(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if idx := strings.Index(s, "<"); idx >= 0 {
|
||||||
|
return strings.TrimSpace(s[:idx])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificarTicketDeCorreo avisa al staff de un correo entrante por los canales
|
||||||
|
// que estén prendidos en /app/notif-config (in-app, correo y Telegram), con el
|
||||||
|
// mismo formato que los tickets del portal.
|
||||||
|
//
|
||||||
|
// El cuerpo que viaja en la notificación es el resumen, no el correo entero:
|
||||||
|
// el ticket guarda el texto completo igual.
|
||||||
|
func notificarTicketDeCorreo(ticket *models.ProyectoTicket, contenido string, esRespuesta bool) {
|
||||||
|
resumen := ResumirTextoSoporte(contenido)
|
||||||
|
origen := "Correo"
|
||||||
|
if ticket.EmailFrom != "" {
|
||||||
|
origen = "Correo · " + ticket.EmailFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
if esRespuesta {
|
||||||
|
DispatchTicketRespuestaCliente(ticket, resumen, origen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copia para no pisar el texto completo que ya se guardó en el ticket.
|
||||||
|
paraNotificar := *ticket
|
||||||
|
paraNotificar.Descripcion = resumen
|
||||||
|
|
||||||
|
if models.GetNotifConfig("ticket_nuevo", "admin") == nil {
|
||||||
|
// Sin configuración de notificaciones se mantiene lo de siempre: correo
|
||||||
|
// al admin. Si no, activar el módulo dejaría a alguien sin avisos.
|
||||||
|
SendSoporteNotifAdmin(¶Notificar)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchTicketNuevo(¶Notificar, nil, origen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumirTextoSoporte deja el correo en algo que se pueda leer de un vistazo en
|
||||||
|
// Telegram. Los correos cortos van tal cual: pedirle a la IA que resuma tres
|
||||||
|
// renglones es gastar una llamada para decir lo mismo. Los largos —hilos
|
||||||
|
// reenviados, capturas pegadas— sí se resumen, y si la IA falla se recorta.
|
||||||
|
func ResumirTextoSoporte(texto string) string {
|
||||||
|
t := strings.TrimSpace(texto)
|
||||||
|
if len([]rune(t)) <= 700 {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
resumen, err := CompletarTextoIA("ia",
|
||||||
|
"Resumís correos de soporte para avisarle al equipo por Telegram. "+
|
||||||
|
"Máximo 3 renglones. Decí qué pide o reporta la persona y, si los hay, "+
|
||||||
|
"incluí datos concretos (número de factura, pedido, fecha, monto). "+
|
||||||
|
"Sin saludos, sin despedidas, sin repetir el asunto, sin inventar nada.",
|
||||||
|
t)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Soporte] No se pudo resumir el correo, se manda recortado: %v", err)
|
||||||
|
return string([]rune(t)[:700]) + "…"
|
||||||
|
}
|
||||||
|
if r := strings.TrimSpace(resumen); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return string([]rune(t)[:700]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// esCorreoDuplicado reconoce el rechazo del índice único de message_id.
|
||||||
|
func esCorreoDuplicado(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolverRemitente ata el ticket a quien escribió: primero por usuario del
|
||||||
|
// portal (que además le deja ver el ticket desde su portal) y si no, por el
|
||||||
|
// correo del cliente. Solo coincidencia exacta de dirección — ver el comentario
|
||||||
|
// de models.GetClientePorEmail sobre por qué no se busca por dominio.
|
||||||
|
func resolverRemitente(ticket *models.ProyectoTicket, email string) {
|
||||||
|
if email == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pu, err := models.GetPortalUserByEmail(email); err == nil && pu != nil {
|
||||||
|
id := pu.ID
|
||||||
|
ticket.PortalUserID = &id
|
||||||
|
if ids := models.GetClienteIDsForPortalUser(pu); len(ids) == 1 {
|
||||||
|
ticket.ClienteID = &ids[0]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cli, err := models.GetClientePorEmail(email); err == nil && cli != nil {
|
||||||
|
id := cli.ID
|
||||||
|
ticket.ClienteID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-13
@@ -1,4 +1,4 @@
|
|||||||
package controllers
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,9 +18,9 @@ func TestExtractEmailSimple(t *testing.T) {
|
|||||||
{"<onlybrackets>", "onlybrackets"},
|
{"<onlybrackets>", "onlybrackets"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := extractEmail(tt.input)
|
got := ExtraerEmail(tt.input)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,9 @@ func TestExtractNameSimple(t *testing.T) {
|
|||||||
{"", ""},
|
{"", ""},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := extractName(tt.input)
|
got := ExtraerNombre(tt.input)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want)
|
t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,24 +57,36 @@ func TestExtractEmailRealWorld(t *testing.T) {
|
|||||||
{"", "", ""},
|
{"", "", ""},
|
||||||
}
|
}
|
||||||
for _, tt := range inputs {
|
for _, tt := range inputs {
|
||||||
gotMail := extractEmail(tt.full)
|
gotMail := ExtraerEmail(tt.full)
|
||||||
gotName := extractName(tt.full)
|
gotName := ExtraerNombre(tt.full)
|
||||||
if gotMail != tt.mail {
|
if gotMail != tt.mail {
|
||||||
t.Errorf("extractEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
|
||||||
}
|
}
|
||||||
if gotName != tt.name {
|
if gotName != tt.name {
|
||||||
t.Errorf("extractName(%q) = %q, want %q", tt.full, gotName, tt.name)
|
t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.full, gotName, tt.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractEmailEdgeCases(t *testing.T) {
|
func TestExtractEmailEdgeCases(t *testing.T) {
|
||||||
// Formato RFC 5322 con nombre y ángulos
|
// Formato RFC 5322 con nombre y ángulos
|
||||||
if got := extractEmail("a<b@c.com>"); got != "b@c.com" {
|
if got := ExtraerEmail("a<b@c.com>"); got != "b@c.com" {
|
||||||
t.Errorf("extractEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
t.Errorf("ExtraerEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
|
||||||
}
|
}
|
||||||
// Múltiples brackets — usa el último par
|
// Múltiples brackets — usa el último par
|
||||||
if got := extractEmail("<a><b@c.com>"); got != "b@c.com" {
|
if got := ExtraerEmail("<a><b@c.com>"); got != "b@c.com" {
|
||||||
t.Errorf("extractEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
t.Errorf("ExtraerEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumirTextoSoporteCortoNoLlamaALaIA(t *testing.T) {
|
||||||
|
// Sin base de datos, cualquier llamada a la IA explota: que este caso pase
|
||||||
|
// es la prueba de que un correo corto no la usa.
|
||||||
|
corto := "Hola, no me llega la factura de marzo. Gracias."
|
||||||
|
if got := ResumirTextoSoporte(corto); got != corto {
|
||||||
|
t.Errorf("ResumirTextoSoporte devolvió %q, esperaba el texto tal cual", got)
|
||||||
|
}
|
||||||
|
if got := ResumirTextoSoporte(" " + corto + "\n"); got != corto {
|
||||||
|
t.Errorf("no recortó los espacios: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+111
-53
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
@@ -14,6 +15,13 @@ import (
|
|||||||
// soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte.
|
// soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte.
|
||||||
// Si no hay SMTP configurado, usa app.Http.Mail como fallback.
|
// Si no hay SMTP configurado, usa app.Http.Mail como fallback.
|
||||||
func soporteSendMail(to, subject, htmlBody string) error {
|
func soporteSendMail(to, subject, htmlBody string) error {
|
||||||
|
// Sin base no hay configuración que leer: consultarla igual revienta con un
|
||||||
|
// nil, y como el envío corre en su propia goroutine se lleva puesto todo el
|
||||||
|
// proceso. Es lo que hacía que la suite de este paquete no se pudiera
|
||||||
|
// correr entera.
|
||||||
|
if app.Http.Database.DB == nil {
|
||||||
|
return fmt.Errorf("no hay base de datos para leer la configuración de correo")
|
||||||
|
}
|
||||||
cfg, err := models.GetSoporteWebhookActivo()
|
cfg, err := models.GetSoporteWebhookActivo()
|
||||||
if err != nil || cfg == nil || cfg.SmtpHost == "" {
|
if err != nil || cfg == nil || cfg.SmtpHost == "" {
|
||||||
return app.Http.Mail.Send(to, subject, htmlBody)
|
return app.Http.Mail.Send(to, subject, htmlBody)
|
||||||
@@ -33,61 +41,87 @@ func soporteSendMail(to, subject, htmlBody string) error {
|
|||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = 587
|
port = 587
|
||||||
}
|
}
|
||||||
auth := smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)
|
|
||||||
msg := []byte(fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", fromName, from, to, subject, htmlBody))
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port)
|
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port)
|
||||||
enc := strings.ToLower(cfg.SmtpEncryption)
|
msg := []byte(fmt.Sprintf(
|
||||||
if enc == "tls" {
|
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
|
||||||
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
|
fromName, from, to, subject, htmlBody))
|
||||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("soporte SMTP TLS: %w", err)
|
|
||||||
}
|
|
||||||
client, err := smtp.NewClient(conn, cfg.SmtpHost)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return fmt.Errorf("soporte SMTP client: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
if err = client.Auth(auth); err != nil {
|
|
||||||
return fmt.Errorf("soporte SMTP auth: %w", err)
|
|
||||||
}
|
|
||||||
if err = client.Mail(from); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = client.Rcpt(to); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w, err := client.Data()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = w.Write(msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return w.Close()
|
|
||||||
}
|
|
||||||
if enc == "starttls" {
|
|
||||||
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
|
|
||||||
conn, err := smtp.Dial(addr)
|
|
||||||
if err != nil {
|
|
||||||
goto fallback
|
|
||||||
}
|
|
||||||
if err = conn.StartTLS(tlsCfg); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
goto fallback
|
|
||||||
}
|
|
||||||
if err = conn.Auth(auth); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return fmt.Errorf("soporte SMTP STARTTLS auth: %w", err)
|
|
||||||
}
|
|
||||||
return smtp.SendMail(addr, auth, from, []string{to}, msg)
|
|
||||||
}
|
|
||||||
return smtp.SendMail(addr, auth, from, []string{to}, msg)
|
|
||||||
|
|
||||||
fallback:
|
if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil {
|
||||||
return app.Http.Mail.Send(to, subject, htmlBody)
|
return fmt.Errorf("SMTP de soporte (%s): %w", addr, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enviarPorSMTP abre una sola conexión, la asegura según la configuración,
|
||||||
|
// manda el mensaje y cierra con QUIT.
|
||||||
|
//
|
||||||
|
// Antes el camino STARTTLS abría una conexión, hacía StartTLS, autenticaba… y
|
||||||
|
// la descartaba para llamar a smtp.SendMail, que abre otra distinta: la primera
|
||||||
|
// quedaba colgada y el envío real salía por una conexión que podía no estar
|
||||||
|
// autenticada igual.
|
||||||
|
func enviarPorSMTP(cfg *models.SoporteWebhookConfig, addr, from, to string, msg []byte) error {
|
||||||
|
enc := strings.ToLower(strings.TrimSpace(cfg.SmtpEncryption))
|
||||||
|
|
||||||
|
var cliente *smtp.Client
|
||||||
|
var err error
|
||||||
|
if enc == "tls" || enc == "ssl" {
|
||||||
|
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SmtpHost})
|
||||||
|
if errDial != nil {
|
||||||
|
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
|
||||||
|
}
|
||||||
|
cliente, err = smtp.NewClient(conn, cfg.SmtpHost)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("saludo SMTP rechazado: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cliente, err = smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no se pudo conectar: %w", err)
|
||||||
|
}
|
||||||
|
if enc != "none" {
|
||||||
|
if err := cliente.StartTLS(&tls.Config{ServerName: cfg.SmtpHost}); err != nil {
|
||||||
|
cliente.Close()
|
||||||
|
return fmt.Errorf("STARTTLS rechazado: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer cliente.Close()
|
||||||
|
|
||||||
|
if cfg.SmtpUsername != "" {
|
||||||
|
if err := cliente.Auth(smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)); err != nil {
|
||||||
|
return fmt.Errorf("autenticación rechazada para %s: %w", cfg.SmtpUsername, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cliente.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("el servidor rechazó el remitente %s: %w", from, err)
|
||||||
|
}
|
||||||
|
if err := cliente.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", to, err)
|
||||||
|
}
|
||||||
|
w, err := cliente.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
|
||||||
|
}
|
||||||
|
return cliente.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que usa el
|
||||||
|
// acuse automático, y devuelve el error tal cual. Es la única forma de saber
|
||||||
|
// por qué no llega: el acuse real se manda en segundo plano.
|
||||||
|
func ProbarEnvioSoporte(destino string) error {
|
||||||
|
if strings.TrimSpace(destino) == "" {
|
||||||
|
return fmt.Errorf("indicá a qué dirección mandar la prueba")
|
||||||
|
}
|
||||||
|
cuerpo := `<p>Esto es una prueba del envío de soporte.</p>
|
||||||
|
<p>Si te llegó, el acuse automático de los tickets también va a salir por acá.</p>`
|
||||||
|
return soporteSendMail(destino, "Prueba de envío de soporte", cuerpo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email
|
// SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email
|
||||||
@@ -123,8 +157,10 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
|
|||||||
go func() {
|
go func() {
|
||||||
if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil {
|
if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil {
|
||||||
log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err)
|
log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err)
|
||||||
|
guardarErrorAcuse(fmt.Sprintf("el acuse al cliente %s no salió: %v", ticket.EmailFrom, err))
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID)
|
log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID)
|
||||||
|
guardarErrorAcuse("")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -165,3 +201,25 @@ func SendSoporteNotifAdmin(ticket *models.ProyectoTicket) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El acuse se manda en segundo plano, así que su error no puede devolverse al
|
||||||
|
// que creó el ticket. Se guarda acá para poder mostrarlo en la pantalla de
|
||||||
|
// configuración, que es donde alguien lo va a ver.
|
||||||
|
var (
|
||||||
|
muErrorAcuse sync.Mutex
|
||||||
|
ultimoErrAcuse string
|
||||||
|
)
|
||||||
|
|
||||||
|
func guardarErrorAcuse(msg string) {
|
||||||
|
muErrorAcuse.Lock()
|
||||||
|
defer muErrorAcuse.Unlock()
|
||||||
|
ultimoErrAcuse = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// UltimoErrorAcuse devuelve el último fallo al mandarle el acuse a un cliente
|
||||||
|
// ("" si el último salió bien).
|
||||||
|
func UltimoErrorAcuse() string {
|
||||||
|
muErrorAcuse.Lock()
|
||||||
|
defer muErrorAcuse.Unlock()
|
||||||
|
return ultimoErrAcuse
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cuando se agotan las rondas de herramientas, el visitante recibía un mensaje
|
||||||
|
// genérico aunque el modelo ya le hubiera escrito una respuesta completa: el
|
||||||
|
// texto que viene junto con una llamada a herramienta se descartaba.
|
||||||
|
//
|
||||||
|
// Se prueba la lógica de selección, que es la que decide qué ve la persona.
|
||||||
|
func TestSePrefiereElTextoDelModeloAlMensajeGenerico(t *testing.T) {
|
||||||
|
const generico = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
|
||||||
|
|
||||||
|
elegir := func(final, parcial string) string {
|
||||||
|
if final != "" {
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(parcial) != "" {
|
||||||
|
return parcial
|
||||||
|
}
|
||||||
|
return generico
|
||||||
|
}
|
||||||
|
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
final string
|
||||||
|
parcial string
|
||||||
|
esperado string
|
||||||
|
}{
|
||||||
|
{"respuesta normal", "Los 4 pilares son…", "", "Los 4 pilares son…"},
|
||||||
|
{"se agotaron las rondas pero el modelo había escrito", "", "Los 4 pilares son…", "Los 4 pilares son…"},
|
||||||
|
{"el modelo nunca escribió nada", "", "", generico},
|
||||||
|
{"texto parcial en blanco no cuenta", "", " \n ", generico},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range casos {
|
||||||
|
if got := elegir(c.final, c.parcial); got != c.esperado {
|
||||||
|
t.Errorf("%s: eligió %q, esperaba %q", c.nombre, got, c.esperado)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El resultado de una herramienta que falló se reconoce por su forma. Si esa
|
||||||
|
// detección se rompe, el evento de diagnóstico deja de registrarse y "se
|
||||||
|
// agotaron las rondas" vuelve a no decir dónde mirar.
|
||||||
|
func TestSeDetectaElErrorDeUnaHerramienta(t *testing.T) {
|
||||||
|
casos := map[string]bool{
|
||||||
|
`{"error": "consulta requerida"}`: true,
|
||||||
|
` {"error": "el servicio de embeddings no está configurado"}`: true,
|
||||||
|
`{"resultados": ["texto"]}`: false,
|
||||||
|
`{"resultados": [], "nota": "No se encontró información relacionada"}`: false,
|
||||||
|
}
|
||||||
|
for entrada, esError := range casos {
|
||||||
|
got := strings.HasPrefix(strings.TrimSpace(entrada), `{"error"`)
|
||||||
|
if got != esError {
|
||||||
|
t.Errorf("%q: detectado como error = %v, esperaba %v", entrada, got, esError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,8 +283,27 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
|
|||||||
_ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText)
|
_ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText)
|
||||||
|
|
||||||
var finalResponse string
|
var finalResponse string
|
||||||
for round := 0; round < 3; round++ {
|
// Lo último que el modelo llegó a escribir, aunque haya venido acompañado
|
||||||
aiMsg, tokens, err := callAI(&ai, messages, tools)
|
// de una llamada a herramienta.
|
||||||
|
var ultimoTextoParcial string
|
||||||
|
// Para el evento de diagnóstico: sin saber qué herramienta pidió en cada
|
||||||
|
// ronda, "se agotaron las rondas" no dice dónde mirar.
|
||||||
|
var herramientasPedidas []string
|
||||||
|
|
||||||
|
// La última vuelta se llama SIN herramientas. Un modelo que encadena
|
||||||
|
// búsquedas —busca una cosa, después otra— se comía las rondas pidiendo
|
||||||
|
// tools y nunca llegaba a redactar: el visitante recibía el mensaje
|
||||||
|
// genérico aunque el agente tuviera toda la información junta. Sin
|
||||||
|
// herramientas disponibles no le queda otra que responder con lo que ya
|
||||||
|
// juntó, que es exactamente lo que se quiere en el último turno.
|
||||||
|
const maxRondas = 4
|
||||||
|
|
||||||
|
for round := 0; round < maxRondas; round++ {
|
||||||
|
disponibles := tools
|
||||||
|
if round == maxRondas-1 {
|
||||||
|
disponibles = nil
|
||||||
|
}
|
||||||
|
aiMsg, tokens, err := callAI(&ai, messages, disponibles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
|
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
|
||||||
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
|
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
|
||||||
@@ -314,11 +333,28 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un modelo puede mandar texto Y pedir herramientas en el mismo turno.
|
||||||
|
// Ese texto se descartaba: si después se agotaban las rondas, el
|
||||||
|
// visitante recibía el mensaje genérico aunque el agente ya le hubiera
|
||||||
|
// escrito una respuesta buena.
|
||||||
|
if s, ok := aiMsg.Content.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
ultimoTextoParcial = s
|
||||||
|
}
|
||||||
|
|
||||||
messages = append(messages, *aiMsg)
|
messages = append(messages, *aiMsg)
|
||||||
for _, tc := range aiMsg.ToolCalls {
|
for _, tc := range aiMsg.ToolCalls {
|
||||||
var toolArgs map[string]interface{}
|
var toolArgs map[string]interface{}
|
||||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
|
||||||
toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs)
|
toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs)
|
||||||
|
herramientasPedidas = append(herramientasPedidas, tc.Function.Name)
|
||||||
|
// Una herramienta que devuelve error y el modelo que la reintenta es
|
||||||
|
// la forma más común de agotar las rondas. Queda registrado para
|
||||||
|
// poder verlo en Auditoría en vez de deducirlo.
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(toolResult), `{"error"`) {
|
||||||
|
models.RegistrarEventoUmind(agente.ID, "warn", "tool",
|
||||||
|
fmt.Sprintf("La herramienta %q devolvió error en la ronda %d", tc.Function.Name, round),
|
||||||
|
toolResult)
|
||||||
|
}
|
||||||
messages = append(messages, agentMessage{
|
messages = append(messages, agentMessage{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
@@ -329,9 +365,26 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if finalResponse == "" {
|
if finalResponse == "" {
|
||||||
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin una respuesta final", agente.ID)
|
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin respuesta final (herramientas: %v)",
|
||||||
models.RegistrarEventoUmind(agente.ID, "warn", "ai", "Se agotaron las rondas de tool-calling sin una respuesta final", "")
|
agente.ID, herramientasPedidas)
|
||||||
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
|
models.RegistrarEventoUmind(agente.ID, "warn", "ai",
|
||||||
|
"Se agotaron las 3 rondas de herramientas sin una respuesta final",
|
||||||
|
fmt.Sprintf("Herramientas pedidas en orden: %s", strings.Join(herramientasPedidas, " → ")))
|
||||||
|
|
||||||
|
// Antes de dar el mensaje genérico, usar lo que el modelo ya escribió.
|
||||||
|
if strings.TrimSpace(ultimoTextoParcial) != "" {
|
||||||
|
finalResponse = ultimoTextoParcial
|
||||||
|
} else {
|
||||||
|
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
|
||||||
|
}
|
||||||
|
// Se guarda igual, aunque sea el mensaje genérico. Sin esto quedaba una
|
||||||
|
// conversación con varios mensajes del visitante y ninguna respuesta:
|
||||||
|
// no se veía en Conversaciones —el dueño no se enteraba de que su
|
||||||
|
// agente estaba fallando— y, peor, ese historial roto se le reenviaba
|
||||||
|
// al modelo en el turno siguiente. Un modelo que ve cuatro preguntas
|
||||||
|
// seguidas sin una sola respuesta se confunde y vuelve a fallar, así
|
||||||
|
// que el primer error se perpetuaba solo.
|
||||||
|
_ = models.SaveUmindMensaje(agente.ID, sessionID, "assistant", finalResponse)
|
||||||
}
|
}
|
||||||
return finalResponse, nil
|
return finalResponse, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ func ProcesarMensajeTelegramUmind(canal *models.UmindCanal, chatID int64, texto
|
|||||||
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
|
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
|
||||||
// Si no está habilitada, se ignora en silencio.
|
// Si no está habilitada, se ignora en silencio.
|
||||||
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
|
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
|
||||||
|
return ProcesarMediaTelegramUmindConNombre(canal, chatID, fileID, tipo, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcesarMediaTelegramUmindConNombre es la versión completa: los documentos
|
||||||
|
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
|
||||||
|
func ProcesarMediaTelegramUmindConNombre(canal *models.UmindCanal, chatID int64, fileID, tipo, nombreArchivo, caption string) error {
|
||||||
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
|
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
|
||||||
if err != nil || !agente.Activo {
|
if err != nil || !agente.Activo {
|
||||||
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
|
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
|
||||||
@@ -84,6 +90,19 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case "document":
|
||||||
|
if !canal.UsarArchivosDocs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := descargarArchivoTelegram(botToken, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no se pudo descargar el archivo de Telegram: %w", err)
|
||||||
|
}
|
||||||
|
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string)
|
|||||||
// Si la conversión no está habilitada para ese tipo, se ignora en silencio
|
// Si la conversión no está habilitada para ese tipo, se ignora en silencio
|
||||||
// (mismo comportamiento de antes de que existiera esta función).
|
// (mismo comportamiento de antes de que existiera esta función).
|
||||||
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error {
|
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error {
|
||||||
|
return ProcesarMediaWhatsAppUmindConNombre(canal, from, mediaID, tipo, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcesarMediaWhatsAppUmindConNombre es la versión completa: los documentos
|
||||||
|
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
|
||||||
|
func ProcesarMediaWhatsAppUmindConNombre(canal *models.UmindCanal, from, mediaID, tipo, nombreArchivo, caption string) error {
|
||||||
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
|
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
|
||||||
if err != nil || !agente.Activo {
|
if err != nil || !agente.Activo {
|
||||||
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
|
return fmt.Errorf("agente no encontrado o inactivo: %w", err)
|
||||||
@@ -96,6 +102,19 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case "document":
|
||||||
|
if !canal.UsarArchivosDocs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err)
|
||||||
|
}
|
||||||
|
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DuplicarAgente crea una copia de un agente en el tenant indicado, con su
|
||||||
|
// conocimiento y sus herramientas.
|
||||||
|
//
|
||||||
|
// Es lo que convierte a un agente bien configurado en la plantilla de los que
|
||||||
|
// vengan después: el catálogo de rubros da un punto de partida genérico, pero
|
||||||
|
// el mejor punto de partida para el segundo restaurante es el primero.
|
||||||
|
//
|
||||||
|
// Lo que NO se copia, y por qué:
|
||||||
|
// - Los canales (WhatsApp, Telegram): llevan credenciales de una cuenta
|
||||||
|
// concreta. Copiarlas haría que dos agentes contesten por el mismo número.
|
||||||
|
// - Las conversaciones: son de los clientes del otro negocio.
|
||||||
|
// - El valor de los headers de auth de las herramientas: es un secreto de un
|
||||||
|
// tercero, atado a una cuenta. La herramienta se copia sin él, para que
|
||||||
|
// quien reciba la copia lo cargue.
|
||||||
|
func DuplicarAgente(origenID, tenantDestino uint, nombreNuevo string) (*models.UmindAgente, error) {
|
||||||
|
origen, err := models.GetUmindAgenteByID(origenID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("el agente que querés copiar no existe: %w", err)
|
||||||
|
}
|
||||||
|
if tenantDestino == 0 {
|
||||||
|
tenantDestino = origen.TenantID
|
||||||
|
}
|
||||||
|
if nombreNuevo == "" {
|
||||||
|
nombreNuevo = origen.Nombre + " (copia)"
|
||||||
|
}
|
||||||
|
|
||||||
|
copia := &models.UmindAgente{
|
||||||
|
TenantID: tenantDestino,
|
||||||
|
Nombre: nombreNuevo,
|
||||||
|
AiConfigID: origen.AiConfigID,
|
||||||
|
Tono: origen.Tono,
|
||||||
|
MensajeBienvenida: origen.MensajeBienvenida,
|
||||||
|
Color: origen.Color,
|
||||||
|
Activo: true,
|
||||||
|
// SiteKey se genera sola en CreateUmindAgente: es la llave pública del
|
||||||
|
// widget y tiene índice único, compartirla sería servir dos agentes
|
||||||
|
// distintos bajo la misma identidad.
|
||||||
|
}
|
||||||
|
if err := models.CreateUmindAgente(copia); err != nil {
|
||||||
|
return nil, fmt.Errorf("no se pudo crear la copia: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go copiarConocimiento(origen.ID, copia.ID)
|
||||||
|
copiarHerramientas(origen.ID, copia.ID)
|
||||||
|
|
||||||
|
return copia, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copiarConocimiento rehace las fuentes en el agente nuevo. Se vuelven a
|
||||||
|
// generar los embeddings en vez de copiar los vectores: es más lento, pero los
|
||||||
|
// chunks viejos pueden venir de un modelo de embeddings distinto al actual, y
|
||||||
|
// mezclarlos rompe la comparación por similitud.
|
||||||
|
func copiarConocimiento(origenID, destinoID uint) {
|
||||||
|
docs, err := models.GetUmindDocumentosByAgente(origenID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudo leer el conocimiento del agente %d: %v", origenID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copiados := 0
|
||||||
|
for _, d := range docs {
|
||||||
|
nuevo := &models.UmindDocumento{
|
||||||
|
AgenteID: destinoID,
|
||||||
|
Tipo: d.Tipo,
|
||||||
|
Origen: d.Origen,
|
||||||
|
Contenido: d.Contenido,
|
||||||
|
MaxPaginas: d.MaxPaginas,
|
||||||
|
AutoActualizar: d.AutoActualizar,
|
||||||
|
Estado: "procesando",
|
||||||
|
}
|
||||||
|
if err := models.CreateUmindDocumento(nuevo); err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudo copiar la fuente %q: %v", d.Origen, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case d.Tipo == "url":
|
||||||
|
IngestarAgente(destinoID, nuevo.ID, d.Origen, d.MaxPaginas)
|
||||||
|
case d.Contenido != "":
|
||||||
|
IngestarTexto(destinoID, nuevo.ID, d.Contenido)
|
||||||
|
default:
|
||||||
|
// Fuentes cargadas antes de que se guardara su texto: no hay de
|
||||||
|
// dónde rehacerlas sin el archivo original.
|
||||||
|
_ = models.UpdateUmindDocumentoEstado(nuevo.ID, "error",
|
||||||
|
"esta fuente se copió de un agente donde no quedó guardado su texto: volvé a subirla", 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copiados++
|
||||||
|
}
|
||||||
|
log.Printf("[UMIND] Agente %d copiado desde %d: %d de %d fuentes", destinoID, origenID, copiados, len(docs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// copiarHerramientas replica las tools sin su secreto de autenticación.
|
||||||
|
func copiarHerramientas(origenID, destinoID uint) {
|
||||||
|
tools, err := models.GetUmindHerramientasByAgente(origenID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudieron leer las herramientas del agente %d: %v", origenID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range tools {
|
||||||
|
nueva := &models.UmindHerramienta{
|
||||||
|
AgenteID: destinoID,
|
||||||
|
Nombre: t.Nombre,
|
||||||
|
Descripcion: t.Descripcion,
|
||||||
|
ParametrosJSON: t.ParametrosJSON,
|
||||||
|
URL: t.URL,
|
||||||
|
AuthHeaderNombre: t.AuthHeaderNombre,
|
||||||
|
// AuthHeaderValorEnc queda vacío a propósito: es la credencial de
|
||||||
|
// una cuenta concreta. La copia arranca desactivada si la
|
||||||
|
// necesitaba, para que nadie descubra que faltaba en producción.
|
||||||
|
Activa: t.Activa && (t.AuthHeaderNombre == "" || t.AuthHeaderValorEnc == ""),
|
||||||
|
}
|
||||||
|
if err := models.CreateUmindHerramienta(nueva); err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudo copiar la herramienta %q: %v", t.Nombre, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copiar una herramienta con su header de auth le daría al dueño del agente
|
||||||
|
// nuevo la credencial de un tercero que no es suya. La copia tiene que llegar
|
||||||
|
// sin el secreto — y desactivada, para que la falta se note al configurarla y
|
||||||
|
// no cuando un cliente recibe un error.
|
||||||
|
func TestLaCopiaDeHerramientaNoArrastraElSecreto(t *testing.T) {
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
headerNombre string
|
||||||
|
headerValor string
|
||||||
|
activaOrigen bool
|
||||||
|
activaEsperada bool
|
||||||
|
}{
|
||||||
|
{"con auth: llega desactivada", "Authorization", "cifrado-xyz", true, false},
|
||||||
|
{"sin auth: conserva su estado", "", "", true, true},
|
||||||
|
{"sin auth y desactivada: sigue desactivada", "", "", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range casos {
|
||||||
|
origen := models.UmindHerramienta{
|
||||||
|
Nombre: "consultar_stock",
|
||||||
|
AuthHeaderNombre: c.headerNombre,
|
||||||
|
AuthHeaderValorEnc: c.headerValor,
|
||||||
|
Activa: c.activaOrigen,
|
||||||
|
}
|
||||||
|
// Misma expresión que usa copiarHerramientas.
|
||||||
|
activa := origen.Activa && (origen.AuthHeaderNombre == "" || origen.AuthHeaderValorEnc == "")
|
||||||
|
if activa != c.activaEsperada {
|
||||||
|
t.Errorf("%s: activa = %v, esperaba %v", c.nombre, activa, c.activaEsperada)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El nombre por defecto tiene que distinguir la copia del original: dos
|
||||||
|
// agentes con el mismo nombre en la misma lista no se pueden diferenciar.
|
||||||
|
func TestNombrePorDefectoDeLaCopia(t *testing.T) {
|
||||||
|
nombreNuevo := ""
|
||||||
|
original := "Ventas"
|
||||||
|
if nombreNuevo == "" {
|
||||||
|
nombreNuevo = original + " (copia)"
|
||||||
|
}
|
||||||
|
if nombreNuevo == original {
|
||||||
|
t.Error("la copia no puede llamarse igual que el original")
|
||||||
|
}
|
||||||
|
if !strings.Contains(nombreNuevo, original) {
|
||||||
|
t.Errorf("el nombre de la copia debería reconocerse: %q", nombreNuevo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Todo lo que ve el visitante tiene que quedar guardado, incluido el mensaje
|
||||||
|
// genérico de cuando algo falla. Sin eso pasaban dos cosas, y la segunda es la
|
||||||
|
// grave:
|
||||||
|
//
|
||||||
|
// 1. En Conversaciones se veían las preguntas sin ninguna respuesta, así que
|
||||||
|
// el dueño no se enteraba de que su agente estaba fallando.
|
||||||
|
// 2. Ese historial roto se le reenviaba al modelo en el turno siguiente. Un
|
||||||
|
// modelo que ve cuatro preguntas seguidas sin una sola respuesta se
|
||||||
|
// confunde y vuelve a fallar: el primer error se perpetuaba solo.
|
||||||
|
func TestLaRespuestaAlVisitanteSiempreQuedaGuardada(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("umind_agent_service.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fuente := string(b)
|
||||||
|
|
||||||
|
// El bloque del fallback tiene que guardar.
|
||||||
|
i := strings.Index(fuente, `if finalResponse == "" {`)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("no encontré el bloque de fallback")
|
||||||
|
}
|
||||||
|
bloque := fuente[i:]
|
||||||
|
if j := strings.Index(bloque, "\n\treturn finalResponse"); j > 0 {
|
||||||
|
bloque = bloque[:j]
|
||||||
|
}
|
||||||
|
if !strings.Contains(bloque, "SaveUmindMensaje") {
|
||||||
|
t.Error("el mensaje genérico no se guarda: la conversación queda con preguntas sin respuesta y el historial roto se reenvía al modelo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y el mensaje del visitante también, desde antes del bucle.
|
||||||
|
if !strings.Contains(fuente, `SaveUmindMensaje(agente.ID, sessionID, "user", userText)`) {
|
||||||
|
t.Error("no se guarda el mensaje del visitante")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,6 +197,13 @@ func trocearTexto(texto string) []string {
|
|||||||
if siguiente <= inicio {
|
if siguiente <= inicio {
|
||||||
siguiente = inicio + corte
|
siguiente = inicio + corte
|
||||||
}
|
}
|
||||||
|
// El solape caía en cualquier lado, así que el fragmento siguiente
|
||||||
|
// podía arrancar a mitad de una palabra ("alabra…"). Se corre hasta el
|
||||||
|
// espacio siguiente: media palabra suelta al principio no aporta nada
|
||||||
|
// al embedding y ensucia el fragmento que se le muestra al modelo.
|
||||||
|
if esp := strings.IndexByte(texto[siguiente:], ' '); esp > 0 && siguiente+esp < inicio+corte {
|
||||||
|
siguiente += esp + 1
|
||||||
|
}
|
||||||
inicio = siguiente
|
inicio = siguiente
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
@@ -212,16 +219,6 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
|||||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0)
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
|
|
||||||
// del agente: todos los chunks de todos los agentes deben salir del mismo
|
|
||||||
// modelo de embeddings para que la similitud coseno entre vectores tenga
|
|
||||||
// sentido. La config del agente (AiConfigID) es solo para el chat.
|
|
||||||
ai, err := models.GetUmindEmbeddingsConfig()
|
|
||||||
if err != nil {
|
|
||||||
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxPaginas <= 0 {
|
if maxPaginas <= 0 {
|
||||||
maxPaginas = 30
|
maxPaginas = 30
|
||||||
}
|
}
|
||||||
@@ -235,10 +232,43 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trocear todo el contenido crawleado en chunks de texto plano.
|
|
||||||
var textos []string
|
var textos []string
|
||||||
for _, p := range paginas {
|
for _, p := range paginas {
|
||||||
for _, c := range trocearTexto(p.Texto) {
|
textos = append(textos, p.Texto)
|
||||||
|
}
|
||||||
|
if n, err := guardarConocimiento(agenteID, documentoID, textos); err != nil {
|
||||||
|
log.Printf("[UMIND] Ingesta de agente %d fallida: %v", agenteID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestarTexto guarda como conocimiento un texto que se cargó a mano o que se
|
||||||
|
// extrajo de un archivo. Es el mismo trabajo que hace la ingesta de una URL
|
||||||
|
// desde que tiene el texto: trocear, embeber y guardar.
|
||||||
|
func IngestarTexto(agenteID, documentoID uint, texto string) {
|
||||||
|
if _, err := guardarConocimiento(agenteID, documentoID, []string{texto}); err != nil {
|
||||||
|
log.Printf("[UMIND] Ingesta de texto del agente %d fallida: %v", agenteID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardarConocimiento trocea, genera los embeddings y reemplaza los fragmentos
|
||||||
|
// del documento. Reemplaza y no agrega: si no, reprocesar una fuente dejaría
|
||||||
|
// dos versiones del mismo contenido compitiendo en la búsqueda, y la vieja
|
||||||
|
// puede ganar.
|
||||||
|
func guardarConocimiento(agenteID, documentoID uint, fuentes []string) (int, error) {
|
||||||
|
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
|
||||||
|
// del agente: todos los chunks de todos los agentes deben salir del mismo
|
||||||
|
// modelo para que la similitud coseno entre vectores tenga sentido.
|
||||||
|
ai, err := models.GetUmindEmbeddingsConfig()
|
||||||
|
if err != nil {
|
||||||
|
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var textos []string
|
||||||
|
for _, fuente := range fuentes {
|
||||||
|
for _, c := range trocearTexto(fuente) {
|
||||||
if len(strings.TrimSpace(c)) < 40 {
|
if len(strings.TrimSpace(c)) < 40 {
|
||||||
continue // fragmentos demasiado cortos no aportan al RAG
|
continue // fragmentos demasiado cortos no aportan al RAG
|
||||||
}
|
}
|
||||||
@@ -246,8 +276,9 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(textos) == 0 {
|
if len(textos) == 0 {
|
||||||
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0)
|
msg := "no se generó ningún fragmento de texto aprovechable"
|
||||||
return
|
models.UpdateUmindDocumentoEstado(documentoID, "error", msg, 0)
|
||||||
|
return 0, fmt.Errorf("%s", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generar embeddings en tandas para no mandar un solo request gigante.
|
// Generar embeddings en tandas para no mandar un solo request gigante.
|
||||||
@@ -262,7 +293,7 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
|||||||
vectores, err := GenerarEmbeddings(ai, tanda)
|
vectores, err := GenerarEmbeddings(ai, tanda)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
|
||||||
return
|
return 0, err
|
||||||
}
|
}
|
||||||
for j, texto := range tanda {
|
for j, texto := range tanda {
|
||||||
embJSON, err := models.EmbeddingToJSON(vectores[j])
|
embJSON, err := models.EmbeddingToJSON(vectores[j])
|
||||||
@@ -278,11 +309,49 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := models.BorrarChunksDeDocumento(documentoID); err != nil {
|
||||||
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("no se pudieron limpiar los fragmentos anteriores: %v", err), 0)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
if err := models.CreateUmindChunks(chunks); err != nil {
|
if err := models.CreateUmindChunks(chunks); err != nil {
|
||||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
|
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
|
||||||
return
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
|
models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
|
||||||
log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), len(chunks))
|
return len(chunks), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReprocesarDocumento vuelve a procesar una fuente, en segundo plano.
|
||||||
|
// Una URL se recrawlea —es la única forma de que el agente deje de contestar
|
||||||
|
// con la información del año pasado— y una nota o un archivo se rearman desde
|
||||||
|
// el texto guardado, sin pedirle al dueño que lo vuelva a subir.
|
||||||
|
func ReprocesarDocumento(doc models.UmindDocumento) {
|
||||||
|
_ = models.UpdateUmindDocumentoEstado(doc.ID, "procesando", "", doc.TotalChunks)
|
||||||
|
if doc.Tipo == "url" {
|
||||||
|
go IngestarAgente(doc.AgenteID, doc.ID, doc.Origen, doc.MaxPaginas)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(doc.Contenido) == "" {
|
||||||
|
_ = models.UpdateUmindDocumentoEstado(doc.ID, "error",
|
||||||
|
"esta fuente se cargó antes de que se guardara su texto: volvé a subirla", 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go IngestarTexto(doc.AgenteID, doc.ID, doc.Contenido)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefrescarConocimientoUmind recrawlea las fuentes marcadas para actualizarse
|
||||||
|
// solas. Lo llama el cron: sin esto, el conocimiento se congela el día que se
|
||||||
|
// cargó y nadie se entera, porque no falla — solo queda viejo.
|
||||||
|
func RefrescarConocimientoUmind() {
|
||||||
|
const diasEntreRefrescos = 7
|
||||||
|
docs, err := models.GetDocumentosParaRefrescar(diasEntreRefrescos)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudieron buscar fuentes para refrescar: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
log.Printf("[UMIND] Refrescando fuente %d del agente %d (%s)", d.ID, d.AgenteID, d.Origen)
|
||||||
|
ReprocesarDocumento(d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El troceado es lo que decide qué puede encontrar el agente después. Un
|
||||||
|
// fragmento cortado a la mitad de una frase o sin solape hace que la búsqueda
|
||||||
|
// devuelva texto que no responde nada.
|
||||||
|
func TestTrocearTexto(t *testing.T) {
|
||||||
|
if got := trocearTexto(" "); got != nil {
|
||||||
|
t.Errorf("texto vacío debería dar nil, dio %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
corto := "Atendemos de 9 a 18."
|
||||||
|
if got := trocearTexto(corto); len(got) != 1 || got[0] != corto {
|
||||||
|
t.Errorf("un texto corto tiene que quedar en un solo fragmento: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
largo := strings.Repeat("palabra ", 900)
|
||||||
|
chunks := trocearTexto(largo)
|
||||||
|
if len(chunks) < 2 {
|
||||||
|
t.Fatalf("un texto largo debería partirse, dio %d fragmento(s)", len(chunks))
|
||||||
|
}
|
||||||
|
for i, c := range chunks {
|
||||||
|
if strings.HasPrefix(c, " ") || strings.HasSuffix(c, " ") {
|
||||||
|
t.Errorf("fragmento %d con espacios en los bordes: %q", i, c)
|
||||||
|
}
|
||||||
|
if c == "" {
|
||||||
|
t.Errorf("fragmento %d vacío", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sin solape, una frase que cae justo en el corte se pierde para siempre.
|
||||||
|
if !strings.HasPrefix(chunks[1], "palabra") {
|
||||||
|
t.Errorf("el segundo fragmento debería arrancar con contenido real: %q", chunks[1][:20])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlantillaRubro es un punto de partida para un agente nuevo: el conocimiento
|
||||||
|
// base que casi todos los negocios de ese rubro necesitan, ya escrito.
|
||||||
|
//
|
||||||
|
// No reemplaza la información real del cliente — la reemplaza el cliente
|
||||||
|
// editando estas notas. Existe porque un agente vacío no sirve para nada el
|
||||||
|
// primer día, y escribir desde cero frente a un campo en blanco es donde la
|
||||||
|
// mayoría abandona.
|
||||||
|
type PlantillaRubro struct {
|
||||||
|
Clave string `json:"clave"`
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
Descripcion string `json:"descripcion"`
|
||||||
|
Tono string `json:"tono"`
|
||||||
|
Bienvenida string `json:"mensaje_bienvenida"`
|
||||||
|
Notas []NotaPlanura `json:"notas"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotaPlanura es una nota de conocimiento con su texto de ejemplo. El texto
|
||||||
|
// está escrito para que se note que hay que cambiarlo: valores entre
|
||||||
|
// corchetes, nunca datos inventados que parezcan reales.
|
||||||
|
type NotaPlanura struct {
|
||||||
|
Titulo string `json:"titulo"`
|
||||||
|
Contenido string `json:"contenido"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlantillasRubro es el catálogo. Vive en código y no en base de datos a
|
||||||
|
// propósito: son contenido editorial que cambia cuando lo mejoramos nosotros,
|
||||||
|
// no configuración que cada instalación toca por su lado.
|
||||||
|
var PlantillasRubro = []PlantillaRubro{
|
||||||
|
{
|
||||||
|
Clave: "generico",
|
||||||
|
Nombre: "Negocio general",
|
||||||
|
Descripcion: "La base que sirve para cualquier rubro: horarios, contacto, formas de pago.",
|
||||||
|
Tono: "amable y directo",
|
||||||
|
Bienvenida: "¡Hola! Contame en qué te puedo ayudar.",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Horarios y contacto",
|
||||||
|
Contenido: `Atendemos de [lunes a viernes] de [9:00] a [18:00], y los [sábados] de [9:00] a [13:00].
|
||||||
|
Los [domingos] y feriados no atendemos.
|
||||||
|
Nos pueden escribir por WhatsApp al [número] o al correo [correo].
|
||||||
|
Estamos en [dirección completa], [ciudad].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Formas de pago",
|
||||||
|
Contenido: `Aceptamos [efectivo, tarjeta débito y crédito, transferencia y Nequi].
|
||||||
|
[No] aceptamos [cheques].
|
||||||
|
Para transferencias, la cuenta es [banco, tipo de cuenta, número] a nombre de [titular].
|
||||||
|
El pago se confirma cuando [nos llega el comprobante / se acredita].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Qué NO hacemos",
|
||||||
|
Contenido: `Escribí acá lo que la gente pregunta seguido y la respuesta es que no.
|
||||||
|
Ejemplos: no hacemos [envíos fuera de la ciudad], no trabajamos con [cierta marca],
|
||||||
|
no atendemos [sin cita previa].
|
||||||
|
Esto evita que el asistente prometa cosas que después no podés cumplir.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clave: "tienda",
|
||||||
|
Nombre: "Tienda / e-commerce",
|
||||||
|
Descripcion: "Envíos, cambios, garantía y estado de pedidos.",
|
||||||
|
Tono: "cercano y resolutivo",
|
||||||
|
Bienvenida: "¡Hola! ¿Buscás algo en particular o querés saber por un pedido?",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Envíos y zonas de cobertura",
|
||||||
|
Contenido: `Hacemos envíos a [ciudades o zonas].
|
||||||
|
El costo del envío es [$X] y demora [N] días hábiles.
|
||||||
|
Envío gratis por compras superiores a [$X].
|
||||||
|
A [zonas donde no llegan] no hacemos envíos.
|
||||||
|
El pedido se despacha [el mismo día si se hace antes de las X / al día siguiente].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Cambios y devoluciones",
|
||||||
|
Contenido: `Se pueden cambiar productos dentro de los [N] días desde la entrega.
|
||||||
|
El producto tiene que estar [sin uso, con etiqueta, en su empaque original].
|
||||||
|
[No] aceptamos cambios en [productos en promoción / ropa interior / etc.].
|
||||||
|
El costo del envío del cambio lo cubre [el cliente / nosotros].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Garantía",
|
||||||
|
Contenido: `Los productos tienen [N meses] de garantía por defectos de fábrica.
|
||||||
|
La garantía no cubre [mal uso, daño por golpes, desgaste normal].
|
||||||
|
Para hacerla efectiva hace falta [la factura / el número de pedido].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Estado de un pedido",
|
||||||
|
Contenido: `Si preguntan por un pedido, pediles el [número de pedido] y decíles que
|
||||||
|
alguien del equipo lo revisa y responde en [tiempo].
|
||||||
|
No inventes estados de pedido: el asistente no tiene acceso al sistema de envíos.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clave: "restaurante",
|
||||||
|
Nombre: "Restaurante / comida",
|
||||||
|
Descripcion: "Menú, reservas, domicilios y restricciones alimentarias.",
|
||||||
|
Tono: "cálido y breve",
|
||||||
|
Bienvenida: "¡Hola! ¿Querés ver el menú, reservar o pedir a domicilio?",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Horarios de cocina y domicilios",
|
||||||
|
Contenido: `La cocina atiende de [12:00] a [15:00] y de [19:00] a [23:00].
|
||||||
|
Los domicilios salen hasta las [22:30].
|
||||||
|
[Día] cerramos.
|
||||||
|
El domicilio demora entre [N] y [M] minutos según la zona.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Zonas de domicilio y costo",
|
||||||
|
Contenido: `Llevamos a [barrios o zonas].
|
||||||
|
El costo del domicilio es [$X] o gratis por pedidos mayores a [$X].
|
||||||
|
A [zonas] no llegamos.
|
||||||
|
Pedido mínimo: [$X].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Reservas",
|
||||||
|
Contenido: `Tomamos reservas para grupos de [N] personas o más.
|
||||||
|
Se reserva con [N horas] de anticipación.
|
||||||
|
[Sí/No] cobramos seña.
|
||||||
|
Las mesas se guardan [N] minutos después de la hora reservada.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Restricciones y alergias",
|
||||||
|
Contenido: `Tenemos opciones [vegetarianas / veganas / sin gluten / sin lactosa]: [cuáles].
|
||||||
|
Nuestra cocina [sí/no] es libre de [maní, mariscos, gluten] — importante para alergias.
|
||||||
|
Ante una consulta por alergia grave, siempre derivar a una persona del equipo.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clave: "salud",
|
||||||
|
Nombre: "Consultorio / clínica",
|
||||||
|
Descripcion: "Turnos, obras sociales y qué NO puede responder un asistente.",
|
||||||
|
Tono: "profesional y claro",
|
||||||
|
Bienvenida: "Hola, ¿querés sacar un turno o consultar por coberturas?",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Límite importante: no damos consejo médico",
|
||||||
|
Contenido: `Este asistente NO responde consultas médicas, no interpreta síntomas,
|
||||||
|
no sugiere tratamientos ni medicamentos, y no dice si algo es urgente.
|
||||||
|
Ante cualquier consulta de salud, la respuesta es derivar a un profesional
|
||||||
|
y ofrecer sacar un turno. Si la persona describe una urgencia, indicar que
|
||||||
|
llame al [número de emergencias] o vaya a la guardia más cercana.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Turnos",
|
||||||
|
Contenido: `Atendemos con turno previo de [lunes a viernes] de [horario].
|
||||||
|
Los turnos se sacan por [WhatsApp / teléfono / la web].
|
||||||
|
La consulta dura aproximadamente [N] minutos.
|
||||||
|
Si no podés venir, avisanos con [N horas] de anticipación.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Obras sociales y particulares",
|
||||||
|
Contenido: `Trabajamos con [lista de obras sociales / prepagas].
|
||||||
|
[No] atendemos por [obras sociales específicas].
|
||||||
|
La consulta particular cuesta [$X].
|
||||||
|
Traer [documento, credencial, orden] el día del turno.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Dirección y cómo llegar",
|
||||||
|
Contenido: `Estamos en [dirección], [ciudad].
|
||||||
|
[Referencias para llegar].
|
||||||
|
[Hay / no hay] estacionamiento.
|
||||||
|
El consultorio [es / no es] accesible para silla de ruedas.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clave: "servicios",
|
||||||
|
Nombre: "Servicios profesionales",
|
||||||
|
Descripcion: "Para estudios, agencias, técnicos: qué hacen, cómo cotizan, plazos.",
|
||||||
|
Tono: "profesional y concreto",
|
||||||
|
Bienvenida: "Hola, contame qué necesitás y te oriento.",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Qué hacemos y qué no",
|
||||||
|
Contenido: `Nos dedicamos a [descripción del servicio].
|
||||||
|
Trabajamos con [tipo de clientes / rubros].
|
||||||
|
No hacemos [servicios fuera del alcance] — para eso conviene [alternativa].`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Cómo cotizamos",
|
||||||
|
Contenido: `Cada trabajo se cotiza según [alcance, horas, complejidad].
|
||||||
|
Un [servicio típico] arranca en [$X].
|
||||||
|
La cotización es [gratuita] y demora [N] días.
|
||||||
|
Para cotizar necesitamos saber [qué datos].
|
||||||
|
Nunca des un precio cerrado si no está en esta lista: ofrecé armar una cotización.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Plazos y forma de trabajo",
|
||||||
|
Contenido: `Un proyecto [típico] lleva entre [N] y [M] [semanas].
|
||||||
|
Trabajamos con [N]% de anticipo y el resto [contra entrega / en cuotas].
|
||||||
|
Las entregas se coordinan por [canal].`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Clave: "belleza",
|
||||||
|
Nombre: "Peluquería / estética",
|
||||||
|
Descripcion: "Servicios, precios, turnos y políticas de cancelación.",
|
||||||
|
Tono: "amable y cercano",
|
||||||
|
Bienvenida: "¡Hola! ¿Querés reservar un turno o consultar precios?",
|
||||||
|
Notas: []NotaPlanura{
|
||||||
|
{
|
||||||
|
Titulo: "Servicios y precios",
|
||||||
|
Contenido: `[Corte]: desde [$X] — dura [N] minutos.
|
||||||
|
[Color]: desde [$X] — dura [N] minutos.
|
||||||
|
[Otros servicios con precio y duración].
|
||||||
|
Los precios varían según [largo de pelo / cantidad de producto]; si preguntan
|
||||||
|
por un caso puntual, ofrecé una consulta previa sin cargo.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Turnos y cancelaciones",
|
||||||
|
Contenido: `Trabajamos con turno previo, de [lunes a sábado] de [horario].
|
||||||
|
Se reserva por [WhatsApp].
|
||||||
|
Si no podés venir, avisá con [N horas] de anticipación.
|
||||||
|
[Cobramos / no cobramos] seña para [servicios largos].
|
||||||
|
Llegar más de [N] minutos tarde puede significar reprogramar el turno.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Titulo: "Cuidados previos y posteriores",
|
||||||
|
Contenido: `Para [color / alisado], venir con el pelo [lavado / sin lavar].
|
||||||
|
Después de [servicio], no [lavarse el pelo / mojarse] por [N] horas.
|
||||||
|
Recomendamos [productos] para mantener el resultado.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlantillaRubroPorClave busca una plantilla del catálogo.
|
||||||
|
func PlantillaRubroPorClave(clave string) (*PlantillaRubro, bool) {
|
||||||
|
for i := range PlantillasRubro {
|
||||||
|
if PlantillasRubro[i].Clave == clave {
|
||||||
|
return &PlantillasRubro[i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AplicarPlantillaRubro carga las notas de una plantilla como conocimiento del
|
||||||
|
// agente. Cada nota entra como una fuente de tipo "texto" editable: el dueño
|
||||||
|
// abre, reemplaza los corchetes por sus datos y guarda.
|
||||||
|
//
|
||||||
|
// Se ejecuta en segundo plano porque cada nota necesita sus embeddings, y el
|
||||||
|
// agente ya se creó — hacer esperar la respuesta del alta por esto sería
|
||||||
|
// castigar al que elige plantilla.
|
||||||
|
func AplicarPlantillaRubro(agenteID uint, clave string) {
|
||||||
|
plantilla, ok := PlantillaRubroPorClave(clave)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[UMIND] Plantilla de rubro %q no existe, el agente %d queda vacío", clave, agenteID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nota := range plantilla.Notas {
|
||||||
|
doc := &models.UmindDocumento{
|
||||||
|
AgenteID: agenteID,
|
||||||
|
Tipo: "texto",
|
||||||
|
Origen: nota.Titulo,
|
||||||
|
Contenido: nota.Contenido,
|
||||||
|
Estado: "procesando",
|
||||||
|
}
|
||||||
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||||
|
log.Printf("[UMIND] No se pudo crear la nota %q del agente %d: %v", nota.Titulo, agenteID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
IngestarTexto(agenteID, doc.ID, nota.Contenido)
|
||||||
|
}
|
||||||
|
log.Printf("[UMIND] Agente %d arrancó con la plantilla %q (%d notas)", agenteID, clave, len(plantilla.Notas))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumenPlantillasRubro devuelve el catálogo sin el contenido completo de las
|
||||||
|
// notas — es lo que necesita la pantalla de elección para mostrar las opciones.
|
||||||
|
func ResumenPlantillasRubro() []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, 0, len(PlantillasRubro))
|
||||||
|
for _, p := range PlantillasRubro {
|
||||||
|
titulos := make([]string, 0, len(p.Notas))
|
||||||
|
for _, n := range p.Notas {
|
||||||
|
titulos = append(titulos, n.Titulo)
|
||||||
|
}
|
||||||
|
out = append(out, map[string]interface{}{
|
||||||
|
"clave": p.Clave,
|
||||||
|
"nombre": p.Nombre,
|
||||||
|
"descripcion": p.Descripcion,
|
||||||
|
"notas": len(p.Notas),
|
||||||
|
"titulos": titulos,
|
||||||
|
"resumen": strings.Join(titulos, " · "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El catálogo es contenido que se le muestra al cliente y que además define el
|
||||||
|
// conocimiento inicial de su agente. Un título vacío o una nota sin marcadores
|
||||||
|
// para completar significa que alguien va a publicar datos de ejemplo como si
|
||||||
|
// fueran reales.
|
||||||
|
func TestCatalogoDeRubrosEstaCompleto(t *testing.T) {
|
||||||
|
if len(PlantillasRubro) < 3 {
|
||||||
|
t.Fatalf("el catálogo tiene %d plantillas, muy pocas para ofrecer", len(PlantillasRubro))
|
||||||
|
}
|
||||||
|
|
||||||
|
vistas := map[string]bool{}
|
||||||
|
for _, p := range PlantillasRubro {
|
||||||
|
if p.Clave == "" || p.Nombre == "" || p.Descripcion == "" {
|
||||||
|
t.Errorf("plantilla incompleta: %+v", p.Clave)
|
||||||
|
}
|
||||||
|
if vistas[p.Clave] {
|
||||||
|
t.Errorf("clave duplicada: %q — la segunda nunca se podría elegir", p.Clave)
|
||||||
|
}
|
||||||
|
vistas[p.Clave] = true
|
||||||
|
|
||||||
|
if len(p.Notas) == 0 {
|
||||||
|
t.Errorf("la plantilla %q no trae ninguna nota: elegirla sería igual que no elegir nada", p.Clave)
|
||||||
|
}
|
||||||
|
for _, n := range p.Notas {
|
||||||
|
if strings.TrimSpace(n.Titulo) == "" {
|
||||||
|
t.Errorf("nota sin título en %q", p.Clave)
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(n.Contenido)) < 40 {
|
||||||
|
t.Errorf("nota %q de %q demasiado corta: no generaría ni un fragmento", n.Titulo, p.Clave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Los valores de ejemplo van entre corchetes justamente para que se note que
|
||||||
|
// hay que reemplazarlos. Una nota sin corchetes se lee como dato real y termina
|
||||||
|
// en boca del asistente.
|
||||||
|
func TestLasNotasPidenSerCompletadas(t *testing.T) {
|
||||||
|
for _, p := range PlantillasRubro {
|
||||||
|
for _, n := range p.Notas {
|
||||||
|
if !strings.Contains(n.Contenido, "[") {
|
||||||
|
t.Errorf("la nota %q de %q no tiene ningún marcador [entre corchetes]: se puede confundir con información real", n.Titulo, p.Clave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El rubro de salud no puede ofrecerse sin decir explícitamente que el
|
||||||
|
// asistente no da consejo médico: es el único donde una respuesta inventada
|
||||||
|
// hace daño de verdad.
|
||||||
|
func TestSaludAclaraQueNoDaConsejoMedico(t *testing.T) {
|
||||||
|
p, ok := PlantillaRubroPorClave("salud")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("falta la plantilla de salud")
|
||||||
|
}
|
||||||
|
junto := strings.ToLower(p.Notas[0].Titulo + " " + p.Notas[0].Contenido)
|
||||||
|
if !strings.Contains(junto, "no responde consultas médicas") && !strings.Contains(junto, "no damos consejo") {
|
||||||
|
t.Error("la primera nota de salud tiene que ser el límite: el asistente no da consejo médico")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlantillaRubroPorClave(t *testing.T) {
|
||||||
|
if _, ok := PlantillaRubroPorClave("no-existe"); ok {
|
||||||
|
t.Error("una clave inventada no debería resolver")
|
||||||
|
}
|
||||||
|
p, ok := PlantillaRubroPorClave("restaurante")
|
||||||
|
if !ok || p.Nombre == "" {
|
||||||
|
t.Error("restaurante debería existir en el catálogo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumenNoFiltraElContenido(t *testing.T) {
|
||||||
|
// El resumen alimenta la pantalla de elección: manda títulos, no el texto
|
||||||
|
// completo de cada nota.
|
||||||
|
for _, r := range ResumenPlantillasRubro() {
|
||||||
|
if _, hay := r["contenido"]; hay {
|
||||||
|
t.Error("el resumen no debería incluir el contenido de las notas")
|
||||||
|
}
|
||||||
|
if r["clave"] == "" || r["nombre"] == "" {
|
||||||
|
t.Errorf("resumen incompleto: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// El bucle de herramientas tiene que garantizar que la última vuelta sea sin
|
||||||
|
// herramientas. Si no, un modelo que encadena búsquedas se come todas las
|
||||||
|
// rondas pidiendo tools y nunca redacta: el visitante recibe el mensaje
|
||||||
|
// genérico aunque el agente ya tenga toda la información junta.
|
||||||
|
//
|
||||||
|
// Es exactamente lo que pasaba en producción: tres eventos seguidos de
|
||||||
|
// "se agotaron las rondas" en un agente que respondía bien en el chat de
|
||||||
|
// prueba, donde la conversación era más corta y no encadenaba búsquedas.
|
||||||
|
func TestLaUltimaRondaVaSinHerramientas(t *testing.T) {
|
||||||
|
const maxRondas = 4
|
||||||
|
|
||||||
|
hubo := 0
|
||||||
|
ultimaConHerramientas := false
|
||||||
|
|
||||||
|
for round := 0; round < maxRondas; round++ {
|
||||||
|
conHerramientas := round != maxRondas-1
|
||||||
|
hubo++
|
||||||
|
if round == maxRondas-1 {
|
||||||
|
ultimaConHerramientas = conHerramientas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hubo != maxRondas {
|
||||||
|
t.Errorf("se corrieron %d rondas, esperaba %d", hubo, maxRondas)
|
||||||
|
}
|
||||||
|
if ultimaConHerramientas {
|
||||||
|
t.Error("la última ronda ofreció herramientas: el modelo puede volver a pedir una y quedarse sin responder")
|
||||||
|
}
|
||||||
|
if maxRondas < 3 {
|
||||||
|
t.Errorf("con %d rondas no alcanza para buscar y después redactar", maxRondas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Con la última ronda sin herramientas, un modelo que pide búsquedas en todas
|
||||||
|
// las vueltas igual termina respondiendo.
|
||||||
|
func TestUnModeloQuePideBusquedasSiempreIgualResponde(t *testing.T) {
|
||||||
|
const maxRondas = 4
|
||||||
|
respondio := false
|
||||||
|
|
||||||
|
for round := 0; round < maxRondas; round++ {
|
||||||
|
hayHerramientas := round != maxRondas-1
|
||||||
|
// El modelo simulado pide herramienta siempre que pueda.
|
||||||
|
pideHerramienta := hayHerramientas
|
||||||
|
if !pideHerramienta {
|
||||||
|
respondio = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !respondio {
|
||||||
|
t.Error("el bucle terminó sin respuesta: es el fallback genérico que ve el visitante")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,11 @@ package services
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para
|
|
||||||
// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al
|
|
||||||
// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso,
|
|
||||||
// y mucho más simple que una columna de estado en el tenant.
|
|
||||||
//
|
|
||||||
// ponytail: en memoria y por proceso. Si algún día corren varias réplicas,
|
|
||||||
// cada una avisa una vez — mover el flag a la BD si eso molesta.
|
|
||||||
var avisosTope struct {
|
|
||||||
sync.Mutex
|
|
||||||
ultimo map[uint]string // tenantID → "2026-08" del último aviso
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su
|
// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su
|
||||||
// plan. El aviso NO corta el servicio: el agente sigue respondiendo.
|
// plan. El aviso NO corta el servicio: el agente sigue respondiendo.
|
||||||
func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) {
|
func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) {
|
||||||
@@ -65,14 +52,9 @@ func cicloActual() (time.Time, time.Time) {
|
|||||||
func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) {
|
func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) {
|
||||||
periodo := desde.Format("2006-01")
|
periodo := desde.Format("2006-01")
|
||||||
|
|
||||||
avisosTope.Lock()
|
// El marcado va en la base y de forma atómica: dos mensajes que crucen el
|
||||||
if avisosTope.ultimo == nil {
|
// tope al mismo tiempo (o dos réplicas del proceso) avisan una sola vez.
|
||||||
avisosTope.ultimo = map[uint]string{}
|
if !models.MarcarAvisoTope(tenantID, periodo) {
|
||||||
}
|
|
||||||
yaAvisado := avisosTope.ultimo[tenantID] == periodo
|
|
||||||
avisosTope.ultimo[tenantID] = periodo
|
|
||||||
avisosTope.Unlock()
|
|
||||||
if yaAvisado {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +71,39 @@ func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, des
|
|||||||
"📊 <b>Tope de consumo superado</b>\nCliente: <b>%s</b>\nPlan: %s\nConsumo del mes: <b>%s %.2f</b> (tope %s %.2f)\n\n<i>El servicio sigue activo — el excedente se cobra en el próximo ciclo.</i>",
|
"📊 <b>Tope de consumo superado</b>\nCliente: <b>%s</b>\nPlan: %s\nConsumo del mes: <b>%s %.2f</b> (tope %s %.2f)\n\n<i>El servicio sigue activo — el excedente se cobra en el próximo ciclo.</i>",
|
||||||
escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre),
|
escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre),
|
||||||
plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual))
|
plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual))
|
||||||
|
|
||||||
|
avisarTopeAlCliente(tenantID, plan, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// avisarTopeAlCliente le manda el aviso al dueño del espacio. Enterarse recién
|
||||||
|
// en la factura de que se pasó del tope es la clase de sorpresa que hace
|
||||||
|
// perder un cliente, y hasta ahora sólo se avisaba al staff.
|
||||||
|
func avisarTopeAlCliente(tenantID uint, plan *models.UmindPlan, total float64) {
|
||||||
|
tenant, err := models.GetUmindTenantByID(tenantID)
|
||||||
|
if err != nil || tenant.ClienteID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID)
|
||||||
|
if err != nil || len(usuarios) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
asunto := "Tu asistente superó el consumo incluido en el plan"
|
||||||
|
cuerpo := fmt.Sprintf(`<p>Hola,</p>
|
||||||
|
<p>Tu asistente <strong>%s</strong> superó el consumo incluido en el plan %s este mes.</p>
|
||||||
|
<p>Consumo hasta ahora: <strong>%s %.2f</strong> — incluido en el plan: %s %.2f.</p>
|
||||||
|
<p><strong>El asistente sigue funcionando con normalidad.</strong> El excedente se factura en el próximo ciclo. Podés ver el detalle en tu panel, en Consumo.</p>`,
|
||||||
|
tenant.Nombre, plan.Nombre, plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual)
|
||||||
|
|
||||||
|
for _, u := range usuarios {
|
||||||
|
if u.Email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destino := u.Email
|
||||||
|
go func() {
|
||||||
|
if err := soporteSendMail(destino, asunto, cuerpo); err != nil {
|
||||||
|
log.Printf("[UMIND_USO] No se pudo avisar del tope a %s: %v", destino, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// El período del aviso sale del mes calendario. Si se calculara mal, el aviso
|
||||||
|
// se repetiría todos los meses el día 1 o no se repetiría nunca más.
|
||||||
|
func TestElPeriodoDelCicloEsElMesCalendario(t *testing.T) {
|
||||||
|
desde, hasta := cicloActual()
|
||||||
|
|
||||||
|
if desde.Day() != 1 || desde.Hour() != 0 || desde.Minute() != 0 {
|
||||||
|
t.Errorf("el ciclo tiene que arrancar el día 1 a las 00:00, arrancó %v", desde)
|
||||||
|
}
|
||||||
|
if !hasta.After(desde) {
|
||||||
|
t.Errorf("el fin del ciclo (%v) tiene que ser posterior al inicio (%v)", hasta, desde)
|
||||||
|
}
|
||||||
|
if hasta.Sub(desde) < 28*24*time.Hour || hasta.Sub(desde) > 31*24*time.Hour {
|
||||||
|
t.Errorf("el ciclo dura %v, fuera del rango de un mes", hasta.Sub(desde))
|
||||||
|
}
|
||||||
|
|
||||||
|
// El período que se guarda para no repetir el aviso.
|
||||||
|
periodo := desde.Format("2006-01")
|
||||||
|
if len(periodo) != 7 || !strings.Contains(periodo, "-") {
|
||||||
|
t.Errorf("el período tiene que ser YYYY-MM, es %q", periodo)
|
||||||
|
}
|
||||||
|
// Tiene que entrar en la columna, que es size:7.
|
||||||
|
if len(periodo) > 7 {
|
||||||
|
t.Errorf("el período %q no entra en la columna de 7 caracteres", periodo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El aviso se manda una sola vez por mes. Antes el control estaba en memoria y
|
||||||
|
// cada despliegue lo borraba — con varios despliegues por día, el mismo cliente
|
||||||
|
// recibía el aviso una y otra vez.
|
||||||
|
func TestElControlDelAvisoNoViveEnMemoria(t *testing.T) {
|
||||||
|
fuente, err := leerFuente("umind_uso_service.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(fuente, "avisosTope") {
|
||||||
|
t.Error("quedó el flag en memoria: un reinicio vuelve a disparar el aviso")
|
||||||
|
}
|
||||||
|
if !strings.Contains(fuente, "MarcarAvisoTope") {
|
||||||
|
t.Error("el aviso tiene que marcarse en la base para sobrevivir a un reinicio")
|
||||||
|
}
|
||||||
|
// Y el cliente tiene que enterarse, no sólo el staff.
|
||||||
|
if !strings.Contains(fuente, "avisarTopeAlCliente") {
|
||||||
|
t.Error("falta el aviso al cliente: enterarse en la factura es la peor forma")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func leerFuente(nombre string) (string, error) {
|
||||||
|
b, err := os.ReadFile(nombre)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="uMind">
|
||||||
|
<title>uMind</title>
|
||||||
|
<rect width="96" height="96" rx="22" fill="#0D1220"/>
|
||||||
|
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#8eb02f" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M60,58 V64" fill="none" stroke="#8eb02f" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
<circle cx="60" cy="28" r="7" fill="#8eb02f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="uMind">
|
||||||
|
<title>uMind</title>
|
||||||
|
<rect width="96" height="96" rx="22" fill="#8eb02f"/>
|
||||||
|
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
<circle cx="60" cy="28" r="7" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,9 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||||
<title>uMind Studio</title>
|
<title>uMind Studio</title>
|
||||||
<script type="module" crossorigin src="/orchestrator/assets/index-CUSjvRBR.js"></script>
|
<script type="module" crossorigin src="/orchestrator/assets/index-NXjEScsg.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DUjBJRtl.css">
|
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CZn9dJyg.css">
|
||||||
</head>
|
</head>
|
||||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||||
que son los que cambian con el tema. Una utilidad acá le ganaba a la
|
que son los que cambian con el tema. Una utilidad acá le ganaba a la
|
||||||
|
|||||||
+1316
File diff suppressed because it is too large
Load Diff
@@ -64,10 +64,17 @@
|
|||||||
<tr class="hover:bg-gray-50 transition">
|
<tr class="hover:bg-gray-50 transition">
|
||||||
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
|
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
|
||||||
<td class="py-2 px-3">
|
<td class="py-2 px-3">
|
||||||
<template x-if="!item.modulo">
|
<!-- Una config de cliente no tiene módulo, así que
|
||||||
|
antes se mostraba como "Global" — que es
|
||||||
|
justo lo que no es. -->
|
||||||
|
<template x-if="item.tenant_id">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700"
|
||||||
|
x-text="'Cliente: ' + (item.tenant_nombre || ('espacio ' + item.tenant_id))"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!item.modulo && !item.tenant_id">
|
||||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
|
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="item.modulo">
|
<template x-if="item.modulo && !item.tenant_id">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
|
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
|
||||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||||
@@ -200,6 +207,26 @@
|
|||||||
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
|
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input x-model="form.es_agente_bot" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||||
|
<span class="text-sm text-gray-700">Es el cerebro del agente (bot de Telegram y chat del panel)</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-1 ml-6">
|
||||||
|
Solo una config puede serlo: al marcar esta, se desmarca la anterior.
|
||||||
|
</p>
|
||||||
|
<div x-show="form.es_agente_bot" x-cloak class="mt-2 ml-6">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Bot de Telegram (opcional)</label>
|
||||||
|
<select x-model="form.telegram_config_id"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
||||||
|
<option value="">Sin bot asignado</option>
|
||||||
|
<template x-for="t in telegramConfigs" :key="t.ID">
|
||||||
|
<option :value="t.ID" x-text="t.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label>
|
<label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label>
|
||||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||||
@@ -288,17 +315,26 @@ function aiConfigApp() {
|
|||||||
search: '',
|
search: '',
|
||||||
showModal: false, editItem: null, deleteId: null, testResult: null,
|
showModal: false, editItem: null, deleteId: null, testResult: null,
|
||||||
errorMsg: '', successMsg: '', formError: '',
|
errorMsg: '', successMsg: '', formError: '',
|
||||||
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] },
|
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' },
|
||||||
|
telegramConfigs: [],
|
||||||
|
|
||||||
moduleOptions: [
|
moduleOptions: [
|
||||||
{ value: 'landing', label: 'Landing Generator' },
|
{ value: 'landing', label: 'Landing Generator' },
|
||||||
{ value: 'query_runner', label: 'Query Runner SQL' },
|
{ value: 'query_runner', label: 'Query Runner SQL' },
|
||||||
{ value: 'ia', label: 'IA / vCard' },
|
{ value: 'ia', label: 'IA general (vCard, soporte, chat del panel)' },
|
||||||
|
{ value: 'plantillas', label: 'Plantillas de documento (importar con IA)' },
|
||||||
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' },
|
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' },
|
||||||
{ value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' },
|
{ value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' },
|
||||||
],
|
],
|
||||||
|
|
||||||
async init() { await this.load() },
|
async init() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/app/loadtelegram')
|
||||||
|
const d = await r.json()
|
||||||
|
this.telegramConfigs = d.registros || d.items || d || []
|
||||||
|
} catch { this.telegramConfigs = [] }
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
@@ -325,7 +361,7 @@ function aiConfigApp() {
|
|||||||
|
|
||||||
openAdd() {
|
openAdd() {
|
||||||
this.editItem = null
|
this.editItem = null
|
||||||
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [] }
|
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' }
|
||||||
this.formError = ''
|
this.formError = ''
|
||||||
this.showModal = true
|
this.showModal = true
|
||||||
},
|
},
|
||||||
@@ -335,7 +371,7 @@ function aiConfigApp() {
|
|||||||
const modulos = item.modulo
|
const modulos = item.modulo
|
||||||
? item.modulo.split(',').map(s => s.trim()).filter(s => s)
|
? item.modulo.split(',').map(s => s.trim()).filter(s => s)
|
||||||
: []
|
: []
|
||||||
this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos }
|
this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos, es_agente_bot: !!item.es_agente_bot, telegram_config_id: item.telegram_config_id || '' }
|
||||||
this.formError = ''
|
this.formError = ''
|
||||||
this.showModal = true
|
this.showModal = true
|
||||||
},
|
},
|
||||||
@@ -346,6 +382,7 @@ function aiConfigApp() {
|
|||||||
this.saving = true; this.formError = ''
|
this.saving = true; this.formError = ''
|
||||||
const payload = { ...this.form, modulo: this.form.modulos.join(',') }
|
const payload = { ...this.form, modulo: this.form.modulos.join(',') }
|
||||||
delete payload.modulos
|
delete payload.modulos
|
||||||
|
payload.telegram_config_id = payload.telegram_config_id ? parseInt(payload.telegram_config_id) : null
|
||||||
const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config'
|
const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config'
|
||||||
const method = this.editItem ? 'PUT' : 'POST'
|
const method = this.editItem ? 'PUT' : 'POST'
|
||||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
|
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
|
||||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||||
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas', 'vcard']" :key="s">
|
<!-- La lista viene del backend: cuando estaba escrita acá, la
|
||||||
|
pantalla ofrecía scopes que el backend rechazaba al guardar. -->
|
||||||
|
<template x-for="s in scopesDisponibles" :key="s">
|
||||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<input type="checkbox" :checked="form.scopes.includes(s)"
|
<input type="checkbox" :checked="form.scopes.includes(s)"
|
||||||
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||||
@@ -191,8 +193,15 @@ function apiKeysApp() {
|
|||||||
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
|
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
|
||||||
errorMsg: '', successMsg: '', formError: '',
|
errorMsg: '', successMsg: '', formError: '',
|
||||||
form: { nombre: '', ip_permitida: '', scopes: [], activa: true },
|
form: { nombre: '', ip_permitida: '', scopes: [], activa: true },
|
||||||
|
scopesDisponibles: [],
|
||||||
|
|
||||||
async init() { await this.load() },
|
async init() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/app/api-keys/scopes')
|
||||||
|
this.scopesDisponibles = await r.json()
|
||||||
|
} catch { this.scopesDisponibles = [] }
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true; this.errorMsg = ''
|
this.loading = true; this.errorMsg = ''
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
|
|
||||||
<!-- Modal Crear / Editar -->
|
<!-- Modal Crear / Editar -->
|
||||||
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 p-6 max-h-[90vh] overflow-y-auto" @click.stop>
|
<div class="bg-white rounded-lg shadow-xl w-full mx-4 p-6 max-h-[90vh] overflow-y-auto transition-all"
|
||||||
|
:class="mostrarPreview ? 'max-w-6xl' : 'max-w-4xl'" @click.stop>
|
||||||
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
|
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
|
||||||
|
|
||||||
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
|
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
|
||||||
@@ -90,6 +91,29 @@
|
|||||||
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
|
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
|
||||||
|
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
Subí el archivo (PDF, Word .docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
|
||||||
|
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mb-2">
|
||||||
|
No usa un agente: esto no conversa ni consulta una base de conocimiento, convierte el documento y listo.
|
||||||
|
El modelo sale de <a href="/app/ai-config" class="underline">Configuración de IA</a> — asignale el módulo
|
||||||
|
<em>Plantillas de documento</em> a la config que quieras usar acá, o dejalo sin asignar y usa la de <em>IA / vCard</em>.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="file" x-ref="archivo" accept=".pdf,.docx,.html,.htm,.txt,.md,image/*"
|
||||||
|
class="text-xs" />
|
||||||
|
<button type="button" @click="importar()" :disabled="importando"
|
||||||
|
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
|
||||||
|
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
|
||||||
|
</button>
|
||||||
|
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
|
||||||
|
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="save()">
|
<form @submit.prevent="save()">
|
||||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -111,13 +135,36 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="grid gap-3" :class="mostrarPreview ? 'lg:grid-cols-2' : ''">
|
||||||
<label class="text-xs font-medium text-gray-600 block mb-1">HTML de la plantilla (Go text/template) *</label>
|
<div>
|
||||||
<textarea x-model="form.contenido_html" rows="16"
|
<div class="flex items-center justify-between mb-1">
|
||||||
@input.debounce.600ms="validar()"
|
<label class="text-xs font-medium text-gray-600">HTML de la plantilla (Go text/template) *</label>
|
||||||
:class="templateError ? 'border-red-400' : ''"
|
<button type="button" @click="mostrarPreview = !mostrarPreview; if (mostrarPreview) previsualizar()"
|
||||||
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
|
class="text-xs px-2 py-1 border rounded">
|
||||||
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
|
<span x-text="mostrarPreview ? 'Ocultar vista previa' : '👁 Ver cómo queda'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea x-model="form.contenido_html" rows="16"
|
||||||
|
@input.debounce.600ms="validar()"
|
||||||
|
:class="templateError ? 'border-red-400' : ''"
|
||||||
|
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
|
||||||
|
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="mostrarPreview" x-cloak>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="text-xs font-medium text-gray-600">Vista previa (con datos de ejemplo)</label>
|
||||||
|
<span x-show="previewCargando" class="text-xs text-gray-400">actualizando…</span>
|
||||||
|
</div>
|
||||||
|
<!-- sandbox sin allow-scripts ni allow-same-origin: el HTML lo
|
||||||
|
escribe un admin, pero no tiene por qué correr con los
|
||||||
|
permisos del panel. -->
|
||||||
|
<iframe x-ref="preview" sandbox="" class="w-full h-[26rem] border rounded bg-white"></iframe>
|
||||||
|
<p x-show="previewError" x-text="previewError" class="text-red-500 text-xs mt-1"></p>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-1">
|
||||||
|
Cliente, ítems y totales son inventados; al generar el documento real se reemplazan por los del cliente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 mt-5">
|
<div class="flex justify-end gap-2 mt-5">
|
||||||
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||||
@@ -154,6 +201,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
|
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
|
||||||
addModal: false, editModal: false, deleteModal: false,
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
selectedId: null, templateError: '',
|
selectedId: null, templateError: '',
|
||||||
|
importando: false, avisoImport: '', errorImport: false,
|
||||||
|
mostrarPreview: false, previewCargando: false, previewError: '',
|
||||||
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
|
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
|
||||||
toast: { show:false, msg:'', type:'ok' },
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
@@ -176,17 +225,68 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa };
|
this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa };
|
||||||
this.selectedId = d.ID;
|
this.selectedId = d.ID;
|
||||||
this.editModal = true;
|
this.editModal = true;
|
||||||
|
this.mostrarPreview = true;
|
||||||
|
this.$nextTick(() => this.previsualizar());
|
||||||
},
|
},
|
||||||
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||||
|
|
||||||
|
async importar() {
|
||||||
|
const f = this.$refs.archivo?.files?.[0];
|
||||||
|
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
|
||||||
|
this.importando = true; this.avisoImport = ''; this.errorImport = false;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('archivo', f);
|
||||||
|
fd.append('tipo', this.form.tipo);
|
||||||
|
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
|
||||||
|
this.form.contenido_html = data.contenido_html || '';
|
||||||
|
if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, '');
|
||||||
|
this.errorImport = !!data.aviso;
|
||||||
|
this.avisoImport = data.aviso || 'Listo, revisá cómo quedó';
|
||||||
|
this.mostrarPreview = true;
|
||||||
|
await this.previsualizar();
|
||||||
|
} catch(e) {
|
||||||
|
this.errorImport = true;
|
||||||
|
// Sin data.error el fallo no vino de la app sino del proxy
|
||||||
|
// (timeout, por ejemplo); mostrar el status ayuda a distinguirlo.
|
||||||
|
this.avisoImport = e.response?.data?.error
|
||||||
|
|| (e.response ? `El servidor respondió ${e.response.status}` : e.message);
|
||||||
|
}
|
||||||
|
this.importando = false;
|
||||||
|
},
|
||||||
|
|
||||||
async validar() {
|
async validar() {
|
||||||
this.templateError = '';
|
this.templateError = '';
|
||||||
|
if (this.mostrarPreview) await this.previsualizar();
|
||||||
|
},
|
||||||
|
|
||||||
|
async previsualizar() {
|
||||||
|
if (!this.form.contenido_html) { this.pintarPreview(''); return; }
|
||||||
|
this.previewCargando = true; this.previewError = '';
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post('/app/api/plantillas-documento/previsualizar', {
|
||||||
|
tipo: this.form.tipo,
|
||||||
|
contenido_html: this.form.contenido_html,
|
||||||
|
});
|
||||||
|
this.pintarPreview(data.html || '');
|
||||||
|
} catch (e) {
|
||||||
|
this.previewError = e.response?.data?.error || e.message;
|
||||||
|
}
|
||||||
|
this.previewCargando = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// srcdoc y no document.write: con el iframe en sandbox no hay acceso a
|
||||||
|
// su documento desde acá.
|
||||||
|
pintarPreview(html) {
|
||||||
|
if (this.$refs.preview) this.$refs.preview.srcdoc = html;
|
||||||
},
|
},
|
||||||
|
|
||||||
closeModals() {
|
closeModals() {
|
||||||
this.addModal = this.editModal = this.deleteModal = false;
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
this.selectedId = null;
|
this.selectedId = null;
|
||||||
this.templateError = '';
|
this.templateError = '';
|
||||||
|
this.avisoImport = ''; this.errorImport = false;
|
||||||
|
this.mostrarPreview = false; this.previewError = '';
|
||||||
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
|
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
class="ml-auto mr-3">
|
class="ml-auto mr-3">
|
||||||
<a x-show="tieneUmind" x-cloak href="/portal/studio" title="uMind Studio"
|
<a x-show="tieneUmind" x-cloak href="/portal/studio" title="uMind Studio"
|
||||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 px-2 sm:px-3 py-1.5 rounded-lg hover:bg-slate-100 transition-colors">
|
class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 px-2 sm:px-3 py-1.5 rounded-lg hover:bg-slate-100 transition-colors">
|
||||||
<span class="w-5 h-5 rounded-md flex items-center justify-center text-white text-[10px] font-bold shrink-0" style="background:#8eb02f">uM</span>
|
<svg viewBox="0 0 96 96" class="w-5 h-5 shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
|
||||||
<!-- En móvil queda solo el logo: el texto no entra al lado de la
|
<!-- En móvil queda solo el logo: el texto no entra al lado de la
|
||||||
campana y el nombre de usuario, pero el acceso no puede faltar. -->
|
campana y el nombre de usuario, pero el acceso no puede faltar. -->
|
||||||
<span class="hidden sm:inline">uMind Studio</span>
|
<span class="hidden sm:inline">uMind Studio</span>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
confía; es el punto de contacto más barato que existe. -->
|
confía; es el punto de contacto más barato que existe. -->
|
||||||
{{ if .tieneUmind }}
|
{{ if .tieneUmind }}
|
||||||
<a href="/portal/studio" class="flex items-center gap-3 bg-white border border-slate-200 hover:border-[#8eb02f] rounded-2xl p-4 mb-6 transition-colors group">
|
<a href="/portal/studio" class="flex items-center gap-3 bg-white border border-slate-200 hover:border-[#8eb02f] rounded-2xl p-4 mb-6 transition-colors group">
|
||||||
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span>
|
<svg viewBox="0 0 96 96" class="w-10 h-10 flex-shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-semibold text-slate-800 text-sm">uMind Studio</p>
|
<p class="font-semibold text-slate-800 text-sm">uMind Studio</p>
|
||||||
<p class="text-slate-500 text-xs mt-0.5">Configurá tus asistentes, revisá conversaciones y consumo.</p>
|
<p class="text-slate-500 text-xs mt-0.5">Configurá tus asistentes, revisá conversaciones y consumo.</p>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="rounded-2xl p-5 mb-6 border border-slate-200 bg-gradient-to-br from-[#8eb02f]/8 to-transparent">
|
<div class="rounded-2xl p-5 mb-6 border border-slate-200 bg-gradient-to-br from-[#8eb02f]/8 to-transparent">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span>
|
<svg viewBox="0 0 96 96" class="w-10 h-10 flex-shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
|
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
|
||||||
<p class="text-slate-600 text-sm mt-1">
|
<p class="text-slate-600 text-sm mt-1">
|
||||||
|
|||||||
@@ -108,6 +108,148 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p>
|
<p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mt-3 items-center">
|
||||||
|
<input x-model="emailPrueba" type="email" placeholder="tu@correo.com"
|
||||||
|
class="border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<button @click="probarEnvio()" :disabled="ocupado"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
||||||
|
Enviar correo de prueba
|
||||||
|
</button>
|
||||||
|
<span x-show="mensajeEnvio" x-text="mensajeEnvio" class="text-sm"
|
||||||
|
:class="errorEnvio ? 'text-red-600' : 'text-green-600'"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">
|
||||||
|
Sale por el mismo camino que el acuse automático que recibe el cliente cuando se le crea un ticket.
|
||||||
|
Si esta prueba falla, ese acuse tampoco está llegando.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── IMAP entrante ────────────────────────────────────────── -->
|
||||||
|
<div class="border-t border-slate-200 pt-5">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<p class="text-sm font-semibold text-slate-700">Buzón entrante (IMAP)</p>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<input type="checkbox" x-model="cfg.imap_activo" class="rounded"> Leer el buzón cada 2 minutos
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mb-3">
|
||||||
|
Alternativa al webhook: en vez de esperar que el proveedor nos avise, entramos al buzón y bajamos
|
||||||
|
los correos sin leer. Cada uno abre un ticket (o se agrega al hilo si es respuesta) y queda marcado como leído.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-3">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Servidor IMAP</label>
|
||||||
|
<input x-model="cfg.imap_host" type="text" placeholder="imap.example.com"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Puerto</label>
|
||||||
|
<input x-model="cfg.imap_port" type="number" placeholder="993"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-3 mt-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Usuario</label>
|
||||||
|
<input x-model="cfg.imap_username" type="text" placeholder="soporte@u-s.app"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Contraseña</label>
|
||||||
|
<input x-model="cfg.imap_password" type="password"
|
||||||
|
:placeholder="tieneImapPassword ? 'Guardada — escribí una nueva para cambiarla' : '••••••••'"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-3 mt-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
|
||||||
|
<select x-model="cfg.imap_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option value="ssl">SSL/TLS (puerto 993)</option>
|
||||||
|
<option value="starttls">STARTTLS (puerto 143)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Carpeta</label>
|
||||||
|
<input x-model="cfg.imap_carpeta" type="text" placeholder="INBOX"
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Antigüedad máxima</label>
|
||||||
|
<select x-model.number="cfg.imap_horas_atras" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option :value="6">Últimas 6 horas</option>
|
||||||
|
<option :value="12">Últimas 12 horas</option>
|
||||||
|
<option :value="24">Últimas 24 horas</option>
|
||||||
|
<option :value="72">Últimos 3 días</option>
|
||||||
|
<option :value="0">Todo lo que haya sin leer</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">
|
||||||
|
Los correos más viejos que esto no se tocan, aunque estén sin leer. Es lo que evita que la
|
||||||
|
primera corrida convierta años de buzón en tickets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mt-3">
|
||||||
|
<button @click="probarImap()" :disabled="ocupado"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
||||||
|
Probar conexión
|
||||||
|
</button>
|
||||||
|
<button @click="revisarAhora()" :disabled="ocupado"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
|
||||||
|
Revisar buzón ahora
|
||||||
|
</button>
|
||||||
|
<span x-show="mensajeImap" x-text="mensajeImap" class="text-sm self-center"
|
||||||
|
:class="errorImap ? 'text-red-600' : 'text-green-600'"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-2">Probá la conexión después de guardar: la contraseña se cifra al guardarse.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Filtro con IA ────────────────────────────────────────── -->
|
||||||
|
<div class="border-t border-slate-200 pt-5">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<p class="text-sm font-semibold text-slate-700">Filtrar con IA</p>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<input type="checkbox" x-model="cfg.clasificar_con_ia" class="rounded"> Detectar si el correo es de soporte
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mb-3">
|
||||||
|
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
|
||||||
|
(newsletters, notificaciones automáticas, facturas de proveedores, spam).
|
||||||
|
<strong>Solo se filtra a los desconocidos</strong>: si el remitente es un cliente o un usuario del
|
||||||
|
portal, siempre abre ticket. Ante la duda también abre: es peor ignorar a alguien que tener un
|
||||||
|
ticket de más, y si la IA no responde el ticket se abre igual.
|
||||||
|
Lo descartado no abre ticket, pero el correo sigue en el buzón y "Revisar buzón ahora" te dice qué
|
||||||
|
dejó afuera y por qué.
|
||||||
|
</p>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
|
||||||
|
<textarea x-model="cfg.contexto_negocio" rows="3"
|
||||||
|
placeholder="Ej: Somos una agencia de software. Nuestros clientes escriben por errores de sus sitios y facturación. Los correos de nuestros proveedores de hosting no son soporte."
|
||||||
|
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none"></textarea>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Borradores de respuesta ──────────────────────────────── -->
|
||||||
|
<div class="border-t border-slate-200 pt-5">
|
||||||
|
<p class="text-sm font-semibold text-slate-700 mb-1">Borradores de respuesta</p>
|
||||||
|
<p class="text-xs text-slate-400 mb-3">
|
||||||
|
En cada ticket aparece un botón <strong>✨ Borrador</strong> que propone una respuesta usando la base
|
||||||
|
de conocimiento del agente que elijas. <strong>Nunca la envía</strong>: cae en el cuadro de respuesta
|
||||||
|
para que la revises y la mandes vos. Sin agente elegido el borrador sale igual, pero solo con la
|
||||||
|
conversación del ticket.
|
||||||
|
</p>
|
||||||
|
<label class="block text-xs font-medium text-slate-600 mb-1">Agente de uMind</label>
|
||||||
|
<select x-model="cfg.agente_borrador_id" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option value="">Sin base de conocimiento</option>
|
||||||
|
<template x-for="a in agentes" :key="a.id">
|
||||||
|
<option :value="a.id" x-text="a.nombre"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
@@ -146,8 +288,27 @@ function soporteWebhook() {
|
|||||||
smtp_encryption: 'starttls',
|
smtp_encryption: 'starttls',
|
||||||
smtp_from_addr: '',
|
smtp_from_addr: '',
|
||||||
smtp_from_name: '',
|
smtp_from_name: '',
|
||||||
|
imap_activo: false,
|
||||||
|
imap_host: '',
|
||||||
|
imap_port: 993,
|
||||||
|
imap_username: '',
|
||||||
|
imap_password: '',
|
||||||
|
imap_encryption: 'ssl',
|
||||||
|
imap_carpeta: 'INBOX',
|
||||||
|
imap_horas_atras: 12,
|
||||||
|
clasificar_con_ia: false,
|
||||||
|
contexto_negocio: '',
|
||||||
|
agente_borrador_id: '',
|
||||||
},
|
},
|
||||||
admins: [],
|
admins: [],
|
||||||
|
agentes: [],
|
||||||
|
tieneImapPassword: false,
|
||||||
|
ocupado: false,
|
||||||
|
mensajeImap: '',
|
||||||
|
errorImap: false,
|
||||||
|
emailPrueba: '',
|
||||||
|
mensajeEnvio: '',
|
||||||
|
errorEnvio: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
try {
|
||||||
@@ -155,12 +316,21 @@ function soporteWebhook() {
|
|||||||
if (r.data && r.data.data) {
|
if (r.data && r.data.data) {
|
||||||
this.cfg = { ...this.cfg, ...r.data.data };
|
this.cfg = { ...this.cfg, ...r.data.data };
|
||||||
this.cfg.asignar_a = r.data.data.asignar_a || '';
|
this.cfg.asignar_a = r.data.data.asignar_a || '';
|
||||||
|
// gorm.Model serializa la clave primaria como "ID"; sin esto el
|
||||||
|
// guardado creaba una fila nueva cada vez y los cambios no se veían.
|
||||||
|
this.cfg.id = r.data.data.ID || r.data.data.id || 0;
|
||||||
|
this.cfg.imap_password = '';
|
||||||
|
this.tieneImapPassword = !!r.data.data.tiene_imap_password;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const r = await axios.get('/app/tickets/admins');
|
const r = await axios.get('/app/tickets/admins');
|
||||||
this.admins = r.data || [];
|
this.admins = r.data || [];
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
const r = await axios.get('/app/soporte/agentes');
|
||||||
|
this.agentes = r.data || [];
|
||||||
|
} catch {}
|
||||||
if (!this.cfg.api_key) {
|
if (!this.cfg.api_key) {
|
||||||
this.cfg.api_key = this.generarClave();
|
this.cfg.api_key = this.generarClave();
|
||||||
}
|
}
|
||||||
@@ -193,14 +363,58 @@ function soporteWebhook() {
|
|||||||
smtp_encryption: this.cfg.smtp_encryption || 'starttls',
|
smtp_encryption: this.cfg.smtp_encryption || 'starttls',
|
||||||
smtp_from_addr: this.cfg.smtp_from_addr || '',
|
smtp_from_addr: this.cfg.smtp_from_addr || '',
|
||||||
smtp_from_name: this.cfg.smtp_from_name || '',
|
smtp_from_name: this.cfg.smtp_from_name || '',
|
||||||
|
imap_activo: !!this.cfg.imap_activo,
|
||||||
|
imap_host: this.cfg.imap_host || '',
|
||||||
|
imap_port: parseInt(this.cfg.imap_port) || 993,
|
||||||
|
imap_username: this.cfg.imap_username || '',
|
||||||
|
imap_password: this.cfg.imap_password || '',
|
||||||
|
imap_encryption: this.cfg.imap_encryption || 'ssl',
|
||||||
|
imap_carpeta: this.cfg.imap_carpeta || 'INBOX',
|
||||||
|
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
|
||||||
|
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
|
||||||
|
contexto_negocio: this.cfg.contexto_negocio || '',
|
||||||
|
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await axios.post('/app/soporte/webhook', payload);
|
await axios.post('/app/soporte/webhook', payload);
|
||||||
|
if (payload.imap_password) { this.tieneImapPassword = true; this.cfg.imap_password = ''; }
|
||||||
|
await this.init();
|
||||||
alert('Configuración guardada');
|
alert('Configuración guardada');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error al guardar: ' + (e.response?.data?.error || e.message));
|
alert('Error al guardar: ' + (e.response?.data?.error || e.message));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async llamarImap(url, okPorDefecto) {
|
||||||
|
this.ocupado = true;
|
||||||
|
this.mensajeImap = '';
|
||||||
|
try {
|
||||||
|
const r = await axios.post(url);
|
||||||
|
this.errorImap = false;
|
||||||
|
this.mensajeImap = r.data?.message || okPorDefecto;
|
||||||
|
} catch (e) {
|
||||||
|
this.errorImap = true;
|
||||||
|
this.mensajeImap = e.response?.data?.error || e.message;
|
||||||
|
}
|
||||||
|
this.ocupado = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async probarEnvio() {
|
||||||
|
this.ocupado = true;
|
||||||
|
this.mensajeEnvio = '';
|
||||||
|
try {
|
||||||
|
const r = await axios.post('/app/soporte/webhook/probar-envio', { email: this.emailPrueba });
|
||||||
|
this.errorEnvio = false;
|
||||||
|
this.mensajeEnvio = r.data?.message || 'Enviado';
|
||||||
|
} catch (e) {
|
||||||
|
this.errorEnvio = true;
|
||||||
|
this.mensajeEnvio = e.response?.data?.error || e.message;
|
||||||
|
}
|
||||||
|
this.ocupado = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
probarImap() { return this.llamarImap('/app/soporte/webhook/probar-imap', 'Conexión correcta'); },
|
||||||
|
revisarAhora() { return this.llamarImap('/app/soporte/webhook/revisar-buzon', 'Buzón revisado'); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
|
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
|
||||||
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
|
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
|
||||||
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
|
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
|
||||||
|
<span x-show="t.categoria" class="badge badge-default text-xs" x-text="t.categoria"></span>
|
||||||
|
<!-- De quién es. Sin cliente resuelto es alguien de afuera: no es
|
||||||
|
un error, pero conviene que se vea distinto. -->
|
||||||
|
<span x-show="t.cliente" class="badge text-xs bg-indigo-100 text-indigo-700"
|
||||||
|
x-text="t.cliente ? '🏢 ' + t.cliente.nombre : ''"></span>
|
||||||
|
<span x-show="!t.cliente && t.origen==='email'" class="badge text-xs bg-slate-100 text-slate-500">👤 Externo</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
|
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
|
||||||
<p class="text-xs text-slate-500 mt-0.5">
|
<p class="text-xs text-slate-500 mt-0.5">
|
||||||
@@ -97,10 +103,16 @@
|
|||||||
|
|
||||||
<!-- Responder -->
|
<!-- Responder -->
|
||||||
<div class="mt-3 flex gap-2">
|
<div class="mt-3 flex gap-2">
|
||||||
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..."
|
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
|
||||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none"
|
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
|
||||||
@keydown.enter.prevent="responder(t)"
|
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
|
||||||
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'">
|
@keydown.enter.exact.prevent="responder(t)"
|
||||||
|
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
|
||||||
|
<button @click="redactar(t)" :disabled="t._redactando"
|
||||||
|
title="Propone una respuesta con la base de conocimiento. No la envía."
|
||||||
|
class="px-3 py-2 rounded-xl border border-slate-300 text-sm text-slate-700 disabled:opacity-50 flex-shrink-0">
|
||||||
|
<span x-text="t._redactando ? 'Redactando…' : '✨ Borrador'"></span>
|
||||||
|
</button>
|
||||||
<button @click="responder(t)"
|
<button @click="responder(t)"
|
||||||
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||||
style="background:#8eb02f"
|
style="background:#8eb02f"
|
||||||
@@ -190,6 +202,18 @@ function ticketsAdmin() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async redactar(t) {
|
||||||
|
t._redactando = true;
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`/app/tickets/${t.ID}/borrador`);
|
||||||
|
// Se escribe en el cuadro de respuesta, no se envía: lo revisa una persona.
|
||||||
|
t._reply = data.borrador || '';
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.error || e.message);
|
||||||
|
}
|
||||||
|
t._redactando = false;
|
||||||
|
},
|
||||||
|
|
||||||
async responder(t) {
|
async responder(t) {
|
||||||
const contenido = (t._reply || '').trim();
|
const contenido = (t._reply || '').trim();
|
||||||
if (!contenido) return;
|
if (!contenido) return;
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ func GetAiConfigs(c *fiber.Ctx) error {
|
|||||||
Modulo string `json:"modulo"`
|
Modulo string `json:"modulo"`
|
||||||
EsAgenteBot bool `json:"es_agente_bot"`
|
EsAgenteBot bool `json:"es_agente_bot"`
|
||||||
TelegramConfigID *uint `json:"telegram_config_id"`
|
TelegramConfigID *uint `json:"telegram_config_id"`
|
||||||
|
TenantID *uint `json:"tenant_id"`
|
||||||
|
TenantNombre string `json:"tenant_nombre"`
|
||||||
}
|
}
|
||||||
|
// Los nombres de los espacios, en una consulta: sin esto la pantalla
|
||||||
|
// mostraría "tenant 7" y nadie sabe de quién es el 7.
|
||||||
|
nombresTenant := models.NombresDeTenantsUmind()
|
||||||
|
|
||||||
safeItems := make([]safe, len(items))
|
safeItems := make([]safe, len(items))
|
||||||
for i, it := range items {
|
for i, it := range items {
|
||||||
// El hint sale de la clave en claro: sobre el ciphertext mostraría
|
// El hint sale de la clave en claro: sobre el ciphertext mostraría
|
||||||
@@ -72,6 +78,10 @@ func GetAiConfigs(c *fiber.Ctx) error {
|
|||||||
Modulo: it.Modulo,
|
Modulo: it.Modulo,
|
||||||
EsAgenteBot: it.EsAgenteBot,
|
EsAgenteBot: it.EsAgenteBot,
|
||||||
TelegramConfigID: it.TelegramConfigID,
|
TelegramConfigID: it.TelegramConfigID,
|
||||||
|
TenantID: it.TenantID,
|
||||||
|
}
|
||||||
|
if it.TenantID != nil {
|
||||||
|
safeItems[i].TenantNombre = nombresTenant[*it.TenantID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +136,9 @@ func CreateAiConfigHandler(c *fiber.Ctx) error {
|
|||||||
if err := models.CreateAiConfig(&item); err != nil {
|
if err := models.CreateAiConfig(&item); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
if item.EsAgenteBot {
|
||||||
|
models.QuitarAgenteBotSalvo(item.ID)
|
||||||
|
}
|
||||||
return c.JSON(fiber.Map{"ok": true, "id": item.ID})
|
return c.JSON(fiber.Map{"ok": true, "id": item.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,21 +166,35 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qué campos vinieron de verdad. Sin esto, un formulario que no manda
|
||||||
|
// es_agente_bot lo guardaba en false: editar cualquier config desde el
|
||||||
|
// panel dejaba al bot de Telegram y al chat del dashboard sin cerebro.
|
||||||
|
var presentes map[string]json.RawMessage
|
||||||
|
_ = json.Unmarshal(c.Body(), &presentes)
|
||||||
|
vino := func(campo string) bool { _, ok := presentes[campo]; return ok }
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"nombre": strings.TrimSpace(req.Nombre),
|
"nombre": strings.TrimSpace(req.Nombre),
|
||||||
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
|
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
|
||||||
"base_url": strings.TrimSpace(req.BaseURL),
|
"base_url": strings.TrimSpace(req.BaseURL),
|
||||||
"model_name": strings.TrimSpace(req.ModelName),
|
"model_name": strings.TrimSpace(req.ModelName),
|
||||||
"is_active": req.IsActive,
|
"is_active": req.IsActive,
|
||||||
"notes": req.Notes,
|
"notes": req.Notes,
|
||||||
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
|
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
|
||||||
"es_agente_bot": req.EsAgenteBot,
|
}
|
||||||
"telegram_config_id": req.TelegramConfigID,
|
if vino("es_agente_bot") {
|
||||||
|
updates["es_agente_bot"] = req.EsAgenteBot
|
||||||
|
}
|
||||||
|
if vino("telegram_config_id") {
|
||||||
|
updates["telegram_config_id"] = req.TelegramConfigID
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.ApiKey) != "" {
|
if strings.TrimSpace(req.ApiKey) != "" {
|
||||||
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
|
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.EsAgenteBot && vino("es_agente_bot") {
|
||||||
|
models.QuitarAgenteBotSalvo(uint(id))
|
||||||
|
}
|
||||||
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
|
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerarTextoIA corre una generación con la configuración de IA que el staff
|
||||||
|
// eligió en /app/ai-config para el módulo "ia" (el que la pantalla llama
|
||||||
|
// "IA / vCard").
|
||||||
|
//
|
||||||
|
// La clave del proveedor no sale de acá: la integración manda su prompt con su
|
||||||
|
// propia API key de /api/v2 y recibe el texto. Es la diferencia con devolverle
|
||||||
|
// la configuración —incluida la clave de OpenAI o Anthropic— a otra aplicación.
|
||||||
|
//
|
||||||
|
// POST /api/v2/vcard/ia {"prompt": "...", "sistema": "..."}
|
||||||
|
func GenerarTextoIA(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Sistema string `json:"sistema"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
if body.Prompt == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "prompt requerido"})
|
||||||
|
}
|
||||||
|
if body.Sistema == "" {
|
||||||
|
body.Sistema = "Respondé de forma clara y breve."
|
||||||
|
}
|
||||||
|
|
||||||
|
// El módulo va fijo: si lo eligiera quien llama, una llave con scope vcard
|
||||||
|
// podría usar la configuración del agente o la del Landing Generator.
|
||||||
|
texto, err := services.CompletarTextoIA("ia", body.Sistema, body.Prompt)
|
||||||
|
if err != nil {
|
||||||
|
// Al log también: la integración suele mostrar solo el código de estado,
|
||||||
|
// y sin esta línea el motivo no queda registrado en ningún lado.
|
||||||
|
log.Printf("[IA v2] falló la generación: %v", err)
|
||||||
|
|
||||||
|
// 503 cuando falta configurar algo de este lado y 502 cuando el que
|
||||||
|
// falló fue el proveedor: son problemas de dueños distintos.
|
||||||
|
estado := 502
|
||||||
|
if strings.Contains(err.Error(), "configuración de IA") {
|
||||||
|
estado = 503
|
||||||
|
}
|
||||||
|
return c.Status(estado).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"texto": texto})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
@@ -38,3 +39,30 @@ func getOSSFromQuery(c *fiber.Ctx) (services.OSSProvider, error) {
|
|||||||
}
|
}
|
||||||
return services.NewOSSProvider(lastActive)
|
return services.NewOSSProvider(lastActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claveObjetoSegura arma una clave de objeto a partir de texto que viene del
|
||||||
|
// cliente. Sin esto, un nombre con "/" o con caracteres de control produce una
|
||||||
|
// clave que OSS rechaza — y el que llama solo ve "error subiendo a OSS".
|
||||||
|
func claveObjetoSegura(nombre string) string {
|
||||||
|
nombre = strings.TrimSpace(nombre)
|
||||||
|
limpio := strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||||
|
return r
|
||||||
|
case r == '-', r == '_', r == '.':
|
||||||
|
return r
|
||||||
|
case r == ' ':
|
||||||
|
return '_'
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}, nombre)
|
||||||
|
limpio = strings.Trim(limpio, "._-")
|
||||||
|
if limpio == "" {
|
||||||
|
return "sin-nombre"
|
||||||
|
}
|
||||||
|
if len(limpio) > 80 {
|
||||||
|
limpio = limpio[:80]
|
||||||
|
}
|
||||||
|
return limpio
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// La clave de objeto se arma con texto que manda el cliente. Antes iba tal
|
||||||
|
// cual: un nombre con "/" o con acentos raros producía una clave que OSS
|
||||||
|
// rechaza, y del otro lado solo se veía "error subiendo archivo a OSS".
|
||||||
|
func TestClaveObjetoSegura(t *testing.T) {
|
||||||
|
casos := map[string]string{
|
||||||
|
"Juan Pérez": "Juan_Prez",
|
||||||
|
"Ana/María": "AnaMara",
|
||||||
|
"../../etc/pass": "etcpass",
|
||||||
|
" ": "sin-nombre",
|
||||||
|
"": "sin-nombre",
|
||||||
|
"ok-nombre_1.2": "ok-nombre_1.2",
|
||||||
|
"emoji 🚀 fin": "emoji__fin",
|
||||||
|
}
|
||||||
|
for in, want := range casos {
|
||||||
|
if got := claveObjetoSegura(in); got != want {
|
||||||
|
t.Errorf("claveObjetoSegura(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := claveObjetoSegura(string(make([]byte, 0)) + "a" + string(rune(0))); got != "a" {
|
||||||
|
t.Errorf("caracter de control no filtrado: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@ type umindTgChat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type umindTgFileRef struct {
|
type umindTgFileRef struct {
|
||||||
FileID string `json:"file_id"`
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"` // solo lo traen los documentos
|
||||||
}
|
}
|
||||||
|
|
||||||
type umindTgMessage struct {
|
type umindTgMessage struct {
|
||||||
Chat umindTgChat `json:"chat"`
|
Chat umindTgChat `json:"chat"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Voice *umindTgFileRef `json:"voice"`
|
Voice *umindTgFileRef `json:"voice"`
|
||||||
Audio *umindTgFileRef `json:"audio"`
|
Audio *umindTgFileRef `json:"audio"`
|
||||||
Photo []umindTgFileRef `json:"photo"`
|
Photo []umindTgFileRef `json:"photo"`
|
||||||
|
Document *umindTgFileRef `json:"document"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type umindTgUpdate struct {
|
type umindTgUpdate struct {
|
||||||
@@ -55,6 +58,8 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
|
|||||||
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
|
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
|
||||||
case len(msg.Photo) > 0:
|
case len(msg.Photo) > 0:
|
||||||
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
|
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
|
||||||
|
case msg.Document != nil && msg.Document.FileID != "":
|
||||||
|
err = services.ProcesarMediaTelegramUmindConNombre(canal, msg.Chat.ID, msg.Document.FileID, "document", msg.Document.FileName, msg.Caption)
|
||||||
default:
|
default:
|
||||||
texto := strings.TrimSpace(msg.Text)
|
texto := strings.TrimSpace(msg.Text)
|
||||||
if texto == "" {
|
if texto == "" {
|
||||||
@@ -81,8 +86,13 @@ type umindWaMessage struct {
|
|||||||
Text struct {
|
Text struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
} `json:"text"`
|
} `json:"text"`
|
||||||
Image umindWaMediaRef `json:"image"`
|
Image umindWaMediaRef `json:"image"`
|
||||||
Audio umindWaMediaRef `json:"audio"`
|
Audio umindWaMediaRef `json:"audio"`
|
||||||
|
Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
} `json:"document"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type umindWaValue struct {
|
type umindWaValue struct {
|
||||||
@@ -172,6 +182,11 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
|
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
|
||||||
|
case "document":
|
||||||
|
if msg.Document.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = services.ProcesarMediaWhatsAppUmindConNombre(canal, msg.From, msg.Document.ID, "document", msg.Document.Filename, msg.Document.Caption)
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ const umindWidgetJS = `(function () {
|
|||||||
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
|
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
|
||||||
'.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}',
|
'.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}',
|
||||||
'.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}',
|
'.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}',
|
||||||
|
// "Escribiendo": los tres puntos que cualquiera reconoce de WhatsApp. Sin
|
||||||
|
// esto, entre que la persona manda y el agente contesta —varios segundos
|
||||||
|
// si tiene que buscar en el conocimiento— el widget se queda mudo y parece
|
||||||
|
// colgado.
|
||||||
|
'.umind-escribiendo{display:flex;gap:4px;align-items:center;width:fit-content;margin-bottom:10px;padding:11px 13px;background:#fff;border:1px solid #ececec;border-radius:13px;border-bottom-left-radius:4px;}',
|
||||||
|
'.umind-escribiendo i{width:6px;height:6px;border-radius:50%;background:#b6b6b6;display:block;animation:umind-punto 1.3s infinite;}',
|
||||||
|
'.umind-escribiendo i:nth-child(2){animation-delay:.18s;}',
|
||||||
|
'.umind-escribiendo i:nth-child(3){animation-delay:.36s;}',
|
||||||
|
'@keyframes umind-punto{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-3px);}}',
|
||||||
|
'@media (prefers-reduced-motion:reduce){.umind-escribiendo i{animation:none;opacity:.6;}}',
|
||||||
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
|
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
|
||||||
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
|
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
|
||||||
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
|
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
|
||||||
@@ -225,13 +235,38 @@ const umindWidgetJS = `(function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El indicador es un elemento suelto que se agrega y se saca: así no hay que
|
||||||
|
// llevar estado ni limpiar nada si la petición falla a mitad de camino.
|
||||||
|
var escribiendoEl = null;
|
||||||
|
|
||||||
|
function mostrarEscribiendo() {
|
||||||
|
if (escribiendoEl) return;
|
||||||
|
escribiendoEl = document.createElement('div');
|
||||||
|
escribiendoEl.className = 'umind-escribiendo';
|
||||||
|
escribiendoEl.setAttribute('aria-label', 'El asistente está escribiendo');
|
||||||
|
for (var i = 0; i < 3; i++) escribiendoEl.appendChild(document.createElement('i'));
|
||||||
|
msgsEl.appendChild(escribiendoEl);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ocultarEscribiendo() {
|
||||||
|
if (!escribiendoEl) return;
|
||||||
|
if (escribiendoEl.parentNode) escribiendoEl.parentNode.removeChild(escribiendoEl);
|
||||||
|
escribiendoEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
function agregarMensaje(texto, quien) {
|
function agregarMensaje(texto, quien) {
|
||||||
var burbuja = document.createElement('div');
|
var burbuja = document.createElement('div');
|
||||||
pintarConEnlaces(burbuja, texto == null ? '' : texto);
|
pintarConEnlaces(burbuja, texto == null ? '' : texto);
|
||||||
var esUser = quien === 'user';
|
var esUser = quien === 'user';
|
||||||
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
|
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
|
||||||
if (esUser) burbuja.style.background = color;
|
if (esUser) burbuja.style.background = color;
|
||||||
msgsEl.appendChild(burbuja);
|
// Antes del indicador, para que los puntitos queden siempre al final.
|
||||||
|
if (escribiendoEl && escribiendoEl.parentNode === msgsEl) {
|
||||||
|
msgsEl.insertBefore(burbuja, escribiendoEl);
|
||||||
|
} else {
|
||||||
|
msgsEl.appendChild(burbuja);
|
||||||
|
}
|
||||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +309,13 @@ const umindWidgetJS = `(function () {
|
|||||||
if (!texto) return;
|
if (!texto) return;
|
||||||
inputEl.value = '';
|
inputEl.value = '';
|
||||||
agregarMensaje(texto, 'user');
|
agregarMensaje(texto, 'user');
|
||||||
|
mostrarEscribiendo();
|
||||||
|
// Se bloquea el envío mientras responde: mandar tres mensajes seguidos
|
||||||
|
// desordena el hilo y multiplica el consumo sin que nadie lo pida.
|
||||||
|
inputEl.disabled = true;
|
||||||
|
sendEl.disabled = true;
|
||||||
|
sendEl.style.opacity = '.5';
|
||||||
|
|
||||||
fetch(apiBase + '/mensaje', {
|
fetch(apiBase + '/mensaje', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -290,6 +332,16 @@ const umindWidgetJS = `(function () {
|
|||||||
? 'El asistente no está disponible en este momento.'
|
? 'El asistente no está disponible en este momento.'
|
||||||
: 'Error de conexión, intenta de nuevo.',
|
: 'Error de conexión, intenta de nuevo.',
|
||||||
'bot');
|
'bot');
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
// Pase lo que pase: si esto quedara solo en el .then de éxito, un
|
||||||
|
// error dejaría los puntitos animándose para siempre y el campo de
|
||||||
|
// texto bloqueado.
|
||||||
|
ocultarEscribiendo();
|
||||||
|
inputEl.disabled = false;
|
||||||
|
sendEl.disabled = false;
|
||||||
|
sendEl.style.opacity = '';
|
||||||
|
inputEl.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image/png"
|
"image/png"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
"github.com/chai2010/webp"
|
||||||
@@ -53,29 +52,24 @@ func CreateQr(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
random := helpers.RandomString(4)
|
random := helpers.RandomString(4)
|
||||||
fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", claveObjetoSegura(data.FirstName), random)
|
||||||
tmpPath := fmt.Sprintf("/tmp/%s.webp", data.FirstName)
|
|
||||||
|
|
||||||
// 4. Guardar archivo temporal
|
// 4. Subir a OSS directo desde memoria: el archivo temporal solo agregaba
|
||||||
if err := os.WriteFile(tmpPath, webpBuf.Bytes(), 0644); err != nil {
|
// una forma más de fallar (y el nombre venía del cliente, sin limpiar).
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"error": "No se pudo guardar el QR temporalmente",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpPath)
|
|
||||||
|
|
||||||
// 5. Subir a OSS
|
|
||||||
ossProvider, err := getOSSFromBody(c)
|
ossProvider, err := getOSSFromBody(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[QR] No se pudo resolver la config de OSS: %v", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error de conexión con OSS",
|
"error": "Error de conexión con OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil {
|
if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
|
||||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
|
||||||
|
// El detalle viaja al que llama: es una API entre servidores y sin esto
|
||||||
|
// el otro lado solo ve "error subiendo a OSS" y no puede hacer nada.
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error subiendo archivo a OSS",
|
"error": "Error subiendo archivo a OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +166,7 @@ func CreateQrTmp(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func CreateUrlQr(c *fiber.Ctx) error {
|
func CreateUrlQr(c *fiber.Ctx) error {
|
||||||
var data struct {
|
var data struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Nombre string `json:"unico"`
|
Nombre string `json:"unico"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(c.Body(), &data); err != nil {
|
if err := json.Unmarshal(c.Body(), &data); err != nil {
|
||||||
@@ -187,14 +181,14 @@ func CreateUrlQr(c *fiber.Ctx) error {
|
|||||||
"error": "No se pudo generar el QR",
|
"error": "No se pudo generar el QR",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
img, err := png.Decode(bytes.NewReader(qrBytes))
|
img, err := png.Decode(bytes.NewReader(qrBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "No se pudo procesar la imagen",
|
"error": "No se pudo procesar la imagen",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codificar a WebP
|
// Codificar a WebP
|
||||||
var webpBuffer bytes.Buffer
|
var webpBuffer bytes.Buffer
|
||||||
if err := webp.Encode(&webpBuffer, img, &webp.Options{Lossless: true}); err != nil {
|
if err := webp.Encode(&webpBuffer, img, &webp.Options{Lossless: true}); err != nil {
|
||||||
@@ -202,7 +196,7 @@ func CreateUrlQr(c *fiber.Ctx) error {
|
|||||||
"error": "No se pudo convertir a WebP",
|
"error": "No se pudo convertir a WebP",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Convertir a WebP
|
// 3. Convertir a WebP
|
||||||
var webpBuf bytes.Buffer
|
var webpBuf bytes.Buffer
|
||||||
if err := webp.Encode(&webpBuf, img, nil); err != nil {
|
if err := webp.Encode(&webpBuf, img, nil); err != nil {
|
||||||
@@ -211,40 +205,29 @@ func CreateUrlQr(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileName := fmt.Sprintf("qrs/url/qr-%s.webp", claveObjetoSegura(data.Nombre))
|
||||||
|
|
||||||
fileName := fmt.Sprintf("qrs/url/qr-%s.webp", strings.ReplaceAll(data.Nombre, " ", "_") )
|
// 4. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
|
||||||
tmpPath := fmt.Sprintf("/tmp/%s.webp", data.Nombre)
|
|
||||||
|
|
||||||
// 4. Guardar archivo temporal
|
|
||||||
if err := os.WriteFile(tmpPath, webpBuf.Bytes(), 0644); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"error": "No se pudo guardar el QR temporalmente",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpPath)
|
|
||||||
|
|
||||||
// 5. Subir a OSS
|
|
||||||
ossProvider, err := getOSSFromBody(c)
|
ossProvider, err := getOSSFromBody(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[QR URL] No se pudo resolver la config de OSS: %v", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error de conexión con OSS",
|
"error": "Error de conexión con OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil {
|
if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
|
||||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error subiendo archivo a OSS",
|
"error": "Error subiendo archivo a OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. URL del archivo
|
// 6. URL del archivo
|
||||||
url := ossProvider.PublicURL(fileName)
|
url := ossProvider.PublicURL(fileName)
|
||||||
|
|
||||||
|
|
||||||
// 8. Retornar la URL
|
// 8. Retornar la URL
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"url": url,
|
"url": url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
@@ -34,29 +33,21 @@ func CreateVcf(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
random := helpers.RandomString(4)
|
random := helpers.RandomString(4)
|
||||||
fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", claveObjetoSegura(data.FirstName), random)
|
||||||
tmpPath := fmt.Sprintf("/tmp/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
|
||||||
|
|
||||||
// 2. Guardar archivo temporal
|
// 2. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
|
||||||
if err := os.WriteFile(tmpPath, vcfContent, 0644); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
||||||
"error": "No se pudo guardar el VCF temporalmente",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpPath)
|
|
||||||
|
|
||||||
// 3. Subir a OSS
|
|
||||||
ossProvider, err := getOSSFromBody(c)
|
ossProvider, err := getOSSFromBody(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[VCF] No se pudo resolver la config de OSS: %v", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error de conexión con OSS",
|
"error": "Error de conexión con OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil {
|
if err := ossProvider.UploadFromReader(fileName, "text/vcard; charset=utf-8", bytes.NewReader(vcfContent)); err != nil {
|
||||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Error subiendo archivo a OSS",
|
"error": "Error subiendo archivo a OSS", "detalle": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,6 +18,20 @@ var scopesValidos = map[string]bool{
|
|||||||
"query_runner": true,
|
"query_runner": true,
|
||||||
"usuarios": true,
|
"usuarios": true,
|
||||||
"pasarelas": true,
|
"pasarelas": true,
|
||||||
|
"vcard": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopesDisponibles devuelve la lista para que la vista la muestre en vez de
|
||||||
|
// tener su propia copia: la copia de la vista ya ofreció "vcard" cuando el
|
||||||
|
// backend todavía lo rechazaba.
|
||||||
|
// GET /app/api-keys/scopes
|
||||||
|
func ScopesDisponibles(c *fiber.Ctx) error {
|
||||||
|
lista := make([]string, 0, len(scopesValidos))
|
||||||
|
for s := range scopesValidos {
|
||||||
|
lista = append(lista, s)
|
||||||
|
}
|
||||||
|
sort.Strings(lista)
|
||||||
|
return c.JSON(lista)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeysIndex renderiza el panel de administración de API keys.
|
// ApiKeysIndex renderiza el panel de administración de API keys.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// La pantalla ofrecía "vcard" mientras el backend lo rechazaba al guardar, y el
|
||||||
|
// único síntoma era "scope inválido: vcard" al crear la llave. Los scopes que
|
||||||
|
// las rutas de /api/v2 exigen con RequireScope tienen que poder asignarse.
|
||||||
|
func TestScopesDeLasRutasSonAsignables(t *testing.T) {
|
||||||
|
// Sacados de los RequireScope() de rest/routes.
|
||||||
|
usadosEnRutas := []string{"oss", "query_runner", "usuarios", "pasarelas", "vcard"}
|
||||||
|
for _, s := range usadosEnRutas {
|
||||||
|
if !scopesValidos[s] {
|
||||||
|
t.Errorf("las rutas exigen el scope %q pero no se puede asignar a una API key", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -441,6 +441,7 @@ func AdminApiSpec(c *fiber.Ctx) error {
|
|||||||
{"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"},
|
{"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"},
|
||||||
{"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"},
|
{"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"},
|
||||||
{"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"},
|
{"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"},
|
||||||
|
{"method": "POST", "path": "/api/v2/vcard/ia", "desc": "Generar texto con la IA configurada en /app/ai-config (módulo 'ia') → {\"texto\": \"...\"}"},
|
||||||
{"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"},
|
{"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"},
|
||||||
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
|
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
|
||||||
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
|
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
|
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
|
||||||
@@ -103,6 +106,69 @@ func DeletePlantillaDocumento(c *fiber.Ctx) error {
|
|||||||
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportarPlantillaDocumento recibe un documento (docx, html, txt o imagen), lo
|
||||||
|
// lee y le pide a la IA que lo devuelva como plantilla HTML con las variables
|
||||||
|
// del generador. NO guarda nada: el HTML vuelve al editor para revisarlo.
|
||||||
|
// POST /app/api/plantillas-documento/importar (multipart: archivo, tipo)
|
||||||
|
func ImportarPlantillaDocumento(c *fiber.Ctx) error {
|
||||||
|
archivo, err := c.FormFile("archivo")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Subí un archivo en el campo 'archivo'"})
|
||||||
|
}
|
||||||
|
if archivo.Size > 10<<20 {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "El archivo supera los 10 MB"})
|
||||||
|
}
|
||||||
|
f, err := archivo.Open()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
datos, err := io.ReadAll(io.LimitReader(f, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
|
||||||
|
}
|
||||||
|
|
||||||
|
texto, err := services.ExtraerTextoDePlantilla(archivo.Filename, datos)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
tipo := c.FormValue("tipo", "cotizacion")
|
||||||
|
html, err := services.ConvertirEnPlantilla(tipo, texto)
|
||||||
|
if err != nil {
|
||||||
|
// Al log también: el mensaje del proveedor es lo único que dice por qué
|
||||||
|
// falló, y desde el navegador se ve recortado.
|
||||||
|
log.Printf("[PLANTILLAS] importar (%s, %s): %v", tipo, archivo.Filename, err)
|
||||||
|
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
// Si la IA devolvió algo que no compila, es mejor decirlo acá que al guardar.
|
||||||
|
if _, err := template.New("validate").Parse(html); err != nil {
|
||||||
|
return c.Status(200).JSON(fiber.Map{
|
||||||
|
"contenido_html": html,
|
||||||
|
"aviso": "La IA devolvió HTML con sintaxis de plantilla inválida (" + err.Error() + "). Revisalo antes de guardar.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"contenido_html": html})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrevisualizarPlantillaDocumento renderiza la plantilla con datos de ejemplo
|
||||||
|
// para ver cómo queda antes de guardarla.
|
||||||
|
// POST /app/api/plantillas-documento/previsualizar {tipo, contenido_html}
|
||||||
|
func PrevisualizarPlantillaDocumento(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tipo string `json:"tipo"`
|
||||||
|
ContenidoHTML string `json:"contenido_html"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
html, err := services.RenderizarPlantillaEjemplo(req.Tipo, req.ContenidoHTML)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"html": html})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tarifas ────────────────────────────────────────────────────────────────
|
// ─── Tarifas ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func GetTarifas(c *fiber.Ctx) error {
|
func GetTarifas(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -28,7 +29,6 @@ type soporteEmailIn struct {
|
|||||||
MessageID string `json:"message_id" form:"message_id"`
|
MessageID string `json:"message_id" form:"message_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
|
||||||
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
|
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
|
||||||
|
|
||||||
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
|
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
|
||||||
@@ -121,111 +121,19 @@ func SoporteWebhook(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range emails {
|
for _, e := range emails {
|
||||||
if e.From == "" || e.Subject == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicación: el proveedor puede reintentar la entrega del mismo correo.
|
|
||||||
if models.EmailMessageIDYaProcesado(e.MessageID) {
|
|
||||||
log.Printf("[SoporteWebhook] Correo duplicado ignorado (message_id=%s)", e.MessageID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fromEmail := extractEmail(e.From)
|
|
||||||
fromName := e.FromName
|
|
||||||
if fromName == "" {
|
|
||||||
fromName = extractName(e.From)
|
|
||||||
}
|
|
||||||
if fromName == "" {
|
|
||||||
fromName = fromEmail
|
|
||||||
}
|
|
||||||
|
|
||||||
contenido := e.Text
|
contenido := e.Text
|
||||||
if contenido == "" {
|
if contenido == "" {
|
||||||
contenido = e.Html
|
contenido = e.Html
|
||||||
}
|
}
|
||||||
contenido = strings.TrimSpace(contenido)
|
services.IngestarCorreoSoporte(cfg, services.CorreoSoporte{
|
||||||
if len(contenido) > 5000 {
|
From: e.From, FromName: e.FromName, Subject: e.Subject,
|
||||||
contenido = contenido[:5000]
|
Texto: contenido, MessageID: e.MessageID,
|
||||||
}
|
})
|
||||||
|
|
||||||
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
|
|
||||||
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
|
|
||||||
// agrega como mensaje en vez de abrir un ticket nuevo. Si no hay token
|
|
||||||
// pero el remitente tiene un ticket abierto reciente, también se enhebra.
|
|
||||||
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
|
|
||||||
msg := &models.TicketMensaje{
|
|
||||||
TicketID: hilo.ID,
|
|
||||||
Contenido: contenido,
|
|
||||||
EsAdmin: false,
|
|
||||||
AutorNombre: fromName,
|
|
||||||
MessageID: e.MessageID,
|
|
||||||
}
|
|
||||||
if err := models.CreateTicketMensaje(msg); err != nil {
|
|
||||||
log.Printf("[SoporteWebhook] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
|
|
||||||
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
|
|
||||||
}
|
|
||||||
log.Printf("[SoporteWebhook] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ticket := &models.ProyectoTicket{
|
|
||||||
AutorNombre: fromName,
|
|
||||||
EmailFrom: fromEmail,
|
|
||||||
Titulo: e.Subject,
|
|
||||||
Descripcion: contenido,
|
|
||||||
Estado: "abierto",
|
|
||||||
Origen: "email",
|
|
||||||
MessageID: e.MessageID,
|
|
||||||
}
|
|
||||||
if cfg.AsignarA != nil {
|
|
||||||
ticket.AsignadoA = cfg.AsignarA
|
|
||||||
}
|
|
||||||
if err := models.CreateProyectoTicket(ticket); err != nil {
|
|
||||||
log.Printf("[SoporteWebhook] Error creando ticket: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("[SoporteWebhook] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
|
||||||
|
|
||||||
// Notificar admin
|
|
||||||
services.SendSoporteNotifAdmin(ticket)
|
|
||||||
|
|
||||||
// Auto-responder (el asunto incluye [Ticket #N] para poder enhebrar la respuesta)
|
|
||||||
if cfg.ResponderAuto {
|
|
||||||
services.SendSoporteAutoRespuesta(ticket)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(200).JSON(fiber.Map{"ok": true})
|
return c.Status(200).JSON(fiber.Map{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
|
||||||
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
|
|
||||||
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
|
|
||||||
// adivinando el número), y si no hay token, por el último ticket abierto de ese
|
|
||||||
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
|
|
||||||
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
|
|
||||||
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
|
|
||||||
id, _ := strconv.ParseUint(m[1], 10, 32)
|
|
||||||
if id > 0 {
|
|
||||||
t, err := models.GetTicketByID(uint(id))
|
|
||||||
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(subject))
|
|
||||||
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
|
|
||||||
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractMessageIDFromHeaders(headers string) string {
|
func extractMessageIDFromHeaders(headers string) string {
|
||||||
if headers == "" {
|
if headers == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -291,26 +199,38 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{"data": nil})
|
return c.JSON(fiber.Map{"data": nil})
|
||||||
}
|
}
|
||||||
|
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
|
||||||
return c.JSON(fiber.Map{"data": cfg})
|
return c.JSON(fiber.Map{"data": cfg})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||||
type body struct {
|
type body struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Nombre string `json:"nombre"`
|
Nombre string `json:"nombre"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
ApiKey string `json:"api_key"`
|
ApiKey string `json:"api_key"`
|
||||||
EmailDestino string `json:"email_destino"`
|
EmailDestino string `json:"email_destino"`
|
||||||
ResponderAuto bool `json:"responder_auto"`
|
ResponderAuto bool `json:"responder_auto"`
|
||||||
MensajeAuto string `json:"mensaje_auto"`
|
MensajeAuto string `json:"mensaje_auto"`
|
||||||
AsignarA *uint `json:"asignar_a"`
|
AsignarA *uint `json:"asignar_a"`
|
||||||
SmtpHost string `json:"smtp_host"`
|
SmtpHost string `json:"smtp_host"`
|
||||||
SmtpPort int `json:"smtp_port"`
|
SmtpPort int `json:"smtp_port"`
|
||||||
SmtpUsername string `json:"smtp_username"`
|
SmtpUsername string `json:"smtp_username"`
|
||||||
SmtpPassword string `json:"smtp_password"`
|
SmtpPassword string `json:"smtp_password"`
|
||||||
SmtpEncryption string `json:"smtp_encryption"`
|
SmtpEncryption string `json:"smtp_encryption"`
|
||||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||||
SmtpFromName string `json:"smtp_from_name"`
|
SmtpFromName string `json:"smtp_from_name"`
|
||||||
|
ImapActivo bool `json:"imap_activo"`
|
||||||
|
ImapHost string `json:"imap_host"`
|
||||||
|
ImapPort int `json:"imap_port"`
|
||||||
|
ImapUsername string `json:"imap_username"`
|
||||||
|
ImapPassword string `json:"imap_password"`
|
||||||
|
ImapEncryption string `json:"imap_encryption"`
|
||||||
|
ImapCarpeta string `json:"imap_carpeta"`
|
||||||
|
ImapHorasAtras int `json:"imap_horas_atras"`
|
||||||
|
AgenteBorradorID *uint `json:"agente_borrador_id"`
|
||||||
|
ClasificarConIA bool `json:"clasificar_con_ia"`
|
||||||
|
ContextoNegocio string `json:"contexto_negocio"`
|
||||||
}
|
}
|
||||||
var b body
|
var b body
|
||||||
if err := c.BodyParser(&b); err != nil {
|
if err := c.BodyParser(&b); err != nil {
|
||||||
@@ -324,22 +244,60 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
|||||||
if enc == "" {
|
if enc == "" {
|
||||||
enc = "starttls"
|
enc = "starttls"
|
||||||
}
|
}
|
||||||
|
imapPort := b.ImapPort
|
||||||
|
if imapPort == 0 {
|
||||||
|
imapPort = 993
|
||||||
|
}
|
||||||
|
imapEnc := b.ImapEncryption
|
||||||
|
if imapEnc == "" {
|
||||||
|
imapEnc = "ssl"
|
||||||
|
}
|
||||||
|
carpeta := b.ImapCarpeta
|
||||||
|
if carpeta == "" {
|
||||||
|
carpeta = "INBOX"
|
||||||
|
}
|
||||||
|
// La contraseña IMAP solo viaja cuando el admin la escribe de nuevo: si el
|
||||||
|
// formulario la manda vacía, se conserva la que ya estaba guardada.
|
||||||
|
passEnc := ""
|
||||||
|
if b.ImapPassword != "" {
|
||||||
|
enc, err := services.CifrarSecretoUmind(b.ImapPassword)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
passEnc = enc
|
||||||
|
} else if b.ID > 0 {
|
||||||
|
if actual, err := models.GetSoporteWebhookActivo(); err == nil && actual != nil && actual.ID == b.ID {
|
||||||
|
passEnc = actual.ImapPasswordEnc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &models.SoporteWebhookConfig{
|
cfg := &models.SoporteWebhookConfig{
|
||||||
Nombre: b.Nombre,
|
Nombre: b.Nombre,
|
||||||
Provider: b.Provider,
|
Provider: b.Provider,
|
||||||
ApiKey: b.ApiKey,
|
ApiKey: b.ApiKey,
|
||||||
EmailDestino: b.EmailDestino,
|
EmailDestino: b.EmailDestino,
|
||||||
ResponderAuto: b.ResponderAuto,
|
ResponderAuto: b.ResponderAuto,
|
||||||
MensajeAuto: b.MensajeAuto,
|
MensajeAuto: b.MensajeAuto,
|
||||||
AsignarA: b.AsignarA,
|
AsignarA: b.AsignarA,
|
||||||
SmtpHost: b.SmtpHost,
|
SmtpHost: b.SmtpHost,
|
||||||
SmtpPort: port,
|
SmtpPort: port,
|
||||||
SmtpUsername: b.SmtpUsername,
|
SmtpUsername: b.SmtpUsername,
|
||||||
SmtpPassword: b.SmtpPassword,
|
SmtpPassword: b.SmtpPassword,
|
||||||
SmtpEncryption: enc,
|
SmtpEncryption: enc,
|
||||||
SmtpFromAddr: b.SmtpFromAddr,
|
SmtpFromAddr: b.SmtpFromAddr,
|
||||||
SmtpFromName: b.SmtpFromName,
|
SmtpFromName: b.SmtpFromName,
|
||||||
Activo: true,
|
ImapActivo: b.ImapActivo,
|
||||||
|
ImapHost: b.ImapHost,
|
||||||
|
ImapPort: imapPort,
|
||||||
|
ImapUsername: b.ImapUsername,
|
||||||
|
ImapPasswordEnc: passEnc,
|
||||||
|
ImapEncryption: imapEnc,
|
||||||
|
ImapCarpeta: carpeta,
|
||||||
|
ImapHorasAtras: b.ImapHorasAtras,
|
||||||
|
ClasificarConIA: b.ClasificarConIA,
|
||||||
|
ContextoNegocio: b.ContextoNegocio,
|
||||||
|
AgenteBorradorID: b.AgenteBorradorID,
|
||||||
|
Activo: true,
|
||||||
}
|
}
|
||||||
cfg.ID = b.ID
|
cfg.ID = b.ID
|
||||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||||
@@ -350,21 +308,94 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func extractEmail(s string) string {
|
// ProbarImapSoporte valida las credenciales del buzón contra el servidor.
|
||||||
s = strings.TrimSpace(s)
|
// POST /app/api/soporte-webhook/probar-imap
|
||||||
if idx := strings.LastIndex(s, "<"); idx >= 0 {
|
func ProbarImapSoporte(c *fiber.Ctx) error {
|
||||||
s = s[idx+1:]
|
cfg, err := models.GetSoporteWebhookActivo()
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
|
||||||
}
|
}
|
||||||
if idx := strings.LastIndex(s, ">"); idx >= 0 {
|
detalle, err := services.ProbarConexionImap(cfg)
|
||||||
s = s[:idx]
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(s)
|
return c.JSON(fiber.Map{"ok": true, "message": detalle})
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractName(s string) string {
|
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
|
||||||
s = strings.TrimSpace(s)
|
// POST /app/api/soporte-webhook/revisar-buzon
|
||||||
if idx := strings.Index(s, "<"); idx >= 0 {
|
func RevisarBuzonAhora(c *fiber.Ctx) error {
|
||||||
return strings.TrimSpace(s[:idx])
|
encontrados, procesados, err := services.RevisarBuzonSoporteConDetalle()
|
||||||
|
if err != nil {
|
||||||
|
if encontrados > 0 {
|
||||||
|
// Se hizo trabajo igual: decir las dos cosas, no solo el fallo.
|
||||||
|
return c.Status(200).JSON(fiber.Map{"ok": false, "message": fmt.Sprintf(
|
||||||
|
"%d correo(s) encontrados, %d convertido(s) en ticket — %s", encontrados, procesados, err.Error())})
|
||||||
|
}
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return ""
|
if encontrados == 0 {
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)
|
||||||
|
// Si el filtro descartó algo hay que decirlo acá: un correo de un cliente
|
||||||
|
// que se comió la IA es exactamente lo que nadie va a ir a buscar al log.
|
||||||
|
if descartes := services.UltimosCorreosDescartados(); len(descartes) > 0 {
|
||||||
|
msg += fmt.Sprintf(" El filtro descartó %d: %s", len(descartes), strings.Join(descartes, " | "))
|
||||||
|
}
|
||||||
|
if e := services.UltimoErrorAcuse(); e != "" {
|
||||||
|
msg += " ⚠️ " + e
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
|
||||||
|
// No envía nada: el borrador vuelve al cuadro de respuesta para que lo revise
|
||||||
|
// una persona.
|
||||||
|
// POST /app/tickets/:ticketID/borrador
|
||||||
|
func BorradorRespuestaTicket(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("ticketID"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||||
|
}
|
||||||
|
ticket, err := models.GetTicketByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
|
||||||
|
}
|
||||||
|
borrador, err := services.RedactarBorradorTicket(ticket)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"borrador": borrador})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentesParaBorrador lista los agentes de uMind para elegir de cuál sale la
|
||||||
|
// documentación de los borradores.
|
||||||
|
// GET /app/soporte/agentes
|
||||||
|
func GetAgentesParaBorrador(c *fiber.Ctx) error {
|
||||||
|
items, err := models.GetTodosLosUmindAgentes()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
salida := make([]fiber.Map, 0, len(items))
|
||||||
|
for _, a := range items {
|
||||||
|
salida = append(salida, fiber.Map{"id": a.ID, "nombre": a.Nombre})
|
||||||
|
}
|
||||||
|
return c.JSON(salida)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que el acuse
|
||||||
|
// automático de los tickets.
|
||||||
|
// POST /app/soporte/webhook/probar-envio
|
||||||
|
func ProbarEnvioSoporte(c *fiber.Ctx) error {
|
||||||
|
var b struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&b); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
if err := services.ProbarEnvioSoporte(b.Email); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "message": "Correo de prueba enviado a " + b.Email})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -197,6 +198,9 @@ type umindAgenteReq struct {
|
|||||||
MensajeBienvenida string `json:"mensaje_bienvenida"`
|
MensajeBienvenida string `json:"mensaje_bienvenida"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Activo bool `json:"activo"`
|
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 {
|
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
||||||
@@ -232,6 +236,14 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
|||||||
if err := models.CreateUmindAgente(agente); err != nil {
|
if err := models.CreateUmindAgente(agente); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
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})
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +309,10 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
|||||||
// segundos/minutos según cuántas páginas tenga el sitio.
|
// segundos/minutos según cuántas páginas tenga el sitio.
|
||||||
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
AgenteID uint `json:"agente_id"`
|
AgenteID uint `json:"agente_id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
MaxPaginas int `json:"max_paginas"`
|
MaxPaginas int `json:"max_paginas"`
|
||||||
|
AutoActualizar bool `json:"auto_actualizar"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
@@ -318,10 +331,12 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
doc := &models.UmindDocumento{
|
doc := &models.UmindDocumento{
|
||||||
AgenteID: req.AgenteID,
|
AgenteID: req.AgenteID,
|
||||||
Tipo: "url",
|
Tipo: "url",
|
||||||
Origen: strings.TrimSpace(req.URL),
|
Origen: strings.TrimSpace(req.URL),
|
||||||
Estado: "procesando",
|
Estado: "procesando",
|
||||||
|
MaxPaginas: req.MaxPaginas,
|
||||||
|
AutoActualizar: req.AutoActualizar,
|
||||||
}
|
}
|
||||||
if err := models.CreateUmindDocumento(doc); err != nil {
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -356,7 +371,7 @@ func GetUmindSesionesHandler(c *fiber.Ctx) error {
|
|||||||
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
items, err := models.GetUmindSesiones(uint(agenteID), 50)
|
items, err := models.GetUmindSesionesPanel(uint(agenteID), 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -547,6 +562,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
|||||||
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
|
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
|
||||||
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
|
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
|
||||||
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
|
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
|
||||||
|
"usar_archivos_docs": canal.UsarArchivosDocs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"items": out})
|
return c.JSON(fiber.Map{"items": out})
|
||||||
@@ -559,6 +575,7 @@ type umindCanalReq struct {
|
|||||||
Activo bool `json:"activo"`
|
Activo bool `json:"activo"`
|
||||||
UsarWhisperAudio bool `json:"usar_whisper_audio"`
|
UsarWhisperAudio bool `json:"usar_whisper_audio"`
|
||||||
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
|
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
|
||||||
|
UsarArchivosDocs bool `json:"usar_archivos_docs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
||||||
@@ -594,6 +611,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
|||||||
canal := &models.UmindCanal{
|
canal := &models.UmindCanal{
|
||||||
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
||||||
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
||||||
|
UsarArchivosDocs: req.UsarArchivosDocs,
|
||||||
}
|
}
|
||||||
if err := models.CreateUmindCanal(canal); err != nil {
|
if err := models.CreateUmindCanal(canal); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -620,7 +638,7 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
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}
|
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 {
|
if len(req.Credenciales) > 0 {
|
||||||
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -856,3 +874,221 @@ func slugSimple(s string) string {
|
|||||||
}
|
}
|
||||||
return out
|
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.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── IA propia del cliente ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Un cliente puede traer su propia cuenta de OpenAI, Anthropic o Gemini en vez
|
||||||
|
// de consumir la nuestra. La config queda atada a su espacio (TenantID) y solo
|
||||||
|
// él la ve.
|
||||||
|
//
|
||||||
|
// Regla que atraviesa todo este archivo: las configs globales (TenantID nil)
|
||||||
|
// son del staff y ningún cliente las toca. Puede usarlas —aparecen en su
|
||||||
|
// selector— pero no editarlas ni borrarlas. Sin esa separación, un cliente
|
||||||
|
// podría cambiarle la clave de IA a todos los demás.
|
||||||
|
|
||||||
|
// aiConfigPropiaReq es lo que manda la pantalla. La clave viaja solo cuando se
|
||||||
|
// carga o se cambia: en blanco significa "dejá la que ya está".
|
||||||
|
type aiConfigPropiaReq struct {
|
||||||
|
TenantID uint `json:"tenant_id"`
|
||||||
|
Nombre string `json:"nombre"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ApiKey string `json:"api_key"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// proveedoresPermitidos acota lo que se puede cargar. Sin esta lista, un
|
||||||
|
// provider mal escrito termina en un error del proveedor que no se parece en
|
||||||
|
// nada a "escribiste mal el nombre".
|
||||||
|
var proveedoresPermitidos = map[string]bool{
|
||||||
|
"openai": true, "anthropic": true, "gemini": true,
|
||||||
|
"groq": true, "qwen": true, "deepseek": true, "ollama": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r aiConfigPropiaReq) validar() error {
|
||||||
|
if strings.TrimSpace(r.Nombre) == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "poné un nombre para reconocerla, ej: \"Mi cuenta de OpenAI\"")
|
||||||
|
}
|
||||||
|
p := strings.ToLower(strings.TrimSpace(r.Provider))
|
||||||
|
if !proveedoresPermitidos[p] {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "proveedor no soportado: elegí OpenAI, Anthropic, Gemini, Groq, Qwen, DeepSeek u Ollama")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.ModelName) == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "falta el modelo, ej: gpt-4o-mini o claude-sonnet-4")
|
||||||
|
}
|
||||||
|
// Ollama es el único que corre sin clave, contra un servidor propio.
|
||||||
|
if p != "ollama" && strings.TrimSpace(r.ApiKey) == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "falta la clave de API de tu cuenta")
|
||||||
|
}
|
||||||
|
if p == "ollama" && strings.TrimSpace(r.BaseURL) == "" {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Ollama necesita la URL de tu servidor, ej: https://ollama.tudominio.com/v1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// accesoAiConfigPropia valida que el que llama pueda tocar esa config.
|
||||||
|
// Devuelve la config ya cargada.
|
||||||
|
func accesoAiConfigPropia(c *fiber.Ctx, id uint) (*models.AiConfig, error) {
|
||||||
|
var cfg models.AiConfig
|
||||||
|
if err := models.GetAiConfigByID(id, &cfg); err != nil {
|
||||||
|
return nil, errSinAcceso(c)
|
||||||
|
}
|
||||||
|
// Una config sin tenant es del staff. Ni siquiera se le confirma al cliente
|
||||||
|
// que existe.
|
||||||
|
if cfg.TenantID == nil {
|
||||||
|
return nil, errSinAcceso(c)
|
||||||
|
}
|
||||||
|
if !middlewares.UmindPuedeVerTenant(c, *cfg.TenantID) {
|
||||||
|
return nil, errSinAcceso(c)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListarAiConfigsPropiasHandler devuelve solo las configs del cliente, con el
|
||||||
|
// detalle para administrarlas. Las globales quedan afuera: se usan pero no se
|
||||||
|
// gestionan desde acá.
|
||||||
|
// GET /umind/ai-propia?tenant_id=N
|
||||||
|
func ListarAiConfigsPropiasHandler(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
||||||
|
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := models.GetAiConfigsPorTenant(uint(tenantID))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]fiber.Map, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
// La clave nunca vuelve, ni al dueño: solo su terminación, que alcanza
|
||||||
|
// para reconocer cuál cargó.
|
||||||
|
clave := it.ClaveEnClaro()
|
||||||
|
pista := "••••"
|
||||||
|
if len(clave) > 4 {
|
||||||
|
pista = "••••" + clave[len(clave)-4:]
|
||||||
|
}
|
||||||
|
out[i] = fiber.Map{
|
||||||
|
"ID": it.ID, "nombre": it.Nombre, "provider": it.Provider,
|
||||||
|
"model_name": it.ModelName, "base_url": it.BaseURL,
|
||||||
|
"api_key_pista": pista, "is_active": it.IsActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrearAiConfigPropiaHandler carga la cuenta de IA del cliente.
|
||||||
|
// POST /umind/ai-propia
|
||||||
|
func CrearAiConfigPropiaHandler(c *fiber.Ctx) error {
|
||||||
|
var req aiConfigPropiaReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
if err := accesoTenant(c, req.TenantID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := req.validar(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := req.TenantID
|
||||||
|
cfg := &models.AiConfig{
|
||||||
|
Nombre: strings.TrimSpace(req.Nombre),
|
||||||
|
Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
|
||||||
|
ApiKey: models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)),
|
||||||
|
BaseURL: strings.TrimSpace(req.BaseURL),
|
||||||
|
ModelName: strings.TrimSpace(req.ModelName),
|
||||||
|
IsActive: true,
|
||||||
|
// El tenant sale del alcance validado, nunca de lo que mande el body
|
||||||
|
// suelto: si no, bastaría con cambiar el número para colgarle una
|
||||||
|
// config a otro cliente.
|
||||||
|
TenantID: &tenantID,
|
||||||
|
// Sin módulo: es de este espacio y nada más. Ponerle uno la metería en
|
||||||
|
// el pozo de configs globales que usa el resto del sistema.
|
||||||
|
Modulo: "",
|
||||||
|
}
|
||||||
|
if err := models.CreateAiConfig(cfg); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": cfg.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActualizarAiConfigPropiaHandler edita una config del cliente.
|
||||||
|
// PUT /umind/ai-propia/:id
|
||||||
|
func ActualizarAiConfigPropiaHandler(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 := accesoAiConfigPropia(c, uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var req aiConfigPropiaReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||||
|
}
|
||||||
|
// En edición la clave puede venir vacía: es "no la cambies".
|
||||||
|
if strings.TrimSpace(req.ApiKey) == "" {
|
||||||
|
req.ApiKey = "sin-cambio"
|
||||||
|
}
|
||||||
|
if err := req.validar(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"nombre": strings.TrimSpace(req.Nombre),
|
||||||
|
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
|
||||||
|
"base_url": strings.TrimSpace(req.BaseURL),
|
||||||
|
"model_name": strings.TrimSpace(req.ModelName),
|
||||||
|
}
|
||||||
|
if req.ApiKey != "sin-cambio" {
|
||||||
|
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
|
||||||
|
}
|
||||||
|
// tenant_id no se toca nunca: mover una config de espacio es regalarle a
|
||||||
|
// otro cliente una clave que no es suya.
|
||||||
|
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BorrarAiConfigPropiaHandler elimina la cuenta de IA del cliente.
|
||||||
|
// DELETE /umind/ai-propia/:id
|
||||||
|
func BorrarAiConfigPropiaHandler(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 := accesoAiConfigPropia(c, uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Los agentes que la estaban usando quedan apuntando a una config que ya
|
||||||
|
// no existe y caerían al fallback global sin avisar. Mejor frenar y que la
|
||||||
|
// persona decida.
|
||||||
|
usando, err := models.ContarAgentesConAiConfig(uint(id))
|
||||||
|
if err == nil && usando > 0 {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||||
|
"error": "hay agentes usando esta cuenta de IA: cambiales el modelo antes de borrarla",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := models.DeleteAiConfig(uint(id)); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbarAiConfigPropiaHandler manda una consulta real a la cuenta del cliente.
|
||||||
|
// Es la diferencia entre enterarse ahora o cuando un cliente real no recibe
|
||||||
|
// respuesta.
|
||||||
|
// POST /umind/ai-propia/:id/probar
|
||||||
|
func ProbarAiConfigPropiaHandler(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"})
|
||||||
|
}
|
||||||
|
cfg, err := accesoAiConfigPropia(c, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
respuesta, err := services.ProbarAiConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "respuesta": respuesta})
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// La validación es lo único que separa "cargué mi cuenta y anda" de un error
|
||||||
|
// del proveedor que nadie sabe leer.
|
||||||
|
func TestValidacionDeCuentaPropia(t *testing.T) {
|
||||||
|
casos := []struct {
|
||||||
|
nombre string
|
||||||
|
req aiConfigPropiaReq
|
||||||
|
valida bool
|
||||||
|
}{
|
||||||
|
{"completa", aiConfigPropiaReq{Nombre: "Mi OpenAI", Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, true},
|
||||||
|
{"sin nombre", aiConfigPropiaReq{Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, false},
|
||||||
|
{"sin modelo", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ApiKey: "sk-x"}, false},
|
||||||
|
{"sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ModelName: "gpt-4o-mini"}, false},
|
||||||
|
|
||||||
|
// Un proveedor inventado se manda igual y falla recién contra la API,
|
||||||
|
// con un mensaje que no dice "escribiste mal el nombre".
|
||||||
|
{"proveedor inventado", aiConfigPropiaReq{Nombre: "X", Provider: "chatgpt-5", ApiKey: "k", ModelName: "m"}, false},
|
||||||
|
|
||||||
|
// Ollama corre contra un servidor propio: no lleva clave, pero sin URL
|
||||||
|
// no hay a dónde ir.
|
||||||
|
{"ollama con url y sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1", BaseURL: "https://o.x/v1"}, true},
|
||||||
|
{"ollama sin url", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1"}, false},
|
||||||
|
|
||||||
|
{"mayúsculas en el proveedor", aiConfigPropiaReq{Nombre: "X", Provider: "OpenAI", ApiKey: "k", ModelName: "m"}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range casos {
|
||||||
|
err := c.req.validar()
|
||||||
|
if c.valida && err != nil {
|
||||||
|
t.Errorf("%s: debería pasar, dio: %v", c.nombre, err)
|
||||||
|
}
|
||||||
|
if !c.valida && err == nil {
|
||||||
|
t.Errorf("%s: debería rechazarse y pasó", c.nombre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las configs globales son del staff y las comparten todos los clientes. Que un
|
||||||
|
// cliente pudiera editarlas sería cambiarle el proveedor de IA a todos los
|
||||||
|
// demás; que pudiera borrarlas, dejarlos sin servicio.
|
||||||
|
func TestLasConfigsGlobalesNoSonDelCliente(t *testing.T) {
|
||||||
|
// accesoAiConfigPropia corta cuando TenantID es nil, antes de mirar
|
||||||
|
// permisos. Se comprueba la condición porque el handler necesita base.
|
||||||
|
var tenantNil *uint
|
||||||
|
if tenantNil != nil {
|
||||||
|
t.Fatal("una config global tiene TenantID nil")
|
||||||
|
}
|
||||||
|
esGlobal := tenantNil == nil
|
||||||
|
if !esGlobal {
|
||||||
|
t.Error("una config sin tenant tiene que tratarse como global e intocable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func AuthServicioPago(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !servicio.IPPermitida(c.IP()) {
|
if !servicio.IPPermitida(IPDelCliente(c)) {
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"})
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ func AdminApiAuth() fiber.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
|
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
if !key.IPValida(c.IP()) {
|
if ip := IPDelCliente(c); !key.IPValida(ip) {
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"})
|
// Decir qué IP se vio: sin eso, del otro lado no hay forma de saber
|
||||||
|
// qué cargar en la llave y se prueba a ciegas.
|
||||||
|
return c.Status(403).JSON(fiber.Map{
|
||||||
|
"error": "IP no autorizada para esta API key",
|
||||||
|
"ip_vista": ip,
|
||||||
|
"ip_permitida": key.IPPermitida,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
go models.RegistrarUsoApiKey(key.ID, c.IP())
|
go models.RegistrarUsoApiKey(key.ID, IPDelCliente(c))
|
||||||
c.Locals("api_key", key)
|
c.Locals("api_key", key)
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user