ExGram.Router

CIHex.pmHexDocsHex Downloads

A declarative routing DSL for ExGram bots.

ExGram.Router replaces hand-written handle/2 pattern-match clauses with a composable scope/filter/handle DSL where everything is a filter — built-in filters cover the common update types (commands, text, callback queries, inline queries, locations, media messages, and more) and you can write custom filters to encode any runtime predicate: conversation state, user roles, feature flags, and more.


Table of Contents


Installation

Add ex_gram_router to your dependencies:

# mix.exs
def deps do
  [
    {:ex_gram, "~> 0.60"},
    {:ex_gram_router, "~> 0.1.0"}
  ]
end

Quick Start

defmodule MyApp.Bot do
  use ExGram.Bot, name: :my_bot, setup_commands: true
  use ExGram.Router

  command("start", description: "Start the bot")
  command("help",  description: "Show help")

  scope do
    filter :command, :start
    handle &MyApp.Handlers.start/1
  end

  scope do
    filter :command, :help
    handle &MyApp.Handlers.help/1
  end

  scope do
    filter :text
    handle &MyApp.Handlers.echo/1
  end

  # Catch-all fallback — always include one
  scope do
    handle &MyApp.Handlers.fallback/1
  end
end
defmodule MyApp.Handlers do
  import ExGram.Dsl

  def start(context), do: answer(context, "Welcome!")
  def help(context),  do: answer(context, "Here is what I can do...")
  def echo(context),  do: answer(context, "You said something!")
  def fallback(context), do: context
end

DSL Reference

scope

A scope is a block that groups filters and a handler (or nested scopes). Scopes are evaluated top-to-bottom; the first matching scope wins.

scope do
  # zero or more filters (AND logic)
  filter :command, :start

  # either a handle (leaf) …
  handle &MyApp.Handlers.start/1
end

scope do
  # … or nested scopes (branch)
  filter :state, :registration

  scope do
    filter :text
    handle &MyApp.Handlers.got_text/1
  end
end

Rules:


filter

Declares a filter for the current scope.

# Built-in alias, no opts (match any update of that type)
filter :command
filter :text
filter :callback_query

# Built-in alias with opts
filter :command, :start            # specific command
filter :text, "keyword"            # exact text match
filter :text, ~r/^\d+$/            # text matching regex
filter :text, contains: "hello"    # text containing substring
filter :text, prefix: "!"          # text starting with prefix
filter :callback_query, "action_a" # exact callback data
filter :callback_query, ~r/^page_/ # callback data matching regex
filter :callback_query, prefix: "settings:"  # callback data with prefix (useful for parent scopes)

# Module directly (no alias needed)
filter MyApp.Filters.AdminOnly

# Module with opts
filter MyApp.Filters.State, :registration

handle

Declares the handler for a leaf scope. Accepts a captured function reference (&Module.function/arity).

# 1-arity: receives (context)
handle &MyApp.Handlers.start/1

# 2-arity: receives (update_info, context)
handle &MyApp.Handlers.echo/2

See Handler Arities for details.


alias_filter

Registers a shorthand name for a filter module, making it available via the :atom form in filter declarations.

alias_filter MyApp.Filters.State, as: :state
alias_filter MyApp.Filters.AdminOnly, as: :admin

# Now usable as:
filter :state, :registration
filter :admin

alias_filter must appear at the top of the module, before any scope blocks.


Built-in Filters

The following filter aliases are available in every bot using ExGram.Router without any alias_filter declaration:

Alias Matches Options
:command{:command, name, msg}nil (any), atom/string (specific command name)
:text{:text, text, msg}nil (any), string (exact match), %Regex{} (regex), prefix:, suffix:, contains:
:callback_query{:callback_query, cq}nil (any), string (exact data), %Regex{} (regex), prefix:, suffix:, contains:; add propagate: true to a prefix: match to enrich child scopes (see Enrich Filters)
:inline_query{:inline_query, iq}nil (any), string (exact query), %Regex{} (regex), prefix:, suffix:, contains:
:regex{:regex, name, msg}nil (any), atom (specific named regex)
:message{:message, msg}nil only (matches any message-type update)
:location{:location, loc}nil only (matches any location update)
:animation{:animation, anim}nil only
:audio{:audio, audio}nil only
:contact{:contact, contact}nil only
:document{:document, doc}nil only
:photo{:photo, photos}nil only
:poll{:poll, poll}nil only
:sticker{:sticker, sticker}nil only
:video{:video, video}nil only
:video_note{:video_note, vnote}nil only
:voice{:voice, voice}nil only

Examples

# Any command
filter :command

# Specific command
filter :command, :start
filter :command, :help

# Any text message
filter :text

# Exact text match
filter :text, "hello"

# Text matching a pattern
filter :text, ~r/\A\d{4}\z/

# Text keyword matchers
filter :text, prefix: "!"
filter :text, suffix: "?"
filter :text, contains: "hello"

# Any callback query
filter :callback_query

# Callback query with exact data
filter :callback_query, "confirm"
filter :callback_query, "cancel"

# Callback query matching a regex
filter :callback_query, ~r/^page_\d+$/

# Callback query keyword matchers — useful for hierarchical callback data
filter :callback_query, prefix: "settings:"
filter :callback_query, suffix: ":confirm"
filter :callback_query, contains: "item"

# Callback query prefix with propagation — child scopes match against the remainder
# "proj:change", "proj:delete", etc. — see Enrich Filters for details
filter :callback_query, prefix: "proj:", propagate: true

# Any inline query
filter :inline_query

# Inline query keyword matchers
filter :inline_query, prefix: "@"

# Any location update
filter :location

# Any message-type update (photos, documents, stickers, etc.)
filter :message

# Media message types
filter :animation
filter :audio
filter :contact
filter :document
filter :photo
filter :poll
filter :sticker
filter :video
filter :video_note
filter :voice

use ExGram.Router Options

use ExGram.Router accepts two optional keyword arguments to customise the alias set available in the module.

aliases

Adds extra filter aliases on top of the builtins. Each key must not conflict with an existing builtin alias name.

use ExGram.Router,
  aliases: [
    state: MyApp.Filters.State,
    role: MyApp.Filters.Role
  ]

After this, filter :state, :registration and filter :role, :admin work without a separate alias_filter call.

exclude_aliases

Removes aliases from the merged set (builtins plus any user-provided aliases). Useful when you want to prevent a builtin from being referenced accidentally, or to keep the alias list minimal.

use ExGram.Router,
  exclude_aliases: [:poll, :video_note, :animation]

Combined example

use ExGram.Router,
  aliases: [state: MyApp.Filters.State],
  exclude_aliases: [:poll, :video_note]

Enrich Filters

Filters can optionally implement the scope_extra/2 callback to enrich context.extra for child scopes after they pass. This is how a parent scope can pass derived data down to its children without the children having to re-derive it.

@callback scope_extra(context :: ExGram.Cnt.t(), opts :: term()) :: map()

scope_extra/2 is called by the dispatcher right after call/3 returns true. The map it returns is merged into context.extra via Map.merge/2 before the dispatcher recurses into child scopes. Sibling scopes always receive the original, un-enriched context — isolation is automatic thanks to Elixir's immutable data.

The callback is @optional_callbacks — existing filters that do not implement it are completely unaffected.

Implementing scope_extra/2

defmodule MyApp.Filters.Project do
  @behaviour ExGram.Router.Filter

  @impl ExGram.Router.Filter
  def call(_update_info, context, project_id) do
    Map.get(context.extra, :project_id) == project_id
  end

  # Called after call/3 returns true — child scopes get context.extra.project
  @impl ExGram.Router.Filter
  def scope_extra(_context, project_id) do
    %{project: MyApp.Projects.get!(project_id)}
  end
end

Child scopes then have context.extra.project available without any extra lookup:

scope do
  filter MyApp.Filters.Project, 42

  scope do
    filter :text
    # context.extra.project is already loaded here
    handle &MyHandlers.handle_text/1
  end
end

Built-in: :callback_query with propagate: true

The built-in :callback_query filter uses scope_extra/2 to implement prefix propagation. When a prefix: match includes propagate: true, the matched prefix is stored in context.extra so that child scopes can match against the remainder of the callback data without repeating it:

scope do
  filter :callback_query, prefix: "proj:", propagate: true

  scope do
    filter :callback_query, "change"   # matches "proj:change"
    handle &MyHandlers.change_project/1
  end

  scope do
    filter :callback_query, "delete"   # matches "proj:delete"
    handle &MyHandlers.delete_project/1
  end
end

Propagation stacks: a child scope can itself propagate, accumulating prefixes across nesting levels:

scope do
  filter :callback_query, prefix: "proj:", propagate: true

  scope do
    filter :callback_query, prefix: "settings:", propagate: true

    scope do
      filter :callback_query, "volume"   # matches "proj:settings:volume"
      handle &MyHandlers.volume/1
    end
  end
end

The mix ex_gram.router.tree task marks propagating filters with a [propagate] indicator; mix ex_gram.router.flat includes them in each leaf's full filter chain.


Handler Arities

Handlers can be either 1-arity or 2-arity. The router detects the arity of the captured function at compile time and dispatches accordingly.

1-arity &Mod.fun/1

Receives only the ExGram.Cnt.t() context. Use when you do not need to inspect the parsed update tuple.

def start(context) do
  answer(context, "Welcome!")
end

2-arity &Mod.fun/2

Receives (update_info, context) where update_info is the full parsed update tuple that ExGram dispatches (e.g. {:command, :echo, msg}). Use when you need to extract data from the update directly.

# /echo hello world  →  msg.text == "hello world"
def echo({:command, _name, %{text: text}}, context) do
  answer(context, text)
end

Mix Tasks

ExGram.Router ships with two Mix tasks for inspecting any bot module's routing configuration. Both tasks compile the project first and call __exgram_routing_tree__/0, which is generated at compile time.

mix ex_gram.router.tree

Prints the routing tree in an indented, hierarchical format. Useful for understanding the full scope structure and nesting at a glance.

mix ex_gram.router.tree MyApp.Bot

Example output:

MyApp.Bot routing tree:
├── scope
│   ├── filters: [Command(:start)]
│   └── handle: &MyApp.Handlers.start/1
├── scope
│   ├── filters: [Command(:help)]
│   └── handle: &MyApp.Handlers.help/1
└── scope
    ├── filters: [CallbackQuery([prefix: "proj:"]) [propagate]]
    ├── scope
    │   ├── filters: [CallbackQuery("change")]
    │   └── handle: &MyApp.Handlers.change_project/1
    └── scope
        ├── filters: [CallbackQuery("delete")]
        └── handle: &MyApp.Handlers.delete_project/1

mix ex_gram.router.flat

Prints a flat, one-line-per-handler listing. Every entry is a leaf with its full accumulated filter chain - parent scope filters are prepended so you can see the complete set of conditions that must pass for each handler to run. Similar to phx.routes in Phoenix.

mix ex_gram.router.flat MyApp.Bot

Example output:

MyApp.Bot handlers:
MyApp.Handlers  start/1          filters: [Command(:start)]
MyApp.Handlers  help/1           filters: [Command(:help)]
MyApp.Handlers  change_project/1 filters: [CallbackQuery([prefix: "proj:"]) [propagate], CallbackQuery("change")]
MyApp.Handlers  delete_project/1 filters: [CallbackQuery([prefix: "proj:"]) [propagate], CallbackQuery("delete")]
MyApp.Handlers  fallback/1       filters: []

Custom Filters

Implement the ExGram.Router.Filter behaviour:

@callback call(update_info :: tuple(), context :: ExGram.Cnt.t(), opts :: term()) :: boolean()

call/3 returns true to pass (scope matches) or false to fail (skip scope). Optionally, implement scope_extra/2 to enrich context.extra for child scopes — see Enrich Filters.

Filters must be pure. They are called on every matching update, potentially many times as the router walks the scope tree, and their result must depend only on the data already present in update_info and context. Never perform database queries, HTTP calls, or other side effects inside a filter — doing so couples routing decisions to I/O latency and makes the router unpredictable under load. If a filter needs external data (user role, feature flag, account status), load it once in a middleware before the router runs and store the result in context.extra. The filter then reads the pre-loaded value cheaply.

Example: Role-based filter

Filters should not perform I/O (database or HTTP calls). Instead, load the user role in a middleware and store it in context.extra. The filter then reads the pre-loaded value:

defmodule MyApp.Middleware.LoadRole do
  use ExGram.Middleware

  def call(context, _opts) do
    case ExGram.Dsl.extract_user(context) do
      {:ok, user} ->
        role = MyApp.Accounts.get_role(user.id)
        add_extra(context, %{role: role})

      :error ->
        context
    end
  end
end
defmodule MyApp.Filters.Role do
  @behaviour ExGram.Router.Filter

  @impl ExGram.Router.Filter
  def call(_update_info, context, required_role) do
    Map.get(context.extra, :role) == required_role
  end
end

Register the middleware and the alias, then use the filter:

defmodule MyApp.Bot do
  use ExGram.Bot, name: :my_bot
  use ExGram.Router

  middleware MyApp.Middleware.LoadRole
  alias_filter MyApp.Filters.Role, as: :role

  scope do
    filter :role, :admin
    handle &MyApp.AdminHandlers.panel/1
  end
end

Example: Conversation state filter

A common pattern is to store conversation state in context.extra via a middleware and then route based on it:

defmodule MyApp.Filters.State do
  @behaviour ExGram.Router.Filter

  # Match top-level state: filter :state, :registration
  @impl ExGram.Router.Filter
  def call(_update_info, context, expected_state) when is_atom(expected_state) do
    Map.get(context.extra, :state) == expected_state
  end

  # Match a nested key: filter :state, {:sub_state, :get_name}
  def call(_update_info, context, {key, expected_value}) do
    Map.get(context.extra, key) == expected_value
  end
end

Nested Scopes and State Machines

Scopes can be arbitrarily nested. A nested scope only runs if its parent's filters have already passed, so parent filters act as guards for all children.

This makes it natural to model multi-step conversation flows. The recommended way to manage conversation state is ExGram.FSM, a companion library that provides finite state machine flows with pluggable storage backends. When both libraries are used together, ExGram.FSM automatically registers :fsm_flow and :fsm_state filter aliases.

Setup

Add ex_gram_fsm alongside ex_gram_router in mix.exs:

def deps do
  [
    {:ex_gram, "~> 0.60"},
    {:ex_gram_router, "~> 0.1.0"},
    {:ex_gram_fsm, "~> 0.1.0"},
    {:jason, ">= 1.0.0"},
    {:req, "~> 0.5"}
  ]
end

Define a flow

Each conversation flow is a separate module:

defmodule MyApp.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

Wire it into the bot

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

defmodule MyApp.Bot do
  use ExGram.Bot, name: :my_bot, setup_commands: true
  use ExGram.Router
  use ExGram.FSM,
    storage: ExGram.FSM.Storage.ETS,
    flows: [MyApp.RegistrationFlow]

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

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

  # Route by flow, then by step within the flow
  scope do
    filter :fsm_flow, :registration

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

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

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

Handlers

defmodule MyApp.Handlers do
  import ExGram.Dsl

  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("Done! Welcome, #{name} (#{email}).")
  end

  def fallback(context), do: context
end

Dispatch rules:

  1. Top-level scopes are tried in declaration order.
  2. When a branch scope's filters all pass, its children are tried in order.
  3. The first leaf scope whose filters all pass wins.
  4. If no scope matches, the update is silently dropped (add a no-filter fallback to handle this).

Testing

ExGram.Router requires no special testing setup. Since the router generates a standard handle/2 function, your bot works exactly like any other ExGram bot in tests — use ExGram.Adapter.Test, push updates, and assert on outgoing API calls as usual.

See the ExGram testing documentation for full setup instructions.


License

Beerware — do whatever you want with it; if we meet someday and you think it was worth it, buy me a beer.

Links