feat(03-01): add action command routing to workflow

- Add new route in Switch node for start/stop/restart commands
- Route matches case-insensitive startsWith for each action
- Add Parse Action Code node to extract action type and container name
- Action branch routes to Parse Action, ready for container matching
This commit is contained in:
Lucas Berger
2026-01-30 08:33:47 -05:00
parent 893412f405
commit 4848e7db16
+62 -5
View File
@@ -72,6 +72,47 @@
"combinator": "or" "combinator": "or"
}, },
"renameOutput": false "renameOutput": false
},
{
"id": "action-command-route",
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "starts-with-start",
"leftValue": "={{ $json.message.text.toLowerCase().trim() }}",
"rightValue": "start ",
"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"
}
}
],
"combinator": "or"
},
"renameOutput": false
} }
] ]
}, },
@@ -98,7 +139,7 @@
}, },
{ {
"parameters": { "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 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}];"
}, },
"id": "code-parse-match", "id": "code-parse-match",
"name": "Parse and Match", "name": "Parse and Match",
@@ -108,7 +149,7 @@
}, },
{ {
"parameters": { "parameters": {
"jsCode": "// Get the data from previous node\nconst data = $input.item.json;\nconst chatId = data.chatId;\n\n// If error or summary, pass through as-is\nif (data.error || data.summary || data.multipleMatches) {\n return [{\n json: {\n chatId: chatId,\n text: data.text\n }\n }];\n}\n\n// Format single container details\nif (data.singleMatch) {\n const container = data.container;\n\n // Emoji mapping for states\n const stateEmoji = {\n 'running': '',\n 'exited': '',\n 'paused': '⏸️',\n 'restarting': '🔄',\n 'dead': '💀'\n };\n\n const emoji = stateEmoji[container.state] || '';\n\n // Format detailed response\n const text = `${emoji} <b>${container.name}</b>\\n\\n` +\n `<b>State:</b> ${container.state}\\n` +\n `<b>Status:</b> ${container.status}\\n` +\n `<b>Image:</b> ${container.image}\\n` +\n `<b>ID:</b> ${container.id.substring(0, 12)}`;\n\n return [{\n json: {\n chatId: chatId,\n text: text\n }\n }];\n}\n\n// Fallback\nreturn [{\n json: {\n chatId: chatId,\n text: \"Unexpected response format\"\n }\n}];" "jsCode": "// Get the data from previous node\nconst data = $input.item.json;\nconst chatId = data.chatId;\n\n// If error or summary, pass through as-is\nif (data.error || data.summary || data.multipleMatches) {\n return [{\n json: {\n chatId: chatId,\n text: data.text\n }\n }];\n}\n\n// Format single container details\nif (data.singleMatch) {\n const container = data.container;\n\n // State indicator mapping\n const stateIndicator = {\n 'running': '[OK]',\n 'exited': '[STOPPED]',\n 'paused': '[PAUSED]',\n 'restarting': '[RESTARTING]',\n 'dead': '[DEAD]'\n };\n\n const indicator = stateIndicator[container.state] || '[?]';\n\n // Format detailed response\n const text = `${indicator} <b>${container.name}</b>\\n\\n` +\n `<b>State:</b> ${container.state}\\n` +\n `<b>Status:</b> ${container.status}\\n` +\n `<b>Image:</b> ${container.image}\\n` +\n `<b>ID:</b> ${container.id.substring(0, 12)}`;\n\n return [{\n json: {\n chatId: chatId,\n text: text\n }\n }];\n}\n\n// Fallback\nreturn [{\n json: {\n chatId: chatId,\n text: \"Unexpected response format\"\n }\n}];"
}, },
"id": "code-format-response", "id": "code-format-response",
"name": "Format Response", "name": "Format Response",
@@ -146,7 +187,7 @@
"name": "Format Echo", "name": "Format Echo",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [900, 400] "position": [900, 600]
}, },
{ {
"parameters": { "parameters": {
@@ -162,13 +203,23 @@
"name": "Send Echo", "name": "Send Echo",
"type": "n8n-nodes-base.telegram", "type": "n8n-nodes-base.telegram",
"typeVersion": 1.2, "typeVersion": 1.2,
"position": [1120, 400], "position": [1120, 600],
"credentials": { "credentials": {
"telegramApi": { "telegramApi": {
"id": "telegram-credential", "id": "telegram-credential",
"name": "Telegram API" "name": "Telegram API"
} }
} }
},
{
"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 <container-name>',\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};"
},
"id": "code-parse-action",
"name": "Parse Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 400]
} }
], ],
"connections": { "connections": {
@@ -204,7 +255,13 @@
"index": 0 "index": 0
} }
], ],
[], [
{
"node": "Parse Action",
"type": "main",
"index": 0
}
],
[], [],
[ [
{ {