From f57c706e01ae61e92b90f45da14ac043cb960f50 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 8 Feb 2026 08:28:57 -0500 Subject: [PATCH] feat(10.1-06): create n8n-matching.json sub-workflow - Extract 12 matching/disambiguation logic nodes into sub-workflow - 23 nodes total: trigger + router + 12 logic + 9 format-return nodes - Three action paths: match_action, match_update, match_batch - Every exit path returns JSON with action field per established pattern - Adapted input references to use trigger data instead of main workflow refs - No response nodes included (stay in main per locked decision) Co-Authored-By: Claude Opus 4.6 --- n8n-matching.json | 866 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 n8n-matching.json diff --git a/n8n-matching.json b/n8n-matching.json new file mode 100644 index 0000000..e7e2bf9 --- /dev/null +++ b/n8n-matching.json @@ -0,0 +1,866 @@ +{ + "name": "Container Matching", + "nodes": [ + { + "parameters": { + "inputSource": "passthrough", + "schema": { + "schemaType": "fromFields", + "inputFieldName": "", + "fields": [ + { + "fieldName": "action", + "fieldType": "string" + }, + { + "fieldName": "containerList", + "fieldType": "string" + }, + { + "fieldName": "searchTerm", + "fieldType": "string" + }, + { + "fieldName": "selectedContainers", + "fieldType": "string" + }, + { + "fieldName": "chatId", + "fieldType": "number" + }, + { + "fieldName": "messageId", + "fieldType": "number" + } + ] + } + }, + "id": "trigger-matching-workflow", + "name": "When executed by another workflow", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 240, + 300 + ] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "match-action", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-match-action", + "leftValue": "={{ $json.action }}", + "rightValue": "match_action", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "match_action" + }, + { + "id": "match-update", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-match-update", + "leftValue": "={{ $json.action }}", + "rightValue": "match_update", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "match_update" + }, + { + "id": "match-batch", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-match-batch", + "leftValue": "={{ $json.action }}", + "rightValue": "match_batch", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "match_batch" + } + ] + }, + "options": { + "fallbackOutput": "none" + } + }, + "id": "switch-route-matching-action", + "name": "Route Action", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 460, + 300 + ] + }, + { + "parameters": { + "jsCode": "// Match container name for action commands\nconst input = $('When executed by another workflow').item.json;\nconst containerListRaw = input.containerList;\nconst searchTerm = input.searchTerm;\nconst chatId = input.chatId;\n\n// Parse JSON response\nlet containers;\ntry {\n if (!containerListRaw || (typeof containerListRaw === 'string' && containerListRaw.trim() === '')) {\n throw new Error('Empty response');\n }\n containers = typeof containerListRaw === 'string' ? JSON.parse(containerListRaw) : containerListRaw;\n} catch (e) {\n return [{\n json: {\n matchCount: -1,\n error: true,\n chatId: chatId,\n text: \"Cannot connect to Docker\"\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\nconst normalized = searchTerm.toLowerCase();\n\n// First check for exact match\nconst exactMatch = containers.find(c => normalizeName(c.Names[0]) === normalized);\nif (exactMatch) {\n return [{\n json: {\n matches: [{ Id: exactMatch.Id, Name: exactMatch.Names[0].replace(/^\\//, ''), State: exactMatch.State }],\n matchCount: 1,\n action: 'action',\n containerQuery: searchTerm,\n chatId: chatId,\n allContainers: containers\n }\n }];\n}\n\n// Fall back to substring matching (container name contains query)\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(normalized);\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: searchTerm,\n chatId: chatId,\n allContainers: containers\n }\n}];" + }, + "id": "code-match-container", + "name": "Match Container", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 100 + ] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "docker-error", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-negative", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "lt" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "error" + }, + { + "id": "no-match", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-zero", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "no_match" + }, + { + "id": "single-match", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "single" + }, + { + "id": "multiple-matches", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-gt-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "multiple" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + }, + "id": "switch-match-count", + "name": "Check Match Count", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 920, + 100 + ] + }, + { + "parameters": { + "jsCode": "// Find closest match for suggestion\nconst query = $json.containerQuery.toLowerCase();\nconst containers = $json.allContainers;\nconst action = $json.action;\nconst chatId = $json.chatId;\n\n// Simple closest match: longest common substring or starts-with\nlet bestMatch = null;\nlet bestScore = 0;\n\nfor (const container of containers) {\n const name = container.Names[0].replace(/^\\//, '').toLowerCase();\n // Score by: contains query, or query contains name, or matching chars\n let score = 0;\n if (name.includes(query)) score = query.length * 2;\n else if (query.includes(name)) score = name.length * 1.5;\n else {\n // Simple: count matching starting characters\n for (let i = 0; i < Math.min(query.length, name.length); i++) {\n if (query[i] === name[i]) score++;\n else break;\n }\n }\n if (score > bestScore) {\n bestScore = score;\n bestMatch = container;\n }\n}\n\n// Require minimum score of 2 to suggest\nif (!bestMatch || bestScore < 2) {\n return { json: { hasSuggestion: false, query, action, chatId } };\n}\n\nconst suggestedName = bestMatch.Names[0].replace(/^\\//, '');\nconst suggestedId = bestMatch.Id.substring(0, 12); // Short ID for callback_data\n\nreturn {\n json: {\n hasSuggestion: true,\n query,\n action,\n chatId,\n suggestedName,\n suggestedId,\n timestamp: Date.now()\n }\n};" + }, + "id": "code-find-closest", + "name": "Find Closest Match", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "has-suggestion", + "leftValue": "={{ $json.hasSuggestion }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-has-suggestion", + "name": "Check Suggestion", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1360, + 0 + ] + }, + { + "parameters": { + "jsCode": "// Build suggestion keyboard for Telegram\nconst { chatId, query, action, suggestedName, suggestedId, timestamp } = $json;\n\n// Use modern action callback format: action:{action}:{containerName}\nconst callbackData = `action:${action}:${suggestedName}`;\n\nreturn {\n json: {\n action: \"suggestion\",\n chat_id: chatId,\n text: `No container '${query}' found.\\n\\nDid you mean ${suggestedName}?`,\n parse_mode: \"HTML\",\n reply_markup: {\n inline_keyboard: [\n [\n { text: `Yes, ${action} ${suggestedName}`, callback_data: callbackData },\n { text: \"Cancel\", callback_data: 'batch:cancel' }\n ]\n ]\n }\n }\n};" + }, + "id": "code-build-suggestion", + "name": "Build Suggestion Keyboard", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1580, + -100 + ] + }, + { + "parameters": { + "jsCode": "// No match and no suggestion - return no_match action\nconst { query, action, chatId } = $json;\n\nreturn {\n json: {\n action: \"no_match\",\n query: query,\n chatId: chatId\n }\n};" + }, + "id": "code-format-no-match-return", + "name": "Format No Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1580, + 100 + ] + }, + { + "parameters": { + "jsCode": "// Single action match found - return matched action\nconst data = $json;\nconst containerId = data.matches[0].Id;\nconst containerName = data.matches[0].Name;\n\nreturn {\n json: {\n action: \"matched\",\n containerId: containerId,\n containerName: containerName,\n matches: data.matches,\n matchCount: data.matchCount,\n actionType: data.action,\n containerQuery: data.containerQuery,\n chatId: data.chatId,\n allContainers: data.allContainers\n }\n};" + }, + "id": "code-format-single-match-return", + "name": "Format Single Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 200 + ] + }, + { + "parameters": { + "jsCode": "// Docker error - return error action\nconst data = $json;\n\nreturn {\n json: {\n action: \"error\",\n errorMessage: data.text || \"Cannot connect to Docker\",\n chatId: data.chatId\n }\n};" + }, + "id": "code-format-error-return", + "name": "Format Error Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + -100 + ] + }, + { + "parameters": { + "jsCode": "// Multiple action matches - return for main workflow to build keyboard\nconst data = $json;\n\nreturn {\n json: {\n action: \"multiple\",\n matches: data.matches,\n matchCount: data.matchCount,\n actionType: data.action,\n containerQuery: data.containerQuery,\n chatId: data.chatId,\n allContainers: data.allContainers\n }\n};" + }, + "id": "code-format-multiple-match-return", + "name": "Format Multiple Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 300 + ] + }, + { + "parameters": { + "jsCode": "// Match container name for update commands\nconst input = $('When executed by another workflow').item.json;\nconst containerListRaw = input.containerList;\nconst searchTerm = input.searchTerm;\nconst chatId = input.chatId;\n\n// Parse JSON response\nlet containers;\ntry {\n if (!containerListRaw || (typeof containerListRaw === 'string' && containerListRaw.trim() === '')) {\n throw new Error('Empty response');\n }\n containers = typeof containerListRaw === 'string' ? JSON.parse(containerListRaw) : containerListRaw;\n} catch (e) {\n return [{\n json: {\n matchCount: -1,\n error: true,\n chatId: chatId,\n text: \"Cannot connect to Docker\"\n }\n }];\n}\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\nconst normalized = searchTerm.toLowerCase();\n\n// First check for exact match\nconst exactMatch = containers.find(c => normalizeName(c.Names[0]) === normalized);\nif (exactMatch) {\n return [{\n json: {\n matches: [{ Id: exactMatch.Id, Name: exactMatch.Names[0].replace(/^\\//, ''), State: exactMatch.State }],\n matchCount: 1,\n containerQuery: searchTerm,\n chatId: chatId,\n allContainers: containers\n }\n }];\n}\n\n// Fall back to substring matching (container name contains query)\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(normalized);\n});\n\n// Return match results\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 containerQuery: searchTerm,\n chatId: chatId,\n allContainers: containers\n }\n}];" + }, + "id": "code-match-update-container", + "name": "Match Update Container", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 700, + 500 + ] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "update-docker-error", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-negative", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "lt" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "error" + }, + { + "id": "update-no-match", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-zero", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "no_match" + }, + { + "id": "update-single-match", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "single" + }, + { + "id": "update-multiple-matches", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "count-gt-one", + "leftValue": "={{ $json.matchCount }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "multiple" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + }, + "id": "switch-update-match-count", + "name": "Check Update Match Count", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 920, + 500 + ] + }, + { + "parameters": { + "jsCode": "// Docker error on update - return error action\nconst data = $json;\n\nreturn {\n json: {\n action: \"error\",\n errorMessage: data.text || \"Cannot connect to Docker\",\n chatId: data.chatId\n }\n};" + }, + "id": "code-format-update-error-return", + "name": "Format Update Error Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 400 + ] + }, + { + "parameters": { + "jsCode": "// No update match found - return no_match_update action\nconst data = $json;\n\nreturn {\n json: {\n action: \"no_match_update\",\n containerQuery: data.containerQuery,\n chatId: data.chatId\n }\n};" + }, + "id": "code-format-update-no-match-return", + "name": "Format Update No Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 500 + ] + }, + { + "parameters": { + "jsCode": "// Single update match found - return matched_update action\nconst data = $json;\nconst containerId = data.matches[0].Id;\nconst containerName = data.matches[0].Name;\n\nreturn {\n json: {\n action: \"matched_update\",\n containerId: containerId,\n containerName: containerName,\n matches: data.matches,\n matchCount: data.matchCount,\n containerQuery: data.containerQuery,\n chatId: data.chatId\n }\n};" + }, + "id": "code-format-update-single-return", + "name": "Format Update Single Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 600 + ] + }, + { + "parameters": { + "jsCode": "// Multiple update matches found - return multiple_update action\nconst data = $json;\n\nreturn {\n json: {\n action: \"multiple_update\",\n matches: data.matches,\n matchCount: data.matchCount,\n containerQuery: data.containerQuery,\n chatId: data.chatId\n }\n};" + }, + "id": "code-format-update-multiple-return", + "name": "Format Update Multiple Match Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1140, + 700 + ] + }, + { + "parameters": { + "jsCode": "// Match batch container names with exact-match priority\nconst input = $('When executed by another workflow').item.json;\nconst containerListRaw = input.containerList;\nconst selectedContainers = input.selectedContainers;\nconst chatId = input.chatId;\nconst messageId = input.messageId;\n\n// Parse selectedContainers (CSV string) into array\nconst containerNames = typeof selectedContainers === 'string' \n ? selectedContainers.split(',').map(s => s.trim()).filter(s => s)\n : (Array.isArray(selectedContainers) ? selectedContainers : []);\n\n// Parse Docker container list\nlet containers;\ntry {\n if (!containerListRaw || (typeof containerListRaw === 'string' && containerListRaw.trim() === '')) {\n throw new Error('Empty response');\n }\n containers = typeof containerListRaw === 'string' ? JSON.parse(containerListRaw) : containerListRaw;\n} catch (e) {\n return [{\n json: {\n action: \"error\",\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: 'batch',\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": [ + 700, + 900 + ] + }, + { + "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": [ + 920, + 900 + ] + }, + { + "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: bselect:{action}:{containerName}\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 action: \"disambiguation\",\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": [ + 1140, + 800 + ] + }, + { + "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": [ + 1140, + 1000 + ] + }, + { + "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 action: \"not_found\",\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 action: \"not_found\",\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": [ + 1360, + 1100 + ] + }, + { + "parameters": { + "jsCode": "// All batch containers matched - return batch_matched action\nconst data = $json;\n\nreturn {\n json: {\n action: \"batch_matched\",\n matchedContainers: data.allMatched,\n chatId: data.chatId,\n messageId: data.messageId,\n originalContainerNames: data.originalContainerNames\n }\n};" + }, + "id": "code-format-batch-matched-return", + "name": "Format Batch Matched Return", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1360, + 1000 + ] + } + ], + "connections": { + "When executed by another workflow": { + "main": [ + [ + { + "node": "Route Action", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Action": { + "main": [ + [ + { + "node": "Match Container", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Match Update Container", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Match Batch Containers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Match Container": { + "main": [ + [ + { + "node": "Check Match Count", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Match Count": { + "main": [ + [ + { + "node": "Format Error Return", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Find Closest Match", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Single Match Return", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Multiple Match Return", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find Closest Match": { + "main": [ + [ + { + "node": "Check Suggestion", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Suggestion": { + "main": [ + [ + { + "node": "Build Suggestion Keyboard", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format No Match Return", + "type": "main", + "index": 0 + } + ] + ] + }, + "Match Update Container": { + "main": [ + [ + { + "node": "Check Update Match Count", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Update Match Count": { + "main": [ + [ + { + "node": "Format Update Error Return", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Update No Match Return", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Update Single Match Return", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Update Multiple Match Return", + "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 + } + ] + ] + }, + "Has Not Found": { + "main": [ + [ + { + "node": "Build Not Found Message", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Format Batch Matched Return", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "any" + } +} \ No newline at end of file