Skip to main content
Version: 0.0.7

Handler Annotations

Handlers are methods in @BotController classes that respond to Telegram updates. Easygram provides annotations for every update type.

All Handler Annotations

AnnotationMatchesExample
@BotCommandBot command messages@BotCommand("/start")
@BotDefaultCommandAny command not matched@BotDefaultCommand
@BotTextExact plain-text message@BotText("hello")
@BotTextPatternRegex-matched text message@BotTextPattern("^\\d{10}$")
@BotTextDefaultAny text not matched@BotTextDefault
@BotCallbackQueryCallback query by data@BotCallbackQuery("btn_ok")
@BotDefaultCallbackQueryAny callback not matched@BotDefaultCallbackQuery
@BotDynamicCallbackQueryDynamic callback by server-side type@BotDynamicCallbackQuery("product_select")
@BotContactContact-sharing message@BotContact
@BotLocationLocation-sharing message@BotLocation
@BotReplyButtonReply keyboard button press@BotReplyButton(" Confirm")
@BotEditedMessageEdited text/media messages@BotEditedMessage
@BotChannelPostChannel publication messages@BotChannelPost
@BotEditedChannelPostEdited channel posts@BotEditedChannelPost
@BotInlineQueryInline queries@BotInlineQuery("search")
@BotChosenInlineResultChosen inline results@BotChosenInlineResult
@BotShippingQueryShipping address queries@BotShippingQuery
@BotPreCheckoutQueryPre-checkout events@BotPreCheckoutQuery
@BotPollPoll state changes@BotPoll
@BotPollAnswerUser-voted-in-poll events@BotPollAnswer
@BotMyChatMemberBot's own member status changes@BotMyChatMember
@BotChatMemberUpdateUser member status changes@BotChatMemberUpdate
@BotChatJoinRequestUser join requests to a chat@BotChatJoinRequest
@BotBusinessConnectionBusiness account connections@BotBusinessConnection
@BotBusinessMessageMessages via business account@BotBusinessMessage
@BotEditedBusinessMessageEdited messages via business account@BotEditedBusinessMessage
@BotDeletedBusinessMessagesBusiness messages-deleted events@BotDeletedBusinessMessages
@BotPaidMediaPurchasedPaid media purchase events@BotPaidMediaPurchased
@BotDefaultHandlerGlobal fallback@BotDefaultHandler
@BotExceptionHandlerException type handler@BotExceptionHandler(NullPointerException.class)

@BotCommand

Route bot commands (messages starting with /).

@BotCommand("/start")
public String onStart() {
return "Welcome!";
}

@BotCommand("/help")
public String onHelp() {
return "Commands: /start, /help, /cancel";
}

Multiple handlers for same command can use @BotOrder for priority:

@BotCommand("/admin")
@BotOrder(1) // Higher priority
public String onAdminAccess(User user) {
if (isAdmin(user)) return "Admin panel";
throw new UnauthorizedException();
}

@BotCommand("/admin")
@BotOrder(100) // Lower priority, fallback
public String onAdminDefault() {
return "Admin access denied";
}

@BotText

Route exact plain-text messages (case-sensitive, full-string match).

@BotText("hello") // Case-sensitive
public String onHello() {
return "Hi there!";
}

@BotText("goodbye")
public String onGoodbye() {
return "See you later!";
}

@BotTextPattern

Route text messages matching a regular expression. Use for dynamic or structural inputs such as phone numbers, order IDs, or messages following a known prefix.

Matching uses Matcher.find() — the pattern does not need to match the full string unless you use ^ / $ anchors. Patterns are compiled once at startup and cached.

// Match 10-digit phone numbers only
@BotTextPattern("^\\d{10}$")
public String onPhone(@BotTextValue String phone) {
return "Got your number: " + phone;
}

// Match messages starting with a keyword
@BotTextPattern({"^buy .+", "^order .+"})
public String onPurchaseIntent(@BotTextValue String text) {
return "Processing your request: " + text;
}

Can be combined with @BotChatState to match regex input only in a specific conversation state.

@BotCallbackQuery

Route inline button clicks (callback queries).

@BotCallbackQuery("btn_yes")
public String onYesClicked() {
return "You clicked Yes!";
}

@BotCallbackQuery("btn_no")
public String onNoClicked() {
return "You clicked No!";
}

Typically used with inline keyboards:

@BotCommand("/poll")
public PlainReply onPoll() {
InlineKeyboardMarkup keyboard = InlineKeyboardMarkup.builder()
.keyboardRow(
InlineKeyboardButton.builder()
.text("Yes")
.callbackData("btn_yes")
.build(),
InlineKeyboardButton.builder()
.text("No")
.callbackData("btn_no")
.build()
)
.build();
return PlainReply.of("Do you like this?").withMarkup(keyboard);
}

@BotDynamicCallbackQuery

Routes callback queries using a server-side type resolved via BotDynamicCallbackQueryService. Unlike @BotCallbackQuery (which matches the raw Telegram callback data string), this annotation stores structured data server-side and uses the raw callback data only as a lookup key. Ideal when callback payloads exceed Telegram's 64-byte limit or need rich structured fields.

@BotDynamicCallbackQuery("product_select")
public String onProductSelect(BotDynamicCallbackData data) {
Long id = (Long) data.getData().get("id");
return "You selected product #" + id;
}

Register the callback data before sending the button:

BotDynamicCallbackData payload = BotDynamicCallbackData.of("product_select",
Map.of("id", 42L));
String callbackKey = dynamicCallbackQueryService.save(payload);
// use callbackKey as the inline button's callbackData

@BotContact

Route contact-sharing messages (when user shares their phone via keyboard).

@BotContact
public String onContactShared(Contact contact) {
return "Got your contact: " + contact.getPhoneNumber();
}

@BotLocation

Route location-sharing messages.

@BotLocation
public String onLocationShared(Location location) {
return "Your location: Lat=" + location.getLatitude() + ", Lon=" + location.getLongitude();
}

@BotDefaultCommand, @BotTextDefault, @BotDefaultCallbackQuery

Fallback handlers for unmatched updates of specific type.

@BotDefaultCommand
public String onUnknownCommand() {
return "Unknown command. Try /help";
}

@BotTextDefault
public String onUnknownText() {
return "I don't understand that text.";
}

@BotDefaultCallbackQuery
public String onUnknownCallback() {
return "Unknown button action.";
}

@BotReplyButton

Routes a plain-text message that exactly matches a reply keyboard button label. This is how Telegram delivers button presses — as plain text messages with the button's label as content.

Without core-i18n — values are matched as literal strings:

@BotReplyButton(" Confirm")
public String onConfirm() {
return "Confirmed!";
}

@BotReplyButton({" Cancel", "Back"})
@BotClearChatState
public String onCancel() {
return "Cancelled. State cleared.";
}

With core-i18n — values are treated as message-bundle keys. The framework resolves each key in the user's current locale and compares the result against the incoming text. A single annotation covers all supported languages automatically:

// messages/bot_en.properties: btn.confirm= Confirm
// messages/bot_ru.properties: btn.confirm= Подтвердить
@BotReplyButton("btn.confirm")
public String onConfirm() {
return "Confirmed!";
}

New Update Type Handlers (0.0.2)

Easygram 0.0.2 adds handler annotations for every remaining Telegram Update field that was previously unaddressed. Each annotation follows the same rules as the existing ones: declare it on a method inside a @BotController class, inject the relevant update-specific types as method parameters, and combine freely with @BotChatState, @BotOrder, and other meta-annotations. All annotations live in uz.osoncode.easygram.core.bind.annotation.

@BotEditedMessage

Routes updates where the user edits a previously sent text or media message (update.getEditedMessage()).

No configurable attributes.

@BotEditedMessage
public String onEdited(Message editedMessage) {
return "You edited your message to: " + editedMessage.getText();
}

@BotChannelPost

Routes new messages published to a channel that the bot administers (update.getChannelPost()).

No configurable attributes.

@BotChannelPost
public void onChannelPost(Message post) {
log.info("New channel post: {}", post.getText());
}

@BotEditedChannelPost

Routes edits to channel posts (update.getEditedChannelPost()).

No configurable attributes.

@BotEditedChannelPost
public void onEditedChannelPost(Message edited) {
log.info("Channel post edited: {}", edited.getText());
}

@BotInlineQuery

Routes inline queries — triggered when users type @BotUsername … in any chat (update.getInlineQuery()).

AttributeTypeDefaultDescription
valueString[]{}Match inline query text; empty = all inline queries

Without core-i18n — values are matched as literal strings:

// Match all inline queries
@BotInlineQuery
public void onAnyInlineQuery(InlineQuery query, @BotInlineQueryValue String queryText) {
log.info("Inline query: {}", queryText);
}

// Match only when query text is exactly "search"
@BotInlineQuery("search")
public void onSearchQuery(InlineQuery query, @BotInlineQueryValue String queryText) {
// Handle search inline query
}

With core-i18n — values are treated as message-bundle keys. The framework resolves each key in the user's current locale and compares the result against the incoming query text. A single annotation covers all supported languages automatically:

// messages/bot_en.properties: inline.search=search
// messages/bot_ru.properties: inline.search=поиск
@BotInlineQuery("inline.search")
public void onSearchQuery(InlineQuery query, @BotInlineQueryValue String queryText) {
// matches "@YourBot search" for English users
// and "@YourBot поиск" for Russian users
}

@BotChosenInlineResult

Routes the event fired when a user selects a result from an inline query (update.getChosenInlineQuery()). Requires "inline feedback" to be enabled in BotFather.

No configurable attributes.

@BotChosenInlineResult
public void onChosenResult(
ChosenInlineResult result,
@BotChosenInlineResultId String resultId) {
log.info("User chose inline result: {}", resultId);
}

@BotShippingQuery

Routes shipping address queries for payments with flexible shipping (update.getShippingQuery()).

No configurable attributes.

@BotShippingQuery
public void onShipping(
ShippingQuery query,
@BotShippingPayload String invoicePayload) {
log.info("Shipping query for invoice: {}", invoicePayload);
}

@BotPreCheckoutQuery

Routes pre-checkout events sent just before a payment is confirmed (update.getPreCheckoutQuery()). You must answer with AnswerPreCheckoutQuery within 10 seconds.

No configurable attributes.

@BotPreCheckoutQuery
public void onPreCheckout(
PreCheckoutQuery query,
@BotPreCheckoutPayload String invoicePayload,
TelegramClient client) throws TelegramApiException {
client.execute(AnswerPreCheckoutQuery.builder()
.preCheckoutQueryId(query.getId())
.ok(true)
.build());
}

@BotPoll

Routes poll state-change updates — sent when a poll is stopped or its vote counts change (update.getPoll()).

No configurable attributes.

@BotPoll
public void onPollUpdate(Poll poll) {
log.info("Poll '{}' updated, total voters: {}", poll.getQuestion(), poll.getTotalVoterCount());
}

@BotPollAnswer

Routes events fired when a user votes in a non-anonymous poll (update.getPollAnswer()).

No configurable attributes.

@BotPollAnswer
public void onPollAnswer(PollAnswer answer) {
log.info("User {} voted option(s) {}", answer.getUser().getId(), answer.getOptionIds());
}

@BotMyChatMember

Routes updates about the bot's own membership status changes in a chat (update.getMyChatMember()). Useful for detecting when the bot is added to or removed from groups/channels.

No configurable attributes.

@BotMyChatMember
public void onBotMembershipChange(ChatMemberUpdated updated) {
log.info("Bot status in chat {} changed from {} to {}",
updated.getChat().getId(),
updated.getOldChatMember().getStatus(),
updated.getNewChatMember().getStatus());
}

@BotChatMemberUpdate

Routes updates about a user's membership status changes in a chat (update.getChatMember()). Requires the bot to be an administrator.

No configurable attributes.

@BotChatMemberUpdate
public void onUserMembershipChange(ChatMemberUpdated updated) {
log.info("User {} status changed in chat {}",
updated.getFrom().getId(), updated.getChat().getId());
}

@BotChatJoinRequest

Routes join requests submitted to a chat that requires admin approval (update.getChatJoinRequest()).

No configurable attributes.

@BotChatJoinRequest
public void onJoinRequest(ChatJoinRequest request, TelegramClient client) throws TelegramApiException {
// Approve the request automatically
client.execute(ApproveChatJoinRequest.builder()
.chatId(request.getChat().getId())
.userId(request.getUser().getId())
.build());
}

@BotBusinessConnection

Routes updates about business account connections (update.getBusinessConnection()). Fired when a user connects or disconnects their business account from the bot.

No configurable attributes.

@BotBusinessConnection
public void onBusinessConnection(BusinessConnection connection) {
log.info("Business connection {} is enabled: {}", connection.getId(), connection.getIsEnabled());
}

@BotBusinessMessage

Routes messages sent on behalf of a connected business account (update.getBusinessMessage()).

No configurable attributes.

@BotBusinessMessage
public void onBusinessMessage(Message message) {
log.info("Business message from {}: {}", message.getChat().getId(), message.getText());
}

@BotEditedBusinessMessage

Routes edits to messages sent via a connected business account (update.getEditedBuinessMessage()).

No configurable attributes.

@BotEditedBusinessMessage
public void onEditedBusinessMessage(Message edited) {
log.info("Business message edited in chat {}", edited.getChat().getId());
}

@BotDeletedBusinessMessages

Routes events fired when messages sent via a business account are deleted (update.getDeletedBusinessMessages()).

No configurable attributes.

@BotDeletedBusinessMessages
public void onDeletedBusinessMessages(BusinessMessagesDeleted deleted) {
log.info("Deleted {} messages in chat {}",
deleted.getMessageIds().size(), deleted.getChat().getId());
}

@BotPaidMediaPurchased

Routes paid media purchase events. Inject the raw Update and call update.getPaidMediaPurchased() to access the payload.

No configurable attributes.

@BotPaidMediaPurchased
public void onPaidMedia(Update update) {
var purchase = update.getPaidMediaPurchased();
log.info("Paid media purchased by user {}, payload: {}",
purchase.getFrom().getId(), purchase.getPaidMediaPayload());
}

@BotDefaultHandler

Global fallback for any unmatched update.

@BotDefaultHandler
public String onDefault() {
return "I didn't understand. Try /start or /help";
}

Used last, after all other handlers fail.

@BotExceptionHandler

Handle specific exception types.

@BotExceptionHandler(IllegalArgumentException.class)
public String handleValidationError(IllegalArgumentException e) {
return "Validation error: " + e.getMessage();
}

@BotExceptionHandler(Exception.class)
public String handleGenericError(Exception e) {
return "Sorry, an error occurred!";
}

Scoped to the containing @BotController class. For global exception handling across all controllers, use @BotControllerAdvice.

@BotOrder

Control execution priority when multiple handlers match the same update.

Lower value = higher priority. Default: Integer.MAX_VALUE.

When two handlers share the same @BotOrder value, execution order is determined by the order they were registered (typically class load order) — which is undefined. Always use distinct values when handler priority matters.

@BotCommand("/start")
@BotOrder(1)
public String vipStart(User user) {
if (isVip(user)) return "Welcome, VIP!";
throw new UnauthorizedException(); // Try next handler
}

@BotCommand("/start")
@BotOrder(100)
public String defaultStart() {
return "Welcome!";
}
Skipping to the next handler

Throwing an exception inside a handler causes the dispatcher to skip to the next matching handler (with a higher @BotOrder value). Use this pattern to implement priority-based access control without early return logic.

Duplicate Mapping Detection

Easygram detects ambiguous handler registrations at startup, analogous to Spring MVC failing when two @GetMapping methods share the same path.

If two handler methods share the same routing condition — same annotation type, same value, and same effective @BotChatState — the application fails to start with a BeanCreationException that identifies both conflicting methods:

Duplicate handler mapping detected for condition [specific:BotCommand:/start:state:]:
First : com.example.BotA#onStart
Second : com.example.BotB#onStart
Remove or rename one of the conflicting handler methods.

This check spans all @BotController beans — placing conflicting methods in different controllers does not avoid the error.

Non-conflicting cases

// ✅ Different @BotChatState — different tiers
@BotCommand("/start")
@BotChatState("ONBOARDING")
public String onStartOnboarding() { ... }

@BotCommand("/start")
@BotChatState("MAIN_MENU")
public String onStartMainMenu() { ... }

// ✅ Different values
@BotCommand("/start")
public String onStart() { ... }

@BotCommand("/help")
public String onHelp() { ... }

// ✅ Same condition, different @BotOrder — valid priority-based dispatch
@BotCommand("/admin")
@BotOrder(1)
public String onAdminForAdmins(User user) { ... }

@BotCommand("/admin")
@BotOrder(100)
public String onAdminFallback() { ... }

@BotOrder is intentionally excluded from the conflict key — two methods with different @BotOrder values and the same routing condition are resolved by priority and are not an error.


Startup Annotation Validation

In addition to duplicate mapping detection, Easygram validates annotation values at startup and rejects blank names that would create untriggerable or ambiguous handlers.

The following throw BeanCreationException on application startup if the value is empty or blank:

AnnotationField validated
@BotMarkup("name")name — the markup registry key
@BotReplyMarkup("name")value — references a registered markup by name
@BotForwardChatState("state")value — the target chat state to transition to

Example error:

BeanCreationException: @BotMarkup name must not be blank on:
com.example.config.KeyboardConfig#mainMenu

This prevents subtle runtime bugs where a handler silently fails to attach a keyboard or transition state because an accidental empty string was passed.

Pair with duplicate detection

Both validation checks fire during the same startup scan in BotHandlerLoader. The application fails at the first detected problem; fix all violations and restart to confirm all issues are resolved.

When an update arrives, Easygram searches in this order:

  1. Tier 1: State handlers (if @BotChatState matches user's state)
  2. Tier 2: Spec handlers (@BotCommand, @BotText, etc.)
  3. Tier 3: Default handlers (@BotDefaultHandler, etc.)

Within each tier, handlers with lower @BotOrder value are tried first.

First matching handler is executed; others are skipped.

Example: Complete Handler Class

@BotController
public class MyBotHandler {

@BotCommand("/start")
public String onStart(User user) {
return "Hello, " + user.getFirstName() + "!";
}

@BotCommand("/help")
@BotOrder(1)
public String onHelp() {
return "Commands: /start, /help, /cancel";
}

@BotText("hello")
public String onHello() {
return "Hi there!";
}

@BotCallbackQuery("btn_yes")
public String onYesButton() {
return "Thanks for clicking!";
}

@BotContact
public String onContactShared(Contact contact) {
return "Got your number!";
}

@BotDefaultHandler
public String onDefault() {
return "I don't understand. Try /help";
}

@BotExceptionHandler(UnauthorizedException.class)
public String handleUnauthorized(UnauthorizedException e) {
return "You don't have permission!";
}
}

Next: Learn about parameter injection to access user data and message content.