Skip to content

FSM and Conversation handling

vendelieu edited this page May 17, 2026 · 7 revisions

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.

Handling process diagram

stateDiagram-v2
    [*] --> Step
    Step: onEntry → wait input → validate
    Step --> Step: Transition.Retry (invalid)
    Step --> NextStep: Transition.Next
    Step --> JumpTarget: Transition.JumpTo(step)
    Step --> [*]: Transition.Finish
    NextStep --> [*]: ...continues
    JumpTarget --> [*]: ...continues
    Step --> [*]: external cancel / new command
Loading

Forward arrows (Transition.Next, Transition.JumpTo) advance the wizard, Transition.Retry keeps the user on the same step until input is valid (for example, when the user types -100 for their age), and Transition.Finish (or an external command) ends the flow entirely.

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 a Transition indicating what happens next.
  • store(ctx: WizardContext): Any? (optional): Returns the value to persist for this step. Return null if 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 sequence
  • Transition.JumpTo(step: KClass<out WizardStep>): Jump to a specific step
  • Transition.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, MapStringStateManager, MapIntStateManager, MapLongStateManager.

Automatic Matching

KSP matches steps to state managers based on the 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

Override the state manager for a specific step using @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

KSP generates type-safe extension functions on WizardContext for each step that stores state.

Generated Functions

For a step that stores a 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

If type-safe methods aren't available, use the 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

Use 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

Steps don't need to store state. Simply return null from store() (or keep as is):

object ConfirmationStep : WizardStep {
    override suspend fun store(ctx: WizardContext): Any? = null
    // ... rest of implementation
}

Custom State Managers

Implement 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

KSP generates:

  1. WizardActivity: A concrete implementation extending WizardActivity with hardcoded steps
  2. Start Activity: Handles the command trigger and starts the wizard
  3. Input Activity: Handles user input during the wizard flow
  4. State Accessors: Type-safe extension functions for state access

Flow

  1. User sends /register → Start Activity is invoked
  2. Start Activity creates WizardContext and calls wizardActivity.start(ctx)
  3. start() enters the initial step and sets inputListener to track the current step
  4. User sends a message → Input Activity is invoked
  5. Input Activity calls wizardActivity.handleInput(ctx)
  6. handleInput() validates input, persists state, and transitions to the next step
  7. Process repeats until Transition.Finish is returned

State Persistence

  • State is persisted after successful validation (before transition)
  • Each step's store() return value is saved using the matching WizardStateManager
  • State is scoped per user and chat (UserChatReference)

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

Prefer generated type-safe methods:

// ✅ 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

Each step should have a single responsibility:

// ✅ 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

// ✅ Good
object EmailVerificationStep : WizardStep

// ❌ Avoid
object Step2 : WizardStep

6. Clean Up State When Needed

If you need to clear state manually:

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

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 @WizardHandler and defining your steps as nested WizardStep objects! if you have any questions contact us in chat, we will be glad to help :)

Clone this wiki locally