Skip to content

feat(telegram): add inline callback buttons to utils and types; docs+…#15

Open
o-ray-o wants to merge 1 commit intoelizaos-plugins:1.xfrom
o-ray-o:feat/callback-buttons
Open

feat(telegram): add inline callback buttons to utils and types; docs+…#15
o-ray-o wants to merge 1 commit intoelizaos-plugins:1.xfrom
o-ray-o:feat/callback-buttons

Conversation

@o-ray-o
Copy link

@o-ray-o o-ray-o commented Sep 25, 2025

Add Callback Button Support for Interactive Menus

Summary

This PR adds support for Telegram callback buttons, enabling interactive menus that update in place without message spam. This feature allows developers to create rich, interactive experiences with navigation flows, confirmation dialogs, and dynamic content updates.

Motivation

Previously, the plugin only supported URL and login buttons which open external links. There was no way to create interactive menus or handle button clicks within the chat itself. This limitation prevented developers from building engaging bot experiences with:

  • Multi-level menus
  • Confirmation dialogs
  • Settings panels
  • Interactive forms

Changes

1. Type System Updates

  • Extended Button type (src/types.ts): Added discriminated union variant for callback buttons with kind: 'callback' and callback_data field
  • Enhanced TelegramContent interface: Added optional callback_data field to track button press data

2. Core Functionality

  • Button Conversion (src/utils.ts): Updated convertToTelegramButtons() to handle callback buttons using Markup.button.callback()
  • Callback Query Handler (src/messageManager.ts): Implemented handleCallbackQuery() method that:
    • Acknowledges callback queries immediately for better UX
    • Processes callback data as messages through the runtime
    • Supports editing original messages in place
    • Creates proper Memory objects for action processing

3. Service Integration

  • Updated Service (src/service.ts):
    • Added 'callback_query' to allowedUpdates in bot launch config
    • Registered callback query event handler
    • Integrated with existing middleware pipeline

4. Documentation & Examples

  • Comprehensive Examples (examples/callbackMenuAction.ts):
    • Interactive menu system with navigation
    • Confirmation dialog pattern
    • Demonstrates edit-in-place functionality
  • README Updates: Added detailed documentation on button types, usage patterns, and best practices

5. Test Coverage

  • Unit Tests (__tests__/utils.test.ts): Added 8 new test cases for callback button conversion

Testing

Test Suite Results

pnpm run test

✓ __tests__/telegramClient.test.ts  (1 test) 3ms
✓ __tests__/utils.test.ts  (26 tests) 63ms  
✓ __tests__/messageManager.test.ts  (6 tests) 27ms

Test Files  3 passed (3)
     Tests  33 passed (33)

Unit Tests Added

 convertToTelegramButtons
   should handle empty button array
   should handle null buttons
   should handle undefined buttons
   should convert URL button correctly
   should convert login button correctly
   should convert callback button correctly
   should handle mixed button types
   should handle buttons with emoji in text
   should handle callback data with special characters

Build Verification

pnpm run build
✓ ESM Build success
✓ DTS Build success
✓ TypeScript compilation passed (npx tsc --noEmit)

Manual Testing Checklist

  • Callback buttons render correctly in Telegram chat
  • Button clicks trigger callback queries
  • Messages update in place without creating new messages
  • Navigation between menu levels works
  • Callback queries are acknowledged (no loading spinner stuck)
  • Actions can process callback_data from button presses
  • Error handling works when callback processing fails

Compliance Verification

  • TypeScript Linting (per .cursor/linter.mdc):
    • No any types in new code
    • Proper type definitions and type guards
    • Follows ElizaOS patterns
  • Error Logging (per .cursor/logger.mdc):
    • Uses logger from @elizaos/core
    • Provides error context
    • No sensitive data logging

Usage Example

// Define an interactive menu action
export const menuAction: Action = {
  name: 'INTERACTIVE_MENU',
  
  validate: async (_runtime, message) => {
    const content = message.content as TelegramContent;
    return content.text === '/menu' || 
           content.callback_data?.startsWith('menu_');
  },
  
  handler: async (runtime, message, _state, _options, callback) => {
    const content = message.content as TelegramContent;
    
    if (content.callback_data === 'menu_settings') {
      // Edit message with settings submenu
      await callback({
        text: '⚙️ Settings',
        buttons: [
          { kind: 'callback', text: '🔔 Notifications', callback_data: 'menu_notif' },
          { kind: 'callback', text: '⬅️ Back', callback_data: 'menu_main' }
        ]
      });
    } else {
      // Show main menu
      await callback({
        text: '📱 Main Menu',
        buttons: [
          { kind: 'callback', text: '⚙️ Settings', callback_data: 'menu_settings' }
        ]
      });
    }
    return true;
  }
};

Breaking Changes

None. This feature is fully backward compatible:

  • Existing URL and login buttons continue to work unchanged
  • No changes to existing APIs
  • Optional feature that developers can adopt incrementally

Migration Guide

No migration needed. To use callback buttons, simply add buttons with kind: 'callback':

// Before (still works)
{ kind: 'url', text: 'Website', url: 'https://example.com' }

// New callback button option
{ kind: 'callback', text: 'Click Me', callback_data: 'action_1' }

Future Enhancements

  • Add support for callback query answers with alerts/notifications
  • Implement inline query support
  • Add button layout helpers (rows/columns)
  • Support for updating specific buttons without full message edit

Related Issues

  • Implements feature request for interactive menus
  • Enables rich user interactions without external URLs
  • Improves UX by reducing message spam

Checklist

  • Code follows project style guidelines
  • Tests have been added/updated
  • Documentation has been updated
  • Changes are backward compatible
  • TypeScript types are properly defined
  • Error handling follows project patterns
  • Examples demonstrate the feature
  • Build passes without errors
  • All tests pass

Screenshots/Demo

The implementation enables creating interactive experiences like:

  • Multi-level navigation menus
  • Settings panels with submenus
  • Confirmation dialogs
  • Dynamic content that updates in place

See examples/callbackMenuAction.ts for a complete working demonstration.

Summary by CodeRabbit

  • New Features
    • Added support for Telegram callback buttons and interactive menus, including handling callback queries and in-place message updates.
    • Expanded button options (URL, Login, Callback) with optional callback data in message content.
  • Documentation
    • Expanded README with button types, inline menu patterns, login flow, best practices, and full examples.
  • Tests
    • Added comprehensive tests for converting buttons, covering URL, Login, Callback, mixed inputs, and edge cases.
  • Chores
    • Updated .gitignore to exclude .cursor (retains .turbo).

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 25, 2025

Walkthrough

Adds Telegram callback button support across types, utilities, message handling, and service wiring. Introduces handleCallbackQuery in MessageManager, updates types and button conversion, expands bot allowed updates and event handling, adds tests and examples, and updates documentation. Minor logging message adjustments. Adds .cursor to .gitignore.

Changes

Cohort / File(s) Summary
Repo config
\.gitignore
Add ".cursor" ignore entry; ".turbo" retained.
Documentation
README.md
Expand Telegram button docs: URL, Login, Callback; inline menus; examples; best practices.
Types and utilities
src/types.ts, src/utils.ts
Add Button union with callback kind; optional callback_data in TelegramContent; extend convertToTelegramButtons to map callback buttons.
Message handling
src/messageManager.ts
Add public handleCallbackQuery to process Telegram callback queries, emit events, construct/edit message content, and adjust error logs.
Service integration
src/service.ts
Include 'callback_query' in allowed updates; add bot event handler delegating to messageManager.handleCallbackQuery.
Examples
examples/callbackMenuAction.ts
Add callbackMenuAction and confirmAction demonstrating multi-level menus and confirmation via callback buttons.
Tests
__tests__/utils.test.ts
Add tests for convertToTelegramButtons covering empty inputs and URL/login/callback mappings, including mixed and emoji cases.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as Telegram User
  participant TG as Telegram (Telegraf ctx)
  participant S as Service/Bot
  participant MM as MessageManager
  participant A as Action(s)
  participant API as Telegram API

  U->>TG: Tap callback button (callback_query)
  TG->>S: 'callback_query' event
  S->>MM: handleCallbackQuery(ctx)
  MM->>MM: Validate ctx, extract callback_data & chat/message
  MM->>A: Run matching action(s) with edit-capable callback
  alt Action returns updated content (text/buttons)
    A-->>MM: TelegramContent with text/buttons/callback_data
    MM->>API: editMessageText / editMessageReplyMarkup (MarkdownV2)
    API-->>MM: ack
  else No update or final message
    A-->>MM: No-op or final state
    MM-->>S: Done
  end
  MM-->>S: Emit MESSAGE_RECEIVED (+ Telegram-specific payload)
  S-->>TG: Ack callback query (answerCallbackQuery)
  TG-->>U: UI updates (in-place message/menu)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I boop a button, hop-hop—what fun!
Tiny payloads dance, a flow just begun.
Menus bloom like clover in spring,
Callback crumbs the trail I wing.
With a twitch of ears and tidy queues—
I edit the past, and update the views. 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly identifies the primary feature—adding inline callback button support to utils and types—and notes the related documentation updates, accurately reflecting the main change in this pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/utils.ts (1)

213-215: Add callback button support: LGTM.

Mapping to Markup.button.callback is correct and consistent with the new Button type.

Optional: consider guarding against callback_data > 64 bytes (Telegram limit) by truncating or validating upstream.

examples/callbackMenuAction.ts (1)

13-22: Validation vs example inconsistency.

The examples include “Show me the menu”, but validate() only accepts '/menu' or 'menu'. Consider broadening validation if you want that example to trigger.

Apply this minimal change if desired:

-    return text === '/menu' || 
-           text === 'menu' || 
+    return text === '/menu' || 
+           text.includes('menu') || 
            content.callback_data?.startsWith('menu_') ||
            content.callback_data === 'back';
src/messageManager.ts (3)

870-875: Enrich callback memory with context.

Include channelType and inReplyTo to align with message memories.

Apply this diff:

       const memory: Memory = {
         id: createUniqueUuid(this.runtime, `${chatId}-${Date.now()}`) as UUID,
         agentId: this.runtime.agentId,
         entityId: entityId,
         roomId: roomId,
         content: {
           text: data,
           callback_data: data,
           source: 'telegram',
+          channelType: getChannelType(chat),
+          inReplyTo:
+            'message_id' in message
+              ? createUniqueUuid(this.runtime, message.message_id.toString())
+              : undefined,
         } as TelegramContent,
         createdAt: Date.now(),
         embedding: undefined,
       };

161-163: Don’t drop error context in logs.

Recent changes removed error details. Keep at least the message for diagnostics.

Example pattern:

-      logger.error("Error processing document:");
+      logger.error({ error }, "Error processing document");

Apply similarly to other catch blocks shown in these ranges.

Also applies to: 225-227, 260-262, 323-323, 682-682


413-433: Inline keyboard with empty array.

Avoid sending reply_markup with an empty inline_keyboard; Telegram can reject it. Only include markup when buttons.length > 0.

Apply this diff:

-        const sentMessage = (await ctx.telegram.sendMessage(ctx.chat.id, chunk, {
+        const keyboard = telegramButtons.length ? Markup.inlineKeyboard(telegramButtons) : undefined;
+        const sentMessage = (await ctx.telegram.sendMessage(ctx.chat.id, chunk, {
           reply_parameters:
             i === 0 && replyToMessageId ? { message_id: replyToMessageId } : undefined,
           parse_mode: 'MarkdownV2',
-          ...Markup.inlineKeyboard(telegramButtons),
+          ...(keyboard ?? {}),
         })) as Message.TextMessage;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 07d8cd6 and 4bc2414.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • .gitignore (1 hunks)
  • README.md (2 hunks)
  • __tests__/utils.test.ts (2 hunks)
  • examples/callbackMenuAction.ts (1 hunks)
  • src/messageManager.ts (6 hunks)
  • src/service.ts (2 hunks)
  • src/types.ts (1 hunks)
  • src/utils.ts (1 hunks)
🔇 Additional comments (6)
.gitignore (1)

3-4: Good call on ignoring Cursor workspace artifacts.

Keeps local editor metadata out of version control.

src/service.ts (2)

178-179: Allowed updates expanded: LGTM.

Including 'callback_query' is required for inline callbacks; 'message_reaction' remains intact.


314-321: Callback query handler wiring: LGTM.

Delegating to MessageManager.handleCallbackQuery with try/catch is appropriate.

README.md (1)

110-217: Docs for callback buttons and menus: LGTM.

Clear examples, includes the 64‑byte note and best practices.

__tests__/utils.test.ts (1)

105-182: Tests for convertToTelegramButtons: LGTM.

Coverage looks good for url, login, callback, emoji, and mixed sets.

src/types.ts (1)

11-12: Type additions: LGTM.

TelegramContent.callback_data and the discriminated Button union are accurate.

Also applies to: 18-35

Comment on lines +824 to +927
/**
* Handles callback query from inline keyboard button press
* @param {NarrowedContext<Context, Update.CallbackQueryUpdate>} ctx - The callback query context
* @returns {Promise<void>}
*/
public async handleCallbackQuery(
ctx: NarrowedContext<Context, Update.CallbackQueryUpdate>
): Promise<void> {
try {
// Acknowledge the callback query immediately
await ctx.answerCbQuery();

const callbackQuery = ctx.callbackQuery;
const data = 'data' in callbackQuery ? callbackQuery.data : '';

if (!data) {
logger.warn('Received callback query without data');
return;
}

// Get the original message context
const message = callbackQuery.message;
if (!message || !('chat' in message)) {
logger.warn('Callback query without message context');
return;
}

const chat = message.chat;
const from = callbackQuery.from;

// Build memory object similar to handleMessage
const chatId = chat.id.toString();
const entityId = createUniqueUuid(this.runtime, from.id.toString()) as UUID;
const roomId = createUniqueUuid(
this.runtime,
'message_thread_id' in message && message.message_thread_id
? `${chat.id}-${message.message_thread_id}`
: chat.id.toString()
) as UUID;

// Create memory for the callback interaction
const memory: Memory = {
id: createUniqueUuid(this.runtime, `${chatId}-${Date.now()}`) as UUID,
agentId: this.runtime.agentId,
entityId: entityId,
roomId: roomId,
content: {
text: data,
callback_data: data,
source: 'telegram',
} as TelegramContent,
createdAt: Date.now(),
embedding: undefined,
};

// Process through runtime actions with edit capability
const callback: HandlerCallback = async (content: Content): Promise<Memory[]> => {
try {
// If content has buttons or text, we can edit the original message
if (content.text || content.buttons) {
const telegramButtons = convertToTelegramButtons((content as TelegramContent).buttons ?? []);
const messageText = convertMarkdownToTelegram(content.text ?? '');

if ('message_id' in message) {
await ctx.telegram.editMessageText(
chat.id,
message.message_id,
undefined,
messageText,
{
parse_mode: 'MarkdownV2',
...Markup.inlineKeyboard(telegramButtons),
}
);
}
}
} catch (error) {
logger.error('Error editing message after callback');
}
return [];
};

await this.runtime.processActions(memory, [], undefined, callback);

// Emit event for callback query received
this.runtime.emitEvent(EventType.MESSAGE_RECEIVED, {
runtime: this.runtime,
message: memory,
callback,
source: 'telegram',
channelType: getChannelType(chat),
});

} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(
{
error: errorMessage,
originalError: error,
},
'Error handling callback query'
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Editing message with empty text can 400; handle text-only, markup-only, and captions.

Current code calls editMessageText even when only buttons change, which can fail with "message_text_is_empty". Also doesn’t handle media captions. Use editMessageReplyMarkup when only buttons change; prefer ctx.editMessageText/ctx.editMessageCaption helpers; ignore “message is not modified” errors.

Apply this diff within handleCallbackQuery():

@@
-      const callback: HandlerCallback = async (content: Content): Promise<Memory[]> => {
+      const callback: HandlerCallback = async (content: Content): Promise<Memory[]> => {
         try {
-          // If content has buttons or text, we can edit the original message
-          if (content.text || content.buttons) {
-            const telegramButtons = convertToTelegramButtons((content as TelegramContent).buttons ?? []);
-            const messageText = convertMarkdownToTelegram(content.text ?? '');
-            
-            if ('message_id' in message) {
-              await ctx.telegram.editMessageText(
-                chat.id,
-                message.message_id,
-                undefined,
-                messageText,
-                {
-                  parse_mode: 'MarkdownV2',
-                  ...Markup.inlineKeyboard(telegramButtons),
-                }
-              );
-            }
-          }
+          const buttons = (content as TelegramContent).buttons ?? [];
+          const telegramButtons = convertToTelegramButtons(buttons);
+          const replyMarkup = telegramButtons.length
+            ? Markup.inlineKeyboard(telegramButtons).reply_markup
+            : undefined;
+
+          // Prefer ctx helpers; they handle inline_message_id automatically
+          const hasText = typeof content.text === 'string' && content.text.length > 0;
+
+          if (hasText) {
+            const messageText = convertMarkdownToTelegram(content.text!);
+            await ctx.editMessageText(messageText, {
+              parse_mode: 'MarkdownV2',
+              reply_markup: replyMarkup,
+            });
+          } else if (replyMarkup) {
+            // Only buttons changed
+            await ctx.editMessageReplyMarkup(replyMarkup);
+          }
         } catch (error) {
-          logger.error('Error editing message after callback');
+          const msg = error instanceof Error ? error.message : String(error);
+          // Avoid noisy logs for no-op edits
+          if (!/message is not modified/i.test(msg)) {
+            logger.error({ error: msg }, 'Error editing message after callback');
+          }
         }
         return [];
       };

Additionally, consider handling captioned messages:

+          else if (!hasText && replyMarkup && 'caption' in (message as any)) {
+            await ctx.editMessageCaption(undefined, { reply_markup: replyMarkup });
+          }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/messageManager.ts around lines 824 to 927, the callback handler attempts
to call editMessageText even when only inline buttons change (or when the new
text is empty), which triggers Telegram 400 "message_text_is_empty" and fails
for captioned media; change the edit logic to: determine whether only buttons
changed (no text) and call editMessageReplyMarkup (or
ctx.editMessageReplyMarkup) in that case; if the message has media with a
caption and you need to change caption call
editMessageCaption/ctx.editMessageCaption; otherwise call ctx.editMessageText
only when messageText is non-empty; wrap edits in try/catch and ignore benign
errors like "message is not modified" or the specific 400 for empty text,
logging other errors as before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant