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)
This commit is contained in:
Lucas Berger
2026-01-30 08:41:27 -05:00
parent 2cbf6e7ec7
commit 56eea26d44
+107 -18
View File
@@ -327,7 +327,7 @@
}, },
{ {
"parameters": { "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", "id": "code-match-container",
"name": "Match Container", "name": "Match Container",
@@ -498,20 +498,80 @@
}, },
{ {
"parameters": { "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 '<b>${data.containerQuery}</b>'`\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", "id": "code-find-closest",
"name": "Format No Match", "name": "Find Closest Match",
"type": "n8n-nodes-base.code", "type": "n8n-nodes-base.code",
"typeVersion": 2, "typeVersion": 2,
"position": [1780, 300] "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 '<b>${query}</b>' found.\\n\\nDid you mean <b>${suggestedName}</b>?`,\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": { "parameters": {
"resource": "message", "resource": "message",
"operation": "sendMessage", "operation": "sendMessage",
"chatId": "={{ $json.chatId }}", "chatId": "={{ $json.chatId }}",
"text": "={{ $json.text }}", "text": "=No container found matching '<b>{{ $json.query }}</b>'",
"additionalFields": { "additionalFields": {
"parse_mode": "HTML" "parse_mode": "HTML"
} }
@@ -520,7 +580,7 @@
"name": "Send No Match", "name": "Send No Match",
"type": "n8n-nodes-base.telegram", "type": "n8n-nodes-base.telegram",
"typeVersion": 1.2, "typeVersion": 1.2,
"position": [2000, 300], "position": [2220, 400],
"credentials": { "credentials": {
"telegramApi": { "telegramApi": {
"id": "telegram-credential", "id": "telegram-credential",
@@ -745,7 +805,7 @@
], ],
[ [
{ {
"node": "Format No Match", "node": "Find Closest Match",
"type": "main", "type": "main",
"index": 0 "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": { "Build Action Command": {
"main": [ "main": [
[ [
@@ -800,17 +900,6 @@
] ]
] ]
}, },
"Format No Match": {
"main": [
[
{
"node": "Send No Match",
"type": "main",
"index": 0
}
]
]
},
"Format Multiple Matches": { "Format Multiple Matches": {
"main": [ "main": [
[ [