Phase 8: Inline Keyboard Infrastructure - 3 plans in 3 waves (sequential dependency) - Plan 01: Container list keyboard and submenu navigation - Plan 02: Action execution and confirmation flow - Plan 03: Progress feedback and completion messages Covers KEY-01 through KEY-05 requirements. Ready for execution. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-inline-keyboard-infrastructure | 01 | execute | 1 |
|
true |
|
Purpose: Enable users to interact with containers via tappable buttons. The /status command shows a paginated container list with buttons. Tapping a container shows a submenu with status details and action buttons.
Output: Updated n8n-workflow.json with container list keyboard and submenu nodes wired to existing status flow.
<execution_context> @/home/luc/.claude/get-shit-done/workflows/execute-plan.md @/home/luc/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-inline-keyboard-infrastructure/08-CONTEXT.md @.planning/phases/08-inline-keyboard-infrastructure/08-RESEARCH.md @n8n-workflow.json Task 1: Add Container List Inline Keyboard n8n-workflow.json Modify the status command flow to return an inline keyboard instead of text.-
Create a Code node "Build Container List Keyboard" that:
- Takes container list from "Get Containers" node
- Strips
linuxserver-prefix from names for display - Groups by state (running containers first)
- Builds paginated keyboard (6 containers per page)
- Uses callback_data format:
select:{containerName}for container buttons - Uses
list:{page}for pagination (e.g.,list:0,list:1) - Each row: one container button with "name — Running/Stopped" text
- Navigation row at bottom: "Previous" / "Next" buttons if needed
- Returns:
{ chatId, text, reply_markup: { inline_keyboard: [...] } }
-
Create an HTTP Request node "Send Container List" that:
- Method: POST
- URL:
https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage - Body (JSON):
{ "chat_id": "={{ $json.chatId }}", "text": "={{ $json.text }}", "parse_mode": "HTML", "reply_markup": {{ JSON.stringify($json.reply_markup) }} }
-
Wire the flow:
- "Get Containers" (status branch) -> "Build Container List Keyboard" -> "Send Container List"
- Remove/bypass the old text-only status response for this flow
-
Handle
/status {name}direct access:- In "Build Container List Keyboard", check if input has a container name filter
- If single container requested, output should route to submenu builder instead
- Add output pin for "single container" case
Code template for keyboard builder:
const containers = $input.all().map(item => item.json);
const chatId = $('IF User Authenticated').item.json.message.chat.id;
const messageText = $('IF User Authenticated').item.json.message.text || '';
// Check for direct container access: "/status plex" or "status plex"
const match = messageText.match(/status\s+(\S+)/i);
const filterName = match ? match[1].toLowerCase() : null;
// If single container requested, find it and route to submenu
if (filterName) {
const container = containers.find(c => {
const name = c.Names[0].replace(/^\//, '').replace(/^linuxserver-/, '').toLowerCase();
return name === filterName || name.includes(filterName);
});
if (container) {
return {
json: {
singleContainer: true,
containerName: container.Names[0].replace(/^\//, '').replace(/^linuxserver-/, ''),
container: container,
chatId: chatId
}
};
}
}
// Build paginated list
const page = 0; // Default to first page
const perPage = 6;
const start = page * perPage;
// Sort: running first, then by name
const sorted = containers.sort((a, b) => {
if (a.State === 'running' && b.State !== 'running') return -1;
if (a.State !== 'running' && b.State === 'running') return 1;
const nameA = a.Names[0].replace(/^\//, '').replace(/^linuxserver-/, '');
const nameB = b.Names[0].replace(/^\//, '').replace(/^linuxserver-/, '');
return nameA.localeCompare(nameB);
});
const pageContainers = sorted.slice(start, start + perPage);
const totalPages = Math.ceil(sorted.length / perPage);
const keyboard = [];
// Container buttons
pageContainers.forEach(container => {
const name = container.Names[0].replace(/^\//, '').replace(/^linuxserver-/, '');
const state = container.State === 'running' ? 'Running' : 'Stopped';
const icon = container.State === 'running' ? '🟢' : '⚪';
keyboard.push([{
text: `${icon} ${name} — ${state}`,
callback_data: `select:${name}`
}]);
});
// Navigation row
const navRow = [];
if (page > 0) {
navRow.push({ text: '◀️ Previous', callback_data: `list:${page - 1}` });
}
if (page < totalPages - 1) {
navRow.push({ text: 'Next ▶️', callback_data: `list:${page + 1}` });
}
if (navRow.length > 0) keyboard.push(navRow);
return {
json: {
singleContainer: false,
chatId: chatId,
text: `<b>Containers</b> (${start + 1}-${Math.min(start + perPage, sorted.length)} of ${sorted.length})\n\nTap a container to manage it:`,
reply_markup: { inline_keyboard: keyboard }
}
};
-
Update "Parse Callback Data" code node to recognize
select:prefix:- If callback_data starts with
select:, extract container name - Set
isSelect: trueandcontainerName: extractedName - Pass through to routing
- If callback_data starts with
-
Add new output to "Route Callback" switch node:
- New rule:
isSelect === true-> output "select" - Position it before the fallback output
- New rule:
-
Create Code node "Prepare Container Fetch" that:
- Takes parsed callback data with containerName
- Outputs container name and callback context for Docker API call
- Preserves queryId, chatId, messageId for response
-
Create HTTP Request node "Get Single Container":
- Method: GET
- URL:
http://docker-socket-proxy:2375/containers/json?all=true&filters={"name":["{{ $json.containerName }}"]} - Returns container details
-
Create Code node "Build Container Submenu" that:
- Takes container details and callback context
- Builds action keyboard based on container state:
- If running: [Stop] [Restart] row, [Logs] [Update] row
- If stopped: [Start] row, [Logs] [Update] row
- Uses callback_data format:
action:{action}:{containerName}(e.g.,action:stop:plex) - Adds "Back to List" button:
list:0 - Builds text with container status details
- Returns structure for editMessageText
-
Create HTTP Request node "Send Container Submenu" that:
- Method: POST
- URL:
https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/editMessageText - Body:
{ "chat_id": "={{ $json.chatId }}", "message_id": {{ $json.messageId }}, "text": "={{ $json.text }}", "parse_mode": "HTML", "reply_markup": {{ JSON.stringify($json.reply_markup) }} }
-
Create HTTP Request node "Answer Select Callback" (place BEFORE submenu fetch):
- Method: POST
- URL:
https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/answerCallbackQuery - Body:
{ "callback_query_id": "={{ $json.queryId }}" } - CRITICAL: Must answer callback FIRST to prevent Telegram loading indicator
-
Wire the flow:
- Route Callback (select output) -> Answer Select Callback -> Prepare Container Fetch -> Get Single Container -> Build Container Submenu -> Send Container Submenu
Code template for Build Container Submenu:
const container = $input.all()[0].json[0]; // First container from filtered list
const { queryId, chatId, messageId, containerName } = $('Prepare Container Fetch').item.json;
const keyboard = [];
// Action row 1: state-dependent
if (container.State === 'running') {
keyboard.push([
{ text: '⏹️ Stop', callback_data: `action:stop:${containerName}` },
{ text: '🔄 Restart', callback_data: `action:restart:${containerName}` }
]);
} else {
keyboard.push([
{ text: '▶️ Start', callback_data: `action:start:${containerName}` }
]);
}
// Action row 2: always available
keyboard.push([
{ text: '📋 Logs', callback_data: `action:logs:${containerName}` },
{ text: '⬆️ Update', callback_data: `action:update:${containerName}` }
]);
// Navigation row
keyboard.push([
{ text: '◀️ Back to List', callback_data: 'list:0' }
]);
// Build status text
const stateIcon = container.State === 'running' ? '🟢' : '⚪';
const status = container.Status || container.State;
const image = container.Image.split(':')[0].split('/').pop(); // Get image name without registry/tag
return {
json: {
chatId: chatId,
messageId: messageId,
text: `${stateIcon} <b>${containerName}</b>\n\n` +
`<b>State:</b> ${container.State}\n` +
`<b>Status:</b> ${status}\n` +
`<b>Image:</b> ${image}`,
reply_markup: { inline_keyboard: keyboard }
}
};
-
Update "Parse Callback Data" to recognize
list:prefix:- If callback_data starts with
list:, extract page number - Set
isList: trueandpage: extractedPage
- If callback_data starts with
-
Add new output to "Route Callback":
- New rule:
isList === true-> output "list"
- New rule:
-
Create Code node "Build Paginated List" (or reuse container list logic):
- Similar to Task 1 keyboard builder but:
- Uses page number from callback
- Uses chatId/messageId from callback (for edit, not send)
- Returns structure for editMessageText
- Similar to Task 1 keyboard builder but:
-
Create HTTP Request "Answer List Callback":
- answerCallbackQuery to prevent loading indicator
-
Create HTTP Request "Edit Container List":
- editMessageText with updated page
-
Wire flow:
- Route Callback (list output) -> Answer List Callback -> Get Containers -> Build Paginated List -> Edit Container List
Note: May need to reuse "Get Containers" node or create parallel path. 1. Have more than 6 containers (or temporarily set perPage to 3 for testing) 2. Send "/status" 3. Tap "Next" button 4. Verify: Message edits to show next page of containers 5. Tap "Previous" button 6. Verify: Returns to previous page 7. Verify: No loading indicator hangs (callback answered) - Pagination buttons navigate between pages - Message edits in-place (no new messages) - Callback answered immediately (no loading indicator) - Page numbers correct in header text
After completing all tasks: 1. `/status` shows inline keyboard with container list 2. Tapping container shows submenu with action buttons 3. "Back to List" returns to container list 4. Pagination works (if >6 containers) 5. `/status plex` shows that container's submenu directly 6. All transitions are message edits, not new messages 7. No hanging loading indicators (callbacks answered)<success_criteria>
- KEY-01 requirement partially met: container list with inline buttons works
- Navigation flow complete: List -> Submenu -> List
- Foundation ready for action execution (Plan 02) </success_criteria>