Packages
ex_gram_fsm
0.1.0
Multi-flow Finite State Machine and conversation state management for ExGram Telegram bots.
Current section
Files
Jump to
Current section
Files
lib/ex_gram/fsm/flow.ex
defmodule ExGram.FSM.Flow do
@moduledoc """
Behaviour and DSL for declaring a named FSM conversation flow.
Each flow is an independent module that bundles a name, optional default state,
and state/transition declarations. Register flows with
`use ExGram.FSM, flows: [MyBot.RegistrationFlow, MyBot.SettingsFlow]`.
## Usage
defmodule MyBot.RegistrationFlow do
use ExGram.FSM.Flow, name: :registration
defstates do
state :get_name, to: [:get_email]
state :get_email, to: [:confirm, :get_name]
state :confirm, to: []
end
# Optional: define the starting state when start_flow/2 is called.
# If not defined, flow starts with state: nil (you must set it manually).
def default_state, do: :get_name
end
## Callbacks
| Callback | Returns | Description |
|----------|---------|-------------|
| `flow_name/0` | `atom()` | The unique flow name atom |
| `default_state/0` | `atom() \\| nil` | Initial state when flow starts (override if needed) |
| `states/0` | `[atom()]` | All valid state atoms |
| `transitions/0` | `%{atom() => [atom()]} \\| :any` | Allowed transitions |
## Notes
- `flow_name/0` is generated from the `:name` option and cannot be overridden.
- `default_state/0` defaults to `nil`; override with your own `def default_state, do: :get_name`.
- `states/0` and `transitions/0` are generated by the `defstates` block (same as `ExGram.FSM.States`).
- The `:name` option is required. A `CompileError` is raised if omitted.
"""
@doc "Returns the unique name atom for this flow."
@callback flow_name() :: atom()
@doc """
Returns the initial state atom when `start_flow/2` is called, or `nil` to
start with no state set (you must call `transition/2` or `set_state/2` manually).
"""
@callback default_state() :: atom() | nil
@doc "Returns all valid state atoms declared in the `defstates` block."
@callback states() :: [atom()]
@doc """
Returns the allowed transitions map, or `:any` if all transitions are allowed.
When returning a map, keys are source states and values are lists of allowed
target states. Transitions not in the map are invalid.
"""
@callback transitions() :: %{atom() => [atom()]} | :any
@doc false
defmacro __using__(opts) do
name = Keyword.get(opts, :name)
if !name do
raise CompileError,
description:
"ExGram.FSM.Flow: `:name` option is required. " <>
"Use `use ExGram.FSM.Flow, name: :my_flow_name`."
end
if !is_atom(name) do
raise CompileError,
description: "ExGram.FSM.Flow: `:name` must be an atom, got #{inspect(name)}"
end
quote do
@behaviour ExGram.FSM.Flow
import ExGram.FSM.States, only: [defstates: 1, state: 1, state: 2]
# Accumulate {state_name, to_list_or_nil} entries (same as ExGram.FSM.States)
Module.register_attribute(__MODULE__, :fsm_declared_states, accumulate: true)
@before_compile ExGram.FSM.Flow
# flow_name/0 is always generated from the :name option
@impl ExGram.FSM.Flow
def flow_name, do: unquote(name)
# default_state/0 default implementation — user can override
@impl ExGram.FSM.Flow
def default_state, do: nil
# Allow the user to override default_state/0
defoverridable default_state: 0
end
end
@doc false
defmacro __before_compile__(env) do
declared = Module.get_attribute(env.module, :fsm_declared_states) |> Enum.reverse()
# Check for duplicate state names
state_names = Enum.map(declared, fn {name, _} -> name end)
duplicates = state_names -- Enum.uniq(state_names)
if duplicates != [] do
IO.warn(
"ExGram.FSM.Flow: duplicate state names declared in #{inspect(env.module)}: " <>
"#{inspect(Enum.uniq(duplicates))}",
Macro.Env.stacktrace(env)
)
end
unique_names = Enum.uniq(state_names)
# Determine if any state has :to targets
has_transitions = Enum.any?(declared, fn {_name, to} -> to != nil end)
transitions_result =
if has_transitions do
for {name, to} <- declared, to != nil, into: %{} do
check_targets(to, name, unique_names, env)
{name, to}
end
else
:any
end
quote do
@impl ExGram.FSM.Flow
def states, do: unquote(unique_names)
@impl ExGram.FSM.Flow
def transitions, do: unquote(Macro.escape(transitions_result))
end
end
defp check_targets(targets, from_name, unique_names, env) do
for target <- targets, do: warn_undeclared_target(target, from_name, unique_names, env)
end
defp warn_undeclared_target(target, from_name, unique_names, env) do
if target not in unique_names do
IO.warn(
"ExGram.FSM.Flow: state :#{target} referenced in `to:` for " <>
":#{from_name} but not declared as a state in #{inspect(env.module)}",
Macro.Env.stacktrace(env)
)
end
end
end