ExGram.FSM.Helpers (ExGram FSM v0.1.0)

Copy Markdown View Source

Runtime helper functions for FSM flow and state management.

All functions take and return ExGram.Cnt.t() for pipeline compatibility. These are automatically imported into your bot module when you call use ExGram.FSM.

Context keys

The helpers read FSM config from context.extra keys set by the middleware:

KeyTypeDescription
context.extra.fsm%ExGram.FSM.State{}Current flow + state + data
context.extra.fsm_keyterm()Storage key (shape depends on configured key module)
context.extra.fsm_storagemoduleStorage backend module
context.extra.fsm_flows%{atom => module}Registered flow modules map
context.extra.fsm_on_invalid_transitionatom or tupleInvalid transition policy

Flow lifecycle

# 1. Start a flow (sets flow name, default state, clears data)
context |> start_flow(:registration)

# 2. Transition through states (validated against the active flow)
context |> transition(:get_email)

# 3. Accumulate data
context |> update_data(%{name: "Alice"})

# 4. End the flow (resets to no-flow state)
context |> clear_flow()

Summary

Functions

Resets the FSM to a clean state: no active flow, no state, no data.

Returns the current FSM data map from context.

Returns the current active flow name atom from context.

Returns the current FSM state atom (step within the active flow) from context.

Unconditionally sets the FSM state within the currently active flow.

Force sets a flow and state, bypassing all conflict and validation checks.

Starts a named flow for the current user/chat.

Sets the FSM state with transition validation against the active flow.

Merges a map into the current FSM data, persisting the result.

Functions

clear_flow(context)

@spec clear_flow(ExGram.Cnt.t()) :: ExGram.Cnt.t()

Resets the FSM to a clean state: no active flow, no state, no data.

Calls storage.clear/1 to remove the user's FSM record entirely.

Example

def handle({:command, :cancel, _}, context) do
  context
  |> clear_flow()
  |> answer("Cancelled. Send /start to begin again.")
end

get_data(context)

@spec get_data(ExGram.Cnt.t()) :: map()

Returns the current FSM data map from context.

Never returns nil — returns %{} if no FSM state exists.

Example

def handle({:command, :status, _}, context) do
  data = get_data(context)
  answer(context, "Your data: #{inspect(data)}")
end

get_flow(context)

@spec get_flow(ExGram.Cnt.t()) :: atom() | nil

Returns the current active flow name atom from context.

Returns nil if no flow is active or if the FSM middleware didn't run.

Example

def handle({:command, :status, _}, context) do
  flow = get_flow(context)
  answer(context, "Current flow: #{inspect(flow)}")
end

get_state(context)

@spec get_state(ExGram.Cnt.t()) :: atom() | nil

Returns the current FSM state atom (step within the active flow) from context.

Returns nil if no state is set or if the FSM middleware didn't run.

Example

def handle({:command, :status, _}, context) do
  state = get_state(context)
  answer(context, "Current step: #{inspect(state)}")
end

set_state(context, new_state)

@spec set_state(ExGram.Cnt.t(), atom()) :: ExGram.Cnt.t()

Unconditionally sets the FSM state within the currently active flow.

Requires an active flow. If no flow is active, the on_invalid_transition policy is applied (with from: nil, to: new_state).

This bypasses transition validation — use transition/2 for the normal path.

Use cases:

  • Resetting to the flow's first step
  • Admin override within a flow
  • Recovery from an error state within a flow

Example

def handle({:command, :restart_flow, _}, context) do
  context
  |> set_state(:get_name)
  |> answer("Let's start over. What's your name?")
end

set_state(context, flow_name, new_state)

@spec set_state(ExGram.Cnt.t(), atom(), atom()) :: ExGram.Cnt.t()

Force sets a flow and state, bypassing all conflict and validation checks.

This is the escape hatch — it always succeeds regardless of the current flow. The data map is preserved as-is. Use this only for admin resets, recovery, or testing.

Example

def handle({:command, :admin_reset, _}, context) do
  context
  |> set_state(:registration, :get_name)
  |> answer("Admin reset. Starting registration flow.")
end

start_flow(context, flow_name)

@spec start_flow(ExGram.Cnt.t(), atom()) :: ExGram.Cnt.t()

Starts a named flow for the current user/chat.

Sets the flow name, applies the flow's default_state/0 as the initial state (if defined), and clears any previous data.

Behavior:

  • If no flow is currently active → starts the new flow
  • If the same flow is already active → re-starts it (resets state + data)
  • If a different flow is active → applies the on_invalid_transition policy. The "from" is the current flow name (treated as a pseudo-state), "to" is the requested flow name.

Example

def handle({:command, :register, _}, context) do
  context
  |> start_flow(:registration)
  |> answer("What's your name?")
end

transition(context, to)

@spec transition(ExGram.Cnt.t(), atom()) :: ExGram.Cnt.t()

Sets the FSM state with transition validation against the active flow.

This is the normal path for moving through steps. Use set_state/2 only as an escape hatch when you want to force a state skip validation.

Behavior:

  1. Reads the current state from context.extra.fsm.state
  2. Reads the active flow from context.extra.fsm.flow
  3. Looks up the flow module and checks whether from -> to is a valid transition
  4. If valid (or no flows configured): updates state and persists to storage
  5. If NOT valid: delegates to the on_invalid_transition policy

Return value depends on policy:

  • :raise — raises ExGram.FSM.TransitionError (never returns normally)
  • :log — logs a warning, returns context unchanged
  • :ignore — returns context unchanged silently
  • {Module, :function} — calls Module.function(context, from, to), returns its result

Example

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

update_data(context, new_data)

@spec update_data(ExGram.Cnt.t(), map()) :: ExGram.Cnt.t()

Merges a map into the current FSM data, persisting the result.

Uses Map.merge/2 semantics: new keys are added, existing keys are overwritten. The active flow and state are preserved.

Example

def handle({:text, name, _}, context) do
  context
  |> update_data(%{name: name})
  |> transition(:get_email)
  |> answer("What's your email?")
end