Files
unraid-docker-manager/.planning/phases/08-inline-keyboard-infrastructure/08-01-PLAN.md
T
Lucas Berger ecd02a4b0e docs(08): create inline keyboard infrastructure plans
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>
2026-02-03 11:44:46 -05:00

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
n8n-workflow.json
true
truths artifacts key_links
User sees container list with tappable buttons when typing /status
Tapping a container name shows submenu with status details and action buttons
Pagination works for container lists longer than 6 containers
Direct access (/status plex) shows that container's submenu directly
path provides contains
n8n-workflow.json Container list keyboard and submenu nodes Build Container List Keyboard
path provides contains
n8n-workflow.json Submenu keyboard builder Build Container Submenu
from to via pattern
Keyword Router (status) Build Container List Keyboard workflow connection inline_keyboard
from to via pattern
Route Callback Build Container Submenu select: callback routing select:
Build the container list inline keyboard and container submenu for Phase 8 Inline Keyboard Infrastructure.

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.
  1. 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: [...] } }
  2. 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) }}
      }
      
  3. 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
  4. 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 }
  }
};
1. Load workflow in n8n UI 2. Send "/status" to bot 3. Verify: Response is inline keyboard (not text) 4. Verify: Containers shown with status icons 5. Verify: Tapping button shows callback in n8n logs (even if not handled yet) - /status returns inline keyboard with container list - Each container is a tappable button with name and state - Running containers shown first with green icon - Pagination navigation appears when >6 containers Task 2: Add Container Submenu with Action Buttons n8n-workflow.json Add routing and nodes to handle `select:{name}` callbacks and display container submenu.
  1. Update "Parse Callback Data" code node to recognize select: prefix:

    • If callback_data starts with select:, extract container name
    • Set isSelect: true and containerName: extractedName
    • Pass through to routing
  2. Add new output to "Route Callback" switch node:

    • New rule: isSelect === true -> output "select"
    • Position it before the fallback output
  3. 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
  4. 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
  5. 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
  6. 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) }}
      }
      
  7. 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
  8. 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 }
  }
};
1. Send "/status" to bot 2. Tap a container button 3. Verify: Message edits in-place to show container details 4. Verify: Action buttons match container state (Stop/Restart for running, Start for stopped) 5. Verify: "Back to List" button present 6. Tap "Back to List" 7. Verify: Returns to container list - Tapping container in list shows submenu with details and action buttons - Submenu shows container state, status, image - Action buttons match container state (Start vs Stop/Restart) - "Back to List" returns to container list - All transitions are message edits (no new messages) Task 3: Handle List Pagination Callbacks n8n-workflow.json Add routing to handle `list:{page}` callbacks for pagination navigation.
  1. Update "Parse Callback Data" to recognize list: prefix:

    • If callback_data starts with list:, extract page number
    • Set isList: true and page: extractedPage
  2. Add new output to "Route Callback":

    • New rule: isList === true -> output "list"
  3. 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
  4. Create HTTP Request "Answer List Callback":

    • answerCallbackQuery to prevent loading indicator
  5. Create HTTP Request "Edit Container List":

    • editMessageText with updated page
  6. 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>
After completion, create `.planning/phases/08-inline-keyboard-infrastructure/08-01-SUMMARY.md`