Files
soft_usite/rest/controllers/api/umind_widget_controller.go
Lizandro GuarnizoandClaude Sonnet 5 8ef49c5169 feat: bucles de crecimiento y activación de uMind
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.

1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
   estaba dando exposición en su sitio y no se capitalizaba. El host sale
   de la URL del propio script, así funciona igual en cualquier entorno.

2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
   las abrió el visitante, y el consumo desglosado. Es lo que el cliente
   necesita para justificar el gasto puertas adentro — un panel al que hay
   que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
   generador de xlsx del cronograma.

3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
   agente sin fuentes responde de memoria e inventa datos, el cliente
   concluye que el producto no sirve y se va. Es el punto donde más gente
   se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
   auditoría como registro de envío para no necesitar tabla nueva ni
   convertir el recordatorio en spam.

4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
   tiene, oferta si no. Es el punto de contacto más barato que hay — ya
   entró, ya confía, y el cobro se suma a la factura que ya recibe. El
   mensaje lidera con notas de voz y fotos, que es lo más difícil de
   copiar de lo que tenemos.

La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:40:28 -05:00

308 lines
14 KiB
Go

package controllers
import (
"crypto/rand"
"encoding/hex"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
func agenteDeContexto(c *fiber.Ctx) (*models.UmindAgente, error) {
agente, ok := c.Locals("umind_agente").(*models.UmindAgente)
if !ok || agente == nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
}
return agente, nil
}
func tenantDeContexto(c *fiber.Ctx) (*models.UmindTenant, error) {
tenant, ok := c.Locals("umind_tenant").(*models.UmindTenant)
if !ok || tenant == nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
}
return tenant, nil
}
func nuevaSessionID() string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// UmindWidgetInit devuelve una sesión nueva y el mensaje de bienvenida del
// agente, sin gastar una llamada al LLM solo para saludar. El nombre que se
// muestra es el del negocio (tenant dueño del agente), no la etiqueta
// interna del agente.
// Ruta: GET /widget/:site_key/init
func UmindWidgetInit(c *fiber.Ctx) error {
agente, err := agenteDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
tenant, err := tenantDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
bienvenida := strings.TrimSpace(agente.MensajeBienvenida)
if bienvenida == "" {
bienvenida = "¡Hola! ¿En qué puedo ayudarte?"
}
color := strings.TrimSpace(agente.Color)
if color == "" {
color = "#8eb02f"
}
return c.JSON(fiber.Map{
"session_id": nuevaSessionID(),
"nombre": tenant.Nombre,
"mensaje_bienvenida": bienvenida,
"color": color,
})
}
// UmindWidgetMensaje procesa un mensaje del visitante y devuelve la respuesta del agente.
// Ruta: POST /widget/:site_key/mensaje
func UmindWidgetMensaje(c *fiber.Ctx) error {
agente, err := agenteDeContexto(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
}
var req struct {
SessionID string `json:"session_id"`
Mensaje string `json:"mensaje"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "body inválido"})
}
if strings.TrimSpace(req.Mensaje) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "mensaje requerido"})
}
sessionID := strings.TrimSpace(req.SessionID)
if sessionID == "" {
sessionID = nuevaSessionID()
}
// Límite generoso pero real: evita que alguien mande un mensaje gigante al LLM.
if len(req.Mensaje) > 4000 {
req.Mensaje = req.Mensaje[:4000]
}
respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": true, "message": err.Error()})
}
return c.JSON(fiber.Map{
"session_id": sessionID,
"respuesta": respuesta,
})
}
// UmindWidgetScript sirve el script embebible que renderiza la burbuja de
// chat. Se instala con: <script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script>
// Ruta: GET /widget/umind.js
func UmindWidgetScript(c *fiber.Ctx) error {
c.Set("Content-Type", "application/javascript; charset=utf-8")
c.Set("Cache-Control", "public, max-age=300")
return c.SendString(umindWidgetJS)
}
const umindWidgetJS = `(function () {
var script = document.currentScript;
var siteKey = script.getAttribute('data-site');
if (!siteKey) { console.error('[uMind] falta data-site en el <script>'); return; }
// El origen sale de la URL del propio script: así el badge apunta al mismo
// host que sirve el widget, sin tener que inyectar la URL desde el backend.
var origen = script.src.replace(/\/widget\/umind\.js.*$/, '');
var apiBase = origen + '/widget/' + siteKey;
var storageKey = 'umind_session_' + siteKey;
var color = '#8eb02f';
var style = document.createElement('style');
style.textContent = [
'.umind-ring{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;z-index:999998;pointer-events:none;animation:umind-pulse 2.4s ease-out 3;}',
'@keyframes umind-pulse{0%{box-shadow:0 0 0 0 var(--umind-color,#8eb02f);}100%{box-shadow:0 0 0 24px transparent;}}',
'.umind-bubble{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.22);z-index:999999;border:none;transition:transform .15s ease;padding:0;}',
'.umind-bubble:hover{transform:scale(1.06);}',
'.umind-bubble svg{width:26px;height:26px;display:block;}',
'.umind-panel{position:fixed;bottom:92px;right:20px;width:356px;max-width:92vw;height:480px;max-height:72vh;background:#fff;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,.22);z-index:999999;display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;opacity:0;transform:translateY(14px) scale(.97);pointer-events:none;transition:opacity .18s ease,transform .18s ease;}',
'.umind-panel.umind-abierto{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}',
'.umind-header{padding:16px 40px 16px 16px;color:#fff;position:relative;flex-shrink:0;}',
'.umind-header b{display:block;font-size:14.5px;font-weight:600;}',
'.umind-header span{display:block;font-size:11px;opacity:.85;margin-top:2px;}',
'.umind-close{position:absolute;top:12px;right:12px;background:rgba(255,255,255,.22);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:15px;line-height:1;}',
'.umind-msgs{flex:1;overflow-y:auto;padding:14px;font-size:13px;line-height:1.45;background:#fafafa;}',
'.umind-msg{margin-bottom:10px;padding:9px 12px;border-radius:13px;max-width:82%;white-space:pre-wrap;}',
'.umind-msg-bot{background:#fff;color:#222;border:1px solid #ececec;border-bottom-left-radius:4px;}',
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
'.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}',
'.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}',
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
'.umind-inputbar button svg{width:16px;height:16px;}',
'.umind-badge{text-align:center;font-size:10.5px;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
'.umind-badge a{color:#aaa;text-decoration:none;transition:color .15s;}',
'.umind-badge a:hover{color:#666;}',
].join('');
document.head.appendChild(style);
var sparkSVG = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M12 2.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6z" fill="#fff"/>' +
'<path d="M19 15c.3 1.8 1.1 2.7 2.9 3-1.8.3-2.6 1.2-2.9 3-.3-1.8-1.1-2.7-2.9-3 1.8-.3 2.6-1.2 2.9-3z" fill="#fff" opacity=".85"/>' +
'</svg>';
var sendSVG = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V5M12 5l-6 6M12 5l6 6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var ring = document.createElement('div');
ring.className = 'umind-ring';
document.body.appendChild(ring);
var bubble = document.createElement('button');
bubble.className = 'umind-bubble';
bubble.setAttribute('aria-label', 'Abrir chat');
bubble.innerHTML = sparkSVG;
document.body.appendChild(bubble);
var panel = document.createElement('div');
panel.className = 'umind-panel';
panel.innerHTML =
'<div class="umind-header" id="umind-header">' +
'<b id="umind-title">Asistente</b><span>Asistente con IA &middot; uMind</span>' +
'<button class="umind-close" id="umind-close" aria-label="Cerrar">&times;</button>' +
'</div>' +
'<div class="umind-msgs" id="umind-msgs"></div>' +
'<div class="umind-inputbar">' +
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." />' +
'<button id="umind-send" aria-label="Enviar"></button>' +
'</div>' +
'<div class="umind-badge">' +
'<a href="' + origen + '/?utm_source=widget&utm_medium=badge&utm_campaign=umind" ' +
'target="_blank" rel="noopener">&#10022; Impulsado por uMind</a>' +
'</div>';
document.body.appendChild(panel);
panel.querySelector('#umind-send').innerHTML = sendSVG;
function aplicarColor(c) {
if (!c) return;
color = c;
ring.style.setProperty('--umind-color', color);
// También en el panel, que es el ancestro de las burbujas: los enlaces
// dentro de un mensaje toman el color de marca desde esta variable.
panel.style.setProperty('--umind-color', color);
bubble.style.background = 'radial-gradient(circle at 30% 28%, rgba(255,255,255,.35), transparent 55%), ' + color;
panel.querySelector('#umind-header').style.background = color;
panel.querySelector('#umind-send').style.background = color;
}
aplicarColor(color);
var msgsEl = panel.querySelector('#umind-msgs');
var inputEl = panel.querySelector('#umind-input');
var sendEl = panel.querySelector('#umind-send');
var sessionId = null;
var abierto = false;
// El contenido del agente sale de páginas crawleadas, así que NUNCA se
// inserta como HTML. Se parte por URL y cada trozo se agrega con
// createTextNode / un <a> cuyo href se arma con la URL ya validada: el
// texto sigue siendo inerte y los enlaces quedan clickeables.
function pintarConEnlaces(el, texto) {
var partes = String(texto).split(/(https?:\/\/[^\s<>"')\]]+)/g);
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (!p) continue;
if (i % 2 === 1) {
var a = document.createElement('a');
a.href = p;
a.target = '_blank';
a.rel = 'noopener noreferrer nofollow';
a.textContent = p;
el.appendChild(a);
} else {
el.appendChild(document.createTextNode(p));
}
}
}
function agregarMensaje(texto, quien) {
var burbuja = document.createElement('div');
pintarConEnlaces(burbuja, texto == null ? '' : texto);
var esUser = quien === 'user';
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
if (esUser) burbuja.style.background = color;
msgsEl.appendChild(burbuja);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
// El servidor responde los errores como {"error":true,"message":"..."} con
// un status 4xx/5xx. Antes esto se leía igual que una respuesta buena y el
// texto del error terminaba en una burbuja, como si lo hubiera dicho el
// agente: imposible distinguir "el bot contestó raro" de "el widget está
// rechazado". Ahora el motivo real va a la consola y al visitante se le
// muestra un mensaje neutro.
function leerRespuesta(r) {
return r.json().catch(function () { return {}; }).then(function (data) {
if (!r.ok || data.error) {
var motivo = data.message || ('HTTP ' + r.status);
console.error('[uMind] el asistente no pudo responder:', motivo,
'— revisá la pestaña Auditoría del agente en uMind Studio.');
var e = new Error(motivo);
e.esDelServidor = true;
throw e;
}
return data;
});
}
function iniciar() {
sessionId = sessionStorage.getItem(storageKey) || null;
fetch(apiBase + '/init')
.then(leerRespuesta)
.then(function (data) {
if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre;
aplicarColor(data.color);
agregarMensaje(data.mensaje_bienvenida, 'bot');
})
.catch(function () { agregarMensaje('El asistente no está disponible en este momento.', 'bot'); });
}
iniciar(); // arranca ya, así el color/nombre reales están listos antes del primer click
function enviar() {
var texto = inputEl.value.trim();
if (!texto) return;
inputEl.value = '';
agregarMensaje(texto, 'user');
fetch(apiBase + '/mensaje', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, mensaje: texto })
})
.then(leerRespuesta)
.then(function (data) {
if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
agregarMensaje(data.respuesta || 'No pude responder eso.', 'bot');
})
.catch(function (e) {
agregarMensaje(
e && e.esDelServidor
? 'El asistente no está disponible en este momento.'
: 'Error de conexión, intenta de nuevo.',
'bot');
});
}
function alternar() {
abierto = !abierto;
panel.classList.toggle('umind-abierto', abierto);
if (abierto) inputEl.focus();
}
bubble.addEventListener('click', alternar);
panel.querySelector('#umind-close').addEventListener('click', alternar);
sendEl.addEventListener('click', enviar);
inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') enviar(); });
})();
`