-
-
Notifications
You must be signed in to change notification settings - Fork 17
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.
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.
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
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.
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
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.
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 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).
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
}
}@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 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.
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
}
}
}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
}
}KSP generates type-safe extension functions on WizardContext for each step that stores state.
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()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
}
}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)@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
}
}
}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
}
}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
}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)
}
}KSP generates:
-
WizardActivity: A concrete implementation extending
WizardActivitywith hardcoded steps - Start Activity: Handles the command trigger and starts the wizard
- Input Activity: Handles user input during the wizard flow
- State Accessors: Type-safe extension functions for state access
- User sends
/register→ Start Activity is invoked - Start Activity creates
WizardContextand callswizardActivity.start(ctx) -
start()enters the initial step and setsinputListenerto track the current step - User sends a message → Input Activity is invoked
- Input Activity calls
wizardActivity.handleInput(ctx) -
handleInput()validates input, persists state, and transitions to the next step - Process repeats until
Transition.Finishis returned
- State is persisted after successful validation (before transition)
- Each step's
store()return value is saved using the matchingWizardStateManager - State is scoped per user and chat (
UserChatReference)
override suspend fun onEntry(ctx: WizardContext) {
message {
"Please enter your email address:\n" +
"(Format: user@example.com)"
}.send(ctx.user, ctx.bot)
}override suspend fun onRetry(ctx: WizardContext) {
message {
"Invalid email format. Please try again.\n" +
"Example: user@example.com"
}.send(ctx.user, ctx.bot)
}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? StringEach 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...
}// ✅ Good
object EmailVerificationStep : WizardStep
// ❌ Avoid
object Step2 : WizardStepIf 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)
}
}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 :)
Telegram bot Wiki © KtGram