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.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 — and matching is case-insensitive, so /Start reaches on_command("start", ...). The command name ends at the first whitespace of any kind: /start\nref=42 is the command start with the payload ref=42.

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.

Typed callback routes

A keyboard.KeyboardCallbackData factory already knows how to build and parse its own payloads. on_callback_data registers a route for exactly those payloads and hands the handler the decoded value:

let page = keyboard.int_callback_data("page")

router
|> router.on_callback_data(page, fn(ctx, _query, page_number) {
  // page_number: Int — no unpack_callback, no error handling in the handler
  reply.with_text(ctx, "Page " <> int.to_string(page_number))
})

A payload belonging to another factory, or one the factory cannot deserialize, never reaches the handler — it leaves the context untouched rather than handing you a 0/False stand-in.

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)

Telegram delivers an album as separate messages sharing a media_group_id, so on_media_group only fires when the bot asks for them to be gathered:

telega.new(api_client)
|> telega.router(router)
// hold an album's messages until 1s passes without another one
|> telega.with_media_group_timeout(1000)

Without it, each message of an album arrives on its own on_photo / on_video / on_audio route. With it, they arrive together on on_media_group and not individually. Messages that arrive while a wait_* conversation is pending are never gathered — the waiting handler expects them one at a time.

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), user/chat filters (from_user, from_users, in_chat, from_chats, is_private_chat, is_group_chat, chat_type, callback_data_starts_with), and message-content filters (is_forwarded, is_reply, in_topic, has_entity, via_bot, via_bot_id, is_automatic_forward, has_media_spoiler).

The message-content filters read the update’s own Message through update.message, which is None for updates that are not about a message (callback queries, inline queries, polls, member changes) — those match none of them. has_entity searches both entities and caption_entities, so a captioned photo with a link matches has_entity("url") just as a text message does. The full table is in the telega/router module docs.

from_user / from_users match on the update’s sender whatever its kind. is_private_chat / is_group_chat / chat_type read the chat’s own type_ via update.chat, so an update that happens in no chat at all — an inline query, a poll answer — matches none of them, rather than being classified by the sign of its stand-in chat_id. 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. The first one added is the outermost, so it runs first and sees the handler’s result last:

router
|> router.use_middleware(router.with_logging)     // outermost, runs first
|> router.use_middleware(auth_middleware)
|> router.use_middleware(rate_limit_middleware)   // innermost, closest to the handler

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(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.proceed()  // let it through
  }
})
|> telega.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, bot_info, and the annotations earlier pre-handlers set — 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.

Annotations

A pre-handler can attach per-update facts for the handlers downstream by returning bot.Continue(annotations:) instead of bot.proceed(). Annotations from successive pre-handlers are merged (a repeated key takes the newer value) and arrive in every handler as ctx.annotations, read back with bot.annotation:

import gleam/dict
import gleam/dynamic
import gleam/dynamic/decode
import gleam/result

telega.new(api_client)
|> telega.use_pre_handler(fn(pre: bot.PreContext(deps)) {
  bot.Continue(annotations: dict.from_list([
    #("locale", dynamic.string(resolve_locale(pre.update))),
  ]))
})

// ... in any handler
let locale =
  bot.annotation(ctx, "locale", decode.string)
  |> result.unwrap("en")

Annotations live for one update and are never persisted — long-lived services belong in dependencies, per-user state in the session.

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(api_client)
|> telega.webhook(url:, path:, secret_token:)
|> telega.use_pre_handler(idempotency.deduplicate(storage: store, ttl_ms: 3600_000))
|> telega.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

There are two types, and they do different jobs.

Merge combines two leaves into one flat leaf; the first wins on conflicts:

let main = router.merge(admin_router, user_router)

A tree tries each leaf in order, each keeping its own middleware and catch handler. append adds a leaf that is always consulted; branch adds one that is only consulted when a filter matches:

let app =
  router.tree()
  |> router.branch(router.is_private_chat(), private_router)
  |> router.branch(router.is_group_chat(), group_router)
  |> router.append(shared_router)
  |> router.tree_fallback(handle_unknown)

telega.new(api_client)
|> telega.router_tree(app)

compose(a, b) and compose_many([a, b, c]) are shorthand for a tree of unconditional branches.

A branch whose filter matches but which has no route for the update is skipped, so the next branch gets its turn. Routes that used to be registered on a composition now go into an explicit trailing leaf:

// `/help` is handled after `private_router` and `public_router` decline it
let app =
  router.compose(private_router, public_router)
  |> router.append(router.new("direct") |> router.on_command("help", handle_help))

Settings that belong to each branch rather than to one of them have tree-level forms: use_middleware_on_tree and with_catch_handler_on_tree (the latter leaves a branch that already has its own catch handler alone).

Scope restricts a leaf to updates matching a predicate. A scoped leaf declines updates outside its scope, so in a tree the next branch gets its turn:

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(api_client)
|> telega.router(router)
|> telega.with_auto_commands()
|> telega.start()

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(api_client)
|> telega.router(router)
|> i18n.with_command_translations(catalog, prefix: "commands.")
|> telega.start()

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(api_client)
|> telega.router(router)
|> telega.with_auto_allowed_updates()
|> telega.start()

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_my_chat_member_updatedmy_chat_member
on_chat_join_requestchat_join_request
on_chat_boost / on_removed_chat_boostchat_boost / removed_chat_boost
on_edited_messageedited_message
on_channel_post / on_edited_channel_postchannel_post / edited_channel_post
on_business_messagebusiness_message
on_web_app_datamessage
on_paid_media_purchasepurchased_paid_media

router.allowed_updates(router) returns the derived list directly; router.tree_allowed_updates(tree) does the same for a composition (one branch that gives up on narrowing gives up for the whole tree).

A narrowed set always admits callback_query. Derivation only sees the router, and a handler that parks on bot.wait_callback is invisible to it — a bot whose router registers only commands used to derive ["message"] and then wait forever. callback_query is therefore always in a non-empty derived set; allowing it costs nothing when unused, because Telegram only sends callback queries for keyboards the bot itself put on screen.

Other kinds a conversation or flow waits for are still invisible. Add them explicitly:

|> telega.with_auto_allowed_updates()
|> telega.with_extra_allowed_updates(["message_reaction"])

Escape hatches. A manual telega.with_allowed_updates(builder, updates) always wins; auto derivation is skipped entirely. And if the router has a fallback, custom, filtered, or on_unknown_update 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 (with_extra_allowed_updates is a no-op in that case). Use with_allowed_updates when you need narrowing alongside catch-all routes.

Search Document