From f8d616e26d39b971099b6a1656ddc6e410cc7991 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 3 Feb 2026 16:15:29 -0500 Subject: [PATCH] feat(08-01): add container list inline keyboard - Add Build Container List Keyboard code node - Add Send Container List HTTP Request node - Add Check Single Container IF node for direct access routing - Add Build Container Submenu Direct for /status flow - Add Send Container Submenu Direct HTTP Request - Wire Keyword Router status -> Docker List -> Build Keyboard flow - Running containers shown first with green icon - Pagination support for >6 containers --- n8n-workflow.json | 148 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index 916fa8d..e5e7802 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -2255,6 +2255,112 @@ "name": "Telegram account" } } + }, + { + "parameters": { + "jsCode": "// Build Container List Keyboard for /status command\nconst dockerOutput = $input.item.json.stdout;\nconst message = $('Keyword Router').item.json.message;\nconst chatId = message.chat.id;\n\n// Parse JSON response\nlet containers;\ntry {\n if (!dockerOutput || dockerOutput.trim() === '') {\n throw new Error('Empty response');\n }\n containers = JSON.parse(dockerOutput);\n} catch (e) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: \"Cannot connect to Docker\",\n isSingleContainer: false\n }\n }];\n}\n\n// Function to normalize container names (strip prefixes)\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Check if user specified a container name (e.g., \"/status plex\")\nconst text = (message.text || '').toLowerCase().trim();\nlet requestedName = null;\nconst parts = text.split(/\\s+/);\nif (parts.length > 1) {\n requestedName = parts.filter(p => p !== 'status' && p !== '/status').join(' ');\n}\n\n// If specific container requested, route to submenu\nif (requestedName) {\n const matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(requestedName);\n });\n \n if (matches.length === 1) {\n const container = matches[0];\n return [{\n json: {\n isSingleContainer: true,\n chatId: chatId,\n containerName: normalizeName(container.Names[0]),\n containerId: container.Id,\n containerState: container.State,\n containerStatus: container.Status,\n containerImage: container.Image\n }\n }];\n } else if (matches.length === 0) {\n return [{\n json: {\n chatId: chatId,\n error: true,\n text: 'No container found matching \"' + requestedName + '\"',\n isSingleContainer: false\n }\n }];\n }\n // Multiple matches - show them all in keyboard below\n}\n\n// Build paginated container list keyboard\nconst page = 0; // Initial page\nconst containersPerPage = 6;\n\n// Group by state (running first)\nconst running = containers.filter(c => c.State === 'running');\nconst stopped = containers.filter(c => c.State !== 'running');\nconst sortedContainers = [...running, ...stopped];\n\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst pageContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard rows\nconst keyboard = [];\n\npageContainers.forEach(container => {\n const name = normalizeName(container.Names[0]);\n const stateIcon = container.State === 'running' ? '\\u{1F7E2}' : '\\u26AA'; // Green circle or white circle\n const stateText = container.State === 'running' ? 'Running' : 'Stopped';\n keyboard.push([{\n text: `${stateIcon} ${name} - ${stateText}`,\n callback_data: `select:${name}`\n }]);\n});\n\n// Add navigation row if needed\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `list:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `list:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Build header text\nconst runningCount = running.length;\nconst totalCount = sortedContainers.length;\nlet headerText = `\\u{1F5C2} Containers (${runningCount}/${totalCount} running)`;\nif (totalPages > 1) {\n headerText += `\\n\\nPage ${page + 1} of ${totalPages}`;\n}\nheaderText += '\\n\\nTap a container to manage it:';\n\nreturn [{\n json: {\n chatId: chatId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n isSingleContainer: false\n }\n}];" + }, + "id": "code-build-container-list-keyboard", + "name": "Build Container List Keyboard", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1340, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", + "options": {} + }, + "id": "http-send-container-list", + "name": "Send Container List", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1560, + 0 + ], + "credentials": { + "telegramApi": { + "id": "I0xTTiASl7C1NZhJ", + "name": "Telegram account" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "is-single-container", + "leftValue": "={{ $json.isSingleContainer }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-single-container", + "name": "Check Single Container", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1560, + -100 + ] + }, + { + "parameters": { + "jsCode": "// Build Container Submenu for direct access (/status plex) or callback selection\nconst data = $input.item.json;\nconst chatId = data.chatId;\nconst containerName = data.containerName;\nconst state = data.containerState;\nconst status = data.containerStatus;\nconst image = data.containerImage;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n]);\n\n// Build status text\nconst stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} ${containerName}\\n\\n`;\ntext += `State: ${state}\\n`;\ntext += `Status: ${status}\\n`;\ntext += `Image: ${image}`;\n\nreturn [{\n json: {\n chatId: chatId,\n text: text,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];" + }, + "id": "code-build-container-submenu-direct", + "name": "Build Container Submenu Direct", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1780, + -100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}", + "options": {} + }, + "id": "http-send-container-submenu-direct", + "name": "Send Container Submenu Direct", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + -100 + ], + "credentials": { + "telegramApi": { + "id": "I0xTTiASl7C1NZhJ", + "name": "Telegram account" + } + } } ], "connections": { @@ -2457,7 +2563,47 @@ "main": [ [ { - "node": "Parse and Match", + "node": "Build Container List Keyboard", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Container List Keyboard": { + "main": [ + [ + { + "node": "Check Single Container", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Single Container": { + "main": [ + [ + { + "node": "Build Container Submenu Direct", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Container List", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Container Submenu Direct": { + "main": [ + [ + { + "node": "Send Container Submenu Direct", "type": "main", "index": 0 }