El widget hacía `data.respuesta || data.message` sin mirar el status, así que un 403/404/500 se pintaba en una burbuja como si lo hubiera dicho el agente. Imposible distinguir "el bot contestó raro" de "el widget está siendo rechazado", que es justo el lazo en el que se puede quedar alguien diagnosticando esto. Además los rechazos del middleware ocurren ANTES del motor, así que no dejaban rastro en ningún lado: ni en la auditoría del agente ni en el navegador. De ahí el síntoma "el chat de prueba anda pero el widget no". - El widget mira r.ok, manda el motivo real a la consola y al visitante le muestra un mensaje neutro. - AuthUmindWidget distingue las tres causas que el chat de prueba NO tiene (agente inactivo, tenant inactivo, dominio no autorizado) y las registra en la auditoría del agente con el detalle accionable — incluyendo qué dominio llamó y cuáles están permitidos. - GetUmindAgentePorSiteKey busca sin filtrar por activo, para poder decir "está apagado" en vez de "no existe". - Test del allowlist de dominios: es fail-closed y www.ejemplo.com NO matchea ejemplo.com, la causa más probable de este síntoma. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
12 KiB
Go
273 lines
12 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; }
|
|
var apiBase = script.src.replace(/\/widget\/umind\.js.*$/, '') + '/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-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;color:#aaa;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
|
|
].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 · uMind</span>' +
|
|
'<button class="umind-close" id="umind-close" aria-label="Cerrar">×</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">✦ Impulsado por uMind</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);
|
|
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;
|
|
|
|
function agregarMensaje(texto, quien) {
|
|
var burbuja = document.createElement('div');
|
|
burbuja.textContent = 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(); });
|
|
})();
|
|
`
|