From ddbae3c54e1e39bddb27815e460f55bce756a837 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 4 Feb 2026 09:46:18 -0500 Subject: [PATCH] fix(09-04): replace splitInBatches with manual loop for batch execution - Route Batch Loop Action now references Build Progress Message node - Build Batch Action Command references Build Progress Message node - Prepare Batch Loop outputs single item with currentIndex for manual loop - Prepare Next Iteration increments currentIndex and sets next container - Is Batch Complete loops back to Build Progress Message instead of Batch Loop - Bypasses problematic splitInBatches node that wasn't processing items Co-Authored-By: Claude Opus 4.5 --- n8n-workflow.json | 182 +++++++++++++++++++++++----------------------- 1 file changed, 90 insertions(+), 92 deletions(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index 39e53f8..47fd4ad 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -755,7 +755,7 @@ }, { "parameters": { - "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, confirm:{action}:{name}:{timestamp}, cancel:{name}, bstop:*, bexec:*, 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\n// Batch stop confirmation: bstop:confirm:{names}:{timestamp} or bstop:cancel\nif (rawData.startsWith('bstop:')) {\n const rest = rawData.substring(6); // Remove 'bstop:'\n if (rest === 'cancel') {\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n }\n // bstop:confirm:{names}:{timestamp}\n const parts = rest.split(':'); // confirm, names, timestamp\n if (parts[0] === 'confirm' && parts.length >= 3) {\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':'); // Handle container names with colons\n const containerNames = namesStr.split(',');\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 isBatchStopConfirm: true,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: true\n }\n };\n }\n}\n\n// Batch execution: bexec:{action}:{names}:{timestamp}\nif (rawData.startsWith('bexec:')) {\n const parts = rawData.substring(6).split(':'); // action, names, timestamp\n const action = parts[0];\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':');\n const containerNames = namesStr.split(',');\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 isBatchExec: true,\n batchAction: action,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: true\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\n\nif (rawData === 'batch:mode') {\n // Enter batch selection mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:toggle:')) {\n // Toggle container selection: batch:toggle:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const selectedCsv = parts[0]; // Currently selected (comma-separated)\n const toggleName = parts.slice(1).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n toggleName: toggleName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:exec:')) {\n // Execute batch action: batch:exec:{action}:{selected_csv}\n const parts = rawData.substring(11).split(':'); // Remove 'batch:exec:'\n const action = parts[0]; // update/start/stop/restart\n const selectedCsv = parts.slice(1).join(':'); // Comma-separated names\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n action: action,\n selectedCsv: selectedCsv,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:clear') {\n // Clear selection\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchClear: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:cancel') {\n // Cancel batch mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('uall:confirm:')) {\n // Update all confirmation: uall:confirm:{timestamp}\n const parts = rawData.substring(13).split(':'); // Remove 'uall:confirm:'\n const timestamp = parseInt(parts[0]);\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 isUpdateAll: true,\n confirmAction: 'updateall',\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n isConfirm: false\n }\n };\n}\n\nif (rawData === 'uall:cancel') {\n // Update all cancel\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAllCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n isConfirm: 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}, bstop:*, bexec:*, 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\n// Batch stop confirmation: bstop:confirm:{names}:{timestamp} or bstop:cancel\nif (rawData.startsWith('bstop:')) {\n const rest = rawData.substring(6); // Remove 'bstop:'\n if (rest === 'cancel') {\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n }\n // bstop:confirm:{names}:{timestamp}\n const parts = rest.split(':'); // confirm, names, timestamp\n if (parts[0] === 'confirm' && parts.length >= 3) {\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':'); // Handle container names with colons\n const containerNames = namesStr.split(',');\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 isBatchStopConfirm: true,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n }\n}\n\n// Batch execution: bexec:{action}:{names}:{timestamp}\nif (rawData.startsWith('bexec:')) {\n const parts = rawData.substring(6).split(':'); // action, names, timestamp\n const action = parts[0];\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':');\n const containerNames = namesStr.split(',');\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 isBatchExec: true,\n batchAction: action,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: true\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\n\nif (rawData === 'batch:mode') {\n // Enter batch selection mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:toggle:')) {\n // Toggle container selection: batch:toggle:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const selectedCsv = parts[0]; // Currently selected (comma-separated)\n const toggleName = parts.slice(1).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n toggleName: toggleName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:exec:')) {\n // Execute batch action: batch:exec:{action}:{selected_csv}\n const parts = rawData.substring(11).split(':'); // Remove 'batch:exec:'\n const action = parts[0]; // update/start/stop/restart\n const selectedCsv = parts.slice(1).join(':'); // Comma-separated names\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n action: action,\n selectedCsv: selectedCsv,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:clear') {\n // Clear selection\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchClear: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:cancel') {\n // Cancel batch mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('uall:confirm:')) {\n // Update all confirmation: uall:confirm:{timestamp}\n const parts = rawData.substring(13).split(':'); // Remove 'uall:confirm:'\n const timestamp = parseInt(parts[0]);\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 isUpdateAll: true,\n confirmAction: 'updateall',\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n isConfirm: false\n }\n };\n}\n\nif (rawData === 'uall:cancel') {\n // Update all cancel\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAllCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n isConfirm: 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", @@ -1083,7 +1083,7 @@ "outputKey": "batchStopCancel" }, { - "id": "is-batch-exec", + "id": "is-bexec-text-cmd", "conditions": { "options": { "caseSensitive": true, @@ -1099,12 +1099,21 @@ "type": "boolean", "operation": "equals" } + }, + { + "id": "is-batch-true", + "leftValue": "={{ $json.isBatch }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } } ], "combinator": "and" }, "renameOutput": true, - "outputKey": "batchExec" + "outputKey": "bexecTextCmd" }, { "id": "is-batch-mode", @@ -2615,7 +2624,7 @@ }, { "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}];" + "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// Add Select Multiple button for batch operations\nkeyboard.push([{ text: \"\u2611\ufe0f Select Multiple\", callback_data: \"batch:mode\" }]);\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", @@ -2886,7 +2895,7 @@ }, { "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}];" + "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// Add Select Multiple button for batch operations\nkeyboard.push([{ text: \"\u2611\ufe0f Select Multiple\", callback_data: \"batch:mode\" }]);\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", @@ -4475,7 +4484,7 @@ "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", + "jsonBody": "={{ JSON.stringify({ callback_query_id: $(\"Parse Callback Data\").item.json.queryId }) }}", "options": {} }, "id": "http-answer-batch-stop-confirm", @@ -4552,7 +4561,7 @@ "conditions": [ { "id": "batch-stop-expired", - "leftValue": "={{ $json.expired }}", + "leftValue": "={{ $(\"Parse Callback Data\").item.json.expired }}", "rightValue": true, "operator": { "type": "boolean", @@ -4637,7 +4646,7 @@ }, { "parameters": { - "jsCode": "// Store progress message ID and prepare containers for loop\nconst batchState = $('Initialize Batch State').item.json;\nconst response = $json;\n\n// Get message_id from Telegram response\nconst progressMessageId = response.result?.message_id || null;\n\n// Return array of items for the loop - each container as separate item\nconst items = batchState.containers.map((container, index) => ({\n json: {\n container: container,\n containerIndex: index,\n action: batchState.action,\n totalCount: batchState.totalCount,\n chatId: batchState.chatId,\n progressMessageId: progressMessageId,\n // Running totals - will be updated as loop progresses\n successCount: 0,\n failureCount: 0,\n warningCount: 0,\n results: []\n }\n}));\n\nreturn items;" + "jsCode": "// Store progress message ID and prepare first iteration\nlet batchState;\ntry {\n batchState = $('Initialize Batch State').item.json;\n} catch (e) {\n throw new Error('Failed to get Initialize Batch State: ' + e.message);\n}\n\nif (!batchState || !batchState.containers || !Array.isArray(batchState.containers)) {\n throw new Error('Invalid batch state');\n}\n\nconst response = $json;\nconst progressMessageId = response.result?.message_id || null;\n\n// Return single item with all data for manual loop\n// We'll process containers[currentIndex] and increment\nreturn {\n json: {\n containers: batchState.containers,\n currentIndex: 0,\n container: batchState.containers[0],\n action: batchState.action,\n totalCount: batchState.totalCount,\n chatId: batchState.chatId,\n progressMessageId: progressMessageId,\n successCount: 0,\n failureCount: 0,\n warningCount: 0,\n results: []\n }\n};" }, "id": "code-prepare-batch-loop", "name": "Prepare Batch Loop", @@ -4650,9 +4659,8 @@ }, { "parameters": { - "options": { - "reset": false - } + "batchSize": 1, + "options": {} }, "id": "loop-batch", "name": "Batch Loop", @@ -4665,7 +4673,7 @@ }, { "parameters": { - "jsCode": "// Build progress message for current container\nconst data = $json;\nconst container = data.container;\nconst action = data.action;\nconst containerIndex = data.containerIndex;\nconst totalCount = data.totalCount;\nconst successCount = data.successCount || 0;\nconst failureCount = data.failureCount || 0;\n\nconst containerName = container.Name || container;\nconst current = containerIndex + 1;\n\nlet progressText = `Batch ${action} in progress...\\n\\n`;\nprogressText += `Current: ${containerName}\\n`;\nprogressText += `Progress: ${current}/${totalCount}\\n\\n`;\n\nif (successCount > 0 || failureCount > 0) {\n progressText += `Completed: ${successCount} success`;\n if (failureCount > 0) {\n progressText += `, ${failureCount} failed`;\n }\n}\n\nreturn {\n json: {\n ...data,\n progressText: progressText,\n containerName: containerName\n }\n};" + "jsCode": "// Build progress message for current container\nconst data = $json;\nconst container = data.container;\nconst action = data.action;\nconst currentIndex = data.currentIndex;\nconst totalCount = data.totalCount;\nconst successCount = data.successCount || 0;\nconst failureCount = data.failureCount || 0;\n\nconst containerName = container.Name || container;\nconst current = currentIndex + 1;\n\nlet progressText = `Batch ${action} in progress...\\n\\n`;\nprogressText += `Current: ${containerName}\\n`;\nprogressText += `Progress: ${current}/${totalCount}\\n\\n`;\n\nif (successCount > 0 || failureCount > 0) {\n progressText += `Completed: ${successCount} success`;\n if (failureCount > 0) {\n progressText += `, ${failureCount} failed`;\n }\n}\n\nreturn {\n json: {\n ...data,\n progressText: progressText,\n containerName: containerName\n }\n};" }, "id": "code-build-progress", "name": "Build Progress Message", @@ -4714,7 +4722,7 @@ "conditions": [ { "id": "is-update", - "leftValue": "={{ $json.action }}", + "leftValue": "={{ $(\"Build Progress Message\").item.json.action }}", "rightValue": "update", "operator": { "type": "string", @@ -4737,7 +4745,7 @@ "conditions": [ { "id": "is-start", - "leftValue": "={{ $json.action }}", + "leftValue": "={{ $(\"Build Progress Message\").item.json.action }}", "rightValue": "start", "operator": { "type": "string", @@ -4760,7 +4768,7 @@ "conditions": [ { "id": "is-stop", - "leftValue": "={{ $json.action }}", + "leftValue": "={{ $(\"Build Progress Message\").item.json.action }}", "rightValue": "stop", "operator": { "type": "string", @@ -4783,7 +4791,7 @@ "conditions": [ { "id": "is-restart", - "leftValue": "={{ $json.action }}", + "leftValue": "={{ $(\"Build Progress Message\").item.json.action }}", "rightValue": "restart", "operator": { "type": "string", @@ -4813,7 +4821,7 @@ }, { "parameters": { - "jsCode": "// Get container ID for the action\nconst data = $json;\nconst container = data.container;\nconst action = data.action;\n\n// Container might have Id directly or just Name\nconst containerId = container.Id || null;\nconst containerName = container.Name || container;\n\n// Build the curl command for start/stop/restart\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\nlet cmd;\nif (containerId) {\n cmd = `curl -s -o /dev/null -w \"%{http_code}\" --max-time 30 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/${action}${timeout}'`;\n} else {\n // Need to find container by name first - use filters\n cmd = `curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/json?all=true&filters=%7B%22name%22%3A%5B%22${containerName}%22%5D%7D'`;\n}\n\nreturn {\n json: {\n ...data,\n cmd: cmd,\n needsLookup: !containerId,\n containerId: containerId\n }\n};" + "jsCode": "// Get container ID for the action\nconst data = $(\"Build Progress Message\").item.json;\nconst container = data.container;\nconst action = data.action;\n\n// Container might have Id directly or just Name\nconst containerId = container.Id || null;\nconst containerName = container.Name || container;\n\n// Build the curl command for start/stop/restart\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\nlet cmd;\nif (containerId) {\n cmd = `curl -s -o /dev/null -w \"%{http_code}\" --max-time 30 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/${action}${timeout}'`;\n} else {\n // Need to find container by name first - use filters\n cmd = `curl -s --max-time 5 'http://docker-socket-proxy:2375/v1.47/containers/json?all=true&filters=%7B%22name%22%3A%5B%22${containerName}%22%5D%7D'`;\n}\n\nreturn {\n json: {\n ...data,\n cmd: cmd,\n needsLookup: !containerId,\n containerId: containerId\n }\n};" }, "id": "code-build-batch-action-cmd", "name": "Build Batch Action Command", @@ -4927,7 +4935,7 @@ }, { "parameters": { - "jsCode": "// Prepare data for next iteration or completion\nconst data = $json;\n\n// Check if more containers to process\nconst containerIndex = data.containerIndex;\nconst totalCount = data.totalCount;\nconst isLastContainer = (containerIndex + 1) >= totalCount;\n\nreturn {\n json: {\n ...data,\n isComplete: isLastContainer\n }\n};" + "jsCode": "// Prepare data for next iteration or completion\nconst data = $json;\n\n// Check if more containers to process\nconst currentIndex = data.currentIndex;\nconst totalCount = data.totalCount;\nconst containers = data.containers;\nconst nextIndex = currentIndex + 1;\nconst isComplete = nextIndex >= totalCount;\n\nreturn {\n json: {\n ...data,\n currentIndex: nextIndex,\n container: isComplete ? null : containers[nextIndex],\n isComplete: isComplete\n }\n};" }, "id": "code-prepare-next-iteration", "name": "Prepare Next Iteration", @@ -4972,7 +4980,7 @@ }, { "parameters": { - "jsCode": "// Prepare batch stop data for execution\n// Input from Check Batch Stop Expired (not expired)\nconst data = $json;\n\n// containerNames is a comma-separated array from callback\nconst containerNames = data.containerNames || [];\n\nreturn {\n json: {\n allMatched: containerNames.map(name => ({ Name: name, Id: null })),\n action: 'stop',\n chatId: data.chatId,\n messageId: data.messageId\n }\n};" + "jsCode": "// Prepare batch stop data for execution\n// Input from Parse Callback Data (via Check Batch Stop Expired)\nconst data = $(\"Parse Callback Data\").item.json;\n\n// containerNames is an array from callback\nconst containerNames = data.containerNames || [];\n\nreturn {\n json: {\n allMatched: containerNames.map(name => ({ Name: name, Id: null })),\n action: 'stop',\n chatId: data.chatId,\n messageId: data.messageId\n }\n};" }, "id": "code-prepare-batch-stop-exec", "name": "Prepare Batch Stop Exec", @@ -5354,7 +5362,8 @@ }, { "parameters": { - "url": "http://docker-socket-proxy:2375/containers/json?all=false", + "method": "GET", + "url": "=http://docker-socket-proxy:2375/containers/json?all=false", "options": {} }, "id": "http-fetch-containers-batch-mode", @@ -5368,7 +5377,7 @@ }, { "parameters": { - "jsCode": "// Build batch selection keyboard with toggle checkmarks\nconst containers = $input.all();\nconst chatId = $json.chatId;\nconst messageId = $json.messageId;\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Limit to 8 containers to avoid callback_data overflow\nconst maxContainers = 8;\nconst displayContainers = sortedContainers.slice(0, maxContainers);\n\n// Build keyboard (no selection yet)\nconst keyboard = displayContainers.map(c => {\n const icon = c.state === 'running' ? '\ud83d\udfe2' : '\u26aa';\n return [\n {\n text: `${icon} ${c.name}`,\n callback_data: `batch:toggle::${c.name}`\n }\n ];\n});\n\n// Add action buttons (disabled initially)\nkeyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n]);\n\nconst message = `Select containers for batch action:\\n(Tap to select/deselect)`;\n\nreturn {\n json: {\n chatId: chatId,\n messageId: messageId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n containers: displayContainers\n }\n};" + "jsCode": "// Build batch selection keyboard with toggle checkmarks\nconst containers = $input.all();\nconst callbackData = $(\"Parse Callback Data\").item.json;\nconst chatId = callbackData.chatId;\nconst messageId = callbackData.messageId;\nconst queryId = callbackData.queryId;\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Limit to 8 containers to avoid callback_data overflow\nconst maxContainers = 8;\nconst displayContainers = sortedContainers.slice(0, maxContainers);\n\n// Build keyboard (no selection yet)\nconst keyboard = displayContainers.map(c => {\n const icon = c.state === 'running' ? '\ud83d\udfe2' : '\u26aa';\n return [\n {\n text: `${icon} ${c.name}`,\n callback_data: `batch:toggle::${c.name}`\n }\n ];\n});\n\n// Add action buttons (disabled initially)\nkeyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n]);\n\nconst message = `Select containers for batch action:\\n(Tap to select/deselect)`;\n\nreturn {\n json: {\n chatId: chatId,\n messageId: messageId,\n queryId: queryId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n containers: displayContainers\n }\n};" }, "id": "code-build-batch-select-keyboard", "name": "Build Batch Select Keyboard", @@ -5403,29 +5412,21 @@ }, { "parameters": { - "resource": "message", - "operation": "editMessageText", - "chatId": "={{ $json.chatId }}", - "messageId": "={{ $json.messageId }}", - "text": "={{ $json.message }}", - "replyMarkup": "inlineKeyboard", - "inlineKeyboard": "={{ JSON.stringify($json.keyboard) }}", + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $(\"Build Batch Select Keyboard\").item.json.chatId, message_id: $(\"Build Batch Select Keyboard\").item.json.messageId, text: $(\"Build Batch Select Keyboard\").item.json.message, parse_mode: 'HTML', reply_markup: $(\"Build Batch Select Keyboard\").item.json.keyboard }) }}", "options": {} }, "id": "telegram-edit-batch-select-keyboard", "name": "Edit To Batch Select Keyboard", - "type": "n8n-nodes-base.telegram", - "typeVersion": 1.2, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, "position": [ 2400, 3500 - ], - "credentials": { - "telegramApi": { - "id": "I0xTTiASl7C1NZhJ", - "name": "Telegram account" - } - } + ] }, { "parameters": { @@ -5498,7 +5499,8 @@ }, { "parameters": { - "url": "http://docker-socket-proxy:2375/containers/json?all=false", + "method": "GET", + "url": "=http://docker-socket-proxy:2375/containers/json?all=false", "options": {} }, "id": "http-fetch-containers-toggle", @@ -5512,7 +5514,7 @@ }, { "parameters": { - "jsCode": "// Rebuild batch selection keyboard with updated checkmarks\nconst containers = $input.all();\nconst selectedCsv = $json.selectedCsv || '';\nconst selectedCount = $json.selectedCount || 0;\nconst chatId = $json.chatId;\nconst messageId = $json.messageId;\nconst queryId = $json.queryId;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort and format\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n })\n .slice(0, 8);\n\n// Build keyboard with checkmarks\nconst keyboard = sortedContainers.map(c => {\n const isSelected = selectedSet.has(c.name);\n const icon = c.state === 'running' ? '\ud83d\udfe2' : '\u26aa';\n const checkmark = isSelected ? '\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `batch:toggle:${selectedCsv}:${c.name}`\n }\n ];\n});\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Update (${selectedCount})`, callback_data: `batch:exec:update:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst message = selectedCount > 0 \n ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\n(Tap to select/deselect)';\n\nreturn {\n json: {\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n selectedCsv: selectedCsv\n }\n};" + "jsCode": "// Rebuild batch selection keyboard with updated checkmarks\nconst containers = $input.all();\nconst toggleData = $(\"Handle Batch Toggle\").item.json;\nconst selectedCsv = toggleData.selectedCsv || '';\nconst selectedCount = toggleData.selectedCount || 0;\nconst chatId = toggleData.chatId;\nconst messageId = toggleData.messageId;\nconst queryId = toggleData.queryId;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort and format\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n })\n .slice(0, 8);\n\n// Build keyboard with checkmarks\nconst keyboard = sortedContainers.map(c => {\n const isSelected = selectedSet.has(c.name);\n const icon = c.state === 'running' ? '\ud83d\udfe2' : '\u26aa';\n const checkmark = isSelected ? '\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `batch:toggle:${selectedCsv}:${c.name}`\n }\n ];\n});\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Update (${selectedCount})`, callback_data: `batch:exec:update:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst message = selectedCount > 0 \n ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\n(Tap to select/deselect)';\n\nreturn {\n json: {\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n selectedCsv: selectedCsv\n }\n};" }, "id": "code-rebuild-batch-select-keyboard", "name": "Rebuild Batch Select Keyboard", @@ -5546,29 +5548,21 @@ }, { "parameters": { - "resource": "message", - "operation": "editMessageText", - "chatId": "={{ $json.chatId }}", - "messageId": "={{ $json.messageId }}", - "text": "={{ $json.message }}", - "replyMarkup": "inlineKeyboard", - "inlineKeyboard": "={{ JSON.stringify($json.keyboard) }}", + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $(\"Rebuild Batch Select Keyboard\").item.json.chatId, message_id: $(\"Rebuild Batch Select Keyboard\").item.json.messageId, text: $(\"Rebuild Batch Select Keyboard\").item.json.message, parse_mode: 'HTML', reply_markup: $(\"Rebuild Batch Select Keyboard\").item.json.keyboard }) }}", "options": {} }, "id": "telegram-edit-batch-keyboard", "name": "Edit Batch Select Keyboard", - "type": "n8n-nodes-base.telegram", - "typeVersion": 1.2, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, "position": [ 2800, 3800 - ], - "credentials": { - "telegramApi": { - "id": "I0xTTiASl7C1NZhJ", - "name": "Telegram account" - } - } + ] }, { "parameters": { @@ -5616,7 +5610,7 @@ }, { "parameters": { - "jsCode": "// Build stop confirmation for batch selection\nconst containerNames = $json.containerNames;\nconst count = $json.count;\nconst chatId = $json.chatId;\nconst selectedCsv = $json.selectedCsv;\n\nconst timestamp = Math.floor(Date.now() / 1000);\n\nconst message = `Stop ${count} container${count !== 1 ? 's' : ''}?\\n\\n${containerNames.map(n => '\u2022 ' + n).join('\\n')}`;\n\nreturn {\n json: {\n chatId: chatId,\n messageId: $json.messageId,\n queryId: $json.queryId,\n message: message,\n keyboard: {\n inline_keyboard: [\n [\n { text: '\u2705 Confirm Stop', callback_data: `bstop:${selectedCsv}:${timestamp}` },\n { text: '\u274c Cancel', callback_data: 'batch:cancel' }\n ]\n ]\n }\n }\n};" + "jsCode": "// Build stop confirmation for batch selection\nconst containerNames = $json.containerNames;\nconst count = $json.count;\nconst chatId = $json.chatId;\nconst selectedCsv = $json.selectedCsv;\n\nconst timestamp = Math.floor(Date.now() / 1000);\n\nconst message = `Stop ${count} container${count !== 1 ? 's' : ''}?\\n\\n${containerNames.map(n => '\u2022 ' + n).join('\\n')}`;\n\nreturn {\n json: {\n chatId: chatId,\n messageId: $json.messageId,\n queryId: $json.queryId,\n message: message,\n keyboard: {\n inline_keyboard: [\n [\n { text: '\u2705 Confirm Stop', callback_data: `bstop:confirm:${selectedCsv}:${timestamp}` },\n { text: '\u274c Cancel', callback_data: 'batch:cancel' }\n ]\n ]\n }\n }\n};" }, "id": "code-build-batch-select-stop-confirm", "name": "Build Batch Select Stop Confirmation", @@ -5651,29 +5645,21 @@ }, { "parameters": { - "resource": "message", - "operation": "editMessageText", - "chatId": "={{ $json.chatId }}", - "messageId": "={{ $json.messageId }}", - "text": "={{ $json.message }}", - "replyMarkup": "inlineKeyboard", - "inlineKeyboard": "={{ JSON.stringify($json.keyboard) }}", + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $(\"Build Batch Select Stop Confirmation\").item.json.chatId, message_id: $(\"Build Batch Select Stop Confirmation\").item.json.messageId, text: $(\"Build Batch Select Stop Confirmation\").item.json.message, parse_mode: 'HTML', reply_markup: $(\"Build Batch Select Stop Confirmation\").item.json.keyboard }) }}", "options": {} }, "id": "telegram-edit-stop-confirmation", "name": "Edit To Stop Confirmation", - "type": "n8n-nodes-base.telegram", - "typeVersion": 1.2, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, "position": [ 2800, 4300 - ], - "credentials": { - "telegramApi": { - "id": "I0xTTiASl7C1NZhJ", - "name": "Telegram account" - } - } + ] }, { "parameters": { @@ -5871,6 +5857,20 @@ "index": 0 } ], + [ + { + "node": "Handle Cancel", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Handle Expired", + "type": "main", + "index": 0 + } + ], [ { "node": "Build Batch Commands", @@ -5920,6 +5920,20 @@ "index": 0 } ], + [ + { + "node": "Answer Batch Stop Confirm", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Answer Batch Stop Cancel", + "type": "main", + "index": 0 + } + ], [ { "node": "Build Callback Action", @@ -5961,17 +5975,7 @@ "type": "main", "index": 0 } - ], - [], - [], - [], - [], - [], - [], - [], - [], - [], - [] + ] ] }, "Answer Select Callback": { @@ -7842,7 +7846,7 @@ "main": [ [ { - "node": "Batch Loop", + "node": "Build Progress Message", "type": "main", "index": 0 } @@ -7851,13 +7855,7 @@ }, "Batch Loop": { "main": [ - [ - { - "node": "Build Progress Message", - "type": "main", - "index": 0 - } - ] + [] ] }, "Build Progress Message": { @@ -8014,7 +8012,7 @@ ], [ { - "node": "Batch Loop", + "node": "Build Progress Message", "type": "main", "index": 0 }