CI Hex.pm HexDocs Hex Downloads

Finite State Machine / multi-flow conversation state management for ExGram Telegram bots.

Provides use ExGram.FSM with pluggable storage backends, named conversation flows, and runtime transition validation. Integrates with ExGram.Router via automatically-registered :fsm_state, :fsm_flow, and :fsm_in_flow filter aliases.

Installation

Add ex_gram_fsm to your dependencies in mix.exs:

def deps do
  [
    {:ex_gram, "~> 0.60"},
    {:ex_gram_fsm, "~> 0.1.0"}
  ]
end

If you are also using ExGram.Router, add it too:

def deps do
  [
    {:ex_gram, "~> 0.60"},
    {:ex_gram_fsm, "~> 0.1.0"},
    {:ex_gram_router, "~> 0.1.0"}
  ]
end

Defining Flows

Each conversation flow is a separate module using use ExGram.FSM.Flow:

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

The name: option sets the flow's identifier atom. defstates declares valid states and their allowed transitions. default_state/0 returns the state automatically set when the flow is started.

Usage

Call use ExGram.Router before use ExGram.FSM. The :fsm_flow, :fsm_state, and :fsm_in_flow filter aliases are registered automatically.

defmodule MyBot do
  use ExGram.Bot, name: :my_bot
  use ExGram.Router
  use ExGram.FSM,
    storage: ExGram.FSM.Storage.ETS,
    flows: [MyBot.RegistrationFlow, MyBot.SettingsFlow],
    on_invalid_transition: :log

  command("register", description: "Start registration")
  command("settings", description: "Change settings")

  scope do
    filter :command, :register
    handle &MyBot.Handlers.start_registration/1
  end

  scope do
    filter :command, :settings
    handle &MyBot.Handlers.start_settings/1
  end

  # Route by flow + state
  scope do
    filter :fsm_flow, :registration

    scope do
      filter :fsm_state, :get_name
      filter :text
      handle &MyBot.Handlers.got_name/1
    end

    scope do
      filter :fsm_state, :get_email
      filter :text
      handle &MyBot.Handlers.got_email/1
    end
  end

  scope do
    handle &MyBot.Handlers.fallback/1
  end
end

Handler functions receive the context and use the imported FSM helpers:

defmodule MyBot.Handlers do
  def start_registration(context) do
    context
    |> start_flow(:registration)
    |> answer("What's your name?")
  end

  def got_name(context) do
    name = context.update.message.text

    context
    |> update_data(%{name: name})
    |> transition(:get_email)
    |> answer("Got it, #{name}! What's your email?")
  end

  def got_email(context) do
    %{name: name} = get_data(context)
    email = context.update.message.text

    context
    |> update_data(%{email: email})
    |> clear_flow()
    |> answer("Registered! Welcome, #{name} (#{email}).")
  end

  def fallback(context), do: context
end

Without ExGram.Router

Pattern-match on context.extra.fsm directly in handle/2 clauses:

defmodule MyBot do
  use ExGram.Bot, name: :my_bot
  use ExGram.FSM,
    storage: ExGram.FSM.Storage.ETS,
    flows: [MyBot.RegistrationFlow],
    on_invalid_transition: :log

  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! What's your email?")
  end

  def handle(_, context), do: context
end

Options

use ExGram.FSM accepts the following options:

OptionTypeDefaultDescription
storage:moduleExGram.FSM.Storage.ETSStorage backend module
flows:list of modules[]Flow modules to register (see ExGram.FSM.Flow)
on_invalid_transition:atom or {m, f}:raisePolicy for invalid transitions
key:moduleExGram.FSM.Key.ChatUserKey adapter module (see ExGram.FSM.Key)

on_invalid_transition policies

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)

Flow Lifecycle

One flow is active at a time per key (by default, per {chat_id, user_id} pair). The flow lifecycle is:

  1. Start - start_flow(context, :flow_name) activates a flow, sets its default state, clears data
  2. Transition - transition(context, :next_state) moves to the next step with validation
  3. Accumulate - update_data(context, %{key: value}) persists form fields
  4. End - clear_flow(context) resets to no-flow state

Attempting to start_flow when a different flow is already active triggers the on_invalid_transition policy.

Helpers

use ExGram.FSM automatically imports these functions into your bot module:

FunctionDescription
start_flow(context, flow)Start a named flow (sets default state, clears data)
get_flow(context)Returns current active flow name atom, or nil
get_state(context)Returns current step atom within the active flow, or nil
get_data(context)Returns current FSM data map, never nil
transition(context, to)Transition to next step with validation
set_state(context, state)Force-set state within active flow, bypassing validation
set_state(context, flow, state)Force-set flow + state, bypassing all checks (escape hatch)
update_data(context, map)Merge a map into the FSM data
clear_flow(context)Reset: no active flow, no state, no data

All helpers take and return ExGram.Cnt.t() for pipeline compatibility.

transition/2 vs set_state/2

  • transition/2 validates the from -> to pair against the flow's declared transitions and applies the on_invalid_transition policy if the transition is not allowed. This is the normal path.
  • set_state/2 unconditionally sets the state within the active flow, bypassing transition validation. Use as an escape hatch (admin resets, recovery).
  • set_state/3 unconditionally sets both flow and state, ignoring any active flow. Use only for testing or extreme recovery scenarios.

Filters (ExGram.Router integration)

When use ExGram.Router is detected on the same module, three filter aliases are registered automatically.

:fsm_flow - match on active flow

scope do
  filter :fsm_flow, :registration
  filter :fsm_state, :get_name
  filter :text
  handle &MyBot.Handlers.got_name/1
end

Match when no flow is active:

scope do
  filter :fsm_flow, nil
  filter :command, :start
  handle &MyBot.Handlers.handle_start/1
end

:fsm_state - match on state or data

Match on state atom:

scope do
  filter :fsm_state, :get_name
  filter :text
  handle &MyBot.Handlers.got_name/1
end

Match on a key in FSM data:

scope do
  filter :fsm_state, {:step, :confirm}
  handle &MyBot.Handlers.confirm/1
end

:fsm_in_flow - match when any flow is active

Returns true whenever any FSM flow is active (i.e. flow is not nil). Useful as a catch-all guard to handle mid-conversation messages without checking which specific flow is running:

scope do
  filter :fsm_in_flow
  handle &MyBot.Handlers.in_flow_fallback/1
end

To register any filter manually (without use ExGram.FSM):

alias_filter ExGram.FSM.Filter.Flow,   as: :fsm_flow
alias_filter ExGram.FSM.Filter.State,  as: :fsm_state
alias_filter ExGram.FSM.Filter.InFlow, as: :fsm_in_flow

Storage

The default backend is ExGram.FSM.Storage.ETS (in-memory, single-node). State is lost on restart.

Storage backends are bot-scoped: every callback receives bot_name as its first argument so that a single backend can serve multiple bots without key collisions. The ETS implementation creates one named table per bot (:"ex_gram_fsm_{bot_name}"). The storage is initialized automatically at bot startup via the ExGram.FSM.StorageInit ExGram.BotInit hook - use ExGram.FSM registers it for you.

For production deployments, implement the ExGram.FSM.Storage behaviour:

defmodule MyApp.RedisStorage do
  @behaviour ExGram.FSM.Storage

  @impl true
  def init(bot_name, opts), do: :ok   # create connection / namespace for bot_name

  @impl true
  def get_state(bot_name, key), do: # read from Redis using bot_name as key prefix

  @impl true
  def set_state(bot_name, key, %ExGram.FSM.State{} = state), do: # write to Redis

  @impl true
  def get_data(bot_name, key), do: # read data portion from Redis

  @impl true
  def set_data(bot_name, key, data), do: # write data portion to Redis

  @impl true
  def update_data(bot_name, key, new_data), do: # merge and write to Redis

  @impl true
  def clear(bot_name, key), do: # delete from Redis
end

Use it via the storage: option:

use ExGram.FSM, storage: MyApp.RedisStorage, flows: [...]

Key Adapters

The key adapter controls how FSM state is scoped. It is a module implementing the ExGram.FSM.Key behaviour, configured via the key: option.

Built-in adapters

ModuleKey shapeScope
ExGram.FSM.Key.ChatUser (default){chat_id, user_id}Per-user per-chat
ExGram.FSM.Key.User{user_id}Global per-user (across all chats)
ExGram.FSM.Key.Chat{chat_id}Per-chat shared (all users share one FSM)
ExGram.FSM.Key.ChatTopic{chat_id, thread_id}Per forum topic, shared by all users
ExGram.FSM.Key.ChatTopicUser{chat_id, thread_id, user_id}Per-user per forum topic
# Default: each user has independent state in each chat
use ExGram.FSM, key: ExGram.FSM.Key.ChatUser, flows: [...]

# User-scoped: same state across DMs, groups, and inline queries
use ExGram.FSM, key: ExGram.FSM.Key.User, flows: [...]

# Chat-scoped: shared state for all users in a chat (e.g., group game sessions)
use ExGram.FSM, key: ExGram.FSM.Key.Chat, flows: [...]

# Forum topic adapters (Telegram groups with Topics mode enabled)
use ExGram.FSM, key: ExGram.FSM.Key.ChatTopic, flows: [...]
use ExGram.FSM, key: ExGram.FSM.Key.ChatTopicUser, flows: [...]

Sentinel values

When a dimension is unavailable (e.g., a message is not in a forum topic), implementations use 0 as a sentinel. When a mandatory dimension is absent (e.g., no user for User), the adapter returns :error and the middleware skips FSM state loading for that update.

Custom key adapters

Implement the ExGram.FSM.Key behaviour to define your own scoping strategy:

defmodule MyApp.FSM.Key.Custom do
  @behaviour ExGram.FSM.Key

  @impl true
  def extract(cnt) do
    with {:ok, user} <- ExGram.Dsl.extract_user(cnt.update),
         {:ok, chat} <- ExGram.Dsl.extract_chat(cnt.update) do
      {:ok, {chat.id, user.language_code}}
    end
  end
end

use ExGram.FSM, key: MyApp.FSM.Key.Custom, flows: [...]

License

Beerware: see LICENSE.