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
This commit is contained in:
Lucas Berger
2026-02-03 21:19:01 -05:00
parent 9e7ff2ab08
commit f02f98406c
+219
View File
@@ -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 = '<b>Multiple matches found:</b>\\n\\n';\n\nfor (const item of disambig) {\n const matchNames = item.matches.map(m => m.Name).join(', ');\n text += `\\u2022 '<b>${item.input}</b>' 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 += '<b>Container(s) not found:</b>\\n';\n for (const name of notFound) {\n text += `\\u2022 ${name}\\n`;\n }\n text += '\\n';\n}\n\nif (allMatched.length > 0) {\n text += `<b>Found ${allMatched.length} container(s):</b>\\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": [
[