Files
LogWhispererAI/workflows/logwhisperer_ingest.json
Luca Sacchi Ricciardi 3d24dfdeaf feat: complete Sprint 2 with Telegram notifications integration
Add Telegram Bot notification node to n8n workflow:

New Features:
- Telegram notification node for critical severity logs
- Italian message template with emoji and MarkdownV2 formatting
- Smart routing: Telegram only for critical logs
- Error handling: continueOnFail prevents workflow interruption
- Environment-based configuration (TELEGRAM_CHAT_ID)

Message Template Includes:
- 🚨 Alert header with severity
- 📍 Server hostname identification
- 📝 AI-generated problem summary
- 💡 Suggested bash command in code block
- ⚠️ Confirmation required flag
- 📝 Additional notes from AI
- 📊 AI processing status
- 🤖 Model used (openai/gpt-4o-mini)
-  Localized Italian timestamp

Workflow Flow:
Webhook → HMAC → Validation → PostgreSQL → OpenRouter → Critical? → Telegram → Response
                                                      ↓ FALSE
                                               Success Response

Configuration Required:
1. Create Telegram Bot via @BotFather
2. Get TELEGRAM_BOT_TOKEN
3. Get TELEGRAM_CHAT_ID via @userinfobot
4. Configure credentials in n8n UI
5. Set TELEGRAM_CHAT_ID environment variable

Documentation:
- docs/telegram_setup.md: Setup instructions
- .env.example: Environment variables template
- .gitignore: Protect sensitive telegram_setup.md
- docs/prd.md: Updated Sprint 2 completion status

Sprint 2 Complete:
 Secure log ingestion (bash)
 n8n webhook workflow
 OpenRouter AI integration
 PostgreSQL storage
 HMAC authentication
 Telegram notifications

Refs: docs/specs/ai_pipeline.md, docs/specs/bash_ingestion_secure.md
2026-04-02 19:47:57 +02:00

415 lines
23 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"name": "LogWhisperer_Ingest",
"nodes": [
{
"parameters": {},
"id": "trigger-node",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
250,
300
],
"webhookId": "logwhisperer-ingest",
"path": "logwhisperer/ingest",
"responseMode": "responseNode",
"options": {}
},
{
"parameters": {
"jsCode": "// HMAC Validation Node\n// Verifica la firma HMAC-SHA256 secondo il Metodo Sacchi: Safety First\n\nconst crypto = require('crypto');\n\n// Recupera headers\nconst signatureHeader = $headers['x-logwhisperer-signature'];\nconst timestampHeader = $headers['x-logwhisperer-timestamp'];\n\n// Recupera secret da variabile ambiente\nconst secret = process.env.LOGWHISPERER_SECRET;\n\nif (!secret) {\n throw new Error('LOGWHISPERER_SECRET not configured');\n}\n\nif (!signatureHeader || !timestampHeader) {\n return [{\n json: {\n valid: false,\n error: 'Missing authentication headers',\n statusCode: 401\n }\n }];\n}\n\n// Estrai timestamp e signature dal formato: timestamp:signature\nconst parts = signatureHeader.split(':');\nif (parts.length !== 2) {\n return [{\n json: {\n valid: false,\n error: 'Invalid signature format',\n statusCode: 401\n }\n }];\n}\n\nconst receivedTimestamp = parts[0];\nconst receivedSignature = parts[1];\n\n// Verifica timestamp (anti-replay: max 5 minuti di differenza)\nconst now = Math.floor(Date.now() / 1000);\nconst requestTime = parseInt(timestampHeader, 10);\nconst timeDiff = Math.abs(now - requestTime);\n\nif (timeDiff > 300) {\n return [{\n json: {\n valid: false,\n error: 'Request timestamp too old',\n statusCode: 401\n }\n }];\n}\n\n// Ottieni il payload raw\nconst payload = JSON.stringify($input.first().json);\n\n// Calcola HMAC atteso: HMAC-SHA256(timestamp:payload)\nconst expectedSignature = crypto\n .createHmac('sha256', secret)\n .update(`${timestampHeader}:${payload}`)\n .digest('hex');\n\n// Comparazione timing-safe\nconst isValid = crypto.timingSafeEqual(\n Buffer.from(receivedSignature, 'hex'),\n Buffer.from(expectedSignature, 'hex')\n);\n\nif (!isValid) {\n return [{\n json: {\n valid: false,\n error: 'Invalid signature',\n statusCode: 401\n }\n }];\n}\n\n// Validazione HMAC passata - restituisci payload per il prossimo nodo\nreturn [{\n json: {\n valid: true,\n data: $input.first().json\n }\n}];"
},
"id": "hmac-validation-node",
"name": "HMAC Validation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
450,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "condition-1",
"leftValue": "={{ $json.valid }}",
"rightValue": "true",
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "hmac-check-node",
"name": "HMAC Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
650,
300
]
},
{
"parameters": {
"jsCode": "// Data Validation Node\n// Validazione campi obbligatori secondo il Metodo Sacchi: Double Check\n\nconst data = $input.first().json.data;\nconst errors = [];\n\n// Validazione UUID per client_id\nfunction isValidUUID(uuid) {\n const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;\n return uuidRegex.test(uuid);\n}\n\n// Validazione campi obbligatori\nif (!data.client_id) {\n errors.push('Missing client_id');\n} else if (!isValidUUID(data.client_id)) {\n errors.push('Invalid client_id format (must be UUID)');\n}\n\nif (!data.raw_log || data.raw_log.trim() === '') {\n errors.push('Missing or empty raw_log');\n}\n\nconst validSeverities = ['low', 'medium', 'critical'];\nif (!data.severity) {\n errors.push('Missing severity');\n} else if (!validSeverities.includes(data.severity.toLowerCase())) {\n errors.push(`Invalid severity: ${data.severity} (must be one of: ${validSeverities.join(', ')})`);\n}\n\nif (errors.length > 0) {\n return [{\n json: {\n valid: false,\n errors: errors,\n received: data\n }\n }];\n}\n\n// Normalizza severity a lowercase\nconst normalizedData = {\n ...data,\n severity: data.severity.toLowerCase()\n};\n\nreturn [{\n json: {\n valid: true,\n data: normalizedData\n }\n}];"
},
"id": "data-validation-node",
"name": "Data Validation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
850,
200
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO logs (client_id, hostname, source, severity, timestamp, raw_log, matched_pattern)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nRETURNING id;",
"options": {
"queryParams": "={{ JSON.stringify([$json.data.client_id, $json.data.hostname, $json.data.source, $json.data.severity, $json.data.timestamp, $json.data.raw_log, $json.data.matched_pattern]) }}"
}
},
"id": "postgres-insert-node",
"name": "Store Log",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.2,
"position": [
1050,
200
],
"credentials": {
"postgres": {
"id": "postgres-credentials",
"name": "PostgreSQL LogWhisperer"
}
}
},
{
"parameters": {
"jsCode": "// ============================================================================\n// n8n Code Node: OpenRouter Processor\n// ============================================================================\n// Input: JSON dal nodo precedente (log data)\n// Output: Oggetto con analisi AI o fallback\n// Provider: OpenRouter (accesso a 300+ modelli AI)\n\nconst OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;\nconst OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';\nconst SITE_URL = process.env.OPENROUTER_SITE_URL || 'https://logwhisperer.ai';\nconst APP_NAME = process.env.OPENROUTER_APP_NAME || 'LogWhispererAI';\n\n// System Prompt completo con Metodo Sacchi\nconst SYSTEM_PROMPT = `Sei LogWhisperer AI, un assistente DevOps esperto specializzato nell'analisi di log di sistema.\n\n## MISSIONE\nAnalizza i log ricevuti e fornisci insight azionabili in italiano, semplice e chiaro.\n\n## PRINCIPI OBBLIGATORI (Metodo Sacchi)\n\n### 1. SAFETY FIRST - Sicurezza Prima di Tutto\nMAI suggerire comandi che possano:\n- Cancellare dati (rm, del, truncate senza backup)\n- Modificare configurazioni critiche senza verifica\n- Riavviare servizi in produzione senza warning\n- Eseguire operazioni irreversibili\n\nREGOLE DI SICUREZZA:\n- Preferisci SEMPRE comandi read-only (cat, grep, df, ps, etc.)\n- Se un comando potrebbe essere distruttivo, imposta \\\"sicuro\\\": false\n- Per operazioni rischiose, richiedi sempre conferma umana\n- Non assumere mai che l'utente sappia cosa sta facendo\n\n### 2. LITTLE OFTEN - Piccoli Passi Verificabili\n- Suggerisci UN solo comando alla volta\n- Ogni azione deve essere verificabile prima della prossima\n- Dividi problemi complessi in step incrementali\n- Preferisci diagnostica prima della mitigazione\n\n### 3. DOUBLE CHECK - Verifica le Ipotesi\n- Non assumere MAI il contesto senza verificarlo\n- Se mancano informazioni, chiedi chiarimenti (richiede_conferma: true)\n- Verifica sempre i presupposti prima di suggerire azioni\n- Se l'errore e' ambiguo, richiedi analisi manuale\n\n## FORMATO OUTPUT OBBLIGATORIO\n\nRispondi SEMPRE in formato JSON valido con questa struttura:\n\n{\n \\\"sintesi\\\": \\\"Descrizione breve e chiara del problema in italiano\\\",\n \\\"severita\\\": \\\"low|medium|critical\\\",\n \\\"comando\\\": \\\"Comando bash esatto per diagnostica/mitigazione (o null)\\\",\n \\\"sicuro\\\": true|false,\n \\\"note\\\": \\\"Istruzioni aggiuntive, link documentazione, o 'Nessuna nota aggiuntiva'\\\",\n \\\"richiede_conferma\\\": true|false\n}\n\n## REGOLE JSON\n\n1. \\\"sintesi\\\": Massimo 150 caratteri, linguaggio semplice\n2. \\\"severita\\\": \n - \\\"critical\\\": Servizio down, rischio data loss, security breach\n - \\\"medium\\\": Degradazione performance, warning spazio disco\n - \\\"low\\\": Messaggi informativi, warning minori\n3. \\\"comando\\\": \n - Deve essere copiabile e incollabile in terminale\n - Se incerto o rischioso, usa null\n - Preferisci diagnostica (ls, grep, df) a modifica (rm, kill)\n4. \\\"sicuro\\\": \n - true solo se comando e' read-only o sicuro al 100%\n - false se potenzialmente distruttivo\n5. \\\"note\\\": \n - Spiega il perche' del comando\n - Aggiungi link a documentazione rilevante\n - Suggerisci verifiche aggiuntive\n6. \\\"richiede_conferma\\\": \n - true se serve conferma umana prima di eseguire\n - true se il problema e' ambiguo o poco chiaro\n\n## ESEMPI\n\nLog: \\\"OutOfMemoryError: Java heap space\\\"\n{\n \\\"sintesi\\\": \\\"Applicazione Java ha esaurito la memoria heap\\\",\n \\\"severita\\\": \\\"critical\\\",\n \\\"comando\\\": \\\"ps aux | grep java | head -5 && free -h\\\",\n \\\"sicuro\\\": true,\n \\\"note\\\": \\\"Verifica processi Java attivi e memoria disponibile. Se necessario, aumentare heap size in JVM options.\\\",\n \\\"richiede_conferma\\\": false\n}\n\nLog: \\\"Connection refused to database on port 5432\\\"\n{\n \\\"sintesi\\\": \\\"Impossibile connettersi al database PostgreSQL\\\",\n \\\"severita\\\": \\\"critical\\\",\n \\\"comando\\\": \\\"sudo systemctl status postgresql && netstat -tlnp | grep 5432\\\",\n \\\"sicuro\\\": true,\n \\\"note\\\": \\\"Verifica stato del servizio PostgreSQL. Se stopped: 'sudo systemctl start postgresql'\\\",\n \\\"richiede_conferma\\\": true\n}\n\nLog: \\\"Disk space warning: /var at 85%\\\"\n{\n \\\"sintesi\\\": \\\"Spazio su disco in esaurimento (85% utilizzato)\\\",\n \\\"severita\\\": \\\"medium\\\",\n \\\"comando\\\": \\\"df -h /var && du -sh /var/log/* | sort -hr | head -10\\\",\n \\\"sicuro\\\": true,\n \\\"note\\\": \\\"Identifica file di log piu' grandi. Per pulizia sicura: usa find per eliminare log vecchi.\\\",\n \\\"richiede_conferma\\\": false\n}\n\n## COMANDI PROIBITI (NON MAI SUGGERIRE)\n- rm -rf / o varianti\n- dd if=/dev/zero (sovrascrittura dischi)\n- mkfs, fdisk (formattazione)\n- kill -9 senza verifica processo\n- chmod 777 ricorsivo\n- Qualsiasi comando con pipe a sh/bash senza verifica\n\n## COMANDI PREFERITI (READ-ONLY)\n- Diagnostica: ps, top, htop, df, du, free, iostat, netstat, ss\n- Log: tail, head, grep, cat, less, journalctl\n- Rete: ping, curl, netstat, ss, lsof\n- Systemd: systemctl status, journalctl -u\n\nRICORDA: L'utente potrebbe essere non-tecnico. Spiega in italiano semplice.`;\n\n// Input dal nodo precedente\nconst inputData = $input.first().json;\n\n// Trunca raw_log se troppo lungo\nconst maxLogLength = 2000;\nconst truncatedLog = inputData.raw_log \n ? inputData.raw_log.substring(0, maxLogLength)\n : 'Nessun log fornito';\n\n// Prepara payload per OpenRouter\nconst payload = {\n model: \\\"openai/gpt-4o-mini\\\",\n messages: [\n {\n role: \\\"system\\\",\n content: SYSTEM_PROMPT\n },\n {\n role: \\\"user\\\",\n content: JSON.stringify({\n timestamp: inputData.timestamp,\n severity: inputData.severity,\n source: inputData.source,\n hostname: inputData.hostname,\n client_id: inputData.client_id,\n raw_log: truncatedLog\n })\n }\n ],\n temperature: 0.3,\n max_tokens: 500,\n response_format: { type: \\\"json_object\\\" },\n store: false\n};\n\n// Timeout configurazione\nconst TIMEOUT_MS = 10000;\n\nasync function callOpenRouter() {\n try {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);\n \n const response = await fetch(OPENROUTER_API_URL, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + OPENROUTER_API_KEY,\n 'HTTP-Referer': SITE_URL,\n 'X-Title': APP_NAME\n },\n body: JSON.stringify(payload),\n signal: controller.signal\n });\n \n clearTimeout(timeoutId);\n \n if (!response.ok) {\n throw new Error('OpenRouter API error: ' + response.status + ' ' + response.statusText);\n }\n \n const data = await response.json();\n const aiResponse = JSON.parse(data.choices[0].message.content);\n \n // Aggiungi info modello usato (per tracking)\n const modelUsed = data.model || 'unknown';\n \n // Merge con dati originali\n return [{\n json: {\n client_id: inputData.client_id,\n hostname: inputData.hostname,\n severity: inputData.severity,\n raw_log: inputData.raw_log,\n ai_analysis: aiResponse,\n ai_status: 'success',\n ai_timestamp: new Date().toISOString(),\n ai_model: modelUsed\n }\n }];\n \n } catch (error) {\n console.error('OpenRouter Error:', error.message);\n \n // Fallback response\n return [{\n json: {\n client_id: inputData.client_id,\n hostname: inputData.hostname,\n severity: inputData.severity,\n raw_log: inputData.raw_log,\n ai_analysis: {\n sintesi: \\\"Errore durante analisi AI\\\",\n severita: inputData.severity || \\\"medium\\\",\n comando: null,\n sicuro: true,\n note: 'Fallback: ' + error.message + '. Controlla manualmente il log.',\n richiede_conferma: true\n },\n ai_status: 'fallback',\n ai_error: error.message,\n ai_timestamp: new Date().toISOString()\n }\n }];\n }\n}\n\n// Esegui chiamata\nreturn await callOpenRouter();"
},
"id": "openrouter-node",
"name": "Call OpenRouter",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1250,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "condition-1",
"leftValue": "={{ $json.severity }}",
"rightValue": "critical",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
}
},
"id": "severity-check-node",
"name": "Critical Severity?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1450,
100
]
},
{
"parameters": {
"chatId": "={{ process.env.TELEGRAM_CHAT_ID }}",
"text": "={{ '🚨 *LogWhisperer Alert* \\n\\n📍 *Server:* ' + $json.hostname + '\\n⚠ *Severity:* ' + $json.severity.toUpperCase() + '\\n\\n📝 *Problema:*\\n' + $json.ai_analysis.sintesi + '\\n\\n💡 *Comando suggerito:*\\n```bash\\n' + ($json.ai_analysis.comando || 'Nessun comando disponibile') + '\\n```\\n\\n⚠ *Richiede conferma:* ' + ($json.ai_analysis.richiede_conferma ? 'SÌ' : 'NO') + '\\n📝 *Note:* ' + ($json.ai_analysis.note || 'Nessuna nota') + '\\n\\n📊 *Analisi AI:* ' + $json.ai_status + '\\n🤖 *Modello:* ' + ($json.ai_model || 'N/A') + '\\n\\n⏰ ' + new Date().toLocaleString('it-IT') }}",
"parseMode": "MarkdownV2",
"options": {
"disable_notification": false
}
},
"id": "telegram-notification-node",
"name": "Send Telegram Notification",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1,
"position": [
1650,
100
],
"continueOnFail": true,
"credentials": {
"telegramApi": {
"id": "telegram-credentials",
"name": "Telegram Bot"
}
}
},
{
"parameters": {
"jsCode": "// AI Processing - Enhanced with OpenRouter Analysis\n// Riceve i dati elaborati da OpenRouter con analisi AI\n\nconst logData = $input.first().json;\n\n// Log di sicurezza: non esporre raw_log completo nei log\nconsole.log('AI Processing completed for log ID:', logData.client_id);\nconsole.log('Client:', logData.client_id);\nconsole.log('Severity:', logData.severity);\nconsole.log('AI Status:', logData.ai_status);\nconsole.log('AI Model:', logData.ai_model || 'N/A');\n\n// Se c'\u00e8 l'analisi AI, logga la sintesi\nif (logData.ai_analysis) {\n console.log('AI Sintesi:', logData.ai_analysis.sintesi);\n console.log('AI Comando suggerito:', logData.ai_analysis.comando || 'Nessuno');\n}\n\nreturn [{\n json: {\n status: 'ai_processing_complete',\n client_id: logData.client_id,\n hostname: logData.hostname,\n severity: logData.severity,\n ai_analysis: logData.ai_analysis,\n ai_status: logData.ai_status,\n ai_model: logData.ai_model,\n timestamp: logData.ai_timestamp || new Date().toISOString()\n }\n}];"
},
"id": "ai-processing-node",
"name": "AI Processing",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1850,
100
]
},
{
"parameters": {
"respondWith": "json",
"options": {}
},
"id": "success-response-node",
"name": "Success Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1650,
250
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\"error\": \"Unauthorized\", \"message\": \"Invalid HMAC signature\"}",
"options": {
"responseCode": 401
}
},
"id": "unauthorized-response-node",
"name": "401 Unauthorized",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
850,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\"error\": \"Bad Request\", \"message\": $json.errors.join(\", \")}",
"options": {
"responseCode": 400
}
},
"id": "validation-error-node",
"name": "400 Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1050,
400
]
},
{
"parameters": {
"jsCode": "// Ensure Table Exists\n// Crea la tabella logs se non esiste gi\u00e0\n\nreturn [{\n json: {\n sql: `\n CREATE TABLE IF NOT EXISTS logs (\n id SERIAL PRIMARY KEY,\n client_id VARCHAR(36) NOT NULL,\n hostname VARCHAR(255),\n source VARCHAR(500),\n severity VARCHAR(20) CHECK (severity IN ('low', 'medium', 'critical')),\n timestamp TIMESTAMP WITH TIME ZONE,\n raw_log TEXT,\n matched_pattern VARCHAR(100),\n created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n );\n \n CREATE INDEX IF NOT EXISTS idx_logs_client_id ON logs(client_id);\n CREATE INDEX IF NOT EXISTS idx_logs_severity ON logs(severity);\n CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);\n `\n }\n}];"
},
"id": "ensure-table-node",
"name": "Ensure Table SQL",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
650,
100
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "={{ $json.sql }}",
"options": {}
},
"id": "create-table-node",
"name": "Create Table",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.2,
"position": [
850,
100
],
"credentials": {
"postgres": {
"id": "postgres-credentials",
"name": "PostgreSQL LogWhisperer"
}
}
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "HMAC Validation",
"type": "main",
"index": 0
},
{
"node": "Ensure Table SQL",
"type": "main",
"index": 0
}
]
]
},
"HMAC Validation": {
"main": [
[
{
"node": "HMAC Valid?",
"type": "main",
"index": 0
}
]
]
},
"HMAC Valid?": {
"main": [
[
{
"node": "Data Validation",
"type": "main",
"index": 0
}
],
[
{
"node": "401 Unauthorized",
"type": "main",
"index": 0
}
]
]
},
"Data Validation": {
"main": [
[
{
"node": "Store Log",
"type": "main",
"index": 0
}
],
[
{
"node": "400 Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Store Log": {
"main": [
[
{
"node": "Call OpenRouter",
"type": "main",
"index": 0
}
]
]
},
"Call OpenRouter": {
"main": [
[
{
"node": "Critical Severity?",
"type": "main",
"index": 0
},
{
"node": "Success Response",
"type": "main",
"index": 0
}
]
]
},
"Critical Severity?": {
"main": [
[
{
"node": "Send Telegram Notification",
"type": "main",
"index": 0
}
],
[
{
"node": "Success Response",
"type": "main",
"index": 0
}
]
]
},
"Send Telegram Notification": {
"main": [
[
{
"node": "AI Processing",
"type": "main",
"index": 0
}
]
]
},
"Ensure Table SQL": {
"main": [
[
{
"node": "Create Table",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "logwhisperer",
"id": "tag-logwhisperer",
"createdAt": "2026-04-02T00:00:00.000Z",
"updatedAt": "2026-04-02T00:00:00.000Z"
},
{
"name": "security",
"id": "tag-security",
"createdAt": "2026-04-02T00:00:00.000Z",
"updatedAt": "2026-04-02T00:00:00.000Z"
},
"ingestion"
]
}