As your bot grows, the handle/2 function can become a long chain of pattern-match clauses — commands, callback queries, text handlers, inline queries, all interleaved in a single module. When you add conversation flows or role-based access, the clause count explodes and it becomes hard to see the overall structure at a glance.
ExGram.Router replaces those hand-written clauses with a declarative scope / filter / handle DSL. You describe what each handler should match, and the router takes care of dispatching. Everything compiles down to a standard handle/2 function, so the rest of ExGram (middlewares, DSL, testing) works exactly the same.
This guide covers the most common usage. For the full API reference and advanced options, see the ExGram.Router HexDocs.
Add ex_gram_router to your dependencies:
# mix.exs
def deps do
[
{:ex_gram, "~> 0.65"},
{:ex_gram_router, "~> 0.1.0"},
{:jason, ">= 1.0.0"},
{:req, "~> 0.5"}
]
endThen add :ex_gram_router to the formatter deps alongside :ex_gram:
# .formatter.exs
[
import_deps: [:ex_gram, :ex_gram_router]
]Here's a typical bot using plain handle/2:
defmodule MyBot do
use ExGram.Bot, name: :my_bot, setup_commands: true
command("start", description: "Start the bot")
command("help", description: "Show help")
def handle({:command, :start, _}, ctx), do: answer(ctx, "Welcome!")
def handle({:command, :help, _}, ctx), do: answer(ctx, "Here is what I can do…")
def handle({:text, _, _}, ctx), do: answer(ctx, "You said something!")
def handle(_, ctx), do: ctx
endThe same bot with ExGram.Router:
defmodule MyBot 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 &MyBot.Handlers.start/1
end
scope do
filter :command, :help
handle &MyBot.Handlers.help/1
end
scope do
filter :text
handle &MyBot.Handlers.echo/2
end
# Catch-all fallback
scope do
handle &MyBot.Handlers.fallback/1
end
enddefmodule MyBot.Handlers do
import ExGram.Dsl
def start(ctx), do: answer(ctx, "Welcome!")
def help(ctx), do: answer(ctx, "Here is what I can do…")
# 2-arity: receives (update_info, context) to extract the message text directly
def echo({:text, text, _msg}, ctx), do: answer(ctx, text)
def fallback(ctx), do: ctx
endNotice that handlers live in their own module. This is optional — you can keep them inline — but separating handlers from routing keeps both sides clean as the bot grows.
Handlers can be 1-arity or 2-arity:
# 1-arity: receives only context
def start(context) do
answer(context, "Welcome!")
end
# 2-arity: receives (update_info, context)
def echo({:text, text, _msg}, context) do
answer(context, text)
endThe router detects the arity at compile time. Use 2-arity when you need to extract data from the parsed update tuple directly.
- Scopes are tried top-to-bottom in declaration order.
- All filters in a scope must pass (AND logic).
- The first matching leaf wins — its handler runs and dispatch stops.
- A scope with no filters matches everything, so a filter-less scope at the bottom acts as a fallback.
The router ships with filters for the most common update types. Use them by alias name:
# Commands
filter :command # any command
filter :command, :start # specific command
# Text messages
filter :text # any text
filter :text, "hello" # exact match
filter :text, ~r/^\d+$/ # regex match
filter :text, prefix: "!" # starts with
filter :text, contains: "hi" # contains substring
# Callback queries
filter :callback_query # any callback
filter :callback_query, "confirm" # exact data
filter :callback_query, ~r/^page_\d+$/ # regex match
filter :callback_query, prefix: "settings:" # prefix match
# Inline queries
filter :inline_query
filter :inline_query, prefix: "@"
# Media & other types
filter :photo
filter :document
filter :location
filter :sticker
filter :voice
filter :video
filter :animation
filter :audio
filter :contact
filter :poll
filter :video_note
filter :message # any message-type update
filter :regex # any named regex matchEach filter alias maps to a module under ExGram.Router.Filters.*. You never need to reference them directly, but you can if you prefer.
Scopes can be nested. A child scope only runs if its parent's filters already passed, so parent filters act as guards for all children:
scope do
filter :callback_query, prefix: "settings:"
scope do
filter :callback_query, "settings:language"
handle &MyBot.Handlers.settings_language/1
end
scope do
filter :callback_query, "settings:timezone"
handle &MyBot.Handlers.settings_timezone/1
end
endThis is especially useful for callback queries with hierarchical data patterns. Instead of repeating the prefix in every leaf, you filter it once at the parent level.
For deeply nested callback data, you can use propagate: true on a prefix filter. The matched prefix is stripped and child scopes match against the remainder:
scope do
filter :callback_query, prefix: "proj:", propagate: true
scope do
filter :callback_query, "change" # matches "proj:change"
handle &MyBot.Handlers.change_project/1
end
scope do
filter :callback_query, prefix: "settings:", propagate: true
scope do
filter :callback_query, "volume" # matches "proj:settings:volume"
handle &MyBot.Handlers.volume/1
end
end
endPropagation stacks across nesting levels, so you can model arbitrary callback data hierarchies cleanly.
When the built-in filters aren't enough, you can implement the ExGram.Router.Filter behaviour to encode any runtime predicate - user roles, conversation state, feature flags, and more. See the Custom Filters section in the ExGram.Router documentation for the full guide including examples and alias registration.
The router ships with two mix tasks for visualizing your bot's routing configuration:
Prints the full scope tree with indentation:
$ mix ex_gram.router.tree MyBot
MyBot routing tree:
├── scope
│ ├── filters: [Command(:start)]
│ └── handle: &MyBot.Handlers.start/1
├── scope
│ ├── filters: [Command(:help)]
│ └── handle: &MyBot.Handlers.help/1
└── scope
├── filters: [CallbackQuery([prefix: "proj:"]) [propagate]]
├── scope
│ ├── filters: [CallbackQuery("change")]
│ └── handle: &MyBot.Handlers.change_project/1
└── scope
├── filters: [CallbackQuery("delete")]
└── handle: &MyBot.Handlers.delete_project/1
Prints one line per handler with the full accumulated filter chain, similar to mix phx.routes in Phoenix:
$ mix ex_gram.router.flat MyBot
MyBot handlers:
MyBot.Handlers start/1 filters: [Command(:start)]
MyBot.Handlers help/1 filters: [Command(:help)]
MyBot.Handlers change_project/1 filters: [CallbackQuery([prefix: "proj:"]) [propagate], CallbackQuery("change")]
MyBot.Handlers delete_project/1 filters: [CallbackQuery([prefix: "proj:"]) [propagate], CallbackQuery("delete")]
MyBot.Handlers fallback/1 filters: []
These are invaluable for debugging routing issues or onboarding new team members.
The router generates a standard handle/2 function, so testing works exactly like any other ExGram bot. Use ExGram.Adapter.Test, push updates, and assert on outgoing API calls. No special setup needed.
See the Testing guide for details.
- FSM - Add finite state machine conversation flows (works great with the router)
- Middlewares - Enrich context with data your filters need
- Handling Updates - Understand the update tuples that filters match against
- Cheatsheet - Quick reference for common patterns