From 56eea26d4491884f71f58e432de83ea9e50042d0 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 30 Jan 2026 08:41:27 -0500 Subject: [PATCH] feat(03-02): implement suggestion flow for no-match cases - Replace Format No Match with Find Closest Match code node - Add Check Suggestion IF node to route based on hasSuggestion - Add Build Suggestion Keyboard code node for inline button payload - Add Send Suggestion HTTP Request to Telegram API with inline_keyboard - Update Match Container to include allContainers for suggestion logic - Suggestion shown when score >= 2 (partial match found) --- n8n-workflow.json | 125 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 18 deletions(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index 0761908..312beba 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -327,7 +327,7 @@ }, { "parameters": { - "jsCode": "// Get Docker API response and action info from Parse Action\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action').item.json;\nconst action = actionData.action;\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 }\n}];" + "jsCode": "// Get Docker API response and action info from Parse Action\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action').item.json;\nconst action = actionData.action;\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\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}];" }, "id": "code-match-container", "name": "Match Container", @@ -498,20 +498,80 @@ }, { "parameters": { - "jsCode": "// No container matched the query\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n text: `No container found matching '${data.containerQuery}'`\n }\n};" + "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-no-match", - "name": "Format No Match", + "id": "code-find-closest", + "name": "Find Closest Match", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1780, 300] }, + { + "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": [2000, 300] + }, + { + "parameters": { + "jsCode": "// Build suggestion keyboard for Telegram\nconst { chatId, query, action, suggestedName, suggestedId, timestamp } = $json;\n\n// callback_data must be <=64 bytes - use short keys\n// a=action (1 char: s=start, t=stop, r=restart)\n// c=container short ID\n// t=timestamp\nconst actionCode = action === 'start' ? 's' : action === 'stop' ? 't' : 'r';\nconst callbackData = JSON.stringify({ a: actionCode, c: suggestedId, t: timestamp });\n\nreturn {\n json: {\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: '{\"a\":\"x\"}' }\n ]\n ]\n }\n }\n};" + }, + "id": "code-build-suggestion", + "name": "Build Suggestion Keyboard", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2220, 200] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "id": "http-send-suggestion", + "name": "Send Suggestion", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2440, 200], + "credentials": { + "telegramApi": { + "id": "telegram-credential", + "name": "Telegram API" + } + } + }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", - "text": "={{ $json.text }}", + "text": "=No container found matching '{{ $json.query }}'", "additionalFields": { "parse_mode": "HTML" } @@ -520,7 +580,7 @@ "name": "Send No Match", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, - "position": [2000, 300], + "position": [2220, 400], "credentials": { "telegramApi": { "id": "telegram-credential", @@ -745,7 +805,7 @@ ], [ { - "node": "Format No Match", + "node": "Find Closest Match", "type": "main", "index": 0 } @@ -767,6 +827,46 @@ [] ] }, + "Find Closest Match": { + "main": [ + [ + { + "node": "Check Suggestion", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Suggestion": { + "main": [ + [ + { + "node": "Build Suggestion Keyboard", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send No Match", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Suggestion Keyboard": { + "main": [ + [ + { + "node": "Send Suggestion", + "type": "main", + "index": 0 + } + ] + ] + }, "Build Action Command": { "main": [ [ @@ -800,17 +900,6 @@ ] ] }, - "Format No Match": { - "main": [ - [ - { - "node": "Send No Match", - "type": "main", - "index": 0 - } - ] - ] - }, "Format Multiple Matches": { "main": [ [