Router

The router is how Telega decides which handler runs for each incoming update. You build one with router.new, attach handlers for the update types you care about, and pass it to the bot with telega.with_router.

import telega/router
import telega/reply

let router =
  router.new("my_bot")
  |> router.on_command("start", handle_start)
  |> router.on_command("help", handle_help)
  |> router.on_any_text(handle_text)
  |> router.on_photo(handle_photo)
  |> router.fallback(handle_unknown)

Every handler has the same shape — it receives the context (with the current session) and the update-specific payload, and returns the updated context:

fn handle_start(ctx, _command: update.Command) {
  use _ <- try(reply.with_text(ctx, "Welcome!"))
  Ok(ctx)
}

reply.with_text returns Result(Message, _), so chain it with use _ <- try(...) (from gleam/result) and return the context yourself.

Routing priority

For each update the router tries routes in this order, and the first match wins:

  1. Commands — exact matches like /start, /help
  2. Callback queries — button presses, matched by callback data
  3. Custom routes — your own matcher functions
  4. Media routes — photo, video, voice, audio, media groups
  5. Text routes — pattern matching on message text
  6. Specialized routes — inline queries, polls, payments, reactions, chat events
  7. Fallback — catch-all for anything unmatched

Within a category routes are tried in registration order.

Commands

router
|> router.on_command("start", handle_start)
|> router.on_commands(["help", "about"], show_info)  // one handler, many commands

Command handlers receive a parsed update.Command with the command name and any arguments. The leading slash is optional — "start" and "/start" are equivalent.

To make a command show up in the Telegram command menu, register it with a description (see Command & update auto-sync):

router
|> router.on_command_with_description("start", "Start the bot", handle_start)

Text and patterns

Text routes match on the message body using a Pattern:

router
|> router.on_text(router.Exact("hello"), handle_hello)
|> router.on_text(router.Prefix("search:"), handle_search)
|> router.on_text(router.Contains("help"), handle_help_mention)
|> router.on_text(router.Suffix("?"), handle_question)
|> router.on_any_text(handle_any_text)  // every text message

Text handlers receive the message text as a String.

Callback queries

Button presses are matched on their callback data, with the same Pattern type:

router
|> router.on_callback(router.Prefix("page:"), handle_pagination)
|> router.on_callback(router.Exact("cancel"), handle_cancel)

Callback handlers receive the callback query id and the data string.

Media

router
|> router.on_photo(handle_photo)            // List(PhotoSize)
|> router.on_video(handle_video)            // Video
|> router.on_voice(handle_voice_message)    // Voice
|> router.on_audio(handle_audio_file)       // Audio
|> router.on_media_group(handle_album)      // media group id + List(Message)

Specialized routes

Dedicated handlers exist for the rest of the Telegram update types:

router
|> router.on_inline_query(handle_inline)
|> router.on_pre_checkout_query(handle_pre_checkout)
|> router.on_reaction_emojis(["👍", "🔥"], handle_thumbs_up)
|> router.on_chat_join_request(handle_join_request)

Custom routes and filters

For logic that doesn’t fit the built-in categories, use a custom matcher:

router
|> router.on_custom(
  matcher: fn(update) {
    case update {
      update.TextUpdate(text: t, ..) -> string.starts_with(t, "https://")
      _ -> False
    }
  },
  handler: handle_link,
)

Filters are composable predicates over updates:

router
|> router.on_filtered(router.is_private_chat(), handle_private)
|> router.on_filtered(router.from_user(admin_id), handle_admin)
|> router.on_filtered(
  router.and([
    router.is_text(),
    router.from_users([admin1, admin2]),
    router.not(router.text_starts_with("/")),
  ]),
  handle_admin_text,
)

Available predicates include message-type filters (is_text, is_command, has_photo, has_video, has_media, is_media_group, is_callback_query), text-content filters (text_equals, text_starts_with, text_contains, command_equals), and user/chat filters (from_user, from_users, in_chat, from_chats, is_private_chat, is_group_chat, callback_data_starts_with). Combine them with and/and2, or/or2, and not, or build your own with filter.

from_users / from_chats are whitelists; wrap them in not for a blacklist:

// Only react in the support chats:
router.on_filtered(router.from_chats([support_a, support_b]), handler)
// React everywhere except a banned chat:
router.on_filtered(router.not(router.from_chats([banned_chat])), handler)

Role filters (is_admin / is_owner)

Filters are pure predicates over the update, so they can’t make API calls. Role checks need getChatMember, so they live in telega/roles, which caches results in ETS (one round-trip is too slow to repeat per message). It exposes booleans (is_admin / is_owner), use-friendly guards (ensure_admin / ensure_owner), and router middleware (require_admin / require_owner):

import telega/roles

let cache = roles.new_cache(ttl_ms: 60_000)

router.new("admin")
|> router.on_command("ban", fn(ctx, _cmd) {
  use ctx <- roles.ensure_admin(ctx, cache, on_denied: fn(ctx) {
    reply.with_text(ctx, "Admins only.")
  })
  // ... only reached for admins/owner ...
  Ok(ctx)
})

“Admin” means administrator or owner; “owner” means the chat creator. Checks fail closed (access denied) on an API error. Pass ttl_ms: 0 to disable caching.

Middleware

Middleware wraps handlers with cross-cutting behavior. It is applied in reverse order of addition, so the last added runs first (outermost):

router
|> router.use_middleware(router.with_logging)
|> router.use_middleware(auth_middleware)
|> router.use_middleware(rate_limit_middleware)

Built-ins: with_logging, with_filter, with_recovery, and with_rate_limit.

Pre-router middleware

Router middleware runs per router, after an update has been dispatched to a chat instance and a session loaded. For cross-cutting concerns that apply to every update — anti-spam, analytics, deduplication — register a pre-router middleware with telega.use_pre_handler. It runs once per update inside the bot actor, before routing and before any chat instance is spawned, so it is cheaper and can drop an update outright:

import telega
import telega/bot

telega.new_for_polling(api_client:)
|> telega.use_pre_handler(fn(pre: bot.PreContext(deps)) {
  case is_banned(pre.update.chat_id) {
    True -> bot.Stop      // drop before routing
    False -> bot.Continue // let it through
  }
})
|> telega.with_router(router)

Pre-handlers run in registration order; the first bot.Stop short-circuits the rest and the router. A PreContext carries the update, config, dependencies, and bot_info — but no session (it hasn’t been loaded yet). Because they all run sequentially in the single bot actor, read-then-write logic is race-free across concurrent updates.

Webhook idempotency (deduplication)

Telegram re-delivers an update (same update_id) when it doesn’t get a 200 in time — on a slow response, a redeploy, or a network blip. That double-runs non-idempotent commands (sending an invoice, charging Stars). The telega/idempotency module provides a ready-made pre-router middleware that remembers each update_id in a KeyValueStorage for a TTL window and drops duplicates:

import telega/idempotency
import telega/storage/ets

let assert Ok(store) = ets.new(name: "telega_dedup")

telega.new(token:, url:, webhook_path:, secret_token:)
|> telega.use_pre_handler(idempotency.deduplicate(storage: store, ttl_ms: 3600_000))
|> telega.with_router(router)

Use a persistent backend (Postgres/SQLite/Redis) when running more than one node or to survive restarts. On a storage error the update is let through (fail-open): processing twice is recoverable, dropping a real update is not.

Error handling

A route handler that returns Error is passed to the router’s catch handler, if set. fallback handles updates that no route matched.

router
|> router.with_catch_handler(fn(error) {
  log.error("Route error: " <> string.inspect(error))
  Error(error)
})
|> router.fallback(handle_unknown)

The catch handler receives only the error (no context) and returns Result(Context, error) — log and re-raise with Error(error), or recover with a context you already hold in scope.

The router’s catch handler only handles errors from route handlers. System-level errors (like session persistence) go to the bot’s catch handler configured via telega.with_catch_handler.

Composition

Routers compose, so you can build complex routing from small pieces.

Merge combines two routers into one flat router; the first wins on conflicts:

let main = router.merge(admin_router, user_router)

Compose tries each sub-router in sequence, each keeping its own middleware and catch handler:

let app = router.compose(private_router, public_router)
let app = router.compose_many([admin, moderator, user])

Scope restricts a router to updates matching a predicate:

let admin =
  router.new("admin")
  |> router.on_command("ban", handle_ban)
  |> router.scope(fn(update) {
    case update {
      update.CommandUpdate(from_id: id, ..) -> is_admin(id)
      _ -> False
    }
  })

Command & update auto-sync

Because the router already knows every command and update type the bot handles, Telega can keep Telegram in sync with it automatically — no hand-maintained setMyCommands list and no allowed_updates that drifts out of date. All of this is opt-in, with manual escape hatches.

Publishing commands on start

Register commands with on_command_with_description and enable telega.with_auto_commands. On startup — after the supervision tree is up and before your with_on_start hook — Telega calls setMyCommands with every described command:

let router =
  router.new("my_bot")
  |> router.on_command_with_description("start", "Start the bot", handle_start)
  |> router.on_command_with_description("help", "Show help", handle_help)
  |> router.on_command("secret", handle_secret)
  // ^ no description → still routed, but not published

telega.new_for_polling(api_client:)
|> telega.with_router(router)
|> telega.with_auto_commands()
|> telega.init_for_polling()

Commands added with plain on_command are skipped. If nothing has a description, no API call is made. router.registered_commands(router) returns the #(command, description) pairs if you want to inspect them yourself.

Localized descriptions with telega_i18n

Put the descriptions in a telega_i18n catalog under a common prefix and wire them in with telega_i18n.with_command_translations. It implies with_auto_commands: the default-language menu is published first, then one setMyCommands(language_code:) call per catalog locale.

# locales/en.toml
[commands]
start = "Start the bot"
help = "Show help"
# locales/ru.toml
[commands]
start = "Запустить бота"
help = "Показать справку"
import telega
import telega_i18n as i18n

let assert Ok(catalog) =
  i18n.new("en")
  |> i18n.load_toml_dir("locales")

telega.new_for_polling(api_client:)
|> telega.with_router(router)
|> i18n.with_command_translations(catalog, prefix: "commands.")
|> telega.init_for_polling()

The description for command start is looked up at commands.start, honoring the catalog’s fallback chains. A missing key falls back to the description the command was registered with on the router.

If you are not using telega_i18n, supply the translator yourself:

telega.with_command_translations(
  builder,
  locales: ["en", "ru"],
  translate: fn(command, locale) {
    // `Some(description)` to override, `None` to keep the router default
    my_lookup(command, locale)
  },
)

Deriving allowed_updates

Enable telega.with_auto_allowed_updates and Telega requests only the update types the router can handle, cutting traffic for everything else:

telega.new_for_polling(api_client:)
|> telega.with_router(router)
|> telega.with_auto_allowed_updates()
|> telega.init_for_polling()

Route → update type mapping:

Routesallowed_updates
commands, text, photo/video/voice/audio, media groupsmessage
callback handlerscallback_query
on_inline_queryinline_query
on_chosen_inline_resultchosen_inline_result
on_shipping_queryshipping_query
on_pre_checkout_querypre_checkout_query
on_poll / on_poll_answerpoll / poll_answer
reaction handlersmessage_reaction
on_reaction_countmessage_reaction_count
on_chat_member_updatedchat_member
on_chat_join_requestchat_join_request

router.allowed_updates(router) returns the derived list directly.

Escape hatches. A manual telega.set_allowed_updates(builder, updates) always wins; auto derivation is skipped entirely. And if the router has a fallback, custom, or filtered route — which can match any update — the set can’t be narrowed safely, so derivation returns the empty list and Telegram falls back to its default update set. Use set_allowed_updates when you need narrowing alongside catch-all routes.

Search Document