{ "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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } }, "webhookId": "86b8d6bb-7c15-4aae-a749-fcaf2dfcb9e0" }, { "parameters": { "rules": { "values": [ { "id": "route-message", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "has-message", "leftValue": "={{ $json.message?.text }}", "rightValue": "", "operator": { "type": "string", "operation": "notEmpty" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "message" }, { "id": "route-callback", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "has-callback", "leftValue": "={{ $json.callback_query?.id }}", "rightValue": "", "operator": { "type": "string", "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": "keyword-menu-start", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "starts-with-start-cmd", "leftValue": "={{ $json.message.text }}", "rightValue": "/start", "operator": { "type": "string", "operation": "startsWith" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "menu" }, { "id": "keyword-status", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-status", "leftValue": "={{ $json.message.text }}", "rightValue": "status", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "status" }, { "id": "keyword-restart", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-restart", "leftValue": "={{ $json.message.text }}", "rightValue": "restart", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "restart" }, { "id": "keyword-start", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-start", "leftValue": "={{ $json.message.text }}", "rightValue": "start", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "start" }, { "id": "keyword-stop", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-stop", "leftValue": "={{ $json.message.text }}", "rightValue": "stop", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "stop" }, { "id": "keyword-update", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-update", "leftValue": "={{ $json.message.text }}", "rightValue": "update", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "update" }, { "id": "keyword-logs", "conditions": { "options": { "caseSensitive": false, "typeValidation": "loose" }, "conditions": [ { "id": "contains-logs", "leftValue": "={{ $json.message.text }}", "rightValue": "logs", "operator": { "type": "string", "operation": "contains" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "logs" } ] }, "options": { "fallbackOutput": "extra" } }, "id": "switch-keyword-router", "name": "Keyword Router", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 900, 200 ] }, { "parameters": { "command": "curl -s --max-time 5 'http://docker-socket-proxy:2375/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": "// Parse action from message text directly\nconst message = $json.message;\nconst text = message.text.toLowerCase().trim();\nconst chatId = message.chat.id;\nconst messageId = message.message_id;\n\n// Determine action from the text\nlet requestedAction = null;\nlet containerQuery = '';\n\n// Parse action and container name from message\nif (text.startsWith('start ')) {\n requestedAction = 'start';\n containerQuery = text.substring(6).trim();\n} else if (text.startsWith('stop ')) {\n requestedAction = 'stop';\n containerQuery = text.substring(5).trim();\n} else if (text.startsWith('restart ')) {\n requestedAction = 'restart';\n containerQuery = text.substring(8).trim();\n} else if (text.includes('start')) {\n requestedAction = 'start';\n containerQuery = text.replace('start', '').trim();\n} else if (text.includes('stop')) {\n requestedAction = 'stop';\n containerQuery = text.replace('stop', '').trim();\n} else if (text.includes('restart')) {\n requestedAction = 'restart';\n containerQuery = text.replace('restart', '').trim();\n}\n\nif (!requestedAction || !containerQuery) {\n return {\n json: {\n error: true,\n errorMessage: 'Please specify action and container name (e.g., \"start plex\")',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n action: requestedAction,\n containerQuery: containerQuery,\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-action-command", "name": "Parse Action Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 900, 400 ] }, { "parameters": { "command": "curl -s --max-time 5 'http://docker-socket-proxy:2375/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 Command\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action Command').item.json;\nconst action = actionData.action || 'restart';\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: \"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 = containerQuery.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: containerQuery,\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: 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, "typeValidation": "loose" }, "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, "typeValidation": "loose" }, "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, "typeValidation": "loose" }, "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, "typeValidation": "loose" }, "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}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/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 (connection issues)\nif (stderr && stderr.trim()) {\n return {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}`\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: `${containerName} ${verb} successfully`\n }\n };\n}\n\n// All error codes get terse message\nreturn {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}`\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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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 '${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{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "=No container found matching '{{ $json.query }}'", "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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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// Validate chatId exists - critical for Telegram API\nif (!chatId) {\n throw new Error('Missing chatId - cannot send batch confirmation. Data: ' + JSON.stringify({ matches: matches?.length, action, query }));\n}\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 ${matches.length} containers matching '${query}':\\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": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chat_id }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML", "reply_markup": "={{ JSON.stringify($json.reply_markup) }}" } }, "id": "telegram-send-batch-confirm", "name": "Send Batch Confirmation", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 2000, 500 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, confirm:{action}:{name}:{timestamp}, cancel:{name}, and legacy JSON format\nconst callback = $json.callback_query;\nconst rawData = callback.data;\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\n\n// Check for new colon-separated format\nif (rawData.startsWith('select:')) {\n // Container selection: select:{containerName}\n const containerName = rawData.substring(7); // Remove 'select:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isSelect: true,\n containerName,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('list:')) {\n // Pagination: list:{page}\n const page = parseInt(rawData.substring(5)) || 0; // Remove 'list:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isList: true,\n page,\n isSelect: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('action:')) {\n // Action execution: action:{action}:{containerName}\n const parts = rawData.substring(7).split(':'); // Remove 'action:'\n const action = parts[0];\n const containerName = parts.slice(1).join(':'); // Handle names with colons\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isAction: true,\n action,\n containerName,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('confirm:')) {\n // Confirmation: confirm:{action}:{containerName}:{timestamp}\n const parts = rawData.substring(8).split(':'); // Remove 'confirm:'\n const action = parts[0]; // stop or update\n const timestamp = parseInt(parts[parts.length - 1]); // Last part is timestamp\n const containerName = parts.slice(1, -1).join(':'); // Everything between action and timestamp\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isConfirm: true,\n confirmAction: action,\n containerName,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n}\n\nif (rawData.startsWith('cancel:')) {\n // Cancel confirmation: cancel:{containerName}\n const containerName = rawData.substring(7); // Remove 'cancel:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isCancelConfirm: true,\n containerName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'noop') {\n // No-op button (like page indicator)\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isNoop: true,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n// Legacy JSON format for backward compatibility\nlet data;\ntry {\n data = JSON.parse(rawData);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\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\n// Detect batch (c is array) vs single suggestion (c is string)\nconst isBatch = Array.isArray(data.c);\nconst containerIds = isBatch ? data.c : (data.c ? [data.c] : []);\n\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n action,\n containerIds,\n containerId: containerIds[0] || null,\n expired: isExpired,\n isBatch,\n isCancel: action === 'cancel',\n isSelect: false,\n isList: false,\n isAction: false\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" }, { "id": "is-batch", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "batch-true", "leftValue": "={{ $json.isBatch }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "batch" }, { "id": "is-select", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "select-true", "leftValue": "={{ $json.isSelect }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "select" }, { "id": "is-list", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "list-true", "leftValue": "={{ $json.isList }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "list" }, { "id": "is-action", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "action-true", "leftValue": "={{ $json.isAction }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "action" }, { "id": "is-noop", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "noop-true", "leftValue": "={{ $json.isNoop }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "noop" }, { "id": "is-confirm", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "confirm-true", "leftValue": "={{ $json.isConfirm }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "confirm" }, { "id": "is-cancel-confirm", "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "cancel-confirm-true", "leftValue": "={{ $json.isCancelConfirm }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "cancelConfirm" } ] }, "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{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/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} container`,\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// All error codes get terse message\nreturn {\n json: {\n success: false,\n chatId,\n messageId,\n queryId,\n text: `Failed to ${action} container`,\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{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "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": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Execute batch action on all containers sequentially\nconst containerIds = $json.containerIds;\nconst action = $json.action;\nconst chatId = $json.chatId;\nconst messageId = $json.messageId;\nconst queryId = $json.queryId;\n\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\nconst results = [];\n\n// Execute each container action sequentially using fetch\nfor (const containerId of containerIds) {\n try {\n // Use n8n's built-in $http or construct command for later execution\n // Since we can't use execSync easily, we'll build commands for chained execution\n results.push({\n containerId,\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/${action}${timeout}'`\n });\n } catch (err) {\n results.push({ containerId, error: err.message });\n }\n}\n\nreturn {\n json: {\n commands: results,\n action,\n queryId,\n chatId,\n messageId,\n totalCount: containerIds.length\n }\n};" }, "id": "code-build-batch-commands", "name": "Build Batch Commands", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1340, 800 ] }, { "parameters": { "jsCode": "// Execute all batch commands and collect results\nconst { commands, action, queryId, chatId, messageId, totalCount } = $json;\nconst results = [];\n\n// Build a single shell command that runs all curl commands sequentially\n// and outputs results in a parseable format\nconst allCommands = commands.map((c, i) => \n `echo \"RESULT_${i}:$(${c.cmd})\"`\n).join(' && ');\n\nreturn {\n json: {\n batchCmd: allCommands,\n containerIds: commands.map(c => c.containerId),\n action,\n queryId,\n chatId,\n messageId,\n totalCount\n }\n};" }, "id": "code-prepare-batch-exec", "name": "Prepare Batch Execution", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1560, 800 ] }, { "parameters": { "command": "={{ $json.batchCmd }}", "options": {} }, "id": "exec-batch-action", "name": "Execute Batch Action", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 1780, 800 ] }, { "parameters": { "jsCode": "// Parse batch execution results\nconst stdout = $input.item.json.stdout || '';\nconst stderr = $input.item.json.stderr || '';\nconst prevData = $('Prepare Batch Execution').item.json;\nconst { containerIds, action, queryId, chatId, messageId, totalCount } = prevData;\n\n// Parse results from output like: RESULT_0:204 RESULT_1:204 RESULT_2:304\nconst results = [];\nfor (let i = 0; i < containerIds.length; i++) {\n const match = stdout.match(new RegExp(`RESULT_${i}:(\\\\d+)`));\n if (match) {\n const statusCode = parseInt(match[1]);\n results.push({\n containerId: containerIds[i],\n success: statusCode === 204 || statusCode === 304,\n statusCode\n });\n } else {\n results.push({\n containerId: containerIds[i],\n success: false,\n error: 'No response'\n });\n }\n}\n\nconst successCount = results.filter(r => r.success).length;\nconst failCount = results.length - successCount;\n\nreturn {\n json: {\n results,\n successCount,\n failCount,\n totalCount: results.length,\n action,\n queryId,\n chatId,\n messageId\n }\n};" }, "id": "code-parse-batch-result", "name": "Parse Batch Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 800 ] }, { "parameters": { "jsCode": "// Format batch result message\nconst { successCount, failCount, totalCount, action } = $json;\nconst verb = action === 'start' ? 'started' :\n action === 'stop' ? 'stopped' : 'restarted';\n\nlet message;\nif (failCount === 0) {\n message = `Successfully ${verb} ${successCount} container${successCount > 1 ? 's' : ''}`;\n} else if (successCount === 0) {\n message = `Failed to ${action} all ${totalCount} containers`;\n} else {\n message = `${verb.charAt(0).toUpperCase() + verb.slice(1)} ${successCount}/${totalCount} containers (${failCount} failed)`;\n}\n\nreturn {\n json: {\n message,\n chatId: $json.chatId,\n queryId: $json.queryId,\n messageId: $json.messageId\n }\n};" }, "id": "code-format-batch-result", "name": "Format Batch Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2220, 800 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-batch-query", "name": "Answer Batch Query", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2440, 800 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/deleteMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $('Format Batch Result').item.json.chatId, message_id: $('Format Batch Result').item.json.messageId }) }}", "options": {} }, "id": "http-delete-batch-confirm-msg", "name": "Delete Batch Confirm Message", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2660, 800 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $('Format Batch Result').item.json.chatId }}", "text": "={{ $('Format Batch Result').item.json.message }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-batch-result", "name": "Send Batch Result", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 2880, 800 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Parse update 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 update pattern: update followed by container name\nconst match = text.match(/^update\\s+(.+)$/i);\n\nif (!match) {\n return {\n json: {\n error: true,\n errorMessage: 'Invalid update format. Use: update ',\n chatId: chatId\n }\n };\n}\n\nreturn {\n json: {\n containerQuery: match[1].trim(),\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-update", "name": "Parse Update Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 900, 1000 ] }, { "parameters": { "command": "curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/json?all=true'", "options": {} }, "id": "exec-docker-list-update", "name": "Docker List for Update", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 1120, 1000 ] }, { "parameters": { "jsCode": "// Get Docker API response and update info\nconst dockerOutput = $input.item.json.stdout;\nconst updateData = $('Parse Update Command').item.json;\nconst containerQuery = updateData.containerQuery;\nconst chatId = updateData.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: \"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 = containerQuery.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: containerQuery,\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: containerQuery,\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": [ 1340, 1000 ] }, { "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": [ 1560, 1000 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-update-error", "name": "Send Update Error", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 1780, 900 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Update requires exact container name - multiple matches not allowed\nconst matches = $json.matches;\nconst query = $json.containerQuery;\nconst chatId = $json.chatId;\n\nconst names = matches.map(m => m.Name).join(', ');\n\nreturn {\n json: {\n chatId: chatId,\n text: `Update requires an exact container name.\\n\\nFound ${matches.length} matches: ${names}`\n }\n};" }, "id": "code-update-multiple-handler", "name": "Handle Update Multiple", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1100 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-update-multiple", "name": "Send Update Multiple", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 2000, 1100 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "=No container found matching '{{ $json.containerQuery }}'", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-update-no-match", "name": "Send Update No Match", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 1780, 1000 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "=Updating {{ $json.matches[0].Name }}...", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-update-started", "name": "Send Update Started", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 1560, 1200 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build inspect command for the matched container\n// Pass through original data from Match Update Container\nconst matchData = $('Check Update Match Count').item.json;\nconst containerId = matchData.matches[0].Id;\nconst containerName = matchData.matches[0].Name;\nconst chatId = matchData.chatId;\n\nreturn {\n json: {\n cmd: `curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/json'`,\n containerId,\n containerName,\n chatId\n }\n};" }, "id": "code-build-inspect-cmd", "name": "Build Inspect Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-inspect-container", "name": "Inspect Container", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2000, 1200 ] }, { "parameters": { "jsCode": "// Parse container inspect output and extract config\nconst stdout = $input.item.json.stdout;\nconst cmdData = $('Build Inspect Command').item.json;\nconst containerId = cmdData.containerId;\nconst containerName = cmdData.containerName;\nconst chatId = cmdData.chatId;\n\nlet inspect;\ntry {\n inspect = JSON.parse(stdout);\n} catch (e) {\n return {\n json: {\n error: true,\n chatId,\n text: `Failed to inspect ${containerName}: ${e.message}`\n }\n };\n}\n\nlet imageName = inspect.Config.Image;\nconst currentImageId = inspect.Image;\n\n// CRITICAL: Ensure image has a tag, otherwise Docker pulls ALL tags!\n// If no tag specified, default to :latest\nif (imageName && !imageName.includes(':') && !imageName.includes('@')) {\n imageName = imageName + ':latest';\n}\n\n// Extract version from labels if available\nconst labels = inspect.Config.Labels || {};\nconst currentVersion = labels['org.opencontainers.image.version']\n || labels['version']\n || currentImageId.substring(7, 19);\n\nreturn {\n json: {\n imageName,\n currentImageId,\n currentVersion,\n containerConfig: inspect.Config,\n hostConfig: inspect.HostConfig,\n networkSettings: inspect.NetworkSettings,\n containerName,\n containerId,\n chatId\n }\n};" }, "id": "code-parse-container-config", "name": "Parse Container Config", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2220, 1200 ] }, { "parameters": { "jsCode": "// Build pull image command\nconst imageName = $json.imageName;\n\n// Pipe through tail to only keep last 10KB - avoids memory issues with large pulls\n// Error/success messages appear at the end of the stream\nreturn {\n json: {\n cmd: `curl -s --max-time 600 -X POST 'http://docker-socket-proxy:2375/v1.47/images/create?fromImage=${encodeURIComponent(imageName)}' | tail -c 10000`,\n imageName,\n currentImageId: $json.currentImageId,\n currentVersion: $json.currentVersion,\n containerConfig: $json.containerConfig,\n hostConfig: $json.hostConfig,\n networkSettings: $json.networkSettings,\n containerName: $json.containerName,\n containerId: $json.containerId,\n chatId: $json.chatId\n }\n};" }, "id": "code-build-pull-cmd", "name": "Build Pull Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2440, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": { "timeout": 660 } }, "id": "exec-pull-image", "name": "Pull Image", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2660, 1200 ] }, { "parameters": { "jsCode": "// Check pull response for errors\nconst stdout = $input.item.json.stdout || '';\nconst pullData = $('Build Pull Command').item.json;\nconst chatId = pullData.chatId;\nconst containerName = pullData.containerName;\n\n// Docker pull streams JSON objects, check for error messages\n// Rate limit error: {\"message\":\"toomanyrequests: ...\"}\n// Other errors: {\"message\":\"...\"}\nif (stdout.includes('\"message\"') && (stdout.includes('toomanyrequests') || stdout.includes('error') || stdout.includes('denied'))) {\n // Extract error message\n let errorMsg = 'Pull failed';\n try {\n const match = stdout.match(/\"message\"\\s*:\\s*\"([^\"]+)\"/); if (match) errorMsg = match[1];\n } catch (e) {}\n \n return {\n json: {\n pullError: true,\n chatId,\n text: `Failed to update ${containerName}: ${errorMsg.substring(0, 100)}`\n }\n };\n}\n\n// Success - pass through data for next node\nreturn {\n json: {\n pullError: false,\n imageName: pullData.imageName,\n currentImageId: pullData.currentImageId,\n currentVersion: pullData.currentVersion,\n containerConfig: pullData.containerConfig,\n hostConfig: pullData.hostConfig,\n networkSettings: pullData.networkSettings,\n containerName: pullData.containerName,\n containerId: pullData.containerId,\n chatId: pullData.chatId\n }\n};" }, "id": "code-check-pull-response", "name": "Check Pull Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2770, 1200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "pull-error-check", "leftValue": "={{ $json.pullError }}", "rightValue": false, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-pull-success", "name": "Check Pull Success", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 2880, 1200 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-pull-error", "name": "Send Pull Error", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 3100, 1350 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build inspect image command to get the new image ID\nconst imageName = $json.imageName;\n\nreturn {\n json: {\n cmd: `curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/images/${encodeURIComponent(imageName)}/json'`,\n imageName,\n currentImageId: $json.currentImageId,\n currentVersion: $json.currentVersion,\n containerConfig: $json.containerConfig,\n hostConfig: $json.hostConfig,\n networkSettings: $json.networkSettings,\n containerName: $json.containerName,\n containerId: $json.containerId,\n chatId: $json.chatId\n }\n};" }, "id": "code-build-inspect-image-cmd", "name": "Build Image Inspect", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2880, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-inspect-image", "name": "Inspect New Image (Text)", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 3100, 1200 ] }, { "parameters": { "jsCode": "// Compare old and new image IDs to detect if update is needed\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Image Inspect').item.json;\nconst currentImageId = prevData.currentImageId;\nconst chatId = prevData.chatId;\n\nlet newImage;\ntry {\n newImage = JSON.parse(stdout);\n} catch (e) {\n return {\n json: {\n error: true,\n chatId,\n text: `Failed to inspect new image: ${e.message}`\n }\n };\n}\n\nconst newImageId = newImage.Id;\n\nif (currentImageId === newImageId) {\n // No update needed - notify user\n return { json: { needsUpdate: false, chatId, containerName: prevData.containerName } };\n}\n\n// Extract new version from labels\nconst labels = newImage.Config?.Labels || {};\nconst newVersion = labels['org.opencontainers.image.version']\n || labels['version']\n || newImageId.substring(7, 19);\n\nreturn {\n json: {\n needsUpdate: true,\n currentImageId,\n newImageId,\n currentVersion: prevData.currentVersion,\n newVersion,\n containerConfig: prevData.containerConfig,\n hostConfig: prevData.hostConfig,\n networkSettings: prevData.networkSettings,\n containerName: prevData.containerName,\n containerId: prevData.containerId,\n chatId\n }\n};" }, "id": "code-compare-digests", "name": "Compare Digests", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3320, 1200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "needs-update", "leftValue": "={{ $json.needsUpdate }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-needs-update", "name": "Check If Update Needed", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 3540, 1200 ] }, { "parameters": { "jsCode": "// Format 'already up to date' message\nconst containerName = $json.containerName;\nconst chatId = $json.chatId;\n\nreturn {\n json: {\n chatId,\n text: `${containerName} is already up to date`\n }\n};" }, "id": "code-format-no-update", "name": "Format No Update", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3540, 1400 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-no-update", "name": "Send No Update", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 3760, 1400 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build stop container command\nconst containerId = $json.containerId;\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/stop?t=10'`,\n containerId,\n containerName: $json.containerName,\n currentVersion: $json.currentVersion,\n newVersion: $json.newVersion,\n currentImageId: $json.currentImageId,\n containerConfig: $json.containerConfig,\n hostConfig: $json.hostConfig,\n networkSettings: $json.networkSettings,\n chatId: $json.chatId\n }\n};" }, "id": "code-build-stop-cmd", "name": "Build Stop Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3760, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-stop-container", "name": "Stop Container", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 3980, 1200 ] }, { "parameters": { "jsCode": "// Verify container stopped and build remove command\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Stop Command').item.json;\nconst containerId = prevData.containerId;\nconst chatId = prevData.chatId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: stopped, 304: already stopped - both OK\nif (statusCode !== 204 && statusCode !== 304) {\n return {\n json: {\n error: true,\n chatId,\n text: `Failed to stop container: HTTP ${statusCode}`\n }\n };\n}\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X DELETE 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}'`,\n containerId,\n containerName: prevData.containerName,\n currentVersion: prevData.currentVersion,\n newVersion: prevData.newVersion,\n currentImageId: prevData.currentImageId,\n containerConfig: prevData.containerConfig,\n hostConfig: prevData.hostConfig,\n networkSettings: prevData.networkSettings,\n chatId\n }\n};" }, "id": "code-verify-stop-build-remove", "name": "Verify Stop Build Remove", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4200, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-remove-container", "name": "Remove Container", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 4420, 1200 ] }, { "parameters": { "jsCode": "// Build container create request body from saved config\nconst prevData = $('Verify Stop Build Remove').item.json;\nconst config = prevData.containerConfig;\nconst hostConfig = prevData.hostConfig;\nconst networkSettings = prevData.networkSettings;\nconst containerName = prevData.containerName;\nconst chatId = prevData.chatId;\n\n// Build NetworkingConfig from NetworkSettings\nconst networks = {};\nfor (const [name, netConfig] of Object.entries(networkSettings.Networks || {})) {\n networks[name] = {\n IPAMConfig: netConfig.IPAMConfig,\n Links: netConfig.Links,\n Aliases: netConfig.Aliases\n };\n}\n\nconst createBody = {\n ...config,\n HostConfig: hostConfig,\n NetworkingConfig: {\n EndpointsConfig: networks\n }\n};\n\n// Remove fields that shouldn't be in create request\ndelete createBody.Hostname;\ndelete createBody.Domainname;\n\nreturn {\n json: {\n createBody: JSON.stringify(createBody),\n containerName,\n currentVersion: prevData.currentVersion,\n newVersion: prevData.newVersion,\n currentImageId: prevData.currentImageId,\n chatId\n }\n};" }, "id": "code-build-create-body", "name": "Build Create Body", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4640, 1200 ] }, { "parameters": { "jsCode": "// Build the create container command using a temp file approach\nconst createBody = $json.createBody;\nconst containerName = $json.containerName;\nconst chatId = $json.chatId;\n\n// Write body to command using here-doc to avoid shell escaping issues\nconst cmd = `curl -s -X POST --max-time 5 -H \"Content-Type: application/json\" -d '${createBody.replace(/'/g, \"'\\\\''\")}' 'http://docker-socket-proxy:2375/v1.47/containers/create?name=${encodeURIComponent(containerName)}'`;\n\nreturn {\n json: {\n cmd,\n containerName,\n currentVersion: $json.currentVersion,\n newVersion: $json.newVersion,\n currentImageId: $json.currentImageId,\n chatId\n }\n};" }, "id": "code-build-create-cmd", "name": "Build Create Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4860, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-create-container", "name": "Create Container", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 5080, 1200 ] }, { "parameters": { "jsCode": "// Parse create response and extract new container ID\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Create Command').item.json;\nconst chatId = prevData.chatId;\n\nlet response;\ntry {\n response = JSON.parse(stdout);\n} catch (e) {\n return {\n json: {\n error: true,\n chatId,\n text: `Create failed: ${stdout}`\n }\n };\n}\n\nif (response.message) {\n // Error response from Docker\n return {\n json: {\n error: true,\n chatId,\n text: `Create failed: ${response.message}`\n }\n };\n}\n\nreturn {\n json: {\n newContainerId: response.Id,\n containerName: prevData.containerName,\n currentVersion: prevData.currentVersion,\n newVersion: prevData.newVersion,\n currentImageId: prevData.currentImageId,\n chatId\n }\n};" }, "id": "code-parse-create-response", "name": "Parse Create Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5300, 1200 ] }, { "parameters": { "jsCode": "// Build start command for new container\nconst newContainerId = $json.newContainerId;\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${newContainerId}/start'`,\n newContainerId,\n containerName: $json.containerName,\n currentVersion: $json.currentVersion,\n newVersion: $json.newVersion,\n currentImageId: $json.currentImageId,\n chatId: $json.chatId\n }\n};" }, "id": "code-build-start-cmd", "name": "Build Start Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5520, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-start-new-container", "name": "Start New Container", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 5740, 1200 ] }, { "parameters": { "jsCode": "// Parse start result and format success message\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Start Command').item.json;\nconst containerName = prevData.containerName;\nconst currentVersion = prevData.currentVersion;\nconst newVersion = prevData.newVersion;\nconst currentImageId = prevData.currentImageId;\nconst chatId = prevData.chatId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: started, 304: already running\nif (statusCode !== 204 && statusCode !== 304) {\n return {\n json: {\n chatId,\n text: `Failed to update ${containerName}`,\n currentImageId: null\n }\n };\n}\n\nconst message = `${containerName} updated: ${currentVersion} \\u2192 ${newVersion}`;\n\nreturn {\n json: {\n chatId,\n text: message,\n currentImageId\n }\n};" }, "id": "code-format-update-result", "name": "Format Update Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5960, 1200 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-update-result", "name": "Send Update Result", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 6180, 1200 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build remove old image command (fire and forget)\n// Reference Format Update Result since Telegram node doesn't pass through our data\nconst currentImageId = $('Format Update Result').item.json.currentImageId;\n\n// Skip if no image ID (error case) - use no-op command\nif (!currentImageId) {\n return { json: { cmd: 'true', skip: true } };\n}\n\n// Remove the old image - ignore errors (image might be used by another container)\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X DELETE 'http://docker-socket-proxy:2375/v1.47/images/${currentImageId}?force=false'`,\n currentImageId\n }\n};" }, "id": "code-build-remove-image", "name": "Build Remove Image Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 6400, 1200 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-remove-old-image", "name": "Remove Old Image", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 6620, 1200 ] }, { "parameters": { "jsCode": "// Parse logs command from message\nconst message = $json.message;\nconst text = message.text.toLowerCase().trim();\nconst chatId = message.chat.id;\nconst messageId = message.message_id;\n\n// Extract container name and optional line count\n// Formats: \"logs plex\", \"logs plex 100\", \"show logs plex\"\nlet containerQuery = '';\nlet lineCount = 50; // default\n\n// Remove \"show\" prefix if present\nlet cleanText = text.replace(/^show\\s+/, '');\n// Remove \"logs\" keyword\ncleanText = cleanText.replace(/^logs\\s*/, '');\n\n// Check for line count at the end\nconst parts = cleanText.trim().split(/\\s+/);\nif (parts.length > 1) {\n const lastPart = parts[parts.length - 1];\n if (/^\\d+$/.test(lastPart)) {\n lineCount = Math.min(Math.max(parseInt(lastPart), 1), 1000);\n parts.pop();\n }\n}\ncontainerQuery = parts.join(' ').trim();\n\nif (!containerQuery) {\n return {\n json: {\n error: true,\n chatId: chatId,\n text: 'Please specify a container name (e.g., \"logs plex\" or \"logs plex 100\")'\n }\n };\n}\n\nreturn {\n json: {\n containerQuery: containerQuery,\n lines: lineCount,\n chatId: chatId,\n messageId: messageId\n }\n};" }, "id": "code-parse-logs", "name": "Parse Logs Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 900, 600 ] }, { "parameters": { "command": "curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/json?all=true'", "options": {} }, "id": "exec-docker-list-logs", "name": "Docker List for Logs", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 1120, 600 ] }, { "parameters": { "jsCode": "// Get Docker API response and logs info from Parse Logs\nconst dockerOutput = $input.item.json.stdout;\nconst logsData = $('Parse Logs Command').item.json;\nconst containerQuery = logsData.containerQuery;\nconst lines = logsData.lines;\nconst chatId = logsData.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: \"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 = containerQuery.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: containerQuery,\n lines: lines,\n chatId: chatId\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 containerQuery: containerQuery,\n lines: lines,\n chatId: chatId\n }\n}];" }, "id": "code-match-logs-container", "name": "Match Logs Container", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1340, 600 ] }, { "parameters": { "rules": { "values": [ { "id": "logs-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": false }, { "id": "logs-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": false }, { "id": "logs-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": false }, { "id": "logs-multiple-matches", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "count-many", "leftValue": "={{ $json.matchCount }}", "rightValue": 1, "operator": { "type": "number", "operation": "gt" } } ], "combinator": "and" }, "renameOutput": false } ] }, "options": {} }, "id": "switch-logs-match-count", "name": "Check Logs Match Count", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 1560, 600 ] }, { "parameters": { "jsCode": "// Build Docker logs API curl command\nconst matchData = $input.item.json;\nconst container = matchData.matches[0]; // Single match verified by switch\nconst lines = matchData.lines;\n\nconst cmd = `curl -s --max-time 5 \"http://docker-socket-proxy:2375/v1.47/containers/${container.Id}/logs?stdout=1&stderr=1&tail=${lines}×tamps=1\"`;\n\nreturn {\n json: {\n command: cmd,\n containerName: container.Name,\n containerId: container.Id,\n lines: lines,\n chatId: matchData.chatId\n }\n};" }, "id": "code-build-logs-cmd", "name": "Build Logs Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 500 ] }, { "parameters": { "command": "={{ $json.command }}", "options": {} }, "id": "exec-logs", "name": "Execute Logs", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2000, 500 ] }, { "parameters": { "jsCode": "// Format Docker logs for Telegram\nconst rawOutput = $input.item.json.stdout || '';\nconst containerName = $('Build Logs Command').item.json.containerName;\nconst requestedLines = $('Build Logs Command').item.json.lines;\nconst chatId = $('Build Logs Command').item.json.chatId;\n\n// HTML escape function for log content\nfunction escapeHtml(text) {\n return text.replace(/&/g, '&').replace(//g, '>');\n}\n\n// Handle empty logs\nif (!rawOutput || rawOutput.trim() === '') {\n return {\n json: {\n chatId: chatId,\n text: `No logs available for ${containerName}`\n }\n };\n}\n\n// Docker API with timestamps returns text lines when using tail parameter\n// But may have 8-byte binary headers we need to strip\nconst lines = rawOutput.split('\\n')\n .filter(line => line.length > 0)\n .map(line => {\n // Check if line starts with binary header (non-printable chars in first 8 bytes)\n if (line.length > 8 && line.charCodeAt(0) <= 2) {\n return line.substring(8);\n }\n return line;\n })\n .join('\\n');\n\n// Truncate for Telegram (4096 char limit, leave room for header)\nconst maxLen = 3800;\nconst truncated = lines.length > maxLen\n ? lines.substring(0, maxLen) + '\\n... (truncated)'\n : lines;\n\n// Escape HTML entities in log content to prevent parse errors\nconst escaped = escapeHtml(truncated);\n\nconst lineCount = lines.split('\\n').length;\nconst header = `Logs for ${containerName} (last ${lineCount} lines):\\n\\n`;\n\nreturn {\n json: {\n chatId: chatId,\n text: header + '
' + escaped + '
'\n }\n};" }, "id": "code-format-logs", "name": "Format Logs", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2220, 500 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-logs", "name": "Send Logs Response", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 2440, 500 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId }}", "text": "={{ $json.text || 'Error retrieving logs' }}", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-send-logs-error", "name": "Send Logs Error", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 1780, 700 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "const data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n text: `No container found matching \"${data.containerQuery}\".\\n\\nTry \"status\" to see all containers.`\n }\n};" }, "id": "code-format-logs-no-match", "name": "Format Logs No Match", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 650 ] }, { "parameters": { "jsCode": "const data = $input.item.json;\nconst names = data.matches.map(m => m.Name).join('\\n- ');\nreturn {\n json: {\n chatId: data.chatId,\n text: `Found ${data.matches.length} containers matching \"${data.containerQuery}\":\\n\\n- ${names}\\n\\nPlease be more specific.`\n }\n};" }, "id": "code-format-logs-multiple", "name": "Format Logs Multiple", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 600 ] }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.message.chat.id }}", "text": "Commands:\n\n• status\n• start [name]\n• stop [name]\n• restart [name]\n• update [name]\n• logs [name]", "additionalFields": { "parse_mode": "HTML" } }, "id": "telegram-show-menu", "name": "Show Menu", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 1120, 300 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build Container List Keyboard for /status command\nconst dockerOutput = $input.item.json.stdout;\nconst message = $('Keyword Router').item.json.message;\nconst chatId = message.chat.id;\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 chatId: chatId,\n error: true,\n text: \"Cannot connect to Docker\",\n isSingleContainer: false\n }\n }];\n}\n\n// Function to normalize container names (strip prefixes)\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Check if user specified a container name (e.g., \"/status plex\")\nconst text = (message.text || '').toLowerCase().trim();\nlet requestedName = null;\nconst parts = text.split(/\\s+/);\nif (parts.length > 1) {\n requestedName = parts.filter(p => p !== 'status' && p !== '/status').join(' ');\n}\n\n// If specific container requested, route to submenu\nif (requestedName) {\n const matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(requestedName);\n });\n \n if (matches.length === 1) {\n const container = matches[0];\n return [{\n json: {\n isSingleContainer: true,\n chatId: chatId,\n containerName: normalizeName(container.Names[0]),\n containerId: container.Id,\n containerState: container.State,\n containerStatus: container.Status,\n containerImage: container.Image\n }\n }];\n } else if (matches.length === 0) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: 'No container found matching \"' + requestedName + '\"',\n isSingleContainer: false\n }\n }];\n }\n // Multiple matches - show them all in keyboard below\n}\n\n// Build paginated container list keyboard\nconst page = 0; // Initial page\nconst containersPerPage = 6;\n\n// Group by state (running first)\nconst running = containers.filter(c => c.State === 'running');\nconst stopped = containers.filter(c => c.State !== 'running');\nconst sortedContainers = [...running, ...stopped];\n\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst pageContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard rows\nconst keyboard = [];\n\npageContainers.forEach(container => {\n const name = normalizeName(container.Names[0]);\n const stateIcon = container.State === 'running' ? '\\u{1F7E2}' : '\\u26AA'; // Green circle or white circle\n const stateText = container.State === 'running' ? 'Running' : 'Stopped';\n keyboard.push([{\n text: `${stateIcon} ${name} - ${stateText}`,\n callback_data: `select:${name}`\n }]);\n});\n\n// Add navigation row if needed\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `list:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `list:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Build header text\nconst runningCount = running.length;\nconst totalCount = sortedContainers.length;\nlet headerText = `\\u{1F5C2} Containers (${runningCount}/${totalCount} running)`;\nif (totalPages > 1) {\n headerText += `\\n\\nPage ${page + 1} of ${totalPages}`;\n}\nheaderText += '\\n\\nTap a container to manage it:';\n\nreturn [{\n json: {\n chatId: chatId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n isSingleContainer: false\n }\n}];" }, "id": "code-build-container-list-keyboard", "name": "Build Container List Keyboard", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1340, 0 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-container-list", "name": "Send Container List", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1560, 0 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "is-single-container", "leftValue": "={{ $json.isSingleContainer }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-single-container", "name": "Check Single Container", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1560, -100 ] }, { "parameters": { "jsCode": "// Build Container Submenu for direct access (/status plex) or callback selection\nconst data = $input.item.json;\nconst chatId = data.chatId;\nconst containerName = data.containerName;\nconst state = data.containerState;\nconst status = data.containerStatus;\nconst image = data.containerImage;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n]);\n\n// Build status text\nconst stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `State: ${state}\\n`;\ntext += `Status: ${status}\\n`;\ntext += `Image: ${image}`;\n\nreturn [{\n json: {\n chatId: chatId,\n text: text,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];" }, "id": "code-build-container-submenu-direct", "name": "Build Container Submenu Direct", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, -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.chatId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-container-submenu-direct", "name": "Send Container Submenu Direct", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, -100 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-select-callback", "name": "Answer Select Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 900 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Prepare container fetch for submenu\nconst data = $(\"Parse Callback Data\").item.json;\nreturn {\n json: {\n queryId: data.queryId,\n chatId: data.chatId,\n messageId: data.messageId,\n containerName: data.containerName\n }\n};" }, "id": "code-prepare-container-fetch", "name": "Prepare Container Fetch", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1560, 900 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-single-container", "name": "Get Single Container", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1780, 900 ] }, { "parameters": { "jsCode": "// Build Container Submenu for callback selection\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Container Fetch\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst searchName = prevData.containerName.toLowerCase();\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, \"\")\n .replace(/^(linuxserver[-_]|binhex[-_])/i, \"\")\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === searchName);\n\nif (!container) {\n return [{\n json: {\n chatId,\n messageId,\n text: `Container \\\"${searchName}\\\" not found`,\n reply_markup: { inline_keyboard: [[{ text: \"\\u25C0\\uFE0F Back to List\", callback_data: \"list:0\" }]] }\n }\n }];\n}\n\nconst containerName = normalizeName(container.Names[0]);\nconst state = container.State;\nconst status = container.Status;\nconst image = container.Image;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === \"running\") {\n keyboard.push([\n { text: \"\\u23F9\\uFE0F Stop\", callback_data: `action:stop:${containerName}` },\n { text: \"\\u{1F504} Restart\", callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: \"\\u25B6\\uFE0F Start\", callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: \"\\u{1F4CB} Logs\", callback_data: `action:logs:${containerName}` },\n { text: \"\\u2B06\\uFE0F Update\", callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: \"\\u25C0\\uFE0F Back to List\", callback_data: \"list:0\" }\n]);\n\n// Build status text\nconst stateIcon = state === \"running\" ? \"\\u{1F7E2}\" : \"\\u26AA\";\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `State: ${state}\\n`;\ntext += `Status: ${status}\\n`;\ntext += `Image: ${image}`;\n\nreturn [{\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];" }, "id": "code-build-container-submenu", "name": "Build Container Submenu", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 900 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-container-submenu", "name": "Send Container Submenu", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2220, 900 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-noop-callback", "name": "Answer Noop Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 1100 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-list-callback", "name": "Answer List Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 1000 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Prepare list pagination request\nconst data = $(\"Parse Callback Data\").item.json;\nreturn {\n json: {\n queryId: data.queryId,\n chatId: data.chatId,\n messageId: data.messageId,\n page: data.page || 0\n }\n};" }, "id": "code-prepare-list-fetch", "name": "Prepare List Fetch", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1560, 1000 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-containers-for-list", "name": "Get Containers For List", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1780, 1000 ] }, { "parameters": { "jsCode": "// Build Paginated Container List Keyboard for callback pagination\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare List Fetch\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst page = prevData.page || 0;\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 containersPerPage = 6;\n\n// Group by state (running first)\nconst running = containers.filter(c => c.State === \"running\");\nconst stopped = containers.filter(c => c.State !== \"running\");\nconst sortedContainers = [...running, ...stopped];\n\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst pageContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard rows\nconst keyboard = [];\n\npageContainers.forEach(container => {\n const name = normalizeName(container.Names[0]);\n const stateIcon = container.State === \"running\" ? \"\\u{1F7E2}\" : \"\\u26AA\";\n const stateText = container.State === \"running\" ? \"Running\" : \"Stopped\";\n keyboard.push([{\n text: `${stateIcon} ${name} - ${stateText}`,\n callback_data: `select:${name}`\n }]);\n});\n\n// Add navigation row if needed\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: \"\\u25C0\\uFE0F Previous\", callback_data: `list:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: \"noop\" });\n if (page < totalPages - 1) {\n navRow.push({ text: \"Next \\u25B6\\uFE0F\", callback_data: `list:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Build header text\nconst runningCount = running.length;\nconst totalCount = sortedContainers.length;\nlet headerText = `\\u{1F5C2} Containers (${runningCount}/${totalCount} running)`;\nif (totalPages > 1) {\n headerText += `\\n\\nPage ${page + 1} of ${totalPages}`;\n}\nheaderText += \"\\n\\nTap a container to manage it:\";\n\nreturn [{\n json: {\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];" }, "id": "code-build-paginated-list", "name": "Build Paginated List", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 1000 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-edit-container-list", "name": "Edit Container List", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2220, 1000 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-action-callback", "name": "Answer Action Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 1200 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "rules": { "values": [ { "id": "action-start", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-start", "leftValue": "={{ $('Parse Callback Data').item.json.action }}", "rightValue": "start", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "start" }, { "id": "action-restart", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-restart", "leftValue": "={{ $('Parse Callback Data').item.json.action }}", "rightValue": "restart", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "restart" }, { "id": "action-stop", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-stop", "leftValue": "={{ $('Parse Callback Data').item.json.action }}", "rightValue": "stop", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "stop" }, { "id": "action-update", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-update", "leftValue": "={{ $('Parse Callback Data').item.json.action }}", "rightValue": "update", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "update" }, { "id": "action-logs", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-logs", "leftValue": "={{ $('Parse Callback Data').item.json.action }}", "rightValue": "logs", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "logs" } ] }, "options": { "fallbackOutput": "none" } }, "id": "switch-route-action-type", "name": "Route Action Type", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 1560, 1200 ] }, { "parameters": { "jsCode": "// Build start/restart action command for inline keyboard\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst action = data.action; // 'start' or 'restart'\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// For restart, use ?t=10 for graceful timeout\nconst timeout = action === 'restart' ? '?t=10' : '';\n\nreturn {\n json: {\n containerName,\n action,\n chatId,\n messageId\n }\n};" }, "id": "code-prepare-immediate-action", "name": "Prepare Immediate Action", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1100 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-container-for-action", "name": "Get Container For Action", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, 1100 ] }, { "parameters": { "jsCode": "// Find container and execute action\nconst containers = $input.all().map(item => item.json);\nconst prevData = $('Prepare Immediate Action').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst action = prevData.action;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\nconst timeout = action === 'restart' ? '?t=10' : '';\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 15 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/${action}${timeout}'`,\n containerId,\n containerName: normalizeName(container.Names[0]),\n action,\n chatId,\n messageId\n }\n};" }, "id": "code-build-immediate-action-cmd", "name": "Build Immediate Action Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2220, 1100 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-immediate-action", "name": "Execute Immediate Action", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2440, 1100 ] }, { "parameters": { "jsCode": "// Build action completion message (success shows back only, error shows retry + back)\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Immediate Action Command').item.json;\nconst containerName = prevData.containerName;\nconst action = prevData.action;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success)\nconst success = statusCode === 204 || statusCode === 304;\n\n// Build message based on action type and result\nconst successMessages = {\n start: `\\u25B6\\uFE0F ${containerName} started`,\n restart: `\\u{1F504} ${containerName} restarted`\n};\n\nlet text;\nlet keyboard;\n\nif (success) {\n text = successMessages[action] || `Action completed on ${containerName}`;\n // Success: only back button\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n} else {\n text = `\\u274C Failed to ${action} ${containerName}`;\n // Error: retry and back buttons\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u{1F504} Try Again', callback_data: `action:${action}:${containerName}` }],\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n}\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};" }, "id": "code-format-immediate-result", "name": "Format Immediate Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2660, 1100 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-immediate-result", "name": "Send Immediate Result", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2880, 1100 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Prepare logs action\nconst data = $('Parse Callback Data').item.json;\nreturn {\n json: {\n containerName: data.containerName,\n chatId: data.chatId,\n messageId: data.messageId,\n lines: 30\n }\n};" }, "id": "code-prepare-logs-action", "name": "Prepare Logs Action", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1300 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-container-for-logs", "name": "Get Container For Logs", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, 1300 ] }, { "parameters": { "jsCode": "// Find container and build logs command\nconst containers = $input.all().map(item => item.json);\nconst prevData = $('Prepare Logs Action').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst lines = prevData.lines;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\n\nreturn {\n json: {\n cmd: `curl -s --max-time 10 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/logs?stdout=true&stderr=true&tail=${lines}'`,\n containerId,\n containerName: normalizeName(container.Names[0]),\n containerState: container.State,\n lines,\n chatId,\n messageId\n }\n};" }, "id": "code-build-logs-action-cmd", "name": "Build Logs Action Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2220, 1300 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-logs-action", "name": "Execute Logs Action", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2440, 1300 ] }, { "parameters": { "jsCode": "// Parse and format logs output\nconst stdout = $input.item.json.stdout || '';\nconst prevData = $('Build Logs Action Command').item.json;\nconst containerName = prevData.containerName;\nconst containerState = prevData.containerState;\nconst lines = prevData.lines;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Docker logs API returns binary stream with 8-byte header per line\n// Strip non-printable characters and clean up\nlet logs = stdout\n .split('\\n')\n .map(line => {\n // Remove Docker stream header (first 8 bytes of each frame)\n // The header contains stream type and length info\n if (line.length > 8) {\n const cleaned = line.substring(8).replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '');\n return cleaned;\n }\n return line.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '');\n })\n .filter(line => line.trim().length > 0)\n .slice(-lines)\n .join('\\n');\n\nif (!logs || logs.trim().length === 0) {\n logs = '(no recent logs)';\n}\n\n// Truncate if too long for Telegram (max ~4096 chars)\nif (logs.length > 3800) {\n logs = '...' + logs.slice(-3800);\n}\n\n// Build keyboard for navigation\nconst keyboard = [];\nif (containerState === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: '\\u{1F4CB} Refresh Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n]);\n\nconst stateIcon = containerState === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nconst timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });\nlet text = `${stateIcon} ${containerName} - Logs (${timestamp})\\n\\n
${logs}
`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n};" }, "id": "code-format-logs-action-result", "name": "Format Logs Action Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2660, 1300 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-logs-result", "name": "Send Logs Result", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2880, 1300 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build Stop Confirmation dialog with timestamp for 30-second timeout\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\nconst timestamp = Math.floor(Date.now() / 1000); // Unix timestamp\n\nconst keyboard = {\n inline_keyboard: [\n [\n { text: '\\u2705 Yes, Stop', callback_data: `confirm:stop:${containerName}:${timestamp}` },\n { text: '\\u274C Cancel', callback_data: `cancel:${containerName}` }\n ]\n ]\n};\n\nconst text = `\\u26A0\\uFE0F Stop ${containerName}?\\n\\nThis will stop the container immediately.\\n\\nConfirmation expires in 30 seconds.`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};" }, "id": "code-build-stop-confirmation", "name": "Build Stop Confirmation", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1400 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-stop-confirmation", "name": "Send Stop Confirmation", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, 1400 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build Update Confirmation dialog with timestamp for 30-second timeout\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\nconst timestamp = Math.floor(Date.now() / 1000); // Unix timestamp\n\nconst keyboard = {\n inline_keyboard: [\n [\n { text: '\\u2705 Yes, Update', callback_data: `confirm:update:${containerName}:${timestamp}` },\n { text: '\\u274C Cancel', callback_data: `cancel:${containerName}` }\n ]\n ]\n};\n\nconst text = `\\u26A0\\uFE0F Update ${containerName}?\\n\\nThis will pull the latest image and recreate the container.\\n\\nConfirmation expires in 30 seconds.`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};" }, "id": "code-build-update-confirmation", "name": "Build Update Confirmation", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1500 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-update-confirmation", "name": "Send Update Confirmation", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, 1500 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-confirm-callback", "name": "Answer Confirm Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 1600 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "check-expired", "leftValue": "={{ $('Parse Callback Data').item.json.expired }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-confirm-expired", "name": "Check Confirm Expired", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1560, 1600 ] }, { "parameters": { "jsCode": "// Confirmation expired - return to submenu\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// Build keyboard for expired message\nconst keyboard = {\n inline_keyboard: [\n [\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n ]\n ]\n};\n\nreturn {\n json: {\n chatId,\n messageId,\n text: `\\u23F0 Confirmation for ${containerName} has expired.\\n\\nPlease try again.`,\n reply_markup: keyboard\n }\n};" }, "id": "code-handle-confirm-expired", "name": "Handle Confirm Expired", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1780, 1700 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-expired-confirm", "name": "Send Expired Confirm", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2000, 1700 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "rules": { "values": [ { "id": "confirm-stop", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-stop-confirm", "leftValue": "={{ $('Parse Callback Data').item.json.confirmAction }}", "rightValue": "stop", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "stop" }, { "id": "confirm-update", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-update-confirm", "leftValue": "={{ $('Parse Callback Data').item.json.confirmAction }}", "rightValue": "update", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "update" } ] }, "options": { "fallbackOutput": "none" } }, "id": "switch-route-confirm-action", "name": "Route Confirm Action", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 1780, 1600 ] }, { "parameters": { "jsCode": "// Prepare stop action from confirmation\nconst data = $('Parse Callback Data').item.json;\nreturn {\n json: {\n containerName: data.containerName,\n chatId: data.chatId,\n messageId: data.messageId\n }\n};" }, "id": "code-prepare-confirmed-stop", "name": "Prepare Confirmed Stop", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 1550 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-container-for-stop", "name": "Get Container For Stop", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2220, 1550 ] }, { "parameters": { "jsCode": "// Find container and build stop command\nconst containers = $input.all().map(item => item.json);\nconst prevData = $('Prepare Confirmed Stop').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 15 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/stop?t=10'`,\n containerId,\n containerName: normalizeName(container.Names[0]),\n chatId,\n messageId\n }\n};" }, "id": "code-build-confirmed-stop-cmd", "name": "Build Confirmed Stop Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2440, 1550 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-confirmed-stop", "name": "Execute Confirmed Stop", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2660, 1550 ] }, { "parameters": { "jsCode": "// Build stop completion message (success shows back only, error shows retry + back)\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Confirmed Stop Command').item.json;\nconst containerName = prevData.containerName;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already stopped\nconst success = statusCode === 204 || statusCode === 304;\n\nlet text;\nlet keyboard;\n\nif (success) {\n text = `\\u23F9\\uFE0F ${containerName} stopped`;\n // Success: only back button\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n} else {\n text = `\\u274C Failed to stop ${containerName}`;\n // Error: retry and back buttons\n const timestamp = Math.floor(Date.now() / 1000);\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u{1F504} Try Again', callback_data: `confirm:stop:${containerName}:${timestamp}` }],\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n}\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};" }, "id": "code-format-confirmed-stop-result", "name": "Format Confirmed Stop Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2880, 1550 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-confirmed-stop-result", "name": "Send Confirmed Stop Result", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 3100, 1550 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build update progress message - removes buttons during operation\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// Progress message with no buttons (prevents duplicate actions)\nconst text = `\\u2B06\\uFE0F Updating ${containerName}...\\n\\nPulling latest image and recreating container.\\nThis may take a few minutes.`;\n\n// Empty keyboard removes all buttons during update\nconst reply_markup = { inline_keyboard: [] };\n\nreturn {\n json: {\n containerName,\n chatId,\n messageId,\n progressText: text,\n reply_markup\n }\n};" }, "id": "code-prepare-confirmed-update", "name": "Prepare Confirmed Update", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 1650 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.progressText, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-show-update-progress", "name": "Show Update Progress", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2220, 1650 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-container-for-update", "name": "Get Container For Update", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2440, 1650 ] }, { "parameters": { "jsCode": "// Find container and get inspect data for update\nconst containers = $input.all().map(item => item.json);\nconst prevData = $('Prepare Confirmed Update').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\nconst fullName = container.Names[0].replace(/^\\//, '');\n\nreturn {\n json: {\n containerId,\n containerName: normalizeName(container.Names[0]),\n fullContainerName: fullName,\n imageName: container.Image,\n chatId,\n messageId\n }\n};" }, "id": "code-find-container-for-update", "name": "Find Container For Update", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2660, 1650 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/{{ $json.containerId }}/json", "options": {} }, "id": "http-inspect-container-for-update", "name": "Inspect Container For Update", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2880, 1650 ] }, { "parameters": { "jsCode": "// Parse container config and build pull command\nconst inspectData = $input.item.json;\nconst prevData = $('Find Container For Update').item.json;\nconst containerId = prevData.containerId;\nconst containerName = prevData.containerName;\nconst fullContainerName = prevData.fullContainerName;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Extract image info\nlet imageName = inspectData.Config.Image;\nconst currentImageId = inspectData.Image;\n\n// CRITICAL: Ensure image has a tag, otherwise Docker pulls ALL tags!\nif (imageName && !imageName.includes(':') && !imageName.includes('@')) {\n imageName = imageName + ':latest';\n}\n\n// Extract config for recreation\nconst containerConfig = inspectData.Config;\nconst hostConfig = inspectData.HostConfig;\nconst networkSettings = inspectData.NetworkSettings;\n\n// Get current version from image digest or tag\nconst currentDigest = currentImageId.substring(7, 19);\n\nreturn {\n json: {\n pullCmd: `curl -s --max-time 120 -X POST 'http://docker-socket-proxy:2375/v1.47/images/create?fromImage=${encodeURIComponent(imageName)}'`,\n containerId,\n containerName,\n fullContainerName,\n imageName,\n currentImageId,\n currentDigest,\n containerConfig,\n hostConfig,\n networkSettings,\n chatId,\n messageId\n }\n};" }, "id": "code-parse-update-container-config", "name": "Parse Update Container Config", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3100, 1650 ] }, { "parameters": { "command": "={{ $json.pullCmd }}", "options": {} }, "id": "exec-pull-update-image", "name": "Pull Update Image", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 3320, 1650 ] }, { "parameters": { "jsCode": "// Check pull result and get new image ID\nconst stdout = $input.item.json.stdout || '';\nconst prevData = $('Parse Update Container Config').item.json;\nconst containerId = prevData.containerId;\nconst containerName = prevData.containerName;\nconst fullContainerName = prevData.fullContainerName;\nconst imageName = prevData.imageName;\nconst currentImageId = prevData.currentImageId;\nconst currentDigest = prevData.currentDigest;\nconst containerConfig = prevData.containerConfig;\nconst hostConfig = prevData.hostConfig;\nconst networkSettings = prevData.networkSettings;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Check if pull succeeded (contains status messages)\nconst pullSuccess = stdout.includes('Pulling') || stdout.includes('Downloaded') || stdout.includes('Status:') || stdout.includes('Digest:');\n\nif (!pullSuccess && stdout.includes('error')) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n containerName,\n text: `Failed to pull image for ${containerName}`\n }\n };\n}\n\nreturn {\n json: {\n inspectCmd: `curl -s --max-time 10 'http://docker-socket-proxy:2375/v1.47/images/${encodeURIComponent(imageName)}/json'`,\n containerId,\n containerName,\n fullContainerName,\n imageName,\n currentImageId,\n currentDigest,\n containerConfig,\n hostConfig,\n networkSettings,\n chatId,\n messageId\n }\n};" }, "id": "code-check-pull-result", "name": "Check Pull Result", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3540, 1650 ] }, { "parameters": { "command": "={{ $json.inspectCmd }}", "options": {} }, "id": "exec-inspect-new-image", "name": "Inspect New Image", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 3760, 1650 ] }, { "parameters": { "jsCode": "// Compare image digests and decide if update needed\nconst stdout = $input.item.json.stdout || '';\nconst prevData = $('Check Pull Result').item.json;\nconst containerId = prevData.containerId;\nconst containerName = prevData.containerName;\nconst fullContainerName = prevData.fullContainerName;\nconst imageName = prevData.imageName;\nconst currentImageId = prevData.currentImageId;\nconst currentDigest = prevData.currentDigest;\nconst containerConfig = prevData.containerConfig;\nconst hostConfig = prevData.hostConfig;\nconst networkSettings = prevData.networkSettings;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\nlet imageData;\ntry {\n imageData = JSON.parse(stdout);\n} catch (e) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n containerName,\n text: `Failed to inspect new image for ${containerName}`\n }\n };\n}\n\nconst newImageId = imageData.Id;\nconst newDigest = newImageId.substring(7, 19);\n\n// Check if image changed\nconst needsUpdate = newImageId !== currentImageId;\n\nif (!needsUpdate) {\n return {\n json: {\n needsUpdate: false,\n chatId,\n messageId,\n containerName,\n text: `${containerName} is already up to date.`\n }\n };\n}\n\n// Proceed with update\nreturn {\n json: {\n needsUpdate: true,\n stopCmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 15 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/stop?t=10'`,\n containerId,\n containerName,\n fullContainerName,\n imageName,\n currentImageId,\n newImageId,\n currentDigest,\n newDigest,\n containerConfig,\n hostConfig,\n networkSettings,\n chatId,\n messageId\n }\n};" }, "id": "code-compare-update-images", "name": "Compare Update Images", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3980, 1650 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "needs-update", "leftValue": "={{ $json.needsUpdate }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-needs-update", "name": "Check If Needs Update", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 4200, 1650 ] }, { "parameters": { "jsCode": "// Container is already up to date - show completion with back button only\nconst data = $input.item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// Completion message shows only navigation button (removes action buttons)\nconst keyboard = {\n inline_keyboard: [\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n};\n\nconst text = `\\u2705 ${containerName} already up to date\\n\\nNo changes needed.`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};" }, "id": "code-format-no-update-needed", "name": "Format No Update Needed", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4420, 1750 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-no-update-needed", "name": "Send No Update Needed", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 4640, 1750 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "command": "={{ $json.stopCmd }}", "options": {} }, "id": "exec-stop-for-update", "name": "Stop For Update", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 4420, 1650 ] }, { "parameters": { "jsCode": "// Verify stop and build remove command\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Compare Update Images').item.json;\nconst containerId = prevData.containerId;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst containerName = prevData.containerName;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: stopped, 304: already stopped - both OK\nif (statusCode !== 204 && statusCode !== 304) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n containerName,\n text: `Failed to stop container: HTTP ${statusCode}`\n }\n };\n}\n\nreturn {\n json: {\n removeCmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X DELETE 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}'`,\n containerId,\n containerName: prevData.containerName,\n fullContainerName: prevData.fullContainerName,\n imageName: prevData.imageName,\n currentImageId: prevData.currentImageId,\n newImageId: prevData.newImageId,\n currentDigest: prevData.currentDigest,\n newDigest: prevData.newDigest,\n containerConfig: prevData.containerConfig,\n hostConfig: prevData.hostConfig,\n networkSettings: prevData.networkSettings,\n chatId,\n messageId\n }\n};" }, "id": "code-verify-update-stop", "name": "Verify Update Stop", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4640, 1650 ] }, { "parameters": { "command": "={{ $json.removeCmd }}", "options": {} }, "id": "exec-remove-for-update", "name": "Remove For Update", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 4860, 1650 ] }, { "parameters": { "jsCode": "// Build container create request body\nconst prevData = $('Verify Update Stop').item.json;\nconst config = prevData.containerConfig;\nconst hostConfig = prevData.hostConfig;\nconst networkSettings = prevData.networkSettings;\nconst containerName = prevData.fullContainerName;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Build NetworkingConfig from NetworkSettings\nconst networks = {};\nfor (const [name, netConfig] of Object.entries(networkSettings.Networks || {})) {\n networks[name] = {\n IPAMConfig: netConfig.IPAMConfig,\n Links: netConfig.Links,\n Aliases: netConfig.Aliases\n };\n}\n\nconst createBody = {\n ...config,\n HostConfig: hostConfig,\n NetworkingConfig: {\n EndpointsConfig: networks\n }\n};\n\n// Remove fields that shouldn't be in create request\ndelete createBody.Hostname;\ndelete createBody.Domainname;\n\nreturn {\n json: {\n createBody: JSON.stringify(createBody),\n containerName,\n shortName: prevData.containerName,\n currentDigest: prevData.currentDigest,\n newDigest: prevData.newDigest,\n currentImageId: prevData.currentImageId,\n chatId,\n messageId\n }\n};" }, "id": "code-build-update-create-body", "name": "Build Update Create Body", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5080, 1650 ] }, { "parameters": { "jsCode": "// Build create container command\nconst createBody = $json.createBody;\nconst containerName = $json.containerName;\nconst chatId = $json.chatId;\nconst messageId = $json.messageId;\n\nconst cmd = `curl -s -X POST --max-time 5 -H \"Content-Type: application/json\" -d '${createBody.replace(/'/g, \"'\\\\''\")}' 'http://docker-socket-proxy:2375/v1.47/containers/create?name=${encodeURIComponent(containerName)}'`;\n\nreturn {\n json: {\n createCmd: cmd,\n containerName,\n shortName: $json.shortName,\n currentDigest: $json.currentDigest,\n newDigest: $json.newDigest,\n currentImageId: $json.currentImageId,\n chatId,\n messageId\n }\n};" }, "id": "code-build-update-create-cmd", "name": "Build Update Create Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5300, 1650 ] }, { "parameters": { "command": "={{ $json.createCmd }}", "options": {} }, "id": "exec-create-for-update", "name": "Create For Update", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 5520, 1650 ] }, { "parameters": { "jsCode": "// Parse create response and extract new container ID\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Update Create Command').item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst containerName = prevData.shortName;\n\nlet response;\ntry {\n response = JSON.parse(stdout);\n} catch (e) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n containerName,\n text: `Create failed: ${stdout}`\n }\n };\n}\n\nif (response.message) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n containerName,\n text: `Create failed: ${response.message}`\n }\n };\n}\n\nreturn {\n json: {\n startCmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${response.Id}/start'`,\n newContainerId: response.Id,\n containerName,\n currentDigest: prevData.currentDigest,\n newDigest: prevData.newDigest,\n currentImageId: prevData.currentImageId,\n chatId,\n messageId\n }\n};" }, "id": "code-parse-update-create-response", "name": "Parse Update Create Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5740, 1650 ] }, { "parameters": { "command": "={{ $json.startCmd }}", "options": {} }, "id": "exec-start-after-update", "name": "Start After Update", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 5960, 1650 ] }, { "parameters": { "jsCode": "// Build update completion message (success shows back only, error shows retry + back)\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Parse Update Create Response').item.json;\nconst containerName = prevData.containerName;\nconst currentDigest = prevData.currentDigest;\nconst newDigest = prevData.newDigest;\nconst currentImageId = prevData.currentImageId;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: started, 304: already running\nconst success = statusCode === 204 || statusCode === 304;\n\nlet text;\nlet keyboard;\n\nif (success) {\n text = `\\u2705 ${containerName} updated successfully\\n\\n${currentDigest} \\u2192 ${newDigest}`;\n // Success: only back button\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n} else {\n text = `\\u274C Failed to start ${containerName} after update`;\n // Error: retry and back buttons\n const timestamp = Math.floor(Date.now() / 1000);\n keyboard = {\n inline_keyboard: [\n [{ text: '\\u{1F504} Try Again', callback_data: `confirm:update:${containerName}:${timestamp}` }],\n [{ text: '\\u25C0\\uFE0F Back to Containers', callback_data: 'list:0' }]\n ]\n };\n}\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard,\n currentImageId\n }\n};" }, "id": "code-format-update-complete", "name": "Format Update Complete", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 6180, 1650 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-update-complete", "name": "Send Update Complete", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 6400, 1650 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Build remove old image command (fire and forget)\nconst currentImageId = $('Format Update Complete').item.json.currentImageId;\n\n// Skip if no image ID (error case)\nif (!currentImageId) {\n return { json: { cmd: 'true', skip: true } };\n}\n\n// Remove the old image - ignore errors (image might be used by another container)\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 5 -X DELETE 'http://docker-socket-proxy:2375/v1.47/images/${currentImageId}?force=false'`,\n currentImageId\n }\n};" }, "id": "code-build-callback-remove-image", "name": "Build Callback Remove Image", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 6620, 1650 ] }, { "parameters": { "command": "={{ $json.cmd }}", "options": {} }, "id": "exec-callback-remove-old-image", "name": "Callback Remove Old Image", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 6840, 1650 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", "options": {} }, "id": "http-answer-cancel-confirm-callback", "name": "Answer Cancel Confirm Callback", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1340, 1800 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Cancel confirmation - return to container submenu\nconst data = $(\"Parse Callback Data\").item.json;\nreturn {\n json: {\n containerName: data.containerName,\n chatId: data.chatId,\n messageId: data.messageId\n }\n};" }, "id": "code-prepare-cancel-return", "name": "Prepare Cancel Return", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1560, 1800 ] }, { "parameters": { "method": "GET", "url": "=http://docker-socket-proxy:2375/containers/json?all=true", "options": {} }, "id": "http-get-container-for-cancel", "name": "Get Container For Cancel", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1780, 1800 ] }, { "parameters": { "jsCode": "// Build submenu for return from cancel\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Cancel Return\").item.json;\nconst searchName = prevData.containerName.toLowerCase();\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, \"\")\n .replace(/^(linuxserver[-_]|binhex[-_])/i, \"\")\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === searchName);\n\nif (!container) {\n return [{\n json: {\n chatId,\n messageId,\n text: `Container \\\"${searchName}\\\" not found`,\n reply_markup: { inline_keyboard: [[{ text: \"\\u25C0\\uFE0F Back to List\", callback_data: \"list:0\" }]] }\n }\n }];\n}\n\nconst containerName = normalizeName(container.Names[0]);\nconst state = container.State;\nconst status = container.Status;\nconst image = container.Image;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === \"running\") {\n keyboard.push([\n { text: \"\\u23F9\\uFE0F Stop\", callback_data: `action:stop:${containerName}` },\n { text: \"\\u{1F504} Restart\", callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: \"\\u25B6\\uFE0F Start\", callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: \"\\u{1F4CB} Logs\", callback_data: `action:logs:${containerName}` },\n { text: \"\\u2B06\\uFE0F Update\", callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: \"\\u25C0\\uFE0F Back to List\", callback_data: \"list:0\" }\n]);\n\n// Build status text\nconst stateIcon = state === \"running\" ? \"\\u{1F7E2}\" : \"\\u26AA\";\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `State: ${state}\\n`;\ntext += `Status: ${status}\\n`;\ntext += `Image: ${image}`;\n\nreturn [{\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];" }, "id": "code-build-cancel-return-submenu", "name": "Build Cancel Return Submenu", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2000, 1800 ] }, { "parameters": { "method": "POST", "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", "options": {} }, "id": "http-send-cancel-return-submenu", "name": "Send Cancel Return Submenu", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2220, 1800 ], "credentials": { "telegramApi": { "id": "I0xTTiASl7C1NZhJ", "name": "Telegram account" } } }, { "parameters": { "jsCode": "// Detect if this is a batch command (multiple container names)\n// Batch commands: {action} {name1} {name2} ... where action is update/start/stop/restart\nconst message = $json.message;\nconst text = message.text.toLowerCase().trim();\nconst chatId = message.chat.id;\nconst messageId = message.message_id;\n\n// Determine action type from the routed keyword\n// This node receives from Keyword Router which already matched the action\nlet action = null;\nlet containerPart = '';\n\nif (text.startsWith('restart ')) {\n action = 'restart';\n containerPart = text.substring(8).trim();\n} else if (text.includes('restart')) {\n action = 'restart';\n containerPart = text.replace('restart', '').trim();\n} else if (text.startsWith('start ')) {\n action = 'start';\n containerPart = text.substring(6).trim();\n} else if (text.includes('start')) {\n action = 'start';\n containerPart = text.replace('start', '').trim();\n} else if (text.startsWith('stop ')) {\n action = 'stop';\n containerPart = text.substring(5).trim();\n} else if (text.includes('stop')) {\n action = 'stop';\n containerPart = text.replace('stop', '').trim();\n} else if (text.startsWith('update ')) {\n action = 'update';\n containerPart = text.substring(7).trim();\n} else if (text.includes('update')) {\n action = 'update';\n containerPart = text.replace('update', '').trim();\n}\n\nif (!action || !containerPart) {\n // No valid action found, pass through as non-batch (will be handled by existing flow)\n return {\n json: {\n isBatch: false,\n action: action,\n containerNames: [],\n originalMessage: text,\n chatId: chatId,\n messageId: messageId,\n message: message\n }\n };\n}\n\n// Split container names by whitespace\nconst containerNames = containerPart.split(/\\s+/).filter(name => name.length > 0);\n\n// Batch = 2 or more containers\nconst isBatch = containerNames.length >= 2;\n\nreturn {\n json: {\n isBatch: isBatch,\n action: action,\n containerNames: containerNames,\n originalMessage: text,\n chatId: chatId,\n messageId: messageId,\n message: message\n }\n};" }, "id": "code-detect-batch", "name": "Detect Batch Command", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 900, -200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "is-batch-check", "leftValue": "={{ $json.isBatch }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "if-is-batch", "name": "Is Batch Command", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1120, -200 ] }, { "parameters": { "rules": { "values": [ { "id": "action-start-stop-restart", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-action-type", "leftValue": "={{ $json.action }}", "rightValue": "update", "operator": { "type": "string", "operation": "notEquals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "action" }, { "id": "action-update", "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose" }, "conditions": [ { "id": "is-update-type", "leftValue": "={{ $json.action }}", "rightValue": "update", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "update" } ] }, "options": { "fallbackOutput": "none" } }, "id": "switch-route-single-action", "name": "Route Single Action", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ 1340, -100 ] }, { "parameters": { "command": "curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/json?all=true'", "options": {} }, "id": "exec-docker-list-batch", "name": "Get Containers for Batch", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 1340, -300 ] } ], "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": "Keyword Router", "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 Batch Commands", "type": "main", "index": 0 } ], [ { "node": "Answer Select Callback", "type": "main", "index": 0 } ], [ { "node": "Answer List Callback", "type": "main", "index": 0 } ], [ { "node": "Answer Action Callback", "type": "main", "index": 0 } ], [ { "node": "Answer Noop Callback", "type": "main", "index": 0 } ], [ { "node": "Answer Confirm Callback", "type": "main", "index": 0 } ], [ { "node": "Answer Cancel Confirm Callback", "type": "main", "index": 0 } ], [ { "node": "Build Callback Action", "type": "main", "index": 0 } ] ] }, "Answer Select Callback": { "main": [ [ { "node": "Prepare Container Fetch", "type": "main", "index": 0 } ] ] }, "Prepare Container Fetch": { "main": [ [ { "node": "Get Single Container", "type": "main", "index": 0 } ] ] }, "Get Single Container": { "main": [ [ { "node": "Build Container Submenu", "type": "main", "index": 0 } ] ] }, "Build Container Submenu": { "main": [ [ { "node": "Send Container Submenu", "type": "main", "index": 0 } ] ] }, "Answer List Callback": { "main": [ [ { "node": "Prepare List Fetch", "type": "main", "index": 0 } ] ] }, "Prepare List Fetch": { "main": [ [ { "node": "Get Containers For List", "type": "main", "index": 0 } ] ] }, "Get Containers For List": { "main": [ [ { "node": "Build Paginated List", "type": "main", "index": 0 } ] ] }, "Build Paginated List": { "main": [ [ { "node": "Edit Container List", "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 } ] ] }, "Docker List Containers": { "main": [ [ { "node": "Build Container List Keyboard", "type": "main", "index": 0 } ] ] }, "Build Container List Keyboard": { "main": [ [ { "node": "Check Single Container", "type": "main", "index": 0 } ] ] }, "Check Single Container": { "main": [ [ { "node": "Build Container Submenu Direct", "type": "main", "index": 0 } ], [ { "node": "Send Container List", "type": "main", "index": 0 } ] ] }, "Build Container Submenu Direct": { "main": [ [ { "node": "Send Container Submenu Direct", "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 } ] ] }, "Build Batch Commands": { "main": [ [ { "node": "Prepare Batch Execution", "type": "main", "index": 0 } ] ] }, "Prepare Batch Execution": { "main": [ [ { "node": "Execute Batch Action", "type": "main", "index": 0 } ] ] }, "Execute Batch Action": { "main": [ [ { "node": "Parse Batch Result", "type": "main", "index": 0 } ] ] }, "Parse Batch Result": { "main": [ [ { "node": "Format Batch Result", "type": "main", "index": 0 } ] ] }, "Format Batch Result": { "main": [ [ { "node": "Answer Batch Query", "type": "main", "index": 0 } ] ] }, "Answer Batch Query": { "main": [ [ { "node": "Delete Batch Confirm Message", "type": "main", "index": 0 } ] ] }, "Delete Batch Confirm Message": { "main": [ [ { "node": "Send Batch Result", "type": "main", "index": 0 } ] ] }, "Parse Update Command": { "main": [ [ { "node": "Docker List for Update", "type": "main", "index": 0 } ] ] }, "Docker List for Update": { "main": [ [ { "node": "Match Update Container", "type": "main", "index": 0 } ] ] }, "Match Update Container": { "main": [ [ { "node": "Check Update Match Count", "type": "main", "index": 0 } ] ] }, "Check Update Match Count": { "main": [ [ { "node": "Send Update Error", "type": "main", "index": 0 } ], [ { "node": "Send Update No Match", "type": "main", "index": 0 } ], [ { "node": "Send Update Started", "type": "main", "index": 0 } ], [ { "node": "Handle Update Multiple", "type": "main", "index": 0 } ], [] ] }, "Handle Update Multiple": { "main": [ [ { "node": "Send Update Multiple", "type": "main", "index": 0 } ] ] }, "Send Update Started": { "main": [ [ { "node": "Build Inspect Command", "type": "main", "index": 0 } ] ] }, "Build Inspect Command": { "main": [ [ { "node": "Inspect Container", "type": "main", "index": 0 } ] ] }, "Inspect Container": { "main": [ [ { "node": "Parse Container Config", "type": "main", "index": 0 } ] ] }, "Parse Container Config": { "main": [ [ { "node": "Build Pull Command", "type": "main", "index": 0 } ] ] }, "Build Pull Command": { "main": [ [ { "node": "Pull Image", "type": "main", "index": 0 } ] ] }, "Pull Image": { "main": [ [ { "node": "Check Pull Response", "type": "main", "index": 0 } ] ] }, "Check Pull Response": { "main": [ [ { "node": "Check Pull Success", "type": "main", "index": 0 } ] ] }, "Check Pull Success": { "main": [ [ { "node": "Build Image Inspect", "type": "main", "index": 0 } ], [ { "node": "Send Pull Error", "type": "main", "index": 0 } ] ] }, "Build Image Inspect": { "main": [ [ { "node": "Inspect New Image (Text)", "type": "main", "index": 0 } ] ] }, "Inspect New Image (Text)": { "main": [ [ { "node": "Compare Digests", "type": "main", "index": 0 } ] ] }, "Compare Digests": { "main": [ [ { "node": "Check If Update Needed", "type": "main", "index": 0 } ] ] }, "Inspect New Image": { "main": [ [ { "node": "Compare Update Images", "type": "main", "index": 0 } ] ] }, "Check If Update Needed": { "main": [ [ { "node": "Build Stop Command", "type": "main", "index": 0 } ], [ { "node": "Format No Update", "type": "main", "index": 0 } ] ] }, "Format No Update": { "main": [ [ { "node": "Send No Update", "type": "main", "index": 0 } ] ] }, "Build Stop Command": { "main": [ [ { "node": "Stop Container", "type": "main", "index": 0 } ] ] }, "Stop Container": { "main": [ [ { "node": "Verify Stop Build Remove", "type": "main", "index": 0 } ] ] }, "Verify Stop Build Remove": { "main": [ [ { "node": "Remove Container", "type": "main", "index": 0 } ] ] }, "Remove Container": { "main": [ [ { "node": "Build Create Body", "type": "main", "index": 0 } ] ] }, "Build Create Body": { "main": [ [ { "node": "Build Create Command", "type": "main", "index": 0 } ] ] }, "Build Create Command": { "main": [ [ { "node": "Create Container", "type": "main", "index": 0 } ] ] }, "Create Container": { "main": [ [ { "node": "Parse Create Response", "type": "main", "index": 0 } ] ] }, "Parse Create Response": { "main": [ [ { "node": "Build Start Command", "type": "main", "index": 0 } ] ] }, "Build Start Command": { "main": [ [ { "node": "Start New Container", "type": "main", "index": 0 } ] ] }, "Start New Container": { "main": [ [ { "node": "Format Update Result", "type": "main", "index": 0 } ] ] }, "Format Update Result": { "main": [ [ { "node": "Send Update Result", "type": "main", "index": 0 } ] ] }, "Send Update Result": { "main": [ [ { "node": "Build Remove Image Command", "type": "main", "index": 0 } ] ] }, "Build Remove Image Command": { "main": [ [ { "node": "Remove Old Image", "type": "main", "index": 0 } ] ] }, "Parse Logs Command": { "main": [ [ { "node": "Docker List for Logs", "type": "main", "index": 0 } ] ] }, "Docker List for Logs": { "main": [ [ { "node": "Match Logs Container", "type": "main", "index": 0 } ] ] }, "Match Logs Container": { "main": [ [ { "node": "Check Logs Match Count", "type": "main", "index": 0 } ] ] }, "Check Logs Match Count": { "main": [ [ { "node": "Send Logs Error", "type": "main", "index": 0 } ], [ { "node": "Format Logs No Match", "type": "main", "index": 0 } ], [ { "node": "Build Logs Command", "type": "main", "index": 0 } ], [ { "node": "Format Logs Multiple", "type": "main", "index": 0 } ] ] }, "Build Logs Command": { "main": [ [ { "node": "Execute Logs", "type": "main", "index": 0 } ] ] }, "Execute Logs": { "main": [ [ { "node": "Format Logs", "type": "main", "index": 0 } ] ] }, "Format Logs": { "main": [ [ { "node": "Send Logs Response", "type": "main", "index": 0 } ] ] }, "Format Logs No Match": { "main": [ [ { "node": "Send Logs Error", "type": "main", "index": 0 } ] ] }, "Format Logs Multiple": { "main": [ [ { "node": "Send Logs Error", "type": "main", "index": 0 } ] ] }, "Keyword Router": { "main": [ [ { "node": "Show Menu", "type": "main", "index": 0 } ], [ { "node": "Docker List Containers", "type": "main", "index": 0 } ], [ { "node": "Detect Batch Command", "type": "main", "index": 0 } ], [ { "node": "Detect Batch Command", "type": "main", "index": 0 } ], [ { "node": "Detect Batch Command", "type": "main", "index": 0 } ], [ { "node": "Detect Batch Command", "type": "main", "index": 0 } ], [ { "node": "Parse Logs Command", "type": "main", "index": 0 } ], [ { "node": "Show Menu", "type": "main", "index": 0 } ] ] }, "Detect Batch Command": { "main": [ [ { "node": "Is Batch Command", "type": "main", "index": 0 } ] ] }, "Is Batch Command": { "main": [ [ { "node": "Get Containers for Batch", "type": "main", "index": 0 } ], [ { "node": "Route Single Action", "type": "main", "index": 0 } ] ] }, "Route Single Action": { "main": [ [ { "node": "Parse Action Command", "type": "main", "index": 0 } ], [ { "node": "Parse Update Command", "type": "main", "index": 0 } ] ] }, "Parse Action Command": { "main": [ [ { "node": "Docker List for Action", "type": "main", "index": 0 } ] ] }, "Answer Action Callback": { "main": [ [ { "node": "Route Action Type", "type": "main", "index": 0 } ] ] }, "Route Action Type": { "main": [ [ { "node": "Prepare Immediate Action", "type": "main", "index": 0 } ], [ { "node": "Prepare Immediate Action", "type": "main", "index": 0 } ], [ { "node": "Build Stop Confirmation", "type": "main", "index": 0 } ], [ { "node": "Build Update Confirmation", "type": "main", "index": 0 } ], [ { "node": "Prepare Logs Action", "type": "main", "index": 0 } ] ] }, "Prepare Immediate Action": { "main": [ [ { "node": "Get Container For Action", "type": "main", "index": 0 } ] ] }, "Get Container For Action": { "main": [ [ { "node": "Build Immediate Action Command", "type": "main", "index": 0 } ] ] }, "Build Immediate Action Command": { "main": [ [ { "node": "Execute Immediate Action", "type": "main", "index": 0 } ] ] }, "Execute Immediate Action": { "main": [ [ { "node": "Format Immediate Result", "type": "main", "index": 0 } ] ] }, "Format Immediate Result": { "main": [ [ { "node": "Send Immediate Result", "type": "main", "index": 0 } ] ] }, "Prepare Logs Action": { "main": [ [ { "node": "Get Container For Logs", "type": "main", "index": 0 } ] ] }, "Get Container For Logs": { "main": [ [ { "node": "Build Logs Action Command", "type": "main", "index": 0 } ] ] }, "Build Logs Action Command": { "main": [ [ { "node": "Execute Logs Action", "type": "main", "index": 0 } ] ] }, "Execute Logs Action": { "main": [ [ { "node": "Format Logs Action Result", "type": "main", "index": 0 } ] ] }, "Format Logs Action Result": { "main": [ [ { "node": "Send Logs Result", "type": "main", "index": 0 } ] ] }, "Build Stop Confirmation": { "main": [ [ { "node": "Send Stop Confirmation", "type": "main", "index": 0 } ] ] }, "Build Update Confirmation": { "main": [ [ { "node": "Send Update Confirmation", "type": "main", "index": 0 } ] ] }, "Answer Confirm Callback": { "main": [ [ { "node": "Check Confirm Expired", "type": "main", "index": 0 } ] ] }, "Check Confirm Expired": { "main": [ [ { "node": "Handle Confirm Expired", "type": "main", "index": 0 } ], [ { "node": "Route Confirm Action", "type": "main", "index": 0 } ] ] }, "Handle Confirm Expired": { "main": [ [ { "node": "Send Expired Confirm", "type": "main", "index": 0 } ] ] }, "Route Confirm Action": { "main": [ [ { "node": "Prepare Confirmed Stop", "type": "main", "index": 0 } ], [ { "node": "Prepare Confirmed Update", "type": "main", "index": 0 } ] ] }, "Prepare Confirmed Stop": { "main": [ [ { "node": "Get Container For Stop", "type": "main", "index": 0 } ] ] }, "Get Container For Stop": { "main": [ [ { "node": "Build Confirmed Stop Command", "type": "main", "index": 0 } ] ] }, "Build Confirmed Stop Command": { "main": [ [ { "node": "Execute Confirmed Stop", "type": "main", "index": 0 } ] ] }, "Execute Confirmed Stop": { "main": [ [ { "node": "Format Confirmed Stop Result", "type": "main", "index": 0 } ] ] }, "Format Confirmed Stop Result": { "main": [ [ { "node": "Send Confirmed Stop Result", "type": "main", "index": 0 } ] ] }, "Prepare Confirmed Update": { "main": [ [ { "node": "Show Update Progress", "type": "main", "index": 0 } ] ] }, "Show Update Progress": { "main": [ [ { "node": "Get Container For Update", "type": "main", "index": 0 } ] ] }, "Get Container For Update": { "main": [ [ { "node": "Find Container For Update", "type": "main", "index": 0 } ] ] }, "Find Container For Update": { "main": [ [ { "node": "Inspect Container For Update", "type": "main", "index": 0 } ] ] }, "Inspect Container For Update": { "main": [ [ { "node": "Parse Update Container Config", "type": "main", "index": 0 } ] ] }, "Parse Update Container Config": { "main": [ [ { "node": "Pull Update Image", "type": "main", "index": 0 } ] ] }, "Pull Update Image": { "main": [ [ { "node": "Check Pull Result", "type": "main", "index": 0 } ] ] }, "Check Pull Result": { "main": [ [ { "node": "Inspect New Image", "type": "main", "index": 0 } ] ] }, "Compare Update Images": { "main": [ [ { "node": "Check If Needs Update", "type": "main", "index": 0 } ] ] }, "Check If Needs Update": { "main": [ [ { "node": "Stop For Update", "type": "main", "index": 0 } ], [ { "node": "Format No Update Needed", "type": "main", "index": 0 } ] ] }, "Format No Update Needed": { "main": [ [ { "node": "Send No Update Needed", "type": "main", "index": 0 } ] ] }, "Stop For Update": { "main": [ [ { "node": "Verify Update Stop", "type": "main", "index": 0 } ] ] }, "Verify Update Stop": { "main": [ [ { "node": "Remove For Update", "type": "main", "index": 0 } ] ] }, "Remove For Update": { "main": [ [ { "node": "Build Update Create Body", "type": "main", "index": 0 } ] ] }, "Build Update Create Body": { "main": [ [ { "node": "Build Update Create Command", "type": "main", "index": 0 } ] ] }, "Build Update Create Command": { "main": [ [ { "node": "Create For Update", "type": "main", "index": 0 } ] ] }, "Create For Update": { "main": [ [ { "node": "Parse Update Create Response", "type": "main", "index": 0 } ] ] }, "Parse Update Create Response": { "main": [ [ { "node": "Start After Update", "type": "main", "index": 0 } ] ] }, "Start After Update": { "main": [ [ { "node": "Format Update Complete", "type": "main", "index": 0 } ] ] }, "Format Update Complete": { "main": [ [ { "node": "Send Update Complete", "type": "main", "index": 0 } ] ] }, "Send Update Complete": { "main": [ [ { "node": "Build Callback Remove Image", "type": "main", "index": 0 } ] ] }, "Build Callback Remove Image": { "main": [ [ { "node": "Callback Remove Old Image", "type": "main", "index": 0 } ] ] }, "Answer Cancel Confirm Callback": { "main": [ [ { "node": "Prepare Cancel Return", "type": "main", "index": 0 } ] ] }, "Prepare Cancel Return": { "main": [ [ { "node": "Get Container For Cancel", "type": "main", "index": 0 } ] ] }, "Get Container For Cancel": { "main": [ [ { "node": "Build Cancel Return Submenu", "type": "main", "index": 0 } ] ] }, "Build Cancel Return Submenu": { "main": [ [ { "node": "Send Cancel Return Submenu", "type": "main", "index": 0 } ] ] } }, "pinData": {}, "settings": { "executionOrder": "v1" }, "staticData": null, "tags": [], "triggerCount": 1, "active": false }