Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Conversations

A conversation is a multi-step interaction where the bot asks questions and the user provides answers one at a time. Each step transitions to the next state.

State Management Pattern

In Rust, conversation state is managed with a shared HashMap protected by an async RwLock:

#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Arc, HashMap, RwLock};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConvState {
    AskName,
    AskAge,
    AskLocation,
    AskBio,
}

type ConvStore = Arc<RwLock<HashMap<i64, ConvState>>>;
}

The key is the chat ID (or user ID), and the value is the current conversation state for that user.

Complete Conversation Bot

This bot collects a user profile through four steps: name, age, location, and bio.

#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
    Application, ApplicationBuilder, Arc, Context, FnHandler,
    HandlerError, HandlerResult, HashMap, MessageEntityType, RwLock, Update,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConvState {
    AskName,
    AskAge,
    AskLocation,
    AskBio,
}

type ConvStore = Arc<RwLock<HashMap<i64, ConvState>>>;
type UserDataStore = Arc<RwLock<HashMap<i64, UserProfile>>>;

#[derive(Debug, Clone, Default)]
struct UserProfile {
    name: Option<String>,
    age: Option<String>,
    location: Option<String>,
    bio: Option<String>,
}
}

Handler Functions

Each handler function receives the shared stores through closure captures:

#![allow(unused)]
fn main() {
async fn start_command(
    update: Arc<Update>,
    context: Context,
    conv_store: ConvStore,
) -> HandlerResult {
    let chat_id = update.effective_chat().unwrap().id;
    conv_store.write().await.insert(chat_id, ConvState::AskName);

    context.bot()
        .send_message(chat_id, "Hi! What is your name? (Send /cancel to stop.)")
        .await
        .map_err(|e| HandlerError::Other(Box::new(e)))?;
    Ok(())
}

async fn receive_name(
    update: Arc<Update>,
    context: Context,
    conv_store: ConvStore,
    user_data: UserDataStore,
) -> HandlerResult {
    let chat_id = update.effective_chat().unwrap().id;
    let text = update.effective_message()
        .and_then(|m| m.text.as_deref())
        .unwrap_or_default();

    user_data.write().await.entry(chat_id).or_default().name = Some(text.to_string());
    conv_store.write().await.insert(chat_id, ConvState::AskAge);

    context.bot()
        .send_message(chat_id, &format!("Nice to meet you, {text}! How old are you?"))
        .await
        .map_err(|e| HandlerError::Other(Box::new(e)))?;
    Ok(())
}

async fn cancel(
    update: Arc<Update>,
    context: Context,
    conv_store: ConvStore,
) -> HandlerResult {
    let chat_id = update.effective_chat().unwrap().id;
    conv_store.write().await.remove(&chat_id);

    context.bot()
        .send_message(chat_id, "Conversation cancelled. Send /start to begin again.")
        .await
        .map_err(|e| HandlerError::Other(Box::new(e)))?;
    Ok(())
}
}

State-Checking Predicates

Each state handler needs a predicate that checks whether the user is in the correct state:

#![allow(unused)]
fn main() {
fn is_text_in_state(update: &Update, conv_store: &ConvStore, state: ConvState) -> bool {
    let msg = match update.effective_message() {
        Some(m) => m,
        None => return false,
    };
    if msg.text.is_none() {
        return false;
    }
    // Exclude commands
    let is_cmd = msg.entities.as_ref()
        .and_then(|ents| ents.first())
        .map(|e| e.entity_type == MessageEntityType::BotCommand && e.offset == 0)
        .unwrap_or(false);
    if is_cmd {
        return false;
    }
    let chat_id = msg.chat.id;
    conv_store.try_read()
        .map(|guard| guard.get(&chat_id) == Some(&state))
        .unwrap_or(false)
}
}

Wiring It Together

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();
    let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
    let app: Arc<Application> = ApplicationBuilder::new().token(token).build();

    let conv_store: ConvStore = Arc::new(RwLock::new(HashMap::new()));
    let user_data: UserDataStore = Arc::new(RwLock::new(HashMap::new()));

    // /start entry point
    {
        let cs = Arc::clone(&conv_store);
        app.add_handler(
            FnHandler::new(
                |u| check_command(u, "start"),
                move |update, ctx| {
                    let cs = Arc::clone(&cs);
                    async move { start_command(update, ctx, cs).await }
                },
            ), 0,
        ).await;
    }

    // /cancel fallback
    {
        let cs = Arc::clone(&conv_store);
        app.add_handler(
            FnHandler::new(
                |u| check_command(u, "cancel"),
                move |update, ctx| {
                    let cs = Arc::clone(&cs);
                    async move { cancel(update, ctx, cs).await }
                },
            ), 0,
        ).await;
    }

    // State: AskName (group 1 so commands in group 0 take priority)
    {
        let cs = Arc::clone(&conv_store);
        let ud = Arc::clone(&user_data);
        let cs_check = Arc::clone(&conv_store);
        app.add_handler(
            FnHandler::new(
                move |u| is_text_in_state(u, &cs_check, ConvState::AskName),
                move |update, ctx| {
                    let cs = Arc::clone(&cs);
                    let ud = Arc::clone(&ud);
                    async move { receive_name(update, ctx, cs, ud).await }
                },
            ), 1,
        ).await;
    }

    // Repeat for AskAge, AskLocation, AskBio...

    app.run_polling().await.unwrap();
}

Key Patterns

Use Group Numbers for Priority

Register command handlers (like /cancel) in group 0 and state handlers in group 1. This ensures commands always take priority over state-based text matching.

Clone Arc Before Moving Into Closures

Every Arc::clone() is cheap (it increments an atomic counter). Always clone before moving into a closure:

#![allow(unused)]
fn main() {
let cs = Arc::clone(&conv_store);
app.add_handler(
    FnHandler::new(
        |u| check_command(u, "start"),
        move |update, ctx| {
            let cs = Arc::clone(&cs);
            async move { start_command(update, ctx, cs).await }
        },
    ), 0,
).await;
}

Use try_read() in Predicates

Predicates run synchronously, so use try_read() instead of .read().await:

#![allow(unused)]
fn main() {
conv_store.try_read()
    .map(|guard| guard.get(&chat_id) == Some(&state))
    .unwrap_or(false)
}

Next Steps