Files
pymesbot-infra/n8n_workflow.json
2026-02-17 04:08:23 +00:00

487 lines
35 KiB
JSON

{
"name": "PymesBot — Coding Loop",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 5
}
]
}
},
"id": "cron-trigger",
"name": "⏰ Cron cada 5 min",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [
0,
300
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 1: LEER TASKS.JSON DESDE GITEA\n// Obtiene el archivo tasks.json del repo\n// ─────────────────────────────────────────────────\nconst GITEA_URL = $env.GITEA_URL;\nconst GITEA_USER = $env.GITEA_USER;\nconst GITEA_TOKEN = $env.GITEA_TOKEN;\nconst REPO = 'pymesbot-infra';\nconst FILE_PATH = 'tasks.json';\n\nconst url = `${GITEA_URL}api/v1/repos/${GITEA_USER}/${REPO}/contents/${FILE_PATH}`;\n\nconst response = await $http.request({\n method: 'GET',\n url,\n headers: {\n 'Authorization': `token ${GITEA_TOKEN}`,\n 'Content-Type': 'application/json'\n }\n});\n\nif (!response.content) {\n throw new Error('tasks.json vacío o no encontrado en el repo');\n}\n\n// El contenido viene en base64\nconst content = Buffer.from(response.content, 'base64').toString('utf-8');\nconst tasks = JSON.parse(content);\n\n// Guardar el sha del archivo para poder actualizarlo después\nreturn [{\n json: {\n tasks: tasks.tareas,\n key_pool: tasks.key_pool,\n config: tasks.config,\n file_sha: response.sha,\n raw_tasks: tasks\n }\n}];"
},
"id": "read-tasks",
"name": "📋 Leer tasks.json de Gitea",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
300
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 2: SELECCIONAR PRÓXIMA TAREA\n// Elige la primera tarea pending en orden de sprint+prioridad\n// Verifica que sus dependencias estén completadas\n// ─────────────────────────────────────────────────\nconst tasks = $input.item.json.tasks;\nconst key_pool = $input.item.json.key_pool;\nconst config = $input.item.json.config;\nconst file_sha = $input.item.json.file_sha;\nconst raw_tasks = $input.item.json.raw_tasks;\n\n// Obtener IDs de tareas completadas\nconst completadas = new Set(\n tasks.filter(t => t.status === 'done').map(t => t.id)\n);\n\n// Encontrar próxima tarea disponible\nconst candidatas = tasks\n .filter(t => {\n if (t.status !== 'pending') return false;\n if (t.intentos >= 3) return false; // máximo 3 intentos\n // Verificar dependencias\n const depsPendientes = (t.dependencias || []).filter(dep => !completadas.has(dep));\n return depsPendientes.length === 0;\n })\n .sort((a, b) => {\n if (a.sprint !== b.sprint) return a.sprint - b.sprint;\n return a.prioridad - b.prioridad;\n });\n\nif (candidatas.length === 0) {\n // No hay tareas disponibles\n const pendientes = tasks.filter(t => t.status === 'pending').length;\n const fallidas = tasks.filter(t => t.intentos >= 3 && t.status !== 'done').length;\n \n return [{\n json: {\n hay_tarea: false,\n motivo: pendientes === 0 ? 'TODAS_COMPLETADAS' : 'DEPENDENCIAS_BLOQUEADAS',\n stats: {\n total: tasks.length,\n completadas: completadas.size,\n pendientes,\n fallidas\n }\n }\n }];\n}\n\nconst tarea = candidatas[0];\n\n// Seleccionar key: la que lleva más tiempo sin usarse\n// y que no está en cooldown\nconst ahora = Date.now();\nconst keysDisponibles = key_pool\n .filter(k => k.disponible)\n .filter(k => {\n if (!k.ultimo_uso) return true;\n const elapsed = (ahora - new Date(k.ultimo_uso).getTime()) / 1000;\n return elapsed >= k.cooldown_segundos;\n })\n .sort((a, b) => {\n if (!a.ultimo_uso) return -1;\n if (!b.ultimo_uso) return 1;\n return new Date(a.ultimo_uso) - new Date(b.ultimo_uso);\n });\n\n// Preferir el modelo sugerido en la tarea\nlet keyElegida = keysDisponibles.find(k => k.servicio === tarea.modelo_preferido);\nif (!keyElegida) keyElegida = keysDisponibles[0]; // fallback a cualquier key disponible\n\nif (!keyElegida) {\n return [{\n json: {\n hay_tarea: false,\n motivo: 'TODAS_KEYS_EN_COOLDOWN',\n stats: { total: tasks.length, completadas: completadas.size }\n }\n }];\n}\n\nreturn [{\n json: {\n hay_tarea: true,\n tarea,\n key_elegida: keyElegida,\n tasks_all: tasks,\n key_pool,\n config,\n file_sha,\n raw_tasks,\n stats: {\n total: tasks.length,\n completadas: completadas.size,\n pendientes: tasks.filter(t => t.status === 'pending').length\n }\n }\n}];"
},
"id": "select-task",
"name": "🎯 Seleccionar próxima tarea",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-hay-tarea",
"leftValue": "={{ $json.hay_tarea }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-hay-tarea",
"name": "¿Hay tarea disponible?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
660,
300
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 3: LEER LOS SPECS RELEVANTES\n// Descarga los fragmentos del spec necesarios para la tarea\n// ─────────────────────────────────────────────────\nconst tarea = $input.item.json.tarea;\nconst config = $input.item.json.config;\nconst GITEA_URL = $env.GITEA_URL;\nconst GITEA_USER = $env.GITEA_USER;\nconst GITEA_TOKEN = $env.GITEA_TOKEN;\nconst REPO = 'pymesbot-infra';\n\n// Determinar qué spec leer según el proyecto\nconst specFile = tarea.proyecto === 'pymesbot-installer'\n ? '02_PYMESBOT_INSTALLER_SPEC.md'\n : '01_PYMESBOT_PROJECT_SPEC.md';\n\nconst url = `${GITEA_URL}api/v1/repos/${GITEA_USER}/${REPO}/contents/specs/${specFile}`;\n\nlet specContent = '';\ntry {\n const resp = await $http.request({\n method: 'GET',\n url,\n headers: { 'Authorization': `token ${GITEA_TOKEN}` }\n });\n specContent = Buffer.from(resp.content, 'base64').toString('utf-8');\n \n // Truncar a 12000 chars para no explotar el context window del modelo\n // Intentar incluir la sección relevante mencionada en contexto_spec\n if (specContent.length > 12000) {\n const seccionMencionada = tarea.contexto_spec || '';\n // Buscar la sección por número (ej: \"Sección 6\")\n const matchSeccion = seccionMencionada.match(/[Ss]ecci[oó]n (\\d+)/);\n if (matchSeccion) {\n const numSeccion = matchSeccion[1];\n const headerRegex = new RegExp(`## ${numSeccion}\\.`);\n const idx = specContent.search(headerRegex);\n if (idx !== -1) {\n // Incluir desde esa sección + 6000 chars de contexto previo\n const start = Math.max(0, idx - 500);\n specContent = specContent.substring(start, start + 10000);\n } else {\n specContent = specContent.substring(0, 12000);\n }\n } else {\n specContent = specContent.substring(0, 12000);\n }\n }\n} catch(e) {\n specContent = `No se pudo cargar el spec: ${e.message}. Implementar según el spec del proyecto PymesBot.`;\n}\n\nreturn [{\n json: {\n ...($input.item.json),\n spec_content: specContent,\n spec_file: specFile\n }\n}];"
},
"id": "read-spec",
"name": "📖 Leer spec relevante",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
180
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 4: CONSTRUIR EL PROMPT PARA EL MODELO\n// ─────────────────────────────────────────────────\nconst tarea = $input.item.json.tarea;\nconst spec_content = $input.item.json.spec_content;\nconst key_elegida = $input.item.json.key_elegida;\n\n// Si la tarea genera múltiples archivos, crear un archivo por vez\n// Usamos el primero de archivos_a_crear si existe, sino archivo_destino\nconst archivoPrincipal = (tarea.archivos_a_crear && tarea.archivos_a_crear.length > 0)\n ? tarea.archivos_a_crear[0]\n : tarea.archivo_destino;\n\nconst prompt = `Sos un desarrollador Python/HTML/JS senior trabajando en el proyecto PymesBot.\nTu trabajo es implementar código de producción limpio, correcto y completo.\n\n## TAREA\nID: ${tarea.id}\nTítulo: ${tarea.titulo}\nDescripción: ${tarea.descripcion}\n\n## ARCHIVO A GENERAR\n${archivoPrincipal}\n\n## CONTEXTO DEL SPEC\n${spec_content}\n\n## REGLAS ABSOLUTAS — NUNCA VIOLARLAS\n1. Respondé ÚNICAMENTE con el código del archivo. Cero texto adicional.\n2. Sin bloques markdown, sin \\`\\`\\`python, sin explicaciones, sin comentarios fuera del código.\n3. El código empieza en la primera línea de tu respuesta y termina en la última.\n4. Seguí EXACTAMENTE los nombres de funciones, clases, rutas y formatos del spec.\n5. Todo el código debe ser Python 3.11+ válido (o HTML/JS según corresponda).\n6. Usá tipado con Pydantic/type hints donde el spec lo indica.\n7. Nunca hardcodear API keys ni contraseñas.\n8. Los comentarios en el código van en español.\n9. Si necesitás importar algo no estándar, asegurate de que esté en requirements.txt del proyecto.\n10. El archivo debe estar completo y funcional — sin TODOs ni partes incompletas.\n\nCÓDIGO:`;\n\nreturn [{\n json: {\n ...($input.item.json),\n prompt,\n archivo_objetivo: archivoPrincipal\n }\n}];"
},
"id": "build-prompt",
"name": "🔨 Construir prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
180
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 5: LLAMAR AL MODELO IA\n// Rotación automática de keys con manejo de rate limits\n// ─────────────────────────────────────────────────\nconst key_elegida = $input.item.json.key_elegida;\nconst prompt = $input.item.json.prompt;\n\n// Mapeo de servicios a sus endpoints OpenAI-compatible\nconst API_CONFIGS = {\n glm: {\n base_url: 'https://api.z.ai/api/paas/v4',\n modelo: 'glm-4.7',\n env_key: $env.GLM_KEY_1\n },\n minimax: {\n base_url: 'https://api.minimax.io/v1',\n modelo: 'MiniMax-M2.5',\n env_key: $env.MINIMAX_KEY_1\n }\n};\n\nconst apiConfig = API_CONFIGS[key_elegida.servicio];\nif (!apiConfig || !apiConfig.env_key) {\n throw new Error(`API key no configurada para servicio: ${key_elegida.servicio}`);\n}\n\nlet codigo_generado = null;\nlet error_api = null;\nlet es_rate_limit = false;\n\ntry {\n const response = await $http.request({\n method: 'POST',\n url: `${apiConfig.base_url}/chat/completions`,\n headers: {\n 'Authorization': `Bearer ${apiConfig.env_key}`,\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n model: apiConfig.modelo,\n messages: [\n {\n role: 'system',\n content: 'Sos un desarrollador senior. Respondés SOLO con código. Sin explicaciones. Sin markdown.'\n },\n {\n role: 'user',\n content: prompt\n }\n ],\n max_tokens: 4000,\n temperature: 0.1\n }),\n timeout: 90000\n });\n\n if (response.choices && response.choices[0]) {\n codigo_generado = response.choices[0].message.content;\n \n // Limpiar por si el modelo ignoró las instrucciones y agregó markdown\n codigo_generado = codigo_generado\n .replace(/^```[a-z]*\\n?/gm, '')\n .replace(/^```\\n?/gm, '')\n .trim();\n } else {\n error_api = `Respuesta inesperada: ${JSON.stringify(response)}`;\n }\n \n} catch(e) {\n const errorStr = e.message || String(e);\n \n // Detectar rate limit\n if (errorStr.includes('429') || errorStr.toLowerCase().includes('rate limit') || errorStr.toLowerCase().includes('too many')) {\n es_rate_limit = true;\n error_api = `Rate limit en ${key_elegida.servicio}: ${errorStr}`;\n } else {\n error_api = errorStr;\n }\n}\n\nreturn [{\n json: {\n ...($input.item.json),\n codigo_generado,\n error_api,\n es_rate_limit,\n key_usada: key_elegida.id,\n timestamp_uso: new Date().toISOString()\n }\n}];"
},
"id": "call-model",
"name": "🤖 Llamar al modelo IA",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
180
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true
},
"conditions": [
{
"id": "check-codigo",
"leftValue": "={{ $json.codigo_generado }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty"
}
}
],
"combinator": "and"
}
},
"id": "if-codigo-ok",
"name": "¿Código generado?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1540,
180
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 6A: COMMITEAR EL CÓDIGO A GITEA\n// Crea o actualiza el archivo en el repo via API de Gitea\n// ─────────────────────────────────────────────────\nconst codigo_generado = $input.item.json.codigo_generado;\nconst tarea = $input.item.json.tarea;\nconst archivo_objetivo = $input.item.json.archivo_objetivo;\nconst key_usada = $input.item.json.key_usada;\n\nconst GITEA_URL = $env.GITEA_URL;\nconst GITEA_USER = $env.GITEA_USER;\nconst GITEA_TOKEN = $env.GITEA_TOKEN;\n\n// Determinar el repo según el proyecto\nconst REPO = tarea.proyecto;\n\n// Verificar si el archivo ya existe para obtener su sha\nlet file_sha = null;\ntry {\n const checkUrl = `${GITEA_URL}api/v1/repos/${GITEA_USER}/${REPO}/contents/${archivo_objetivo}`;\n const existingFile = await $http.request({\n method: 'GET',\n url: checkUrl,\n headers: { 'Authorization': `token ${GITEA_TOKEN}` }\n });\n file_sha = existingFile.sha;\n} catch(e) {\n // El archivo no existe, es nuevo — OK\n}\n\n// Encodear el contenido en base64\nconst contenidoBase64 = Buffer.from(codigo_generado).toString('base64');\n\nconst commitMessage = `[BOT/${key_usada}] ${tarea.id} — ${tarea.titulo}`;\n\nconst body = {\n message: commitMessage,\n content: contenidoBase64,\n branch: 'main',\n author: {\n name: 'PymesBot Bot',\n email: 'bot@rvconsultas.com'\n }\n};\n\nif (file_sha) {\n body.sha = file_sha; // necesario para actualizar archivo existente\n}\n\nconst commitUrl = `${GITEA_URL}api/v1/repos/${GITEA_USER}/${REPO}/contents/${archivo_objetivo}`;\n\nconst commitResponse = await $http.request({\n method: file_sha ? 'PUT' : 'POST',\n url: commitUrl,\n headers: {\n 'Authorization': `token ${GITEA_TOKEN}`,\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(body)\n});\n\nreturn [{\n json: {\n ...($input.item.json),\n commit_exitoso: true,\n commit_sha: commitResponse.commit?.sha,\n commit_message: commitMessage,\n archivo_commiteado: archivo_objetivo\n }\n}];"
},
"id": "commit-to-gitea",
"name": "📦 Commitear a Gitea",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1760,
100
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 7: ACTUALIZAR TASKS.JSON\n// Marca la tarea como done/failed y actualiza el uso de la key\n// ─────────────────────────────────────────────────\nconst input = $input.item.json;\nconst tasks_all = input.tasks_all;\nconst key_pool = input.key_pool;\nconst raw_tasks = input.raw_tasks;\nconst tarea = input.tarea;\nconst commit_exitoso = input.commit_exitoso || false;\nconst es_rate_limit = input.es_rate_limit || false;\nconst file_sha = input.file_sha;\n\nconst GITEA_URL = $env.GITEA_URL;\nconst GITEA_USER = $env.GITEA_USER;\nconst GITEA_TOKEN = $env.GITEA_TOKEN;\nconst REPO = 'pymesbot-infra';\n\n// Actualizar la tarea\nconst tasks_actualizadas = tasks_all.map(t => {\n if (t.id !== tarea.id) return t;\n \n if (commit_exitoso) {\n return {\n ...t,\n status: 'done',\n intentos: t.intentos + 1,\n modelo_usado: input.key_usada,\n completada_el: new Date().toISOString(),\n notas_error: null\n };\n } else {\n const nuevoIntentos = t.intentos + 1;\n return {\n ...t,\n status: nuevoIntentos >= 3 ? 'failed' : 'pending',\n intentos: nuevoIntentos,\n notas_error: input.error_api || 'Error desconocido'\n };\n }\n});\n\n// Actualizar el uso de la key\nconst key_pool_actualizado = key_pool.map(k => {\n if (k.id !== (input.key_usada || input.key_elegida?.id)) return k;\n return {\n ...k,\n ultimo_uso: new Date().toISOString(),\n usos_hoy: (k.usos_hoy || 0) + 1,\n disponible: !es_rate_limit // marcar como no disponible si hay rate limit\n };\n});\n\n// Reconstruir el JSON completo\nconst nuevo_tasks_json = {\n ...raw_tasks,\n tareas: tasks_actualizadas,\n key_pool: key_pool_actualizado\n};\n\n// Subir el tasks.json actualizado a Gitea\nconst contenidoBase64 = Buffer.from(JSON.stringify(nuevo_tasks_json, null, 2)).toString('base64');\n\nconst accion = commit_exitoso ? '✅ done' : '❌ failed';\nconst updateBody = {\n message: `[BOT] tasks.json — ${tarea.id} marcada como ${accion}`,\n content: contenidoBase64,\n sha: file_sha,\n branch: 'main',\n author: {\n name: 'PymesBot Bot',\n email: 'bot@rvconsultas.com'\n }\n};\n\nawait $http.request({\n method: 'PUT',\n url: `${GITEA_URL}api/v1/repos/${GITEA_USER}/pymesbot-infra/contents/tasks.json`,\n headers: {\n 'Authorization': `token ${GITEA_TOKEN}`,\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(updateBody)\n});\n\n// Stats finales\nconst completadas = tasks_actualizadas.filter(t => t.status === 'done').length;\nconst pendientes = tasks_actualizadas.filter(t => t.status === 'pending').length;\nconst fallidas = tasks_actualizadas.filter(t => t.status === 'failed').length;\n\nreturn [{\n json: {\n ciclo_completado: true,\n tarea_id: tarea.id,\n resultado: commit_exitoso ? 'SUCCESS' : 'FAILED',\n archivo: input.archivo_objetivo,\n commit_sha: input.commit_sha,\n stats: { completadas, pendientes, fallidas, total: tasks_actualizadas.length }\n }\n}];"
},
"id": "update-tasks",
"name": "✍️ Actualizar tasks.json",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
180
]
},
{
"parameters": {
"jsCode": "// ─────────────────────────────────────────────────\n// PASO 6B: MANEJAR ERROR DEL MODELO\n// Rate limit → marcar key como en cooldown y continuar\n// Otro error → loguear y marcar tarea como fallida\n// ─────────────────────────────────────────────────\nconst input = $input.item.json;\nconst es_rate_limit = input.es_rate_limit || false;\nconst error_api = input.error_api || 'Error desconocido';\nconst key_elegida = input.key_elegida;\n\nconsole.log(`[ERROR] Tarea ${input.tarea.id} falló con key ${key_elegida?.id}: ${error_api}`);\n\nif (es_rate_limit) {\n console.log(`[RATE_LIMIT] Key ${key_elegida?.id} en cooldown. Se intentará con otra key en el próximo ciclo.`);\n}\n\n// Pasar el error para que update-tasks lo registre\nreturn [{\n json: {\n ...input,\n commit_exitoso: false,\n error_registrado: error_api\n }\n}];"
},
"id": "handle-error",
"name": "⚠️ Manejar error",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1760,
280
]
},
{
"parameters": {
"jsCode": "// No hay tareas — loguear el estado y terminar el ciclo\nconst input = $input.item.json;\nconsole.log(`[LOOP] Sin tareas disponibles. Motivo: ${input.motivo}`);\nconsole.log(`[LOOP] Stats: ${JSON.stringify(input.stats)}`);\n\nif (input.motivo === 'TODAS_COMPLETADAS') {\n console.log('[LOOP] 🎉 ¡PROYECTO COMPLETADO! Todas las tareas están hechas.');\n}\n\nreturn [{ json: { loop_skip: true, ...input } }];"
},
"id": "no-task-log",
"name": "💤 Sin tareas — esperar",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
420
]
},
{
"id": "tg-ok",
"name": "Telegram Commit OK",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2200,
80
],
"parameters": {
"jsCode": "const inp = $input.item.json;\nconst t = inp.tarea;\nconst s = inp.stats || {};\nconst tot = s.total || 30;\nconst ok = s.completadas || 0;\nconst pct = Math.round((ok/tot)*100);\nconst bar = '='.repeat(Math.round(pct/10)) + '-'.repeat(10-Math.round(pct/10));\nconst sha = (inp.commit_sha||'n/a').substring(0,8);\nconst msg = '*Commit OK* (' + t.id + ')\\n'\n + t.titulo + '\\n'\n + 'Modelo: ' + inp.key_usada + '\\n'\n + '`' + inp.archivo_objetivo + '`\\n'\n + 'sha: `' + sha + '`\\n\\n'\n + '[' + bar + '] ' + pct + '% (' + ok + '/' + tot + ')';\nawait $http.request({\n method: 'POST',\n url: 'https://api.telegram.org/bot8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU/sendMessage',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n chat_id: '692714536',\n text: msg,\n parse_mode: 'Markdown',\n disable_web_page_preview: true\n })\n});\nreturn [$input.item];"
}
},
{
"id": "tg-err",
"name": "Telegram Error",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2200,
300
],
"parameters": {
"jsCode": "const inp = $input.item.json;\nconst t = inp.tarea || {};\nconst rl = inp.es_rate_limit || false;\nconst err = (inp.error_api||'desconocido').substring(0,160);\nconst intento = (t.intentos||0)+1;\nconst fatal = !rl && intento >= 3;\nconst tipo = rl ? 'Rate limit' : 'Error';\nconst fin = fatal ? 'Tarea marcada como FALLIDA.' : rl ? 'Se usara otra key.' : 'Se reintentara.';\nconst msg = tipo + ' (' + (t.id||'?') + ')\\n'\n + (t.titulo||'') + '\\n'\n + 'Key: ' + (inp.key_elegida?.id||'?') + '\\n'\n + err + '\\n'\n + 'Intento ' + intento + '/3\\n'\n + fin;\nawait $http.request({\n method: 'POST',\n url: 'https://api.telegram.org/bot8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU/sendMessage',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n chat_id: '692714536',\n text: msg,\n parse_mode: 'Markdown',\n disable_web_page_preview: true\n })\n});\nreturn [$input.item];"
}
},
{
"id": "tg-idle",
"name": "Telegram Sin tareas",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
460
],
"parameters": {
"jsCode": "const inp = $input.item.json;\nconst m = inp.motivo || '';\nconst s = inp.stats || {};\nlet msg = '';\nif (m === 'TODAS_COMPLETADAS') {\n msg = '*PROYECTO COMPLETADO!*\\n'\n + 'Las ' + (s.total||30) + ' tareas de PymesBot estan listas.\\n\\n'\n + 'Backend: https://gitea.cbcren.online/renato97/pymesbot-backend\\n'\n + 'Installer: https://gitea.cbcren.online/renato97/pymesbot-installer\\n\\n'\n + 'Ya podes levantar el stack en demo2.cbcren.online';\n await $http.request({\n method: 'POST',\n url: 'https://api.telegram.org/bot8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU/sendMessage',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n chat_id: '692714536',\n text: msg,\n parse_mode: 'Markdown',\n disable_web_page_preview: true\n })\n});\n} else if (m === 'TODAS_KEYS_EN_COOLDOWN') {\n msg = 'Keys en cooldown. El bot retoma en 5 min.';\n await $http.request({\n method: 'POST',\n url: 'https://api.telegram.org/bot8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU/sendMessage',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n chat_id: '692714536',\n text: msg,\n parse_mode: 'Markdown',\n disable_web_page_preview: true\n })\n});\n}\nreturn [$input.item];"
}
},
{
"id": "cron-hr",
"name": "Cron cada hora",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [
0,
600
],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
}
},
{
"id": "tg-hr",
"name": "Telegram Resumen horario",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
280,
600
],
"parameters": {
"jsCode": "const GU = $env.GITEA_URL;\nconst GN = $env.GITEA_USER;\nconst GT = $env.GITEA_TOKEN;\nlet tareas = [];\ntry {\n const r = await $http.request({\n method:'GET',\n url: GU+'api/v1/repos/'+GN+'/pymesbot-infra/contents/tasks.json',\n headers:{'Authorization':'token '+GT}\n });\n tareas = JSON.parse(Buffer.from(r.content,'base64').toString()).tareas||[];\n} catch(e){return [{json:{skip:true}}];}\nconst tot = tareas.length;\nconst ok = tareas.filter(t=>t.status==='done').length;\nconst pend = tareas.filter(t=>t.status==='pending').length;\nconst fail = tareas.filter(t=>t.status==='failed').length;\nconst pct = Math.round((ok/tot)*100);\nconst bar = '='.repeat(Math.round(pct/10))+'-'.repeat(10-Math.round(pct/10));\nconst last = tareas\n .filter(t=>t.status==='done'&&t.completada_el)\n .sort((a,b)=>new Date(b.completada_el)-new Date(a.completada_el))\n .slice(0,3).map(t=>'[ok] '+t.id+' - '+t.titulo.substring(0,40)).join('\\n')||'(ninguna)';\nconst next = tareas.filter(t=>t.status==='pending')\n .slice(0,3).map(t=>'[..] '+t.id+' - '+t.titulo.substring(0,40)).join('\\n')||'(vacia)';\nconst hora = new Date().toLocaleTimeString('es-AR',{timeZone:'America/Argentina/Buenos_Aires',hour:'2-digit',minute:'2-digit'});\nconst msg = '*Resumen ' + hora + ' ART*\\n\\n'\n + '['+bar+'] '+pct+'%\\n'\n + 'OK:'+ok+' pend:'+pend+' fail:'+fail+' total:'+tot+'\\n\\n'\n + '*Ultimas:*\\n'+last+'\\n\\n'\n + '*Proximas:*\\n'+next;\nawait $http.request({\n method: 'POST',\n url: 'https://api.telegram.org/bot8551922819:AAHIXNbavzcI90eEIGVx1NIisYHecfcBYtU/sendMessage',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n chat_id: '692714536',\n text: msg,\n parse_mode: 'Markdown',\n disable_web_page_preview: true\n })\n});\nreturn [{json:{pct,ok,pend,fail}}];"
}
}
],
"connections": {
"⏰ Cron cada 5 min": {
"main": [
[
{
"node": "📋 Leer tasks.json de Gitea",
"type": "main",
"index": 0
}
]
]
},
"📋 Leer tasks.json de Gitea": {
"main": [
[
{
"node": "🎯 Seleccionar próxima tarea",
"type": "main",
"index": 0
}
]
]
},
"🎯 Seleccionar próxima tarea": {
"main": [
[
{
"node": "¿Hay tarea disponible?",
"type": "main",
"index": 0
}
]
]
},
"¿Hay tarea disponible?": {
"main": [
[
{
"node": "📖 Leer spec relevante",
"type": "main",
"index": 0
}
],
[
{
"node": "💤 Sin tareas — esperar",
"type": "main",
"index": 0
}
]
]
},
"📖 Leer spec relevante": {
"main": [
[
{
"node": "🔨 Construir prompt",
"type": "main",
"index": 0
}
]
]
},
"🔨 Construir prompt": {
"main": [
[
{
"node": "🤖 Llamar al modelo IA",
"type": "main",
"index": 0
}
]
]
},
"🤖 Llamar al modelo IA": {
"main": [
[
{
"node": "¿Código generado?",
"type": "main",
"index": 0
}
]
]
},
"¿Código generado?": {
"main": [
[
{
"node": "📦 Commitear a Gitea",
"type": "main",
"index": 0
}
],
[
{
"node": "⚠️ Manejar error",
"type": "main",
"index": 0
}
]
]
},
"📦 Commitear a Gitea": {
"main": [
[
{
"node": "Telegram Commit OK",
"type": "main",
"index": 0
}
]
]
},
"⚠️ Manejar error": {
"main": [
[
{
"node": "Telegram Error",
"type": "main",
"index": 0
}
]
]
},
"Telegram Commit OK": {
"main": [
[
{
"node": "✍️ Actualizar tasks.json",
"type": "main",
"index": 0
}
]
]
},
"Telegram Error": {
"main": [
[
{
"node": "✍️ Actualizar tasks.json",
"type": "main",
"index": 0
}
]
]
},
"💤 Sin tareas — esperar": {
"main": [
[
{
"node": "Telegram Sin tareas",
"type": "main",
"index": 0
}
]
]
},
"Cron cada hora": {
"main": [
[
{
"node": "Telegram Resumen horario",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner",
"errorWorkflow": ""
},
"staticData": null,
"tags": [
"pymesbot",
"coding-bot",
"automation"
],
"meta": {
"instanceId": "pymesbot-coding-loop-v1"
},
"_readme": {
"variables_de_entorno_requeridas": [
"GITEA_URL → https://gitea.cbcren.online/",
"GITEA_USER → renato97",
"GITEA_TOKEN → efeed2af00597883adb04da70bd6a7c2993ae92d",
"GLM_KEY_1 → tu API key de GLM (bigmodel.cn)",
"MINIMAX_KEY_1 → tu API key de Minimax"
],
"repos_a_crear_en_gitea": [
"renato97/pymesbot-backend → donde van los archivos del backend",
"renato97/pymesbot-installer → donde van los archivos del instalador",
"renato97/pymesbot-infra → donde vive el tasks.json y los specs"
],
"setup_inicial": [
"1. Crear 3 repos en Gitea: pymesbot-backend, pymesbot-installer, pymesbot-infra",
"2. Subir tasks.json a pymesbot-infra/tasks.json",
"3. Subir specs a pymesbot-infra/specs/01_PYMESBOT_PROJECT_SPEC.md y 02_PYMESBOT_INSTALLER_SPEC.md",
"4. Levantar n8n con docker-compose en n8n.cbcren.online (ver docker-compose abajo)",
"5. Configurar variables de entorno en n8n: GITEA_URL, GITEA_USER, GITEA_TOKEN, GLM_KEY_1, MINIMAX_KEY_1",
"6. Importar este workflow en n8n",
"7. Activar el workflow — corre cada 5 minutos"
],
"dominio_demo": "demo2.cbcren.online",
"dominio_n8n": "n8n.cbcren.online",
"docker_compose_n8n": "\n# Guardar como /opt/n8n/docker-compose.yml en el servidor\n# Levantar con: docker compose up -d\n\nversion: '3.8'\nservices:\n n8n:\n image: n8nio/n8n:latest\n container_name: n8n\n restart: always\n ports:\n - '5678:5678'\n volumes:\n - n8n_data:/home/node/.n8n\n environment:\n - N8N_HOST=n8n.cbcren.online\n - N8N_PORT=5678\n - N8N_PROTOCOL=https\n - WEBHOOK_URL=https://n8n.cbcren.online/\n - N8N_BASIC_AUTH_ACTIVE=true\n - N8N_BASIC_AUTH_USER=admin\n - N8N_BASIC_AUTH_PASSWORD=CAMBIAME\n - GITEA_URL=https://gitea.cbcren.online/\n - GITEA_USER=renato97\n - GITEA_TOKEN=efeed2af00597883adb04da70bd6a7c2993ae92d\n - GLM_KEY_1=TU_GLM_KEY_ACA\n - MINIMAX_KEY_1=TU_MINIMAX_KEY_ACA\n - TZ=America/Argentina/Buenos_Aires\nvolumes:\n n8n_data:\n\n# NGINX CONFIG para n8n.cbcren.online\n# (agregar a /etc/nginx/conf.d/n8n.conf)\n#\n# server {\n# listen 80;\n# server_name n8n.cbcren.online;\n# location / {\n# proxy_pass http://localhost:5678;\n# proxy_http_version 1.1;\n# proxy_set_header Upgrade $http_upgrade;\n# proxy_set_header Connection 'upgrade';\n# proxy_set_header Host $host;\n# proxy_cache_bypass $http_upgrade;\n# }\n# }\n# Despues: certbot --nginx -d n8n.cbcren.online\n",
"nginx_demo2": "\n# Config Nginx para demo2.cbcren.online\n# El cliente demo corre en puerto 8201\n#\n# server {\n# listen 80;\n# server_name demo2.cbcren.online;\n# location / {\n# proxy_pass http://localhost:8201;\n# proxy_http_version 1.1;\n# proxy_set_header Upgrade $http_upgrade;\n# proxy_set_header Connection 'upgrade';\n# proxy_set_header Host $host;\n# proxy_cache_bypass $http_upgrade;\n# proxy_read_timeout 300s;\n# proxy_send_timeout 300s;\n# }\n# }\n# Despues: certbot --nginx -d demo2.cbcren.online\n"
}
}