feat(telegram): add inline callback buttons to utils and types; docs+…#15
feat(telegram): add inline callback buttons to utils and types; docs+…#15o-ray-o wants to merge 1 commit intoelizaos-plugins:1.xfrom
Conversation
WalkthroughAdds 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
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.
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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
| /** | ||
| * 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' | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
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:
Changes
1. Type System Updates
Buttontype (src/types.ts): Added discriminated union variant for callback buttons withkind: 'callback'andcallback_datafieldTelegramContentinterface: Added optionalcallback_datafield to track button press data2. Core Functionality
src/utils.ts): UpdatedconvertToTelegramButtons()to handle callback buttons usingMarkup.button.callback()src/messageManager.ts): ImplementedhandleCallbackQuery()method that:3. Service Integration
src/service.ts):'callback_query'toallowedUpdatesin bot launch config4. Documentation & Examples
examples/callbackMenuAction.ts):5. Test Coverage
__tests__/utils.test.ts): Added 8 new test cases for callback button conversionTesting
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
Build Verification
Manual Testing Checklist
Compliance Verification
.cursor/linter.mdc):anytypes in new code.cursor/logger.mdc):loggerfrom @elizaos/coreUsage Example
Breaking Changes
None. This feature is fully backward compatible:
Migration Guide
No migration needed. To use callback buttons, simply add buttons with
kind: 'callback':Future Enhancements
Related Issues
Checklist
Screenshots/Demo
The implementation enables creating interactive experiences like:
See
examples/callbackMenuAction.tsfor a complete working demonstration.Summary by CodeRabbit