Skip to main content

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:

  1. Tier 1: State Handlers — Check if handler's @BotChatState matches user's current state

    • If user is in "WAITING_AGE", only @BotChatState("WAITING_AGE") handlers are checked
    • Highest priority
  2. Tier 2: Non-State Handlers — Check regular handlers (no @BotChatState)

    • Execute if no Tier 1 match
    • User can always use commands like /cancel even in any state
  3. 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 /register has no @BotChatState
  • The existing state is not automatically cleared — the user is still in WAITING_AGE
  • Add an explicit @BotForwardChatState("WAITING_NAME") on /register to 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.