feat(08-02): route action callbacks to container operations

- Add Answer Action Callback node to answer query immediately
- Add Route Action Type switch with start/restart/stop/update/logs outputs
- Wire start/restart to immediate action flow (Get Container, Build Command, Execute, Format Result)
- Wire logs to logs action flow with 30-line display
- Wire stop/update to confirmation dialog builders with 30s timeout
- All action results update message in-place with editMessageText

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-02-03 16:24:21 -05:00
parent 1a3feecd91
commit d1584197f8
+605 -1
View File
@@ -2659,6 +2659,422 @@
"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-action-callback",
"name": "Answer Action Callback",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1340,
1200
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"rules": {
"values": [
{
"id": "action-start",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-start",
"leftValue": "={{ $('Parse Callback Data').item.json.action }}",
"rightValue": "start",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "start"
},
{
"id": "action-restart",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-restart",
"leftValue": "={{ $('Parse Callback Data').item.json.action }}",
"rightValue": "restart",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "restart"
},
{
"id": "action-stop",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-stop",
"leftValue": "={{ $('Parse Callback Data').item.json.action }}",
"rightValue": "stop",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "stop"
},
{
"id": "action-update",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-update",
"leftValue": "={{ $('Parse Callback Data').item.json.action }}",
"rightValue": "update",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "update"
},
{
"id": "action-logs",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-logs",
"leftValue": "={{ $('Parse Callback Data').item.json.action }}",
"rightValue": "logs",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "logs"
}
]
},
"options": {
"fallbackOutput": "none"
}
},
"id": "switch-route-action-type",
"name": "Route Action Type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1560,
1200
]
},
{
"parameters": {
"jsCode": "// Build start/restart action command for inline keyboard\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst action = data.action; // 'start' or 'restart'\nconst chatId = data.chatId;\nconst messageId = data.messageId;\n\n// For restart, use ?t=10 for graceful timeout\nconst timeout = action === 'restart' ? '?t=10' : '';\n\nreturn {\n json: {\n containerName,\n action,\n chatId,\n messageId\n }\n};"
},
"id": "code-prepare-immediate-action",
"name": "Prepare Immediate Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1780,
1100
]
},
{
"parameters": {
"method": "GET",
"url": "=http://docker-socket-proxy:2375/containers/json?all=true",
"options": {}
},
"id": "http-get-container-for-action",
"name": "Get Container For Action",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2000,
1100
]
},
{
"parameters": {
"jsCode": "// Find container and execute action\nconst containers = $input.item.json;\nconst prevData = $('Prepare Immediate Action').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst action = prevData.action;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\nconst timeout = action === 'restart' ? '?t=10' : '';\n\nreturn {\n json: {\n cmd: `curl -s -o /dev/null -w \"%{http_code}\" --max-time 15 -X POST 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/${action}${timeout}'`,\n containerId,\n containerName: normalizeName(container.Names[0]),\n action,\n chatId,\n messageId\n }\n};"
},
"id": "code-build-immediate-action-cmd",
"name": "Build Immediate Action Command",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2220,
1100
]
},
{
"parameters": {
"command": "={{ $json.cmd }}",
"options": {}
},
"id": "exec-immediate-action",
"name": "Execute Immediate Action",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
2440,
1100
]
},
{
"parameters": {
"jsCode": "// Parse immediate action result and update submenu\nconst stdout = $input.item.json.stdout;\nconst prevData = $('Build Immediate Action Command').item.json;\nconst containerName = prevData.containerName;\nconst action = prevData.action;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success)\nconst success = statusCode === 204 || statusCode === 304;\n\nconst verb = action === 'start' ? 'started' : 'restarted';\nconst icon = success ? '\\u2705' : '\\u274C';\nconst resultText = success ? `${containerName} ${verb} successfully` : `Failed to ${action} ${containerName}`;\n\n// Build updated keyboard based on new state (assume action succeeded)\nconst keyboard = [];\nif (action === 'start') {\n // Container is now 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 // Restart - container is still 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}\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\nconst stateIcon = action === 'start' || success ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} <b>${containerName}</b>\\n\\n`;\ntext += `${icon} ${resultText}`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n};"
},
"id": "code-format-immediate-result",
"name": "Format Immediate Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2660,
1100
]
},
{
"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-immediate-result",
"name": "Send Immediate Result",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2880,
1100
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"jsCode": "// Prepare logs action\nconst data = $('Parse Callback Data').item.json;\nreturn {\n json: {\n containerName: data.containerName,\n chatId: data.chatId,\n messageId: data.messageId,\n lines: 30\n }\n};"
},
"id": "code-prepare-logs-action",
"name": "Prepare Logs Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1780,
1300
]
},
{
"parameters": {
"method": "GET",
"url": "=http://docker-socket-proxy:2375/containers/json?all=true",
"options": {}
},
"id": "http-get-container-for-logs",
"name": "Get Container For Logs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2000,
1300
]
},
{
"parameters": {
"jsCode": "// Find container and build logs command\nconst containers = $input.item.json;\nconst prevData = $('Prepare Logs Action').item.json;\nconst containerName = prevData.containerName.toLowerCase();\nconst lines = prevData.lines;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === containerName);\n\nif (!container) {\n return {\n json: {\n error: true,\n chatId,\n messageId,\n text: `Container \"${containerName}\" not found`\n }\n };\n}\n\nconst containerId = container.Id;\n\nreturn {\n json: {\n cmd: `curl -s --max-time 10 'http://docker-socket-proxy:2375/v1.47/containers/${containerId}/logs?stdout=true&stderr=true&tail=${lines}'`,\n containerId,\n containerName: normalizeName(container.Names[0]),\n containerState: container.State,\n lines,\n chatId,\n messageId\n }\n};"
},
"id": "code-build-logs-action-cmd",
"name": "Build Logs Action Command",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2220,
1300
]
},
{
"parameters": {
"command": "={{ $json.cmd }}",
"options": {}
},
"id": "exec-logs-action",
"name": "Execute Logs Action",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [
2440,
1300
]
},
{
"parameters": {
"jsCode": "// Parse and format logs output\nconst stdout = $input.item.json.stdout || '';\nconst prevData = $('Build Logs Action Command').item.json;\nconst containerName = prevData.containerName;\nconst containerState = prevData.containerState;\nconst lines = prevData.lines;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\n\n// Docker logs API returns binary stream with 8-byte header per line\n// Strip non-printable characters and clean up\nlet logs = stdout\n .split('\\n')\n .map(line => {\n // Remove Docker stream header (first 8 bytes of each frame)\n // The header contains stream type and length info\n if (line.length > 8) {\n const cleaned = line.substring(8).replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '');\n return cleaned;\n }\n return line.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '');\n })\n .filter(line => line.trim().length > 0)\n .slice(-lines)\n .join('\\n');\n\nif (!logs || logs.trim().length === 0) {\n logs = '(no recent logs)';\n}\n\n// Truncate if too long for Telegram (max ~4096 chars)\nif (logs.length > 3800) {\n logs = '...' + logs.slice(-3800);\n}\n\n// Build keyboard for navigation\nconst keyboard = [];\nif (containerState === '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} Refresh 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\nconst stateIcon = containerState === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} <b>${containerName}</b> - Logs\\n\\n<pre>${logs}</pre>`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard }\n }\n};"
},
"id": "code-format-logs-action-result",
"name": "Format Logs Action Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2660,
1300
]
},
{
"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-logs-result",
"name": "Send Logs Result",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2880,
1300
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"jsCode": "// Build Stop Confirmation dialog with timestamp for 30-second timeout\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\nconst timestamp = Math.floor(Date.now() / 1000); // Unix timestamp\n\nconst keyboard = {\n inline_keyboard: [\n [\n { text: '\\u2705 Yes, Stop', callback_data: `confirm:stop:${containerName}:${timestamp}` },\n { text: '\\u274C Cancel', callback_data: `cancel:${containerName}` }\n ]\n ]\n};\n\nconst text = `\\u26A0\\uFE0F <b>Stop ${containerName}?</b>\\n\\nThis will stop the container immediately.\\n\\n<i>Confirmation expires in 30 seconds.</i>`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};"
},
"id": "code-build-stop-confirmation",
"name": "Build Stop Confirmation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1780,
1400
]
},
{
"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-stop-confirmation",
"name": "Send Stop Confirmation",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2000,
1400
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
},
{
"parameters": {
"jsCode": "// Build Update Confirmation dialog with timestamp for 30-second timeout\nconst data = $('Parse Callback Data').item.json;\nconst containerName = data.containerName;\nconst chatId = data.chatId;\nconst messageId = data.messageId;\nconst timestamp = Math.floor(Date.now() / 1000); // Unix timestamp\n\nconst keyboard = {\n inline_keyboard: [\n [\n { text: '\\u2705 Yes, Update', callback_data: `confirm:update:${containerName}:${timestamp}` },\n { text: '\\u274C Cancel', callback_data: `cancel:${containerName}` }\n ]\n ]\n};\n\nconst text = `\\u26A0\\uFE0F <b>Update ${containerName}?</b>\\n\\nThis will pull the latest image and recreate the container.\\n\\n<i>Confirmation expires in 30 seconds.</i>`;\n\nreturn {\n json: {\n chatId,\n messageId,\n text,\n reply_markup: keyboard\n }\n};"
},
"id": "code-build-update-confirmation",
"name": "Build Update Confirmation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1780,
1500
]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/editMessageText",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId, text: $json.text, parse_mode: 'HTML', reply_markup: $json.reply_markup }) }}",
"options": {}
},
"id": "http-send-update-confirmation",
"name": "Send Update Confirmation",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2000,
1500
],
"credentials": {
"telegramApi": {
"id": "I0xTTiASl7C1NZhJ",
"name": "Telegram account"
}
}
} }
], ],
"connections": { "connections": {
@@ -2763,7 +3179,13 @@
"index": 0 "index": 0
} }
], ],
[], [
{
"node": "Answer Action Callback",
"type": "main",
"index": 0
}
],
[ [
{ {
"node": "Answer Noop Callback", "node": "Answer Noop Callback",
@@ -3823,6 +4245,188 @@
} }
] ]
] ]
},
"Answer Action Callback": {
"main": [
[
{
"node": "Route Action Type",
"type": "main",
"index": 0
}
]
]
},
"Route Action Type": {
"main": [
[
{
"node": "Prepare Immediate Action",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare Immediate Action",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Stop Confirmation",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Update Confirmation",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare Logs Action",
"type": "main",
"index": 0
}
]
]
},
"Prepare Immediate Action": {
"main": [
[
{
"node": "Get Container For Action",
"type": "main",
"index": 0
}
]
]
},
"Get Container For Action": {
"main": [
[
{
"node": "Build Immediate Action Command",
"type": "main",
"index": 0
}
]
]
},
"Build Immediate Action Command": {
"main": [
[
{
"node": "Execute Immediate Action",
"type": "main",
"index": 0
}
]
]
},
"Execute Immediate Action": {
"main": [
[
{
"node": "Format Immediate Result",
"type": "main",
"index": 0
}
]
]
},
"Format Immediate Result": {
"main": [
[
{
"node": "Send Immediate Result",
"type": "main",
"index": 0
}
]
]
},
"Prepare Logs Action": {
"main": [
[
{
"node": "Get Container For Logs",
"type": "main",
"index": 0
}
]
]
},
"Get Container For Logs": {
"main": [
[
{
"node": "Build Logs Action Command",
"type": "main",
"index": 0
}
]
]
},
"Build Logs Action Command": {
"main": [
[
{
"node": "Execute Logs Action",
"type": "main",
"index": 0
}
]
]
},
"Execute Logs Action": {
"main": [
[
{
"node": "Format Logs Action Result",
"type": "main",
"index": 0
}
]
]
},
"Format Logs Action Result": {
"main": [
[
{
"node": "Send Logs Result",
"type": "main",
"index": 0
}
]
]
},
"Build Stop Confirmation": {
"main": [
[
{
"node": "Send Stop Confirmation",
"type": "main",
"index": 0
}
]
]
},
"Build Update Confirmation": {
"main": [
[
{
"node": "Send Update Confirmation",
"type": "main",
"index": 0
}
]
]
} }
}, },
"pinData": {}, "pinData": {},