From feea06c4c3df13c723b80ca79d2d26ff881cddb9 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 3 Feb 2026 21:22:19 -0500 Subject: [PATCH] feat(09-01): wire batch routing and add batch stop confirmation - Add "Route Batch Action" switch node (update/start/restart/stop) - Add "Build Batch Stop Confirmation" code node with confirm/cancel keyboard - Add "Send Batch Stop Confirmation" HTTP node - Update Parse Callback Data to handle bstop: and bexec: prefixes - Add Route Callback outputs for batch stop confirm/cancel/exec - Add callback handler nodes for batch operations: - Answer Batch Stop Confirm with 30-second timeout check - Answer Batch Stop Cancel with message deletion - Answer Batch Exec for batch execution callbacks - Add Check Batch Stop Expired with expiry message flow Routing behavior: - update/start/restart: route immediately to batch execution (Plan 02) - stop: requires confirmation due to fuzzy matching risk - Confirmation callback format: bstop:confirm:{names}:{timestamp} - Cancel callback: bstop:cancel --- n8n-workflow.json | 442 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 441 insertions(+), 1 deletion(-) diff --git a/n8n-workflow.json b/n8n-workflow.json index 3a2d256..66bbf87 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -732,7 +732,7 @@ }, { "parameters": { - "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, confirm:{action}:{name}:{timestamp}, cancel:{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.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\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};" + "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// 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", @@ -962,6 +962,78 @@ }, "renameOutput": true, "outputKey": "cancelConfirm" + }, + { + "id": "is-batch-stop-confirm", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "batch-stop-confirm-true", + "leftValue": "={{ $json.isBatchStopConfirm }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "batchStopConfirm" + }, + { + "id": "is-batch-stop-cancel", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "batch-stop-cancel-true", + "leftValue": "={{ $json.isBatchStopCancel }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "batchStopCancel" + }, + { + "id": "is-batch-exec", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "batch-exec-true", + "leftValue": "={{ $json.isBatchExec }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "batchExec" } ] }, @@ -4063,6 +4135,283 @@ 2440, -100 ] + }, + { + "parameters": { + "rules": { + "values": [ + { + "id": "batch-update", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-update", + "leftValue": "={{ $json.action }}", + "rightValue": "update", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "update" + }, + { + "id": "batch-start", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-start", + "leftValue": "={{ $json.action }}", + "rightValue": "start", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "start" + }, + { + "id": "batch-restart", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-restart", + "leftValue": "={{ $json.action }}", + "rightValue": "restart", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "restart" + }, + { + "id": "batch-stop", + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "is-stop", + "leftValue": "={{ $json.action }}", + "rightValue": "stop", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "stop" + } + ] + }, + "options": { + "fallbackOutput": "none" + } + }, + "id": "switch-route-batch-action", + "name": "Route Batch Action", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 2220, + -300 + ] + }, + { + "parameters": { + "jsCode": "// Build batch stop confirmation message\n// Per context: Batch stop confirms due to fuzzy matching risk\nconst data = $json;\nconst allMatched = data.allMatched;\nconst chatId = data.chatId;\n\nconst count = allMatched.length;\nconst names = allMatched.map(c => c.Name);\nconst namesStr = names.join(',');\nconst timestamp = Math.floor(Date.now() / 1000);\n\n// Build confirmation message\nlet text = `Stop ${count} container${count > 1 ? 's' : ''}?\\n\\n`;\nfor (const name of names) {\n text += `\\u2022 ${name}\\n`;\n}\n\n// Callback format: bstop:confirm:{comma-separated-names}:{timestamp}\nconst confirmCallback = `bstop:confirm:${namesStr}:${timestamp}`;\nconst cancelCallback = 'bstop:cancel';\n\nreturn {\n json: {\n chat_id: chatId,\n text: text,\n parse_mode: 'HTML',\n reply_markup: {\n inline_keyboard: [\n [\n { text: 'Confirm', callback_data: confirmCallback },\n { text: 'Cancel', callback_data: cancelCallback }\n ]\n ]\n }\n }\n};" + }, + "id": "code-build-batch-stop-confirm", + "name": "Build Batch Stop Confirmation", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2440, + -200 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "id": "http-send-batch-stop-confirm", + "name": "Send Batch Stop Confirmation", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2660, + -200 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", + "options": {} + }, + "id": "http-answer-batch-stop-confirm", + "name": "Answer Batch Stop Confirm", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1340, + 700 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId, text: 'Cancelled' }) }}", + "options": {} + }, + "id": "http-answer-batch-stop-cancel", + "name": "Answer Batch Stop Cancel", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1340, + 800 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.queryId }) }}", + "options": {} + }, + "id": "http-answer-batch-exec", + "name": "Answer Batch Exec", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1340, + 900 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/deleteMessage", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ chat_id: $json.chatId, message_id: $json.messageId }) }}", + "options": {} + }, + "id": "http-delete-batch-stop-cancel", + "name": "Delete Batch Stop Cancel Message", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1560, + 800 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "batch-stop-expired", + "leftValue": "={{ $json.expired }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-batch-stop-expired", + "name": "Check Batch Stop Expired", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1560, + 700 + ] + }, + { + "parameters": { + "jsCode": "// Handle expired batch stop confirmation\nconst data = $json;\nreturn {\n json: {\n chat_id: data.chatId,\n message_id: data.messageId,\n text: 'Confirmation expired. Please try again.',\n parse_mode: 'HTML'\n }\n};" + }, + "id": "code-batch-stop-expired", + "name": "Build Batch Stop Expired", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1780, + 600 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": {} + }, + "id": "http-send-batch-stop-expired", + "name": "Send Batch Stop Expired", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + 600 + ] } ], "connections": { @@ -4201,6 +4550,27 @@ "type": "main", "index": 0 } + ], + [ + { + "node": "Answer Batch Stop Confirm", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Answer Batch Stop Cancel", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Answer Batch Exec", + "type": "main", + "index": 0 + } ] ] }, @@ -5353,6 +5723,76 @@ ] ] }, + "Route Batch Action": { + "main": [ + [], + [], + [], + [ + { + "node": "Build Batch Stop Confirmation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Batch Stop Confirmation": { + "main": [ + [ + { + "node": "Send Batch Stop Confirmation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer Batch Stop Confirm": { + "main": [ + [ + { + "node": "Check Batch Stop Expired", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Batch Stop Expired": { + "main": [ + [ + { + "node": "Build Batch Stop Expired", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "Build Batch Stop Expired": { + "main": [ + [ + { + "node": "Send Batch Stop Expired", + "type": "main", + "index": 0 + } + ] + ] + }, + "Answer Batch Stop Cancel": { + "main": [ + [ + { + "node": "Delete Batch Stop Cancel Message", + "type": "main", + "index": 0 + } + ] + ] + }, "Parse Action Command": { "main": [ [