Skip to content

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.

Handling process diagram

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 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 :)