ecd02a4b0e
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>
384 lines
14 KiB
Markdown
384 lines
14 KiB
Markdown
---
|
|
phase: 08-inline-keyboard-infrastructure
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified: [n8n-workflow.json]
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "n8n-workflow.json"
|
|
provides: "Container list keyboard and submenu nodes"
|
|
contains: "Build Container List Keyboard"
|
|
- path: "n8n-workflow.json"
|
|
provides: "Submenu keyboard builder"
|
|
contains: "Build Container Submenu"
|
|
key_links:
|
|
- from: "Keyword Router (status)"
|
|
to: "Build Container List Keyboard"
|
|
via: "workflow connection"
|
|
pattern: "inline_keyboard"
|
|
- from: "Route Callback"
|
|
to: "Build Container Submenu"
|
|
via: "select: callback routing"
|
|
pattern: "select:"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/luc/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add Container List Inline Keyboard</name>
|
|
<files>n8n-workflow.json</files>
|
|
<action>
|
|
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):
|
|
```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:
|
|
```javascript
|
|
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 }
|
|
}
|
|
};
|
|
```
|
|
</action>
|
|
<verify>
|
|
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)
|
|
</verify>
|
|
<done>
|
|
- /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
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Add Container Submenu with Action Buttons</name>
|
|
<files>n8n-workflow.json</files>
|
|
<action>
|
|
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:
|
|
```json
|
|
{
|
|
"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:
|
|
```javascript
|
|
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 }
|
|
}
|
|
};
|
|
```
|
|
</action>
|
|
<verify>
|
|
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
|
|
</verify>
|
|
<done>
|
|
- 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)
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Handle List Pagination Callbacks</name>
|
|
<files>n8n-workflow.json</files>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
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)
|
|
</verify>
|
|
<done>
|
|
- Pagination buttons navigate between pages
|
|
- Message edits in-place (no new messages)
|
|
- Callback answered immediately (no loading indicator)
|
|
- Page numbers correct in header text
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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)
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/08-inline-keyboard-infrastructure/08-01-SUMMARY.md`
|
|
</output>
|