From f02f98406ceb0bda4e9850802580976731702f1a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 3 Feb 2026 21:19:01 -0500 Subject: [PATCH] feat(09-01): add container matching with exact-match priority - Add "Match Batch Containers" code node with exact-match-first algorithm - Add "Needs Disambiguation" IF node to route ambiguous matches - Add "Build Disambiguation Message" code node with inline keyboard - Add "Send Disambiguation" HTTP node to display options - Add "Has Not Found" IF node to handle missing containers - Add "Build Not Found Message" code node with partial match confirmation - Add "Send Not Found Message" HTTP node Matching algorithm: 1. Exact match first: 'plex' matches 'plex' even if 'jellyplex' exists 2. Single fuzzy match: treated as found 3. Multiple fuzzy matches: triggers disambiguation with keyboard options 4. No matches: reported as not found with option to proceed with found containers --- n8n-workflow.json | 219 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/n8n-workflow.json b/n8n-workflow.json index 21bc9c6..3a2d256 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -3924,6 +3924,145 @@ 1340, -300 ] + }, + { + "parameters": { + "jsCode": "// Match batch container names with exact-match priority\nconst dockerOutput = $input.item.json.stdout;\nconst batchData = $('Detect Batch Command').item.json;\nconst containerNames = batchData.containerNames;\nconst action = batchData.action;\nconst chatId = batchData.chatId;\nconst messageId = batchData.messageId;\n\n// Parse Docker container list\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 error: true,\n errorMessage: 'Cannot connect to Docker',\n chatId: chatId\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// Match results\nconst allMatched = []; // Containers that matched exactly\nconst needsDisambiguation = []; // { input, matches: [...] }\nconst notFound = []; // Container names with no matches\n\nfor (const inputName of containerNames) {\n const normalized = inputName.toLowerCase().trim();\n \n // 1. Check for exact match first (highest priority)\n const exactMatch = containers.find(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName === normalized;\n });\n \n if (exactMatch) {\n // Exact match wins - no disambiguation needed\n allMatched.push({\n Id: exactMatch.Id,\n Name: exactMatch.Names[0].replace(/^\\//, ''),\n State: exactMatch.State,\n Image: exactMatch.Image,\n inputName: inputName,\n matchType: 'exact'\n });\n continue;\n }\n \n // 2. Check for substring matches (fuzzy)\n const fuzzyMatches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(normalized);\n });\n \n if (fuzzyMatches.length === 0) {\n // No matches at all\n notFound.push(inputName);\n } else if (fuzzyMatches.length === 1) {\n // Single fuzzy match - treat as found\n const match = fuzzyMatches[0];\n allMatched.push({\n Id: match.Id,\n Name: match.Names[0].replace(/^\\//, ''),\n State: match.State,\n Image: match.Image,\n inputName: inputName,\n matchType: 'fuzzy'\n });\n } else {\n // Multiple fuzzy matches - needs disambiguation\n needsDisambiguation.push({\n input: inputName,\n matches: fuzzyMatches.map(c => ({\n Id: c.Id,\n Name: c.Names[0].replace(/^\\//, ''),\n State: c.State\n }))\n });\n }\n}\n\nreturn [{\n json: {\n allMatched: allMatched,\n needsDisambiguation: needsDisambiguation,\n notFound: notFound,\n hasDisambiguation: needsDisambiguation.length > 0,\n hasNotFound: notFound.length > 0,\n action: action,\n chatId: chatId,\n messageId: messageId,\n originalContainerNames: containerNames,\n allContainers: containers\n }\n}];" + }, + "id": "code-match-batch-containers", + "name": "Match Batch Containers", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + -300 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "has-disambiguation", + "leftValue": "={{ $json.hasDisambiguation }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-needs-disambiguation", + "name": "Needs Disambiguation", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1780, + -300 + ] + }, + { + "parameters": { + "jsCode": "// Build disambiguation message with inline keyboard\nconst data = $json;\nconst disambig = data.needsDisambiguation;\nconst action = data.action;\nconst chatId = data.chatId;\n\n// Build message text\nlet text = 'Multiple matches found:\\n\\n';\n\nfor (const item of disambig) {\n const matchNames = item.matches.map(m => m.Name).join(', ');\n text += `\\u2022 '${item.input}' matches: ${matchNames}\\n`;\n}\n\ntext += '\\nPlease be more specific or select from options below.';\n\n// Build inline keyboard with exact container names\n// Each row has one disambiguation option\nconst keyboard = [];\n\nfor (const item of disambig) {\n // Create a row for each ambiguous input\n const row = [];\n for (const match of item.matches.slice(0, 3)) { // Max 3 options per row\n // Callback format: batch:{action}:{containerName}\n // This will be handled by callback router to add to batch\n row.push({\n text: match.Name,\n callback_data: `bselect:${action}:${match.Name}`\n });\n }\n keyboard.push(row);\n}\n\n// Add cancel button\nkeyboard.push([{ text: 'Cancel', callback_data: '{\"a\":\"x\"}' }]);\n\nreturn {\n json: {\n chat_id: chatId,\n text: text,\n parse_mode: 'HTML',\n reply_markup: {\n inline_keyboard: keyboard\n }\n }\n};" + }, + "id": "code-build-disambiguation", + "name": "Build Disambiguation Message", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2000, + -400 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "id": "http-send-disambiguation", + "name": "Send Disambiguation", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2220, + -400 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "has-not-found", + "leftValue": "={{ $json.hasNotFound }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-has-not-found", + "name": "Has Not Found", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 2000, + -200 + ] + }, + { + "parameters": { + "jsCode": "// Build not found message\nconst data = $json;\nconst notFound = data.notFound;\nconst allMatched = data.allMatched;\nconst action = data.action;\nconst chatId = data.chatId;\n\n// Build message\nlet text = '';\n\nif (notFound.length > 0) {\n text += 'Container(s) not found:\\n';\n for (const name of notFound) {\n text += `\\u2022 ${name}\\n`;\n }\n text += '\\n';\n}\n\nif (allMatched.length > 0) {\n text += `Found ${allMatched.length} container(s):\\n`;\n for (const c of allMatched) {\n text += `\\u2022 ${c.Name}\\n`;\n }\n text += `\\nProceed with ${action} on found containers?`;\n \n // Build confirmation keyboard\n const names = allMatched.map(c => c.Name).join(',');\n const timestamp = Math.floor(Date.now() / 1000);\n \n return {\n json: {\n chat_id: chatId,\n text: text,\n parse_mode: 'HTML',\n reply_markup: {\n inline_keyboard: [\n [\n { text: `Yes, ${action} ${allMatched.length} containers`, callback_data: `bexec:${action}:${names}:${timestamp}` },\n { text: 'Cancel', callback_data: '{\"a\":\"x\"}' }\n ]\n ]\n },\n hasMatched: true,\n matchedContainers: allMatched\n }\n };\n} else {\n // No containers matched at all\n return {\n json: {\n chat_id: chatId,\n text: text + 'No valid containers to ' + action + '.',\n parse_mode: 'HTML',\n hasMatched: false\n }\n };\n}" + }, + "id": "code-build-not-found", + "name": "Build Not Found Message", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2220, + -100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text, parse_mode: $json.parse_mode, reply_markup: $json.reply_markup }) }}", + "options": {} + }, + "id": "http-send-not-found", + "name": "Send Not Found Message", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2440, + -100 + ] } ], "connections": { @@ -5134,6 +5273,86 @@ ] ] }, + "Get Containers for Batch": { + "main": [ + [ + { + "node": "Match Batch Containers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Match Batch Containers": { + "main": [ + [ + { + "node": "Needs Disambiguation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Needs Disambiguation": { + "main": [ + [ + { + "node": "Build Disambiguation Message", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Has Not Found", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Disambiguation Message": { + "main": [ + [ + { + "node": "Send Disambiguation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has Not Found": { + "main": [ + [ + { + "node": "Build Not Found Message", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Route Batch Action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Not Found Message": { + "main": [ + [ + { + "node": "Send Not Found Message", + "type": "main", + "index": 0 + } + ] + ] + }, "Parse Action Command": { "main": [ [