diff --git a/n8n-status.json b/n8n-status.json
index 91cb5c5..1dc64ee 100644
--- a/n8n-status.json
+++ b/n8n-status.json
@@ -173,7 +173,7 @@
},
{
"parameters": {
- "jsCode": "// Build Paginated Container List Keyboard\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare List Request\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst page = prevData.page || 0;\nconst searchTerm = prevData.searchTerm;\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// If specific container requested via search term, check for exact/single match\nif (searchTerm) {\n const matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(searchTerm.toLowerCase());\n });\n \n if (matches.length === 1) {\n const container = matches[0];\n const containerName = normalizeName(container.Names[0]);\n const state = container.State;\n const status = container.Status;\n const image = container.Image;\n \n // Build action keyboard based on container state\n const keyboard = [];\n if (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 keyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ]);\n keyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n ]);\n \n // Build status text\n const stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n let text = `${stateIcon} ${containerName}\\n\\n`;\n text += `State: ${state}\\n`;\n text += `Status: ${status}\\n`;\n text += `Image: ${image}`;\n \n return [{\n json: {\n success: true,\n action: 'status_direct',\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard },\n container: { id: container.Id, name: containerName, state, status, image }\n }\n }];\n } else if (matches.length === 0) {\n return [{\n json: {\n success: false,\n action: 'list',\n chatId,\n messageId,\n error: `No container found matching \"${searchTerm}\"`,\n text: `No container found matching \"${searchTerm}\"`\n }\n }];\n }\n // Multiple matches - continue to show list\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'; // 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 success: true,\n action: 'list',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
+ "jsCode": "// Build Paginated Container List Keyboard\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare List Request\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst page = prevData.page || 0;\nconst searchTerm = prevData.searchTerm;\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// If specific container requested via search term, check for exact/single match\nif (searchTerm) {\n const matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(searchTerm.toLowerCase());\n });\n \n if (matches.length === 1) {\n const container = matches[0];\n const containerName = normalizeName(container.Names[0]);\n const state = container.State;\n const status = container.Status;\n const image = container.Image;\n \n // Build action keyboard based on container state\n const keyboard = [];\n if (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 keyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ]);\n keyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n ]);\n \n // Build status text\n const stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n let text = `${stateIcon} ${containerName}\\n\\n`;\n text += `State: ${state}\\n`;\n text += `Status: ${status}\\n`;\n text += `Image: ${image}`;\n \n return [{\n json: {\n success: true,\n action: 'status_direct',\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard },\n container: { id: container.Id, name: containerName, state, status, image }\n }\n }];\n } else if (matches.length === 0) {\n return [{\n json: {\n success: false,\n action: 'list',\n chatId,\n messageId,\n error: `No container found matching \"${searchTerm}\"`,\n text: `No container found matching \"${searchTerm}\"`\n }\n }];\n }\n // Multiple matches - continue to show list\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'; // 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 Update All button\nkeyboard.push([{\n text: '\\u{1F504} Update All :latest',\n callback_data: 'uall:start'\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 success: true,\n action: 'list',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
},
"id": "status-build-list",
"name": "Build Container List",
@@ -255,7 +255,7 @@
},
{
"parameters": {
- "jsCode": "// Build Paginated Container List Keyboard for pagination callbacks\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Paginate Request\").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 success: true,\n action: 'paginate',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
+ "jsCode": "// Build Paginated Container List Keyboard for pagination callbacks\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Paginate Request\").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 Update All button\nkeyboard.push([{\n text: '\\u{1F504} Update All :latest',\n callback_data: 'uall:start'\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 success: true,\n action: 'paginate',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
},
"id": "status-build-paginated",
"name": "Build Paginated List",
diff --git a/n8n-workflow.json b/n8n-workflow.json
index 937c764..9cb1178 100644
--- a/n8n-workflow.json
+++ b/n8n-workflow.json
@@ -586,7 +586,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} or bstop:confirm:{names}:{timestamp}:kb\n const parts = rest.split(':'); // confirm, names, timestamp[, kb]\n if (parts[0] === 'confirm' && parts.length >= 3) {\n // Check for :kb marker (indicates inline keyboard origin)\n const fromKeyboard = parts[parts.length - 1] === 'kb';\n const timestampIdx = fromKeyboard ? parts.length - 2 : parts.length - 1;\n const timestamp = parseInt(parts[timestampIdx]);\n const namesStr = parts.slice(1, timestampIdx).join(':'); // Handle container names with colons\n // Detect if bitmap format (no commas, alphanumeric base36)\n const isBitmap = !namesStr.includes(',') && /^[0-9a-z]+$/i.test(namesStr);\n const containerNames = isBitmap ? [] : namesStr.split(',');\n const bitmap = isBitmap ? namesStr : null;\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 fromKeyboard: fromKeyboard,\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\n\n// NEW: Bitmap-encoded batch toggle: b:{page}:{bitmap}:{containerIndex}\nif (rawData.startsWith('b:') && !rawData.startsWith('batch:') && !rawData.startsWith('bstop:') && !rawData.startsWith('bexec:') && !rawData.startsWith('bn:') && !rawData.startsWith('be:')) {\n const parts = rawData.substring(2).split(':');\n const page = parseInt(parts[0]) || 0;\n const bitmap = parts[1] || '0';\n const containerIndex = parseInt(parts[2]);\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchToggle: true,\n batchPage: page,\n bitmap: bitmap,\n containerIndex: containerIndex,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\n// NEW: Bitmap-encoded batch nav: bn:{bitmap}:{page}\nif (rawData.startsWith('bn:')) {\n const parts = rawData.substring(3).split(':');\n const bitmap = parts[0] || '0';\n const page = parseInt(parts[1]) || 0;\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchNav: true,\n batchPage: page,\n bitmap: bitmap,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\n// NEW: Bitmap-encoded batch exec: be:{action}:{bitmap}\nif (rawData.startsWith('be:')) {\n const parts = rawData.substring(3).split(':');\n const action = parts[0];\n const bitmap = parts[1] || '0';\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchExec: true,\n action: action,\n bitmap: bitmap,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\nif (rawData === 'batch:mode' || rawData.startsWith('batch:mode:')) {\n // Enter batch selection mode with optional page: batch:mode or batch:mode:{page}\n let page = 0;\n if (rawData.startsWith('batch:mode:')) {\n page = parseInt(rawData.substring(11)) || 0;\n }\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n batchPage: page,\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:nav:')) {\n // Navigate batch selection with preserved selection: batch:nav:{page}:{selected_csv}\n const parts = rawData.substring(10).split(':'); // Remove 'batch:nav:'\n const page = parseInt(parts[0]) || 0;\n const selectedCsv = parts.slice(1).join(':') || '';\n const selectedCount = selectedCsv ? selectedCsv.split(',').length : 0;\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchNav: true,\n batchPage: page,\n selectedCsv: selectedCsv,\n bitmap: '0', // Legacy format, empty bitmap\n selectedCount: selectedCount,\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:{page}:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const page = parseInt(parts[0]) || 0; // Current page\n const selectedCsv = parts[1] || ''; // Currently selected (comma-separated)\n const toggleName = parts.slice(2).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n batchPage: page,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n bitmap: '0', // Legacy format, empty bitmap\n toggleName: toggleName,\n containerIndex: 0, // Legacy format, no index\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 bitmap: '0', // Legacy format, empty bitmap\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: false,\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} or bstop:confirm:{names}:{timestamp}:kb\n const parts = rest.split(':'); // confirm, names, timestamp[, kb]\n if (parts[0] === 'confirm' && parts.length >= 3) {\n // Check for :kb marker (indicates inline keyboard origin)\n const fromKeyboard = parts[parts.length - 1] === 'kb';\n const timestampIdx = fromKeyboard ? parts.length - 2 : parts.length - 1;\n const timestamp = parseInt(parts[timestampIdx]);\n const namesStr = parts.slice(1, timestampIdx).join(':'); // Handle container names with colons\n // Detect if bitmap format (no commas, alphanumeric base36)\n const isBitmap = !namesStr.includes(',') && /^[0-9a-z]+$/i.test(namesStr);\n const containerNames = isBitmap ? [] : namesStr.split(',');\n const bitmap = isBitmap ? namesStr : null;\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 fromKeyboard: fromKeyboard,\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\n\n// NEW: Bitmap-encoded batch toggle: b:{page}:{bitmap}:{containerIndex}\nif (rawData.startsWith('b:') && !rawData.startsWith('batch:') && !rawData.startsWith('bstop:') && !rawData.startsWith('bexec:') && !rawData.startsWith('bn:') && !rawData.startsWith('be:')) {\n const parts = rawData.substring(2).split(':');\n const page = parseInt(parts[0]) || 0;\n const bitmap = parts[1] || '0';\n const containerIndex = parseInt(parts[2]);\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchToggle: true,\n batchPage: page,\n bitmap: bitmap,\n containerIndex: containerIndex,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\n// NEW: Bitmap-encoded batch nav: bn:{bitmap}:{page}\nif (rawData.startsWith('bn:')) {\n const parts = rawData.substring(3).split(':');\n const bitmap = parts[0] || '0';\n const page = parseInt(parts[1]) || 0;\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchNav: true,\n batchPage: page,\n bitmap: bitmap,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\n// NEW: Bitmap-encoded batch exec: be:{action}:{bitmap}\nif (rawData.startsWith('be:')) {\n const parts = rawData.substring(3).split(':');\n const action = parts[0];\n const bitmap = parts[1] || '0';\n\n return {\n json: {\n queryId, chatId, messageId,\n isBatchExec: true,\n action: action,\n bitmap: bitmap,\n isSelect: false, isList: false, isAction: false,\n isCancel: false, isBatch: false, expired: false\n }\n };\n}\n\nif (rawData === 'batch:mode' || rawData.startsWith('batch:mode:')) {\n // Enter batch selection mode with optional page: batch:mode or batch:mode:{page}\n let page = 0;\n if (rawData.startsWith('batch:mode:')) {\n page = parseInt(rawData.substring(11)) || 0;\n }\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n batchPage: page,\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:nav:')) {\n // Navigate batch selection with preserved selection: batch:nav:{page}:{selected_csv}\n const parts = rawData.substring(10).split(':'); // Remove 'batch:nav:'\n const page = parseInt(parts[0]) || 0;\n const selectedCsv = parts.slice(1).join(':') || '';\n const selectedCount = selectedCsv ? selectedCsv.split(',').length : 0;\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchNav: true,\n batchPage: page,\n selectedCsv: selectedCsv,\n bitmap: '0', // Legacy format, empty bitmap\n selectedCount: selectedCount,\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:{page}:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const page = parseInt(parts[0]) || 0; // Current page\n const selectedCsv = parts[1] || ''; // Currently selected (comma-separated)\n const toggleName = parts.slice(2).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n batchPage: page,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n bitmap: '0', // Legacy format, empty bitmap\n toggleName: toggleName,\n containerIndex: 0, // Legacy format, no index\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 bitmap: '0', // Legacy format, empty bitmap\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: false,\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:start') {\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAllStart: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n isConfirm: false,\n expired: 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",
@@ -649,6 +649,30 @@
"renameOutput": true,
"outputKey": "updateallcancel"
},
+ {
+ "id": "is-update-all-start",
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "update-all-start-true",
+ "leftValue": "={{ $json.isUpdateAllStart }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "renameOutput": true,
+ "outputKey": "updateallstart"
+ },
{
"id": "is-cancel",
"conditions": {
@@ -2763,7 +2787,7 @@
},
{
"parameters": {
- "jsCode": "// Check which containers have available updates\n// Focus on containers using :latest tag for practical performance\n\nconst containers = $input.all();\nconst chatId = $('Keyword Router').first().json.message.chat.id;\nconst messageId = $('Keyword Router').first().json.message.message_id;\n\n// Extract container data from API response\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// Filter to containers using :latest tag\n// For now, we'll mark all :latest containers as \"potentially need update\"\n// A full implementation would pull each image, but that's expensive\n// We'll show all :latest containers and let batch execution handle the actual check\n\nconst containersToCheck = allContainers\n .filter(c => {\n const image = c.Image || '';\n // Check if using :latest or no tag specified (defaults to :latest)\n return image.includes(':latest') || !image.includes(':');\n })\n .map(c => ({\n id: c.Id.substring(0, 12),\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n image: c.Image,\n state: c.State\n }));\n\nreturn {\n json: {\n containersToUpdate: containersToCheck,\n count: containersToCheck.length,\n chatId: chatId,\n messageId: messageId,\n allContainers: allContainers\n }\n};"
+ "jsCode": "// Check which containers have available updates\n// Focus on containers using :latest tag for practical performance\n\nconst containers = $input.all();\n\n// Get chatId and messageId from either text command or callback origin\nlet chatId, messageId;\ntry {\n const kwData = $('Keyword Router').first().json;\n chatId = kwData.message.chat.id;\n messageId = kwData.message.message_id;\n} catch(e) {\n // Inline keyboard origin -- data from Parse Callback Data\n const cbData = $('Parse Callback Data').first().json;\n chatId = cbData.chatId;\n messageId = cbData.messageId;\n}\n\n// Extract container data from API response\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// Filter to containers using :latest tag\n// For now, we'll mark all :latest containers as \"potentially need update\"\n// A full implementation would pull each image, but that's expensive\n// We'll show all :latest containers and let batch execution handle the actual check\n\nconst containersToCheck = allContainers\n .filter(c => {\n const image = c.Image || '';\n // Check if using :latest or no tag specified (defaults to :latest)\n return image.includes(':latest') || !image.includes(':');\n })\n .map(c => ({\n id: c.Id.substring(0, 12),\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n image: c.Image,\n state: c.State\n }));\n\nreturn {\n json: {\n containersToUpdate: containersToCheck,\n count: containersToCheck.length,\n chatId: chatId,\n messageId: messageId,\n allContainers: allContainers\n }\n};"
},
"id": "code-check-available-updates",
"name": "Check Available Updates",
@@ -4850,6 +4874,29 @@
2220,
700
]
+ },
+ {
+ "parameters": {
+ "operation": "callback",
+ "callbackQueryId": "={{ $json.queryId }}",
+ "additionalFields": {
+ "text": "Checking for updates..."
+ }
+ },
+ "id": "answer-update-all-start",
+ "name": "Answer Update All Start",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [
+ 1340,
+ 700
+ ],
+ "credentials": {
+ "telegramApi": {
+ "id": "I0xTTiASl7C1NZhJ",
+ "name": "Telegram account"
+ }
+ }
}
],
"connections": {
@@ -4931,6 +4978,13 @@
"index": 0
}
],
+ [
+ {
+ "node": "Answer Update All Start",
+ "type": "main",
+ "index": 0
+ }
+ ],
[
{
"node": "Handle Cancel",
@@ -6768,6 +6822,17 @@
}
]
]
+ },
+ "Answer Update All Start": {
+ "main": [
+ [
+ {
+ "node": "Get All Containers For Update All",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
}
},
"pinData": {},