Fsm And Conversation Handling
The library also supports the FSM mechanism, which is a mechanism for progressive processing of user input with incorrect input handling.
Note
TL;DR: See example there.
In theory¶
Let's imagine a situation where you need to collect a user survey, you can ask for all the data of a person at one step, but with incorrect input of one of the parameters, it will be difficult both for the user and for us, and each step may have a difference depending on certain input data.
Now let's imagine step-by-step input of data, where the bot enters dialogue mode with the user.
Green arrows indicate the process of transitioning through steps without errors, blue arrows mean saving the current state and waiting for re-input (for example, if the user indicated that he is -100 years old, it should ask for age again), and red ones show exit from the entire process due to any command or any other meaning cancellation.
In practice¶
The Wizard system enables multi-step user interactions in Telegram bots. It guides users through a sequence of steps, validates input, stores state, and transitions between steps.
Key Benefits: - Type-safe: Compile-time type checking for state access - Declarative: Define steps as nested classes/objects - Flexible: Support for conditional transitions, jumps, and retries - Stateful: Automatic state persistence with pluggable storage backends - Integrated: Works with the existing Activity system
Core Concepts¶
WizardStep¶
A WizardStep represents a single step in the wizard flow. Each step must implement:
onEntry(ctx: WizardContext): Called when the user enters this step. Use this to prompt the user.onRetry(ctx: WizardContext): Called when validation fails and the step should retry. Use this to show error messages.validate(ctx: WizardContext): Transition: Validates the current input and returns aTransitionindicating what happens next.store(ctx: WizardContext): Any?(optional): Returns the value to persist for this step. Returnnullif the step doesn't store state.
object NameStep : WizardStep(isInitial = true) {
override suspend fun onEntry(ctx: WizardContext) {
message { "What is your name?" }.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) {
message { "Name cannot be empty. Please try again." }.send(ctx.user, ctx.bot)
}
override suspend fun validate(ctx: WizardContext): Transition {
return if (ctx.update.text.isNullOrBlank()) {
Transition.Retry
} else {
Transition.Next
}
}
override suspend fun store(ctx: WizardContext): String {
return ctx.update.text!!
}
}
Note
If some step is not marked as initial -> first declared step is considered as.
Transition¶
A Transition determines what happens after validation:
Transition.Next: Move to the next step in sequenceTransition.JumpTo(step: KClass<out WizardStep>): Jump to a specific stepTransition.Retry: Retry the current step (validation failed)Transition.Finish: Finish the wizard
// Conditional jump based on input
override suspend fun validate(ctx: WizardContext): Transition {
val age = ctx.update.text?.toIntOrNull()
return when {
age == null -> Transition.Retry
age < 18 -> Transition.JumpTo(UnderageStep::class)
else -> Transition.Next
}
}
WizardContext¶
WizardContext provides access to: - user: User: The current user - update: ProcessedUpdate: The current update - bot: TelegramBot: The bot instance - userReference: UserChatReference: User and chat ID reference for state storage
Plus type-safe state access methods (generated by KSP).
Defining a Wizard¶
Basic Structure¶
A wizard is defined as a class or object annotated with @WizardHandler:
@WizardHandler(trigger = ["/survey"])
object SurveyWizard {
object NameStep : WizardStep(isInitial = true) {
// ... step implementation
}
object AgeStep : WizardStep {
// ... step implementation
}
object FinishStep : WizardStep {
// ... step implementation
}
}
Annotation Parameters¶
@WizardHandler accepts: - trigger: Array<String>: Commands that start the wizard (e.g., ["/start", "/survey"]) - scope: Array<UpdateType>: Update types to listen for (default: [UpdateType.MESSAGE]) - stateManagers: Array<KClass<out WizardStateManager<*>>>: State manager classes for storing step data
State Management¶
WizardStateManager¶
State is stored using WizardStateManager<T> implementations. Each manager handles a specific type:
interface WizardStateManager<T : Any> {
suspend fun get(key: KClass<out WizardStep>, reference: UserChatReference): T?
suspend fun set(key: KClass<out WizardStep>, reference: UserChatReference, value: T)
suspend fun del(key: KClass<out WizardStep>, reference: UserChatReference)
}
See also: MapStateManager KSP matches steps to state managers based on the Override the state manager for a specific step using KSP generates type-safe extension functions on For a step that stores a If type-safe methods aren't available, use the fallback methods: Use Steps don't need to store state. Simply return Implement KSP generates: Prefer generated type-safe methods: Each step should have a single responsibility: If you need to clear state manually: The Wizard system provides: - ✅ Type-safe state management with compile-time checking - ✅ Declarative step definitions as nested classes - ✅ Flexible transitions with conditional logic - ✅ Automatic code generation via KSP - ✅ Integrated with the existing Activity system - ✅ Pluggable state storage backends Start building wizards by annotating a class with Automatic Matching¶
store() return type:@WizardHandler(
trigger = ["/survey"],
stateManagers = [StringStateManager::class, IntStateManager::class]
)
object SurveyWizard {
object NameStep : WizardStep(isInitial = true) {
override suspend fun store(ctx: WizardContext): String {
return ctx.update.text!! // Matches StringStateManager
}
}
object AgeStep : WizardStep {
override suspend fun store(ctx: WizardContext): Int {
return ctx.update.text!!.toInt() // Matches IntStateManager
}
}
}
Per-Step Override¶
@WizardHandler.StateManager:@WizardHandler(
trigger = ["/survey"],
stateManagers = [DefaultStateManager::class]
)
object SurveyWizard {
object NameStep : WizardStep(isInitial = true) {
// Uses DefaultStateManager
}
@WizardHandler.StateManager(CustomStateManager::class)
object AgeStep : WizardStep {
// Uses CustomStateManager instead
}
}
Type-Safe State Access¶
WizardContext for each step that stores state.Generated Functions¶
String:// Generated automatically by KSP
suspend inline fun <reified S : WizardStep> WizardContext.getState(): String?
suspend inline fun <reified S : WizardStep> WizardContext.setState(value: String)
suspend inline fun <reified S : WizardStep> WizardContext.delState()
Usage¶
object FinishStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
// Type-safe access - returns String? (nullable)
val name: String? = ctx.getState<NameStep>()
// Type-safe access - returns Int? (nullable)
val age: Int? = ctx.getState<AgeStep>()
val summary = buildString {
appendLine("Name: $name")
appendLine("Age: $age")
}
message { summary }.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) = Unit
override suspend fun validate(ctx: WizardContext): Transition {
return Transition.Finish
}
}
Fallback Methods¶
// Fallback - returns Any?
val name = ctx.getState(NameStep::class)
// Fallback - accepts Any?
ctx.setState(NameStep::class, "John")
ctx.delState(NameStep::class)
Complete Example¶
User Registration Wizard¶
@WizardHandler(
trigger = ["/register"],
stateManagers = [StringStateManager::class, IntStateManager::class]
)
object RegistrationWizard {
object NameStep : WizardStep(isInitial = true) {
override suspend fun onEntry(ctx: WizardContext) {
message { "What is your name?" }.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) {
message { "Please enter a valid name." }.send(ctx.user, ctx.bot)
}
override suspend fun validate(ctx: WizardContext): Transition {
val name = ctx.update.text?.trim()
return if (name.isNullOrBlank() || name.length < 2) {
Transition.Retry
} else {
Transition.Next
}
}
override suspend fun store(ctx: WizardContext): String {
return ctx.update.text!!.trim()
}
}
object AgeStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
message { "How old are you?" }.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) {
message { "Please enter a valid age (must be a number)." }.send(ctx.user, ctx.bot)
}
override suspend fun validate(ctx: WizardContext): Transition {
val age = ctx.update.text?.toIntOrNull()
return when {
age == null -> Transition.Retry
age < 0 || age > 150 -> Transition.Retry
age < 18 -> Transition.JumpTo(UnderageStep::class)
else -> Transition.Next
}
}
override suspend fun store(ctx: WizardContext): Int {
return ctx.update.text!!.toInt()
}
}
object UnderageStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
message {
"Sorry, you must be 18 or older to register."
}.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) = Unit
override suspend fun validate(ctx: WizardContext): Transition {
return Transition.Finish
}
}
object ConfirmationStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
// Type-safe state access
val name: String? = ctx.getState<NameStep>()
val age: Int? = ctx.getState<AgeStep>()
val confirmation = buildString {
appendLine("Please confirm your information:")
appendLine("Name: $name")
appendLine("Age: $age")
appendLine()
appendLine("Reply 'yes' to confirm or 'no' to start over.")
}
message { confirmation }.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) {
message { "Please reply 'yes' or 'no'." }.send(ctx.user, ctx.bot)
}
override suspend fun validate(ctx: WizardContext): Transition {
val response = ctx.update.text?.lowercase()?.trim()
return when (response) {
"yes" -> Transition.Finish
"no" -> Transition.JumpTo(NameStep::class) // Start over
else -> Transition.Retry
}
}
}
object FinishStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
val name: String? = ctx.getState<NameStep>()
val age: Int? = ctx.getState<AgeStep>()
// Save to database, send confirmation, etc.
message {
"Registration complete! Welcome, $name (age $age)."
}.send(ctx.user, ctx.bot)
}
override suspend fun onRetry(ctx: WizardContext) = Unit
override suspend fun validate(ctx: WizardContext): Transition {
return Transition.Finish
}
}
}
Advanced Features¶
Conditional Transitions¶
Transition.JumpTo for conditional flows:override suspend fun validate(ctx: WizardContext): Transition {
val choice = ctx.update.text?.lowercase()
return when (choice) {
"premium" -> Transition.JumpTo(PremiumStep::class)
"basic" -> Transition.JumpTo(BasicStep::class)
else -> Transition.Retry
}
}
Stateless Steps¶
null from store() (or keep as is):object ConfirmationStep : WizardStep {
override suspend fun store(ctx: WizardContext): Any? = null
// ... rest of implementation
}
Custom State Managers¶
WizardStateManager<T> for custom storage (database, Redis, etc.):class DatabaseStateManager : WizardStateManager<String> {
override suspend fun get(
key: KClass<out WizardStep>,
reference: UserChatReference
): String? {
// Load from database
return database.getWizardState(reference.userId, key.qualifiedName)
}
override suspend fun set(
key: KClass<out WizardStep>,
reference: UserChatReference,
value: String
) {
// Save to database
database.saveWizardState(reference.userId, key.qualifiedName, value)
}
override suspend fun del(
key: KClass<out WizardStep>,
reference: UserChatReference
) {
// Delete from database
database.deleteWizardState(reference.userId, key.qualifiedName)
}
}
How It Works Internally¶
Code Generation¶
WizardActivity with hardcoded stepsFlow¶
/register → Start Activity is invokedWizardContext and calls wizardActivity.start(ctx)start() enters the initial step and sets inputListener to track the current stepwizardActivity.handleInput(ctx)handleInput() validates input, persists state, and transitions to the next stepTransition.Finish is returnedState Persistence¶
store() return value is saved using the matching WizardStateManagerUserChatReference)
Best Practices¶
1. Always Provide Clear Prompts¶
override suspend fun onEntry(ctx: WizardContext) {
message {
"Please enter your email address:\n" +
"(Format: user@example.com)"
}.send(ctx.user, ctx.bot)
}
2. Handle Validation Errors Gracefully¶
override suspend fun onRetry(ctx: WizardContext) {
message {
"Invalid email format. Please try again.\n" +
"Example: user@example.com"
}.send(ctx.user, ctx.bot)
}
3. Use Type-Safe State Access¶
// ✅ Good - type-safe
val name: String? = ctx.getState<NameStep>()
// ❌ Avoid - loses type safety
val name = ctx.getState(NameStep::class) as? String
4. Keep Steps Focused¶
// ✅ Good - focused step
object EmailStep : WizardStep {
// Only handles email collection
}
// ❌ Avoid - too much logic
object PersonalInfoStep : WizardStep {
// Handles name, email, phone, address...
}
5. Use Meaningful Step Names¶
6. Clean Up State When Needed¶
object CancelStep : WizardStep {
override suspend fun onEntry(ctx: WizardContext) {
// Clear all wizard state
ctx.delState<NameStep>()
ctx.delState<AgeStep>()
message { "Registration cancelled." }.send(ctx.user, ctx.bot)
}
}
Summary¶
@WizardHandler and defining your steps as nested WizardStep objects! if you have any questions contact us in chat, we will be glad to help :)