This workflow corresponds to n8n.io template #10665 — we link there as the canonical source.
This workflow follows the Agent → Googlegemini 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 →
{
"id": "qVwL4UVRZaWLVZtQ",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "ESPOLBot CALENDARIO|FAQS",
"tags": [],
"nodes": [
{
"id": "6d06b9e5-4027-4626-8315-2b02a3087727",
"name": "Construir_Prompt_Gemini1",
"type": "n8n-nodes-base.code",
"position": [
-1008,
2448
],
"parameters": {
"jsCode": "const preguntaUsuario = $json.pregunta_usuario;\nconst faqsRelevantes = $json.contexto_faqs || [];\n\nlet contextoFAQs = '';\nif (faqsRelevantes.length > 0) {\n contextoFAQs = 'Aqui hay informacion relevante de la base de datos:\\n\\n';\n contextoFAQs += faqsRelevantes.map((faq, index) => {\n return `${index + 1}. P: ${faq.pregunta}\\n R: ${faq.respuesta}${faq.enlaces ? `\\n Link: ${faq.enlaces}` : ''}`;\n }).join('\\n\\n');\n} else {\n contextoFAQs = 'No encontre informacion especifica en la base de datos para esta pregunta.';\n}\n\nconst prompt = `Eres un asistente de ESPOL. Un estudiante te pregunto:\n\n\"${preguntaUsuario}\"\n\n${contextoFAQs}\n\nIMPORTANTE:\n- La pregunta del usuario ES VALIDA y CLARA\n- Si hay informacion arriba, usala para responder\n- Si NO hay informacion especifica, di algo como: \"No tengo informacion especifica sobre [tema], pero puedes contactar a ESPOL\"\n- NUNCA digas \"no has hecho ninguna pregunta\"\n\nContactos generales:\n- Email: user@example.com\n- Telefono: (04) 2269-269\n- Web: https://www.espol.edu.ec\n\nResponde de forma COMPLETA, clara y util. Incluye TODOS los detalles necesarios:`;\n\nreturn [{\n json: {\n prompt: prompt\n }\n}];"
},
"typeVersion": 2
},
{
"id": "95fcc718-5c12-423d-b062-b9935d3f8506",
"name": "Buscar_FAQs_Relevantes",
"type": "n8n-nodes-base.code",
"position": [
-1264,
2448
],
"parameters": {
"jsCode": "function limpiarTexto(texto) {\n if (!texto) return '';\n return texto\n .toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[\u00bf?\u00a1!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n// Leer la pregunta del Telegram Trigger\n// En runOnceForAllItems, usar .all() para acceder al primer (y normalmente \u00fanico) mensaje\nconst telegramInput = $('Telegram Trigger - Inicio').all()[0]?.json || {};\nconst preguntaUsuario = telegramInput.message?.text || '';\nconst chatId = telegramInput.message?.chat?.id || '';\n\n// Las FAQs vienen del input (MongoDB)\nconst todasLasFAQs = $input.all();\n\nconst preguntaLimpia = limpiarTexto(preguntaUsuario);\nconst palabrasUsuario = preguntaLimpia.split(' ').filter(p => p.length > 2);\n\nconst faqsRelevantes = [];\n\nconsole.log(`YOUR_AWS_SECRET_KEY_HERE`);\nconsole.log(`Pregunta usuario: \"${preguntaUsuario}\"`);\nconsole.log(`Chat ID: ${chatId}`);\nconsole.log(`Palabras clave: ${palabrasUsuario.join(', ')}`);\nconsole.log(`Total FAQs en MongoDB: ${todasLasFAQs.length}`);\n\nfor (const faq of todasLasFAQs) {\n const preguntaFAQ = faq.json.PREGUNTA || faq.json.pregunta || '';\n if (!preguntaFAQ) continue;\n \n const preguntaFAQLimpia = limpiarTexto(preguntaFAQ);\n \n let coincidencias = 0;\n for (const palabra of palabrasUsuario) {\n if (preguntaFAQLimpia.includes(palabra)) {\n coincidencias++;\n }\n }\n \n if (coincidencias >= 2) {\n const score = coincidencias / palabrasUsuario.length;\n \n faqsRelevantes.push({\n pregunta: preguntaFAQ,\n respuesta: faq.json.RESPUESTA || faq.json.respuesta || '',\n enlaces: faq.json.ENLACES || faq.json.enlaces || null,\n similitud: Math.round(score * 100) / 100,\n coincidencias: coincidencias\n });\n }\n}\n\nfaqsRelevantes.sort((a, b) => b.coincidencias - a.coincidencias);\nconst top10 = faqsRelevantes.slice(0, 10);\n\nconsole.log(`FAQs encontradas: ${top10.length}`);\nif (top10.length > 0) {\n console.log(`Top 5 candidatas:`);\n top10.slice(0, 5).forEach((faq, i) => {\n console.log(` ${i+1}. \"${faq.pregunta.substring(0, 70)}...\" (${faq.coincidencias} palabras)`);\n });\n} else {\n console.log(`\u26a0\ufe0f NO SE ENCONTRARON FAQs`);\n}\nconsole.log(`YOUR_AWS_SECRET_KEY_HERE`);\n\nreturn [{\n json: {\n pregunta_usuario: preguntaUsuario,\n contexto_faqs: top10,\n total_encontradas: top10.length,\n chat_id: chatId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "d5e736d4-9d75-44d5-b1c8-dfe94b289e68",
"name": "Respuesta Usuario1",
"type": "n8n-nodes-base.switch",
"position": [
-1616,
1184
],
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "3874c471-84a4-4058-8a35-0cc833b8617d",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.callback_query.data }}",
"rightValue": "feedback_yes"
}
]
}
},
{
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "a53cc39a-2efc-4196-9425-1afba6fc8d7d",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.callback_query.data }}",
"rightValue": "feedback_no"
}
]
}
}
]
},
"options": {}
},
"typeVersion": 3.3
},
{
"id": "d9139b9c-e970-4c60-88b8-47bd105ab0aa",
"name": "S\u00ed - Agradecimiento1",
"type": "n8n-nodes-base.telegram",
"position": [
-1264,
1008
],
"parameters": {
"text": "\ud83c\udf89 \u00a1Genial! Me alegra mucho saber que pude ayudarte \ud83d\udcaa Si necesitas algo m\u00e1s, estoy aqu\u00ed para ti \ud83e\udd16",
"chatId": "={{ $json.result.id }}",
"additionalFields": {
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "9c41f1ef-12bf-4620-9719-821461334db6",
"name": "No - Mensaje Feedback1",
"type": "n8n-nodes-base.telegram",
"position": [
-1264,
1152
],
"parameters": {
"text": "=\ud83d\ude14 Lamento que la informaci\u00f3n no te haya sido \u00fatil.\n\u00bfPodr\u00edas contarme qu\u00e9 necesitabas exactamente para mejorar mi ayuda? \ud83e\udd16",
"chatId": "={{ $json.callback_query.message.chat.id }}",
"forceReply": {
"force_reply": true
},
"replyMarkup": "forceReply",
"additionalFields": {
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "cc0258e3-303c-44e5-8c96-80287ba1ab88",
"name": "Comandos1",
"type": "n8n-nodes-base.switch",
"position": [
-1248,
1664
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "/help",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "62d19c71-6b1b-4d67-9a45-8aff710df07c",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/help\" }}",
"rightValue": "/start"
}
]
},
"renameOutput": true
},
{
"outputKey": "/faqs",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "4a392957-3709-4c6e-a7ac-6f6004836860",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/faqs\" }}",
"rightValue": ""
}
]
},
"renameOutput": true
},
{
"outputKey": "/contact",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "32b9b535-3e0a-4d3f-8691-fdc1e955a9ce",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/contact\" }}",
"rightValue": ""
}
]
},
"renameOutput": true
},
{
"outputKey": "/events",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "9393b68b-7c44-48cf-b2d0-d23289673ff6",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/events\" }}",
"rightValue": ""
}
]
},
"renameOutput": true
},
{
"outputKey": "/feedback",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "df2037b1-36f0-4428-bdd4-b5118f687d8f",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/feedback\" }}",
"rightValue": ""
}
]
},
"renameOutput": true
},
{
"outputKey": "/start",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "58d0352b-c98a-4ac7-bf60-f6a6e6919744",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json[\"message\"] && $json[\"message\"][\"text\"] === \"/start\" }}",
"rightValue": ""
}
]
},
"renameOutput": true
}
]
},
"options": {},
"looseTypeValidation": true
},
"typeVersion": 3.3
},
{
"id": "79a97506-c1e2-4049-a62a-e5820b681a0d",
"name": "Inicio - Feedback",
"type": "n8n-nodes-base.telegram",
"position": [
-880,
1312
],
"parameters": {
"text": "=\ud83d\udcac *Enviar Feedback*\n\n\u00bfTienes alguna sugerencia o comentario sobre el bot? \n\n\ud83d\udcdd Escribe tu mensaje a continuaci\u00f3n y lo revisaremos.\n\n\ud83e\udd16 *\u00a1Tu opini\u00f3n es importante!* \n\n\ud83d\udca1 *Tip:* Los botones \ud83d\udc4d \ud83d\udc4e aparecen despu\u00e9s de cada respuesta para que califiques la informaci\u00f3n.",
"chatId": "={{ $json.message.chat.id }}",
"replyMarkup": "inlineKeyboard",
"inlineKeyboard": {
"rows": [
{
"row": {
"buttons": [
{
"text": "\ud83d\udc4d S\u00ed, me ayud\u00f3",
"additionalFields": {
"callback_data": "feedback_yes"
}
},
{
"text": "\ud83d\udc4e No, no me ayud\u00f3",
"additionalFields": {
"callback_data": "feedback_no"
}
}
]
}
}
]
},
"additionalFields": {
"parse_mode": "Markdown",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "accfeb10-fcb6-4b1e-a1bb-a40ed82ebcdd",
"name": "FAQs",
"type": "n8n-nodes-base.telegram",
"position": [
-880,
784
],
"parameters": {
"text": "\ud83d\udcac Secci\u00f3n de Consultas \u2013 ChatBot ESPOL\n\nEst\u00e1s en la secci\u00f3n de preguntas frecuentes.\nAqu\u00ed puedes escribir cualquier duda o consulta sobre la universidad \u2014por ejemplo, temas de matr\u00edcula, eventos, horarios o servicios estudiantiles\u2014 y te responder\u00e9 enseguida.\n\n\u270d\ufe0f Escribe tu pregunta para continuar.\nPor ejemplo: \u201cNecesito informaci\u00f3n sobre los eventos de esta semana\u201d o \u201c\u00bfD\u00f3nde puedo comunicarme con admisiones?\u201d",
"chatId": "={{ $('Telegram Trigger - Inicio').item.json.message.chat.id }}",
"additionalFields": {
"parse_mode": "Markdown",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "ccd1f1ed-5aa2-402c-a40e-5650ee6066d7",
"name": "Leer FAQs de MongoDB",
"type": "n8n-nodes-base.mongoDb",
"position": [
-1520,
2448
],
"parameters": {
"options": {},
"collection": "espol_faqs"
},
"credentials": {
"mongoDb": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "6529ea5d-0af3-4acc-b81b-087730a484b6",
"name": "Telegram Trigger - Inicio",
"type": "n8n-nodes-base.telegramTrigger",
"position": [
-1872,
1744
],
"parameters": {
"updates": [
"message",
"callback_query",
"*"
],
"additionalFields": {}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "5bfddfd3-92ac-432f-aa64-3f19ab58f30a",
"name": "Respuesta Contact",
"type": "n8n-nodes-base.telegram",
"position": [
-880,
960
],
"parameters": {
"text": "=\ud83d\udcde *Informaci\u00f3n de Contacto ESPOL*\n\n\ud83c\udf10 Web: https://www.espol.edu.ec\n\ud83d\udce7 Email: admision@espol.edu.ec\n\ud83d\udcf1 Tel\u00e9fono: (04) 2269-269\n\n\ud83d\udccd Campus Gustavo Galindo Velasco\nKm. 30.5 V\u00eda Perimetral\nGuayaquil, Ecuador\n\n\ud83d\udce7 Otros contactos:\nComunicaci\u00f3n: comunicacion@espol.edu.ec\nRelaciones Externas y Vinculaci\u00f3n Corporativa: relex@espol.edu.ec\nPostgrados: postgrad@espol.edu.ec\nTransparencia: transparencia@espol.edu.ec",
"chatId": "={{ $('Telegram Trigger - Inicio').item.json.message.chat.id }}",
"additionalFields": {
"parse_mode": "Markdown",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "8aa3525d-815a-486a-92cb-9d9e5da991e5",
"name": "Respuesta Events",
"type": "n8n-nodes-base.telegram",
"position": [
-880,
1136
],
"parameters": {
"text": "=\ud83d\udcc5 *Pr\u00f3ximos Eventos Acad\u00e9micos*\n\nPara ver el calendario completo de eventos, visita:\nhttps://www.espol.edu.ec/es/calendario-academico\n\nO preg\u00fantame sobre fechas espec\u00edficas, por ejemplo:\n\"\u00bfCu\u00e1ndo son las vacaciones?\"\n\"\u00bfCu\u00e1ndo es el examen de admisi\u00f3n?\"",
"chatId": "={{ $json.message.chat.id }}",
"additionalFields": {
"parse_mode": "Markdown",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "767c3c89-dfb0-4505-8c87-a0208fe31aba",
"name": "Limpiar texto Gemini",
"type": "n8n-nodes-base.code",
"position": [
-480,
2448
],
"parameters": {
"jsCode": "const geminiResponse = $input.first().json;\nconst chatId = $('Buscar_FAQs_Relevantes').first().json.chat_id;\n\nlet texto = '';\n\ntry {\n if (geminiResponse.candidates && geminiResponse.candidates[0] && geminiResponse.candidates[0].content && geminiResponse.candidates[0].content.parts) {\n texto = geminiResponse.candidates[0].content.parts[0].text;\n } else if (geminiResponse.content && geminiResponse.content.parts) {\n texto = geminiResponse.content.parts[0].text;\n } else if (geminiResponse.text) {\n texto = geminiResponse.text;\n } else {\n texto = \"Lo siento, hubo un problema al procesar la respuesta.\";\n }\n} catch (error) {\n texto = \"Lo siento, ocurrio un error.\";\n}\n\nif (texto && texto.length > 0) {\n texto = texto.replace(/\\*\\*/g, '').replace(/`/g, '').trim();\n}\n\nreturn [{\n json: {\n texto_limpio: texto,\n chat_id: chatId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "7cabbbc4-e002-4c04-a4d9-b5c3633e98af",
"name": "Get a chat",
"type": "n8n-nodes-base.telegram",
"position": [
-1488,
1056
],
"parameters": {
"chatId": "={{ $json.callback_query.message.chat.id }}",
"resource": "chat"
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "b5cdf1bf-6f19-4e62-a812-022e5fe6073c",
"name": "Switch_Que_BD_Leer",
"type": "n8n-nodes-base.switch",
"position": [
-1856,
2304
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "=Consultar Calendario",
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "9404fad9-6063-443e-9a4c-80c86a20a6a9",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.es_consulta_calendario }}",
"rightValue": ""
}
]
},
"renameOutput": true
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"typeVersion": 3.3
},
{
"id": "09ddecdf-9223-4371-bd53-9d836315b7a3",
"name": "Detector_Calendario_Pre",
"type": "n8n-nodes-base.code",
"position": [
-2064,
2304
],
"parameters": {
"jsCode": "// ===== DETECTOR PRE-LECTURA DE CALENDARIO =====\n\nfunction limpiarTexto(texto) {\n if (!texto) return '';\n return texto\n .toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[\u00bf?\u00a1!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n// Procesar todos los items entrantes del Trigger\nconst inputItems = $('Telegram Trigger - Inicio').all();\nconst resultados = [];\n\nfor (const item of inputItems) {\n const preguntaUsuario = item.json.message?.text || '';\n const chatId = item.json.message?.chat?.id || '';\n const preguntaLimpia = limpiarTexto(preguntaUsuario);\n\n // ===== PALABRAS CLAVE DE CALENDARIO =====\n const palabrasClaveCalendario = {\n eventos: [\n 'matricula', 'matriculas', 'matriculacion',\n 'inicio de clases', 'fin de clases', 'clases',\n 'primer termino', 'segundo termino', 'tercer termino',\n 'examenes', 'examen', 'prueba', 'pruebas',\n 'evaluaciones', 'evaluacion',\n 'suspension', 'feriado', 'feriados',\n 'vacaciones', 'receso',\n 'cambio de carrera', 'cambio de materia',\n 'retiro', 'retiros',\n 'solicitudes', 'solicitud',\n 'inscripciones', 'inscripcion',\n 'graduacion', 'grado',\n 'titulacion',\n 'becas', 'beca',\n 'nivelacion'\n ],\n \n tiempo: [\n 'cuando', 'fecha', 'fechas', 'dia', 'dias',\n 'plazo', 'plazos', 'periodo', 'periodos',\n 'termino', 'terminos', 'semestre', 'a\u00f1o',\n 'inicio', 'fin', 'cierre', 'apertura',\n 'hasta cuando', 'desde cuando',\n 'calendario', 'cronograma',\n 'horario', 'horarios',\n 'proxima', 'proximo', 'siguiente'\n ],\n \n terminos: [\n '2024-2025', '2025-2026',\n 'PAO', 'PAOI', 'PAOII', 'IPAO', 'IIPAO', 'PAO1', 'PAO2',\n 'primer', 'segundo', 'tercer',\n 'trimestre', 'bimestre', 'semestral'\n ]\n };\n\n // Combinar todas las palabras clave\n const todasLasPalabrasClave = [\n ...palabrasClaveCalendario.eventos,\n ...palabrasClaveCalendario.tiempo,\n ...palabrasClaveCalendario.terminos\n ];\n\n // ===== DETECCI\u00d3N =====\n let palabrasEncontradas = [];\n let puntuacion = 0;\n\n for (const palabra of todasLasPalabrasClave) {\n if (preguntaLimpia.includes(palabra)) {\n palabrasEncontradas.push(palabra);\n if (palabrasClaveCalendario.eventos.includes(palabra)) {\n puntuacion += 2;\n } else {\n puntuacion += 1;\n }\n }\n }\n\n // Decisi\u00f3n: \u00bfEs consulta de calendario?\n const esConsultaCalendario = puntuacion >= 2 || palabrasEncontradas.length >= 2;\n\n // LOGS\n console.log(`YOUR_AWS_SECRET_KEY_HERE`);\n console.log(`\ud83d\udd0d DETECTOR PRE-LECTURA DE CALENDARIO`);\n console.log(`Pregunta: \"${preguntaUsuario}\"`);\n console.log(`Palabras encontradas: ${palabrasEncontradas.join(', ') || 'ninguna'}`);\n console.log(`Puntuaci\u00f3n: ${puntuacion}`);\n console.log(`\u00bfEs calendario? ${esConsultaCalendario ? '\u2705 S\u00cd' : '\u274c NO'}`);\n console.log(`${esConsultaCalendario ? '\u2192 Ir\u00e1 a BD_Calendario' : '\u2192 Ir\u00e1 a FAQs (flujo normal)'}`);\n console.log(`YOUR_AWS_SECRET_KEY_HERE`);\n\n resultados.push({\n json: {\n pregunta_usuario: preguntaUsuario,\n chat_id: chatId,\n es_consulta_calendario: esConsultaCalendario,\n palabras_encontradas: palabrasEncontradas,\n puntuacion: puntuacion\n }\n });\n}\n\nreturn resultados;"
},
"typeVersion": 2
},
{
"id": "81ea9f6c-121c-4bc5-99e4-e947dae8bf7a",
"name": "Mensaje de Gemini",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"position": [
-800,
2448
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "models/gemini-2.5-flash-lite-preview-06-17",
"cachedResultName": "models/gemini-2.5-flash-lite-preview-06-17"
},
"options": {
"temperature": 0.3,
"maxOutputTokens": 2000
},
"messages": {
"values": [
{
"content": "={{ $json.prompt }}"
}
]
},
"simplify": false
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1,
"alwaysOutputData": false
},
{
"id": "8116c6c4-e4ca-4450-89ab-17df67b4b886",
"name": "Enviar Respuesta sobre faqs",
"type": "n8n-nodes-base.telegram",
"position": [
-224,
2448
],
"parameters": {
"text": "={{ $json.texto_limpio }}\n\n\ud83d\udcac \u00bfDeseas revisar otra consulta o hacer una nueva pregunta?",
"chatId": "={{ $json.chat_id }}",
"replyMarkup": "inlineKeyboard",
"inlineKeyboard": {
"rows": [
{
"row": {
"buttons": [
{
"text": "\ud83d\udc4d S\u00ed",
"additionalFields": {
"callback_data": "=feedback_yes"
}
},
{
"text": "\ud83d\udc4e No",
"additionalFields": {
"callback_data": "=feedback_no"
}
}
]
}
}
]
},
"additionalFields": {
"parse_mode": "HTML",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "5f4eed47-a777-4d27-a80a-bcc145808f42",
"name": "Guardado en cvs Feedback",
"type": "n8n-nodes-base.googleSheets",
"position": [
-1264,
1296
],
"parameters": {
"columns": {
"value": {
"id": "={{ $json.message.chat.id }}",
"Nombre": "={{ $json.message.reply_to_message.chat.first_name }}",
"Comentario": "={{ $json.message.text }}"
},
"schema": [
{
"id": "id",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "id",
"defaultMatch": true,
"canBeUsedToMatch": true
},
{
"id": "Nombre",
"type": "string",
"display": true,
"required": false,
"displayName": "Nombre",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Comentario",
"type": "string",
"display": true,
"required": false,
"displayName": "Comentario",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"id"
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1dcEsIPMMnjBtQ1YXy5Zc50OWwBVOcWXpxkJUJWZmE4E/edit#gid=0",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1dcEsIPMMnjBtQ1YXy5Zc50OWwBVOcWXpxkJUJWZmE4E",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1dcEsIPMMnjBtQ1YXy5Zc50OWwBVOcWXpxkJUJWZmE4E/edit?usp=drivesdk",
"cachedResultName": "Feedback"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
},
{
"id": "cdf6ad08-7f31-467e-9feb-9d3a8e1c9ba6",
"name": "Construir_Prompt_Calendario1",
"type": "n8n-nodes-base.code",
"position": [
-1008,
2160
],
"parameters": {
"jsCode": "const preguntaUsuario = $json.consulta || $json.pregunta_usuario || $json.pregunta || '';\nconst chatId = $json.chat_id;\n\n// Preferir el nuevo esquema del buscador\nlet eventos = Array.isArray($json.resultados) ? $json.resultados : [];\n\n// Compatibilidad con el arreglo antiguo { eventos_calendario: [...] }\nif ((!eventos || eventos.length === 0) && Array.isArray($json.eventos_calendario)) {\n eventos = $json.eventos_calendario.map((e) => ({\n PERIODO_FECHAS: e.periodo || e.PERIODO_FECHAS || '',\n ACTIVIDADES_GRADO: e.actividades_grado || e.ACTIVIDADES_GRADO || '',\n PROCESOS_GRADO: e.procesos_grado || e.PROCESOS_GRADO || '',\n ACTIVIDADES_FORMACION: e.actividades_formacion || e.ACTIVIDADES_FORMACION || '',\n fecha_inicio_ts: e.fecha_inicio_ts ?? null,\n score: e.score ?? null,\n }));\n}\n\n// Se\u00f1al de \u201cdemasiadas coincidencias\u201d y facetas sugeridas (si vienen del buscador)\nconst demasiadas = Boolean($json.demasiadas_coincidencias);\nconst filtros = $json.filtros_sugeridos || null;\n\n// === Funciones auxiliares ===\nfunction safe(v) { return (v == null ? '' : String(v)); }\n\nfunction fmtFecha(ts) {\n if (ts == null) return '';\n try {\n const d = new Date(Number(ts));\n if (isNaN(d.getTime())) return '';\n const dd = String(d.getDate()).padStart(2, '0');\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const yyyy = d.getFullYear();\n return `${yyyy}-${mm}-${dd}`;\n } catch { return ''; }\n}\n\n// === Determinar PAO actual seg\u00fan la fecha del sistema ===\nconst ahora = new Date();\nconst mesActual = ahora.getMonth() + 1;\nconst anioActual = ahora.getFullYear();\n\nlet paoActual = '';\nif (mesActual >= 5 && mesActual <= 9) paoActual = 'PAO I';\nelse if (mesActual >= 9 || mesActual <= 2) paoActual = 'PAO II';\nelse paoActual = 'Vacaciones o PAE';\n\nconst contextoTiempo = `\ud83d\udcc6 Fecha actual: ${anioActual}-${String(mesActual).padStart(2, '0')} (${paoActual} en curso)`;\n\n// === Ordenar eventos por relevancia y fecha ===\nconst eventosOrdenados = [...eventos].sort((a, b) => {\n const sa = (a.score == null) ? -Infinity : Number(a.score);\n const sb = (b.score == null) ? -Infinity : Number(b.score);\n if (sb !== sa) return sb - sa;\n\n const ta = (a.fecha_inicio_ts == null) ? Number.POSITIVE_INFINITY : Number(a.fecha_inicio_ts);\n const tb = (b.fecha_inicio_ts == null) ? Number.POSITIVE_INFINITY : Number(b.fecha_inicio_ts);\n return ta - tb;\n});\n\nlet contextoCalendario = '';\n\nif (eventosOrdenados.length > 0) {\n contextoCalendario = '\ud83d\udcc5 He encontrado estas coincidencias (ordenadas por relevancia):\\n\\n';\n\n // Mostrar solo el top 10\n const toShow = eventosOrdenados.slice(0, 10);\n\n contextoCalendario += toShow.map((ev, idx) => {\n const periodo = safe(ev.PERIODO_FECHAS || ev.periodo);\n const ag = safe(ev.ACTIVIDADES_GRADO || ev.actividades_grado);\n const pg = safe(ev.PROCESOS_GRADO || ev.procesos_grado);\n const af = safe(ev.ACTIVIDADES_FORMACION || ev.actividades_formacion);\n const fechaISO = fmtFecha(ev.fecha_inicio_ts);\n const score = (ev.score != null) ? Number(ev.score).toFixed(3) : '';\n\n let info = `${idx + 1}. \ud83d\udccc ${periodo || '(sin fecha)'}`;\n if (fechaISO) info += `\\n \ud83d\uddd3\ufe0f Inicio (estimado): ${fechaISO}`;\n if (ag.trim()) info += `\\n \ud83c\udf93 Actividades de Grado: ${ag}`;\n if (pg.trim()) info += `\\n \ud83d\udccb Procesos: ${pg}`;\n if (af.trim()) info += `\\n \ud83d\udd27 Formaci\u00f3n T\u00e9cnica: ${af}`;\n if (score) info += `\\n \ud83d\udd0e score:${score}`;\n return info;\n }).join('\\n\\n');\n\n // Sugerencias si hay demasiadas coincidencias\n if (demasiadas) {\n const tips = [];\n if (filtros?.posibles_anios?.length) tips.push(`\u2022 especifica a\u00f1o: ${filtros.posibles_anios.slice(-3).join(' / ')}`);\n if (filtros?.posibles_meses?.length) tips.push(`\u2022 a\u00f1ade mes: ${filtros.posibles_meses.slice(0, 4).join(' / ')}`);\n if (filtros?.posibles_tipos_evaluacion?.length) tips.push(`\u2022 tipo de evaluaci\u00f3n: ${filtros.posibles_tipos_evaluacion.join(' / ')}`);\n if (filtros?.posibles_ciclos?.length) tips.push(`\u2022 ciclo: ${filtros.posibles_ciclos.join(' / ')}`);\n\n if (tips.length) {\n contextoCalendario += '\\n\\n\u26a0\ufe0f Hay muchas coincidencias. Sugerencias para refinar:\\n' + tips.join('\\n');\n }\n }\n\n} else {\n contextoCalendario = 'No encontr\u00e9 informaci\u00f3n espec\u00edfica en el calendario acad\u00e9mico para tu consulta.';\n}\n\n// === Reglas de interpretaci\u00f3n PAO/PAE ===\nconst reglasPAO = `\nREGLAS DE INTERPRETACI\u00d3N (PAO/PAE y feriados):\n\u2022 Si un evento dice \u201cvacaciones\u201d y la fecha cae ENTRE finales de febrero y los primeros d\u00edas de mayo \u21d2 corresponde a VACACIONES del PAO II.\n\u2022 Desde inicios de mayo hasta inicios/mediados de septiembre \u21d2 corresponde a PAO I.\n\u2022 Si un evento cae entre finales de septiembre y la 1.\u00aa o 2.\u00aa semana de febrero \u21d2 corresponde a PAO II (per\u00edodo lectivo).\n\u2022 En PAO II, si aparecen \u201cvacaciones estudiantiles\u201d del 28 de diciembre al 1 de enero \u21d2 son feriado (no vacaciones entre PAOs).\n\u2022 PAE (Programa de Acompa\u00f1amiento/Evaluaci\u00f3n) ocurre normalmente desde inicios de marzo hasta finales de abril o inicios de mayo.\n\u2022 Si dentro de un PAO aparece \u201cvacaciones\u201d, interpr\u00e9talas como feriados espec\u00edficos, no como cambio de PAO.\n\u2022 Si el evento no indica expl\u00edcitamente PAO I o II, infi\u00e9relo con estas reglas. Si hay ambig\u00fcedad o la fecha cae fuera de rango, indica que no hay informaci\u00f3n espec\u00edfica.\n`.trim();\n\n// === Construcci\u00f3n final del prompt ===\nconst prompt = `Eres un asistente de ESPOL especializado en calendario acad\u00e9mico.\n\n${contextoTiempo}\n\nPregunta del estudiante:\n\"${preguntaUsuario}\"\n\n${contextoCalendario}\n\n${reglasPAO}\n\nINSTRUCCIONES IMPORTANTES (ESTRICTAS):\n1) Si existe la lista anterior, elige SOLO 1 evento que sea la MEJOR COINCIDENCIA para la pregunta.\n \u2022 Considera intenci\u00f3n (evaluaci\u00f3n/vacaciones/inicio/fin), ordinal (primera/segunda/tercera),\n y a\u00f1o/mes impl\u00edcitos o expl\u00edcitos.\n \u2022 Si ning\u00fan evento es claramente pertinente, responde que NO hay informaci\u00f3n espec\u00edfica.\n2) No inventes datos. No cites eventos que no est\u00e9n arriba.\n3) Si das un resultado, pres\u00e9ntalo claro y breve con emojis, destacando FECHA y ACTIVIDAD.\n4) Si NO hay informaci\u00f3n espec\u00edfica, sugiere contactar a:\n \ud83d\udce7 Email: user@example.com\n \ud83d\udcde Tel\u00e9fono: (04) 2269-269\n \ud83c\udf10 Web: https://www.espol.edu.ec\n5) Si identificas el PAO (I o II) por las fechas, ind\u00edcalo entre par\u00e9ntesis o nota breve (\u201cseg\u00fan fechas corresponde al PAO I/II\u201d).\n6) Responde de forma clara, organizada y \u00fatil.`;\n\n// === Salida para el siguiente nodo (Gemini) ===\nreturn [{\n json: {\n prompt,\n chat_id: chatId\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "728f012c-ceed-400d-a643-8f1353d9f8f1",
"name": "Message a model1",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"position": [
-800,
2160
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "models/gemini-2.5-flash-preview-05-20",
"cachedResultName": "models/gemini-2.5-flash-preview-05-20"
},
"options": {
"temperature": 0.3,
"maxOutputTokens": 200000000
},
"messages": {
"values": [
{
"content": "={{ $json.prompt }}"
}
]
},
"simplify": false
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "4af01328-9345-4714-a79d-ea81b2a16a38",
"name": "Limpiar_Texto_Gemini1",
"type": "n8n-nodes-base.code",
"position": [
-480,
2160
],
"parameters": {
"jsCode": "// ---- 1) Extracci\u00f3n robusta del texto de Gemini ----\nfunction extraerTextoGemini(payload) {\n // Formato m\u00e1s com\u00fan: candidates[0].content.parts[*].text\n try {\n const parts = payload?.candidates?.[0]?.content?.parts;\n if (Array.isArray(parts) && parts.length > 0) {\n // Concatenar todos los parts .text\n const textos = parts\n .map(p => (typeof p?.text === 'string' ? p.text : ''))\n .filter(Boolean);\n if (textos.length) return textos.join('\\n').trim();\n }\n } catch {}\n\n // Alternativos:\n if (typeof payload?.text === 'string' && payload.text.trim()) {\n return payload.text.trim();\n }\n\n if (Array.isArray(payload?.content?.parts) && payload.content.parts.length > 0) {\n const textos2 = payload.content.parts\n .map(p => (typeof p?.text === 'string' ? p.text : ''))\n .filter(Boolean);\n if (textos2.length) return textos2.join('\\n').trim();\n }\n\n // \u00daltimo recurso: stringify recortado\n try {\n const s = JSON.stringify(payload);\n if (s && s.length) return s.substring(0, 8000);\n } catch {}\n\n return 'Lo siento, no pude procesar tu consulta.';\n}\n\nconst respuestaGemini = extraerTextoGemini($json);\n\n// ---- 2) Recuperar chat_id desde varios nodos/ubicaciones ----\nlet chatId = $json.chat_id;\n\nif (!chatId) {\n // Tu prompt actual\n try { chatId = $('Construir_Prompt_Calendario1').first()?.json?.chat_id; } catch {}\n}\nif (!chatId) {\n // Nombre anterior\n try { chatId = $('Construir_Prompt_Calendario').first()?.json?.chat_id; } catch {}\n}\nif (!chatId) {\n try { chatId = $('Buscar_Eventos_Calendario').first()?.json?.chat_id; } catch {}\n}\nif (!chatId) {\n try { chatId = $('Detector_Calendario_Pre').first()?.json?.chat_id; } catch {}\n}\nif (!chatId) {\n // Telegram Trigger\n try { chatId = $('Telegram Trigger - Inicio').first()?.json?.message?.chat?.id; } catch {}\n}\n\n// ---- 3) Limpieza / saneamiento para enviar por Telegram con parse_mode HTML ----\nfunction escapeHTML(s) {\n // Evita que Telegram interprete etiquetas si usas parse_mode: \"HTML\"\n return s.replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>');\n}\n\nfunction limpiarRespuesta(texto) {\n if (!texto) return 'Lo siento, no obtuve una respuesta v\u00e1lida.';\n\n // Eliminar bloques de c\u00f3digo ```...``` (incluye variantes con lenguaje)\n texto = texto.replace(/```[\\s\\S]*?```/g, '');\n\n // Eliminar comillas invertidas inline `\n texto = texto.replace(/`+/g, '');\n\n // Convertir enlaces markdown [texto](url) \u2192 \"texto (url)\"\n texto = texto.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '$1 ($2)');\n\n // Quitar markdown simple de estilo: **negritas**, *it\u00e1licas*, __subrayado__ _\n texto = texto.replace(/\\*\\*(.*?)\\*\\*/g, '$1')\n .replace(/\\*(.*?)\\*/g, '$1')\n .replace(/__(.*?)__/g, '$1')\n .replace(/_(.*?)_/g, '$1');\n\n // Quitar encabezados markdown (##, ###, etc.)\n texto = texto.replace(/^\\s{0,3}#{1,6}\\s+/gm, '');\n\n // Normalizar saltos y espacios\n texto = texto.replace(/\\r/g, '')\n .replace(/\\n{3,}/g, '\\n\\n')\n .replace(/[ \\t]+\\n/g, '\\n')\n .trim();\n\n // Escapar HTML para Telegram (parse_mode: \"HTML\")\n texto = escapeHTML(texto);\n\n // L\u00edmite prudente para Telegram (4096 es el duro). Dejamos margen.\n const LIMITE = 3500;\n if (texto.length > LIMITE) {\n texto = texto.substring(0, LIMITE - 20).trimEnd() + '\u2026 (mensaje acortado)';\n }\n\n return texto;\n}\n\nconst textoLimpio = limpiarRespuesta(respuestaGemini);\n\n// ---- 4) Logs \u00fatiles ----\nconsole.log(`YOUR_AWS_SECRET_KEY_HERE`);\nconsole.log(`\ud83e\uddf9 LIMPIEZA DE TEXTO`);\nconsole.log(`Chat ID encontrado: ${chatId}`);\nconsole.log(`Texto original (100): ${String(respuestaGemini).substring(0, 100)}...`);\nconsole.log(`Texto limpio (100): ${textoLimpio.substring(0, 100)}...`);\nconsole.log(`YOUR_AWS_SECRET_KEY_HERE`);\n\n// ---- 5) Salida ----\nreturn [{\n json: {\n texto_limpio: textoLimpio,\n chat_id: chatId,\n respuesta_original: respuestaGemini\n }\n}];"
},
"typeVersion": 2
},
{
"id": "84c7ffb2-d72d-432a-ac54-9892d31ac5b9",
"name": "Enviar Respuesta sobre calendario1",
"type": "n8n-nodes-base.telegram",
"position": [
-240,
2160
],
"parameters": {
"text": "={{ $json.texto_limpio }}\n\n\ud83d\udcac \u00bfDeseas revisar otra consulta o hacer una nueva pregunta?",
"chatId": "={{ $json.chat_id }}",
"replyMarkup": "inlineKeyboard",
"inlineKeyboard": {
"rows": [
{
"row": {
"buttons": [
{
"text": "\ud83d\udc4d S\u00ed",
"additionalFields": {
"callback_data": "=feedback_yes"
}
},
{
"text": "\ud83d\udc4e No",
"additionalFields": {
"callback_data": "=feedback_no"
}
}
]
}
}
]
},
"additionalFields": {
"parse_mode": "HTML",
"appendAttribution": false
}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "144e290f-5f0a-400b-b82b-269d90d5f32a",
"name": "Calendario Acad\u00e9mico",
"type": "n8n-nodes-base.googleSheets",
"position": [
-1520,
2160
],
"parameters": {
"options": {
"outputFormatting": {
"values": {
"date": "FORMATTED_STRING",
"general": "UNFORMATTED_VALUE"
}
},
"returnFirstMatch": false
},
"sheetName": {
"__rl": true,
"mode": "list",
"value": 1817620056,
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1WWE-eLP0g5M9Q2CeGpQZat56OhJrQyeYZJShjtETCgo/edit#gid=1817620056",
"cachedResultName": "CALENDAR"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1WWE-eLP0g5M9Q2CeGpQZat56OhJrQyeYZJShjtETCgo",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1WWE-eLP0g5M9Q2CeGpQZat56OhJrQyeYZJShjtETCgo/edit?usp=drivesdk",
"cachedResultName": "BASE-DATOS-FAQ_ESPOL"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
},
{
"id": "21267be1-e42b-4362-aefc-42bf96705258",
"name": "Buscar_Eventos_Calendario1",
"type": "n8n-nodes-base.code",
"position": [
-1264,
2160
],
"parameters": {
"jsCode": "const detectorItems = $('Detector_Calendario_Pre').all();\nconst detector =\n Array.isArray(detectorItems) && detectorItems.length > 0\n ? detectorItems[0].json\n : {};\nconst preguntaUsuario = detector.pregunta_usuario || '';\nconst chatId = detector.chat_id ?? null;\nconst filas = $input.all().map(it => it.json || {});\n\nconst HOY = new Date();\nconst ANIO_ACTUAL = HOY.getFullYear();\nconst ANIO_SIG = ANIO_ACTUAL + 1;\n\n// ---------- helpers ----------\nfunction limpiarTexto(t) {\n if (!t) return '';\n return String(t)\n .toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9\u00f1\\s]/gi, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst STOP = new Set([\n \"de\",\"la\",\"que\",\"el\",\"en\",\"y\",\"a\",\"los\",\"del\",\"se\",\"las\",\"por\",\"un\",\"para\",\"con\",\"no\",\"una\",\n \"su\",\"al\",\"lo\",\"como\",\"mas\",\"m\u00e1s\",\"pero\",\"sus\",\"le\",\"ya\",\"o\",\"fue\",\"ha\",\"si\",\"s\u00ed\",\"porque\",\n \"muy\",\"sin\",\"sobre\",\"tambien\",\"tambi\u00e9n\",\"entre\",\"cuando\",\"todo\",\"esta\",\"est\u00e1\",\"ser\",\"son\",\n \"dos\",\"han\",\"hay\",\"donde\",\"quien\",\"qui\u00e9n\",\"desde\",\"cada\",\"cual\",\"cu\u00e1l\",\"cuales\",\"cu\u00e1les\"\n]);\nfunction tokenizar(t) { return limpiarTexto(t).split(' ').filter(x => x && x.length > 2 && !STOP.has(x)); }\n\nfunction baseEs(w) {\n w = limpiarTexto(w);\n if (!w) return '';\n w = w.replace(/\\b1(ra|era)\\b/g, 'primera')\n .replace(/\\b2da\\b/g, 'segunda')\n .replace(/\\b3ra\\b/g, 'tercera');\n if (w.endsWith('es')) w = w.slice(0, -2);\n else if (w.endsWith('s')) w = w.slice(0, -1);\n w = w.replace(/evaluacione?$/,'evaluacion');\n return w;\n}\nfunction matchToken(userTok, rowTok) {\n const u = baseEs(userTok), r = baseEs(rowTok);\n if (!u || !r) return false;\n if (u === r) return true;\n if (u.length >= 5 && (r.includes(u) || u.includes(r))) return true;\n return false;\n}\n\nfunction detectarIntencion(pregunta) {\n const p = limpiarTexto(pregunta);\n if (/(vacaciones|receso|feriado|descanso)/.test(p)) return \"vacaciones\";\n if (/(evaluacion|evaluaci\u00f3n|examen|parcial|prueba|evaluar|evaluaciones)/.test(p)) return \"evaluacion\";\n if (/(inicio|empieza|matriculacion|matriculaci\u00f3n|induccion|inducci\u00f3n|apertura|arranque)/.test(p)) return \"inicio\";\n if (/(final|fin|cierre|termina|culmina|conclusion|conclusi\u00f3n)/.test(p)) return \"fin\";\n if (/(eleccion|elecci\u00f3n|votacion|votaci\u00f3n)/.test(p)) return \"elecciones\";\n return \"otro\";\n}\nfunction detectarOrdinal(p) {\n const t = limpiarTexto(p);\n if (/\\b(primera|1ra|1era)\\b/.test(t) || /\\bevaluacion\\s*i\\b/.test(t)) return 1;\n if (/\\b(segunda|2da)\\b/.test(t) || /\\bevaluacion\\s*ii\\b/.test(t)) return 2;\n if (/\\b(tercera|3ra)\\b/.test(t) || /\\bevaluacion\\s*iii\\b/.test(t)) return 3;\n return 0;\n}\n\nconst MESES = { ene:0, enero:0, feb:1, febrero:1, mar:2, marzo:2, abr:3, abril:3, may:4, mayo:4, jun:5, junio:5, jul:6, julio:6, ago:7, agosto:7, sep:8, sept:8, septiembre:8, oct:9, octubre:9, nov:10, noviembre:10, dic:11, diciembre:11 };\n\nfunction normalizarPeriodo(p) {\n if (!p) return '';\n return String(p).replace(/([0-9])([a-zA-Z])/g, '$1 $2').replace(/\\s*-\\s*/g, ' - ').replace(/\\s+/g, ' ').trim();\n}\nfunction parseFechaInicio(periodo) {\n const txt = normalizarPeriodo(periodo).toLowerCase();\n\n // \"13 - 17 julio 2026\"\n let m = /(\\d{1,2})\\s*-\\s*(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)\\s+(\\d{4})/.exec(txt);\n if (m) {\n const d1 = parseInt(m[1], 10);\n const mes = MESES[m[3].normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')];\n const year = parseInt(m[4], 10);\n if (mes != null) return new Date(year, mes, d1, 0, 0, 0, 0).getTime();\n }\n\n // \"30 marzo - 03 abril 2026\"\n m = /(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)\\s*-\\s*(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)\\s+(\\d{4})/.exec(txt);\n if (m) {\n const d1 = parseInt(m[1], 10);\n const mes1 = MESES[m[2].normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')];\n const year = parseInt(m[5], 10);\n if (mes1 != null) return new Date(year, mes1, d1, 0, 0, 0, 0).getTime();\n }\n\n // sin a\u00f1o \u2192 asume actual\n m = /(\\d{1,2})\\s*-\\s*(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)/.exec(txt);\n if (m) {\n const d1 = parseInt(m[1], 10);\n const mes1 = MESES[m[3].normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')];\n if (mes1 != null) return new Date(ANIO_ACTUAL, mes1, d1, 0, 0, 0, 0).getTime();\n }\n m = /(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)\\s*-\\s*(\\d{1,2})\\s+([a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1]+)/.exec(txt);\n if (m) {\n const d1 = parseInt(m[1], 10);\n const mes1 = MESES[m[2].normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')];\n if (mes1 != null) return new Date(ANIO_ACTUAL, mes1, d1, 0, 0, 0, 0).getTime();\n }\n return null;\n}\nfunction extraerAniosTexto(texto) {\n const t = String(texto || '');\n const m = t.match(/\\b(20\\d{2})\\b/g);\n if (!m) return new Set();\n return new Set(m.map(x => parseInt(x, 10)));\n}\nfunction extraerAniosPregunta(p) { return extraerAniosTexto(p); }\nfunction extraerMesesPregunta(p) {\n const t = limpiarTexto(p);\n const meses = new Set();\n Object.keys(MESES).forEach(k => { if (new RegExp(`\\\\b${k}\\\\b`).test(t)) meses.add(MESES[k]); });\n return meses;\n}\nfunction yearFromTs(ts) { if (ts == null) return null; try { return new Date(ts).getFullYear(); } catch { return null; } }\n\nconst intencion = detectarIntencion(preguntaUsuario);\nconst ordinalBuscado = detectarOrdinal(preguntaUsuario);\nconst tokensUsuario = tokenizar(preguntaUsuario);\nconst setTokens = new Set(tokensUsuario);\nconst aniosPregunta = extraerAniosPregunta(preguntaUsuario);\nconst mesesPregunta = extraerMesesPregunta(preguntaUsuario);\nconst HARD_FILTER_BY_YEAR = aniosPregunta.size > 0;\n\n// ---------- scoring ----------\nconst PESOS = { PERIODO_FECHAS: 0.8, ACTIVIDADES_GRADO: 1.0, PROCESOS_GRADO: 0.6, ACTIVIDADES_FORMACION: 0.9 };\nconst BONUS_INTENCION = 2.2;\nconst BONUS_FRASE_EXACTA = 1.0;\nconst BONUS_TIENE_EVALUACION = 0.7;\nconst BONUS_ORDINAL_MATCH = 1.5;\nconst PENALIZA_ORD_DISTINTO = 0.5;\nconst PENALIZA_SIN_EVAL_CON_ORD = 0.35;\n\nconst BONUS_ANIO_ACTUAL_STRONG = 1.1;\nconst PENALIZA_ANIO_NO_ACTUAL = 0.6;\nconst BONUS_MATCH_ANIO_PREGUNTA = 1.2;\nconst BONUS_MATCH_MES_ACTUAL = 0.6;\nconst BONUS_ANIO_EXPLICITO = 0.3;\n\nconst UMBRAL_SCORE_MIN = 0;\nconst UMBRAL_DEMASIADAS = 25; // \u2190 si hay m\u00e1s que esto, sugerimos refinar\n\nif (!preguntaUsuario || !limpiarTexto(preguntaUsuario)) {\n return [{ json: { ok:false, motivo:\"pregunta_vacia\", mensaje:\"No se recibi\u00f3 una pregunta (pregunta_usuario).\", chat_id:chatId } }];\n}\nif (!Array.isArray(filas) || filas.length === 0) {\n return [{ json: { ok:false, motivo:\"sin_datos\", mensaje:\"No llegaron filas desde Google Sheets.\", chat_id:chatId } }];\n}\n\nfunction filaTokens(row) {\n const F = (k) => { const v = row[k]; return (v == null) ? '' : String(v); };\n const actTecKey = 'ACTIVIDADES DE FORMACI\u00d3N T\u00c9CNICA Y TECNOL\u00d3GICA';\n const campos = {\n row_number: row.row_number ?? null,\n PERIODO_FECHAS: F('PERIODO_FECHAS'),\n ACTIVIDADES_GRADO: F('ACTIVIDADES_GRADO'),\n PROCESOS_GRADO: F('PROCESOS_GRADO'),\n ACTIVIDADES_FORMACION: F(actTecKey) || F('ACTIVIDADES_FORMACION') || ''\n };\n const textoTotal = [campos.PERIODO_FECHAS, campos.ACTIVIDADES_GRADO, campos.PROCESOS_GRADO, campos.ACTIVIDADES_FORMACION].filter(Boolean).join(' | ');\n const normTotal = limpiarTexto(textoTotal);\n const toks = {\n PERIODO_FECHAS: tokenizar(campos.PERIODO_FECHAS),\n ACTIVIDADES_GRADO: tokenizar(campos.ACTIVIDADES_GRADO),\n PROCESOS_GRADO: tokenizar(campos.PROCESOS_GRADO),\n ACTIVIDADES_FORMACION: tokenizar(campos.ACTIVIDADES_FORMACION),\n TOTAL: tokenizar(textoTotal)\n };\n const aniosTexto = extraerAniosTexto(textoTotal);\n const tsInicio = parseFechaInicio(campos.PERIODO_FECHAS);\n const anioTs = yearFromTs(tsInicio);\n const aniosFila = new Set(aniosTexto);\n if (anioTs != null) aniosFila.add(anioTs);\n let mesInicio = null;\n if (tsInicio != null) mesInicio = new Date(tsInicio).getMonth();\n\n return { campos, textoTotal, normTotal, toks, aniosFila, tsInicio, mesInicio };\n}\nfunction filaTieneOrdinalEvaluacion(norm) {\n const hasEval = /\\bevaluacion\\b/.test(norm);\n const primera = hasEval && (/\\bprimera\\b/.test(norm) || /\\bevaluacion\\s*i\\b/.test(norm) || /\\bi\\s*evaluacion\\b/.test(norm));\n const segunda = hasEval && (/\\bsegunda\\b/.test(norm) || /\\bevaluacion\\s*ii\\b/.test(norm) || /\\bii\\s*evaluacion\\b/.test(norm));\n const tercera = hasEval && (/\\btercera\\b/.test(norm) || /\\bevaluacion\\s*iii\\b/.test(norm) || /\\biii\\s*evaluacion\\b/.test(norm));\n return { hasEval, primera, segunda, tercera };\n}\nfunction intersect(setA, setB) { for (const x of setA) if (setB.has(x)) return true; return false; }\n\nfunction puntuar(row) {\n const { campos, textoTotal, normTotal, toks, aniosFila, tsInicio, mesInicio } = filaTokens(row);\n if (!normTotal) return null;\n\n if (HARD_FILTER_BY_YEAR) {\n if (aniosFila.size > 0 && !intersect(aniosFila, aniosPregunta)) return null;\n // si no hay a\u00f1o en la fila, la dejamos pasar con score bajo (puedes excluirla retornando null)\n }\n\n let score = 0, coincidencias = 0;\n const matchPorCampo = { PERIODO_FECHAS: [], ACTIVIDADES_GRADO: [], PROCESOS_GRADO: [], ACTIVIDADES_FORMACION: [] };\n const matchTokens = [];\n\n for (const t of setTokens) {\n let hit = false;\n for (const campo of Object.keys(PESOS)) {\n const arr = toks[campo];\n if (arr && arr.some(rt => matchToken(t, rt))) {\n score += PESOS[campo];\n matchPorCampo[campo].push(t);\n hit = true;\n }\n }\n if (hit) { coincidencias++; matchTokens.push(t); }\n }\n\n const INTENT_TERMS = {\n vacaciones: [\"vacaciones\",\"receso\",\"feriado\",\"descanso\",\"estudiantiles\"],\n evaluacion: [\"evaluacion\",\"evaluaciones\",\"examen\",\"parcial\",\"prueba\",\"ciclo\",\"semana de evaluacion\"],\n inicio: [\"inicio\",\"empieza\",\"apertura\",\"induccion\",\"matriculacion\",\"novatos\"],\n fin: [\"final\",\"fin\",\"cierre\",\"termina\",\"culmina\",\"conclusion\",\"proceso final\"],\n elecciones: [\"eleccion\",\"votacion\"]\n };\n if (intencion !== 'otro') {\n const terms = INTENT_TERMS[intencion] || [];\n if (terms.some(term => normTotal.includes(limpiarTexto(term)))) score += BONUS_INTENCION;\n }\n\n const ord = filaTieneOrdinalEvaluacion(normTotal);\n if (intencion === 'evaluacion' && ord.hasEval) score += BONUS_TIENE_EVALUACION;\n if (ordinalBuscado) {\n const ok = (ordinalBuscado === 1 && ord.primera) || (ordinalBuscado === 2 && ord.segunda) || (ordinalBuscado === 3 && ord.tercera);\n if (ok) score += BONUS_ORDINAL_MATCH;\n else {\n if (ord.primera || ord.segunda || ord.tercera) score -= PENALIZA_ORD_DISTINTO;\n if (!ord.hasEval) score -= PENALIZA_SIN_EVAL_CON_ORD;\n }\n }\n\n if (HARD_FILTER_BY_YEAR) {\n if (aniosFila.size > 0 && intersect(aniosFila, aniosPregunta)) score += BONUS_MATCH_ANIO_PREGUNTA;\n } else {\n if (aniosFila.size > 0) {\n if (aniosFila.has(ANIO_ACTUAL)) score += BONUS_ANIO_ACTUAL_STRONG;\n else score -= PENALIZA_ANIO_NO_ACTUAL;\n }\n }\n\n if (mesesPregunta.size > 0 && mesInicio != null) {\n if (mesesPregunta.has(mesInicio)) {\n if (!HARD_FILTER_BY_YEAR && (aniosFila.size === 0 || aniosFila.has(ANIO_ACTUAL))) score += BONUS_MATCH_MES_ACTUAL;\n else score += BONUS_MATCH_MES_ACTUAL * 0.6;\n }\n }\n\n if (/\\b20\\d{2}\\b/.test(normTotal)) score += BONUS_ANIO_EXPLICITO;\n\n const frase = limpiarTexto(preguntaUsuario);\n if (frase && normTotal.includes(frase)) score += BONUS_FRASE_EXACTA;\n\n const len = normTotal.split(' ').length || 1;\n score = score / Math.pow(len, 0.03);\n\n return {\n row_number: campos.row_number,\n PERIODO_FECHAS: campos.PERIODO_FECHAS,\n ACTIVIDADES_GRADO: campos.ACTIVIDADES_GRADO,\n PROCESOS_GRADO: campos.PROCESOS_GRADO,\n ACTIVIDADES_FORMACION: campos.ACTIVIDADES_FORMACION,\n preview: textoTotal,\n score: Number(score.toFixed(4)),\n coincidencias,\n match_tokens: matchTokens,\n match_por_campo: matchPorCampo,\n fecha_inicio_ts: tsInicio,\n mes_inicio: mesInicio,\n anios_fila: [...aniosFila]\n };\n}\n\n// ---------- ejecutar y ordenar ----------\nconst evaluadas = [];\nfor (const row of filas) {\n const r = puntuar(row);\n if (!r) continue;\n if (r.score > UMBRAL_SCORE_MIN || r.coincidencias > 0) evaluadas.push(r);\n}\n\nevaluadas.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n if (b.coincidencias !== a.coincidencias) return b.coincidencias - a.coincidencias;\n const ta = a.fecha_inicio_ts ?? Number.POSITIVE_INFINITY;\n const tb = b.fecha_inicio_ts ?? Number.POSITIVE_INFINITY;\n return ta - tb;\n});\n\n// chat_ids v\u00e1lidos (si existen en la data)\nconst chatIds = $input.all()\n .map(it => it.json?.CHAT_ID)\n .filter(id => id !== undefined && id !== null && String(id).trim() !== '')\n .map(id => String(id).trim());\nconst chat_ids_validos = [...new Set(chatIds)];\n\n// ---------- sin resultados ----------\nif (evaluadas.length === 0) {\n const sugerencias = { email: \"user@example.com\", telefono: \"(04) 2269-269\", web: \"https://www.espol.edu.ec\" };\n const yearInfo = aniosPregunta.size > 0 ? ` para el/los a\u00f1o(s): ${[...aniosPregunta].join(', ')}` : ` para el a\u00f1o ${ANIO_ACTUAL}`;\n const mensaje = [\n `\ud83d\ude15 No encontr\u00e9 informaci\u00f3n espec\u00edfica${yearInfo}.`,\n \"Puedes comunicarte por:\",\n `\ud83d\udce7 ${sugerencias.email}`,\n `\ud83d\udcde ${sugerencias.telefono}`,\n `\ud83c\udf10 ${sugerencias.web}`\n ].join('\\n');\n\n return [{ json: {\n ok: false,\n consulta: preguntaUsuario,\n intencion_detectada: intencion,\n mensaje,\n sugerencias_contacto: sugerencias,\n chat_id: chatId,\n chat_ids_validos\n }}];\n}\n\n// ---------- construir sugerencias si hay demasiadas coincidencias ----------\nfunction nombreMes(idx) {\n const nombres = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];\n return (idx >=0 && idx <=11) ? nombres[idx] : null;
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.
googlePalmApigoogleSheetsOAuth2ApimongoDbtelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
This workflow acts as a virtual assistant that answers university questions, shares upcoming academic dates, and gathers feedback from students and staff through Telegram. It pulls relevant information from MongoDB to handle common queries and uses Gemini AI to craft clear, natural responses while scraping the web for calendar updates. The core step is the AI-driven routing that decides whether to answer directly, request feedback, or trigger a calendar check.
Use it for ongoing student support in departments with high query volumes or scattered information sources. Avoid it for real-time critical alerts or when data privacy rules prevent storing questions in MongoDB. Common variations include swapping the calendar scraper for direct Google Calendar pulls or adding Google Sheets logging for deeper analytics.
About this workflow
This project is a template for building a complete academic virtual assistant using n8n. It connects to Telegram, answers frequently asked questions by querying MongoDB, keeps the community informed about key dates (via web scraping), and collects user feedback for continuous…
Source: https://n8n.io/workflows/10665/ — 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.
This workflow creates a multi-talented AI assistant named Simran that interacts with users via Telegram. It can handle text and voice messages, understand the user's intent, and perform various tasks.
Telegram Trigger receives incoming messages (text, voice, photo, document). Switch routes by message type to appropriate processors: Text → forwarded as-is. Voice → downloaded and sent to Transcribe a
Transform your Telegram messenger into a powerful, multi-modal personal or team assistant. This n8n workflow creates an intelligent agent that can understand text, voice, images, and documents, and ta
This workflow transforms your Telegram bot into an intelligent creative assistant. It can chat conversationally, fetch trending image prompts from PromptHero for inspiration, or perform a deep "remix"
> AI-powered nutrition assistant for Telegram — log meals, set goals, and get personalized daily reports with Google Sheets integration.