Compare commits
25
Commits
e681f41639
..
main
@@ -44,6 +44,7 @@ require (
|
||||
github.com/chai2010/webp v1.4.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
|
||||
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/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
@@ -55,6 +56,7 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
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
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
@@ -75,7 +77,6 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // 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
|
||||
@@ -137,7 +138,6 @@ require (
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/tklauser/numcpus v0.8.0 // 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/x448/float16 v0.8.4 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
|
||||
@@ -203,6 +203,7 @@ func main() {
|
||||
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
|
||||
// uMind (ver el comentario de la función).
|
||||
migrations.LiberarColumnasHuerfanasUmind()
|
||||
migrations.IndicesUnicosMessageID()
|
||||
migrations.MigrarUmindAgentes()
|
||||
migrations.SeedApiKeys()
|
||||
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>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function guardar() {
|
||||
}
|
||||
|
||||
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}`))
|
||||
if (tenantActivoId.value === String(t.ID)) router.push('/')
|
||||
await cargar()
|
||||
@@ -140,9 +140,15 @@ onMounted(() => {
|
||||
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
|
||||
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="h-14 px-4 flex items-center border-b border-borde">
|
||||
<router-link to="/" class="text-base font-semibold text-texto">
|
||||
uMind <span class="text-brand">Studio</span>
|
||||
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
|
||||
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +157,7 @@ onMounted(() => {
|
||||
class="btn-primary w-full"
|
||||
@click="nuevoTenant"
|
||||
>
|
||||
+ Nuevo tenant
|
||||
+ Nuevo espacio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +166,7 @@ onMounted(() => {
|
||||
<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-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>
|
||||
<div
|
||||
v-for="t in tenants"
|
||||
@@ -207,10 +213,10 @@ onMounted(() => {
|
||||
>
|
||||
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
|
||||
<h2 class="font-semibold text-texto mb-4">
|
||||
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
||||
{{ editing ? 'Editar espacio' : 'Nuevo espacio' }}
|
||||
</h2>
|
||||
<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>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
@@ -241,7 +247,7 @@ onMounted(() => {
|
||||
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<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>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
|
||||
|
||||
<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>
|
||||
<div class="mt-4"><slot /></div>
|
||||
<p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
|
||||
<div v-if="$slots.default" class="mt-4"><slot /></div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -86,3 +86,16 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tenantId: { type: String, required: true },
|
||||
@@ -37,6 +38,15 @@ async function cargarDocumentos() {
|
||||
documentos.value = r.items || []
|
||||
}
|
||||
|
||||
// Tres formas de cargar conocimiento, no una: la web, un archivo (lista de
|
||||
// precios, condiciones) y lo que el dueño escribe a mano, que es lo más
|
||||
// valioso y lo único que no está en ningún documento.
|
||||
const fuenteNueva = ref('texto')
|
||||
const autoActualizar = ref(true)
|
||||
const notaTitulo = ref('')
|
||||
const notaTexto = ref('')
|
||||
const archivoRef = ref(null)
|
||||
|
||||
async function agregarFuente() {
|
||||
if (!nuevaUrl.value.trim()) return
|
||||
ingestando.value = true
|
||||
@@ -46,6 +56,7 @@ async function agregarFuente() {
|
||||
agente_id: agenteIdNum.value,
|
||||
url: nuevaUrl.value.trim(),
|
||||
max_paginas: Number(maxPaginas.value) || 30,
|
||||
auto_actualizar: autoActualizar.value,
|
||||
})
|
||||
nuevaUrl.value = ''
|
||||
await cargarDocumentos()
|
||||
@@ -56,6 +67,113 @@ async function agregarFuente() {
|
||||
}
|
||||
}
|
||||
|
||||
async function agregarNota() {
|
||||
if (!notaTexto.value.trim()) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind('/umind/documentos/texto'), {
|
||||
agente_id: agenteIdNum.value,
|
||||
titulo: notaTitulo.value.trim(),
|
||||
contenido: notaTexto.value,
|
||||
})
|
||||
notaTitulo.value = ''
|
||||
notaTexto.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subirArchivo() {
|
||||
const f = archivoRef.value?.files?.[0]
|
||||
if (!f) return
|
||||
ingestando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('agente_id', String(agenteIdNum.value))
|
||||
fd.append('archivo', f)
|
||||
// Sin Content-Type a mano: el navegador tiene que poner el boundary.
|
||||
const res = await fetch(apiUmind('/umind/documentos/archivo'), { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'No se pudo subir')
|
||||
archivoRef.value.value = ''
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
ingestando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Editar una fuente escrita a mano. Las plantillas de rubro dejan las notas
|
||||
// con valores entre corchetes para reemplazar — sin esto, no había con qué.
|
||||
const editandoDoc = ref(null)
|
||||
const docForm = ref({ titulo: '', contenido: '' })
|
||||
const guardandoDoc = ref(false)
|
||||
|
||||
function editarDocumento(d) {
|
||||
editandoDoc.value = d
|
||||
docForm.value = { titulo: d.origen, contenido: d.contenido || '' }
|
||||
}
|
||||
|
||||
async function guardarDocumento() {
|
||||
if (!editandoDoc.value) return
|
||||
guardandoDoc.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.put(apiUmind(`/umind/documentos/${editandoDoc.value.ID}`), {
|
||||
titulo: docForm.value.titulo,
|
||||
contenido: docForm.value.contenido,
|
||||
})
|
||||
editandoDoc.value = null
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
guardandoDoc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocesar(d) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post(apiUmind(`/umind/documentos/${d.ID}/reprocesar`), {})
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function alternarAuto(d) {
|
||||
try {
|
||||
await api.put(apiUmind(`/umind/documentos/${d.ID}`), { auto_actualizar: !d.auto_actualizar })
|
||||
await cargarDocumentos()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// "hace 3 días" en vez de una fecha: lo que importa no es cuándo se procesó
|
||||
// sino qué tan viejo es lo que el agente está contestando.
|
||||
function antiguedad(fecha) {
|
||||
if (!fecha) return 'sin procesar'
|
||||
const dias = Math.floor((Date.now() - new Date(fecha)) / 86400000)
|
||||
if (dias <= 0) return 'hoy'
|
||||
if (dias === 1) return 'ayer'
|
||||
if (dias < 30) return `hace ${dias} días`
|
||||
const meses = Math.floor(dias / 30)
|
||||
return meses === 1 ? 'hace un mes' : `hace ${meses} meses`
|
||||
}
|
||||
|
||||
function estaVieja(d) {
|
||||
if (!d.procesado_at) return false
|
||||
return Date.now() - new Date(d.procesado_at) > 60 * 86400000
|
||||
}
|
||||
|
||||
async function eliminarDocumento(id) {
|
||||
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
|
||||
await api.del(apiUmind(`/umind/documentos/${id}`))
|
||||
@@ -160,7 +278,7 @@ async function guardarTool() {
|
||||
}
|
||||
|
||||
async function eliminarTool(t) {
|
||||
if (!confirm(`¿Eliminar la tool "${t.nombre}"?`)) return
|
||||
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
|
||||
await api.del(apiUmind(`/umind/tools/${t.ID}`))
|
||||
await cargarTools()
|
||||
}
|
||||
@@ -198,7 +316,7 @@ async function copiarWidget() {
|
||||
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_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +344,7 @@ async function guardarCanal() {
|
||||
await api.post(apiUmind('/umind/canales'), {
|
||||
agente_id: agenteIdNum.value, 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
|
||||
await cargarCanales()
|
||||
@@ -234,29 +353,24 @@ async function guardarCanal() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCanal(c) {
|
||||
// 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,
|
||||
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,
|
||||
})
|
||||
await cargarCanales()
|
||||
}
|
||||
|
||||
async function toggleCanalWhisper(c) {
|
||||
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,
|
||||
})
|
||||
await cargarCanales()
|
||||
}
|
||||
|
||||
async function toggleCanalOcr(c) {
|
||||
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,
|
||||
})
|
||||
await cargarCanales()
|
||||
}
|
||||
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
|
||||
@@ -398,7 +512,7 @@ watch(
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-1.5 mb-6 overflow-x-auto pb-1">
|
||||
<div class="flex gap-1.5 mb-6 overflow-x-auto sin-barra pb-1">
|
||||
<button
|
||||
v-for="[key, label] in tabs"
|
||||
:key="key"
|
||||
@@ -414,25 +528,146 @@ watch(
|
||||
|
||||
<!-- Base de conocimiento -->
|
||||
<div v-if="tab === 'conocimiento'">
|
||||
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50 transition-colors">
|
||||
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="flex gap-1 mb-3">
|
||||
<button
|
||||
v-for="[k, l] in [['texto', '✍️ Escribir'], ['archivo', '📄 Subir archivo'], ['url', '🌐 Sitio web']]"
|
||||
:key="k"
|
||||
class="px-3 py-1.5 rounded-lg text-sm"
|
||||
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
|
||||
@click="fuenteNueva = k"
|
||||
>{{ l }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
|
||||
<form v-if="fuenteNueva === 'texto'" class="space-y-2" @submit.prevent="agregarNota">
|
||||
<input v-model="notaTitulo" placeholder="Título (ej: Horarios y zonas de entrega)" class="input" />
|
||||
<textarea
|
||||
v-model="notaTexto"
|
||||
rows="5"
|
||||
required
|
||||
placeholder="Atendemos de lunes a viernes de 9 a 18. No hacemos envíos fuera de la ciudad. La garantía es de 6 meses y cubre…"
|
||||
class="input font-normal"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">Lo que te preguntan todos los días y no está en tu web.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Guardando…' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else-if="fuenteNueva === 'archivo'" class="space-y-2" @submit.prevent="subirArchivo">
|
||||
<input ref="archivoRef" type="file" accept=".pdf,.docx,.txt,.md,.csv,.html,image/*" class="text-sm text-texto" />
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-tenue">PDF, Word, texto o una foto. Ej: tu lista de precios.</p>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Subir' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else class="space-y-2" @submit.prevent="agregarFuente">
|
||||
<div class="flex gap-2">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="input flex-1" />
|
||||
<input v-model="maxPaginas" type="number" min="1" max="200" class="input w-24" title="Máximo de páginas a leer" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2 text-xs text-tenue cursor-pointer">
|
||||
<input v-model="autoActualizar" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
|
||||
Releer el sitio cada semana
|
||||
</label>
|
||||
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
|
||||
{{ ingestando ? 'Leyendo…' : 'Leer sitio' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<div v-if="documentos.length === 0" class="p-6 text-sm text-tenue">Sin fuentes todavía.</div>
|
||||
<UiEmptyState
|
||||
v-if="documentos.length === 0"
|
||||
estado="buscando"
|
||||
titulo="Todavía no sabe nada de tu negocio"
|
||||
detalle="Sin información cargada va a contestar que no sabe. Empezá por escribir tus horarios y lo que te preguntan todos los días — es lo más rápido y lo que más se nota."
|
||||
/>
|
||||
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-texto">{{ d.origen }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
|
||||
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
|
||||
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
|
||||
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
|
||||
que delata que el agente contesta con información vieja. -->
|
||||
<span :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
|
||||
· leído {{ antiguedad(d.procesado_at) }}
|
||||
</span>
|
||||
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
|
||||
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
v-if="d.tipo === 'url'"
|
||||
class="text-xs text-tenue hover:text-texto"
|
||||
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
|
||||
@click="alternarAuto(d)"
|
||||
>{{ d.auto_actualizar ? '🔁 auto' : '↻ manual' }}</button>
|
||||
<!-- Solo lo que tiene texto propio guardado: una URL se rehace
|
||||
crawleando, editarla a mano se perdería en la próxima pasada. -->
|
||||
<button
|
||||
v-if="d.tipo === 'texto' || d.tipo === 'archivo'"
|
||||
class="text-sm text-brand hover:underline"
|
||||
@click="editarDocumento(d)"
|
||||
>Editar</button>
|
||||
<button
|
||||
class="text-sm text-tenue hover:text-texto disabled:opacity-40"
|
||||
:disabled="d.estado === 'procesando'"
|
||||
:title="d.tipo === 'url' ? 'Volver a leer el sitio' : 'Rehacer los fragmentos'"
|
||||
@click="reprocesar(d)"
|
||||
>Actualizar</button>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editar una nota o el texto extraído de un archivo -->
|
||||
<div
|
||||
v-if="editandoDoc"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="editandoDoc = null"
|
||||
>
|
||||
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h2 class="font-semibold text-texto mb-1">Editar fuente</h2>
|
||||
<p class="text-xs text-tenue mb-4">
|
||||
Al guardar se vuelve a leer y el agente empieza a contestar con esto.
|
||||
</p>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="guardarDocumento">
|
||||
<div>
|
||||
<label class="label">Título</label>
|
||||
<input v-model="docForm.titulo" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contenido</label>
|
||||
<textarea
|
||||
v-model="docForm.contenido"
|
||||
rows="16"
|
||||
required
|
||||
class="input font-normal leading-relaxed"
|
||||
></textarea>
|
||||
<p class="text-xs text-tenue mt-1.5">
|
||||
Si viene de una plantilla, reemplazá lo que está [entre corchetes] por tus datos.
|
||||
Lo que quede sin reemplazar el agente lo va a leer tal cual.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" class="btn-ghost" @click="editandoDoc = null">Cancelar</button>
|
||||
<button type="submit" class="btn-primary" :disabled="guardandoDoc">
|
||||
{{ guardandoDoc ? 'Guardando…' : 'Guardar y volver a leer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,13 +675,17 @@ watch(
|
||||
<!-- Herramientas -->
|
||||
<div v-else-if="tab === 'herramientas'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="label">Máximo 10 tools activas por agente.</p>
|
||||
<p class="label">Máximo 10 herramientas activas por agente.</p>
|
||||
<button class="btn-primary" @click="nuevaTool">
|
||||
+ Nueva tool
|
||||
+ Nueva herramienta
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<div v-if="tools.length === 0" class="p-6 text-sm text-tenue">Sin tools custom todavía.</div>
|
||||
<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>
|
||||
@@ -466,7 +705,7 @@ watch(
|
||||
|
||||
<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 tool' : 'Nueva tool' }}</h2>
|
||||
<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>
|
||||
@@ -560,7 +799,11 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<div v-if="canales.length === 0" class="p-6 text-sm text-tenue">Sin canales configurados.</div>
|
||||
<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>
|
||||
@@ -585,6 +828,10 @@ watch(
|
||||
<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>
|
||||
@@ -640,6 +887,10 @@ watch(
|
||||
<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>
|
||||
@@ -665,7 +916,11 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
<div class="card divide-y divide-borde">
|
||||
<div v-if="conexiones.length === 0" class="p-6 text-sm text-tenue">Sin cuentas conectadas.</div>
|
||||
<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>
|
||||
@@ -683,7 +938,7 @@ watch(
|
||||
<div v-else-if="tab === 'chat'" class="card p-4 flex flex-col h-[28rem]">
|
||||
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||
<p v-if="chatMensajes.length === 0" class="text-sm text-tenue">
|
||||
Probá este agente tal cual lo va a ver un visitante — usa la misma config de IA y las mismas tools/base de conocimiento.
|
||||
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 chatMensajes"
|
||||
@@ -706,7 +961,11 @@ watch(
|
||||
<!-- Conversaciones -->
|
||||
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
|
||||
<div v-if="sesiones.length === 0" class="p-4 text-sm text-tenue">Sin conversaciones.</div>
|
||||
<UiEmptyState
|
||||
v-if="sesiones.length === 0"
|
||||
titulo="Nadie escribió todavía"
|
||||
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
|
||||
/>
|
||||
<button
|
||||
v-for="s in sesiones"
|
||||
:key="s.session_id"
|
||||
@@ -734,10 +993,15 @@ watch(
|
||||
<!-- Auditoría -->
|
||||
<div v-else>
|
||||
<p class="label mb-4">
|
||||
Errores y eventos técnicos de este agente — fallos al llamar al AI, a tools, a correo o a los canales. Últimos 100.
|
||||
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">
|
||||
<div v-if="eventos.length === 0" class="p-6 text-sm text-tenue">Sin eventos registrados — buena señal.</div>
|
||||
<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>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiMascota from '../components/ui/UiMascota.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const cargando = ref(contexto.esPortal)
|
||||
const sinEspacios = ref(false)
|
||||
|
||||
// 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
|
||||
@@ -18,6 +20,10 @@ async function irAlAgenteSiEsUnoSolo() {
|
||||
try {
|
||||
const t = await api.get(apiUmind('/umind/tenants'))
|
||||
const tenants = t.items || []
|
||||
if (tenants.length === 0) {
|
||||
sinEspacios.value = true
|
||||
return
|
||||
}
|
||||
if (tenants.length !== 1) return
|
||||
|
||||
const tenantId = tenants[0].ID
|
||||
@@ -43,10 +49,28 @@ onMounted(irAlAgenteSiEsUnoSolo)
|
||||
Abriendo tu asistente…
|
||||
</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 class="text-4xl mb-4">💬</div>
|
||||
<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>
|
||||
<p class="text-sm text-tenue mt-1">
|
||||
{{ contexto.esPortal
|
||||
|
||||
@@ -62,23 +62,30 @@ const cupo = computed(() => {
|
||||
})
|
||||
|
||||
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() {
|
||||
error.value = ''
|
||||
cargando.value = true
|
||||
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/agentes?tenant_id=${props.id}`)),
|
||||
api.get(apiUmind('/umind/ai-configs')),
|
||||
api.get(apiUmind('/umind/plantillas-rubro')),
|
||||
])
|
||||
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
|
||||
agentes.value = a.items || []
|
||||
plan.value = a.plan || null
|
||||
resumen.value = a.resumen || {}
|
||||
aiConfigs.value = ai.items || []
|
||||
plantillas.value = pl.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
@@ -86,6 +93,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() {
|
||||
editing.value = null
|
||||
form.value = vacio()
|
||||
@@ -160,7 +185,7 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
|
||||
</div>
|
||||
<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 v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
|
||||
Alcanzaste el máximo de agentes de tu plan.
|
||||
@@ -184,7 +209,7 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
<UiEmptyState
|
||||
v-else-if="agentes.length === 0"
|
||||
class="card"
|
||||
icono="🤖"
|
||||
estado="durmiendo"
|
||||
titulo="Todavía no hay agentes"
|
||||
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
|
||||
>
|
||||
@@ -218,6 +243,11 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
<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="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
|
||||
@click.prevent.stop="duplicarAgente(a)"
|
||||
>⧉</button>
|
||||
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,13 +261,44 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
</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 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>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
|
||||
</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>
|
||||
<label class="label">Config de IA</label>
|
||||
<select v-model="form.ai_config_id" class="input">
|
||||
|
||||
+52
-19
@@ -19,7 +19,7 @@ type AiConfig struct {
|
||||
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
|
||||
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"`
|
||||
// Modulo indica a qué servicio pertenece esta config.
|
||||
// "" = 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
|
||||
func SplitModulos(modulo string) []string {
|
||||
@@ -171,6 +156,15 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
|
||||
var ai AiConfig
|
||||
@@ -230,9 +224,48 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cualquier config activa como último recurso
|
||||
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
|
||||
return &items[0], nil
|
||||
// 3. Cualquier config activa como último recurso, pero nunca una que esté
|
||||
// dedicada a un servicio que no sabe conversar: la de embeddings devuelve
|
||||
// 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 = ?", 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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,26 @@ import (
|
||||
|
||||
type ProyectoTicket struct {
|
||||
gorm.Model
|
||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
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
|
||||
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||
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)
|
||||
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
|
||||
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
|
||||
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
|
||||
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
|
||||
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
|
||||
Titulo string `json:"titulo" gorm:"column:titulo"`
|
||||
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
|
||||
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
|
||||
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
|
||||
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
|
||||
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)
|
||||
// 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" }
|
||||
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
|
||||
|
||||
func GetAllTickets(estado string) ([]ProyectoTicket, error) {
|
||||
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" {
|
||||
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).
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,19 @@ type SoporteWebhookConfig struct {
|
||||
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.
|
||||
|
||||
+44
-4
@@ -131,11 +131,23 @@ func (t *UmindTenant) DominioPermitido(host string) bool {
|
||||
type UmindDocumento struct {
|
||||
gorm.Model
|
||||
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
|
||||
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo
|
||||
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del 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, el nombre del archivo, o el título del texto
|
||||
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
||||
@@ -159,11 +171,32 @@ func GetUmindDocumentoByID(id uint) (*UmindDocumento, 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,
|
||||
"error": errMsg,
|
||||
"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 {
|
||||
@@ -206,6 +239,13 @@ func EmbeddingFromJSON(s string) ([]float32, error) {
|
||||
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 {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -47,6 +47,15 @@ func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
||||
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) {
|
||||
var a UmindAgente
|
||||
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.
|
||||
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"`
|
||||
// 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" }
|
||||
|
||||
@@ -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,6 +78,13 @@ func IniciarCron() {
|
||||
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 {
|
||||
|
||||
+20
-91
@@ -1,111 +1,40 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// CompletarTextoIA hace una llamada simple (sin streaming, sin tools) al
|
||||
// proveedor configurado para el servicio dado y devuelve el texto de la
|
||||
// respuesta. Es la contraparte no-streaming de GeneraTextoStream, para los
|
||||
// casos en que el backend necesita el resultado completo antes de seguir.
|
||||
// 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 para %q; configurá una en /app/ai-config", servicio)
|
||||
return "", fmt.Errorf("no hay configuración de IA activa; configurá una en /app/ai-config")
|
||||
}
|
||||
modelo := config.ModelName
|
||||
if modelo == "" {
|
||||
return "", fmt.Errorf("la configuración de IA de %q no tiene modelo definido", servicio)
|
||||
if strings.TrimSpace(config.ModelName) == "" {
|
||||
return "", fmt.Errorf("la configuración de IA %q no tiene modelo definido (/app/ai-config)", config.Nombre)
|
||||
}
|
||||
|
||||
provider := strings.ToLower(config.Provider)
|
||||
clave := config.ClaveEnClaro()
|
||||
baseURL := strings.TrimRight(config.BaseURL, "/")
|
||||
|
||||
var endpoint string
|
||||
var cuerpo []byte
|
||||
if provider == "gemini" {
|
||||
endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelo, clave)
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"parts": []map[string]string{{"text": sistema + "\n\n" + usuario}}},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
endpoint = strings.TrimSuffix(baseURL, "/v1") + "/v1/chat/completions"
|
||||
cuerpo, _ = json.Marshal(map[string]any{
|
||||
"model": modelo,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": sistema},
|
||||
{"role": "user", "content": usuario},
|
||||
},
|
||||
"stream": false,
|
||||
})
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(cuerpo))
|
||||
msg, _, err := callAI(config, []agentMessage{
|
||||
{Role: "system", Content: sistema},
|
||||
{Role: "user", Content: usuario},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if provider != "gemini" && clave != "" {
|
||||
if provider == "ollama" && clave != "ollama" {
|
||||
req.SetBasicAuth("ollama", clave)
|
||||
} else if provider != "ollama" {
|
||||
req.Header.Set("Authorization", "Bearer "+clave)
|
||||
}
|
||||
return "", fmt.Errorf("%s (%s): %w", config.Nombre, config.Provider, err)
|
||||
}
|
||||
|
||||
// Generar una plantilla entera es lento; el timeout es alto a propósito.
|
||||
resp, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no se pudo conectar al proveedor de IA: %w", err)
|
||||
texto, _ := msg.Content.(string)
|
||||
if strings.TrimSpace(texto) == "" {
|
||||
return "", fmt.Errorf("%s no devolvió texto", config.Nombre)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("el proveedor de IA respondió %d: %s", resp.StatusCode, recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
|
||||
if provider == "gemini" {
|
||||
var out struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Candidates) == 0 || len(out.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del proveedor: %s", recortar(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("el proveedor de IA no devolvió texto")
|
||||
}
|
||||
return out.Choices[0].Message.Content, nil
|
||||
return texto, nil
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
|
||||
// ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
|
||||
// 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) {
|
||||
if ticket == nil || portalUser == nil {
|
||||
if ticket == nil {
|
||||
return
|
||||
}
|
||||
_ = portalUser
|
||||
cfg := models.GetNotifConfig("ticket_nuevo", "admin")
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -409,10 +412,22 @@ func sendTelegramAdmin(mensaje string) {
|
||||
log.Printf("[Notif] Error obteniendo configs telegram: %v", err)
|
||||
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 {
|
||||
if !cfg.Activo {
|
||||
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}
|
||||
sendErr := ts.SendMessage(cfg.ChatID, mensaje)
|
||||
logEntry := &models.TelegramLog{
|
||||
|
||||
@@ -1,89 +1,30 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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.Nit}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
|
||||
"cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
|
||||
{{.Alcance}}, {{.TipoProyecto}}, {{.Total}},
|
||||
{{range .Items}} … {{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}} … {{end}}`,
|
||||
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
|
||||
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
|
||||
"acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`,
|
||||
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Nit}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
|
||||
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
|
||||
}
|
||||
|
||||
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
|
||||
|
||||
// ExtraerTextoDePlantilla saca el texto de un archivo subido para usarlo como
|
||||
// referencia. Los formatos de texto se leen directo; el .docx es un zip con XML
|
||||
// adentro (stdlib alcanza) y las imágenes pasan por OCR.
|
||||
// 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) {
|
||||
ext := strings.ToLower(filepath.Ext(nombreArchivo))
|
||||
switch ext {
|
||||
case ".html", ".htm", ".txt", ".md":
|
||||
return string(datos), nil
|
||||
case ".docx":
|
||||
return textoDeDocx(datos)
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
|
||||
mime := "image/png"
|
||||
if ext == ".jpg" || ext == ".jpeg" {
|
||||
mime = "image/jpeg"
|
||||
} else if ext != ".png" {
|
||||
mime = "image/" + strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
|
||||
return ExtraerTextoOCR(0, datos, mime)
|
||||
case ".pdf":
|
||||
return "", fmt.Errorf("el PDF todavía no se puede leer acá; exportalo a .docx o subí una captura de pantalla del documento")
|
||||
default:
|
||||
return "", fmt.Errorf("formato %s no soportado: subí .docx, .html, .txt o una imagen del documento", ext)
|
||||
}
|
||||
}
|
||||
|
||||
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
|
||||
// conservar el formato: la IA 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 y un salto de línea explícito valen como salto de línea;
|
||||
// el resto de las etiquetas se descarta.
|
||||
s = strings.ReplaceAll(s, "</w:p>", "\n")
|
||||
s = strings.ReplaceAll(s, "<w:br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tr>", "\n")
|
||||
s = strings.ReplaceAll(s, "</w:tc>", "\t")
|
||||
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)")
|
||||
return ExtraerTextoDeArchivo(0, nombreArchivo, datos)
|
||||
}
|
||||
|
||||
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
|
||||
@@ -120,7 +61,16 @@ Documento de referencia:
|
||||
%s
|
||||
---`, tipo, variablesComunes, vars, textoDocumento)
|
||||
|
||||
salida, err := CompletarTextoIA("ia", sistema, usuario)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -48,9 +48,6 @@ func TestExtraerTextoDePlantillaDocx(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.pdf", []byte("x")); err == nil {
|
||||
t.Error("el PDF debería devolver error explicando la alternativa")
|
||||
}
|
||||
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
|
||||
t.Error("una extensión desconocida debería devolver error")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
@@ -36,33 +37,64 @@ func RevisarBuzonSoporte() {
|
||||
}
|
||||
defer imapEnCurso.Unlock()
|
||||
|
||||
n, err := revisarBuzon(cfg)
|
||||
encontrados, procesados, err := revisarBuzon(cfg)
|
||||
if err != nil {
|
||||
log.Printf("[SoporteIMAP] %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[SoporteIMAP] %d correo(s) procesado(s)", n)
|
||||
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) error {
|
||||
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) {
|
||||
c, err := conectarImap(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer c.Close()
|
||||
carpeta := cfg.ImapCarpeta
|
||||
if carpeta == "" {
|
||||
carpeta = "INBOX"
|
||||
}
|
||||
if _, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||
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()
|
||||
return nil
|
||||
// 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) {
|
||||
@@ -92,30 +124,122 @@ func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) {
|
||||
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
|
||||
c, err := conectarImap(cfg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, 0, err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
carpeta := cfg.ImapCarpeta
|
||||
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 0, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||
return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
|
||||
}
|
||||
|
||||
buscados, err := c.Search(&imap.SearchCriteria{
|
||||
NotFlag: []imap.Flag{imap.FlagSeen},
|
||||
}, &imap.SearchOptions{ReturnAll: true}).Wait()
|
||||
// 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 0, fmt.Errorf("búsqueda de no leídos falló: %w", err)
|
||||
return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err)
|
||||
}
|
||||
uids := buscados.AllUIDs()
|
||||
if len(uids) == 0 {
|
||||
return 0, nil
|
||||
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.
|
||||
@@ -124,15 +248,20 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) {
|
||||
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{
|
||||
BodySection: []*imap.FetchItemBodySection{{}},
|
||||
InternalDate: true,
|
||||
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("descarga de correos falló: %w", err)
|
||||
return nil, fmt.Errorf("descarga de correos falló: %w", err)
|
||||
}
|
||||
|
||||
procesados := 0
|
||||
var out []CorreoConUID
|
||||
for _, m := range msgs {
|
||||
if !dentroDeLaVentana(m.InternalDate, horasAtras) {
|
||||
continue
|
||||
}
|
||||
var crudo []byte
|
||||
for _, b := range m.BodySection {
|
||||
crudo = b.Bytes
|
||||
@@ -146,20 +275,18 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) {
|
||||
log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err)
|
||||
continue
|
||||
}
|
||||
if IngestarCorreoSoporte(cfg, correo) {
|
||||
procesados++
|
||||
}
|
||||
// Se marca leído aunque se haya ignorado por duplicado: si no, se
|
||||
// vuelve a bajar en cada corrida para siempre.
|
||||
if err := c.Store(imap.UIDSetNum(m.UID), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil).Close(); err != nil {
|
||||
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", m.UID, err)
|
||||
}
|
||||
out = append(out, CorreoConUID{UID: m.UID, Correo: correo})
|
||||
}
|
||||
return procesados, nil
|
||||
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.
|
||||
@@ -188,11 +315,12 @@ func parsearCorreo(crudo []byte) (CorreoSoporte, error) {
|
||||
}
|
||||
|
||||
return CorreoSoporte{
|
||||
From: from,
|
||||
FromName: nombre,
|
||||
Subject: decodificar(msg.Header.Get("Subject")),
|
||||
Texto: limpiarCitas(cuerpo),
|
||||
MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -275,3 +403,20 @@ func limpiarCitas(texto string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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
|
||||
@@ -18,6 +21,9 @@ type CorreoSoporte struct {
|
||||
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+)\]`)
|
||||
@@ -26,15 +32,29 @@ var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
|
||||
// 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 {
|
||||
if e.From == "" || e.Subject == "" {
|
||||
return false
|
||||
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
|
||||
return false, ""
|
||||
}
|
||||
|
||||
fromEmail := ExtraerEmail(e.From)
|
||||
@@ -63,14 +83,19 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
||||
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
|
||||
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)
|
||||
return true
|
||||
notificarTicketDeCorreo(hilo, contenido, true)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
ticket := &models.ProyectoTicket{
|
||||
@@ -82,20 +107,49 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
|
||||
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
|
||||
return false, ""
|
||||
}
|
||||
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
|
||||
|
||||
SendSoporteNotifAdmin(ticket)
|
||||
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
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
|
||||
@@ -142,3 +196,93 @@ func ExtraerNombre(s string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +78,15 @@ func TestExtractEmailEdgeCases(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+104
-53
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
@@ -33,61 +34,87 @@ func soporteSendMail(to, subject, htmlBody string) error {
|
||||
if port == 0 {
|
||||
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)
|
||||
enc := strings.ToLower(cfg.SmtpEncryption)
|
||||
if enc == "tls" {
|
||||
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
|
||||
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)
|
||||
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))
|
||||
|
||||
fallback:
|
||||
return app.Http.Mail.Send(to, subject, htmlBody)
|
||||
if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil {
|
||||
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
|
||||
@@ -123,8 +150,10 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
|
||||
go func() {
|
||||
if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil {
|
||||
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 {
|
||||
log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID)
|
||||
guardarErrorAcuse("")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -165,3 +194,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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// Si no está habilitada, se ignora en silencio.
|
||||
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)
|
||||
if err != nil || !agente.Activo {
|
||||
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 {
|
||||
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:
|
||||
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
|
||||
// (mismo comportamiento de antes de que existiera esta función).
|
||||
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)
|
||||
if err != nil || !agente.Activo {
|
||||
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 {
|
||||
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:
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,13 @@ func trocearTexto(texto string) []string {
|
||||
if siguiente <= inicio {
|
||||
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
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
maxPaginas = 30
|
||||
}
|
||||
@@ -235,10 +232,43 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
|
||||
return
|
||||
}
|
||||
|
||||
// Trocear todo el contenido crawleado en chunks de texto plano.
|
||||
var textos []string
|
||||
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 {
|
||||
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 {
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0)
|
||||
return
|
||||
msg := "no se generó ningún fragmento de texto aprovechable"
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", msg, 0)
|
||||
return 0, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
|
||||
return
|
||||
return 0, err
|
||||
}
|
||||
for j, texto := range tanda {
|
||||
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 {
|
||||
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
|
||||
return
|
||||
return 0, err
|
||||
}
|
||||
|
||||
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,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>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-CUSjvRBR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DUjBJRtl.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-Cv_aH8gm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Bk6phDz-.css">
|
||||
</head>
|
||||
<!-- 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
|
||||
|
||||
+1316
File diff suppressed because it is too large
Load Diff
@@ -200,6 +200,26 @@
|
||||
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
|
||||
</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>
|
||||
<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">
|
||||
@@ -288,17 +308,26 @@ function aiConfigApp() {
|
||||
search: '',
|
||||
showModal: false, editItem: null, deleteId: null, testResult: null,
|
||||
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: [
|
||||
{ value: 'landing', label: 'Landing Generator' },
|
||||
{ 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: '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() {
|
||||
this.loading = true
|
||||
@@ -325,7 +354,7 @@ function aiConfigApp() {
|
||||
|
||||
openAdd() {
|
||||
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.showModal = true
|
||||
},
|
||||
@@ -335,7 +364,7 @@ function aiConfigApp() {
|
||||
const modulos = item.modulo
|
||||
? 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.showModal = true
|
||||
},
|
||||
@@ -346,6 +375,7 @@ function aiConfigApp() {
|
||||
this.saving = true; this.formError = ''
|
||||
const payload = { ...this.form, modulo: this.form.modulos.join(',') }
|
||||
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 method = this.editItem ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||||
|
||||
@@ -105,7 +105,9 @@
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<input type="checkbox" :checked="form.scopes.includes(s)"
|
||||
@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,
|
||||
errorMsg: '', successMsg: '', formError: '',
|
||||
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() {
|
||||
this.loading = true; this.errorMsg = ''
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
|
||||
@@ -93,11 +94,16 @@
|
||||
<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 (.docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
|
||||
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=".docx,.html,.htm,.txt,.md,image/*"
|
||||
<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">
|
||||
@@ -129,13 +135,36 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-medium text-gray-600 block mb-1">HTML de la plantilla (Go text/template) *</label>
|
||||
<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 class="grid gap-3" :class="mostrarPreview ? 'lg:grid-cols-2' : ''">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-medium text-gray-600">HTML de la plantilla (Go text/template) *</label>
|
||||
<button type="button" @click="mostrarPreview = !mostrarPreview; if (mostrarPreview) previsualizar()"
|
||||
class="text-xs px-2 py-1 border rounded">
|
||||
<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 class="flex justify-end gap-2 mt-5">
|
||||
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
|
||||
@@ -173,6 +202,7 @@ document.addEventListener('alpine:init', () => {
|
||||
addModal: false, editModal: false, deleteModal: false,
|
||||
selectedId: null, templateError: '',
|
||||
importando: false, avisoImport: '', errorImport: false,
|
||||
mostrarPreview: false, previewCargando: false, previewError: '',
|
||||
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
|
||||
toast: { show:false, msg:'', type:'ok' },
|
||||
|
||||
@@ -195,6 +225,8 @@ document.addEventListener('alpine:init', () => {
|
||||
this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa };
|
||||
this.selectedId = d.ID;
|
||||
this.editModal = true;
|
||||
this.mostrarPreview = true;
|
||||
this.$nextTick(() => this.previsualizar());
|
||||
},
|
||||
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
|
||||
|
||||
@@ -210,16 +242,43 @@ document.addEventListener('alpine:init', () => {
|
||||
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á el HTML abajo';
|
||||
this.avisoImport = data.aviso || 'Listo, revisá cómo quedó';
|
||||
this.mostrarPreview = true;
|
||||
await this.previsualizar();
|
||||
} catch(e) {
|
||||
this.errorImport = true;
|
||||
this.avisoImport = e.response?.data?.error || e.message;
|
||||
// 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() {
|
||||
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() {
|
||||
@@ -227,6 +286,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.selectedId = null;
|
||||
this.templateError = '';
|
||||
this.avisoImport = ''; this.errorImport = false;
|
||||
this.mostrarPreview = false; this.previewError = '';
|
||||
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
|
||||
},
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
class="ml-auto mr-3">
|
||||
<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">
|
||||
<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
|
||||
campana y el nombre de usuario, pero el acceso no puede faltar. -->
|
||||
<span class="hidden sm:inline">uMind Studio</span>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
confía; es el punto de contacto más barato que existe. -->
|
||||
{{ 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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -37,7 +37,7 @@
|
||||
{{ else }}
|
||||
<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">
|
||||
<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">
|
||||
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
|
||||
<p class="text-slate-600 text-sm mt-1">
|
||||
|
||||
@@ -108,6 +108,21 @@
|
||||
</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>
|
||||
|
||||
<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 ────────────────────────────────────────── -->
|
||||
@@ -165,6 +180,21 @@
|
||||
</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">
|
||||
@@ -180,6 +210,48 @@
|
||||
<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 class="pt-4">
|
||||
<button @click="guardar()"
|
||||
class="px-6 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||
@@ -223,12 +295,20 @@ function soporteWebhook() {
|
||||
imap_password: '',
|
||||
imap_encryption: 'ssl',
|
||||
imap_carpeta: 'INBOX',
|
||||
imap_horas_atras: 12,
|
||||
clasificar_con_ia: false,
|
||||
contexto_negocio: '',
|
||||
agente_borrador_id: '',
|
||||
},
|
||||
admins: [],
|
||||
agentes: [],
|
||||
tieneImapPassword: false,
|
||||
ocupado: false,
|
||||
mensajeImap: '',
|
||||
errorImap: false,
|
||||
emailPrueba: '',
|
||||
mensajeEnvio: '',
|
||||
errorEnvio: false,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
@@ -247,6 +327,10 @@ function soporteWebhook() {
|
||||
const r = await axios.get('/app/tickets/admins');
|
||||
this.admins = r.data || [];
|
||||
} catch {}
|
||||
try {
|
||||
const r = await axios.get('/app/soporte/agentes');
|
||||
this.agentes = r.data || [];
|
||||
} catch {}
|
||||
if (!this.cfg.api_key) {
|
||||
this.cfg.api_key = this.generarClave();
|
||||
}
|
||||
@@ -286,6 +370,10 @@ function soporteWebhook() {
|
||||
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 {
|
||||
await axios.post('/app/soporte/webhook', payload);
|
||||
@@ -311,6 +399,20 @@ function soporteWebhook() {
|
||||
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'); },
|
||||
};
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
<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==='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>
|
||||
<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">
|
||||
@@ -97,10 +103,16 @@
|
||||
|
||||
<!-- Responder -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..."
|
||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none"
|
||||
@keydown.enter.prevent="responder(t)"
|
||||
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'">
|
||||
<!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
|
||||
<textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
|
||||
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
|
||||
@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)"
|
||||
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
|
||||
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) {
|
||||
const contenido = (t._reply || '').trim();
|
||||
if (!contenido) return;
|
||||
|
||||
@@ -126,6 +126,9 @@ func CreateAiConfigHandler(c *fiber.Ctx) error {
|
||||
if err := models.CreateAiConfig(&item); err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -153,21 +156,35 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error {
|
||||
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{}{
|
||||
"nombre": strings.TrimSpace(req.Nombre),
|
||||
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
|
||||
"base_url": strings.TrimSpace(req.BaseURL),
|
||||
"model_name": strings.TrimSpace(req.ModelName),
|
||||
"is_active": req.IsActive,
|
||||
"notes": req.Notes,
|
||||
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
|
||||
"es_agente_bot": req.EsAgenteBot,
|
||||
"telegram_config_id": req.TelegramConfigID,
|
||||
"nombre": strings.TrimSpace(req.Nombre),
|
||||
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
|
||||
"base_url": strings.TrimSpace(req.BaseURL),
|
||||
"model_name": strings.TrimSpace(req.ModelName),
|
||||
"is_active": req.IsActive,
|
||||
"notes": req.Notes,
|
||||
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
|
||||
}
|
||||
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) != "" {
|
||||
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 {
|
||||
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 (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
@@ -38,3 +39,30 @@ func getOSSFromQuery(c *fiber.Ctx) (services.OSSProvider, error) {
|
||||
}
|
||||
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 {
|
||||
FileID string `json:"file_id"`
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"` // solo lo traen los documentos
|
||||
}
|
||||
|
||||
type umindTgMessage struct {
|
||||
Chat umindTgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
Voice *umindTgFileRef `json:"voice"`
|
||||
Audio *umindTgFileRef `json:"audio"`
|
||||
Photo []umindTgFileRef `json:"photo"`
|
||||
Chat umindTgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
Voice *umindTgFileRef `json:"voice"`
|
||||
Audio *umindTgFileRef `json:"audio"`
|
||||
Photo []umindTgFileRef `json:"photo"`
|
||||
Document *umindTgFileRef `json:"document"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
type umindTgUpdate struct {
|
||||
@@ -55,6 +58,8 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
|
||||
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
|
||||
case len(msg.Photo) > 0:
|
||||
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:
|
||||
texto := strings.TrimSpace(msg.Text)
|
||||
if texto == "" {
|
||||
@@ -81,8 +86,13 @@ type umindWaMessage struct {
|
||||
Text struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"text"`
|
||||
Image umindWaMediaRef `json:"image"`
|
||||
Audio umindWaMediaRef `json:"audio"`
|
||||
Image umindWaMediaRef `json:"image"`
|
||||
Audio umindWaMediaRef `json:"audio"`
|
||||
Document struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Caption string `json:"caption"`
|
||||
} `json:"document"`
|
||||
}
|
||||
|
||||
type umindWaValue struct {
|
||||
@@ -172,6 +182,11 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
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:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/chai2010/webp"
|
||||
@@ -53,29 +52,24 @@ func CreateQr(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
random := helpers.RandomString(4)
|
||||
fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
||||
tmpPath := fmt.Sprintf("/tmp/%s.webp", data.FirstName)
|
||||
fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", claveObjetoSegura(data.FirstName), random)
|
||||
|
||||
// 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
|
||||
// 4. Subir a OSS directo desde memoria: el archivo temporal solo agregaba
|
||||
// una forma más de fallar (y el nombre venía del cliente, sin limpiar).
|
||||
ossProvider, err := getOSSFromBody(c)
|
||||
if err != nil {
|
||||
log.Printf("[QR] No se pudo resolver la config de OSS: %v", err)
|
||||
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 {
|
||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
||||
if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
|
||||
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{
|
||||
"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 {
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Nombre string `json:"unico"`
|
||||
}
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(qrBytes))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "No se pudo procesar la imagen",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Codificar a WebP
|
||||
var webpBuffer bytes.Buffer
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 3. Convertir a WebP
|
||||
var webpBuf bytes.Buffer
|
||||
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, " ", "_") )
|
||||
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
|
||||
// 4. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
|
||||
ossProvider, err := getOSSFromBody(c)
|
||||
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{
|
||||
"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 {
|
||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
||||
if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
|
||||
log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
|
||||
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
|
||||
url := ossProvider.PublicURL(fileName)
|
||||
|
||||
|
||||
// 8. Retornar la URL
|
||||
return c.JSON(fiber.Map{
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -34,29 +33,21 @@ func CreateVcf(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
random := helpers.RandomString(4)
|
||||
fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
||||
tmpPath := fmt.Sprintf("/tmp/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random)
|
||||
fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", claveObjetoSegura(data.FirstName), random)
|
||||
|
||||
// 2. Guardar archivo temporal
|
||||
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
|
||||
// 2. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
|
||||
ossProvider, err := getOSSFromBody(c)
|
||||
if err != nil {
|
||||
log.Printf("[VCF] No se pudo resolver la config de OSS: %v", err)
|
||||
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 {
|
||||
log.Printf("Error subiendo archivo a OSS: %v", err)
|
||||
if err := ossProvider.UploadFromReader(fileName, "text/vcard; charset=utf-8", bytes.NewReader(vcfContent)); err != nil {
|
||||
log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
|
||||
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 (
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +18,20 @@ var scopesValidos = map[string]bool{
|
||||
"query_runner": true,
|
||||
"usuarios": 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.
|
||||
|
||||
@@ -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-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/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": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
|
||||
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"text/template"
|
||||
@@ -135,7 +136,10 @@ func ImportarPlantillaDocumento(c *fiber.Ctx) error {
|
||||
tipo := c.FormValue("tipo", "cotizacion")
|
||||
html, err := services.ConvertirEnPlantilla(tipo, texto)
|
||||
if err != nil {
|
||||
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
|
||||
// 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 {
|
||||
@@ -147,6 +151,24 @@ func ImportarPlantillaDocumento(c *fiber.Ctx) error {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
func GetTarifas(c *fiber.Ctx) error {
|
||||
|
||||
@@ -2,9 +2,11 @@ package controllers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
@@ -203,28 +205,32 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
|
||||
func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Provider string `json:"provider"`
|
||||
ApiKey string `json:"api_key"`
|
||||
EmailDestino string `json:"email_destino"`
|
||||
ResponderAuto bool `json:"responder_auto"`
|
||||
MensajeAuto string `json:"mensaje_auto"`
|
||||
AsignarA *uint `json:"asignar_a"`
|
||||
SmtpHost string `json:"smtp_host"`
|
||||
SmtpPort int `json:"smtp_port"`
|
||||
SmtpUsername string `json:"smtp_username"`
|
||||
SmtpPassword string `json:"smtp_password"`
|
||||
SmtpEncryption string `json:"smtp_encryption"`
|
||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||
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"`
|
||||
ID uint `json:"id"`
|
||||
Nombre string `json:"nombre"`
|
||||
Provider string `json:"provider"`
|
||||
ApiKey string `json:"api_key"`
|
||||
EmailDestino string `json:"email_destino"`
|
||||
ResponderAuto bool `json:"responder_auto"`
|
||||
MensajeAuto string `json:"mensaje_auto"`
|
||||
AsignarA *uint `json:"asignar_a"`
|
||||
SmtpHost string `json:"smtp_host"`
|
||||
SmtpPort int `json:"smtp_port"`
|
||||
SmtpUsername string `json:"smtp_username"`
|
||||
SmtpPassword string `json:"smtp_password"`
|
||||
SmtpEncryption string `json:"smtp_encryption"`
|
||||
SmtpFromAddr string `json:"smtp_from_addr"`
|
||||
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
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
@@ -266,28 +272,32 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
cfg := &models.SoporteWebhookConfig{
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
MensajeAuto: b.MensajeAuto,
|
||||
AsignarA: b.AsignarA,
|
||||
SmtpHost: b.SmtpHost,
|
||||
SmtpPort: port,
|
||||
SmtpUsername: b.SmtpUsername,
|
||||
SmtpPassword: b.SmtpPassword,
|
||||
SmtpEncryption: enc,
|
||||
SmtpFromAddr: b.SmtpFromAddr,
|
||||
SmtpFromName: b.SmtpFromName,
|
||||
ImapActivo: b.ImapActivo,
|
||||
ImapHost: b.ImapHost,
|
||||
ImapPort: imapPort,
|
||||
ImapUsername: b.ImapUsername,
|
||||
ImapPasswordEnc: passEnc,
|
||||
ImapEncryption: imapEnc,
|
||||
ImapCarpeta: carpeta,
|
||||
Activo: true,
|
||||
Nombre: b.Nombre,
|
||||
Provider: b.Provider,
|
||||
ApiKey: b.ApiKey,
|
||||
EmailDestino: b.EmailDestino,
|
||||
ResponderAuto: b.ResponderAuto,
|
||||
MensajeAuto: b.MensajeAuto,
|
||||
AsignarA: b.AsignarA,
|
||||
SmtpHost: b.SmtpHost,
|
||||
SmtpPort: port,
|
||||
SmtpUsername: b.SmtpUsername,
|
||||
SmtpPassword: b.SmtpPassword,
|
||||
SmtpEncryption: enc,
|
||||
SmtpFromAddr: b.SmtpFromAddr,
|
||||
SmtpFromName: b.SmtpFromName,
|
||||
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
|
||||
if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
|
||||
@@ -305,15 +315,87 @@ func ProbarImapSoporte(c *fiber.Ctx) error {
|
||||
if err != nil || cfg == nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
|
||||
}
|
||||
if err := services.ProbarConexionImap(cfg); err != nil {
|
||||
detalle, err := services.ProbarConexionImap(cfg)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Conexión IMAP correcta"})
|
||||
return c.JSON(fiber.Map{"ok": true, "message": detalle})
|
||||
}
|
||||
|
||||
// RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
|
||||
// POST /app/api/soporte-webhook/revisar-buzon
|
||||
func RevisarBuzonAhora(c *fiber.Ctx) error {
|
||||
services.RevisarBuzonSoporte()
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Buzón revisado, mirá la lista de tickets"})
|
||||
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()})
|
||||
}
|
||||
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 (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -197,6 +198,9 @@ type umindAgenteReq struct {
|
||||
MensajeBienvenida string `json:"mensaje_bienvenida"`
|
||||
Color string `json:"color"`
|
||||
Activo bool `json:"activo"`
|
||||
// PlantillaRubro precarga el conocimiento base de un rubro al crear el
|
||||
// agente. Vacío = agente en blanco, como era antes.
|
||||
PlantillaRubro string `json:"plantilla_rubro"`
|
||||
}
|
||||
|
||||
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
||||
@@ -232,6 +236,14 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
||||
if err := models.CreateUmindAgente(agente); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// El conocimiento de la plantilla se carga en segundo plano: cada nota
|
||||
// necesita sus embeddings y el agente ya existe. Hacer esperar el alta por
|
||||
// esto sería castigar justo al que eligió una plantilla.
|
||||
if clave := strings.TrimSpace(req.PlantillaRubro); clave != "" {
|
||||
go services.AplicarPlantillaRubro(agente.ID, clave)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
|
||||
}
|
||||
|
||||
@@ -297,9 +309,10 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
||||
// segundos/minutos según cuántas páginas tenga el sitio.
|
||||
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
AgenteID uint `json:"agente_id"`
|
||||
URL string `json:"url"`
|
||||
MaxPaginas int `json:"max_paginas"`
|
||||
AgenteID uint `json:"agente_id"`
|
||||
URL string `json:"url"`
|
||||
MaxPaginas int `json:"max_paginas"`
|
||||
AutoActualizar bool `json:"auto_actualizar"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
@@ -318,10 +331,12 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
doc := &models.UmindDocumento{
|
||||
AgenteID: req.AgenteID,
|
||||
Tipo: "url",
|
||||
Origen: strings.TrimSpace(req.URL),
|
||||
Estado: "procesando",
|
||||
AgenteID: req.AgenteID,
|
||||
Tipo: "url",
|
||||
Origen: strings.TrimSpace(req.URL),
|
||||
Estado: "procesando",
|
||||
MaxPaginas: req.MaxPaginas,
|
||||
AutoActualizar: req.AutoActualizar,
|
||||
}
|
||||
if err := models.CreateUmindDocumento(doc); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -547,6 +562,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
||||
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
|
||||
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
|
||||
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
|
||||
"usar_archivos_docs": canal.UsarArchivosDocs,
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"items": out})
|
||||
@@ -559,6 +575,7 @@ type umindCanalReq struct {
|
||||
Activo bool `json:"activo"`
|
||||
UsarWhisperAudio bool `json:"usar_whisper_audio"`
|
||||
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
|
||||
UsarArchivosDocs bool `json:"usar_archivos_docs"`
|
||||
}
|
||||
|
||||
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
||||
@@ -594,6 +611,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
||||
canal := &models.UmindCanal{
|
||||
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
||||
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
||||
UsarArchivosDocs: req.UsarArchivosDocs,
|
||||
}
|
||||
if err := models.CreateUmindCanal(canal); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -620,7 +638,7 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes}
|
||||
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
|
||||
if len(req.Credenciales) > 0 {
|
||||
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
||||
if err != nil {
|
||||
@@ -856,3 +874,221 @@ func slugSimple(s string) string {
|
||||
}
|
||||
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.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,17 @@ func AdminApiAuth() fiber.Handler {
|
||||
if err != nil {
|
||||
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
if !key.IPValida(c.IP()) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"})
|
||||
if ip := IPDelCliente(c); !key.IPValida(ip) {
|
||||
// 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)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// IPDelCliente devuelve la IP pública desde la que se hizo la petición.
|
||||
//
|
||||
// c.IP() no sirve para esto en producción: detrás del proxy del hosting
|
||||
// devuelve la IP interna del proxy (10.x.x.x), así que ninguna IP pública
|
||||
// autorizada coincidía nunca y toda llave con restricción de IP daba 403.
|
||||
//
|
||||
// Sin Cloudflare se toma la ÚLTIMA entrada de X-Forwarded-For, no la primera:
|
||||
// con un solo proxy adelante, esa es la que escribió el proxy y no la puede
|
||||
// falsear quien llama. Si el cliente manda su propio X-Forwarded-For, el proxy
|
||||
// le agrega la IP real al final — quedarse con la primera sería creerle al que
|
||||
// llama.
|
||||
//
|
||||
// ponytail: esto vale mientras todo el tráfico entre por el proxy (y por
|
||||
// Cloudflare, si está). Quien pueda pegarle al origen directo puede falsear
|
||||
// ambos encabezados; si eso pasa a importar, hay que cerrar el origen a las
|
||||
// redes del proxy en vez de complicar esta función.
|
||||
func IPDelCliente(c *fiber.Ctx) string {
|
||||
// Cloudflare adelante: el proxy del hosting ve la IP del edge de CF, así
|
||||
// que la última entrada de X-Forwarded-For es 104.x.x.x y no el que llama.
|
||||
// CF-Connecting-IP la escribe Cloudflare con la IP real y sobrescribe la
|
||||
// que mande el cliente, así que cuando está es la buena.
|
||||
if cf := strings.TrimSpace(c.Get("CF-Connecting-IP")); cf != "" {
|
||||
return cf
|
||||
}
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
partes := strings.Split(xff, ",")
|
||||
if ip := strings.TrimSpace(partes[len(partes)-1]); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
if real := strings.TrimSpace(c.Get("X-Real-Ip")); real != "" {
|
||||
return real
|
||||
}
|
||||
return c.IP()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestIPDelCliente(t *testing.T) {
|
||||
casos := []struct {
|
||||
nombre string
|
||||
xff string
|
||||
real string
|
||||
cf string
|
||||
want string
|
||||
}{
|
||||
// Con Cloudflare adelante, la última entrada del XFF es el edge de CF
|
||||
// (104.x.x.x): la IP real solo está en CF-Connecting-IP.
|
||||
{"detrás de cloudflare", "46.202.93.92, 104.22.14.222", "", "46.202.93.92", "46.202.93.92"},
|
||||
{"un solo proxy", "46.202.93.92", "", "", "46.202.93.92"},
|
||||
// Si quien llama manda su propio X-Forwarded-For, el proxy le agrega la
|
||||
// IP real al final. Quedarse con la primera sería dejar que elija su IP.
|
||||
{"cliente intentando falsear", "1.2.3.4, 46.202.93.92", "", "", "46.202.93.92"},
|
||||
{"con espacios", " 10.0.0.1 , 46.202.93.92 ", "", "", "46.202.93.92"},
|
||||
{"sin xff, con x-real-ip", "", "46.202.93.92", "", "46.202.93.92"},
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/ip", func(c *fiber.Ctx) error {
|
||||
return c.SendString(IPDelCliente(c))
|
||||
})
|
||||
|
||||
for _, cs := range casos {
|
||||
req := httptest.NewRequest("GET", "/ip", nil)
|
||||
if cs.xff != "" {
|
||||
req.Header.Set("X-Forwarded-For", cs.xff)
|
||||
}
|
||||
if cs.real != "" {
|
||||
req.Header.Set("X-Real-Ip", cs.real)
|
||||
}
|
||||
if cs.cf != "" {
|
||||
req.Header.Set("CF-Connecting-IP", cs.cf)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", cs.nombre, err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
if got := string(buf[:n]); got != cs.want {
|
||||
t.Errorf("%s: IPDelCliente = %q, want %q", cs.nombre, got, cs.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,10 @@ func AdminApiRoutes(api fiber.Router) {
|
||||
vcard.Post("/qr-url", apiControllers.CreateUrlQr)
|
||||
vcard.Post("/vcf", apiControllers.CreateVcf)
|
||||
|
||||
// Generación de texto con la configuración de IA del panel. La integración
|
||||
// manda el prompt y recibe el texto: la clave del proveedor no sale de acá.
|
||||
vcard.Post("/ia", apiControllers.GenerarTextoIA)
|
||||
|
||||
// dLocal y Rapyd bajo el mismo scope: son las operaciones de cobro que
|
||||
// acompañan a la VCard, y quien integra una necesita la otra.
|
||||
vcard.Post("/dlocal/planes", apiControllers.CreatePlan)
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestRutasVcardRegistradas(t *testing.T) {
|
||||
"POST /v2/vcard/qr",
|
||||
"POST /v2/vcard/qr-url",
|
||||
"POST /v2/vcard/vcf",
|
||||
"POST /v2/vcard/ia",
|
||||
"POST /v2/vcard/dlocal/planes",
|
||||
"GET /v2/vcard/dlocal/planes",
|
||||
"GET /v2/vcard/dlocal/planes/:planID",
|
||||
|
||||
@@ -86,6 +86,7 @@ func RenovacionesRoutes(protected fiber.Router) {
|
||||
// ─── Automatización IA: Plantillas de documento ────────────────────
|
||||
protected.Get("/automatizacion/plantillas", middlewares.MenuMiddleware, controllers.PlantillasDocumentoView)
|
||||
protected.Post("/api/plantillas-documento/importar", controllers.ImportarPlantillaDocumento)
|
||||
protected.Post("/api/plantillas-documento/previsualizar", controllers.PrevisualizarPlantillaDocumento)
|
||||
protected.Get("/api/plantillas-documento", controllers.GetPlantillasDocumento)
|
||||
protected.Get("/api/plantillas-documento/:id", controllers.GetPlantillaDocumento)
|
||||
protected.Post("/api/plantillas-documento", controllers.CreatePlantillaDocumento)
|
||||
|
||||
@@ -35,10 +35,16 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
|
||||
g.Get("/umind/agentes", r(controllers.GetUmindAgentesHandler)...)
|
||||
g.Post("/umind/agentes", w(controllers.CreateUmindAgenteHandler)...)
|
||||
g.Put("/umind/agentes/:id", w(controllers.UpdateUmindAgenteHandler)...)
|
||||
g.Post("/umind/agentes/:id/duplicar", w(controllers.DuplicarUmindAgenteHandler)...)
|
||||
g.Delete("/umind/agentes/:id", w(controllers.DeleteUmindAgenteHandler)...)
|
||||
|
||||
g.Get("/umind/plantillas-rubro", r(controllers.GetPlantillasRubroHandler)...)
|
||||
g.Get("/umind/documentos", r(controllers.GetUmindDocumentosHandler)...)
|
||||
g.Post("/umind/documentos", w(controllers.CreateUmindDocumentoHandler)...)
|
||||
g.Post("/umind/documentos/texto", w(controllers.CreateUmindTextoHandler)...)
|
||||
g.Post("/umind/documentos/archivo", w(controllers.CreateUmindArchivoHandler)...)
|
||||
g.Put("/umind/documentos/:id", w(controllers.ActualizarUmindDocumentoHandler)...)
|
||||
g.Post("/umind/documentos/:id/reprocesar", w(controllers.ReprocesarUmindDocumentoHandler)...)
|
||||
g.Delete("/umind/documentos/:id", w(controllers.DeleteUmindDocumentoHandler)...)
|
||||
|
||||
g.Get("/umind/sesiones", r(controllers.GetUmindSesionesHandler)...)
|
||||
|
||||
@@ -346,6 +346,7 @@ func UserRoutes(app fiber.Router) {
|
||||
// dar acceso administrativo — solo un administrador puede administrarlas.
|
||||
protected.Get("/api-keys", middlewares.MenuMiddleware, controllers.ApiKeysIndex)
|
||||
protected.Get("/api-keys/list", controllers.GetApiKeys)
|
||||
protected.Get("/api-keys/scopes", controllers.ScopesDisponibles)
|
||||
protected.Post("/api-keys", middlewares.SoloAdmin, controllers.CreateApiKeyHandler)
|
||||
protected.Put("/api-keys/:id", middlewares.SoloAdmin, controllers.UpdateApiKeyHandler)
|
||||
protected.Delete("/api-keys/:id", middlewares.SoloAdmin, controllers.DeleteApiKeyHandler)
|
||||
@@ -530,6 +531,7 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Put("/tickets/:ticketID/estado", controllers.UpdateTicketEstadoAdmin)
|
||||
protected.Post("/tickets/:ticketID/mensaje", controllers.AdminResponderTicket)
|
||||
protected.Put("/tickets/:ticketID/asignar", controllers.AsignarTicket)
|
||||
protected.Post("/tickets/:ticketID/borrador", controllers.BorradorRespuestaTicket)
|
||||
protected.Get("/tickets/admins", controllers.GetSoporteAdmins)
|
||||
|
||||
// Configuración webhook de soporte
|
||||
@@ -538,6 +540,8 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Post("/soporte/webhook", controllers.SaveSoporteWebhookConfig)
|
||||
protected.Post("/soporte/webhook/probar-imap", controllers.ProbarImapSoporte)
|
||||
protected.Post("/soporte/webhook/revisar-buzon", controllers.RevisarBuzonAhora)
|
||||
protected.Get("/soporte/agentes", controllers.GetAgentesParaBorrador)
|
||||
protected.Post("/soporte/webhook/probar-envio", controllers.ProbarEnvioSoporte)
|
||||
|
||||
// Configuración de notificaciones
|
||||
protected.Get("/notif-config", middlewares.MenuMiddleware, controllers.NotifConfigIndex)
|
||||
|
||||
Reference in New Issue
Block a user