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

Finite State Machine (FSM)

The FSM module lets bots track per-user conversation state across multiple messages without managing a global HashMap manually. State is stored in a pluggable StateStorage backend and keyed by user ID + chat ID.


Quick start

Add the derive feature to your Cargo.toml:

ferogram = { version = "0.3", features = ["derive"] }

Define a state enum and derive FsmState:

#![allow(unused)]
fn main() {
use ferogram::FsmState;

#[derive(FsmState, Clone, Debug, PartialEq)]
enum OrderState {
    AwaitingProduct,
    AwaitingQuantity,
    AwaitingConfirmation,
}
}

Wire it into the dispatcher:

use std::sync::Arc;
use ferogram::{Client, filters, fsm::MemoryStorage};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (client, _sd) = Client::builder()
        .api_id(12345)
        .api_hash("your_hash")
        .session("bot.session")
        .connect()
        .await?;

    client.bot_sign_in("TOKEN").await?;
    client.save_session().await?;

    let mut dp = filters::Dispatcher::new();
    dp.with_state_storage(Arc::new(MemoryStorage::new()));

    // Entry point - no active state yet
    dp.on_message(filters::command("order"), |msg| async move {
        msg.reply("What product would you like?").await.ok();
    });

    // Triggered only when the user is in AwaitingProduct
    dp.on_message_fsm(filters::text(), OrderState::AwaitingProduct, |msg, state| async move {
        let product = msg.text().unwrap_or_default().to_string();
        state.set_data("product", &product).await.ok();
        state.transition(OrderState::AwaitingQuantity).await.ok();
        msg.reply("How many?").await.ok();
    });

    dp.on_message_fsm(filters::text(), OrderState::AwaitingQuantity, |msg, state| async move {
        let qty = msg.text().unwrap_or_default().to_string();
        let product: String = state.get_data("product").await.unwrap_or_default();
        state.transition(OrderState::AwaitingConfirmation).await.ok();
        msg.reply(format!("Order {} × {}. Confirm? (yes/no)", qty, product)).await.ok();
    });

    dp.on_message_fsm(filters::text(), OrderState::AwaitingConfirmation, |msg, state| async move {
        match msg.text().unwrap_or("").trim() {
            "yes" => {
                state.clear_state().await.ok();
                msg.reply("Order placed!").await.ok();
            }
            _ => {
                state.clear_state().await.ok();
                msg.reply("Order cancelled.").await.ok();
            }
        }
    });

    let mut stream = client.stream_updates();
    while let Some(upd) = stream.next().await {
        dp.dispatch(upd).await;
    }
    Ok(())
}

FsmState trait

Any enum that derives FsmState can be used as a state discriminant:

#![allow(unused)]
fn main() {
use ferogram::FsmState;

#[derive(FsmState, Clone, Debug, PartialEq)]
enum Form {
    Name,
    Email,
    Done,
}
}

You can also implement FsmState manually if you need custom serialization:

#![allow(unused)]
fn main() {
impl ferogram::fsm::FsmState for Form {
    fn as_key(&self) -> String {
        format!("{:?}", self)
    }
    fn from_key(key: &str) -> Option<Self> {
        match key {
            "Name" => Some(Self::Name),
            "Email" => Some(Self::Email),
            "Done" => Some(Self::Done),
            _ => None,
        }
    }
}
}

StateContext

Handler functions registered via on_message_fsm receive a StateContext as the second argument. It provides the following methods:

MethodDescription
state.transition(new_state).awaitMove to a new FSM state
state.clear_state().awaitReset to no state (end the flow)
state.set_data(field, value).awaitStore a serializable value
state.get_data::<T>(field).awaitRetrieve a stored value
state.get_all_data().awaitAll stored data as HashMap<String, Value>
state.clear_data().awaitDelete all data, keep current state
state.clear_all().awaitDelete state and all associated data
state.key()Inspect the active StateKey

set_data / get_data use serde_json internally, so any Serialize + DeserializeOwned type works.


State key strategies

By default, state is tracked per user per chat. Change the strategy on the dispatcher:

#![allow(unused)]
fn main() {
use ferogram::fsm::StateKeyStrategy;

dp.with_key_strategy(StateKeyStrategy::PerUser);    // one session per user across all chats
dp.with_key_strategy(StateKeyStrategy::PerChat);    // shared state per chat (e.g. group games)
dp.with_key_strategy(StateKeyStrategy::PerUserPerChat); // default
}

Storage backends

MemoryStorage (default for testing)

In-process DashMap-backed storage. State is lost on restart.

#![allow(unused)]
fn main() {
use ferogram::fsm::MemoryStorage;
use std::sync::Arc;

dp.with_state_storage(Arc::new(MemoryStorage::new()));
}

Custom backend

Implement the StateStorage trait to persist state in Redis, a database, or any store:

#![allow(unused)]
fn main() {
use ferogram::fsm::{StateStorage, StateKey, StorageError};
use async_trait::async_trait;

struct RedisStorage { /* ... */ }

#[async_trait]
impl StateStorage for RedisStorage {
    async fn get_state(&self, key: &StateKey) -> Result<Option<String>, StorageError> { todo!() }
    async fn set_state(&self, key: &StateKey, state: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_state(&self, key: &StateKey) -> Result<(), StorageError> { todo!() }
    async fn get_data(&self, key: &StateKey, field: &str) -> Result<Option<String>, StorageError> { todo!() }
    async fn set_data(&self, key: &StateKey, field: &str, value: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_data(&self, key: &StateKey, field: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_all_data(&self, key: &StateKey) -> Result<(), StorageError> { todo!() }
}
}

Handler signature

FSM handlers take two arguments: the message and the state context:

#![allow(unused)]
fn main() {
dp.on_message_fsm(filter, MyState::SomeVariant, |msg, state| async move {
    // msg  : ferogram::update::IncomingMessage
    // state: ferogram::fsm::StateContext
});
}

For edited messages use on_edit_fsm with the same signature.


Routers with FSM

Routers support FSM handlers too, which is useful for splitting a bot into feature modules:

#![allow(unused)]
fn main() {
use ferogram::filters::Router;
use std::sync::Arc;

fn order_router() -> Router {
    let mut r = Router::new();
    r.on_message_fsm(filters::text(), OrderState::AwaitingProduct, handle_product);
    r.on_message_fsm(filters::text(), OrderState::AwaitingQuantity, handle_qty);
    r
}

// Then include in the dispatcher:
dp.include(order_router());
}