Chat State Management
Build multi-step workflows and stateful conversations using chat state.
The @BotChatState Annotation
Tag handler methods with state names. Only handlers matching the user's current state execute.
@BotCommand("/register")
@BotChatState("REGISTRATION")
public String handleRegistrationStep() {
return "Enter your name:";
}
State Transitions
@BotForwardChatState
Transition to a new state after handler executes:
@BotCommand("/register")
@BotForwardChatState("WAITING_AGE") // Sets user's chat state to "WAITING_AGE"
public String askForAge() {
return "Now enter your age:";
}
@BotText("\\d+")
@BotChatState("WAITING_AGE")
@BotForwardChatState("REGISTRATION_COMPLETE") // Transition when age is provided
public String processAge(String ageText) {
return "Registration complete!";
}
@BotClearChatState
Clear the user's state:
@BotCommand("/cancel")
@BotClearChatState
public String cancelRegistration() {
return "Registration cancelled.";
}
Example: Registration Wizard
@BotController
public class RegistrationBot {
@BotCommand("/register")
public String startRegistration() {
// User is not in any state yet
return "Welcome to registration! Please enter your name:";
}
@BotText("^[A-Za-z ]{3,}$") // Regex: 3+ letters
@BotForwardChatState("WAITING_AGE")
public String acceptName(@BotTextValue String name) {
return "Thanks, " + name + "! Now enter your age:";
}
@BotTextDefault
public String invalidName() {
return "Please enter a valid name (3+ letters)";
}
@BotText("^\\d{1,3}$") // Regex: 1-3 digits
@BotChatState("WAITING_AGE")
@BotForwardChatState("WAITING_CONFIRMATION")
public String acceptAge(@BotTextValue String age) {
return "You are " + age + " years old. Confirm? (yes/no)";
}
@BotTextDefault
@BotChatState("WAITING_AGE")
public String invalidAge() {
return "Please enter a valid age (1-3 digits)";
}
@BotText("yes")
@BotChatState("WAITING_CONFIRMATION")
@BotClearChatState
public String confirmRegistration() {
return "✅ Registration complete! Your profile has been created.";
}
@BotText("no")
@BotChatState("WAITING_CONFIRMATION")
@BotClearChatState
public String cancelRegistration() {
return "Registration cancelled. Type /register to start over.";
}
}
How Handler Dispatch Works with State
When an update arrives:
-
Tier 1: State Handlers — Check if handler's
@BotChatStatematches user's current state- If user is in
"WAITING_AGE", only@BotChatState("WAITING_AGE")handlers are checked - Highest priority
- If user is in
-
Tier 2: Non-State Handlers — Check regular handlers (no
@BotChatState)- Execute if no Tier 1 match
- User can always use commands like
/canceleven in any state
-
Tier 3: Default Handlers —
@BotDefaultHandler- Execute if no Tier 1 or Tier 2 match
Example:
@BotController
public class SmartBot {
@BotCommand("/cancel")
public String cancelAnywhere() {
// No @BotChatState → always available
return "Cancelled!";
}
@BotCommand("/status")
@BotChatState("REGISTRATION")
public String statusInRegistration() {
// Only if user is in "REGISTRATION" state
return "You are registering...";
}
@BotCommand("/status")
public String statusDefault() {
// If user is NOT in "REGISTRATION" state
return "You are not registering.";
}
}
When user types /cancel in ANY state, the first handler executes (no state restriction). When user types /status:
- If in
"REGISTRATION"state → first handler (with state) executes - Otherwise → second handler (without state) executes
Custom BotChatStateService
Replace the in-memory implementation with Redis or database:
@Component
public class RedisChatStateService implements BotChatStateService {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void setState(Long chatId, String state) {
redisTemplate.opsForValue().set(
"chat_state:" + chatId,
state,
Duration.ofDays(7) // Expire after 7 days of inactivity
);
}
@Override
public String getState(Long chatId) {
return redisTemplate.opsForValue().get("chat_state:" + chatId);
// Returns null when no state is set — NOT an empty string
}
@Override
public void clearState(Long chatId) {
redisTemplate.delete("chat_state:" + chatId);
}
}
The framework detects and uses your implementation (due to @ConditionalOnMissingBean).
In-Memory vs Redis: State Expiration
The built-in InMemoryBotChatStateService keeps state indefinitely in a ConcurrentHashMap.
States only clear when @BotClearChatState or setState(chatId, null) is called. A Redis
backend lets you attach a TTL (e.g., Duration.ofDays(7)) so stale wizard states
automatically expire without manual cleanup.
Enum-Based States
Use enum constants instead of raw strings for compile-time safety and IDE autocompletion.
BotChatStateService has built-in overloads for enums:
public enum RegistrationState {
WAITING_NAME, WAITING_AGE, WAITING_CONFIRMATION
}
@BotCommand("/register")
@BotForwardChatState("WAITING_NAME")
public String startRegistration() {
return "Enter your name:";
}
// Read state as enum in a filter or service:
RegistrationState current = chatStateService.getStateAs(chatId, RegistrationState.class);
// Set state from enum:
chatStateService.setState(chatId, RegistrationState.WAITING_AGE);
The @BotChatState and @BotForwardChatState annotations still use string values — match them
to RegistrationState.WAITING_NAME.name() (i.e., the exact enum constant name in ALL_CAPS).
Re-Entry Behavior
If a user re-sends /register while already in the WAITING_AGE state:
- The dispatch falls into Tier 2 (non-state handlers) because
/registerhas no@BotChatState - The existing state is not automatically cleared — the user is still in
WAITING_AGE - Add an explicit
@BotForwardChatState("WAITING_NAME")on/registerto restart the flow:
@BotCommand("/register")
@BotForwardChatState("WAITING_NAME") // Always resets flow to beginning
public String startRegistration() {
return "Starting over. Enter your name:";
}
Group Chat Caution
Chat state is keyed by chatId (not userId). In group chats, all members share the same
state. If two users simultaneously send messages to a group bot, their updates race through
the same state value. For group-aware bots, key state on a composite chatId + userId string,
or restrict stateful handlers to private chats only:
@BotCommand("/register")
public String startRegistration(Chat chat) {
if (!chat.isUserChat()) {
return "Registration is only available in private chat.";
}
// ... proceed
}
State Naming Conventions
- Use UPPERCASE_WITH_UNDERSCORES:
"WAITING_NAME","PAYMENT_PENDING" - Keep names descriptive but concise
- Document state transitions in comments
Testing States
Mock the state service in tests:
@SpringBootTest
public class RegistrationBotTest {
@MockBean
private BotChatStateService chatStateService;
@Autowired
private RegistrationBot bot;
@Test
public void testAgeValidation() {
// Mock user is in WAITING_AGE state
when(chatStateService.getState(123L)).thenReturn("WAITING_AGE");
String response = bot.acceptAge("25");
assertThat(response).contains("Confirm?");
}
}
Next: Learn about all return types supported by handlers.