Packages

Build Ussd (Unstructured Supplementary Service Data) applications in Elixir without breaking a sweat.

Current section

Files

Jump to
ussd GUIDE.md
Raw

GUIDE.md

# Guide
The [README](README.md) covers installation and a quick example. This guide covers every
feature in depth, one concept at a time.
- [Getting Started](#getting-started)
- [Context](#context)
- [Record](#record)
- [Menu](#menu)
- [Decisions](#decisions)
- [States & Transitions](#states--transitions)
- [Pagination & Truncation](#pagination--truncation)
- [Actions & Conditional Branching](#actions--conditional-branching)
- [Back Navigation](#back-navigation)
- [Resuming Sessions](#resuming-sessions)
- [Configurators](#configurators)
- [Localized Menus](#localized-menus)
- [Encrypted Records](#encrypted-records)
- [Exception Handling](#exception-handling)
- [Gateway Responses](#gateway-responses)
- [Visualizing a Flow](#visualizing-a-flow)
- [Simulating a Session](#simulating-a-session)
- [Linting a Flow](#linting-a-flow)
- [Generators](#generators)
- [Testing](#testing)
- [Session Events](#session-events)
- [Cache Backends](#cache-backends)
- [Configuration Reference](#configuration-reference)
## Getting Started
Every request into a flow is a `Ussd.Context` (who's calling, what they typed) run through
a builder pipeline that ends in `Ussd.run/1`:
```elixir
alias Ussd.Context
context = Context.new(session_id, phone_number, input)
Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()
```
`Context.new/3` takes three strings:
- `uid` - unique to *this specific dial-in*. Usually your gateway's session id.
- `gid` - shared across a caller's redials. Usually their phone number.
- `input` - what they typed this request. An empty string on the very first request.
`Ussd.run/1` looks up (or starts) the session behind that context, resolves exactly one
screen, and returns whatever `Ussd.use_response/2`'s formatter produces - a string, a map,
whatever your gateway expects.
## Context
`Ussd.Context` is the read-only value every `render/1`, `c:Ussd.Action.execute/1`, and
`transition`/`back`/`paginate` callback receives.
```elixir
Ussd.Context.uid(context) #=> "session-abc123"
Ussd.Context.gid(context) #=> "233200000000"
Ussd.Context.input(context) #=> "1"
```
Attach extra values your states need - the caller's network, a request header, anything
your gateway sent alongside the standard fields - with `with_bag/2`, and read them back
with `get/2`:
```elixir
context =
Context.new(session_id, phone, input)
|> Context.with_bag(%{network: params["network"]})
Ussd.Context.get(context, :network) #=> "MTN"
```
`with_bag/2` *replaces* the bag rather than merging into it, so set everything you need in
one call before passing the context to `Ussd.build/1`.
## Record
`Ussd.Record` is per-session storage - the thing that lets a flow remember what the caller
already told it across requests. It's automatically namespaced by the context's `uid`,
backed by a pluggable `Ussd.Cache` (see [Cache Backends](#cache-backends)):
```elixir
def render(context) do
record = Ussd.Record.new(Ussd.Context.uid(context), Ussd.Context.gid(context))
Ussd.Record.set(record, "selected_plan", "unlimited")
Ussd.Record.get(record, "selected_plan") #=> "unlimited"
Ussd.Record.get(record, "missing_key", "none") #=> "none"
end
```
Every read/write function accepts options:
- `:ttl` - seconds until the value expires on its own (default: never)
- `:public` - store under `gid` instead of `uid` (default: `false`), so the value is
visible to a *different* `uid` sharing the same `gid` - this is exactly how "continuing"
sessions (see [Resuming Sessions](#resuming-sessions)) hand a live session from an
expired `uid` to the redial's new one
The full API: `get/4`, `set/4`, `get_many/4`, `set_many/3`, `has?/3`, `increment/4`,
`decrement/4`, `forget/3`, `forget_many/3`, plus `locale/1`/`set_locale/2` and
`set_encrypted/4`/`get_encrypted/4` (see [Encrypted Records](#encrypted-records)).
```elixir
Ussd.Record.increment(record, "otp_attempts") #=> 1
Ussd.Record.increment(record, "otp_attempts") #=> 2
Ussd.Record.set_many(record, %{"a" => 1, "b" => 2})
Ussd.Record.get_many(record, ["a", "b", "c"]) #=> [1, 2, nil]
```
## Menu
`Ussd.Menu` is the fluent builder every `render/1` returns. Build one with `build/0` and
grow it with the pipe operator:
```elixir
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome to Speso Bank")
|> Ussd.Menu.line("Select an option")
|> Ussd.Menu.listing(["Check Balance", "Transfer", "Buy Airtime"])
|> Ussd.Menu.line_break()
|> Ussd.Menu.text("Powered by Speso")
end
```
| Function | What it does |
|---|---|
| `text/2` | Appends raw text, no newline |
| `line/2` | Appends text followed by a newline |
| `line_break/2` | Appends `n` blank lines (default 1) |
| `listing/3` | Appends a numbered list; see below |
| `format/3` | Appends `:io_lib.format/2`-formatted text, e.g. `format(menu, "Balance: ~s", ["GHS 10"])` |
| `append/2` / `prepend/2` | Splices in another menu, or the menu built by a `(menu -> menu)` function |
| `trans/4` / `trans_line/4` | Appends a Gettext-translated string; see [Localized Menus](#localized-menus) |
`listing/3` takes options:
```elixir
Ussd.Menu.build()
|> Ussd.Menu.listing(
["Airtime", "Data Bundle"],
numbering: &(&1 + 1), # index -> label, default 1-based
spacer: ". ", # between number and item, default "."
divider: "\n", # between items, default "\n"
page: 1, # slice the list before rendering
per_page: 5
)
```
A menu implements `String.Chars`, so `to_string(menu)` (or plain interpolation) always
gives you the rendered text - useful when a `truncate/2` decision needs to measure it.
## Decisions
A decision is a struct plus a `Ussd.Decision.decide/2` implementation - something that
looks at the caller's raw input string and says yes or no. They're used everywhere routing
happens: `transition/2`, `back/2`, `paginate/2`, `truncate/2`, and `c:Ussd.ContinueState.confirm/0`.
| Module | Matches when input... |
|---|---|
| `Ussd.Decisions.Equal.new(x)` | equals `x` (numeric-aware: `"7"` matches `7`) |
| `Ussd.Decisions.NotEqual.new(x)` | doesn't equal `x` |
| `Ussd.Decisions.Between.new(low, high)` | is in `[low, high]` inclusive, numerically |
| `Ussd.Decisions.NotBetween.new(low, high)` | is outside `[low, high]` |
| `Ussd.Decisions.GreaterThan.new(x)` | is greater than `x` |
| `Ussd.Decisions.GreaterThanOrEqualTo.new(x)` | is greater than or equal to `x` |
| `Ussd.Decisions.LessThan.new(x)` | is less than `x` |
| `Ussd.Decisions.LessThanOrEqualTo.new(x)` | is less than or equal to `x` |
| `Ussd.Decisions.In.new([a, b, ...])` | equals any of the list |
| `Ussd.Decisions.NotIn.new([a, b, ...])` | equals none of the list |
| `Ussd.Decisions.IsNumeric.new()` | parses fully as an integer or float |
| `Ussd.Decisions.Length.new(n)` | is exactly `n` characters |
| `Ussd.Decisions.Regex.new(~r/.../)` | matches the given `Regex` |
| `Ussd.Decisions.Fallback.new()` | always matches - use as a catch-all last `transition/2` |
`Equal`, `Between`, and the other comparison decisions coerce both sides to numbers before
comparing when both sides *look* numeric, and fall back to string comparison otherwise -
`Between.new(1, 10)` correctly matches `"9"` and `"10"`, not just `"1"` through `"9"` the
way naive string comparison would.
Custom decisions are just a struct and an implementation - no macro required:
```elixir
defmodule MyApp.Decisions.EvenLength do
defstruct []
def new, do: %__MODULE__{}
defimpl Ussd.Decision do
def decide(_decision, actual), do: rem(String.length(actual), 2) == 0
end
end
```
## States & Transitions
A `Ussd.State` is one screen. `use Ussd.State` gives you five macros to declare how it
routes, and requires you to implement `render/1`:
```elixir
defmodule MyApp.Ussd.States.Welcome do
use Ussd.State, initial: true
alias Ussd.Decisions.{Equal, Fallback}
transition Equal.new("1"), to: MyApp.Ussd.States.Airtime
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle
transition Fallback.new(), to: __MODULE__
@impl Ussd.State
def render(_context) do
Ussd.Menu.build()
|> Ussd.Menu.line("Welcome")
|> Ussd.Menu.listing(["Airtime", "Data Bundle"])
end
end
```
`transition/2` is repeatable - the *first* one whose decision matches wins, so put your
catch-all `Fallback` last. Each also accepts `callback:` (a 0- or 1-arity function run when
it matches, before moving on):
```elixir
transition Equal.new("2"), to: MyApp.Ussd.States.DataBundle, callback: fn context ->
Analytics.track(Ussd.Context.uid(context), "selected_data_bundle")
end
```
`initial: true` is advisory metadata - `mix ussd.lint` uses it to auto-discover flow entry
points when you don't pass one explicitly. It is *not* enforced by
`Ussd.use_initial_state/2`; any `Ussd.State` or `Ussd.Action` works there.
A state reached with input that matches nothing raises
`Ussd.Exceptions.NextStateNotFoundError` - run `mix ussd.lint` to find these before your
users do (see [Linting a Flow](#linting-a-flow)).
## Pagination & Truncation
**Pagination** pages a *structured* list (like `listing/3`) across screens. Mix in
`Ussd.Pagination` and declare `paginate/1`:
```elixir
defmodule MyApp.Ussd.States.Products do
use Ussd.State
use Ussd.Pagination, per_page: 5
alias Ussd.Decisions.{Equal, Fallback}
paginate next: Equal.new("#"), previous: Equal.new("0")
transition Fallback.new(), to: MyApp.Ussd.States.ProductDetail
@impl Ussd.Pagination
def get_items(_context), do: Catalog.list_products()
@impl Ussd.State
def render(context) do
Ussd.Menu.build()
|> Ussd.Menu.listing(get_items(context), page: current_page(context), per_page: 5)
|> Ussd.Menu.line(if has_next_page?(context), do: "#. More")
end
end
```
`use Ussd.Pagination` injects `current_page/1`, `last_page/1`, `first_page?/1`,
`last_page?/1`, `has_next_page?/1`, and `has_previous_page?/1`, all backed by a counter in
`Ussd.Record`. You must implement `get_items/1`; `per_page/1` is either given via the
`per_page:` option or implemented yourself (e.g. if it varies per caller).
Pressing `next`/`previous` past the first/last page falls through to your other
`transition/2`s rather than looping - so a `Fallback` transition after `paginate/1` acts as
"anything else, including an exhausted next/previous, moves on".
**Truncation** caps how much of a *single long string* comes back per screen (some gateways
have a hard character limit per response):
```elixir
truncate limit: 160, ending: "\n0. More", more: Equal.new("0")
```
The rendered content is wrapped and shown a chunk at a time; pressing the `more:` decision's
key advances to the next chunk of the *same* state. Once exhausted, further input falls
through to `transition`/`back` normally, same as pagination.
## Actions & Conditional Branching
Sometimes the next state can't be a static `transition/2` - it depends on an HTTP call, a
database lookup, or anything else that has to run before you know where to go. A
`Ussd.Action` is a one-shot decision point with no screen shown to the caller:
```elixir
defmodule MyApp.Ussd.Actions.LoadAccount do
@behaviour Ussd.Action
@impl Ussd.Action
def execute(context) do
case Accounts.find(Ussd.Context.get(context, :phone)) do
{:ok, _account} -> MyApp.Ussd.States.Dashboard
:error -> MyApp.Ussd.States.Registration
end
end
end
```
Point a `transition/2` (or `Ussd.use_initial_state/2`) at an action just like a state - the
engine keeps calling `execute/1` while the result is itself an action, so actions can chain,
and stops once it reaches a real `Ussd.State` to render.
## Back Navigation
`back/2` returns to whatever state most recently transitioned *into* the current one, via an
automatic per-session stack - no manual bookkeeping:
```elixir
defmodule MyApp.Ussd.States.Airtime do
use Ussd.State
alias Ussd.Decisions.{Equal, Fallback}
back Equal.new("0")
transition Fallback.new(), to: MyApp.Ussd.States.Confirm
@impl Ussd.State
def render(_context), do: Ussd.Menu.build() |> Ussd.Menu.line("Enter amount") |> Ussd.Menu.line("0. Back")
end
```
Every `transition/2` that moves to a *different* state pushes the state being left onto the
stack; `back/2` pops it. If the stack is empty (nothing to go back to), `back/2` simply
doesn't match, and resolution falls through to `transition/2` as normal.
## Resuming Sessions
By default (`:start`, the implicit default), every dial is a brand-new session. Two other
modes let a redial pick back up after a dropped call, configured via
`Ussd.use_continuing_state/4`:
```elixir
Ussd.build(context)
|> Ussd.use_continuing_state(:continue, 300) # resume silently, session valid 300s
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()
```
- **`:continue`** - a redial under a new session id (but the same `gid`/phone number)
silently resumes exactly where the previous session left off, as long as it's within the
given TTL.
- **`:confirm`** - instead of resuming silently, the redial is routed to a `continuing_state`
- a module declared with `use Ussd.State, continue: true` and a `confirm/0` callback -
which asks the caller whether they want to resume:
```elixir
defmodule MyApp.Ussd.States.ConfirmResume do
use Ussd.State, continue: true
@impl Ussd.ContinueState
def confirm, do: Ussd.Decisions.Equal.new("1")
@impl Ussd.State
def render(_context) do
Ussd.Menu.build() |> Ussd.Menu.line("Resume your previous session?") |> Ussd.Menu.line("1. Yes")
end
end
Ussd.build(context)
|> Ussd.use_continuing_state(:confirm, 300, MyApp.Ussd.States.ConfirmResume)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()
```
A reply matching `confirm/0`'s decision resumes the previous session; anything else starts
a brand-new one at `initial_state`.
Both modes rely on `Ussd.Record`'s `public: true` scoping (see [Record](#record)) to hand
the live session from the expired `uid` to the redial's new one under the shared `gid`.
## Configurators
Group repeated `Ussd.use_*/2` setup so it isn't copy-pasted across every entry point:
```elixir
defmodule MyApp.UssdConfigurator do
@behaviour Ussd.Configurator
@impl Ussd.Configurator
def configure(ussd) do
ussd
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.use_exception_handler(&MyApp.UssdErrors.handle/1)
end
end
Ussd.build(context)
|> Ussd.use_configurator(MyApp.UssdConfigurator)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.run()
```
## Localized Menus
`Menu.trans/4` and `trans_line/4` translate via a configured Gettext backend:
```elixir
# mix.exs
{:gettext, "~> 0.20"}
# config/config.exs
config :ussd, :gettext_backend, MyApp.Gettext
```
```elixir
Ussd.Menu.build() |> Ussd.Menu.trans_line("greeting", %{name: caller_name})
```
The engine also persists a session's chosen locale (via `Ussd.Record.set_locale/2`) and
applies it automatically on every subsequent request in that session, so a caller who picks
French once stays in French for the rest of the conversation.
## Encrypted Records
Store sensitive values (PINs, account numbers) encrypted at rest instead of in plain text:
```elixir
# config/config.exs
config :ussd, :encryption_key, System.fetch_env!("USSD_ENCRYPTION_KEY")
```
```elixir
Ussd.Record.set_encrypted(record, "pin", "1234")
Ussd.Record.get_encrypted(record, "pin") #=> "1234"
Ussd.Record.get(record, "pin") #=> the ciphertext, not "1234"
```
Encryption is AES-256-GCM; the configured key is hashed down to 256 bits, so it can be any
length/format convenient for you to manage as a secret.
## Exception Handling
An unhandled exception during `Ussd.run/1` doesn't crash the request - it's caught and
turned into a message via whatever's configured with `Ussd.use_exception_handler/2`:
```elixir
defmodule MyApp.UssdErrors do
@behaviour Ussd.ExceptionHandler
@impl Ussd.ExceptionHandler
def handle(_exception), do: "Sorry, something went wrong. Please try again."
end
Ussd.build(context)
|> Ussd.use_exception_handler(MyApp.UssdErrors)
# or inline: |> Ussd.use_exception_handler(fn exception -> Exception.message(exception) end)
|> Ussd.run()
```
The session always ends (`terminating?: true`) after an exception - there's no live state
left to safely resume from.
## Gateway Responses
`Ussd.use_response/2` shapes the resolved `{message, terminating?}` into whatever your
gateway's HTTP contract expects. Six formatters ship out of the box:
| Module | Shape |
|---|---|
| `Ussd.Responses.AfricasTalking` | `"CON message"` / `"END message"` |
| `Ussd.Responses.Speso` | `%{"message" => ..., "action" => "prompt" \| "end"}` |
| `Ussd.Responses.Nalo` | `%{"USERID" => uid, "MSISDN" => gid, "USERDATA" => input, "MSG" => ..., "MSGTYPE" => bool}` |
| `Ussd.Responses.Nsano` | `%{"USSDResp" => %{"action" => ..., "menus" => "", "title" => ...}}` |
| `Ussd.Responses.Moolre` | `%{"message" => ..., "reply" => bool}` |
| `Ussd.Responses.Arkesel` | `%{"sessionID" => ..., "userID" => ..., "msisdn" => ..., "message" => ..., "continueSession" => bool}` |
```elixir
Ussd.build(context)
|> Ussd.use_initial_state(MyApp.Ussd.States.Welcome)
|> Ussd.use_response(&Ussd.Responses.AfricasTalking.respond/3)
|> Ussd.run()
```
Or write your own - a module implementing `Ussd.Response`, or just an inline 3-arity
function `(context, message, terminating? -> term)`:
```elixir
mix ussd.gen.response MyGateway
```
## Visualizing a Flow
```
mix ussd.graph MyApp.Ussd.States.Welcome
mix ussd.graph MyApp.Ussd.States.Welcome --output flow.mmd
```
Walks every `transition/2` from the given state and renders a Mermaid `stateDiagram-v2`.
States only reachable via `back/2` collapse into a single "Previous state" node, matching
how the back-stack actually behaves at runtime; states resolved dynamically (via
`Ussd.Action`) are shown but not expanded further.
## Simulating a Session
```
mix ussd.simulate MyApp.Ussd.States.Welcome
mix ussd.simulate MyApp.Ussd.States.Welcome --phone 233200000000 --response Ussd.Responses.AfricasTalking
```
Walks a flow interactively in the terminal like a real handset - no gateway or phone
required. Useful flags: `--with KEY=VALUE` (repeatable, extra context bag values),
`--configurator MODULE` (repeatable), `--continuing-mode start|continue|confirm`,
`--continuing-state MODULE`, `--continuing-ttl SECONDS`, `--store MODULE`. Type `:restart`
to drop the call without ending the session, `:exit` to hang up.
## Linting a Flow
```
mix ussd.lint
mix ussd.lint MyApp.Ussd.States.Welcome
mix ussd.lint --strict
```
Walks the flow from its `initial: true` states (or the ones you pass explicitly) and
reports:
- **Errors** - a `transition/2` target that doesn't exist or isn't a `Ussd.State`/
`Ussd.Action`; a state with no `transition`, `back`, or `terminate` at all (it would
always raise `NextStateNotFoundError` if reached).
- **Warnings** - a state only reachable via `back/2` with no `transition`/`terminate` of its
own; a duplicate `transition/2` match (the second one can never trigger); a state that
exists but is never reached from any root.
`--strict` makes warnings fail the command too, so you can wire it into CI.
## Generators
```
mix ussd.gen.state Welcome
mix ussd.gen.state Welcome --initial
mix ussd.gen.state Confirm --continue
mix ussd.gen.action LoadAccount
mix ussd.gen.response MyGateway
mix ussd.gen.decision EvenLength
mix ussd.gen.configurator Default
mix ussd.gen.exception_handler Default
```
Each scaffolds a starting-point module under `lib/<namespace>/<kind>/<name>.ex`, where
`<namespace>` defaults to `<YourApp>.Ussd` (override with `config :ussd, :namespace`).
## Testing
`Ussd.Test` is a fluent ExUnit helper for walking a flow screen by screen:
```elixir
import Ussd.Test
test "buying airtime" do
build(MyApp.Ussd.States.Welcome)
|> start()
|> assert_see("Welcome")
|> input("1")
|> assert_see("Enter amount")
|> input("5")
|> assert_terminated()
end
```
Available assertions: `assert_see/2`, `assert_terminated/1`, `assert_not_terminated/1`,
`assert_context_has/3`, `assert_context_missing/2`, `assert_record_has/3`,
`assert_record_missing/2` (the `/3` versions take an optional expected value or a 1-arity
predicate function). `acting_as/2` switches which simulated caller is dialing in - each
name gets its own session id and its own remembered screen, so you can test multi-party
scenarios and switch back and forth without losing either side's state. `timeout/3`
simulates a redial after a real sleep, for testing `:continue`/`:confirm` TTL expiry.
## Session Events
Two `:telemetry` events fire during `Ussd.run/1`, for logging or analytics without
touching core modules:
```elixir
:telemetry.attach("log-ussd-states", [:ussd, :state, :entered], fn _event, _measurements, %{state: state, context: context}, _config ->
Logger.info("#{Ussd.Context.uid(context)} entered #{inspect(state)}")
end, nil)
```
- `[:ussd, :state, :entered]` - metadata `%{state: module, context: Ussd.Context.t()}`,
fired every time a screen is about to render.
- `[:ussd, :session, :terminated]` - metadata `%{context: Ussd.Context.t()}`, fired when a
response comes back with `terminating?: true`.
## Cache Backends
`Ussd.Record` reads/writes through a pluggable `Ussd.Cache` behaviour. The default,
`Ussd.Cache.ETS`, is process-local (a supervised GenServer + ETS table) - fine for a single
node, with real TTL expiry via both lazy reads and a periodic sweep. Anything running on
more than one node needs a shared backend (Redis, `:cachex` with a distributed adapter,
Mnesia, ...) - implement the six `Ussd.Cache` callbacks and point at it:
```elixir
# config/config.exs
config :ussd, :cache, MyApp.RedisCache
# or per-request:
Ussd.build(context) |> Ussd.use_store(MyApp.RedisCache) |> ...
```
## Configuration Reference
```elixir
config :ussd,
namespace: "MyApp.Ussd", # mix ussd.gen.* / mix ussd.lint auto-discovery
cache: Ussd.Cache.ETS, # or your own Ussd.Cache implementation
cache_sweep_interval: :timer.minutes(1), # Ussd.Cache.ETS's expired-entry sweep
session_ttl: 300, # seconds a mid-flow session survives with no input
encryption_key: System.fetch_env!("USSD_ENCRYPTION_KEY"), # required for set_encrypted/get_encrypted
gettext_backend: MyApp.Gettext, # required for Menu.trans/trans_line
arkesel_user_id: System.get_env("ARKESEL_USER_ID") # used by Ussd.Responses.Arkesel
```