# FSM (Finite State Machine)
Many bots need multi-step conversation flows: registration forms, settings wizards, onboarding sequences. You can model these with pattern matching on `context.extra`, but as flows multiply, managing state transitions, validation, and cleanup becomes tedious and error-prone.
[ExGram.FSM](https://hex.pm/packages/ex_gram_fsm) provides structured conversation state management with:
- **Named flows** with explicitly declared states and valid transitions
- **Runtime transition validation** — catch illegal state jumps early
- **Pluggable storage** — ETS for development, bring your own backend for production
- **Key adapters** — scope state per-user, per-chat, or per-topic
- **Router integration** — automatic `:fsm_flow` and `:fsm_state` filter aliases when used with [ExGram.Router](router.md)
> This guide covers the most common usage. For the full API reference and advanced options, see the [ExGram.FSM HexDocs](https://hexdocs.pm/ex_gram_fsm).
## Installation
Add `ex_gram_fsm` to your dependencies:
```elixir
# mix.exs
def deps do
[
{:ex_gram, "~> 0.65"},
{:ex_gram_fsm, "~> 0.1.0"},
{:jason, ">= 1.0.0"},
{:req, "~> 0.5"}
]
end
```
If you're also using [ExGram.Router](router.md) (recommended), add it too:
```elixir
def deps do
[
{:ex_gram, "~> 0.65"},
{:ex_gram_router, "~> 0.1.0"},
{:ex_gram_fsm, "~> 0.1.0"},
{:jason, ">= 1.0.0"},
{:req, "~> 0.5"}
]
end
```
## Defining Flows
Each conversation flow is a module that declares its states and allowed transitions:
```elixir
defmodule MyBot.RegistrationFlow do
use ExGram.FSM.Flow, name: :registration
defstates do
state :get_name, to: [:get_email]
state :get_email, to: [:done]
state :done, to: []
end
def default_state, do: :get_name
end
```
- **`name:`** — Identifies this flow (used in `start_flow/2` and filters)
- **`defstates`** — Declares valid states and where each can transition to
- **`default_state/0`** — The initial state when the flow starts
The transition graph is validated at compile time and enforced at runtime. If you try `transition(context, :done)` from `:get_name`, the configured policy kicks in (raise, log, or ignore).
## Flow Lifecycle
A flow goes through four stages:
1. **Start** — `start_flow(context, :registration)` activates the flow, sets the default state, and clears any previous data.
2. **Transition** — `transition(context, :get_email)` moves to the next state. The transition is validated against the flow's declared graph.
3. **Accumulate** — `update_data(context, %{name: name})` merges data into the flow's persistent data map. Collect form fields step by step.
4. **End** — `clear_flow(context)` resets everything: no active flow, no state, no data.
## Basic Example (Without Router)
You can use ExGram.FSM with plain `handle/2` pattern matching:
```elixir
defmodule MyBot do
use ExGram.Bot, name: :my_bot, setup_commands: true
use ExGram.FSM,
storage: ExGram.FSM.Storage.ETS,
flows: [MyBot.RegistrationFlow]
command("register", description: "Start registration")
def handle({:command, :register, _}, context) do
context
|> start_flow(:registration)
|> answer("What's your name?")
end
def handle(
{:text, name, _},
%{extra: %{fsm: %ExGram.FSM.State{flow: :registration, state: :get_name}}} = context
) do
context
|> update_data(%{name: name})
|> transition(:get_email)
|> answer("Got it, #{name}! What's your email?")
end
def handle(
{:text, email, _},
%{extra: %{fsm: %ExGram.FSM.State{flow: :registration, state: :get_email}}} = context
) do
%{name: name} = get_data(context)
context
|> update_data(%{email: email})
|> clear_flow()
|> answer("Done! Welcome, #{name} (#{email}).")
end
def handle(_, context), do: context
end
```
This works fine, but the pattern matching on `context.extra.fsm` is verbose. With [ExGram.Router](router.md), you get dedicated filter aliases.
## With ExGram.Router (Recommended)
When `use ExGram.Router` is present in the same module, `use ExGram.FSM` automatically registers three filter aliases: `:fsm_flow`, `:fsm_state`, and `:fsm_in_flow`.
> **Important:** `use ExGram.Router` must come *before* `use ExGram.FSM`.
```elixir
defmodule MyBot do
use ExGram.Bot, name: :my_bot, setup_commands: true
use ExGram.Router
use ExGram.FSM,
storage: ExGram.FSM.Storage.ETS,
flows: [MyBot.RegistrationFlow]
command("register", description: "Start registration")
scope do
filter :command, :register
handle &MyBot.Handlers.start_registration/1
end
scope do
filter :fsm_flow, :registration
scope do
filter :fsm_state, :get_name
filter :text
handle &MyBot.Handlers.got_name/2
end
scope do
filter :fsm_state, :get_email
filter :text
handle &MyBot.Handlers.got_email/2
end
end
scope do
handle &MyBot.Handlers.fallback/1
end
end
```
```elixir
defmodule MyBot.Handlers do
import ExGram.Dsl
def start_registration(context) do
context
|> start_flow(:registration)
|> answer("What's your name?")
end
def got_name({:text, name, _}, context) do
context
|> update_data(%{name: name})
|> transition(:get_email)
|> answer("Got it, #{name}! What's your email?")
end
def got_email({:text, email, _}, context) do
%{name: name} = get_data(context)
context
|> update_data(%{email: email})
|> clear_flow()
|> answer("Registered! Welcome, #{name} (#{email}).")
end
def fallback(context), do: context
end
```
The routing structure makes the flow visible at a glance: the outer scope guards on `:fsm_flow`, and each inner scope matches a specific step plus the expected input type.
### FSM Filter Reference
| Filter | Options | Matches when… |
|--------|---------|---------------|
| `:fsm_flow` | atom or `nil` | Active flow matches the given name (or `nil` for no active flow) |
| `:fsm_state` | atom | Current state matches the given atom |
| `:fsm_state` | `{key, value}` | `fsm_data[key] == value` |
| `:fsm_in_flow` | (none) | Any flow is active |
## Helper Functions
`use ExGram.FSM` imports these functions into your bot module (and they're available in handler modules that import from the bot):
| Function | Description |
|----------|-------------|
| `start_flow(context, flow_name)` | Start a flow — sets default state and clears data |
| `transition(context, state)` | Move to the next state (validated) |
| `set_state(context, state)` | Force-set state, bypassing validation |
| `set_state(context, flow, state)` | Force-set flow + state (escape hatch) |
| `get_flow(context)` | Current active flow name, or `nil` |
| `get_state(context)` | Current step within the active flow, or `nil` |
| `get_data(context)` | FSM data map (never `nil`) |
| `update_data(context, map)` | Merge a map into the FSM data |
| `clear_flow(context)` | Reset: no flow, no state, no data |
All helpers take and return `ExGram.Cnt.t()`, so they work seamlessly in pipelines.
### `transition/2` vs `set_state/2`
- **`transition/2`** validates the move against the flow's declared transitions and applies the `on_invalid_transition` policy if it's not allowed. This is the normal path — use it in your handlers.
- **`set_state/2`** and **`set_state/3`** bypass validation entirely. Use them for admin tools, recovery, or testing.
## Transition Policies
Configure what happens when an invalid transition is attempted:
```elixir
use ExGram.FSM,
storage: ExGram.FSM.Storage.ETS,
flows: [MyBot.RegistrationFlow],
on_invalid_transition: :log # or :raise, :ignore, {Module, :function}
```
| Policy | Behavior |
|--------|----------|
| `:raise` (default) | Raises `ExGram.FSM.TransitionError` |
| `:log` | Logs a warning, returns context unchanged |
| `:ignore` | Silent no-op, returns context unchanged |
| `{Module, :function}` | Calls `Module.function(context, from, to)` for custom handling |
## Storage Backends
The default backend is `ExGram.FSM.Storage.ETS` — in-memory, single-node, and **state is lost on restart**. This is fine for development and simple bots.
For production, implement the `ExGram.FSM.Storage` behaviour:
```elixir
defmodule MyBot.RedisStorage do
@behaviour ExGram.FSM.Storage
@impl true
def init(bot_name, _opts), do: :ok
@impl true
def get_state(bot_name, key), do: # read from Redis
@impl true
def set_state(bot_name, key, %ExGram.FSM.State{} = state), do: # write
@impl true
def get_data(bot_name, key), do: # read data
@impl true
def set_data(bot_name, key, data), do: # write data
@impl true
def update_data(bot_name, key, new_data), do: # merge and write
@impl true
def clear(bot_name, key), do: # delete
end
```
Use it:
```elixir
use ExGram.FSM, storage: MyBot.RedisStorage, flows: [...]
```
Storage is bot-scoped: the `bot_name` argument lets a single backend serve multiple bots without key collisions. The ETS implementation creates one named table per bot.
## Key Adapters
Key adapters control how FSM state is scoped — who shares state with whom:
| Adapter | Key | Use case |
|---------|-----|----------|
| `ExGram.FSM.Key.ChatUser` (default) | `{chat_id, user_id}` | Each user has independent state per chat |
| `ExGram.FSM.Key.User` | `{user_id}` | Same state across all chats (DMs, groups) |
| `ExGram.FSM.Key.Chat` | `{chat_id}` | Shared state for all users in a chat |
| `ExGram.FSM.Key.ChatTopic` | `{chat_id, thread_id}` | Per forum topic, shared |
| `ExGram.FSM.Key.ChatTopicUser` | `{chat_id, thread_id, user_id}` | Per-user per forum topic |
```elixir
# User-scoped: state follows the user everywhere
use ExGram.FSM, key: ExGram.FSM.Key.User, flows: [...]
# Chat-scoped: shared state, e.g. group game sessions
use ExGram.FSM, key: ExGram.FSM.Key.Chat, flows: [...]
```
You can also implement `ExGram.FSM.Key` to define your own scoping strategy.
## Next Steps
- [Router](router.md) - Declarative routing DSL (pairs perfectly with FSM)
- [Middlewares](middlewares.md) - Enrich context before routing and FSM
- [Handling Updates](handling-updates.md) - Understand update types
- [Testing](testing.md) - Test your bot end-to-end