Dynamic Callback Queries
Telegram limits inline button callback data to 64 bytes. When you need to pass richer
payloads through a button click — product IDs, form state, multi-field context — you can
store the data server-side and put only a lookup key in the button. Easygram provides
first-class support for this pattern via @BotDynamicCallbackQuery.
How it works
- Build the button — store a
BotDynamicCallbackDatapayload viaBotDynamicCallbackQueryServiceand put a generated UUID key as the button'scallbackData.BotKeyboardFactorydoes this in one call. - User clicks the button — Telegram sends a callback query containing the UUID key.
- Framework resolves the payload —
BotDynamicCallbackQueryMetaDataResolverlooks up the key, checks the payload'stypefield, and routes to the matching handler. - Handler receives the data —
BotDynamicCallbackDatais injected as a method parameter.
Button callbackData = "a3f2b1c0-…" (UUID key)
↓
BotDynamicCallbackQueryService.resolve("a3f2b1c0-…")
↓
BotDynamicCallbackData { type = "product_buy", data = { id: 42, currency: "USD" } }
↓
@BotDynamicCallbackQuery("product_buy") handler invoked
BotDynamicCallbackData
An immutable model with a type discriminator and a flexible Map<String, Object> payload.
BotDynamicCallbackData payload = BotDynamicCallbackData.builder()
.type("product_buy")
.put("id", 42L)
.put("currency", "USD")
.build();
String type = payload.getType(); // "product_buy"
Long id = (Long) payload.getData().get("id"); // 42
BotDynamicCallbackQueryService
SPI interface for storing and retrieving payloads. The default in-memory implementation is
registered automatically. Replace it with your own @Bean for Redis or database persistence.
public interface BotDynamicCallbackQueryService {
BotDynamicCallbackData resolve(String callbackData);
void store(String callbackData, BotDynamicCallbackData payload);
void remove(String callbackData);
}
Custom implementation (e.g., Redis)
@Bean
public BotDynamicCallbackQueryService redisDynamicCallbackService(RedisTemplate<String, Object> redis) {
return new RedisBotDynamicCallbackQueryService(redis);
}
@BotDynamicCallbackQuery
Method-level annotation that routes callback queries by payload type.
@BotController
public class ProductHandler {
@BotDynamicCallbackQuery("product_buy")
public String onBuy(BotDynamicCallbackData data) {
Long id = (Long) data.getData().get("id");
return "You bought product #" + id + "!";
}
@BotDynamicCallbackQuery("product_view")
public String onView(BotDynamicCallbackData data) {
return "Viewing product #" + data.getData().get("id");
}
}
Multiple types can be matched in one handler:
@BotDynamicCallbackQuery({"product_buy", "product_view"})
public String onProductAction(BotDynamicCallbackData data) {
return "Action: " + data.getType();
}
Building dynamic buttons with BotKeyboardFactory
Direct helper
@Autowired BotKeyboardFactory keyboardFactory;
// Single button — UUID key generated automatically, payload stored in service
InlineKeyboardButton btn = keyboardFactory.dynamicInlineButton(
"btn.buy",
BotDynamicCallbackData.builder().type("product_buy").put("id", productId).build(),
request
);
Fluent builder — dynamicRow
InlineKeyboardMarkup keyboard = keyboardFactory.inline(request)
.dynamicRow("btn.buy",
BotDynamicCallbackData.builder().type("product_buy").put("id", productId).build())
.dynamicRow(
BotKeyboardFactory.entry("btn.view",
BotDynamicCallbackData.builder().type("product_view").put("id", productId).build()),
BotKeyboardFactory.entry("btn.cancel",
BotDynamicCallbackData.builder().type("product_cancel").put("id", productId).build()))
.build();
Cleaning up after handling
Call remove() after processing to prevent unbounded memory growth (especially with the
default in-memory service):
@Autowired BotDynamicCallbackQueryService dynamicService;
@BotDynamicCallbackQuery("product_buy")
public String onBuy(BotDynamicCallbackData data, @BotCallbackQueryData String callbackKey) {
dynamicService.remove(callbackKey); // clean up the stored entry
Long id = (Long) data.getData().get("id");
return "Bought product #" + id;
}
Full example
@BotController
public class ShopHandler {
@Autowired private BotKeyboardFactory keyboardFactory;
@Autowired private BotDynamicCallbackQueryService dynamicService;
@BotCommand("/shop")
public SendMessage showProducts(BotRequest request) {
long productId = 42L;
InlineKeyboardMarkup keyboard = keyboardFactory.inline(request)
.dynamicRow(
BotKeyboardFactory.entry("btn.buy",
BotDynamicCallbackData.builder()
.type("product_buy").put("id", productId).build()),
BotKeyboardFactory.entry("btn.details",
BotDynamicCallbackData.builder()
.type("product_view").put("id", productId).build()))
.build();
return SendMessage.builder()
.chatId(request.getChat().getId())
.text("Choose an action:")
.replyMarkup(keyboard)
.build();
}
@BotDynamicCallbackQuery("product_buy")
public String onBuy(BotDynamicCallbackData data, @BotCallbackQueryData String key) {
dynamicService.remove(key);
return "You bought product #" + data.getData().get("id") + "!";
}
@BotDynamicCallbackQuery("product_view")
public String onView(BotDynamicCallbackData data) {
return "Details for product #" + data.getData().get("id");
}
}
Memory management
The default InMemoryBotDynamicCallbackQueryService is an unbounded ConcurrentHashMap.
For long-running bots, either:
- Call
dynamicService.remove(key)in every handler, or - Use a TTL-backed store (e.g., Redis with key expiry).