Current section
Files
Jump to
Current section
Files
lib/fsm.ex
defmodule TelegramEx.FSM do
@moduledoc """
Per-user Finite State Machine backed by Pockets (ETS).
This module provides per-user state management for conversational bots.
Each user (identified by chat ID) can have their own state and associated data,
allowing you to build multi-step workflows, wizards, and stateful interactions.
The FSM stores tuples of `{state, data}` keyed by chat ID in an ETS table
managed by the Pockets library.
## State Management
- **State**: An atom representing the current conversation state (e.g., `:waiting_name`, `:confirming`)
- **Data**: Any Elixir term to store context (e.g., `%{step: 1, answers: []}`)
## Usage
# Get current state and data
{state, data} = FSM.get_state(:my_bot, chat_id)
# Set state only (keeps existing data)
FSM.set_state(:my_bot, chat_id, :waiting_name)
# Set state and data
FSM.set_state(:my_bot, chat_id, :waiting_name, %{step: 1})
# Reset state (removes entry)
FSM.reset_state(:my_bot, chat_id)
## Using with defstate
The `defstate/2` macro allows you to define handlers that only match
when the user is in a specific state:
defstate :waiting_name do
def handle_message(%{text: text, chat: chat}, ctx) do
# This only runs when user is in :waiting_name state
{:transition, :waiting_age, Map.put(ctx.data, :name, text)}
end
end
"""
@type chat_id :: TelegramEx.Types.chat_id()
@doc """
Initializes the FSM storage for a bot.
This is called automatically by `TelegramEx.Server` when the bot starts.
You typically don't need to call this manually.
## Parameters
- `name` - The bot name (atom)
## Returns
- `:ok` - FSM initialized successfully
- `{:error, reason}` - Initialization failed
"""
@spec init(atom()) :: :ok | {:error, term()}
def init(name) do
case Pockets.new(name) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Resets the FSM state for a user, removing their stored state and data.
## Parameters
- `name` - The bot name (atom)
- `id` - The chat ID
## Returns
- `:ok` - State reset successfully
- `{:error, reason}` - Failed to reset state
## Examples
FSM.reset_state(:my_bot, 123456)
# User's state is now cleared
"""
@spec reset_state(atom(), chat_id()) :: atom() | {:error, term()}
def reset_state(name, id) do
Pockets.delete(name, id)
end
@doc """
Retrieves the current FSM state and data for a user.
## Parameters
- `name` - The bot name (atom)
- `id` - The chat ID
## Returns
A tuple `{state, data}` where:
- `state` - Current state atom (or `nil` if no state set)
- `data` - Associated data (or `nil` if no data set)
## Examples
{state, data} = FSM.get_state(:my_bot, 123456)
# => {:waiting_name, %{step: 1}}
{state, data} = FSM.get_state(:my_bot, 999999)
# => {nil, nil} # User has no state
"""
@spec get_state(atom(), chat_id()) :: {term(), term()}
def get_state(name, id) do
Pockets.get(name, id, {nil, nil})
end
@doc """
Sets the FSM state for a user, keeping existing data.
## Parameters
- `name` - The bot name (atom)
- `id` - The chat ID
- `state` - New state atom
## Returns
- `:ok` - State set successfully
- `{:error, reason}` - Failed to set state
## Examples
FSM.set_state(:my_bot, 123456, :waiting_age)
# State changed to :waiting_age, data preserved
"""
@spec set_state(atom(), chat_id(), atom()) :: atom() | {:error, term()}
def set_state(name, id, state) do
{_, data} = get_state(name, id)
Pockets.put(name, id, {state, data})
end
@doc """
Sets the FSM state and data for a user.
## Parameters
- `name` - The bot name (atom)
- `id` - The chat ID
- `state` - New state atom
- `data` - New data to store
## Returns
- `:ok` - State and data set successfully
- `{:error, reason}` - Failed to set state
## Examples
FSM.set_state(:my_bot, 123456, :waiting_age, %{name: "John", step: 2})
# State and data both updated
"""
@spec set_state(atom(), chat_id(), atom(), term()) :: atom() | {:error, term()}
def set_state(name, id, state, data) do
Pockets.put(name, id, {state, data})
end
@doc """
Defines handlers that only execute when the user is in a specific FSM state.
This macro automatically injects state pattern matching into handler functions,
so they only run when the user's FSM state matches the specified state.
## Parameters
- `state` - The state atom to match
- `do` - Block containing handler function definitions
## Examples
defstate :waiting_name do
def handle_message(%{text: text, chat: chat}, ctx) do
# Only runs when user is in :waiting_name state
ctx
|> Message.text("Got your name: \#{text}")
|> Message.send(chat["id"])
{:transition, :waiting_age, Map.put(ctx.data, :name, text)}
end
end
defstate :waiting_age do
def handle_message(%{text: age, chat: chat}, ctx) do
# Only runs when user is in :waiting_age state
name = ctx.data.name
ctx
|> Message.text("Hello \#{name}, you are \#{age} years old")
|> Message.send(chat["id"])
FSM.reset_state(:my_bot, chat["id"])
end
end
## Note
The `ctx` parameter in handlers defined within `defstate` will have
`ctx.state` automatically matched to the specified state atom.
"""
defmacro defstate(state, do: block) do
Macro.prewalk(block, fn
{:def, def_meta, [func_header | rest]} ->
{name, meta, args} = func_header
new_args =
case args do
[msg_arg, {:=, eq_meta, [{:%{}, map_meta, pairs}, binding]}] ->
[msg_arg, {:=, eq_meta, [{:%{}, map_meta, [{:state, state} | pairs]}, binding]}]
[msg_arg, {var_name, var_meta, var_ctx}] ->
binding = {var_name, var_meta, var_ctx}
[msg_arg, {:=, [], [{:%{}, [], [{:state, state}]}, binding]}]
_ ->
args
end
{:def, def_meta, [{name, meta, new_args} | rest]}
other ->
other
end)
end
end