FSM (Finite State Machine)

Copy Markdown View Source

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 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

This guide covers the most common usage. For the full API reference and advanced options, see the ExGram.FSM HexDocs.

Installation

Add ex_gram_fsm to your dependencies:

# 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 (recommended), add it too:

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:

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. Startstart_flow(context, :registration) activates the flow, sets the default state, and clears any previous data.
  2. Transitiontransition(context, :get_email) moves to the next state. The transition is validated against the flow's declared graph.
  3. Accumulateupdate_data(context, %{name: name}) merges data into the flow's persistent data map. Collect form fields step by step.
  4. Endclear_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:

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, you get dedicated filter aliases.

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.

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
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

FilterOptionsMatches when…
:fsm_flowatom or nilActive flow matches the given name (or nil for no active flow)
:fsm_stateatomCurrent 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):

FunctionDescription
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:

use ExGram.FSM,
  storage: ExGram.FSM.Storage.ETS,
  flows: [MyBot.RegistrationFlow],
  on_invalid_transition: :log  # or :raise, :ignore, {Module, :function}
PolicyBehavior
:raise (default)Raises ExGram.FSM.TransitionError
:logLogs a warning, returns context unchanged
:ignoreSilent 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:

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:

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:

AdapterKeyUse 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
# 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