Files
Lucas Berger fe4c19c7c6 docs(08): research phase domain
Phase 8: Inline Keyboard Infrastructure
- Standard stack identified (HTTP Request + Telegram API)
- Architecture patterns documented (5 core patterns)
- Pitfalls catalogued (5 critical issues)
- n8n-specific workarounds for dynamic keyboards
2026-02-03 11:39:09 -05:00

23 KiB

Phase 8: Inline Keyboard Infrastructure - Research

Researched: 2026-02-03 Domain: Telegram Bot inline keyboards in n8n workflows Confidence: MEDIUM

Summary

This phase implements inline keyboard buttons for the Telegram bot to enable visual, tap-based container control. The implementation must work within n8n's workflow architecture, using HTTP Request nodes for dynamic keyboard generation (native Telegram node has limitations) and callback query handling through Telegram Trigger + Switch routing.

Key constraint: n8n's native Telegram node does not support dynamic inline keyboards via expressions. The solution requires direct Telegram API calls via HTTP Request nodes to send messages with reply_markup JSON.

State management challenge: n8n workflows are stateless by default. Callback query routing and confirmation timeouts require careful workflow design to maintain context between user interactions.

Primary recommendation: Use HTTP Request nodes for all inline keyboard operations (send, edit), leverage callback_data for compact state encoding (64-byte limit), and route callbacks via Switch node checking $json.callback_query.data patterns.

Standard Stack

Core Technologies

Technology Version Purpose Why Standard
Telegram Bot API 7.0+ Inline keyboard support Official API for bot features; API 7.0 sets 100-button/64-byte limits
n8n HTTP Request node Built-in Dynamic keyboard generation Native Telegram node lacks dynamic keyboard support; HTTP direct access required
n8n Telegram Trigger Built-in Receive callback queries Configured with updates: ["message", "callback_query"] to handle button presses
n8n Switch node Built-in Route callbacks by data Conditional routing based on callback_query.data patterns
n8n Code node Built-in Build keyboard JSON JavaScript for generating reply_markup structures dynamically

Supporting Tools

Tool Purpose When to Use
answerCallbackQuery Acknowledge button presses ALWAYS - Must call within timeout or Telegram shows loading indefinitely
editMessageText Update message + keyboard Confirmations, progress updates, navigation between menus
editMessageReplyMarkup Update keyboard only When text stays same but buttons change

n8n-Specific Requirements

Workflow configuration:

{
  "Telegram Trigger": {
    "parameters": {
      "updates": ["message", "callback_query"]
    }
  }
}

Existing workflow already has:

  • Telegram Trigger with callback_query support (line 8 in workflow)
  • Switch node routing messages vs callbacks (line 82-90)
  • Authentication checks for both paths (line 114, 146)

Architecture Patterns

Pattern 1: HTTP Request for Dynamic Keyboards

What: Use HTTP Request node instead of native Telegram node to send messages with inline keyboards.

Why: n8n's Telegram node interprets arrays as strings and rejects expression-based keyboard construction. Community consensus: bypass the native node entirely.

Structure:

// In Code node: Build keyboard structure
const keyboard = {
  inline_keyboard: [
    [
      { text: "▶️ Start", callback_data: "action:start:plex" },
      { text: "⏹️ Stop", callback_data: "action:stop:plex" }
    ],
    [
      { text: "🔄 Restart", callback_data: "action:restart:plex" }
    ]
  ]
};

return {
  json: {
    chatId: chat_id,
    text: "Container: plex",
    reply_markup: keyboard
  }
};

HTTP Request node configuration:

Method: POST
URL: https://api.telegram.org/bot{{ $credentials.telegramApi.token }}/sendMessage
Body:
{
  "chat_id": "={{ $json.chatId }}",
  "text": "={{ $json.text }}",
  "parse_mode": "HTML",
  "reply_markup": {{ JSON.stringify($json.reply_markup) }}
}

Confidence: HIGH (verified via n8n community, multiple sources)

Pattern 2: Callback Query Routing with Switch Node

What: Route callback queries based on callback_data patterns using Switch node.

Structure:

Telegram Trigger (callback_query)
  → Route Update Type (existing Switch: line 82)
    → IF Callback Authenticated (line 146)
      → Parse Callback Data (new Code node)
        → Switch on Action Type (new Switch node)
          ├─ "action:list" → Show container list
          ├─ "action:select:*" → Show container submenu
          ├─ "action:start:*" → Execute start
          ├─ "action:stop:*" → Show confirmation
          └─ "confirm:*" / "cancel:*" → Handle confirmation

Data access in Switch node:

  • Callback query ID: {{ $json.callback_query.id }}
  • Callback data: {{ $json.callback_query.data }}
  • Chat ID: {{ $json.callback_query.message.chat.id }}
  • Message ID: {{ $json.callback_query.message.message_id }}

Confidence: HIGH (verified in existing workflow + official n8n docs)

Pattern 3: Message Editing for In-Place Updates

What: Edit existing messages to update keyboards and text without creating new messages.

Use cases:

  1. Navigation: Container list → container submenu → action result
  2. Confirmations: "Stop container?" with Yes/No buttons
  3. Progress: "Stopping plex..." → "Plex stopped "

HTTP Request configuration (editMessageText):

Method: POST
URL: https://api.telegram.org/bot{{ $credentials.telegramApi.token }}/editMessageText
Body:
{
  "chat_id": "={{ $json.callback_query.message.chat.id }}",
  "message_id": {{ $json.callback_query.message.message_id }},
  "text": "={{ $json.newText }}",
  "parse_mode": "HTML",
  "reply_markup": {{ JSON.stringify($json.reply_markup) }}
}

Confidence: HIGH (Telegram API official)

Pattern 4: Answer Callback Query (Critical)

What: ALWAYS call answerCallbackQuery after receiving a callback, even with no visible notification.

Why: Telegram clients show loading indicator until bot answers. Failure to answer causes 502 timeout error and poor UX.

HTTP Request configuration:

Method: POST
URL: https://api.telegram.org/bot{{ $credentials.telegramApi.token }}/answerCallbackQuery
Body:
{
  "callback_query_id": "={{ $json.callback_query.id }}",
  "text": "={{ $json.notificationText || '' }}",
  "show_alert": false
}

Best practice: Call answerCallbackQuery FIRST in callback handling flow, before any other processing.

Confidence: HIGH (official Telegram docs + python-telegram-bot docs)

Pattern 5: Pagination for Container Lists

What: Show 5-8 containers per page with Previous/Next navigation buttons.

Recommended structure:

// Calculate pagination
const containersPerPage = 6;
const currentPage = parseInt(page) || 0;
const totalPages = Math.ceil(containers.length / containersPerPage);
const start = currentPage * containersPerPage;
const pageContainers = containers.slice(start, start + containersPerPage);

// Build keyboard
const keyboard = [];

// Container rows (1 per container)
pageContainers.forEach(container => {
  keyboard.push([{
    text: `${container.name}${container.state}`,
    callback_data: `action:select:${container.name}`
  }]);
});

// Navigation row
const navRow = [];
if (currentPage > 0) {
  navRow.push({ text: "◀️ Previous", callback_data: `action:list:${currentPage - 1}` });
}
if (currentPage < totalPages - 1) {
  navRow.push({ text: "Next ▶️", callback_data: `action:list:${currentPage + 1}` });
}
if (navRow.length > 0) keyboard.push(navRow);

return {
  json: {
    text: `<b>Containers</b> (${start + 1}-${Math.min(start + containersPerPage, containers.length)} of ${containers.length})`,
    reply_markup: { inline_keyboard: keyboard }
  }
};

Confidence: MEDIUM (based on best practices, not n8n-specific verification)

Anti-Patterns to Avoid

1. Using Native Telegram Node for Dynamic Keyboards

  • Why bad: n8n interprets arrays as strings; expressions fail
  • Instead: Use HTTP Request node with manually constructed JSON

2. Not Answering Callback Queries

  • Why bad: Telegram client shows loading forever, 502 timeout errors
  • Instead: Always call answerCallbackQuery, even with empty parameters

3. Embedding Full State in callback_data

  • Why bad: 64-byte limit enforced by API 7.0; error 400 BUTTON_DATA_INVALID
  • Instead: Use compact identifiers (e.g., action:stop:plex not {"action":"stop","container":"plex","user":"admin"})

4. Creating New Messages for Updates

  • Why bad: Chat gets cluttered, poor UX
  • Instead: Use editMessageText to update in place

Don't Hand-Roll

Problem Don't Build Use Instead Why
Callback query timeout management Custom timeout tracking system Accept 30-second hard limit, revert via editMessageText Telegram's timeout is enforced server-side; cannot extend
Button state storage Redis/database for button state Encode state in callback_data (64 bytes) n8n workflows are stateless; external storage adds complexity
Keyboard layouts Custom positioning logic 2D array = rows, inner arrays = columns Telegram's inline_keyboard structure is straightforward
Update detection Poll Unraid API for updates Defer to future phase (not in scope) Context explicitly defers this

Common Pitfalls

Pitfall 1: callback_data Size Limit Violations

What goes wrong: Buttons fail with error 400 BUTTON_DATA_INVALID when callback_data exceeds 64 bytes.

Why it happens: UTF-8 encoding; emojis cost 4 bytes each. {"action":"restart","container":"linuxserver-plex","user":"admin"} = 68 bytes.

How to avoid:

  • Use compact encoding: action:restart:plex (22 bytes)
  • Hash long container names if necessary: a:r:sha1hash
  • Test byte length: Buffer.byteLength(callback_data, 'utf8')

Warning signs: 400 errors from sendMessage/editMessageText API calls

Confidence: HIGH (Telegram API 7.0 spec)

Pitfall 2: Switch Node Expression Mismatch

What goes wrong: Switch node fails to route callbacks; flow doesn't match expected conditions.

Why it happens: Incorrect data path reference (e.g., $json.data instead of $json.callback_query.data)

How to avoid:

  • Use existing workflow pattern: Check $json.callback_query?.id for presence (line 63)
  • Access callback data via: $json.callback_query.data
  • Use string operations: .startsWith("action:stop:") for prefix matching
  • Test with n8n's expression editor to verify data structure

Warning signs: Switch node always goes to fallback output

Confidence: MEDIUM (community reports issue, but no clear resolution found)

Pitfall 3: Confirmation Timeout Without Fallback

What goes wrong: User doesn't respond to "Stop container? Yes/No" within 30 seconds; buttons remain clickable but outdated.

Why it happens: No timeout handling implemented; old callback_data still works.

How to avoid:

  • Include timestamp in callback_data: confirm:stop:plex:1738595200
  • In callback handler, check if timestamp is within 30 seconds
  • If expired, call editMessageText to remove buttons and show "Confirmation expired"

Warning signs: Users report clicking old buttons and triggering unexpected actions

Confidence: MEDIUM (timeout behavior is known; implementation pattern is extrapolated)

Pitfall 4: Race Conditions with editMessageText

What goes wrong: Multiple editMessageText calls in rapid succession; only last one takes effect, or API returns 429 rate limit errors.

Why it happens: Telegram API has rate limits; editing same message multiple times quickly violates limits.

How to avoid:

  • For progress updates, only show final state for quick actions (context decision: "show final result only")
  • For longer operations (updates), use single progress message edit: "Updating..." → "Updated "
  • Don't edit every second; minimum 2-3 second intervals if progress needed

Warning signs: 429 Too Many Requests errors, messages not updating

Confidence: MEDIUM (based on API rate limit documentation)

Pitfall 5: Persistent Menu Button Without Implementation

What goes wrong: Context mentions "persistent menu button" but Telegram Bot API doesn't support this directly.

Why it happens: Confusion between inline keyboards (message-attached) and reply keyboards (persistent in input field).

How to avoid:

  • If "persistent" means reply keyboard: Use ReplyKeyboardMarkup with /status command button (different from inline)
  • If "persistent" means always-visible inline keyboard: Send pinned message with keyboard
  • Clarify requirement: likely means reply keyboard with "🗂 Containers" button that sends /status

Warning signs: Searching for "persistent inline keyboard" yields no results

Confidence: LOW (ambiguous requirement; needs clarification)

Code Examples

Example 1: Send Container List with Inline Keyboard

Source: Synthesized from Telegram API docs + n8n HTTP Request pattern

// Code node: Build Container List Keyboard
const containers = $input.all().map(item => item.json);
const chatId = $('Route Update Type').item.json.message.chat.id;

// Group running vs stopped
const running = containers.filter(c => c.State === 'running');
const stopped = containers.filter(c => c.State === 'exited');

// Build keyboard (max 6 containers for readability)
const keyboard = [];

running.slice(0, 6).forEach(container => {
  const name = container.Names[0].replace(/^\//, '').replace(/^linuxserver-/, '');
  keyboard.push([{
    text: `${name} — Running`,
    callback_data: `select:${name}`
  }]);
});

if (running.length > 6) {
  keyboard.push([{
    text: `View all (${running.length} total)`,
    callback_data: 'list:all:0'
  }]);
}

return {
  json: {
    chatId: chatId,
    text: '<b>🗂 Containers</b>\n\nTap a container to manage it:',
    reply_markup: { inline_keyboard: keyboard }
  }
};

HTTP Request node:

POST https://api.telegram.org/bot{{ $credentials.telegramApi.token }}/sendMessage

{
  "chat_id": "={{ $json.chatId }}",
  "text": "={{ $json.text }}",
  "parse_mode": "HTML",
  "reply_markup": {{ JSON.stringify($json.reply_markup) }}
}

Example 2: Container Submenu with Action Buttons

// Code node: Build Container Actions Keyboard
const containerName = $json.selectedContainer;
const container = $json.containerDetails; // from Docker API
const chatId = $json.callback_query.message.chat.id;

const keyboard = [];

// Action row 1: Start/Stop based on state
if (container.State === 'running') {
  keyboard.push([
    { text: '⏹️ Stop', callback_data: `stop:${containerName}` },
    { text: '🔄 Restart', callback_data: `restart:${containerName}` }
  ]);
} else {
  keyboard.push([
    { text: '▶️ Start', callback_data: `start:${containerName}` }
  ]);
}

// Action row 2: Other actions
keyboard.push([
  { text: '📋 Logs', callback_data: `logs:${containerName}` },
  { text: '⬆️ Update', callback_data: `update:${containerName}` }
]);

// Navigation row
keyboard.push([
  { text: '◀️ Back to List', callback_data: 'list:all:0' }
]);

const stateIndicator = container.State === 'running' ? '🟢' : '⚪';

return {
  json: {
    text: `${stateIndicator} <b>${containerName}</b>\n\n` +
          `State: ${container.State}\n` +
          `Uptime: ${container.Status}\n` +
          `Image: ${container.Image}`,
    reply_markup: { inline_keyboard: keyboard }
  }
};

Example 3: Confirmation Flow with Timeout

// Code node: Build Stop Confirmation
const containerName = $json.containerName;
const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp

const keyboard = {
  inline_keyboard: [
    [
      { text: '✅ Yes, Stop', callback_data: `confirm:stop:${containerName}:${timestamp}` },
      { text: '❌ No, Cancel', callback_data: `cancel:stop:${containerName}` }
    ]
  ]
};

return {
  json: {
    text: `⚠️ Stop <b>${containerName}</b>?\n\nThis will stop the container immediately.`,
    reply_markup: keyboard,
    confirmTimestamp: timestamp
  }
};

Validation in callback handler:

// Code node: Validate Confirmation Timeout
const callbackData = $json.callback_query.data; // "confirm:stop:plex:1738595200"
const parts = callbackData.split(':');
const action = parts[0]; // "confirm"
const operation = parts[1]; // "stop"
const containerName = parts[2]; // "plex"
const timestamp = parseInt(parts[3]); // 1738595200

const currentTime = Math.floor(Date.now() / 1000);
const elapsed = currentTime - timestamp;

if (elapsed > 30) {
  // Timeout expired
  return {
    json: {
      expired: true,
      text: '⏱️ Confirmation expired. Please try again.',
      removeKeyboard: true
    }
  };
}

// Still valid
return {
  json: {
    expired: false,
    operation: operation,
    containerName: containerName
  }
};

Example 4: Answer Callback Query (Always Required)

// Code node: Prepare Callback Answer
const callbackQueryId = $json.callback_query.id;

return {
  json: {
    callback_query_id: callbackQueryId,
    text: '', // Empty for silent acknowledgment
    show_alert: false
  }
};

HTTP Request node (place FIRST in callback flow):

POST https://api.telegram.org/bot{{ $credentials.telegramApi.token }}/answerCallbackQuery

{
  "callback_query_id": "={{ $json.callback_query_id }}"
}

State of the Art

Old Approach Current Approach (2026) When Changed Impact
Native Telegram node with inline keyboards HTTP Request node + JSON construction n8n PR #17258 (pending since Oct 2025) Native node still lacks dynamic keyboard support; HTTP workaround required
Separate reply keyboard for menus Inline keyboards for all interactions Bot API 2.0 (2016) Inline keyboards don't send messages to chat; cleaner UX
Store button state in database Encode state in callback_data Callback queries introduced API 2.0 Simpler for stateless workflows; 64-byte limit requires compact encoding
200-button limit for edits 100-button limit enforced Bot API 7.0 (2023) Must use pagination for large lists

Deprecated/outdated:

  • ReplyKeyboardMarkup for action buttons: Use inline keyboards instead; reply keyboards send text messages, inline keyboards work silently
  • Long-polling for updates: Telegram Trigger node handles webhooks automatically in n8n
  • Custom keyboard builder libraries: Not applicable in n8n; use Code node with plain JavaScript

Open Questions

1. Persistent Menu Button Implementation

What we know: Context mentions "persistent menu button ('Containers')" but doesn't specify type.

What's unclear:

  • Is this a ReplyKeyboardMarkup (persistent in input field)?
  • Or a pinned inline keyboard message?
  • Or the existing /start command menu?

Recommendation: Implement as ReplyKeyboardMarkup with single button "🗂 Containers" that sends /status command. This provides persistent access without cluttering chat.

Confidence: LOW (ambiguous requirement)

2. Unraid Update Detection API

What we know: Context defers "cached update status checking" but mentions "use Unraid's native update detection if accessible."

What's unclear: Whether Unraid API exposes update availability via Docker socket proxy.

Recommendation: Research in separate task during implementation. If available, add update indicator emoji (⬆️) to container name in list. If not, defer entirely.

Confidence: LOW (Unraid API not researched)

3. Direct Container Access with Inline Response

What we know: /status plex should "jump straight to that container's submenu."

What's unclear: Should this respond with inline keyboard immediately, or maintain current text-only response?

Recommendation: Respond with inline keyboard matching the submenu structure (status details + action buttons). Maintains consistency with button-based flow.

Confidence: MEDIUM (logical extension of requirements)

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: MEDIUM - HTTP Request workaround verified by community, but no official n8n documentation on this pattern
  • Architecture patterns: MEDIUM - Telegram API patterns are HIGH confidence; n8n-specific implementation is based on community consensus and existing workflow analysis
  • Pitfalls: MEDIUM - API constraints are well-documented (HIGH); n8n-specific issues are community-reported (MEDIUM); timeout handling is extrapolated (LOW)

Research date: 2026-02-03 Valid until: ~30 days (stable domain; n8n PR #17258 may change recommendations if merged)

Key research limitations:

  1. Could not access complete n8n workflow template #7664 (template page showed only CSS, not workflow JSON)
  2. n8n's official docs don't document the HTTP Request workaround for dynamic keyboards (community knowledge only)
  3. No official n8n examples for Telegram inline keyboard + callback query patterns found
  4. "Persistent menu button" requirement is ambiguous (needs clarification during planning)