diff --git a/n8n-workflow.json b/n8n-workflow.json index f3effce..a76c38c 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -782,7 +782,7 @@ }, { "parameters": { - "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{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 === '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};" + "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", @@ -964,6 +964,54 @@ }, "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" } ] }, @@ -3075,6 +3123,706 @@ "name": "Telegram account" } } + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", + "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{{ $credentials.telegramApi.accessToken }}/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.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": "// Parse stop result and update message\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\nconst icon = success ? '\\u2705' : '\\u274C';\nconst resultText = success ? `${containerName} stopped successfully` : `Failed to stop ${containerName}`;\n\n// Build updated keyboard (container is now stopped)\nconst keyboard = [\n [{ text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }],\n [\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ],\n [{ text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }]\n];\n\nconst stateIcon = success ? '\\u26AA' : '\\u{1F7E2}';\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `${icon} ${resultText}`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: 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{{ $credentials.telegramApi.accessToken }}/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": "// Prepare update action from confirmation - show \"Updating...\" message\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// Show progress message\nconst text = `\\u23F3 Updating ${containerName}...\\n\\nPulling latest image and recreating container.`;\n\nreturn {\n json: {\n containerName,\n chatId,\n messageId,\n progressText: text\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{{ $credentials.telegramApi.accessToken }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.progressText, parse_mode: 'HTML' }) }}", + "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.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\nconst imageName = inspectData.Config.Image;\nconst currentImageId = inspectData.Image;\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 result\nconst data = $input.item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// Build keyboard\nconst keyboard = [\n [\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ],\n [\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ],\n [{ text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }]\n];\n\nlet text = `\\u{1F7E2} ${containerName}\\n\\n`;\ntext += `\\u2705 Already up to date - no changes needed.`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: 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{{ $credentials.telegramApi.accessToken }}/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": "// Parse start result and format success message\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\nconst icon = success ? '\\u2705' : '\\u274C';\nconst resultText = success ? `${containerName} updated: ${currentDigest} \\u2192 ${newDigest}` : `Failed to start ${containerName} after update`;\n\n// Build updated keyboard\nconst keyboard = [\n [\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ],\n [\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ],\n [{ text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }]\n];\n\nconst stateIcon = success ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `${icon} ${resultText}`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: 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{{ $credentials.telegramApi.accessToken }}/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": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/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 = $input.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.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{{ $credentials.telegramApi.accessToken }}/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" + } + } } ], "connections": { @@ -3193,6 +3941,20 @@ "index": 0 } ], + [ + { + "node": "Answer Confirm Callback", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Answer Cancel Confirm Callback", + "type": "main", + "index": 0 + } + ], [ { "node": "Build Callback Action", @@ -4427,6 +5189,401 @@ } ] ] + }, + "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 + } + ] + ] + }, + "Inspect New Image": { + "main": [ + [ + { + "node": "Compare Update Images", + "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 + } + ] + ] + }, + "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": {},