Files
Lucas Berger 893412f405 docs(03): create phase plan
Phase 03: Container Actions
- 4 plans in 4 waves (sequential due to shared workflow file)
- Ready for execution
2026-01-29 21:48:58 -05:00

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
03-container-actions 02 execute 2
03-01
n8n-workflow.json
true
truths artifacts key_links
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
path provides contains
n8n-workflow.json Callback query handling and suggestion flow Route Update Type
from to via pattern
Telegram Trigger Switch node (Route Update Type) message or callback_query routing callback_query
from to via pattern
HTTP Request node Telegram Bot API sendMessage with inline_keyboard 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.

<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/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:

    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):

    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 '<b>${query}</b>' found.\n\nDid you mean <b>${suggestedName}</b>?`,
        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 }}'
  6. "stop nonexistent" → should show "No container found" (no suggestion if nothing close)

  7. "stop plx" when "plex" exists → should show "Did you mean plex?" with button

  8. 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):

    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):

    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
  7. "stop plx" → suggestion appears → click "Yes, stop plex" → container stops, suggestion message deleted

  8. "stop plx" → suggestion appears → click "Cancel" → suggestion deleted, "Cancelled" toast

  9. "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.

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/03-container-actions/03-02-SUMMARY.md`