Files
unraid-docker-manager/n8n-workflow.json
T
Lucas Berger ab8d5282c0 feat(03-03): build batch confirmation keyboard with inline buttons
- Replace placeholder 'Format Multiple Matches' with 'Build Batch Keyboard'
- Create inline_keyboard with 'Yes, <action> N containers' and 'Cancel' buttons
- Encode batch container IDs in callback_data (limit 4 for 64-byte constraint)
- Use HTTP Request for sendMessage (same pattern as suggestion flow)
- Format container list with bullet points in confirmation message
2026-01-30 08:45:56 -05:00

1338 lines
49 KiB
JSON

{
"name": "Docker Manager Bot",
"nodes": [
{
"parameters": {
"updates": ["message", "callback_query"]
},
"id": "telegram-trigger",
"name": "Telegram Trigger",
"type": "n8n-nodes-base.telegramTrigger",
"typeVersion": 1.1,
"position": [240, 300],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"rules": {
"values": [
{
"id": "route-message",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "has-message",
"leftValue": "={{ $json.message }}",
"rightValue": "",
"operator": {
"type": "object",
"operation": "notEmpty"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "message"
},
{
"id": "route-callback",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "has-callback",
"leftValue": "={{ $json.callback_query }}",
"rightValue": "",
"operator": {
"type": "object",
"operation": "notEmpty"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "callback_query"
}
]
},
"options": {
"fallbackOutput": "none"
}
},
"id": "switch-update-type",
"name": "Route Update Type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [460, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "user-auth-condition",
"leftValue": "={{ $json.message.from.id.toString() }}",
"rightValue": "563878771",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-auth",
"name": "IF User Authenticated",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [680, 200]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "callback-auth-condition",
"leftValue": "={{ $json.callback_query.from.id.toString() }}",
"rightValue": "563878771",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-callback-auth",
"name": "IF Callback Authenticated",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [680, 500]
},
{
"parameters": {
"rules": {
"values": [
{
"id": "docker-query-route",
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "contains-status",
"leftValue": "={{ $json.message.text.toLowerCase() }}",
"rightValue": "status",
"operator": {
"type": "string",
"operation": "contains"
}
}
],
"combinator": "or"
},
"renameOutput": false
},
{
"id": "action-command-route",
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "starts-with-start",
"leftValue": "={{ $json.message.text.toLowerCase().trim() }}",
"rightValue": "start ",
"operator": {
"type": "string",
"operation": "startsWith"
}
},
{
"id": "starts-with-stop",
"leftValue": "={{ $json.message.text.toLowerCase().trim() }}",
"rightValue": "stop ",
"operator": {
"type": "string",
"operation": "startsWith"
}
},
{
"id": "starts-with-restart",
"leftValue": "={{ $json.message.text.toLowerCase().trim() }}",
"rightValue": "restart ",
"operator": {
"type": "string",
"operation": "startsWith"
}
}
],
"combinator": "or"
},
"renameOutput": false
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "switch-route",
"name": "Route Message",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [900, 200]
},
{
"parameters": {
"command": "curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/containers/json?all=true'",
"options": {}
},
"id": "exec-docker-list",
"name": "Docker List Containers",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [1120, 100]
},
{
"parameters": {
"jsCode": "// Get Docker API response and user input\nconst dockerOutput = $input.item.json.stdout;\nconst userMessage = $('Telegram Trigger').item.json.message.text.toLowerCase().trim();\nconst chatId = $('Telegram Trigger').item.json.message.chat.id;\n\n// Parse JSON response - only error if we can't parse valid JSON from stdout\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 chatId: chatId,\n error: true,\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// Extract container name from user query\n// Remove common query words to get the container name\nconst queryWords = ['status', 'show', 'check', 'container', 'docker', 'what', 'is', 'the', 'of', 'for'];\nconst words = userMessage.split(/\\s+/).filter(word => !queryWords.includes(word));\nconst requestedName = words.join(' ').trim();\n\n// If no container name specified, return summary\nif (!requestedName || requestedName === '') {\n const counts = containers.reduce((acc, c) => {\n acc[c.State] = (acc[c.State] || 0) + 1;\n return acc;\n }, {});\n\n const parts = [];\n if (counts.running) parts.push(`${counts.running} running`);\n if (counts.exited) parts.push(`${counts.exited} stopped`);\n if (counts.paused) parts.push(`${counts.paused} paused`);\n if (counts.restarting) parts.push(`${counts.restarting} restarting`);\n\n const summary = parts.length > 0 ? parts.join(', ') : 'No containers found';\n\n return [{\n json: {\n chatId: chatId,\n summary: true,\n containers: containers,\n text: `Container summary: ${summary}`\n }\n }];\n}\n\n// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = requestedName.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Handle no matches\nif (matches.length === 0) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: `No container found matching \"${requestedName}\".\\n\\nTry \"status\" to see all containers.`\n }\n }];\n}\n\n// Handle multiple matches\nif (matches.length > 1) {\n const names = matches.map(c => c.Names[0].replace(/^\\//, '')).join('\\n- ');\n return [{\n json: {\n chatId: chatId,\n multipleMatches: true,\n matches: matches,\n text: `Found ${matches.length} matches:\\n\\n- ${names}\\n\\nPlease be more specific.`\n }\n }];\n}\n\n// Single match - return container details\nconst container = matches[0];\nreturn [{\n json: {\n chatId: chatId,\n singleMatch: true,\n container: {\n id: container.Id,\n name: container.Names[0].replace(/^\\//, ''),\n state: container.State,\n status: container.Status,\n image: container.Image\n }\n }\n}];"
},
"id": "code-parse-match",
"name": "Parse and Match",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 100]
},
{
"parameters": {
"jsCode": "// Get the data from previous node\nconst data = $input.item.json;\nconst chatId = data.chatId;\n\n// If error or summary, pass through as-is\nif (data.error || data.summary || data.multipleMatches) {\n return [{\n json: {\n chatId: chatId,\n text: data.text\n }\n }];\n}\n\n// Format single container details\nif (data.singleMatch) {\n const container = data.container;\n\n // State indicator mapping\n const stateIndicator = {\n 'running': '[OK]',\n 'exited': '[STOPPED]',\n 'paused': '[PAUSED]',\n 'restarting': '[RESTARTING]',\n 'dead': '[DEAD]'\n };\n\n const indicator = stateIndicator[container.state] || '[?]';\n\n // Format detailed response\n const text = `${indicator} <b>${container.name}</b>\\n\\n` +\n `<b>State:</b> ${container.state}\\n` +\n `<b>Status:</b> ${container.status}\\n` +\n `<b>Image:</b> ${container.image}\\n` +\n `<b>ID:</b> ${container.id.substring(0, 12)}`;\n\n return [{\n json: {\n chatId: chatId,\n text: text\n }\n }];\n}\n\n// Fallback\nreturn [{\n json: {\n chatId: chatId,\n text: \"Unexpected response format\"\n }\n}];"
},
"id": "code-format-response",
"name": "Format Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1560, 100]
},
{
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $json.chatId }}",
"text": "={{ $json.text }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send-docker",
"name": "Send Docker Response",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [1560, 200],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "const message = $input.item.json.message;\nconst timestamp = new Date().toISOString();\nconst text = message.text || '(no text)';\n\nreturn {\n json: {\n chatId: message.chat.id,\n text: `Got: ${text}\\n\\nProcessed: ${timestamp}`\n }\n};"
},
"id": "code-format-echo",
"name": "Format Echo",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 800]
},
{
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $json.chatId }}",
"text": "={{ $json.text }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send",
"name": "Send Echo",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [1120, 800],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "// Parse action command from message\nconst text = $json.message.text.toLowerCase().trim();\nconst chatId = $json.message.chat.id;\nconst messageId = $json.message.message_id;\n\n// Match action pattern: start/stop/restart followed by container name\nconst match = text.match(/^(start|stop|restart)\\s+(.+)$/i);\n\nif (!match) {\n return {\n json: {\n error: true,\n errorMessage: 'Invalid action format. Use: start/stop/restart <container-name>',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n action: match[1].toLowerCase(),\n containerQuery: match[2].trim(),\n chatId: chatId,\n messageId: messageId\n }\n};"
},
"id": "code-parse-action",
"name": "Parse Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 400]
},
{
"parameters": {
"command": "curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/containers/json?all=true'",
"options": {}
},
"id": "exec-docker-list-action",
"name": "Docker List for Action",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [1120, 400]
},
{
"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\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",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 400]
},
{
"parameters": {
"rules": {
"values": [
{
"id": "docker-error",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "count-negative",
"leftValue": "={{ $json.matchCount }}",
"rightValue": "0",
"operator": {
"type": "number",
"operation": "lt"
}
}
],
"combinator": "and"
},
"renameOutput": false
},
{
"id": "no-match",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "count-zero",
"leftValue": "={{ $json.matchCount }}",
"rightValue": "0",
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": false
},
{
"id": "single-match",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "count-one",
"leftValue": "={{ $json.matchCount }}",
"rightValue": "1",
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": false
},
{
"id": "multiple-matches",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "count-gt-one",
"leftValue": "={{ $json.matchCount }}",
"rightValue": "1",
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"renameOutput": false
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "switch-match-count",
"name": "Check Match Count",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [1560, 400]
},
{
"parameters": {
"jsCode": "// Build the curl command for the action\nconst data = $input.item.json;\nconst containerId = data.matches[0].Id;\nconst action = data.action;\nconst containerName = data.matches[0].Name;\nconst chatId = data.chatId;\n\n// stop and restart use ?t=10 for graceful timeout\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\n// Build curl command that returns HTTP status code\nconst cmd = `curl -s -o /dev/null -w \"%{http_code}\" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`;\n\nreturn {\n json: {\n cmd: cmd,\n containerId: containerId,\n containerName: containerName,\n action: action,\n chatId: chatId\n }\n};"
},
"id": "code-build-action-cmd",
"name": "Build Action Command",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1780, 400]
},
{
"parameters": {
"command": "={{ $json.cmd }}",
"options": {}
},
"id": "exec-action",
"name": "Execute Action",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [2000, 400]
},
{
"parameters": {
"jsCode": "// Parse the HTTP status code from curl output\nconst stdout = $input.item.json.stdout;\nconst stderr = $input.item.json.stderr;\nconst actionData = $('Build Action Command').item.json;\nconst containerName = actionData.containerName;\nconst action = actionData.action;\nconst chatId = actionData.chatId;\n\n// Check for curl-level errors first\nif (stderr && stderr.trim()) {\n return {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${stderr.trim()}`\n }\n };\n}\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success for user)\nif (statusCode === 204 || statusCode === 304) {\n const verb = action === 'start' ? 'started' :\n action === 'stop' ? 'stopped' : 'restarted';\n return {\n json: {\n success: true,\n chatId: chatId,\n text: `<b>${containerName}</b> ${verb} successfully`\n }\n };\n}\n\n// Handle error codes\nlet errorMsg;\nswitch (statusCode) {\n case 404:\n errorMsg = 'Container not found';\n break;\n case 409:\n errorMsg = 'Container is in a conflicting state';\n break;\n case 500:\n errorMsg = 'Docker server error';\n break;\n default:\n errorMsg = `HTTP ${statusCode}`;\n}\n\nreturn {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${errorMsg}`\n }\n};"
},
"id": "code-parse-action-result",
"name": "Parse Action Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2220, 400]
},
{
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $json.chatId }}",
"text": "={{ $json.text }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send-action-result",
"name": "Send Action Result",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [2440, 400],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"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": [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": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $json.chatId }}",
"text": "=No container found matching '<b>{{ $json.query }}</b>'",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send-no-match",
"name": "Send No Match",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [2220, 400],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "// Build batch confirmation keyboard for multiple matches\nconst matches = $json.matches;\nconst action = $json.action;\nconst chatId = $json.chatId;\nconst query = $json.containerQuery;\n\n// List matched container names\nconst names = matches.map(m => m.Name);\nconst shortIds = matches.map(m => m.Id.substring(0, 12));\n\n// Build callback_data - must be <=64 bytes\n// For batch: a=action code, c=array of short IDs, t=timestamp\nconst actionCode = action === 'start' ? 's' : action === 'stop' ? 't' : 'r';\nconst timestamp = Date.now();\n\n// Check size - if too many containers, callback_data might exceed 64 bytes\n// Each short ID is 12 chars, plus overhead. Max ~3-4 containers safely\nlet callbackData;\nlet limitedCount = shortIds.length;\nif (shortIds.length <= 4) {\n callbackData = JSON.stringify({ a: actionCode, c: shortIds, t: timestamp });\n} else {\n // Too many containers - limit to first 4\n callbackData = JSON.stringify({ a: actionCode, c: shortIds.slice(0, 4), t: timestamp });\n limitedCount = 4;\n}\n\n// Format container list\nconst listText = names.map(n => ` \\u2022 ${n}`).join('\\n');\n\n// Build action verb for button\nconst actionVerb = action.charAt(0).toUpperCase() + action.slice(1);\n\nreturn {\n json: {\n chat_id: chatId,\n text: `Found <b>${matches.length}</b> containers matching '<b>${query}</b>':\\n\\n${listText}\\n\\n${actionVerb} all?`,\n parse_mode: \"HTML\",\n reply_markup: {\n inline_keyboard: [\n [\n { text: `Yes, ${action} ${limitedCount} containers`, callback_data: callbackData },\n { text: \"Cancel\", callback_data: '{\"a\":\"x\"}' }\n ]\n ]\n },\n // Store metadata for summary\n _meta: {\n action,\n containers: matches,\n timestamp,\n limitedCount\n }\n }\n};"
},
"id": "code-build-batch-keyboard",
"name": "Build Batch Keyboard",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1780, 500]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/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-batch-confirm",
"name": "Send Batch Confirmation",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [2000, 500],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $json.chatId }}",
"text": "={{ $json.text }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send-docker-error",
"name": "Send Docker Error",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [1780, 200],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "// Parse callback data from suggestion button click\nconst callback = $json.callback_query;\nlet data;\ntry {\n data = JSON.parse(callback.data);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\n\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\n\n// Check 2-minute timeout (120000ms)\nconst TWO_MINUTES = 120000;\nconst isExpired = data.t && (Date.now() - data.t > TWO_MINUTES);\n\n// Decode action: s=start, t=stop, r=restart, x=cancel\nconst actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' };\nconst action = actionMap[data.a] || 'cancel';\n\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n action,\n containerId: data.c || null,\n expired: isExpired,\n isCancel: action === 'cancel'\n }\n};"
},
"id": "code-parse-callback",
"name": "Parse Callback Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 500]
},
{
"parameters": {
"rules": {
"values": [
{
"id": "is-cancel",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "cancel-true",
"leftValue": "={{ $json.isCancel }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "cancel"
},
{
"id": "is-expired",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "expired-true",
"leftValue": "={{ $json.expired }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "expired"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "switch-route-callback",
"name": "Route Callback",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [1120, 500]
},
{
"parameters": {
"jsCode": "// Prepare cancel response - answer callback query and delete message\nconst { queryId, chatId, messageId } = $json;\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n answerText: 'Cancelled'\n }\n};"
},
"id": "code-handle-cancel",
"name": "Handle Cancel",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 500]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId, text: $json.answerText }) }}",
"options": {}
},
"id": "http-answer-cancel",
"name": "Answer Cancel Query",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1560, 500],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/deleteMessage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $('Handle Cancel').item.json.chatId, message_id: $('Handle Cancel').item.json.messageId }) }}",
"options": {}
},
"id": "http-delete-cancel-msg",
"name": "Delete Cancel Message",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1780, 500],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "// Prepare expired response\nconst { queryId, chatId, messageId } = $json;\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n answerText: 'Confirmation expired. Please try again.'\n }\n};"
},
"id": "code-handle-expired",
"name": "Handle Expired",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 600]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId, text: $json.answerText, show_alert: true }) }}",
"options": {}
},
"id": "http-answer-expired",
"name": "Answer Expired Query",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1560, 600],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/deleteMessage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $('Handle Expired').item.json.chatId, message_id: $('Handle Expired').item.json.messageId }) }}",
"options": {}
},
"id": "http-delete-expired-msg",
"name": "Delete Expired Message",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1780, 600],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"jsCode": "// Build curl command for callback action execution\nconst data = $input.item.json;\nconst containerId = data.containerId;\nconst action = data.action;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\nconst queryId = data.queryId;\n\n// stop and restart use ?t=10 for graceful timeout\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\n// Build curl command that returns HTTP status code\nconst cmd = `curl -s -o /dev/null -w \"%{http_code}\" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`;\n\nreturn {\n json: {\n cmd,\n containerId,\n action,\n chatId,\n messageId,\n queryId\n }\n};"
},
"id": "code-build-callback-cmd",
"name": "Build Callback Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 700]
},
{
"parameters": {
"command": "={{ $json.cmd }}",
"options": {}
},
"id": "exec-callback-action",
"name": "Execute Callback Action",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [1560, 700]
},
{
"parameters": {
"jsCode": "// Parse callback action result and get container name\nconst stdout = $input.item.json.stdout;\nconst stderr = $input.item.json.stderr;\nconst cmdData = $('Build Callback Action').item.json;\nconst containerId = cmdData.containerId;\nconst action = cmdData.action;\nconst chatId = cmdData.chatId;\nconst messageId = cmdData.messageId;\nconst queryId = cmdData.queryId;\n\n// Check for curl-level errors first\nif (stderr && stderr.trim()) {\n return {\n json: {\n success: false,\n chatId,\n messageId,\n queryId,\n text: `Failed to ${action}: ${stderr.trim()}`,\n answerText: 'Action failed'\n }\n };\n}\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success)\nif (statusCode === 204 || statusCode === 304) {\n const verb = action === 'start' ? 'started' :\n action === 'stop' ? 'stopped' : 'restarted';\n return {\n json: {\n success: true,\n chatId,\n messageId,\n queryId,\n containerId,\n text: `Container ${verb} successfully`,\n answerText: `Container ${verb}`\n }\n };\n}\n\n// Handle error codes\nlet errorMsg;\nswitch (statusCode) {\n case 404:\n errorMsg = 'Container not found';\n break;\n case 409:\n errorMsg = 'Container is in a conflicting state';\n break;\n case 500:\n errorMsg = 'Docker server error';\n break;\n default:\n errorMsg = `HTTP ${statusCode}`;\n}\n\nreturn {\n json: {\n success: false,\n chatId,\n messageId,\n queryId,\n text: `Failed to ${action}: ${errorMsg}`,\n answerText: 'Action failed'\n }\n};"
},
"id": "code-parse-callback-result",
"name": "Parse Callback Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1780, 700]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId, text: $json.answerText }) }}",
"options": {}
},
"id": "http-answer-action",
"name": "Answer Action Query",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [2000, 700],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/deleteMessage",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $('Parse Callback Result').item.json.chatId, message_id: $('Parse Callback Result').item.json.messageId }) }}",
"options": {}
},
"id": "http-delete-suggestion-msg",
"name": "Delete Suggestion Message",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [2220, 700],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
},
{
"parameters": {
"resource": "message",
"operation": "sendMessage",
"chatId": "={{ $('Parse Callback Result').item.json.chatId }}",
"text": "={{ $('Parse Callback Result').item.json.text }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "telegram-send-callback-result",
"name": "Send Callback Result",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [2440, 700],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
"name": "Telegram API"
}
}
}
],
"connections": {
"Telegram Trigger": {
"main": [
[
{
"node": "Route Update Type",
"type": "main",
"index": 0
}
]
]
},
"Route Update Type": {
"main": [
[
{
"node": "IF User Authenticated",
"type": "main",
"index": 0
}
],
[
{
"node": "IF Callback Authenticated",
"type": "main",
"index": 0
}
]
]
},
"IF User Authenticated": {
"main": [
[
{
"node": "Route Message",
"type": "main",
"index": 0
}
],
[]
]
},
"IF Callback Authenticated": {
"main": [
[
{
"node": "Parse Callback Data",
"type": "main",
"index": 0
}
],
[]
]
},
"Parse Callback Data": {
"main": [
[
{
"node": "Route Callback",
"type": "main",
"index": 0
}
]
]
},
"Route Callback": {
"main": [
[
{
"node": "Handle Cancel",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Expired",
"type": "main",
"index": 0
}
],
[],
[
{
"node": "Build Callback Action",
"type": "main",
"index": 0
}
]
]
},
"Handle Cancel": {
"main": [
[
{
"node": "Answer Cancel Query",
"type": "main",
"index": 0
}
]
]
},
"Answer Cancel Query": {
"main": [
[
{
"node": "Delete Cancel Message",
"type": "main",
"index": 0
}
]
]
},
"Handle Expired": {
"main": [
[
{
"node": "Answer Expired Query",
"type": "main",
"index": 0
}
]
]
},
"Answer Expired Query": {
"main": [
[
{
"node": "Delete Expired Message",
"type": "main",
"index": 0
}
]
]
},
"Build Callback Action": {
"main": [
[
{
"node": "Execute Callback Action",
"type": "main",
"index": 0
}
]
]
},
"Execute Callback Action": {
"main": [
[
{
"node": "Parse Callback Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Callback Result": {
"main": [
[
{
"node": "Answer Action Query",
"type": "main",
"index": 0
}
]
]
},
"Answer Action Query": {
"main": [
[
{
"node": "Delete Suggestion Message",
"type": "main",
"index": 0
}
]
]
},
"Delete Suggestion Message": {
"main": [
[
{
"node": "Send Callback Result",
"type": "main",
"index": 0
}
]
]
},
"Route Message": {
"main": [
[
{
"node": "Docker List Containers",
"type": "main",
"index": 0
}
],
[
{
"node": "Parse Action",
"type": "main",
"index": 0
}
],
[],
[
{
"node": "Format Echo",
"type": "main",
"index": 0
}
]
]
},
"Docker List Containers": {
"main": [
[
{
"node": "Parse and Match",
"type": "main",
"index": 0
}
]
]
},
"Parse and Match": {
"main": [
[
{
"node": "Format Response",
"type": "main",
"index": 0
}
]
]
},
"Format Response": {
"main": [
[
{
"node": "Send Docker Response",
"type": "main",
"index": 0
}
]
]
},
"Format Echo": {
"main": [
[
{
"node": "Send Echo",
"type": "main",
"index": 0
}
]
]
},
"Parse Action": {
"main": [
[
{
"node": "Docker List for Action",
"type": "main",
"index": 0
}
]
]
},
"Docker List for Action": {
"main": [
[
{
"node": "Match Container",
"type": "main",
"index": 0
}
]
]
},
"Match Container": {
"main": [
[
{
"node": "Check Match Count",
"type": "main",
"index": 0
}
]
]
},
"Check Match Count": {
"main": [
[
{
"node": "Send Docker Error",
"type": "main",
"index": 0
}
],
[
{
"node": "Find Closest Match",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Action Command",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Batch Keyboard",
"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": "Send No Match",
"type": "main",
"index": 0
}
]
]
},
"Build Suggestion Keyboard": {
"main": [
[
{
"node": "Send Suggestion",
"type": "main",
"index": 0
}
]
]
},
"Build Action Command": {
"main": [
[
{
"node": "Execute Action",
"type": "main",
"index": 0
}
]
]
},
"Execute Action": {
"main": [
[
{
"node": "Parse Action Result",
"type": "main",
"index": 0
}
]
]
},
"Parse Action Result": {
"main": [
[
{
"node": "Send Action Result",
"type": "main",
"index": 0
}
]
]
},
"Build Batch Keyboard": {
"main": [
[
{
"node": "Send Batch Confirmation",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 1,
"active": false
}