Conversation API

Low-level conversation API for building interactive message handlers with wait_* functions.

Looking for structured conversation management? Check out the Conversation Flows guide for high-level flow modules with persistent state.

Introduction

The Conversation API provides fundamental building blocks for creating handlers that span multiple messages. It’s built into Telega and leverages BEAM actor system’s power to pause and resume handler execution.

This API was inspired by grammY’s conversations plugin, adapted to take advantage of Gleam and BEAM’s capabilities.

How Conversations Work

In traditional message handlers, you only have access to a single update at a time:

handle_command("start", fn(ctx, _) {
  reply.with_text(ctx, "Welcome!")
})

With conversations, you can create handlers that span multiple messages:

handle_command("name", fn(ctx, _) {
  // First message
  use _ <- try(reply.with_text(ctx, "What's your name?"))

  // Wait for user's text response
  use ctx, name <- wait_text(ctx, or: None, timeout: None)

  // Handle the response
  reply.with_text(ctx, "Hello, " <> name <> "!")
})

reply.with_text returns Result(Message, _), so chain it with use _ <- try(...) (from gleam/result). Only the wait_* functions hand you back an updated ctx.

Under the hood, Telega uses the BEAM actor model to pause the execution of your handler at each wait_* call, resuming it when the expected message type arrives.

Important: Each wait_* call only pauses the current chat actor (identified by {chat_id}:{from_id}). Other users and chats continue to be handled concurrently. Think of it like await in async code — it yields the current “coroutine” (actor) but doesn’t block the event loop or other conversations.

Available Wait Functions

Basic Wait Functions

Enhanced Wait Functions (Forms API)

These functions provide built-in validation and error handling:

wait_number - Validated Number Input

Wait for a number with automatic validation:

use ctx, age <- wait_number(
  ctx,
  min: Some(0),
  max: Some(120),
  or: Some(bot.HandleText(fn(ctx, invalid) {
    reply.with_text(ctx, "Please enter valid age (0-120)")
  })),
  timeout: None,
)

Parameters:

wait_email - Email Validation

Wait for email with regex validation:

use ctx, email <- wait_email(
  ctx,
  or: Some(bot.HandleText(fn(ctx, invalid) {
    reply.with_text(ctx, "Invalid email format. Try again.")
  })),
  timeout: None,
)

Pattern: ^[^\s@]+@[^\s@]+\.[^\s@]+$

wait_choice - Multiple Choice Selection

Create inline keyboard and wait for user selection:

use ctx, color <- wait_choice(
  ctx,
  text: "Pick a color",
  options: [
    #("🔴 Red", Red),
    #("🔵 Blue", Blue),
    #("🟢 Green", Green),
  ],
  or: None,
  timeout: None,
)

Features:

wait_for - Custom Filter

Wait for update matching custom filter:

use ctx, photo_update <- wait_for(
  ctx,
  filter: fn(upd) {
    case upd {
      update.PhotoUpdate(..) -> True
      _ -> False
    }
  },
  or: Some(bot.HandleAll(fn(ctx, wrong_update) {
    reply.with_text(ctx, "Please send a photo")
  })),
  timeout: Some(60_000),
)

Common Parameters

Each wait function accepts these common parameters:

The or handler covers both kinds of mismatch: an update of the wrong type (a photo while waiting for text), and an update of the right type that fails the wait’s own filter — a different command than wait_command asked for, text that no wait_hears pattern matches, callback data the wait_callback_query regexp rejects, an update rejected by wait_for/wait_filtered. After or runs the conversation keeps waiting, so the user can try again; without an or handler the update is ignored and reported as unhandled.

Commands are the exception. A command the conversation did not ask for (and that no or handler claimed) is passed to the router, so /cancel, /start and the rest keep working while a wait_* is armed — otherwise a user who changes their mind mid-conversation has no way out. The conversation stays armed afterwards; a command handler that means to end it calls bot.cancel_conversation_in(ctx):

router.on_command(r, "cancel", fn(ctx, _cmd) {
  bot.cancel_conversation_in(ctx)
  reply.with_text(ctx, "Cancelled.")
})

Payment queries are the other exception. A pre-checkout or shipping query the conversation did not ask for also goes to the router, for the same reason with a harder deadline: Telegram fails the payment if the bot does not answer a pre-checkout query within 10 seconds, and in a private chat that query arrives on the same chat instance as the messages. So a handler can send an invoice and park on payments.wait_successful_payment while router.on_pre_checkout_query answers the query that has to be answered for the payment to happen at all. The wait stays armed through it.

Examples

Basic Name Collection

fn set_name_command_handler(ctx, _) {
  // Ask for a name
  use _ <- try(reply.with_text(ctx, "What's your name?"))

  // Wait for text response
  use ctx, name <- wait_text(ctx, or: None, timeout: None)

  // Confirm and store the name
  use _ <- try(reply.with_text(ctx, "Your name is: " <> name <> " set!"))
  bot.next_session(ctx, NameBotSession(name: name))
}

Registration Form with Validation

fn registration_handler(ctx, _cmd) {
  // Collect age with validation
  use _ <- try(reply.with_text(ctx, "Let's register! What's your age?"))

  use ctx, age <- wait_number(
    ctx,
    min: Some(13),
    max: Some(120),
    or: Some(bot.HandleText(fn(ctx, invalid) {
      reply.with_text(ctx, "Invalid age. Please enter 13-120")
    })),
    timeout: None,
  )

  // Collect email with validation
  use _ <- try(reply.with_text(ctx, "What's your email?"))

  use ctx, email <- wait_email(
    ctx,
    or: Some(bot.HandleText(fn(ctx, invalid) {
      reply.with_text(ctx, "Invalid email. Try again.")
    })),
    timeout: None,
  )

  // Select plan
  use ctx, plan <- wait_choice(
    ctx,
    text: "Choose your plan:",
    options: [
      #("🆓 Free", Free),
      #("💎 Premium", Premium),
      #("🚀 Enterprise", Enterprise),
    ],
    or: None,
    timeout: None,
  )

  // Complete
  reply.with_text(ctx, "Registration complete! Age: "
    <> int.to_string(age)
    <> ", Email: " <> email)
}

With Fallback Handler

use ctx, text <- wait_hears(
  ctx,
  telega_keyboard.hear(keyboard),
  or: Some(bot.HandleAll(fn(ctx, other_update) {
    reply.with_text(ctx, "Please use the keyboard buttons")
  })),
  timeout: None,
)

With Timeout

let assert Ok(filter) = telega_keyboard.filter_inline_keyboard_query(keyboard)

use ctx, payload, callback_query_id <- wait_callback_query(
  ctx,
  filter: Some(filter),
  or: None,
  timeout: Some(30_000),  // 30 seconds
)

filter_inline_keyboard_query returns Result(CallbackQueryFilter, String), so unwrap it first and pass Some(filter) — the filter: parameter is an Option.

Conversation will be stopped after 30 seconds of waiting for a callback query, and normal handler execution will continue.

Advanced Features

Conversations and chat instance lifetime

A conversation lives in the memory of the chat instance process that runs it, so it survives for as long as that process does. Two settings bound that:

Best Practices

  1. Keep conversations focused: Design conversations for specific tasks with clear endpoints
  2. Handle timeouts: Consider what happens if a user doesn’t respond by setting appropriate timeouts
  3. Provide fallback handlers: Use the or parameter to handle unexpected message types
  4. Provide exit commands: Allow users to exit conversations gracefully (e.g., /cancel)
  5. Use session storage: Store conversation state in the session
  6. Use validation: Take advantage of wait_number, wait_email, wait_choice for better UX
  7. Clear error messages: Provide helpful feedback when validation fails
Search Document