From a29f444e080bbbaf2875d193d313f5988ca46244 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 31 Jan 2026 21:09:33 -0500 Subject: [PATCH] feat(05-01): replace NLU/Claude with keyword routing - Remove Prepare Claude Request, Claude Intent Parser, Parse Intent, Intent Router, Send Unknown Intent, Send Intent Error nodes - Remove Anthropic API credential reference - Rename Route Message to Keyword Router with updated rules - Update IF User Authenticated to connect to Keyword Router - Update Parse and Match to work without NLU context - Update Parse Action Command to parse from message text directly - Update Match Container to reference Parse Action Command - Update Parse Logs Command to work with keyword routing Keyword Router handles: /start, status, restart, start, stop, update, logs with fallback to menu Co-Authored-By: Claude Opus 4.5 --- n8n-workflow.json | 657 +++++++++++----------------------------------- 1 file changed, 151 insertions(+), 506 deletions(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index c029c30..1ea57dc 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -157,17 +157,39 @@ "rules": { "values": [ { - "id": "docker-query-route", + "id": "keyword-menu-start", + "conditions": { + "options": { + "caseSensitive": false, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "starts-with-start-cmd", + "leftValue": "={{ $json.message.text }}", + "rightValue": "/start", + "operator": { + "type": "string", + "operation": "startsWith" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "menu" + }, + { + "id": "keyword-status", "conditions": { "options": { "caseSensitive": false, - "leftValue": "", "typeValidation": "loose" }, "conditions": [ { "id": "contains-status", - "leftValue": "={{ $json.message.text.toLowerCase() }}", + "leftValue": "={{ $json.message.text }}", "rightValue": "status", "operator": { "type": "string", @@ -175,96 +197,125 @@ } } ], - "combinator": "or" + "combinator": "and" }, - "renameOutput": false + "renameOutput": true, + "outputKey": "status" }, { - "id": "action-command-route", + "id": "keyword-restart", "conditions": { "options": { "caseSensitive": false, - "leftValue": "", "typeValidation": "loose" }, "conditions": [ { - "id": "starts-with-start", - "leftValue": "={{ $json.message.text.toLowerCase().trim() }}", - "rightValue": "start ", + "id": "contains-restart", + "leftValue": "={{ $json.message.text }}", + "rightValue": "restart", "operator": { "type": "string", - "operation": "startsWith" - } - }, - { - "id": "starts-with-stop", - "leftValue": "={{ $json.message.text.toLowerCase().trim() }}", - "rightValue": "stop ", - "operator": { - "type": "string", - "operation": "startsWith" - } - }, - { - "id": "starts-with-restart", - "leftValue": "={{ $json.message.text.toLowerCase().trim() }}", - "rightValue": "restart ", - "operator": { - "type": "string", - "operation": "startsWith" + "operation": "contains" } } ], - "combinator": "or" + "combinator": "and" }, - "renameOutput": false + "renameOutput": true, + "outputKey": "restart" }, { - "id": "update-command-route", + "id": "keyword-start", "conditions": { "options": { "caseSensitive": false, - "leftValue": "", "typeValidation": "loose" }, "conditions": [ { - "id": "starts-with-update", - "leftValue": "={{ $json.message.text.toLowerCase().trim() }}", - "rightValue": "update ", + "id": "contains-start", + "leftValue": "={{ $json.message.text }}", + "rightValue": "start", "operator": { "type": "string", - "operation": "startsWith" + "operation": "contains" } } ], - "combinator": "or" + "combinator": "and" }, - "renameOutput": false + "renameOutput": true, + "outputKey": "start" }, { - "id": "logs-command-route", + "id": "keyword-stop", "conditions": { "options": { "caseSensitive": false, - "leftValue": "", "typeValidation": "loose" }, "conditions": [ { - "id": "matches-logs", - "leftValue": "={{ $json.message.text.toLowerCase().trim() }}", - "rightValue": "^(show\\s+)?logs\\s+", + "id": "contains-stop", + "leftValue": "={{ $json.message.text }}", + "rightValue": "stop", "operator": { "type": "string", - "operation": "regex" + "operation": "contains" } } ], - "combinator": "or" + "combinator": "and" }, - "renameOutput": false + "renameOutput": true, + "outputKey": "stop" + }, + { + "id": "keyword-update", + "conditions": { + "options": { + "caseSensitive": false, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "contains-update", + "leftValue": "={{ $json.message.text }}", + "rightValue": "update", + "operator": { + "type": "string", + "operation": "contains" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "update" + }, + { + "id": "keyword-logs", + "conditions": { + "options": { + "caseSensitive": false, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "contains-logs", + "leftValue": "={{ $json.message.text }}", + "rightValue": "logs", + "operator": { + "type": "string", + "operation": "contains" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "logs" } ] }, @@ -272,8 +323,8 @@ "fallbackOutput": "extra" } }, - "id": "switch-route", - "name": "Route Message", + "id": "switch-keyword-router", + "name": "Keyword Router", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ @@ -297,7 +348,7 @@ }, { "parameters": { - "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}];" + "jsCode": "// Get Docker API response and message\nconst dockerOutput = $input.item.json.stdout;\nconst message = $('Keyword Router').item.json.message;\nconst chatId = 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 specified a container name in message (e.g., \"status plex\")\nconst text = (message.text || '').toLowerCase().trim();\nlet requestedName = null;\n\n// Extract container name if message is more than just \"status\"\nconst parts = text.split(/\\s+/);\nif (parts.length > 1) {\n // Join all parts except \"status\" as the container query\n requestedName = parts.filter(p => p !== 'status').join(' ');\n}\n\n// If no container name specified, return summary\nif (!requestedName) {\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", @@ -348,48 +399,10 @@ }, { "parameters": { - "jsCode": "const message = $input.item.json.message;\nconst timestamp = new Date().toISOString();\nconst text = message.text || '(no text)';\n\nreturn {\n json: {\n chatId: message.chat.id,\n text: `Got: ${text}\\n\\nProcessed: ${timestamp}`\n }\n};" + "jsCode": "// Parse action from message text directly\nconst message = $json.message;\nconst text = message.text.toLowerCase().trim();\nconst chatId = message.chat.id;\nconst messageId = message.message_id;\n\n// Determine action from the text\nlet requestedAction = null;\nlet containerQuery = '';\n\n// Parse action and container name from message\nif (text.startsWith('start ')) {\n requestedAction = 'start';\n containerQuery = text.substring(6).trim();\n} else if (text.startsWith('stop ')) {\n requestedAction = 'stop';\n containerQuery = text.substring(5).trim();\n} else if (text.startsWith('restart ')) {\n requestedAction = 'restart';\n containerQuery = text.substring(8).trim();\n} else if (text.includes('start')) {\n requestedAction = 'start';\n containerQuery = text.replace('start', '').trim();\n} else if (text.includes('stop')) {\n requestedAction = 'stop';\n containerQuery = text.replace('stop', '').trim();\n} else if (text.includes('restart')) {\n requestedAction = 'restart';\n containerQuery = text.replace('restart', '').trim();\n}\n\nif (!requestedAction || !containerQuery) {\n return {\n json: {\n error: true,\n errorMessage: 'Please specify action and container name (e.g., \"start plex\")',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n action: requestedAction,\n containerQuery: containerQuery,\n chatId: chatId,\n messageId: messageId\n }\n};" }, - "id": "code-format-echo", - "name": "Format Echo", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 900, - 800 - ] - }, - { - "parameters": { - "resource": "message", - "operation": "sendMessage", - "chatId": "={{ $json.chatId }}", - "text": "={{ $json.text }}", - "additionalFields": { - "parse_mode": "HTML" - } - }, - "id": "telegram-send", - "name": "Send Echo", - "type": "n8n-nodes-base.telegram", - "typeVersion": 1.2, - "position": [ - 1120, - 800 - ], - "credentials": { - "telegramApi": { - "id": "telegram-credential", - "name": "Telegram API" - } - } - }, - { - "parameters": { - "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", + "id": "code-parse-action-command", + "name": "Parse Action Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ @@ -413,7 +426,7 @@ }, { "parameters": { - "jsCode": "// Get Docker API response and action info from Parse Intent\nconst dockerOutput = $input.item.json.stdout;\nconst intent = $('Parse Intent').item.json;\nconst action = intent.parameters?.action || 'restart'; // Default action\nconst containerQuery = intent.container || '';\nconst chatId = intent.original_message?.chat?.id;\n\n// Parse JSON response\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 matchCount: -1,\n error: true,\n chatId: chatId,\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// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = containerQuery.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Return match results with all necessary context\n// Include allContainers for suggestion finding when no matches\nreturn [{\n json: {\n matches: matches.map(c => ({\n Id: c.Id,\n Name: c.Names[0].replace(/^\\//, ''),\n State: c.State\n })),\n matchCount: matches.length,\n action: action,\n containerQuery: containerQuery,\n chatId: chatId,\n allContainers: containers\n }\n}];" + "jsCode": "// Get Docker API response and action info from Parse Action Command\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action Command').item.json;\nconst action = actionData.action || 'restart';\nconst containerQuery = actionData.containerQuery || '';\nconst chatId = actionData.chatId;\n\n// Parse JSON response\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 matchCount: -1,\n error: true,\n chatId: chatId,\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// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = containerQuery.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Return match results with all necessary context\nreturn [{\n json: {\n matches: matches.map(c => ({\n Id: c.Id,\n Name: c.Names[0].replace(/^\\//, ''),\n State: c.State\n })),\n matchCount: matches.length,\n action: action,\n containerQuery: containerQuery,\n chatId: chatId,\n allContainers: containers\n }\n}];" }, "id": "code-match-container", "name": "Match Container", @@ -1797,7 +1810,7 @@ }, { "parameters": { - "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};" + "jsCode": "// Parse logs command from message\nconst message = $json.message;\nconst text = message.text.toLowerCase().trim();\nconst chatId = message.chat.id;\nconst messageId = message.message_id;\n\n// Extract container name and optional line count\n// Formats: \"logs plex\", \"logs plex 100\", \"show logs plex\"\nlet containerQuery = '';\nlet lineCount = 50; // default\n\n// Remove \"show\" prefix if present\nlet cleanText = text.replace(/^show\\s+/, '');\n// Remove \"logs\" keyword\ncleanText = cleanText.replace(/^logs\\s*/, '');\n\n// Check for line count at the end\nconst parts = cleanText.trim().split(/\\s+/);\nif (parts.length > 1) {\n const lastPart = parts[parts.length - 1];\n if (/^\\d+$/.test(lastPart)) {\n lineCount = Math.min(Math.max(parseInt(lastPart), 1), 1000);\n parts.pop();\n }\n}\ncontainerQuery = parts.join(' ').trim();\n\nif (!containerQuery) {\n return {\n json: {\n error: true,\n chatId: chatId,\n text: 'Please specify a container name (e.g., \"logs plex\" or \"logs plex 100\")'\n }\n };\n}\n\nreturn {\n json: {\n containerQuery: containerQuery,\n lineCount: lineCount,\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-logs", "name": "Parse Logs Command", @@ -2056,321 +2069,22 @@ 600 ] }, - { - "parameters": { - "jsCode": "// Build Claude API request body\nconst userMessage = $json.message?.text || '';\n\nconst systemPrompt = `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\nconst body = {\n model: 'claude-sonnet-4-5-20250929',\n max_tokens: 256,\n system: [{\n type: 'text',\n text: systemPrompt,\n cache_control: { type: 'ephemeral' }\n }],\n messages: [{\n role: 'user',\n content: userMessage\n }]\n};\n\nreturn {\n claudeBody: JSON.stringify(body),\n message: $json.message\n};" - }, - "id": "code-prepare-claude-body", - "name": "Prepare Claude Request", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 900, - 300 - ] - }, { "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" - } - ] - }, + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage", "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ $json.claudeBody }}", - "options": { - "timeout": 30000, - "retry": { - "enabled": true, - "maxTries": 3 - } - } + "jsonBody": "={{ JSON.stringify({ \"chat_id\": $json.message.chat.id, \"text\": \"Use buttons below or type commands:\", \"parse_mode\": \"HTML\", \"reply_markup\": { \"keyboard\": [[{\"text\": \"Status\"}], [{\"text\": \"Start\"}, {\"text\": \"Stop\"}], [{\"text\": \"Restart\"}, {\"text\": \"Update\"}], [{\"text\": \"Logs\"}]], \"is_persistent\": true, \"resize_keyboard\": true, \"one_time_keyboard\": false } }) }}", + "options": {} }, - "id": "http-claude-intent", - "name": "Claude Intent Parser", + "id": "http-show-menu", + "name": "Show Menu", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [ - 1010, - 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 - get from Prepare Claude Request node\nintent.original_message = $('Prepare Claude Request').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": { @@ -2414,7 +2128,7 @@ "main": [ [ { - "node": "Prepare Claude Request", + "node": "Keyword Router", "type": "main", "index": 0 } @@ -2422,17 +2136,6 @@ [] ] }, - "Prepare Claude Request": { - "main": [ - [ - { - "node": "Claude Intent Parser", - "type": "main", - "index": 0 - } - ] - ] - }, "IF Callback Authenticated": { "main": [ [ @@ -2587,38 +2290,6 @@ ] ] }, - "Route Message": { - "main": [ - [ - { - "node": "Docker List Containers", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Parse Action", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Parse Update Command", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Format Echo", - "type": "main", - "index": 0 - } - ] - ] - }, "Docker List Containers": { "main": [ [ @@ -2652,28 +2323,6 @@ ] ] }, - "Format Echo": { - "main": [ - [ - { - "node": "Send Echo", - "type": "main", - "index": 0 - } - ] - ] - }, - "Parse Action": { - "main": [ - [ - { - "node": "Docker List for Action", - "type": "main", - "index": 0 - } - ] - ] - }, "Docker List for Action": { "main": [ [ @@ -3308,30 +2957,50 @@ ] ] }, - "Claude Intent Parser": { + "Keyword Router": { "main": [ [ { - "node": "Parse Intent", + "node": "Show Menu", "type": "main", "index": 0 } - ] - ] - }, - "Parse Intent": { - "main": [ + ], [ { - "node": "Intent Router", + "node": "Docker List Containers", "type": "main", "index": 0 } - ] - ] - }, - "Intent Router": { - "main": [ + ], + [ + { + "node": "Parse Action Command", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Parse Action Command", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Parse Action Command", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Parse Update Command", + "type": "main", + "index": 0 + } + ], [ { "node": "Parse Logs Command", @@ -3341,45 +3010,21 @@ ], [ { - "node": "Send Stats Placeholder", + "node": "Show Menu", "type": "main", "index": 0 } - ], + ] + ] + }, + "Parse Action Command": { + "main": [ [ { "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 - } ] ] }