--- phase: 03-container-actions plan: 02 type: execute wave: 2 depends_on: ["03-01"] files_modified: [n8n-workflow.json] autonomous: true must_haves: truths: - "Telegram Trigger receives callback_query updates from inline buttons" - "Callback queries route to dedicated handler branch" - "No-match suggestions show 'Did you mean X?' with inline button" - "User can accept suggestion without retyping command" artifacts: - path: "n8n-workflow.json" provides: "Callback query handling and suggestion flow" contains: "Route Update Type" key_links: - from: "Telegram Trigger" to: "Switch node (Route Update Type)" via: "message or callback_query routing" pattern: "callback_query" - from: "HTTP Request node" to: "Telegram Bot API" via: "sendMessage with inline_keyboard" pattern: "api.telegram.org.*sendMessage" --- Add callback query infrastructure and implement the "did you mean?" suggestion flow for no-match cases. Purpose: Enable inline button interactions in Telegram. When a user's container name doesn't match exactly, show a suggestion with an inline button they can click to accept without retyping. Output: Extended n8n workflow with callback handling and suggestion UI. @/home/luc/.claude/get-shit-done/workflows/execute-plan.md @/home/luc/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-container-actions/03-CONTEXT.md @.planning/phases/03-container-actions/03-RESEARCH.md @.planning/phases/03-container-actions/03-01-SUMMARY.md @n8n-workflow.json Task 1: Configure Telegram Trigger for callback queries n8n-workflow.json Modify the Telegram Trigger node to receive both message and callback_query updates: 1. Find the Telegram Trigger node in the workflow 2. Update the "Updates" field to include both types: - In n8n UI: Updates → ["message", "callback_query"] - In JSON: "updates": ["message", "callback_query"] 3. Add a new Switch node immediately after the Telegram Trigger and before the authentication IF node: - Name: "Route Update Type" - Mode: Rules - Rules: - Rule 1: `{{ $json.message }}` is not empty → Output "message" - Rule 2: `{{ $json.callback_query }}` is not empty → Output "callback_query" 4. Restructure connections: - Telegram Trigger → Route Update Type - Route Update Type (message) → IF User Authenticated (existing flow) - Route Update Type (callback_query) → new callback handler branch For callback_query authentication, add a new IF node: - Name: "IF Callback Authenticated" - Condition: `{{ $json.callback_query.from.id }}` equals authorized user ID - True: continue to callback processing - False: no connection (silent ignore per CONTEXT.md) 1. Send a regular message → should route to message branch and work as before 2. Workflow should not error on receiving callback_query updates Telegram Trigger receives callback_query, updates route to appropriate branches Task 2: Implement suggestion flow for no-match cases n8n-workflow.json Replace the placeholder "No Match" branch from Plan 03-01 with a suggestion flow: 1. **Find Closest Match** (Code node): After determining zero exact matches, find the closest container name: ```javascript const query = $json.containerQuery.toLowerCase(); const containers = $json.allContainers; // Full list from Docker const action = $json.action; const chatId = $json.chatId; // Simple closest match: longest common substring or starts-with let bestMatch = null; let bestScore = 0; for (const container of containers) { const name = container.Names[0].replace(/^\//, '').toLowerCase(); // Score by: contains query, or query contains name, or Levenshtein-like let score = 0; if (name.includes(query)) score = query.length; else if (query.includes(name)) score = name.length * 0.8; else { // Simple: count matching characters for (let i = 0; i < Math.min(query.length, name.length); i++) { if (query[i] === name[i]) score++; } } if (score > bestScore) { bestScore = score; bestMatch = container; } } if (!bestMatch || bestScore < 2) { return { json: { hasSuggestion: false, query, action, chatId } }; } const suggestedName = bestMatch.Names[0].replace(/^\//, ''); const suggestedId = bestMatch.Id.substring(0, 12); // Short ID for callback_data return { json: { hasSuggestion: true, query, action, chatId, suggestedName, suggestedId, timestamp: Date.now() } }; ``` 2. **Check Suggestion** (IF node): - Condition: `{{ $json.hasSuggestion }}` equals true - True: send suggestion with button - False: send "no container found" message 3. **Build Suggestion Keyboard** (Code node, on True branch): ```javascript const { chatId, query, action, suggestedName, suggestedId, timestamp } = $json; // callback_data must be ≤64 bytes - use short keys // a=action (1 char: s=start, t=stop, r=restart) // c=container short ID // t=timestamp const actionCode = action === 'start' ? 's' : action === 'stop' ? 't' : 'r'; const callbackData = JSON.stringify({ a: actionCode, c: suggestedId, t: timestamp }); return { json: { chat_id: chatId, text: `No container '${query}' found.\n\nDid you mean ${suggestedName}?`, parse_mode: "HTML", reply_markup: { inline_keyboard: [ [ { text: `Yes, ${action} ${suggestedName}`, callback_data: callbackData }, { text: "Cancel", callback_data: '{"a":"x"}' } ] ] } } }; ``` 4. **Send Suggestion** (HTTP Request node): - Method: POST - URL: `https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage` - Body Content Type: JSON - Body: `{{ JSON.stringify($json) }}` 5. **No Suggestion Message** (Telegram Send Message, on False branch): - Chat ID: `{{ $json.chatId }}` - Text: `No container found matching '{{ $json.query }}'` 1. "stop nonexistent" → should show "No container found" (no suggestion if nothing close) 2. "stop plx" when "plex" exists → should show "Did you mean plex?" with button 3. Verify button appears and is clickable (don't click yet - callback handling in next task) No-match cases show suggestion with inline button when a close match exists Task 3: Handle suggestion callback and execute action n8n-workflow.json Add callback processing for suggestion buttons on the callback_query branch: 1. **Parse Callback Data** (Code node, after IF Callback Authenticated): ```javascript const callback = $json.callback_query; let data; try { data = JSON.parse(callback.data); } catch (e) { data = { a: 'x' }; // Treat parse error as cancel } const queryId = callback.id; const chatId = callback.message.chat.id; const messageId = callback.message.message_id; // Check 2-minute timeout const TWO_MINUTES = 120000; const isExpired = data.t && (Date.now() - data.t > TWO_MINUTES); // Decode action const actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' }; const action = actionMap[data.a] || 'cancel'; return { json: { queryId, chatId, messageId, action, containerId: data.c || null, expired: isExpired, isSuggestion: true, // Single container suggestion, not batch isCancel: action === 'cancel' } }; ``` 2. **Route Callback** (Switch node): - Rule 1: `{{ $json.isCancel }}` equals true → Cancel branch - Rule 2: `{{ $json.expired }}` equals true → Expired branch - Rule 3: Default → Execute branch 3. **Cancel Handler** (Telegram node): - Operation: Answer Query - Query ID: `{{ $json.queryId }}` - Text: "Cancelled" - Show Alert: false Then HTTP Request to delete the suggestion message: - POST to `https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/deleteMessage` - Body: `{ "chat_id": {{ $json.chatId }}, "message_id": {{ $json.messageId }} }` 4. **Expired Handler**: - Answer callback query with "Confirmation expired. Please try again." - Delete the old message 5. **Execute from Callback** (Execute Command node): ```javascript const containerId = $json.containerId; const action = $json.action; const timeout = (action === 'stop' || action === 'restart') ? '?t=10' : ''; const cmd = `curl -s -o /dev/null -w "%{http_code}" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`; return { json: { cmd, containerId, action, queryId: $json.queryId, chatId: $json.chatId, messageId: $json.messageId } }; ``` 6. **Parse and Respond** (Code node): - Check status code (204/304 = success) - Fetch container name for response message - Answer callback query - Delete suggestion message - Send success/failure message 1. "stop plx" → suggestion appears → click "Yes, stop plex" → container stops, suggestion message deleted 2. "stop plx" → suggestion appears → click "Cancel" → suggestion deleted, "Cancelled" toast 3. "stop plx" → wait 2+ minutes → click button → shows "expired" message Clicking suggestion button executes the action and cleans up the UI End-to-end callback flow verification: 1. Regular messages still work (status, echo, actions from Plan 01) 2. "stop typo" when similar container exists → suggestion with button 3. Click "Yes" → action executes, success message appears 4. Click "Cancel" → suggestion dismissed 5. Wait 2 minutes, click → "expired" message 6. "stop nonexistent" with no close match → plain "not found" message Import updated workflow and test all scenarios. - Telegram Trigger receives both messages and callback_queries - Suggestion buttons appear for typos/close matches - Clicking suggestion executes the action - Cancel button dismisses suggestion - Expired confirmations handled gracefully - Old messages cleaned up after interaction After completion, create `.planning/phases/03-container-actions/03-02-SUMMARY.md`