diff --git a/n8n-workflow.json b/n8n-workflow.json index 1ed2a1f..b4f11b8 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -299,7 +299,7 @@ }, { "parameters": { - "jsCode": "// Get Docker API response and user input\nconst dockerOutput = $input.item.json.stdout;\nconst userMessage = $('Telegram Trigger').item.json.message.text.toLowerCase().trim();\nconst chatId = $('Telegram Trigger').item.json.message.chat.id;\n\n// Parse JSON response - only error if we can't parse valid JSON from stdout\nlet containers;\ntry {\n if (!dockerOutput || dockerOutput.trim() === '') {\n throw new Error('Empty response');\n }\n containers = JSON.parse(dockerOutput);\n} catch (e) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: \"Can't reach Docker - check if n8n has socket access\"\n }\n }];\n}\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '') // Remove leading slash\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '') // Remove common prefixes\n .toLowerCase();\n}\n\n// Extract container name from user query\n// Remove common query words to get the container name\nconst queryWords = ['status', 'show', 'check', 'container', 'docker', 'what', 'is', 'the', 'of', 'for'];\nconst words = userMessage.split(/\\s+/).filter(word => !queryWords.includes(word));\nconst requestedName = words.join(' ').trim();\n\n// If no container name specified, return summary\nif (!requestedName || requestedName === '') {\n const counts = containers.reduce((acc, c) => {\n acc[c.State] = (acc[c.State] || 0) + 1;\n return acc;\n }, {});\n\n const parts = [];\n if (counts.running) parts.push(`${counts.running} running`);\n if (counts.exited) parts.push(`${counts.exited} stopped`);\n if (counts.paused) parts.push(`${counts.paused} paused`);\n if (counts.restarting) parts.push(`${counts.restarting} restarting`);\n\n const summary = parts.length > 0 ? parts.join(', ') : 'No containers found';\n\n return [{\n json: {\n chatId: chatId,\n summary: true,\n containers: containers,\n text: `Container summary: ${summary}`\n }\n }];\n}\n\n// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = requestedName.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Handle no matches\nif (matches.length === 0) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: `No container found matching \"${requestedName}\".\\n\\nTry \"status\" to see all containers.`\n }\n }];\n}\n\n// Handle multiple matches\nif (matches.length > 1) {\n const names = matches.map(c => c.Names[0].replace(/^\\//, '')).join('\\n- ');\n return [{\n json: {\n chatId: chatId,\n multipleMatches: true,\n matches: matches,\n text: `Found ${matches.length} matches:\\n\\n- ${names}\\n\\nPlease be more specific.`\n }\n }];\n}\n\n// Single match - return container details\nconst container = matches[0];\nreturn [{\n json: {\n chatId: chatId,\n singleMatch: true,\n container: {\n id: container.Id,\n name: container.Names[0].replace(/^\\//, ''),\n state: container.State,\n status: container.Status,\n image: container.Image\n }\n }\n}];" + "jsCode": "// Get Docker API response and intent data\nconst dockerOutput = $input.item.json.stdout;\nconst intent = $('Parse Intent').item.json;\nconst chatId = intent.original_message.chat.id;\n\n// Parse JSON response - only error if we can't parse valid JSON from stdout\nlet containers;\ntry {\n if (!dockerOutput || dockerOutput.trim() === '') {\n throw new Error('Empty response');\n }\n containers = JSON.parse(dockerOutput);\n} catch (e) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: \"Can't reach Docker - check if n8n has socket access\"\n }\n }];\n}\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '') // Remove leading slash\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '') // Remove common prefixes\n .toLowerCase();\n}\n\n// Check if user requested a specific container or general list\nconst requestedName = intent.container;\n\n// If no container name specified, return summary\nif (!requestedName || requestedName === '' || intent.action === 'list_containers') {\n const counts = containers.reduce((acc, c) => {\n acc[c.State] = (acc[c.State] || 0) + 1;\n return acc;\n }, {});\n \n const total = containers.length;\n const running = counts['running'] || 0;\n const stopped = counts['exited'] || 0;\n const other = total - running - stopped;\n \n let summary = `Docker Containers\\n\\n`;\n summary += `Running: ${running}/${total}\\n`;\n if (stopped > 0) summary += `Stopped: ${stopped}\\n`;\n if (other > 0) summary += `Other: ${other}\\n`;\n \n // List running containers\n const runningContainers = containers\n .filter(c => c.State === 'running')\n .map(c => {\n const name = normalizeName(c.Names[0]);\n const uptime = c.Status;\n return ` \u2022 ${name} - ${uptime}`;\n });\n \n if (runningContainers.length > 0) {\n summary += `\\nRunning:\\n${runningContainers.join('\\n')}`;\n }\n \n return [{\n json: {\n chatId: chatId,\n text: summary\n }\n }];\n}\n\n// Find matching containers\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(requestedName);\n});\n\nif (matches.length === 0) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: `No container found matching \"${requestedName}\"`\n }\n }];\n}\n\nif (matches.length === 1) {\n const container = matches[0];\n const name = normalizeName(container.Names[0]);\n const state = container.State;\n const status = container.Status;\n const image = container.Image;\n \n let text = `${name}\\n`;\n text += `State: ${state}\\n`;\n text += `Status: ${status}\\n`;\n text += `Image: ${image}`;\n \n return [{\n json: {\n chatId: chatId,\n text: text\n }\n }];\n}\n\n// Multiple matches - list them\nconst matchList = matches.map(c => {\n const name = normalizeName(c.Names[0]);\n return ` \u2022 ${name} (${c.State})`;\n}).join('\\n');\n\nreturn [{\n json: {\n chatId: chatId,\n text: `Multiple matches for \"${requestedName}\":\\n${matchList}`\n }\n}];" }, "id": "code-parse-match", "name": "Parse and Match", @@ -388,7 +388,7 @@ }, { "parameters": { - "jsCode": "// Parse action command from message\nconst text = $json.message.text.toLowerCase().trim();\nconst chatId = $json.message.chat.id;\nconst messageId = $json.message.message_id;\n\n// Match action pattern: start/stop/restart followed by container name\nconst match = text.match(/^(start|stop|restart)\\s+(.+)$/i);\n\nif (!match) {\n return {\n json: {\n error: true,\n errorMessage: 'Invalid action format. Use: start/stop/restart ',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n action: match[1].toLowerCase(),\n containerQuery: match[2].trim(),\n chatId: chatId,\n messageId: messageId\n }\n};" + "jsCode": "// Parse action from intent\n// Intent structure: { action: \"container_action\", container: \"name\", parameters: { action: \"restart\" } }\nconst text = $json.original_message.text.toLowerCase().trim();\nconst chatId = $json.original_message.chat.id;\nconst messageId = $json.original_message.message_id;\n\n// If intent provided an action, use it\nlet requestedAction = $json.parameters.action || null;\nlet containerQuery = $json.container;\n\n// If no action in parameters, try to infer from the original text\nif (!requestedAction) {\n if (text.includes('start') && !text.includes('restart')) {\n requestedAction = 'start';\n } else if (text.includes('stop')) {\n requestedAction = 'stop';\n } else if (text.includes('restart')) {\n requestedAction = 'restart';\n } else if (text.includes('update')) {\n requestedAction = 'update';\n }\n}\n\nif (!requestedAction || !containerQuery) {\n return {\n json: {\n error: true,\n errorMessage: 'Please specify an action (start/stop/restart/update) and container name.',\n chatId: chatId\n }\n };\n}\n\n// Map action names\nconst actionMap = {\n 'start': 'start',\n 'stop': 'stop',\n 'restart': 'restart',\n 'update': 'update'\n};\n\nconst action = actionMap[requestedAction.toLowerCase()];\n\nif (!action) {\n return {\n json: {\n error: true,\n errorMessage: 'Unknown action. Valid actions: start, stop, restart, update',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n action: action,\n containerQuery: containerQuery,\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-action", "name": "Parse Action", @@ -1805,7 +1805,7 @@ }, { "parameters": { - "jsCode": "// Parse logs command from message\nconst text = $json.message.text.toLowerCase().trim();\nconst chatId = $json.message.chat.id;\nconst messageId = $json.message.message_id;\n\n// Match logs pattern: (show )logs (optional line count)\n// Examples:\n// - \"logs plex\" -> { container: \"plex\", lines: 50 }\n// - \"show logs sonarr\" -> { container: \"sonarr\", lines: 50 }\n// - \"logs nginx 100\" -> { container: \"nginx\", lines: 100 }\n// - \"show logs radarr last 200\" -> { container: \"radarr\", lines: 200 }\n\nconst match = text.match(/^(?:show\\s+)?logs\\s+(.+?)(?:\\s+(?:last\\s+)?(\\d+))?$/i);\n\nif (!match) {\n return {\n json: {\n error: true,\n errorMessage: 'Invalid logs format. Use: logs [line-count]',\n chatId: chatId\n }\n };\n}\n\nconst containerQuery = match[1].trim();\nconst lines = match[2] ? parseInt(match[2], 10) : 50;\n\n// Validate line count (reasonable limits)\nconst validatedLines = Math.min(Math.max(lines, 1), 1000);\n\nreturn {\n json: {\n containerQuery: containerQuery,\n lines: validatedLines,\n chatId: chatId,\n messageId: messageId\n }\n};" + "jsCode": "// Parse logs command from intent\n// Intent structure: { action: \"view_logs\", container: \"name\", parameters: { lines: 50 } }\nconst chatId = $json.original_message.chat.id;\nconst messageId = $json.original_message.message_id;\nconst containerQuery = $json.container;\nconst lines = $json.parameters.lines || 50;\n\nif (!containerQuery) {\n return {\n json: {\n error: true,\n errorMessage: 'Please specify which container logs you want to see.',\n chatId: chatId\n }\n };\n}\n\n// Validate line count (reasonable limits)\nconst validatedLines = Math.min(Math.max(lines, 1), 1000);\n\nreturn {\n json: {\n containerQuery: containerQuery,\n lines: validatedLines,\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-logs", "name": "Parse Logs Command", @@ -2067,6 +2067,316 @@ 1780, 600 ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "anthropic-version", + "value": "2023-06-01" + } + ] + }, + "sendBody": true, + "contentType": "json", + "jsonBody": "={{ JSON.stringify({\n \"model\": \"claude-sonnet-4-5-20250929\",\n \"max_tokens\": 256,\n \"system\": [{\n \"type\": \"text\",\n \"text\": \"You are a Docker container management assistant. Parse user requests and return ONLY valid JSON.\\n\\nValid actions:\\n- view_logs: User wants to see container logs\\n- query_stats: User asks about resource usage (memory, CPU)\\n- container_action: User wants to start/stop/restart/update a container\\n- container_status: User asks about container status\\n- list_containers: User wants to see all containers\\n- unknown: Cannot determine intent\\n\\nRespond with JSON: {\\\"action\\\": \\\"\\\", \\\"container\\\": \\\"\\\", \\\"parameters\\\": {}}\\n\\nExamples:\\n- \\\"show me plex logs\\\" -> {\\\"action\\\": \\\"view_logs\\\", \\\"container\\\": \\\"plex\\\", \\\"parameters\\\": {\\\"lines\\\": 50}}\\n- \\\"what's using the most memory?\\\" -> {\\\"action\\\": \\\"query_stats\\\", \\\"container\\\": null, \\\"parameters\\\": {\\\"metric\\\": \\\"memory\\\", \\\"sort\\\": \\\"desc\\\"}}\\n- \\\"restart nginx\\\" -> {\\\"action\\\": \\\"container_action\\\", \\\"container\\\": \\\"nginx\\\", \\\"parameters\\\": {\\\"action\\\": \\\"restart\\\"}}\\n- \\\"how's sonarr doing?\\\" -> {\\\"action\\\": \\\"container_status\\\", \\\"container\\\": \\\"sonarr\\\", \\\"parameters\\\": {}}\\n- \\\"hello\\\" -> {\\\"action\\\": \\\"unknown\\\", \\\"container\\\": null, \\\"parameters\\\": {\\\"message\\\": \\\"I can help with Docker containers. Try: 'show logs', 'restart plex', or 'what's using memory?'\\\"}}\",\n \"cache_control\": {\"type\": \"ephemeral\"}\n }],\n \"messages\": [{\n \"role\": \"user\",\n \"content\": $json.message.text\n }]\n}) }}", + "options": { + "timeout": 30000, + "retry": { + "enabled": true, + "maxTries": 3 + } + } + }, + "id": "http-claude-intent", + "name": "Claude Intent Parser", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 900, + 300 + ], + "credentials": { + "httpHeaderAuth": { + "id": "anthropic-api-key", + "name": "Anthropic API Key" + } + } + }, + { + "parameters": { + "jsCode": "// Parse and validate Claude's intent response\nconst response = $input.item.json;\n\n// Claude response structure: { content: [{ type: \"text\", text: \"...\" }] }\nlet intentText = '';\ntry {\n intentText = response.content[0].text;\n} catch (e) {\n return {\n action: 'error',\n error: 'Invalid Claude response structure',\n raw: JSON.stringify(response)\n };\n}\n\n// Parse JSON from Claude's response\nlet intent;\ntry {\n // Claude might wrap JSON in markdown code blocks, strip them\n const cleaned = intentText.replace(/```json\\n?/g, '').replace(/```\\n?/g, '').trim();\n intent = JSON.parse(cleaned);\n} catch (e) {\n return {\n action: 'error',\n error: 'Could not parse intent JSON',\n raw: intentText\n };\n}\n\n// Validate required fields\nconst validActions = ['view_logs', 'query_stats', 'container_action', 'container_status', 'list_containers', 'unknown'];\nif (!intent.action || !validActions.includes(intent.action)) {\n return {\n action: 'unknown',\n error: 'Invalid or missing action',\n parameters: { message: 'I didn\\'t understand that. Try: \"show logs plex\" or \"restart nginx\"' }\n };\n}\n\n// Normalize container name if present\nif (intent.container) {\n intent.container = intent.container.toLowerCase().trim();\n}\n\n// Set defaults for parameters\nintent.parameters = intent.parameters || {};\n\n// Preserve original message for fallback\nintent.original_message = $input.item.json.message || {};\n\nreturn intent;" + }, + "id": "code-parse-intent", + "name": "Parse Intent", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 300 + ] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "route-view-logs", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-view-logs", + "leftValue": "={{ $json.action }}", + "rightValue": "view_logs", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "view_logs" + }, + { + "id": "route-query-stats", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-query-stats", + "leftValue": "={{ $json.action }}", + "rightValue": "query_stats", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "query_stats" + }, + { + "id": "route-container-action", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-container-action", + "leftValue": "={{ $json.action }}", + "rightValue": "container_action", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "container_action" + }, + { + "id": "route-container-status", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-container-status", + "leftValue": "={{ $json.action }}", + "rightValue": "container_status", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "container_status" + }, + { + "id": "route-list-containers", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-list-containers", + "leftValue": "={{ $json.action }}", + "rightValue": "list_containers", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "list_containers" + }, + { + "id": "route-unknown", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-unknown", + "leftValue": "={{ $json.action }}", + "rightValue": "unknown", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "unknown" + }, + { + "id": "route-error", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-error", + "leftValue": "={{ $json.action }}", + "rightValue": "error", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "error" + } + ] + }, + "options": { + "fallbackOutput": "none" + } + }, + "id": "switch-intent-router", + "name": "Intent Router", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 1340, + 300 + ] + }, + { + "parameters": { + "chatId": "={{ $json.original_message.chat.id }}", + "text": "={{ $json.parameters.message || \"I can help manage your Docker containers. Try:\\n- 'show logs plex'\\n- 'restart sonarr'\\n- 'what containers are running?'\\n- 'what's using the most memory?'\" }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-unknown", + "name": "Send Unknown Intent", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1560, + 100 + ], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } + }, + { + "parameters": { + "chatId": "={{ $json.original_message.chat.id }}", + "text": "Stats queries coming soon! For now, try 'status' to see running containers.", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-stats-placeholder", + "name": "Send Stats Placeholder", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1560, + 200 + ], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } + }, + { + "parameters": { + "chatId": "={{ $json.original_message.chat.id }}", + "text": "Sorry, I encountered an error understanding your request. Please try again.", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "id": "telegram-send-intent-error", + "name": "Send Intent Error", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1560, + 500 + ], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } } ], "connections": { @@ -2103,7 +2413,7 @@ "main": [ [ { - "node": "Route Message", + "node": "Claude Intent Parser", "type": "main", "index": 0 } @@ -2996,6 +3306,81 @@ } ] ] + }, + "Claude Intent Parser": { + "main": [ + [ + { + "node": "Parse Intent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Intent": { + "main": [ + [ + { + "node": "Intent Router", + "type": "main", + "index": 0 + } + ] + ] + }, + "Intent Router": { + "main": [ + [ + { + "node": "Parse Logs Command", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Stats Placeholder", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Docker List for Action", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Docker List Containers", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Docker List Containers", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Unknown Intent", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Intent Error", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {},