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
- }
]
]
}