feat(08-01): add container submenu with action buttons

- Update Parse Callback Data to recognize select:, list:, action:, noop formats
- Add Route Callback outputs: select, list, action, noop
- Add Answer Select Callback HTTP node (prevents loading indicator)
- Add Prepare Container Fetch code node
- Add Get Single Container HTTP Request node
- Add Build Container Submenu code node with state-based action buttons
- Add Send Container Submenu HTTP node (editMessageText for in-place updates)
- Add Answer Noop Callback for page indicator button
- Wire complete select flow: Route -> Answer -> Fetch -> Submenu -> Send
This commit is contained in:
Lucas Berger
2026-02-03 16:17:38 -05:00
parent f8d616e26d
commit 01482827fb
+270 -1
View File
@@ -782,7 +782,7 @@
}, },
{ {
"parameters": { "parameters": {
"jsCode": "// Parse callback data from button click (single suggestion or batch)\nconst callback = $json.callback_query;\nlet data;\ntry {\n data = JSON.parse(callback.data);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\n\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\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, // Array for batch support\n containerId: containerIds[0] || null, // For single-container compat\n expired: isExpired,\n isBatch,\n isCancel: action === 'cancel'\n }\n};" "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, and legacy JSON format\nconst callback = $json.callback_query;\nconst rawData = callback.data;\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\n\n// Check for new colon-separated format\nif (rawData.startsWith('select:')) {\n // Container selection: select:{containerName}\n const containerName = rawData.substring(7); // Remove 'select:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isSelect: true,\n containerName,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('list:')) {\n // Pagination: list:{page}\n const page = parseInt(rawData.substring(5)) || 0; // Remove 'list:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isList: true,\n page,\n isSelect: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('action:')) {\n // Action execution: action:{action}:{containerName}\n const parts = rawData.substring(7).split(':'); // Remove 'action:'\n const action = parts[0];\n const containerName = parts.slice(1).join(':'); // Handle names with colons\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isAction: true,\n action,\n containerName,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'noop') {\n // No-op button (like page indicator)\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isNoop: true,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n// Legacy JSON format for backward compatibility\nlet data;\ntry {\n data = JSON.parse(rawData);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\n\n// Check 2-minute timeout (120000ms)\nconst TWO_MINUTES = 120000;\nconst isExpired = data.t && (Date.now() - data.t > TWO_MINUTES);\n\n// Decode action: s=start, t=stop, r=restart, x=cancel\nconst actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' };\nconst action = actionMap[data.a] || 'cancel';\n\n// Detect batch (c is array) vs single suggestion (c is string)\nconst isBatch = Array.isArray(data.c);\nconst containerIds = isBatch ? data.c : (data.c ? [data.c] : []);\n\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n action,\n containerIds,\n containerId: containerIds[0] || null,\n expired: isExpired,\n isBatch,\n isCancel: action === 'cancel',\n isSelect: false,\n isList: false,\n isAction: false\n }\n};"
}, },
"id": "code-parse-callback", "id": "code-parse-callback",
"name": "Parse Callback Data", "name": "Parse Callback Data",
@@ -868,6 +868,102 @@
}, },
"renameOutput": true, "renameOutput": true,
"outputKey": "batch" "outputKey": "batch"
},
{
"id": "is-select",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "select-true",
"leftValue": "={{ $json.isSelect }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "select"
},
{
"id": "is-list",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "list-true",
"leftValue": "={{ $json.isList }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "list"
},
{
"id": "is-action",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "action-true",
"leftValue": "={{ $json.isAction }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "action"
},
{
"id": "is-noop",
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "noop-true",
"leftValue": "={{ $json.isNoop }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "noop"
} }
] ]
}, },
@@ -2361,6 +2457,119 @@
"name": "Telegram account" "name": "Telegram account"
} }
} }
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}",
"options": {}
},
"id": "http-answer-select-callback",
"name": "Answer Select Callback",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1340,
900
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"jsCode": "// Prepare container fetch for submenu\nconst data = $input.item.json;\nreturn {\n json: {\n queryId: data.queryId,\n chatId: data.chatId,\n messageId: data.messageId,\n containerName: data.containerName\n }\n};"
},
"id": "code-prepare-container-fetch",
"name": "Prepare Container Fetch",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1560,
900
]
},
{
"parameters": {
"method": "GET",
"url": "=http://docker-socket-proxy:2375/containers/json?all=true",
"options": {}
},
"id": "http-get-single-container",
"name": "Get Single Container",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1780,
900
]
},
{
"parameters": {
"jsCode": "// Build Container Submenu for callback selection\nconst containers = $input.item.json;\nconst prevData = $('Prepare Container Fetch').item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst searchName = prevData.containerName.toLowerCase();\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === searchName);\n\nif (!container) {\n return [{\n json: {\n chatId,\n messageId,\n text: `Container \"${searchName}\" not found`,\n reply_markup: { inline_keyboard: [[{ text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }]] }\n }\n }];\n}\n\nconst containerName = normalizeName(container.Names[0]);\nconst state = container.State;\nconst status = container.Status;\nconst image = container.Image;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n]);\n\n// Build status text\nconst stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} <b>${containerName}</b>\\n\\n`;\ntext += `<b>State:</b> ${state}\\n`;\ntext += `<b>Status:</b> ${status}\\n`;\ntext += `<b>Image:</b> ${image}`;\n\nreturn [{\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n}];"
},
"id": "code-build-container-submenu",
"name": "Build Container Submenu",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2000,
900
]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/editMessageText",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}",
"options": {}
},
"id": "http-send-container-submenu",
"name": "Send Container Submenu",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2220,
900
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}",
"options": {}
},
"id": "http-answer-noop-callback",
"name": "Answer Noop Callback",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1340,
1100
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
} }
], ],
"connections": { "connections": {
@@ -2451,6 +2660,22 @@
"index": 0 "index": 0
} }
], ],
[
{
"node": "Answer Select Callback",
"type": "main",
"index": 0
}
],
[],
[],
[
{
"node": "Answer Noop Callback",
"type": "main",
"index": 0
}
],
[ [
{ {
"node": "Build Callback Action", "node": "Build Callback Action",
@@ -2460,6 +2685,50 @@
] ]
] ]
}, },
"Answer Select Callback": {
"main": [
[
{
"node": "Prepare Container Fetch",
"type": "main",
"index": 0
}
]
]
},
"Prepare Container Fetch": {
"main": [
[
{
"node": "Get Single Container",
"type": "main",
"index": 0
}
]
]
},
"Get Single Container": {
"main": [
[
{
"node": "Build Container Submenu",
"type": "main",
"index": 0
}
]
]
},
"Build Container Submenu": {
"main": [
[
{
"node": "Send Container Submenu",
"type": "main",
"index": 0
}
]
]
},
"Handle Cancel": { "Handle Cancel": {
"main": [ "main": [
[ [