This workflow follows the Agent β HTTP Request recipe pattern β see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide β
{
"updatedAt": "2026-03-30T22:18:19.769Z",
"createdAt": "2026-03-09T14:05:16.356Z",
"id": "sNeOUViiSYyROtea",
"name": "\ud83e\uddea LABR - nuevo asistente (REPARADO)",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "ycloud-lab-test-v2",
"options": {}
},
"id": "2daa57db-e4ab-4696-a96a-4b4cad12b8fc",
"name": "\ud83d\udce5 Webhook YCloud",
"type": "n8n-nodes-base.webhook",
"position": [
-528,
1200
],
"typeVersion": 2
},
{
"parameters": {
"respondWith": "text",
"responseBody": "OK",
"options": {}
},
"id": "e0703b6d-d536-4e7f-bd8a-6c0adf2969e4",
"name": "\u2705 Responder OK",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
-160,
976
],
"typeVersion": 1.4
},
{
"parameters": {
"jsCode": "// ======================================================\n// \ud83d\udee1\ufe0f FIX: Verificar que el nodo se ejecut\u00f3 antes de acceder\n// ======================================================\nlet cliente = {};\ntry {\n cliente = $('2. Obtener Cliente').first().json || {};\n} catch (e) {\n cliente = { id: null, telefono: '', nombre: 'Cliente' };\n}\n\n// ======================================================\n// \ud83e\udde0 CONTIN\u00daA CON EL RESTO DEL C\u00d3DIGO ORIGINAL\n// ======================================================\n\n// \ud83e\udde0 PRE-PROCESAMIENTO v15 - BLOQUEO DE ESCALADOS\n// =====================================================\n// \u00daltima actualizaci\u00f3n: 2026-02-14\n// NUEVO: Bloqueo autom\u00e1tico de clientes escalados\n// =====================================================\n\nconst rawJson = $input.item.json;\nconst body = rawJson.body || rawJson;\nlet whatsappMsg = body.whatsappInboundMessage;\n\nif (!whatsappMsg && body.body && body.body.whatsappInboundMessage) {\n whatsappMsg = body.body.whatsappInboundMessage;\n}\n\n// =====================================================\n// \ud83d\udee1\ufe0f DETECTAR MODO ADMIN/CLIENTE (MAURICIO)\n// =====================================================\nconst NUMEROS_DIRECTOR = ['573203062007', '3203062007'];\n\nconst fromRaw = whatsappMsg?.from || '';\nconst fromNormalizado = fromRaw.replace(/[\\+\\s-]/g, '');\nconst from = (whatsappMsg?.from || '').replace('+', '');\nconst to = (whatsappMsg?.to || '').replace('+', '');\nconst customerName = whatsappMsg?.customerProfile?.name || 'Cliente';\nconst messageType = whatsappMsg?.type || 'unknown';\nconst messageTextRaw = whatsappMsg?.text?.body || '';\nconst messageText = (NUMEROS_DIRECTOR.some(num => fromNormalizado === num || fromNormalizado.endsWith(num))) && (messageTextRaw.trim().startsWith('>')) ? messageTextRaw.trim().substring(1).trim() : messageTextRaw;\n\n// Saludo seg\u00fan hora (COLOMBIA UTC-5)\nconst now = new Date();\nconst horaCol = new Date(now.getTime() - 5 * 60 * 60 * 1000).getHours();\nlet saludo;\nif (horaCol < 12) saludo = 'Buenos d\u00edas';\nelse if (horaCol < 19) saludo = 'Buenas tardes';\nelse saludo = 'Buenas noches';\n\nconst esNumeroDirector = NUMEROS_DIRECTOR.some(num =>\n fromNormalizado === num || fromNormalizado.endsWith(num)\n);\n\n// =====================================================\n// \ud83d\udd04 PREFIJO: > para modo CLIENTE\n// =====================================================\nconst mensajeEmpiezaConPrefijo = messageTextRaw.trim().startsWith('>');\nconst mensajeSinPrefijo = mensajeEmpiezaConPrefijo\n ? messageTextRaw.trim().substring(1).trim()\n : messageTextRaw;\n\n// =====================================================\n// \ud83d\udeab NUEVO: BLOQUEO DE CLIENTES ESCALADOS\n// =====================================================\n// IMPORTANTE: Si el cliente est\u00e1 escalado, NO procesar el mensaje\n// El cliente debe esperar respuesta humana\nconst ESTADOS_ESCALADOS = ['ESCALADO', 'PENDIENTE_HUMANO', 'EN_REVISION_HUMANA'];\n\n// Variable para guardar si el mensaje fue bloqueado\nlet mensajeBloqueado = false;\n\n// =====================================================\n// DETECTAR RESPUESTAS DE BOTONES INTERACTIVOS\n// =====================================================\nlet esRespuestaBoton = false;\nlet botonId = '';\nlet botonTexto = '';\nif (messageType === 'interactive') {\n const interactive = whatsappMsg.interactive;\n if (interactive?.type === 'button_reply') {\n esRespuestaBoton = true;\n botonId = interactive.button_reply?.id || '';\n botonTexto = interactive.button_reply?.title || '';\n } else if (interactive?.type === 'list_reply') {\n esRespuestaBoton = true;\n botonId = interactive.list_reply?.id || '';\n botonTexto = interactive.list_reply?.title || '';\n }\n}\n\n// =====================================================\n// MANEJO DE IM\u00c1GENES Y MEDIA\n// =====================================================\nconst esReaccion = messageType === 'reaction';\nconst esImagen = messageType === 'image';\nconst esDocumento = messageType === 'document';\nconst esAudio = messageType === 'audio';\nconst esVideo = messageType === 'video';\nconst esSticker = messageType === 'sticker';\nconst esMedia = esImagen || esDocumento || esAudio || esVideo || esSticker;\n\n// =====================================================\n// MANEJO DE REACCIONES (ESTADOS)\n// =====================================================\nif (esReaccion) {\n const emoji = whatsappMsg.reaction?.emoji || '\u2764\ufe0f';\n return [{\n json: {\n esComandoCopiloto: false,\n mensajeCliente: `[Reacci\u00f3n: ${emoji}]`,\n from, to, customerName, saludo,\n esMediaNoSoportado: false,\n esMedia: false,\n esReaccion: true,\n emojiReaccion: emoji,\n tipoMensaje: 'reaction',\n messageText: `[El cliente reaccion\u00f3 con ${emoji}]`,\n esRespuestaBoton: false,\n botonId: '', botonTexto: '',\n productoIdDelBoton: null,\n accionBoton: '',\n esSoloSaludo: false,\n esPreguntaConversacional: false,\n debeHacerBusqueda: false,\n esPedidoPlataforma: false,\n infoPedidoPlataforma: null,\n tieneMultiplesProductos: false,\n terminosIndividuales: [],\n terminoBusqueda: '',\n terminoNormalizado: '',\n variantesBusqueda: [],\n terminosNormalizados: [],\n timestamp: whatsappMsg?.sendTime\n }\n }];\n}\n\n// Si es media, no importa si est\u00e1 escalado o no\nif (esMedia) {\n let textoMedia = '[Mensaje multimedia]';\n if (esImagen) textoMedia = '[\ud83d\udcf8 Imagen recibida]';\n else if (esDocumento) textoMedia = '[\ud83d\udcc4 Documento recibido]';\n else if (esAudio) textoMedia = '[\ud83c\udfb5 Audio recibido]';\n else if (esVideo) textoMedia = '[\ud83c\udfa5 Video recibido]';\n else if (esSticker) textoMedia = '[\ud83d\ude0a Sticker recibido]';\n \n return [{\n json: {\n esComandoCopiloto: false,\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n esMediaNoSoportado: false,\n esMedia: true,\n esImagen, esDocumento, esAudio, esVideo, esSticker,\n tipoMensaje: messageType,\n messageText: textoMedia,\n captionImagen: whatsappMsg?.image?.caption ||\n whatsappMsg?.document?.caption || '',\n esRespuestaBoton: false,\n botonId: '', botonTexto: '',\n productoIdDelBoton: null,\n accionBoton: '',\n esSoloSaludo: false,\n esPreguntaConversacional: false,\n debeHacerBusqueda: false,\n esPedidoPlataforma: false,\n infoPedidoPlataforma: null,\n tieneMultiplesProductos: false,\n terminosIndividuales: [],\n terminoBusqueda: '',\n terminoNormalizado: '',\n variantesBusqueda: [],\n terminosNormalizados: [],\n timestamp: whatsappMsg?.sendTime\n }\n }];\n}\n\n// =====================================================\n// MANEJO DE RESPUESTAS DE BOTONES\n// =====================================================\nif (esRespuestaBoton) {\n let productoIdDelBoton = null;\n let accionBoton = 'otro';\n \n if (botonId.startsWith('agregar_producto_')) {\n productoIdDelBoton = parseInt(botonId.replace('agregar_producto_', ''));\n accionBoton = 'agregar';\n } else if (botonId.startsWith('ver_mas_producto_')) {\n productoIdDelBoton = parseInt(botonId.replace('ver_mas_producto_', ''));\n accionBoton = 'ver_mas';\n } else if (botonId === 'ver_carrito') {\n accionBoton = 'ver_carrito';\n } else if (botonId === 'completar_pedido') {\n accionBoton = 'completar_pedido';\n } else if (botonId === 'cancelar_pedido') {\n accionBoton = 'cancelar_pedido';\n }\n \n return [{\n json: {\n esComandoCopiloto: false,\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n esMediaNoSoportado: false,\n esMedia: false,\n tipoMensaje: messageType,\n messageText: botonTexto,\n esRespuestaBoton: true,\n botonId, botonTexto,\n productoIdDelBoton,\n accionBoton,\n esSoloSaludo: false,\n esPreguntaConversacional: false,\n debeHacerBusqueda: false,\n esPedidoPlataforma: false,\n infoPedidoPlataforma: null,\n tieneMultiplesProductos: false,\n terminosIndividuales: [],\n terminoBusqueda: '',\n terminoNormalizado: '',\n variantesBusqueda: [],\n terminosNormalizados: [],\n timestamp: whatsappMsg?.sendTime\n }\n }];\n}\n\n// =====================================================\n// VALIDAR MENSAJE DE TEXTO\n// =====================================================\nif (!whatsappMsg || messageType !== 'text') {\n return [{\n json: {\n esComandoCopiloto: false,\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n esMediaNoSoportado: true,\n esMedia: false,\n tipoMensaje: messageType,\n messageText: '',\n esRespuestaBoton: false,\n botonId: '', botonTexto: '',\n productoIdDelBoton: null,\n accionBoton: '',\n esSoloSaludo: false,\n esPreguntaConversacional: false,\n debeHacerBusqueda: false,\n esPedidoPlataforma: false,\n infoPedidoPlataforma: null,\n tieneMultiplesProductos: false,\n terminosIndividuales: [],\n terminoBusqueda: '',\n terminoNormalizado: '',\n variantesBusqueda: [],\n terminosNormalizados: [],\n timestamp: whatsappMsg?.sendTime\n }\n }];\n}\n\n// =====================================================\n// \ud83d\udeab NUEVO: VERIFICAR SI EST\u00c1 ESCALADO\n// =====================================================\n// Obtener estado del cliente (si viene del input) - CON SEGURIDAD\nlet clienteInput = {};\ntry {\n clienteInput = $('2. Obtener Cliente').first().json || {};\n} catch (e) {\n clienteInput = {};\n}\n\n// Verificar si el cliente est\u00e1 escalado\nif (clienteInput && clienteInput.estado_conversacion && \n ESTADOS_ESCALADOS.includes(clienteInput.estado_conversacion)) {\n \n console.log('\ud83d\udeab Cliente escalado detectado. Bloqueando respuesta autom\u00e1tica.');\n console.log(`\ud83d\udcde Cliente: ${from}`);\n console.log(`\ud83d\udcca Estado: ${clienteInput.estado_conversacion}`);\n console.log(`\ud83d\udcdd Mensaje: ${messageTextRaw}`);\n \n // \u2705 RETORNAR MENSAJE DE ESPERA (NO PROCESAR M\u00c1S)\n mensajeBloqueado = true;\n \n return [{\n json: {\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n esComandoCopiloto: false,\n esMediaNoSoportado: false,\n esMedia: false,\n tipoMensaje: 'text',\n messageText: messageTextRaw,\n esRespuestaBoton: false,\n botonId: '', botonTexto: '',\n productoIdDelBoton: null,\n accionBoton: '',\n // Indicadores para el siguiente flujo\n esClienteEscalado: true,\n estado_conversacion: clienteInput.estado_conversacion,\n // Marcador para que el flujo sepa que est\u00e1 escalado\n bloquearRespuesta: true\n }\n }];\n}\n\n// =====================================================\n// SI ES DIRECTOR\n// =====================================================\nif (esNumeroDirector) {\n if (mensajeEmpiezaConPrefijo) {\n // Contin\u00faa como cliente normal\n } else {\n // MODO COPILOTO\n const normalizedText = messageTextRaw.replace(/[^\\d]/g, '');\n const phoneMatch = normalizedText.match(/(?:57)?(3\\d{9})/);\n const telefono_objetivo = phoneMatch ? phoneMatch[1].replace(/^57/, '') : null;\n \n let nombre_extraido = null;\n const nombrePatterns = [\n /(?:a|al nombre)\\s+[\"']?([A-Za-z\u00c1-\u00ff\\s]+?)[\"']?(?:\\s|$)/i,\n /(?:nombre es|llama)\\s+[\"']?([A-Za-z\u00c1-\u00ff\\s]+?)[\"']?(?:\\s+y|\\s*$)/i,\n ];\n for (const pattern of nombrePatterns) {\n const match = messageTextRaw.match(pattern);\n if (match && match[1]) {\n nombre_extraido = match[1].trim().replace(/\\b(del|cliente|el|la)\\b/gi, '').trim();\n if (nombre_extraido.length >= 2) break;\n else nombre_extraido = null;\n }\n }\n \n return [{\n json: {\n esComandoCopiloto: true,\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n messageText: messageTextRaw,\n prompt: messageTextRaw,\n mensajeClienteActual: messageTextRaw,\n telefono_objetivo: telefono_objetivo,\n nombre_extraido: nombre_extraido,\n esMediaNoSoportado: false,\n esMedia: false,\n tipoMensaje: messageType,\n timestamp: whatsappMsg?.sendTime\n }\n }];\n }\n}\n\n// =====================================================\n// RESTO DEL C\u00d3DIGO (CLIENTES NORMALES O DIRECTOR CON >)\n// =====================================================\n// messageText movido al inicio\n\n// Detectar saludos simples\nconst esSoloSaludo = [\n /^hola[,!\\.\\s]*$/i,\n /^buenos?\\s+(d[i\u00ed]as?|tardes?|noches?)[,!\\.\\s]*$/i,\n /^buenas[,!\\.\\s]*$/i,\n /^hey[,!\\.\\s]*$/i,\n /^qu[e\u00e9]\\s+tal[,!\\.\\s]*$/i,\n /^c[o\u00f3]mo\\s+est[a\u00e1](s|n)?[,!\\.\\s]*$/i,\n /^gracias[,!\\.\\s]*$/i,\n /^ok[,!\\.\\s]*$/i,\n /^s[i\u00ed][,!\\.\\s]*$/i,\n /^no[,!\\.\\s]*$/i,\n /^perfecto[,!\\.\\s]*$/i,\n /^listo[,!\\.\\s]*$/i,\n /^dale[,!\\.\\s]*$/i,\n /^bien[,!\\.\\s]*$/i\n].some(p => p.test(messageText));\n\n// Detectar pedido desde plataforma\nconst esPedidoPlataforma = [\n /acabo\\s+de\\s+hacer\\s+un\\s+pedido/i,\n /hice\\s+un\\s+pedido\\s+en\\s+(la\\s+)?tienda/i,\n /tus-aguacates\\.vercel\\.app/i,\n /mi\\s+pedido:.*\\n.*total/is\n].some(p => p.test(messageText));\n\nlet infoPedidoPlataforma = null;\nif (esPedidoPlataforma) {\n const nombreMatch = messageText.match(/me\\s+llamo\\s+(\\w+)/i);\n const pagoMatch = messageText.match(/pago:\\s*(.+?)(\\n|$)/i);\n const totalMatch = messageText.match(/total:\\s*\\$?([.\\d,]+)/i);\n infoPedidoPlataforma = {\n nombre: nombreMatch ? nombreMatch[1] : null,\n metodoPago: pagoMatch ? pagoMatch[1].trim() : null,\n total: totalMatch ? totalMatch[1] : null\n };\n}\n\n// Detectar preguntas conversacionales\nconst esPreguntaConversacional = [\n /c[o\u00f3]mo\\s+(veo|entro|accedo|visito|llego|abro)/i,\n /d[o\u00f3]nde\\s+(est[a\u00e1]|queda|encuentro|veo)/i,\n /tienda\\s+(en\\s+)?l[i\u00ed]nea/i,\n /p[a\u00e1]gina\\s+web/i,\n /\\b(link|enlace|url)\\b/i,\n /\\bhorario/i,\n /a\\s+qu[e\u00e9]\\s+hora/i,\n /hasta\\s+qu[e\u00e9]\\s+hora/i,\n /\\benv[i\u00ed]o/i,\n /\\bentrega/i,\n /\\bdomicilio/i,\n /hacen\\s+env[i\u00ed]o/i,\n /zona\\s+de\\s+(cobertura|entrega)/i,\n /m[e\u00e9]todo(s)?\\s+de\\s+pago/i,\n /aceptan\\s+(tarjeta|nequi|daviplata|efectivo)/i,\n /puedo\\s+pagar\\s+con/i,\n /\\bcontacto/i,\n /\\bubicaci[o\u00f3]n/i,\n /\\bdirecci[o\u00f3]n/i,\n /d[o\u00f3]nde\\s+est[a\u00e1]n/i,\n /mi\\s+pedido/i,\n /qu[e\u00e9]\\s+llevo/i,\n /eso\\s+es\\s+todo/i,\n /total\\s+(del\\s+)?pedido/i,\n /cu[a\u00e1]nto\\s+es\\s+(el\\s+)?total/i,\n /cu[a\u00e1]nto\\s+te\\s+debo/i\n].some(p => p.test(messageText));\n\n// =====================================================\n// \ud83d\udd24 EXTRACCI\u00d3N DE T\u00c9RMINO DE B\u00daSQUEDA - v14 ENRIQUECIDO\n// =====================================================\nconst palabrasIgnorar = new Set([\n // === PALABRAS DE PRUEBA ===\n 'test', 'prueba', 'testing', 'probando', 'probar',\n // === PREGUNTAS ===\n 'cuales', 'cu\u00e1les', 'cual', 'cu\u00e1l', 'donde', 'd\u00f3nde',\n 'cuando', 'cu\u00e1ndo', 'porque', 'porqu\u00e9', 'como', 'c\u00f3mo',\n // === VERBOS PODER (todas conjugaciones) ===\n 'poder', 'puedo', 'puedes', 'puede', 'podemos', 'pueden',\n 'podr\u00eda', 'podr\u00edas', 'podr\u00edamos', 'podr\u00edan',\n 'pudiera', 'pudieras', 'pudieran', 'pude', 'pudiste',\n // === VERBOS QUERER (todas conjugaciones) ===\n 'querer', 'quiero', 'quieres', 'quiere', 'queremos', 'quieren',\n 'quisiera', 'quisieras', 'quisi\u00e9ramos', 'quisieran',\n 'querr\u00eda', 'querr\u00edas', 'querr\u00edamos', 'querr\u00edan',\n // === VERBOS AGREGAR/A\u00d1ADIR (todas conjugaciones) ===\n 'agregar', 'agrego', 'agregas', 'agrega', 'agregamos', 'agregan',\n 'agr\u00e9game', 'agregarme', 'agregarle', 'agr\u00e9gale',\n 'a\u00f1adir', 'a\u00f1ado', 'a\u00f1ades', 'a\u00f1ade', 'a\u00f1adimos', 'a\u00f1aden',\n 'a\u00f1ademe', 'a\u00f1adirme', 'a\u00f1adirle',\n // === VERBOS DAR (todas conjugaciones) ===\n 'dar', 'doy', 'das', 'da', 'damos', 'dan',\n 'dame', 'deme', 'darme', 'darle', 'd\u00e1melo',\n 'd\u00e1ndome', 'd\u00e1ndole',\n // === VERBOS TRAER/ENVIAR ===\n 'traer', 'traigo', 'traes', 'trae', 'traemos', 'traen',\n 'traeme', 'traerme', 'traerle',\n 'enviar', 'env\u00edo', 'env\u00edas', 'env\u00eda', 'enviamos', 'env\u00edan',\n 'enviame', 'enviarme', 'enviarle', 'env\u00edame',\n // === VERBOS PONER/METER ===\n 'poner', 'pongo', 'pones', 'pone', 'ponemos', 'ponen',\n 'ponme', 'ponerle',\n 'meter', 'meto', 'metes', 'mete', 'metemos', 'meten',\n 'meteme', 'meterme', 'meterle',\n // === VERBOS CONSEGUIR ===\n 'conseguir', 'consigo', 'consigues', 'consigue', 'conseguimos', 'consiguen',\n 'conseguirme', 'conseguirle',\n // === VERBOS TENER/HABER ===\n 'tener', 'tengo', 'tienes', 'tiene', 'tenemos', 'tienen',\n 'tendr\u00eda', 'tendr\u00edas', 'tendr\u00edamos', 'tendr\u00edan', 'tendr\u00e1n',\n 'haber', 'hay', 'hab\u00eda', 'habr\u00e1', 'habr\u00eda', 'habr\u00edamos', 'habr\u00edan',\n // === VERBOS BUSCAR ===\n 'buscar', 'busco', 'buscas', 'busca', 'buscamos', 'buscan',\n 'buscando', 'b\u00fascame', 'buscarme',\n // === VERBOS VENDER/MANEJAR ===\n 'vender', 'vendo', 'vendes', 'vende', 'vemos', 'venden',\n 'manejar', 'manejo', 'manejas', 'maneja', 'manejamos', 'manejan',\n 'ofrecer', 'ofrezco', 'ofreces', 'ofrece', 'ofrecemos', 'ofrecen',\n // === VERBOS NECESITAR ===\n 'necesitar', 'necesito', 'necesitas', 'necesita', 'necesitamos', 'necesitan',\n // === VERBOS LLEVAR/COMPRAR/PEDIR ===\n 'llevar', 'llevo', 'llevas', 'leva', 'llevamos', 'levan',\n 'comprar', 'compro', 'compras', 'compra', 'compramos', 'compran',\n 'pedir', 'pido', 'pides', 'pide', 'pedimos', 'piden',\n 'pedido', 'pedidos',\n // === VERBOS SER/ESTAR ===\n 'ser', 'soy', 'eres', 'es', 'somos', 'son',\n 'era', 'fue', 'era', 'estoy', 'est\u00e1s', 'est\u00e1', 'estamos', 'est\u00e1n',\n 'ser\u00eda', 'ser\u00edas', 'ser\u00edamos', 'ser\u00edan',\n 'estar\u00eda', 'estar\u00edas', 'estar\u00edamos', 'estar\u00edan',\n // === VERBOS VER/MOSTRAR ===\n 'ver', 'veo', 'ves', 've', 'vemos', 'ven',\n 'mostrar', 'muestro', 'muestras', 'muestra', 'mostramos', 'muestran',\n 'ense\u00f1ar', 'ense\u00f1o', 'ense\u00f1as', 'ense\u00f1a', 'ense\u00f1amos', 'ense\u00f1an',\n 'mirar', 'miro', 'miras', 'mira', 'miramos', 'miran',\n // === ART\u00cdCULOS ===\n 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas',\n 'este', 'esta', 'estos', 'estas', 'ese', 'esa',\n 'aquel', 'aquella', 'aquellos', 'aquellas',\n // === PRONOMBRES DEMOSTRATIVOS ===\n 'este', 'esta', 'estos', 'estas', 'ese', 'esa',\n 'aquel', 'aquella', 'aquellos', 'aquellas',\n // === PRONOMBRES PERSONALES ===\n 'yo', 'tu', 't\u00fa', 'usted', '\u00e9l', 'ella', 'nosotros', 'ustedes', 'ellos', 'ellas',\n 'me', 'te', 'se', 'le', 'lo', 'la', 'les',\n // === PREPOSICIONES ===\n 'de', 'del', 'a', 'al', 'para', 'por', 'con', 'sin', 'hacia', 'desde', 'entre', 'sobre', 'en',\n // === INTERROGATIVOS ===\n 'que', 'qu\u00e9', 'cu\u00e1l', 'cu\u00e1les', 'd\u00f3nde', 'cu\u00e1ndo', 'cu\u00e1ndo', 'cu\u00e1nto', 'cu\u00e1nta',\n 'cu\u00e1ntos', 'cu\u00e1ntas', 'c\u00f3mo', 'c\u00f3mo', 'cu\u00e1l',\n // === PRECIOS ===\n 'vale', 'valen', 'cuesta', 'cuestan', 'costar', 'costar\u00eda', 'precio', 'precios', 'costo', 'costos',\n 'valor', 'valores',\n // === SALUDOS ===\n 'hola', 'buenos', 'buenas', 'hey', 'oye', 'oiga', 'disculpa', 'disculpe', 'perd\u00f3n', 'perdon',\n 'gracias', 'muchas', 'mil', 'please', 'plz', 'plis',\n // === CORTES\u00cdA ===\n 'm\u00e1s', 'mas', 'menos', 'muy', 'mucho', 'mucha', 'poco', 'poca', 'tambi\u00e9n', 'tambien', 'adem\u00e1s', 'ademas', 'incluso',\n 'bueno', 'buena', 'buenos', 'buenas', 'mejor', 'mejores', 'peor', 'peores', 'grande', 'peque\u00f1o', 'peque\u00f1a',\n 'fresco', 'fresca', 'frescos', 'frescas',\n // === ADJETIVOS/ADVERBIOS ===\n 'm\u00e1s', 'mas', 'menos', 'muy', 'mucho', 'mucha', 'poco', 'poca', 'tambi\u00e9n', 'tambien', 'adem\u00e1s', 'ademas', 'incluso',\n 'bueno', 'buena', 'buenos', 'buenas', 'mejor', 'mejores', 'peor', 'peores', 'grande', 'peque\u00f1o', 'peque\u00f1a',\n 'fresco', 'fresca', 'frescos', 'frescas',\n // === N\u00daMEROS ===\n 'uno', 'dos', 'tres', 'cuatro', 'cinco', 'seis', 'siete', 'ocho', 'nueve', 'diez',\n 'primero', 'segundo', 'tercero', 'cuarto', 'quinto', 'sexto', 's\u00e9ptimo', 'octavo', 'noveno', 'd\u00e9cimo',\n // === UNIDADES ===\n 'kilo', 'kilos', 'gramos', 'grs', 'libra', 'libras', 'unidad', 'unidades', 'paquete', 'paquetes',\n 'bandeja', 'bandejas', 'caja', 'cajas',\n // === CONECTORES ===\n 'pero', 'aunque', 'entonces', 'pues', 'bueno', 'mira', 'oiga', 'digame', 'd\u00edgame', 'cu\u00e9ntame', 'cuentame',\n 'okay', 'vale', 'listo', 'dale', 'aja', 'aj\u00e1',\n // === ADVERBIOS TIEMPO/LUGAR ===\n 'solo', 's\u00f3lo', 'solamente', '\u00fanicamente', 'unicamente',\n 'siempre', 'nunca', 'ahora', 'despu\u00e9s', 'despues', 'antes', 'antes',\n 'aqu\u00ed', 'aqui', 'ac\u00e1', 'aca', 'all\u00e1', 'alla', 'ah\u00ed', 'ahi',\n // === OTRAS PALABRAS COMUNES ===\n 'ok', 'si', 'no', 'perfecto', 'listo', 'bien', 'todo', 'nada', 'alguno', 'alguna',\n 'otro', 'otra', 'otros', 'otras', 'misma', 'mismo', 'mismos', 'mismas',\n 'tal', 'vez', 'general', 'claro', 'casi', 'aprox', 'realmente', 'cierto'\n]);\n\nlet terminoBusqueda = messageText\n .replace(/[^\\w\\s\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1\u00fc\u00c1-\u00ff\u00cd-\u00da\\s]/g, '')\n .split(/\\s+/)\n .filter(w => w.length > 2 && !palabrasIgnorar.has(w.toLowerCase()))\n .join(' ')\n .trim();\n\n// Detectar m\u00faltiples productos\nconst separadores = /\\s+(?:y|o|,)\\s+/gi;\nconst tieneMultiplesProductos = separadores.test(terminoBusqueda);\n\nlet terminosIndividuales = [];\nif (tieneMultiplesProductos) {\n terminosIndividuales = terminoBusqueda\n .split(/s*(?:y|o|,)\\s*/gi)\n .map(t => t.trim())\n .filter(t => t.length > 0);\n} else if (terminoBusqueda) {\n terminosIndividuales = [terminoBusqueda];\n}\n\n// NORMALIZACI\u00d3N DE PLURALES\nlet terminoNormalizado = terminoBusqueda;\nlet variantesBusqueda = [];\nlet terminosNormalizados = [];\n\nterminosIndividuales.forEach(termino => {\n const palabras = termino.split(' ');\n const palabrasNormalizadas = palabras.map(palabra => {\n if (palabra.endsWith('s') && palabra.length > 3) {\n return palabra.slice(0, -1);\n }\n if (palabra.endsWith('es') && palabra.length > 4) {\n return palabra.slice(0, -2);\n }\n return palabra;\n });\n \n const terminoNormalizado = palabrasNormalizadas.join(' ');\n terminosNormalizados.push(terminoNormalizado);\n \n // Mantener el t\u00e9rmino original tambi\u00e9n\n if (!variantesBusqueda.includes(termino)) {\n variantesBusqueda.push(termino);\n }\n if (!variantesBusqueda.includes(terminoNormalizado)) {\n variantesBusqueda.push(terminoNormalizado);\n }\n});\n\nterminoNormalizado = terminosNormalizados.join(' ');\nvariantesBusqueda = [...new Set(variantesBusqueda)];\n\n// DECISI\u00d3N DE B\u00daSQUEDA\nconst debeHacerBusqueda =\n !esSoloSaludo &&\n !esPreguntaConversacional &&\n terminoBusqueda.length > 0;\n\n// RESULTADO FINAL (CLIENTE NORMAL)\nreturn [{\n json: {\n esComandoCopiloto: false,\n mensajeCliente: messageText,\n from, to, customerName, saludo,\n esMediaNoSoportado: false,\n esMedia: false,\n esImagen: false,\n esDocumento: false,\n esAudio: false,\n esVideo: false,\n esSticker: false,\n tipoMensaje: 'text',\n captionImagen: '',\n esRespuestaBoton: false,\n botonId: '',\n botonTexto: '',\n productoIdDelBoton: null,\n accionBoton: '',\n esSoloSaludo,\n esPreguntaConversacional,\n debeHacerBusqueda,\n esPedidoPlataforma,\n infoPedidoPlataforma,\n tieneMultiplesProductos,\n terminosIndividuales,\n terminoBusqueda,\n terminoNormalizado,\n variantesBusqueda,\n timestamp: whatsappMsg.sendTime\n }\n}];\n"
},
"id": "c2d8c4de-9bdd-4b66-bbd8-abac56aa9c8f",
"name": "1. Pre-procesamiento YCloud",
"type": "n8n-nodes-base.code",
"position": [
-224,
1440
],
"typeVersion": 2,
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "es-media",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.esMediaNoSoportado }}",
"rightValue": true
}
]
},
"options": {}
},
"id": "2ce881a6-e0cc-4836-91ab-c43a398a3b68",
"name": "\u2753 \u00bfEs Media?",
"type": "n8n-nodes-base.if",
"position": [
320,
1168
],
"typeVersion": 2.2
},
{
"parameters": {
"method": "POST",
"url": "https://httpbin.org/post",
"sendBody": true,
"bodyParameters": {
"parameters": [
{}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
416,
976
],
"id": "1c1dc511-7909-42f1-89bc-538c2ba4245a",
"name": "\ud83d\udcf1 Responder Media No Soportado"
},
{
"parameters": {
"operation": "executeQuery",
"query": "WITH nuevo_cliente AS (\n INSERT INTO clientes (telefono, nombre, estado_conversacion)\n SELECT\n '{{ $json.from }}',\n NULL,\n 'NUEVO'\n WHERE NOT EXISTS (\n SELECT 1 FROM clientes WHERE telefono = '{{ $json.from }}'\n )\n RETURNING id\n), candidatos AS (\n SELECT\n id,\n telefono,\n nombre,\n direccion,\n estado_conversacion,\n pre_pedido,\n total_pedidos,\n CASE\n WHEN COALESCE(jsonb_array_length(COALESCE(pre_pedido, '[]'::jsonb)), 0) > 0 THEN 0\n ELSE 1\n END AS prioridad_carrito\n FROM clientes\n WHERE telefono = '{{ $json.from }}'\n)\nSELECT id, telefono, nombre, direccion, estado_conversacion, pre_pedido, total_pedidos\nFROM candidatos\nORDER BY prioridad_carrito ASC, id DESC\nLIMIT 1;",
"options": {}
},
"name": "2. Obtener Cliente",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"id": "61583a03-3ecc-4030-94f8-3acc01b29774",
"position": [
464,
1344
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "es-busqueda",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $('1. Pre-procesamiento YCloud').first().json.debeHacerBusqueda }}",
"rightValue": true
}
]
},
"options": {}
},
"id": "9987fdfe-c019-409d-a941-1538ada467b3",
"name": "\u2753 \u00bfBusca Producto?",
"type": "n8n-nodes-base.if",
"position": [
1152,
1184
],
"typeVersion": 2.2
},
{
"parameters": {
"operation": "executeQuery",
"query": "WITH search_terms AS (\n SELECT\n LOWER('{{ $json.terminoBusqueda }}') AS term_original,\n LOWER(\n CASE\n WHEN LOWER('{{ $json.terminoBusqueda }}') IN ('hass', 'aguacate', 'aguacate hass') THEN '{{ $json.terminoBusqueda }}'\n WHEN LOWER('{{ $json.terminoBusqueda }}') ~ 'es$' AND LENGTH('{{ $json.terminoBusqueda }}') > 4 THEN TRIM(TRAILING 'es' FROM LOWER('{{ $json.terminoBusqueda }}'))\n WHEN LOWER('{{ $json.terminoBusqueda }}') ~ 's$' AND LENGTH('{{ $json.terminoBusqueda }}') > 3 THEN TRIM(TRAILING 's' FROM LOWER('{{ $json.terminoBusqueda }}'))\n ELSE '{{ $json.terminoBusqueda }}'\n END\n ) AS term_base,\n LOWER('{{ $json.mensajeCliente || \"\" }}') AS full_message\n), productos_match AS (\n SELECT\n p.id, p.name, p.price, p.main_image_url, p.description, p.category_name, p.stock, p.supabase_id,\n CASE\n WHEN s.term_original = 'hass' AND LOWER(p.name) LIKE '%hass%' THEN 0\n WHEN s.term_original LIKE '%aguacate%' AND LOWER(p.name) LIKE '%aguacate%' AND LOWER(p.name) LIKE 'caja %' THEN 0\n WHEN s.full_message LIKE '%24%' AND LOWER(p.name) LIKE '%24%' THEN 0\n WHEN s.full_message LIKE '%caja%' AND LOWER(p.name) LIKE 'caja %' THEN 1\n WHEN regexp_replace(LOWER(p.name), '[^a-z0-9\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1\u00fc ]', '', 'g') ~ ('(^| )' || regexp_replace(s.term_original, '[^a-z0-9\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1\u00fc ]', '', 'g') || '( |$)') THEN 2\n WHEN LOWER(p.name) LIKE '%' || s.term_original || '%' THEN 3\n WHEN LOWER(COALESCE(p.category_name, '')) LIKE '%' || s.term_original || '%' THEN 4\n ELSE 99\n END AS match_priority\n FROM public.productos_tienda p, search_terms s\n WHERE p.is_active = true\n AND (\n LOWER(p.name) LIKE '%' || s.term_original || '%'\n OR LOWER(COALESCE(p.category_name, '')) LIKE '%' || s.term_original || '%'\n OR LOWER(p.description) LIKE '%' || s.term_original || '%'\n )\n)\nSELECT pm.id, pm.name, pm.price, pm.main_image_url, pm.description, pm.category_name, pm.stock, pm.supabase_id, pm.match_priority,\n (SELECT COALESCE(json_agg(json_build_object('variante_id', v.id,'tipo', v.variant_name,'presentacion', v.variant_value,'precio', v.price,'stock', v.stock_quantity) ORDER BY v.price), '[]'::json)\n FROM variantes_productos v WHERE v.product_supabase_id = pm.supabase_id AND v.is_active = true) AS variantes\nFROM productos_match pm\nGROUP BY pm.id, pm.name, pm.price, pm.main_image_url, pm.description, pm.category_name, pm.stock, pm.supabase_id, pm.match_priority\nORDER BY pm.match_priority ASC, CASE WHEN LOWER(pm.name) LIKE 'caja %' THEN 0 ELSE 1 END, pm.name\nLIMIT 3;",
"options": {}
},
"id": "2a74cbde-7789-4f86-a771-9eed4733e6b1",
"name": "3. B\u00fasqueda Autom\u00e1tica Productos",
"type": "n8n-nodes-base.postgres",
"position": [
1296,
752
],
"typeVersion": 2.6,
"alwaysOutputData": true,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Combinar datos del cliente y resultados de b\u00fasqueda\n// v5: Manejo robusto de clientes y pedidos web\n\nconst preproceso = $('1. Pre-procesamiento YCloud').first().json;\n\n// Obtener cliente con manejo de errores\nlet cliente = {};\ntry {\n cliente = $('2. Obtener Cliente').first().json || {};\n} catch (e) {\n // Si no existe el cliente, crear uno vac\u00edo\n cliente = { id: null, telefono: preproceso.from, nombre: preproceso.customerName || 'Cliente' };\n}\n\n// Obtener productos de b\u00fasqueda\nlet productosEncontrados = [];\ntry {\n const busquedaItems = $('3. B\u00fasqueda Autom\u00e1tica Productos').all();\n productosEncontrados = busquedaItems\n .map(item => item.json)\n .filter(p => p && p.id && p.name && typeof p.price !== 'undefined');\n} catch (e) {\n productosEncontrados = [];\n}\n\n// Formatear productos de forma SIMPLE\nlet productosTexto = '';\nif (productosEncontrados.length > 0) {\n const lineas = [];\n productosEncontrados.forEach(p => {\n if (p.variantes && Array.isArray(p.variantes) && p.variantes.length > 0) {\n p.variantes.forEach(v => {\n lineas.push(`\u2022 ${p.name} - ${v.presentacion} $${Number(v.precio).toLocaleString('es-CO')} [ID:${p.id}]`);\n });\n } else {\n lineas.push(`\u2022 ${p.name} - $${Number(p.price).toLocaleString('es-CO')} [ID:${p.id}]`);\n }\n });\n productosTexto = [...new Set(lineas)].join('\\n');\n} else if (preproceso.debeHacerBusqueda && preproceso.terminoBusqueda) {\n productosTexto = `\u274c No se encontraron productos para: \"${preproceso.terminoBusqueda}\"`;\n} else {\n productosTexto = '(No se realiz\u00f3 b\u00fasqueda)';\n}\n\n// Contexto de tiempo\nconst now = new Date();\nconst dayOfWeek = now.getDay();\nconst hour = now.getHours();\nconst pasoCutoff = hour >= 10;\n\nlet diasHastaEntrega, mensajeEntrega;\nswitch(dayOfWeek) {\n case 0: diasHastaEntrega = 2; mensajeEntrega = \"Pr\u00f3xima entrega: Martes\"; break;\n case 1: diasHastaEntrega = 1; mensajeEntrega = \"Pr\u00f3xima entrega: Martes\"; break;\n case 2: \n if (pasoCutoff) { diasHastaEntrega = 3; mensajeEntrega = \"Pr\u00f3xima entrega: Viernes\"; }\n else { diasHastaEntrega = 0; mensajeEntrega = \"\u00a1Entrega HOY antes 10AM!\"; }\n break;\n case 3: diasHastaEntrega = 2; mensajeEntrega = \"Pr\u00f3xima entrega: Viernes\"; break;\n case 4: diasHastaEntrega = 1; mensajeEntrega = \"Pr\u00f3xima entrega: Viernes\"; break;\n case 5:\n if (pasoCutoff) { diasHastaEntrega = 4; mensajeEntrega = \"Pr\u00f3xima entrega: Martes\"; }\n else { diasHastaEntrega = 0; mensajeEntrega = \"\u00a1Entrega HOY antes 10AM!\"; }\n break;\n case 6: diasHastaEntrega = 3; mensajeEntrega = \"Pr\u00f3xima entrega: Martes\"; break;\n default: diasHastaEntrega = 1; mensajeEntrega = \"Pr\u00f3xima entrega: Martes o Viernes\";\n}\n\nconst fechaEntrega = new Date(now);\nfechaEntrega.setDate(now.getDate() + diasHastaEntrega);\nconst proximaEntrega = fechaEntrega.toLocaleDateString('es-CO', { weekday: 'long', day: 'numeric', month: 'long' });\nconst fechaActual = now.toLocaleDateString('es-CO', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });\nconst horaActual = now.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit', hour12: true });\n\nreturn {\n json: {\n clienteId: cliente.id || null,\n clienteTelefono: cliente.telefono || preproceso.from,\n clienteNombre: cliente.nombre || preproceso.customerName || 'Cliente',\n clienteEstado: cliente.estado_conversacion || 'NUEVO',\n clienteDireccion: cliente.direccion || '',\n clienteCarrito: cliente.pre_pedido || [],\n clienteTotalPedidos: cliente.total_pedidos || 0,\n \n mensajeCliente: preproceso.mensajeCliente || preproceso.messageText,\n terminoBusqueda: preproceso.terminoBusqueda || '',\n esIntentoBusqueda: preproceso.esIntentoBusqueda || false,\n esPedidoPlataforma: preproceso.esPedidoPlataforma || false,\n infoPedidoPlataforma: preproceso.infoPedidoPlataforma || null,\n \n productosEncontrados,\n productosTexto,\n \n from: preproceso.from,\n to: preproceso.to,\n saludo: preproceso.saludo,\n \n fechaActual, horaActual, horaNumero: hour, diaNumero: dayOfWeek,\n pasoCutoff, proximaEntrega, mensajeEntrega, diasHastaEntrega,\n retry_count: 0\n }\n};\n"
},
"id": "4313e79a-64c2-46a3-8e81-e3e4efa7236d",
"name": "4. Merge Datos + Productos",
"type": "n8n-nodes-base.code",
"position": [
1584,
592
],
"typeVersion": 2,
"alwaysOutputData": true
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "USA OBLIGATORIAMENTE cuando el cliente diga \"quiero\", \"dame\", \"agr\u00e9game\" o similares. NO digas que ya lo hiciste si no has ejecutado esta herramienta. Requiere: producto_id, producto_nombre, precio, cantidad.",
"operation": "executeQuery",
"query": "UPDATE clientes \nSET pre_pedido = COALESCE(pre_pedido, '[]'::jsonb) || \n jsonb_build_object(\n 'producto_id', $1::int,\n 'producto_nombre', $2,\n 'precio', $3::numeric,\n 'cantidad', COALESCE($4::int, 1)\n ),\n estado_conversacion = 'EN_PEDIDO'\nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\nRETURNING pre_pedido;",
"options": {
"queryReplacement": "={{ $fromAI('producto_id','ID del producto a agregar','number',0) }}\n{{ $fromAI('producto_nombre','Nombre del producto','string','Producto') }}\n{{ $fromAI('precio','Precio del producto','number',0) }}\n{{ $fromAI('cantidad','Cantidad a agregar','number',1) }}"
}
},
"id": "211df281-543d-497a-b643-25dca132695c",
"name": "TOOL_AnadirAlCarrito",
"type": "n8n-nodes-base.postgresTool",
"position": [
1584,
1552
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "USA cuando el cliente diga \"eso es todo\", \"cu\u00e1nto es\", \"calcular total\". Devuelve el total del carrito.",
"operation": "executeQuery",
"query": "SELECT \n COALESCE(\n (SELECT SUM((item->>'precio')::numeric * COALESCE((item->>'cantidad')::int, 1))\n FROM clientes, jsonb_array_elements(pre_pedido) AS item\n WHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\n ), 0\n ) as total,\n pre_pedido as items\nFROM clientes\nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').item.json.from }}';",
"options": {}
},
"id": "68d9c2da-d254-46d7-be1e-e702329223e9",
"name": "TOOL_CalcularTotalPrePedido",
"type": "n8n-nodes-base.postgresTool",
"position": [
2512,
1680
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Guardar el nombre del cliente cuando lo proporcione.",
"operation": "executeQuery",
"query": "UPDATE clientes \nSET \n nombre = $1, \n estado_conversacion = 'ATENCION_LUZ' \nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\nRETURNING nombre, estado_conversacion;",
"options": {
"queryReplacement": "={{ $fromAI('nombre_cliente','Nombre del cliente','string','Cliente') }}"
}
},
"id": "6a73ac10-62d0-490d-a7a8-59007eab3613",
"name": "TOOL_GuardarNombreCliente",
"type": "n8n-nodes-base.postgresTool",
"position": [
1872,
1824
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "USA SIEMPRE despu\u00e9s de cada acci\u00f3n importante. Estados: EN_PEDIDO (agreg\u00f3 productos), PEDIDO_CONFIRMADO (confirm\u00f3), PEDIDO_ONLINE (de tienda), ESCALADO (queja).",
"operation": "executeQuery",
"query": "UPDATE clientes \nSET estado_conversacion = $1\nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\nRETURNING estado_conversacion;",
"options": {
"queryReplacement": "={{ $fromAI('nuevo_estado','Estado v\u00e1lido','string','ATENCION_LUZ') }}"
}
},
"id": "84728a22-7913-434d-91b4-15690b9a14b2",
"name": "TOOL_CambiarEstadoCliente",
"type": "n8n-nodes-base.postgresTool",
"position": [
2224,
1744
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const contexto = $('4. Merge Datos + Productos').first().json;\nconst respuestaIA = $input.first().json.text || $input.first().json.output || '';\nlet mensaje = String(respuestaIA || '').replace(/\\*/g, '').trim();\nconst nombreCompleto = String(contexto.clienteNombre || '').trim();\nconst tieneNombreCompleto = nombreCompleto.split(/\\s+/).filter(Boolean).length >= 2;\nconst tieneDireccion = String(contexto.clienteDireccion || '').trim().length > 5;\nconst carrito = Array.isArray(contexto.clienteCarrito) ? contexto.clienteCarrito : [];\nconst pareceResumen = /subtotal|total:|resumen/i.test(mensaje);\nif (pareceResumen && carrito.length > 0 && (!tieneNombreCompleto || !tieneDireccion)) {\n mensaje = '\u00a1Casi listo! Para coordinar el env\u00edo, por favor conf\u00edrmame tu nombre completo y la direcci\u00f3n de entrega.';\n}\nif (!mensaje) return [];\nreturn [{ json: { from: contexto.to, to: contexto.from, type: 'text', text: { body: mensaje, preview_url: false } } }];"
},
"id": "0262ccbe-0660-46a8-a027-0e674dd48319",
"name": "\ud83d\udce4 Preparar Respuesta",
"type": "n8n-nodes-base.code",
"position": [
2864,
720
],
"typeVersion": 2
},
{
"parameters": {
"method": "POST",
"url": "https://httpbin.org/post",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "lab_intercept",
"value": "={{ $json.toJsonString() }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3424,
592
],
"id": "4503a0ed-b573-452a-9788-4c5e27b600a5",
"name": "\ud83d\udcf1 Enviar WhatsApp YCloud"
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Herramienta de FALLBACK para buscar productos. \u00dasala SOLO cuando los productos encontrados autom\u00e1ticamente NO coinciden con lo que pidi\u00f3 el cliente. Input: t\u00e9rmino de b\u00fasqueda del producto.",
"operation": "executeQuery",
"query": "SELECT \n id,\n name,\n COALESCE(discount_price, price) as precio,\n description,\n category_name\nFROM productos_tienda p\nWHERE \n is_active = true\n AND p.name NOT ILIKE '%Fresa Premium 250g%' AND p.price > 0 AND p.stock > 0 AND (\n name ILIKE '%' || $1 || '%'\n OR description ILIKE '%' || $1 || '%'\n OR category_name ILIKE '%' || $1 || '%'\n )\nORDER BY \n CASE \n WHEN is_featured = true THEN 0 \n ELSE 1 \n END,\n name\nLIMIT 5;\n",
"options": {
"queryReplacement": "={{ $fromAI('termino_busqueda','Producto a buscar','string','aguacate') }}"
}
},
"id": "37802349-d077-4ffe-a17d-acbf9652535d",
"name": "TOOL_BuscarProductos",
"type": "n8n-nodes-base.postgresTool",
"position": [
2640,
1568
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "ESTA HERRAMIENTA ES OBLIGATORIA PARA ESCALAR. \u00dasala para transferir la sesi\u00f3n a un humano ante cualquier queja, molestia o pedido de soporte humano. NO PUEDES CONECTAR CON UN HUMANO SOLO CON TEXTO, DEBES ACTIVAR ESTA HERRAMIENTA.",
"operation": "executeQuery",
"query": "UPDATE clientes \nSET \n estado_conversacion = 'ESCALADO'\nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\nRETURNING \n nombre,\n telefono,\n estado_conversacion,\n 'Conversaci\u00f3n escalada. Motivo: ' || $1 as mensaje_confirmacion;\n",
"options": {
"queryReplacement": "={{ $fromAI('motivo_escalado','Motivo del escalado','string','Cliente solicita hablar con humano') }}"
}
},
"id": "19f90844-7253-4e05-ab20-53780c3e8338",
"name": "TOOL_EscalarServicioCliente",
"type": "n8n-nodes-base.postgresTool",
"position": [
1744,
1664
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Consultar el estado del cliente y su \u00faltimo pedido. Usarla cuando el cliente pregunte: \u00bfc\u00f3mo va mi pedido? \u00bftengo pedidos?",
"operation": "executeQuery",
"query": "SELECT \n c.nombre,\n c.telefono,\n c.estado_conversacion as estado,\n c.direccion,\n c.pre_pedido as carrito,\n c.updated_at as ultima_actualizacion,\n CASE \n WHEN c.estado_conversacion = 'PEDIDO_CONFIRMADO' THEN '\u2705 Tu pedido fue confirmado. Te contactaremos para coordinar la entrega.'\n WHEN c.estado_conversacion = 'PEDIDO_ONLINE' THEN '\ud83d\uded2 Tu pedido de la tienda online est\u00e1 siendo procesado.'\n WHEN c.estado_conversacion = 'EN_PEDIDO' THEN '\ud83d\udce6 Tienes un pedido en proceso. \u00bfDeseas confirmarlo?'\n WHEN c.estado_conversacion = 'ESCALADO' THEN '\ud83d\udc64 Un asesor te contactar\u00e1 pronto.'\n ELSE '\ud83d\udcac No encontr\u00e9 pedidos recientes. \u00bfQuieres hacer uno nuevo?'\n END as mensaje_estado\nFROM clientes c\nWHERE c.telefono LIKE '%' || RIGHT('{{ $('1. Pre-procesamiento YCloud').first().json.from }}', 10) || '%'\nLIMIT 1;",
"options": {}
},
"id": "65cf129f-27f4-4962-afb3-139f78304515",
"name": "TOOL_ConsultarEstadoPedido",
"type": "n8n-nodes-base.postgresTool",
"position": [
2816,
1488
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Guarda la direcci\u00f3n de entrega del cliente. Usar cuando el cliente proporciona su direcci\u00f3n completa. Input: direccion (la direcci\u00f3n del cliente).",
"operation": "executeQuery",
"query": "UPDATE clientes \nSET direccion = $1\nWHERE telefono = '{{ $('1. Pre-procesamiento YCloud').first().json.from }}'\nRETURNING telefono, nombre, direccion, 'Direcci\u00f3n guardada correctamente' as status;",
"options": {
"queryReplacement": "=={{ [$fromAI('direccion','Direcci\u00f3n completa del cliente incluyendo calle, n\u00famero, barrio y ciudad','string','')] }}"
}
},
"id": "7e6be0a3-dadf-4979-950a-e18cf3d5e7b0",
"name": "TOOL_GuardarDireccionCliente",
"type": "n8n-nodes-base.postgresTool",
"position": [
2976,
1408
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {},
"id": "d6bc2944-ed4b-47f0-bf9d-9e1f2c5e40d3",
"name": "Calculator",
"type": "@n8n/n8n-nodes-langchain.toolCalculator",
"position": [
2336,
1760
],
"typeVersion": 1
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "USA SIEMPRE cuando el cliente pregunte por un producto. Obtiene tama\u00f1os, pesos y precios. Input: supabase_id del producto (b\u00fascalo en productosEncontrados).",
"operation": "executeQuery",
"query": "SELECT \n v.variant_name as tipo,\n v.variant_value as presentacion,\n v.price as precio,\n v.stock_quantity as stock,\n p.name as producto\nFROM variantes_productos v\nJOIN productos_tienda p ON p.supabase_id = v.product_supabase_id\nWHERE p.id = $1\n AND v.is_active = true\nORDER BY v.variant_name, v.price;",
"options": {
"queryReplacement": "={{ $fromAI('producto_id','ID num\u00e9rico del producto','number',0) }}"
}
},
"id": "fb1e25a8-8f9b-417a-924f-5e713233d2f9",
"name": "TOOL_ObtenerVariantes",
"type": "n8n-nodes-base.postgresTool",
"position": [
2048,
1744
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Obtener el producto de la consulta\nconst producto = $input.first().json;\n// Obtener el contexto del preprocesamiento\nconst contexto = $('1. Pre-procesamiento YCloud').first().json;\n// Formatear producto al formato esperado\nconst productoFormateado = {\n producto_id: producto.id,\n producto_nombre: producto.name,\n name: producto.name,\n id: producto.id,\n precio: producto.price,\n price: producto.price,\n main_image_url: producto.main_image_url,\n description: producto.description,\n category: producto.category,\n stock: producto.stock\n};\n// Retornar contexto con el producto encontrado\nreturn {\n json: {\n ...contexto,\n productosEncontrados: [productoFormateado],\n productosTexto: `Producto del bot\u00f3n: ${producto.name} - $${Number(producto.price).toLocaleString('es-CO')}`\n }\n};"
},
"id": "89f8b753-f1c0-4d14-9c34-ad9238beb3c5",
"name": "Code - Formatear Producto",
"type": "n8n-nodes-base.code",
"position": [
1040,
960
],
"typeVersion": 2
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT \n p.id,\n p.name,\n p.price,\n p.main_image_url,\n p.description,\n p.category_id,\n p.stock\nFROM public.productos_tienda p\nWHERE p.id = {{ $json.productoIdDelBoton }}\nLIMIT 1;",
"options": {}
},
"id": "23d2a521-41a3-482b-b38f-de96a5d7317b",
"name": "Buscar Producto del Bot\u00f3n",
"type": "n8n-nodes-base.postgres",
"position": [
768,
912
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "31594272-3c47-4074-9324-e697e674cd9b",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.esRespuestaBoton }}",
"rightValue": ""
}
]
},
"renameOutput": true,
"outputKey": "Buscar Producto"
},
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "c5c155de-73a0-4e73-8ec0-0c21914d6b18",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"leftValue": "={{ $json.esRespuestaBoton }}",
"rightValue": ""
}
]
},
"renameOutput": true,
"outputKey": "Sin Producto"
}
]
},
"looseTypeValidation": true,
"options": {
"fallbackOutput": "none"
}
},
"id": "2ac9fb8b-0172-48ac-9ab2-cb27f0ec396a",
"name": "Detectar Click de Bot\u00f3n",
"type": "n8n-nodes-base.switch",
"position": [
624,
1232
],
"typeVersion": 3.3
},
{
"parameters": {},
"id": "77068eff-d068-48d8-9f7b-f309db2c675d",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"position": [
976,
1488
],
"typeVersion": 3.2
},
{
"parameters": {},
"id": "eaf862ec-e845-4496-a1b4-fc4ccbca2e93",
"name": "Merge1",
"type": "n8n-nodes-base.merge",
"position": [
416,
1600
],
"typeVersion": 3.2
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "45a7a667-e7c8-41ab-823c-ea0048f4ca53",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.esComandoCopiloto }}",
"rightValue": ""
}
]
},
"renameOutput": true,
"outputKey": "Copiloto"
},
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "6e38428c-70d8-45a2-a0f1-a98aea4009f7",
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
},
"leftValue": "={{ $json.esComandoCopiloto }}",
"rightValue": ""
}
]
},
"renameOutput": true,
"outputKey": "Cliente"
}
]
},
"looseTypeValidation": true,
"options": {}
},
"id": "757b19ad-3ad2-47e9-bf59-a9b94b898bc3",
"name": "\ud83d\udd00 \u00bfEs Copiloto?",
"type": "n8n-nodes-base.switch",
"position": [
144,
1312
],
"typeVersion": 3.3
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "copiloto_operaciones",
"contextWindowLength": 2
},
"id": "90dfdd22-402b-41b4-b9b3-ae50eb6513f3",
"name": "\ud83e\udde0 Memoria Copiloto",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"position": [
3296,
1696
],
"typeVersion": 1.3
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Listar clientes SIN NOMBRE. Usar cuando pidan 'clientes sin nombre', 'an\u00f3nimos'.",
"operation": "executeQuery",
"query": "SELECT telefono, estado_conversacion, total_pedidos FROM clientes WHERE nombre IS NULL OR TRIM(nombre) = '' OR nombre ILIKE '%cliente%' ORDER BY total_pedidos DESC LIMIT 50;",
"options": {}
},
"id": "ede8d0ad-2614-419a-97fc-189c92b73006",
"name": "TOOL_ListarClientesSinNombre",
"type": "n8n-nodes-base.postgresTool",
"position": [
4032,
1648
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Actualizar el NOMBRE de un cliente. Requiere: telefono del cliente y nuevo nombre.",
"operation": "executeQuery",
"query": "UPDATE clientes SET nombre = $1 WHERE REPLACE(REPLACE(telefono, '+', ''), ' ', '') LIKE '%' || $2 || '%' RETURNING telefono, nombre, 'Actualizado' as status;",
"options": {
"queryReplacement": "={{ $fromAI('nombre','Nuevo nombre del cliente','string','') }}\n{{ $fromAI('telefono','Tel\u00e9fono del cliente (solo n\u00fameros)','string','') }}"
}
},
"id": "73d7abf3-eb92-4fff-a438-843d591a5de0",
"name": "TOOL_ADMIN_ActualizarNombre",
"type": "n8n-nodes-base.postgresTool",
"position": [
3552,
1680
],
"typeVersion": 2.6,
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Consultar datos completos de UN cliente por tel\u00e9fono. Devuelve nombre, direcci\u00f3n, carrito (pre_pedido), estado y total de pedidos.",
"operation": "executeQuery",
"query": "SELECT telefono, nombre, direccion, estado_conversacion, total_pedidos, jsonb_array_length(COALESCE(pre_p
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing β you'll add your own.
mistralCloudApiopenRouterApipostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
π§ͺ LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCalculator. Webhook trigger; 63 nodes.
Source: https://github.com/maurixio8/tus-aguacates/blob/7c1be578345ab34f11b4261c02d27be15fc178e8/n8n-workflows/labr-put-response-3.json β original creator credit. Request a take-down β
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
CLINICAINTEGRAL_secretary. Uses postgres, mcpClientTool, googleDriveTool, toolWorkflow. Webhook trigger; 89 nodes.
secretaria. Uses postgres, n8n-nodes-evolution-api, openAi, httpRequest. Webhook trigger; 71 nodes.
Brokeria-v20. Uses n8n-nodes-waha, httpRequest, redis, googleGemini. Webhook trigger; 56 nodes.
Brokeria-v15. Uses n8n-nodes-waha, httpRequest, postgres, redis. Webhook trigger; 55 nodes.
Remi 1.1. Uses lmChatOpenAi, memoryPostgresChat, openAi, postgres. Webhook trigger; 89 nodes.