Repository navigation
What is FSM ?
A Telegram bot is stateless by default. Every message that hits your bot is treated as a brand new, isolated event, the bot has no idea what it said five seconds ago, or what it's waiting for you to reply with. That's fine for simple commands like /help or /ping. It falls apart the moment you need a conversation: "what product do you want?" → "how many?" → "what's your address?" → "confirm?"
An FSM is just a formal way of saying: this conversation is on step X right now, and step X decides what happens next. Instead of one big pile of "if this text matches this, do that" logic, you break a flow into named steps (states), and each step has its own handler that only runs when the conversation is actually sitting on that step.
That's the whole concept. Everything else is implementation detail.
I kept running into the same wall, over and over, across every bot I wrote. My quiz bot needed to know "which question is this user currently answering." Any order or checkout-style bot needs "which step of the form are we on." Any onboarding flow needs the same thing. And every single time, I ended up hand-rolling the same broken pattern:
- A
HashMap<user_id, Step>(or something equally fragile) I'd sketch out fresh, per project - Manual
if/matchchains scattered across handlers, checking "is this user mid-flow, and where" - Zero persistence, restart the bot and every user's progress just vanishes
- No clean way to let a user back out (
/cancel) without writing that logic separately for every single step
I got tired of solving the same problem badly, differently, every time. So instead of patching it per-bot, I built it once, properly, as part of ferogram itself, so every bot built on top of it gets this for free instead of reinventing it.
Two pieces of information, tracked per conversation:
- State: which step is this conversation on right now.
- Data: whatever the user has answered so far, tied to that same conversation.
Both live behind a storage trait. How they're actually persisted (in-memory, Redis, a database, whatever) is a plug-in point, not something baked into how you write handlers. Swap the backend, the rest of your bot doesn't change.
The FsmState derive macro. Turn a plain enum into a set of states:
#[derive(FsmState, Clone, Debug, PartialEq)]
enum OrderState {
WaitingProduct,
WaitingQuantity,
WaitingAddress,
Confirm,
}The StateStorage trait. The actual read/write backend for state and data. Ships with MemoryStorage, in-process, DashMap-backed, zero setup, gone when the process restarts. Anyone can implement the trait themselves for Redis, Postgres, SQLite, whatever fits, and nothing else in the bot needs to change.
State-scoped handlers. on_message_fsm, on_edit_fsm, and on_callback_query_fsm, available on both Router and Dispatcher. Each one only fires when the conversation's current state matches what you registered it for:
r.on_message_fsm(text(), OrderState::WaitingProduct, handle_product);Button presses work the same way as typed messages here, a wizard can advance whether someone types a reply or taps an inline keyboard button, same state machine underneath either way.
StateContext, handed to you inside a matched handler:
-
transition(new_state): move to the next step -
set_data(field, value)/get_data(field): stash and read typed data for this conversation (serialized to JSON under the hood, so you can store real structs, not just strings) -
clear_state(): reset just the step, keep the data -
clear_data(): wipe the stashed answers, keep the step -
clear_all(): full reset, and this is what a/cancelhandler calls
StateKey / StateKeyStrategy. Controls how a conversation slot is identified, per user, per chat, or a combination of both. The same key logic applies whether the update came in as a text message or a callback query, so an in-chat button press correctly matches the same conversation slot as a typed reply. One honest edge case: for a callback on a message sent via inline mode (no regular chat behind it), there's no chat ID to key on, so that specific case can land in a different slot than the user's regular in-chat conversation under the default per-user-per-chat strategy. Worth knowing if you're mixing inline-mode buttons into a flow that also runs in regular chats.
- You stop writing the same tracking hack every time. The step logic lives in the framework, not duplicated across every bot you build.
- Persistence is a backend swap, not a rewrite. Move from in-memory to a real database without touching a single handler.
- Cancel/escape flows are trivial. Because state is a first-class, queryable thing, "let the user bail out from any step" is a five-line loop instead of separate logic per step.
- One system for messages and buttons. You're not building two different tracking mechanisms depending on how the user responds.
- Quiz bots. This is the one closest to home for me, "waiting for an answer to question N" → "show question N+1" is exactly an FSM loop. Plug in a real storage backend (once one exists beyond the in-memory default) and quiz progress survives a bot restart instead of resetting everyone mid-session.
- Orders and checkout flows. Product → quantity → address → confirm, the example this whole module was built around.
- Onboarding / setup wizards. "Step 1 of 4," collecting a few pieces of info before a user's account is ready.
- Surveys and multi-question forms.
- Approval chains. "Waiting on an admin to approve or reject," where the state itself represents "pending."
- Honestly, any time your bot asks a question and needs to remember it asked, that's an FSM, whether you call it one or not.
I'd rather be upfront about the gaps than let someone assume they don't exist:
- No built-in database backend. Only the trait and the in-memory implementation ship today. Redis/Postgres/etc. backends are possible right now, just not written yet.
- No FSM support for inline queries. Didn't build this in, inline queries are one-shot, stateless exchanges by nature, there's no "next step" to track.
-
Bootstrapping the first state is manual. Starting a flow (e.g.
/order) has to reach intoStateStoragedirectly and set the first state by hand, from a regular handler. There's no shortcut for that first step yet, it's on the list.
This isn't a novel idea, finite state machines for conversational flows are a well-known pattern. What I built is ferogram's version of it, shaped around how the rest of the library already works (same filter system, same Router/Dispatcher registration style), so it doesn't feel like a bolted-on extra. If you're building anything that needs more than a single request/response, this is the piece that makes it manageable instead of a pile of manual state-tracking hacks.