Keyboard, payload handling
- Chapter
- 7 / 12
- Time
- 4 min
- Code
- 17
Keyboards are usually the first thing that makes a bot feel friendlier.
Without keyboards, a bot often feels like this:
- the user has to remember commands
- the user has to type choices manually
- you keep checking raw text by hand
With keyboards, the interaction becomes much nicer.
Telegrinder has two main keyboard types:
- regular
Keyboard - inline
InlineKeyboard
Regular keyboard#
This is the keyboard that replaces the user's system keyboard inside Telegram.
A very small example:
from telegrinder.tools import Button, Keyboard
keyboard = (
Keyboard()
.add(Button("1"))
.add(Button("2"))
.row()
.add(Button("3"))
)What is happening here:
Keyboard()creates the keyboard object.add(...)adds a button to the current row.row()starts a new row
Send it like this:
@bot.on.message(Text("/keyboard"))
async def handle_keyboard_command(message: Message) -> None:
await message.answer(
"Here is your keyboard",
reply_markup=keyboard.get_markup(),
)get_markup() converts the telegrinder keyboard object into the Telegram API markup object.
Handling a regular button press#
For a regular keyboard, pressing a button is just a new incoming message.
So the simplest handler is usually a Text(...) rule:
@bot.on.message(Text("1"))
async def handle_press_button(message: Message) -> None:
await message.answer("You pressed button 1")That makes regular keyboards a very nice starting point, because you do not have to think about callback data yet.
Static keyboards#
When a keyboard is reused often and does not change much, defining it as a class is usually cleaner.
from telegrinder.tools.keyboard import Button, Keyboard
class FruitsKeyboard(Keyboard, max_in_row=2, one_time_keyboard=True):
APPLE = Button("Apple")
BANANA = Button("Banana")
KIWI = Button("Kiwi")Why this feels nice:
- the keyboard is described in one place
- you do not rebuild it in every handler
- buttons can also be used as rules
Sending it:
@bot.on.message(Text("/eat"))
async def eat(message: Message) -> None:
await message.answer(
"What do you want to eat?",
reply_markup=FruitsKeyboard.get_markup(),
)And now the fun part:
@bot.on.message(FruitsKeyboard.APPLE)
async def eat_apple(message: Message) -> None:
await message.answer(
"Good choice",
reply_markup=FruitsKeyboard.get_keyboard_remove(),
)Here FruitsKeyboard.APPLE acts both as a button and as a rule.
That is a very pleasant pattern for menus.
Button styles#
Telegrinder also has styled buttons:
DangerButtonandDangerInlineButtonPrimaryButtonandPrimaryInlineButtonSuccessButtonandSuccessInlineButton
These are useful because they visually hint what kind of action the user is about to trigger.
For example:
from telegrinder import DangerButton, Keyboard, PrimaryButton, SuccessButton
class MenuKeyboard(Keyboard, max_in_row=2):
PROFILE = SuccessButton("Profile")
BALANCE = PrimaryButton("Balance")
EXIT = DangerButton("Exit")A practical rule of thumb:
Successfor confirmationPrimaryfor the main actionDangerfor cancel, delete, or exit
Not required, but helpful for UX.
Inline keyboards#
Inline buttons live directly under a message and usually work together with callback_data.
Example:
from telegrinder.tools.keyboard import InlineButton, InlineKeyboard
inline_keyboard = (
InlineKeyboard()
.add(InlineButton("1", callback_data="button/1"))
.add(InlineButton("2", callback_data="button/2"))
.row()
.add(InlineButton("3", callback_data="button/3"))
)Sending it:
@bot.on.message(Text("/inline_keyboard"))
async def handle_inline_keyboard_command(message: Message) -> None:
await message.answer(
"Here is your inline keyboard",
reply_markup=inline_keyboard.get_markup(),
)Inline buttons are especially good when:
- you do not want extra messages in chat
- you want to edit the current message
- the action should be encoded in
callback_data
What payload means here#
When the user presses an inline button, Telegram usually does not send you the button text as a new message.
Instead, it sends a callback_query, and inside it there is callback_data.
That is what people usually mean by payload in this context.
Telegrinder has two rule families for this:
- older
CallbackData* - newer and more direct
Payload*Rule
For new code, Payload*Rule is usually the easier path.
The simplest payload#
A plain string.
from telegrinder import CallbackQuery
from telegrinder.rules import PayloadEqRule
@bot.on.callback_query(PayloadEqRule("button/1"))
async def handle_press_inline_button(cb: CallbackQuery) -> None:
await cb.answer("Button 1 pressed")This is the best place to begin.
You give the button a string:
InlineButton("1", callback_data="button/1")and catch the same string in the rule.
Payload by template#
If you have many buttons and only one part changes, a template is cleaner.
from telegrinder.rules import PayloadMarkupRule
@bot.on.callback_query(PayloadMarkupRule("button/<index:int>"))
async def handle_press_inline_button(cb: CallbackQuery, index: int) -> None:
# index arrives as an int automatically.
await cb.answer(f"Button {index} pressed")This is especially useful for:
- pagination
- item lists
- menu entries with ids
Payload as a model#
For more serious menus, models are often much nicer than raw strings.
callback_data may be:
strdictdataclassmsgspec.Struct
Example:
import msgspec
from telegrinder.tools.keyboard import InlineButton, InlineKeyboard
class ItemModel(msgspec.Struct):
item: str
amount: int
action: str
keyboard = (
InlineKeyboard()
.add(
InlineButton(
"Buy doughnut",
callback_data=ItemModel(item="doughnut", amount=100, action="buy"),
)
)
)Handling it:
from telegrinder.rules import PayloadModelRule
@bot.on.callback_query(PayloadModelRule(ItemModel, alias="data"))
async def buy(cb: CallbackQuery, data: ItemModel) -> None:
# data is already a ready model object.
await cb.edit_text(f"You bought {data.item} for {data.amount}")Why this feels good:
- no need to manually pack fields into a string
- no manual parsing on the way back
- types help keep things straight
For small bots strings are fine. For shops, admin panels, and more complex menus, models are usually much nicer.
Payload serializers#
Telegrinder has two main serializers:
JSONSerializerMsgPackSerializer
Import:
from telegrinder.tools import JSONSerializer, MsgPackSerializerSometimes the model itself can declare which serializer it prefers:
import dataclasses
from telegrinder.tools import MsgPackSerializer
@dataclasses.dataclass(slots=True, frozen=True)
class StoreCallback:
__key__ = "store"
__serializer__ = MsgPackSerializer["StoreCallback"]
action: str
item: str
price: intThat is useful when you want the serialization logic to stay close to the payload model itself.
Using an inline button directly as a rule#
Static inline buttons can be used directly in decorators.
class MainMenuKeyboard(InlineKeyboard):
show_fact = PrimaryInlineButton("Show fact", callback_data="menu/fact", new_row=True)
exit = DangerInlineButton("Exit", callback_data="menu/exit")
@bot.on.callback_query(MainMenuKeyboard.show_fact)
async def show_fact(cb: CallbackQuery) -> None:
await cb.answer("Interesting fact")This is one of the nicest telegrinder patterns:
- the button is defined next to the menu
- the handler uses the button itself
- fewer chances to mistype a payload string
A practical beginner path#
If you do not want to overcomplicate things, a good path is:
- Start with
Keyboardfor simple menus. - Move to
InlineKeyboardfor actions under a message. - Use plain strings and
PayloadEqRulefor simple callback handling. - Use
PayloadMarkupRulewhen callback strings become structured. - Use models and
PayloadModelRulewhen the menu starts becoming complex.
You do not need every part of the API at once.
What to remember#
Keyboardis great for simple user-facing menusInlineKeyboardis great for actions under a message- static keyboards are often best expressed as classes
- styled buttons help make the interface clearer
- for payload handling, starting with strings is fine, and moving to templates or models later is perfectly normal