Registration Wizard Example
A multi-step registration form implemented as a chat-state machine. The bot guides the user
through three steps (name → age → city), validates input at each step, and provides a cancel
button at every stage. This example demonstrates the full @BotChatState / @BotForwardChatState
/ @BotClearChatState state machine.
Full source: samples/chatstate-bot
State Machine Diagram
/register
↓ @BotForwardChatState("AWAITING_NAME")
[state: AWAITING_NAME] "Step 1/3 — What is your full name?"
↓ user sends any text → @BotForwardChatState("AWAITING_AGE")
[state: AWAITING_AGE] "Step 2/3 — How old are you?"
↓ user sends valid number → manual setState("AWAITING_CITY")
↓ invalid number → stay in AWAITING_AGE, show error
[state: AWAITING_CITY] "Step 3/3 — Which city do you live in?"
↓ user sends any text → @BotClearChatState
[no state] "🎉 Registration complete!"
Cancel via /cancel or ❌ Cancel button is available at every step.
Project Setup
<dependency>
<groupId>uz.osoncode.easygram</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>0.0.1</version>
</dependency>
telegram:
bot:
token: "${BOT_TOKEN}"
spring:
application:
name: chatstate-bot
State Enum
Using an enum rather than raw strings gives compile-time safety and IDE autocompletion:
package uz.example.chatstate;
public enum RegistrationState {
/** Bot has asked for the user's full name. */
AWAITING_NAME,
/** Bot has asked for the user's age. */
AWAITING_AGE,
/** Bot has asked for the user's city. */
AWAITING_CITY
}
BotChatStateService stores enum values as their name() string internally, and getStateAs
parses them back with Enum.valueOf.
Cancel Keyboard Markup
Declare a reusable keyboard using @BotConfiguration and @BotMarkup:
@BotConfiguration
@RequiredArgsConstructor
@Slf4j
public class RegistrationMarkups {
private final BotKeyboardFactory botKeyboardFactory;
/**
* @BotMarkup methods support any parameter resolvable by the argument resolver system.
* User, BotRequest, Locale, and custom types are all valid — resolved at creation time.
*/
@BotMarkup("kb_cancel")
public ReplyKeyboard cancelKeyboard(User user) {
log.info("Building cancel keyboard for user: {}", user.getUserName());
return ReplyKeyboardMarkup.builder()
.keyboardRow(new KeyboardRow("❌ Cancel"))
.resizeKeyboard(true)
.oneTimeKeyboard(true)
.build();
}
}
Global Commands Controller
Commands available at any point, regardless of chat state:
@BotController
public class GlobalCommandController {
private final BotChatStateService chatStateService;
public GlobalCommandController(BotChatStateService chatStateService) {
this.chatStateService = chatStateService;
}
@BotCommand("/start")
public String onStart(User user) {
return "👋 Hello, " + user.getFirstName() + "! I'm a registration bot.\n\n" +
"Commands:\n" +
" /register — start the registration wizard\n" +
" /status — show your current step\n" +
" /cancel — cancel the wizard at any step";
}
/** Shows where the user is in the wizard by reading state directly. */
@BotCommand("/status")
public String onStatus(User user) {
RegistrationState state = chatStateService.getStateAs(user.getId(), RegistrationState.class);
if (state == null) {
return "ℹ️ No active wizard. Use /register to start.";
}
return switch (state) {
case AWAITING_NAME -> "📝 Waiting for your name (step 1/3).";
case AWAITING_AGE -> "📝 Waiting for your age (step 2/3).";
case AWAITING_CITY -> "📝 Waiting for your city (step 3/3).";
};
}
/**
* @BotClearChatState automatically clears state after this method returns.
* Injecting chatStateService here allows checking if there's something to cancel.
*/
@BotCommand("/cancel")
@BotClearChatState
public String onCancel(User user) {
String current = chatStateService.getState(user.getId());
if (current == null) {
return "ℹ️ No active wizard to cancel.";
}
return "❌ Wizard cancelled. Use /register to start again.";
}
@BotDefaultHandler
public SendMessage onDefault(BotRequest request) {
return SendMessage.builder()
.chatId(request.getChat().getId())
.text("Use /register to start, /status to check, or /cancel to stop.")
.build();
}
}
Registration Flow Controller
The class-level @BotChatState restricts all methods to run only when the user is in one
of the three registration states:
@BotController
@BotChatState({"AWAITING_NAME", "AWAITING_AGE", "AWAITING_CITY"})
public class RegistrationFlowController {
private final BotChatStateService chatStateService;
public RegistrationFlowController(BotChatStateService chatStateService) {
this.chatStateService = chatStateService;
}
// ── Step 0: entry point ──────────────────────────────────────────────────
/**
* @BotChatState with an empty array overrides the class-level restriction,
* making /register reachable at any point (including when there is no state).
* @BotForwardChatState advances to AWAITING_NAME automatically after return.
*/
@BotCommand("/register")
@BotChatState // empty array = override class guard, accept any state
@BotForwardChatState("AWAITING_NAME")
@BotReplyMarkup("kb_cancel")
public String startRegistration(User user) {
return "📝 Let's get you registered!\n\nStep 1/3 — What is your full name?";
}
// ── Step 1: collect name ─────────────────────────────────────────────────
@BotTextDefault
@BotChatState("AWAITING_NAME")
@BotForwardChatState("AWAITING_AGE")
@BotReplyMarkup("kb_cancel")
public String collectName(@BotTextValue String name) {
// In production, persist the name to your database here
return "✅ Name saved: " + name + "\n\nStep 2/3 — How old are you? (enter a number)";
}
// ── Step 2: collect age ──────────────────────────────────────────────────
/**
* State advance is conditional on valid input, so it is done manually.
* @BotForwardChatState cannot be used here because it always fires unconditionally.
*/
@BotTextDefault
@BotChatState("AWAITING_AGE")
@BotReplyMarkup("kb_cancel")
public String collectAge(@BotTextValue String ageText, User user) {
try {
int age = Integer.parseInt(ageText.trim());
if (age <= 0 || age > 120) {
return "⚠️ Please enter a valid age (1–120).";
}
chatStateService.setState(user.getId(), RegistrationState.AWAITING_CITY);
return "✅ Age saved: " + age + "\n\nStep 3/3 — Which city do you live in?";
} catch (NumberFormatException e) {
return "⚠️ That doesn't look like a number. Please enter digits only.";
}
}
// ── Step 3: collect city ─────────────────────────────────────────────────
@BotTextDefault
@BotChatState("AWAITING_CITY")
@BotClearMarkup
@BotClearChatState
public String collectCity(@BotTextValue String city) {
return "🎉 Registration complete!\n\n" +
"Summary:\n" +
"• Name: (saved in step 1)\n" +
"• Age: (saved in step 2)\n" +
"• City: " + city + "\n\n" +
"Use /status to check your registration or /register to start over.";
}
// ── Cancel button ────────────────────────────────────────────────────────
/** "❌ Cancel" button tap during any registration step. */
@BotReplyButton("❌ Cancel")
@BotClearChatState
@BotClearMarkup
public String onCancel() {
return "🛑 Registration cancelled. Use /register to start again.";
}
}
How State Transitions Work
Annotation-driven: @BotForwardChatState
Applies a fixed state transition after the handler returns — used when the transition is unconditional:
@BotForwardChatState("AWAITING_AGE") // always runs after return
public String collectName() { ... }
Manual transition
Used when the next state depends on runtime data:
if (validAge) {
chatStateService.setState(userId, RegistrationState.AWAITING_CITY);
return "✅ Age saved...";
} else {
return "⚠️ Invalid age..."; // state unchanged — user stays in AWAITING_AGE
}
@BotClearChatState
Clears the state (sets to null) after the handler returns. The keyboard removal version
@BotClearMarkup removes the reply keyboard.
Dispatch Priority
When the user is in AWAITING_NAME and sends text:
- Tier 1 (state handlers) —
collectNamematches → executed ✅ - Tier 2 and Tier 3 never reached
When the user has no state and sends /register:
- Tier 1 —
startRegistrationhas@BotChatStatewith empty array, meaning "any state" → matches ✅
When the user has no state and sends text:
- Tier 1 — no match (RegistrationFlowController requires a state)
- Tier 3 —
GlobalCommandController.onDefaultmatches ✅
Running the Bot
export BOT_TOKEN="your_token_here"
mvn spring-boot:run
Test the wizard:
/register→ "Let's get you registered! Step 1/3…"Alice Johnson→ "✅ Name saved. Step 2/3…"abc→ "⚠️ That doesn't look like a number."30→ "✅ Age saved. Step 3/3…"New York→ "🎉 Registration complete!"
See also:
- Chat State (Core Concept) — annotations and flow
- Chat State Backends — Redis / JDBC persistent backends
- Handlers (Core Concept) — dispatch tier priorities