Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist
node_modules
.turbo
.turbo
.cursor
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,71 @@ bun start --character="characters/your-character.json"

## Utilizing Telegram Buttons

To send a message with native Telegram buttons, include an array of buttons in the message content. The following action demonstrates how to initiate a login flow using a Telegram button.
To send a message with native Telegram buttons, include an array of buttons in the message content. The plugin supports three types of buttons:

### Button Types

1. **URL Buttons** - Opens a URL when clicked
2. **Login Buttons** - Initiates Telegram authentication flow
3. **Callback Buttons** - Triggers actions without opening URLs (perfect for interactive menus)

### Inline Menus with Callback Buttons

Callback buttons enable interactive menus that update in place, avoiding message spam. When a user clicks a callback button, the bot receives the `callback_data` and can edit the original message with new content and buttons.

```typescript
// Button type definitions
type Button =
| { kind: 'url'; text: string; url: string }
| { kind: 'login'; text: string; url: string }
| { kind: 'callback'; text: string; callback_data: string };
```

### Example: Interactive Menu Action

```typescript
export const menuAction: Action = {
name: 'INTERACTIVE_MENU',
description: 'Demonstrates interactive menu using callback buttons',

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;
const data = content.callback_data;

if (data === 'menu_settings') {
// Edit message with settings submenu
await callback({
text: '⚙️ Settings',
buttons: [
{ kind: 'callback', text: '🔔 Notifications', callback_data: 'menu_notif' },
{ kind: 'callback', text: '🌍 Language', callback_data: 'menu_lang' },
{ kind: 'callback', text: '⬅️ Back', callback_data: 'menu_main' }
]
});
} else {
// Show main menu
await callback({
text: '📱 Main Menu',
buttons: [
{ kind: 'callback', text: '📊 Stats', callback_data: 'menu_stats' },
{ kind: 'callback', text: '⚙️ Settings', callback_data: 'menu_settings' },
{ kind: 'callback', text: 'ℹ️ Help', callback_data: 'menu_help' }
]
});
}

return true;
}
};
```

### Example: Login Flow with URL Button

```typescript
export const initAuthHandshakeAction: Action = {
Expand Down Expand Up @@ -141,3 +205,13 @@ export const initAuthHandshakeAction: Action = {
},
};
```

### Best Practices for Callback Buttons

- **Use callback buttons for interactive menus** - They update in place without creating new messages
- **Keep callback_data concise** - Telegram limits it to 64 bytes
- **Acknowledge callback queries promptly** - The plugin automatically calls `answerCbQuery()` to provide user feedback
- **Structure callback_data logically** - Use prefixes like `menu_`, `action_`, `confirm_` for better organization
- **Provide navigation** - Always include a "Back" button in submenus for better UX

See the `examples/callbackMenuAction.ts` file for a complete implementation of an interactive menu system.
85 changes: 83 additions & 2 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { convertMarkdownToTelegram, splitMessage } from '../src/utils';
import { describe, expect, it, vi } from 'vitest';
import { convertMarkdownToTelegram, splitMessage, convertToTelegramButtons } from '../src/utils';
import { Markup } from 'telegraf';
import type { Button } from '../src/types';

describe('Telegram Utils', () => {
describe('splitMessage', () => {
Expand Down Expand Up @@ -99,4 +101,83 @@ describe('Telegram Utils', () => {
expect(result).toBe('\\[Link\\]\\(https://example\\.com/path\\(with\\)parentheses\\)');
});
});

describe('convertToTelegramButtons', () => {
it('should handle empty button array', () => {
const result = convertToTelegramButtons([]);
expect(result).toEqual([]);
});

it('should handle null buttons', () => {
const result = convertToTelegramButtons(null);
expect(result).toEqual([]);
});

it('should handle undefined buttons', () => {
const result = convertToTelegramButtons(undefined);
expect(result).toEqual([]);
});

it('should convert URL button correctly', () => {
const buttons: Button[] = [
{ kind: 'url', text: 'Visit Website', url: 'https://example.com' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(Markup.button.url('Visit Website', 'https://example.com'));
});

it('should convert login button correctly', () => {
const buttons: Button[] = [
{ kind: 'login', text: 'Login', url: 'https://example.com/auth' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(Markup.button.login('Login', 'https://example.com/auth'));
});

it('should convert callback button correctly', () => {
const buttons: Button[] = [
{ kind: 'callback', text: 'Click Me', callback_data: 'action_clicked' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(Markup.button.callback('Click Me', 'action_clicked'));
});

it('should handle mixed button types', () => {
const buttons: Button[] = [
{ kind: 'url', text: 'Website', url: 'https://example.com' },
{ kind: 'callback', text: 'Settings', callback_data: 'open_settings' },
{ kind: 'login', text: 'Auth', url: 'https://example.com/login' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(3);
expect(result[0]).toEqual(Markup.button.url('Website', 'https://example.com'));
expect(result[1]).toEqual(Markup.button.callback('Settings', 'open_settings'));
expect(result[2]).toEqual(Markup.button.login('Auth', 'https://example.com/login'));
});

it('should handle buttons with emoji in text', () => {
const buttons: Button[] = [
{ kind: 'callback', text: '📊 Statistics', callback_data: 'stats' },
{ kind: 'callback', text: '⚙️ Settings', callback_data: 'settings' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(Markup.button.callback('📊 Statistics', 'stats'));
expect(result[1]).toEqual(Markup.button.callback('⚙️ Settings', 'settings'));
});

it('should handle callback data with special characters', () => {
const buttons: Button[] = [
{ kind: 'callback', text: 'Action', callback_data: 'menu_settings_notifications' },
{ kind: 'callback', text: 'Back', callback_data: 'back:main-menu' }
];
const result = convertToTelegramButtons(buttons);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(Markup.button.callback('Action', 'menu_settings_notifications'));
expect(result[1]).toEqual(Markup.button.callback('Back', 'back:main-menu'));
});
});
});
Loading