Compare commits

..
25 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 195590f5e4 fix(umind): faltaba el botón para editar las notas del conocimiento
Las plantillas de rubro dejan las notas con valores entre corchetes y la propia
pantalla dice "abrilas y reemplazalas por tus datos" — pero no había con qué.
El endpoint para editarlas existía desde que se agregaron las fuentes de texto;
lo que nunca se agregó fue el botón, así que el flujo que el producto promete
no se podía completar. Se elegía la plantilla de restaurante y quedaban cuatro
notas diciendo "[12:00]" sin forma de arreglarlas salvo borrarlas y escribirlas
de nuevo.

Ahora cada nota tiene "Editar": abre el contenido, se corrige y al guardar se
rehacen los fragmentos, así que el agente pasa a contestar con lo nuevo en el
momento.

También se puede editar el texto que se le extrajo a un archivo: si el OCR de
un PDF salió torcido, corregirlo a mano es tan válido como escribir la nota, y
es el mismo código. Las URLs no — su contenido se rehace crawleando y cualquier
corrección se perdería en la próxima actualización.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:03:58 -05:00
Lizandro GuarnizoandClaude Opus 5 3ceb61c035 feat(umind): Umi, la mascota, y un solo vocabulario en toda la interfaz
La mascota. Umi sale del logo, no es un dibujo aparte: su cuerpo es la "u" del
monograma y el punto del logo pasa a ser su antena, así que símbolo y personaje
son la misma forma vista dos veces. Por eso puede estar al lado del logo sin
competirle.

Tiene estados, y no son decoración: cada pantalla vacía dice algo distinto y la
cara lo dice antes que el texto. Dormida cuando todavía no pasó nada, buscando
cuando falta cargarle información, contenta cuando no hay errores, alerta
cuando algo se rompió. Los ojos toman el color de la superficie de atrás en vez
de ser blancos, así se apoya sobre cualquier tarjeta y en modo oscuro no quedan
dos puntos flotando.

Las pantallas vacías. Había una buena y ocho que decían "Sin canales
configurados." y nada más — el usuario quedaba resolviendo solo qué significa
eso y qué hacer. Ahora las ocho explican qué va ahí y por qué importa: "No está
atendiendo en ningún lado", "Todavía no sabe nada de tu negocio".

El vocabulario. La misma cosa tenía dos nombres según la pantalla: tenant y
espacio, tool y herramienta. Un producto que se llama distinto a sí mismo en
cada lugar se siente como varios productos pegados. Queda: espacio, agente,
herramienta, fuente. Y "al AI" pasa a "a la IA", que además es el género que ya
usaba el resto del panel.

Y la barra de scroll de las pestañas, que se veía gris y gruesa sobre el
contenido: en escritorio las siete entran holgadas y no hacía falta, en móvil
siguen deslizándose pero sin la barra encima.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:19:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5086087e1b feat(umind): duplicar un agente para usarlo como plantilla del siguiente
El catálogo de rubros da un arranque genérico, pero el mejor punto de partida
para el segundo restaurante es el primero — el que ya tiene el conocimiento
real, el tono ajustado y las herramientas andando. Ahora se puede copiar: se
lleva conocimiento, tono, bienvenida, color, config de IA y herramientas.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:28:15 -05:00
Lizandro GuarnizoandClaude Opus 5 af71b66bb8 feat(umind): logo propio, plantillas de agente por rubro y página promocional
Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y
esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con
el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro,
que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal,
el header, el sidebar del Studio y la promo, y va también de favicon.

Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el
conocimiento desde cero frente a un campo en blanco es donde la mayoría
abandona. Al crear uno se puede elegir un punto de partida —tienda,
restaurante, consultorio, servicios, peluquería o la base genérica— y arranca
con sus notas ya escritas, listas para editar.

Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay
que reemplazarlos: una nota que parezca dato real termina en boca del asistente.
Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite —
el asistente no da consejo médico ni interpreta síntomas — porque es el único
rubro donde una respuesta inventada hace daño de verdad.

El catálogo vive en código, no en base: es contenido editorial que mejoramos
nosotros, no configuración que cada instalación toca por su lado. Las notas se
cargan en segundo plano porque cada una necesita sus embeddings, y hacer
esperar el alta sería castigar justo al que eligió plantilla.

Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de
guardar quedaba abajo del borde: el modal ahora tiene su propio scroll.

Y la página promocional de uMind queda servida en /umind.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:21:46 -05:00
Lizandro GuarnizoandClaude Opus 5 3ea17b0980 feat(umind): cargar conocimiento de tres formas y que deje de quedar viejo en silencio
Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:

Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.

Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.

Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.

De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:45:41 -05:00
Lizandro GuarnizoandClaude Opus 5 3ee83c8534 fix(studio): el cliente sin uMind entraba a un callejón sin salida
Si un cliente llegaba a /portal/studio sin ningún espacio asignado —por la URL
directa o un enlace viejo— la pantalla le decía "elegí tu espacio de la
izquierda" y "adentro vas a poder crear tus agentes". La izquierda estaba
vacía y crear no puede.

Ahora ve qué es uMind, un botón para pedir que se lo activen y la vuelta al
portal. Es el mismo mensaje que ya muestra el dashboard cuando no lo tiene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:30:57 -05:00
Lizandro GuarnizoandClaude Opus 5 86cdb2d368 feat(plantillas): ver cómo queda la plantilla mientras se edita
Se editaba HTML a ciegas: había que guardar y generar un documento real para
saber si estaba bien. Ahora el modal tiene un panel de vista previa al lado del
editor, que se actualiza mientras se escribe y se abre solo al editar una
plantilla o al importar una con IA.

Se ejecuta la plantilla de verdad contra datos de ejemplo, no se reemplaza
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 en
vez de al generar el PDF. Los datos de ejemplo salen de DatosBaseDocumento,
igual que en producción, para que la vista previa no muestre una cosa y el
documento otra.

El iframe va en sandbox sin scripts ni same-origin: el HTML lo escribe un
admin, pero no tiene por qué correr con los permisos del panel.

De paso, la ayuda de variables ofrecía {{.Cliente.Nit}}, que no existe en el
modelo — el campo es Documento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:28:06 -05:00
Lizandro GuarnizoandClaude Opus 5 5f95e67595 fix(api-v2): que el fallo de /vcard/ia quede en el log y distinga de quién es el problema
La integración muestra solo "error code: 502" y el motivo no quedaba escrito en
ningún lado. Ahora se loguea.

Y el estado separa los dos casos: 503 si falta configurar la IA de este lado,
502 si el que falló fue el proveedor. Con el mismo código para ambos no se sabe
a quién mirar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:19:15 -05:00
Lizandro GuarnizoandClaude Opus 5 f5ffed7a13 fix(ai-config): editar cualquier config dejaba al agente sin cerebro
es_agente_bot no se podía marcar desde ninguna pantalla —el formulario nunca
mandaba el campo— y el update lo escribía igual con el valor cero. O sea que
guardar cualquier config desde /app/ai-config apagaba el cerebro del bot de
Telegram y del chat del panel, y no había forma de volver a prenderlo salvo
tocando la base.

- El update solo escribe los campos que vinieron en el body.
- El formulario tiene la casilla y el selector de bot de Telegram.
- Marcar una desmarca la anterior: GetAgenteBotAiConfig hace First(), así que
  con dos marcadas ganaba la que estuviera primero en la tabla.

Y el otro comportamiento raro: cuando ningún módulo coincidía, se usaba
"cualquier config activa". Eso podía elegir la de embeddings o la de Whisper,
que no conversan — el error que llegaba era del proveedor y no se parecía en
nada a la causa. Ahora esas quedan excluidas del comodín y, si no queda
ninguna usable, el error dice qué módulo asignar y dónde.

De paso: la etiqueta "IA / vCard" mentía (ese módulo alimenta además soporte,
el chat del panel y las plantillas), el comentario de is_active decía "solo uno
activo a la vez" cuando hace falta uno por módulo, y GetActiveAiConfig no la
usaba nadie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:16:15 -05:00
Lizandro GuarnizoandClaude Opus 5 6a3a8c9248 fix(api-keys): detrás de Cloudflare la IP que se comparaba era la del edge
El 403 reportaba ip_vista=104.22.14.222, que es un edge de Cloudflare: el
tráfico entra por CF y recién ahí por el proxy del hosting, así que la última
entrada de X-Forwarded-For es el edge y no quien llama.

Se usa CF-Connecting-IP cuando está: la escribe Cloudflare con la IP real y
sobrescribe la que mande el cliente. Sin Cloudflare sigue valiendo la última
entrada del XFF, como estaba.

Queda anotado que todo esto vale mientras el tráfico entre por el proxy: quien
pueda pegarle al origen directo puede falsear los dos encabezados, y eso se
cierra en la red, no en esta función.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:09:50 -05:00
Lizandro GuarnizoandClaude Opus 5 dcffab4311 feat(plantillas): poder elegir qué modelo usa "Leer con IA"
Convertir un documento entero en plantilla pide un modelo más capaz que el
resto de las tareas, pero salía por el módulo "IA / vCard", compartido con la
integración de vCard: subir el modelo ahí es subírselo a todo.

Ahora hay un módulo "Plantillas de documento" en /app/ai-config. Si alguna
config activa lo declara, la importación usa esa; si no, sigue saliendo por
"ia" exactamente como hasta ahora. Nadie tiene que configurar nada para que
siga funcionando.

La pantalla de plantillas aclara además que esto no usa un agente de uMind y
adónde ir a cambiar el modelo, que era la duda.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:08:17 -05:00
Lizandro GuarnizoandClaude Opus 5 c75a6d7deb fix(api-keys): la restricción por IP comparaba contra la IP del proxy, no la del cliente
c.IP() detrás del proxy del hosting devuelve la IP interna del contenedor que
reenvía (10.x.x.x). Contra eso, ninguna IP pública cargada en una llave podía
coincidir: toda API key con restricción de IP daba 403, que es exactamente lo
que le pasó a la integración de vCard.

Ahora se lee X-Forwarded-For, y se toma la ÚLTIMA entrada, no la primera: con
un proxy adelante esa es la que escribió el proxy. 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 cada uno declare su IP y la restricción no valdría
nada. El test fija ese caso.

Mismo arreglo en la autenticación de Pagos Externos, que comparaba igual.

El 403 ahora dice qué IP se vio y cuál está permitida: sin eso, del otro lado
se prueba a ciegas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:02:45 -05:00
Lizandro GuarnizoandClaude Opus 5 dda5898c0d fix(api-keys): no se podía asignar el scope vcard
La pantalla lo ofrecía y las rutas de /api/v2/vcard lo exigen, pero la lista de
scopes válidos del backend nunca lo incluyó: crear la llave devolvía "scope
inválido: vcard" y la integración de vCard no tenía forma de autenticarse.

La lista estaba escrita dos veces —una en Go y otra en el HTML— y se
desincronizaron. Ahora la vista la pide al backend, así que no puede volver a
ofrecer algo que el backend rechace, y un test fija que todo scope exigido por
las rutas se pueda asignar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:49:07 -05:00
Lizandro GuarnizoandClaude Opus 5 4135850e5b feat(api-v2): endpoint de generación de texto para la integración de VCard
El sistema de vCard necesitaba usar la IA configurada en /app/ai-config y el
único camino era /api/v1/ia/ollama-generate, que autentica con cookie de
sesión: incómodo para una llamada entre servidores y la razón de los 401.

Ahora hay POST /api/v2/vcard/ia, bajo el mismo scope y la misma API key
(Bearer + IP) que ya usa esa integración para los QR y los VCF. Manda prompt,
recibe texto.

La clave del proveedor no sale del servidor. La alternativa —devolverle la
configuración con la api_key adentro, como hace el Landing Generator— reparte
la credencial de OpenAI/Anthropic a cada aplicación que la quiera usar.

El módulo va fijo en "ia": si lo eligiera quien llama, una llave con scope
vcard podría usar la configuración del agente o la del Landing Generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:44:35 -05:00
Lizandro GuarnizoandClaude Opus 5 5afbd9e375 fix(soporte): aviso repetido en Telegram y el acuse al cliente que no salía
Telegram: los avisos se mandaban a todas las configuraciones activas sin mirar
a dónde apuntan. Con el mismo chat cargado en dos filas, cada aviso llegaba dos
veces. Ahora se manda una vez por destino (bot + chat); los chats distintos
siguen recibiendo todos.

Correo al remitente: el camino STARTTLS abría una conexión, hacía StartTLS,
autenticaba… y la descartaba para llamar a smtp.SendMail, que abre otra. La
primera quedaba colgada y el envío real salía por una conexión distinta, que
podía no estar autenticada. Reescrito: una sola conexión, asegurada según la
configuración, y cierre con QUIT.

Y como el acuse se manda en segundo plano, su error moría en el log. Ahora hay
un botón "Enviar correo de prueba" que usa exactamente el mismo camino y
devuelve el error del servidor en pantalla, y el último fallo del acuse real
aparece en el resultado de "Revisar buzón ahora".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:13:11 -05:00
Lizandro GuarnizoandClaude Opus 5 17ed57cb56 fix(vcard): decir por qué falla la subida a OSS, y no armar la clave con texto crudo del cliente
El sistema que consume /v2/vcard/qr-url solo recibía "Error subiendo archivo a
OSS": el motivo quedaba en nuestro log y del otro lado no había nada que hacer
con eso. Ahora la respuesta incluye el error real del proveedor —es una API
entre servidores y el que llama ya conoce la config de OSS que mandó— y el log
dice además qué clave se intentó subir.

La clave del objeto se armaba con el nombre tal como venía en el body. Un
nombre con "/" o con caracteres que OSS no acepta producía justamente ese
error genérico. Ahora se limpia.

El mismo nombre crudo se usaba para el archivo temporal (/tmp/<nombre>.webp),
o sea escritura de archivos en una ruta que elegía quien llamaba. Se fue
entero: los tres endpoints suben desde memoria con UploadFromReader, que
además saca el paso a disco y su limpieza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:04:34 -05:00
Lizandro GuarnizoandClaude Opus 5 4674413257 fix(soporte): el filtro se estaba comiendo correos nuevos, y nadie se enteraba
Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no.
Por eso el sistema parecía funcionar solo con las respuestas: los correos
nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin
acuse al cliente y sin más rastro que un renglón de log que nadie mira.

Dos cambios:

- El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un
  usuario del portal, siempre se abre el ticket. Para eso la resolución del
  remitente pasa a correr antes del filtro. A un cliente registrado no se le
  descarta el correo por lo que diga un modelo.

- Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el
  filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico
  de hace un rato, no algo que valga una tabla.

Además, si la respuesta automática está apagada, el log lo dice al crear el
ticket — era la otra explicación posible de "no me contestó" y no se distinguía.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:56:32 -05:00
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
En cada ticket, " Borrador" propone una respuesta usando los fragmentos más
parecidos de la base de conocimiento del agente de uMind que se elija en la
configuración de soporte, más el hilo de la conversación.

El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona
y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que
nadie lo lea, es la forma más rápida de perderlo.

Sin agente configurado el borrador se arma igual, solo con la conversación —
peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o
pasos que no estén en la documentación, y le pide decir qué falta preguntar
cuando no alcanza para resolver.

El cuadro de respuesta pasa a textarea: un borrador de varios renglones no
entraba en un input de una línea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:45:38 -05:00
Lizandro GuarnizoandClaude Opus 5 bfcc320b7c feat(soporte): el ticket de correo ya sabe de quién es, y llega con prioridad
Un ticket que entraba por correo guardaba la dirección como texto suelto: no se
podía cruzar con nada, el cliente no lo veía en su portal y no había forma de
pedir "todos los tickets de Acme". Ahora al crearlo se resuelve el remitente —
primero contra los usuarios del portal, si no contra el correo del cliente— y el
ticket queda atado a quien escribió. El que no matchea queda como contacto
externo, que también es información.

Solo coincidencia exacta de dirección, nunca por dominio: con gmail.com de por
medio, adivinar ata el ticket al cliente equivocado.

Y la llamada de IA que ya clasificaba el correo ahora devuelve también prioridad
y categoría en el mismo JSON: sin costo ni latencia extra. El campo prioridad
existía desde siempre y nadie lo llenaba. La urgencia la define el problema
descrito, no el tono del mensaje — está dicho en el prompt.

En la lista de tickets se ven los tres datos nuevos: cliente (o "externo"),
categoría y la prioridad que ya se mostraba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:43:28 -05:00
Lizandro GuarnizoandClaude Opus 5 6afad025c2 feat(soporte): leer solo los correos recientes, no todo el buzón
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer,
que en una casilla de años es una avalancha. Ahora hay un desplegable de
antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas.

El corte se hace en dos pasos porque IMAP no da para más: al servidor se le
pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el
corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el
SINCE sería perder los correos del borde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:39:00 -05:00
Lizandro GuarnizoandClaude Opus 5 d7c266f111 feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets
Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.

Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.

Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.

Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5875f9e36b fix(soporte): el buzón IMAP nunca se leía — la búsqueda pedía UIDs a una búsqueda por secuencia
Usé c.Search, que devuelve números de secuencia, y después le pedí los UIDs al
resultado. go-imap devuelve nil ahí, así que la lista salía siempre vacía: el
cron conectaba bien, buscaba bien, y se iba sin leer nada ni escribir un solo
error en el log. Va con UIDSearch.

Con eso se arreglan las dos cosas: no leía y no creaba tickets.

Además:
- Fetch con Peek: el correo queda marcado como leído solo si se llegó a
  procesar. Antes, un fallo a mitad de camino lo perdía para siempre.
- "Probar conexión" ahora informa cuántos mensajes hay en la carpeta y cuántos
  sin leer; "Revisar buzón ahora" informa cuántos encontró y cuántos convirtió
  en ticket, y avisa si la lectura está desactivada.
- Un correo sin asunto abre ticket como "(sin asunto)" en vez de descartarse en
  silencio, y los descartes quedan en el log.

Los tres tests nuevos corren contra un servidor IMAP en memoria: bajar los no
leídos, que marcar como leído los saque de la próxima corrida, y que bajarlos
sin procesarlos no los marque. El primero falla si se vuelve a poner Search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:20:27 -05:00
Lizandro GuarnizoandClaude Opus 5 d1bbb4a508 feat(soporte): avisar por Telegram cuando entra un correo, con resumen si es largo
Los tickets del portal ya avisaban por Telegram; los que entran por correo solo
mandaban un mail al admin, que es justo el canal que nadie mira a tiempo. Ahora
pasan por el mismo despachador de notificaciones, así que se prenden y apagan
desde /app/notif-config como cualquier otro evento — nuevo ticket y respuesta
del cliente por separado.

El cuerpo del aviso es un resumen cuando el correo pasa los 700 caracteres
(hilos reenviados, texto pegado); abajo de eso va tal cual, porque resumir tres
renglones es gastar una llamada de IA para decir lo mismo. Si la IA falla, se
manda recortado. El ticket guarda siempre el texto completo.

DispatchTicketNuevo ahora acepta portalUser nil: un correo no tiene usuario de
portal detrás y no por eso hay que dejar de avisar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:12:36 -05:00
Lizandro GuarnizoandClaude Opus 5 ac47769c27 fix(plantillas): "Leer con IA" fallaba con la config de IA que ya está en producción
Escribí una segunda implementación de "llamar al proveedor" en vez de usar la
que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic
—se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base
vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que
ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en
el 502.

Ahora usa callAI, que despacha por proveedor y completa la URL base. El error
del proveedor queda en el log del servidor y el mensaje llega entero a la vista;
si la respuesta no trae mensaje, la vista muestra el status para distinguir un
fallo de la app de uno del proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:07:03 -05:00
Lizandro GuarnizoandClaude Opus 5 c4b0305112 feat(umind): los agentes también leen archivos, no solo audios e imágenes
Faltaba la tercera pata: audio pasaba por Whisper, imagen por OCR, y un PDF o
un Word adjunto se ignoraba en silencio. Ahora hay un interruptor por canal
—"Leer archivos adjuntos"— y los documentos que llegan por Telegram o WhatsApp
se convierten a texto antes de pasar al agente.

PDF se lee con stdlib cuando el documento es digital (facturas, cotizaciones,
lo exportado por cualquier programa) y cae al OCR si es un escaneo. Word .docx
es un zip con XML adentro; texto plano, CSV y JSON van directo.

El agente recibe el contenido con contexto ("el cliente adjuntó X, y escribió
Y"), no el chorizo pelado: sin eso el modelo contesta como si el cliente
hubiera tipeado una factura.

De paso la importación de plantillas gana PDF, que usa el mismo extractor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:50:59 -05:00
80 changed files with 5179 additions and 564 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -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 {
+34
View File
@@ -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)
}
}
+1
View File
@@ -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,
+15 -9
View File
@@ -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>
+13
View File
@@ -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;
}
}
+304 -40
View File
@@ -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>
+25 -1
View File
@@ -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
+66 -5
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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)
}
}
}
+36 -14
View File
@@ -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
}
+13
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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 {
+3
View File
@@ -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" }
+193
View File
@@ -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("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").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()
}
+68
View File
@@ -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)
}
}
}
+7
View File
@@ -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
View File
@@ -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
}
+16 -1
View File
@@ -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{
+18 -68
View File
@@ -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("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").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
}
-3
View File
@@ -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")
}
+67
View File
@@ -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)
}
+68
View File
@@ -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)
}
}
}
}
+77
View File
@@ -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]
}
+71
View File
@@ -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)
}
}
+166
View File
@@ -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")
}
}
+183 -38
View File
@@ -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
}
+152 -8
View File
@@ -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(&paraNotificar)
return
}
DispatchTicketNuevo(&paraNotificar, 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
}
}
+12
View File
@@ -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
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+86 -17
View File
@@ -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)
}
}
+38
View File
@@ -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])
}
}
+300
View File
@@ -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)
}
}
}
+7
View File
@@ -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

+7
View File
@@ -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 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+35 -5
View File
@@ -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) })
+11 -2
View File
@@ -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 };
},
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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">
+102
View File
@@ -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'); },
};
+28 -4
View File
@@ -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;
+26 -9
View File
@@ -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()})
}
+52
View File
@@ -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})
}
+28
View File
@@ -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
}
+26
View File
@@ -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
}
+21 -38
View File
@@ -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,
})
}
+8 -17
View File
@@ -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.
+16
View File
@@ -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 {
+130 -48
View File
@@ -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})
}
+244 -8
View File
@@ -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.",
})
}
+1 -1
View File
@@ -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"})
}
+9 -3
View File
@@ -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()
}
+43
View File
@@ -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()
}
+55
View File
@@ -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)
}
}
}
+4
View File
@@ -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)
+1
View File
@@ -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",
+1
View File
@@ -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)
+6
View File
@@ -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)...)
+4
View File
@@ -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)