telega/router

Telega Router

The router module provides a flexible and composable routing system for Telegram bot updates. It allows you to define handlers for different types of messages and organize them into logical groups with middleware support, error handling, and composition capabilities.

Two types, two jobs

telega.router takes a Router; telega.router_tree takes a RouterTree. Both are converted to a Routable internally.

Basic Usage

import telega/router
import telega/update
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)

Routing Priority

Routes are matched in the following priority order:

  1. Commands - Exact command matches (e.g., “/start”, “/help”)
  2. Callback Queries - Callback data patterns
  3. Custom Routes - User-defined matchers
  4. Media Routes - Photo, video, voice, audio handlers
  5. Text Routes - Text pattern matching
  6. Fallback - Catch-all handler for unmatched updates

Within each category, routes are tried in the order they were added, with the first matching route handling the update.

Pattern Matching

Text and callback queries support flexible pattern matching:

router
|> router.on_text(Exact("hello"), handle_hello)
|> router.on_text(Prefix("search:"), handle_search)
|> router.on_text(Contains("help"), handle_help_mention)
|> router.on_text(Suffix("?"), handle_question)

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

Typed callback routes

A keyboard.KeyboardCallbackData factory already knows how to serialize and parse its own payloads. on_callback_data registers a route for exactly that factory’s payloads and hands the handler the decoded value — no unpack_callback in the handler, and a payload that fails to decode never reaches it:

let page = keyboard.int_callback_data("page")

router
|> router.on_callback_data(page, fn(ctx, query, page_number) {
  // page_number: Int
  reply.with_text(ctx, "Page " <> int.to_string(page_number))
})

Middleware System

Middleware allows you to wrap handlers with additional functionality. The first middleware added is the outermost one, 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

On a RouterTree, use_middleware_on_tree pushes the middleware into every branch (each branch keeps its own copy, applied around its own handlers).

Built-in middleware includes:

Error Handling

Routers support catch handlers to gracefully handle errors from routes:

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

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

Note: The router’s catch handler only handles errors from route handlers. System-level errors (like session persistence failures) are handled by the bot’s main catch handler configured via telega.with_catch_handler.

Composition

Merging leaves

merge combines two leaf routers into one, with all routes unified. Routes from the first router take priority in case of conflicts:

let admin_router =
  router.new("admin")
  |> router.on_command("ban", handle_ban)
  |> router.on_command("stats", handle_stats)

let user_router =
  router.new("user")
  |> router.on_command("start", handle_start)
  |> router.on_command("help", handle_help)

let main_router = router.merge(admin_router, user_router)

Building a tree

append adds a leaf that is tried in order; branch adds one that is only consulted when a filter matches. Each leaf keeps its own middleware and catch handler:

let tree =
  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(tree)

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

Scoped leaves

scope restricts a whole leaf to updates matching a predicate. A scoped leaf declines out-of-scope updates outright, so the next branch of the tree gets its turn:

let admin_router =
  router.new("admin")
  |> router.on_command("ban", handle_ban)
  |> router.scope(fn(update) { is_admin(update.from_id) })

Custom Routes

For complex routing logic, use custom matchers:

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

Magic Filters

The router includes a powerful filter system for creating complex routing conditions:

// Simple filters
router
|> router.on_filtered(router.is_private_chat(), handle_private)
|> router.on_filtered(router.from_user(admin_id), handle_admin)

// Combining filters with AND logic
router
|> router.on_filtered(
  router.and2(
    router.is_group_chat(),
    router.text_starts_with("!")
  ),
  handle_group_command
)

// Combining multiple filters
router
|> router.on_filtered(
  router.and([
    router.is_text(),
    router.from_users([admin1, admin2, admin3]),
    router.not(router.text_starts_with("/"))
  ]),
  handle_admin_text
)

Filter reference

Every filter is a predicate over the whole update, so one table covers them all. “Reads” says where the answer comes from — an update that has no such field never matches.

FilterReadsTrue when
is_text()update kindthe update is a plain text message
text_equals(t)textthe text is exactly t
text_starts_with(p)textthe text starts with p
text_contains(s)textthe text contains s
is_command()update kindthe update is a command
command_equals(c)commandthe command is c (leading / optional)
from_user(id)update.from_idthe sender is id
from_users(ids)update.from_idthe sender is one of ids
in_chat(id)update.chat_idthe update happened in chat id
from_chats(ids)update.chat_idthe chat is one of ids
is_private_chat()update.chat().type_the chat is "private"
is_group_chat()update.chat().type_the chat is "group" or "supergroup"
chat_type(t)update.chat().type_the chat type is exactly t
has_photo()update kindthe message carries photos
has_video()update kindthe message carries a video
is_media_group()update kindthe update is a buffered album
has_media()update kindphoto, video, voice, audio or album
is_callback_query()update kindthe update is a button press
callback_data_starts_with(p)query.datathe callback payload starts with p
is_forwarded()message.forward_originthe message was forwarded
is_reply()message.reply_to_messagethe message replies to another
in_topic(id)message.message_thread_idthe message is in forum topic id
has_entity(kind)message.entities + caption_entitiesan entity of that type is present (e.g. "url", "mention")
via_bot()message.via_botthe message was sent through an inline bot
via_bot_id(id)message.via_bot.idit was sent through bot id
is_automatic_forward()message.is_automatic_forwarda channel post auto-forwarded to its discussion group
has_media_spoiler()message.has_media_spoilerthe media is spoiler-covered

The message-reading filters use update.message, which answers None for updates that are not about a message (callback queries, inline queries, polls, member changes) — those match none of them.

Handler Types

Different route types receive different handler signatures:

Types

pub type AudioHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.Audio) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )

Handler for a typed callback route registered with on_callback_data: the callback query itself plus the payload already decoded by the keyboard.KeyboardCallbackData factory the route was registered with.

pub type CallbackDataHandler(
  session,
  error,
  dependencies,
  data,
) =
  fn(
    bot.Context(session, error, dependencies),
    types.CallbackQuery,
    data,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type CallbackHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), String, String) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type ChatBoostHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.ChatBoostUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type ChatJoinRequestHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.ChatJoinRequest,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type ChatMemberUpdatedHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.ChatMemberUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type ChosenInlineResultHandler(
  session,
  error,
  dependencies,
) =
  fn(
    bot.Context(session, error, dependencies),
    types.ChosenInlineResult,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type CommandHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), update.Command) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )

Filter type for composable update filtering

pub opaque type Filter

Generic handler type for all updates

pub type Handler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), update.Update) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type InlineQueryHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.InlineQuery) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type MediaGroupHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    String,
    List(types.Message),
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type MessageHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.Message) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type MessageReactionCountHandler(
  session,
  error,
  dependencies,
) =
  fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionCountUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type MessageReactionHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error)

Middleware wraps a handler with additional functionality

pub type Middleware(session, error, dependencies) =
  fn(
    fn(bot.Context(session, error, dependencies), update.Update) -> Result(
      bot.Context(session, error, dependencies),
      error,
    ),
  ) -> fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type PaidMediaPurchaseHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.PaidMediaPurchased,
  ) -> Result(bot.Context(session, error, dependencies), error)

Pattern matching for text and callbacks

pub type Pattern {
  Exact(String)
  Prefix(String)
  Contains(String)
  Suffix(String)
}

Constructors

  • Exact(String)
  • Prefix(String)
  • Contains(String)
  • Suffix(String)
pub type PhotoHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    List(types.PhotoSize),
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type PollAnswerHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.PollAnswer) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type PollHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.Poll) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type PreCheckoutQueryHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.PreCheckoutQuery,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type RemovedChatBoostHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.ChatBoostRemoved,
  ) -> Result(bot.Context(session, error, dependencies), error)

What telega actually stores: a leaf and a tree reduced to the four things the bot needs from a router. Build one with routable or tree_routable.

pub type Routable(session, error, dependencies) {
  Routable(
    name: String,
    handle: fn(
      bot.Context(session, error, dependencies),
      update.Update,
    ) -> Result(bot.Context(session, error, dependencies), error),
    allowed_updates: List(String),
    registered_commands: List(#(String, String)),
  )
}

Constructors

  • Routable(
      name: String,
      handle: fn(
        bot.Context(session, error, dependencies),
        update.Update,
      ) -> Result(bot.Context(session, error, dependencies), error),
      allowed_updates: List(String),
      registered_commands: List(#(String, String)),
    )

    Arguments

    allowed_updates

    The derived allowed_updates set; [] means “do not restrict”.

    registered_commands

    #(command, description) pairs for setMyCommands.

Unified route type that encompasses all route types

pub type Route(session, error, dependencies) {
  TextPatternRoute(
    pattern: Pattern,
    handler: fn(bot.Context(session, error, dependencies), String) -> Result(
      bot.Context(session, error, dependencies),
      error,
    ),
  )
  PhotoRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      List(types.PhotoSize),
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  VideoRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Video,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  VoiceRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Voice,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  AudioRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Audio,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MediaGroupRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      String,
      List(types.Message),
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  WebAppDataRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.WebAppData,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  EditedMessageRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Message,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ChannelPostRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Message,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  EditedChannelPostRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Message,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  BusinessMessageRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Message,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  InlineQueryRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.InlineQuery,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ChosenInlineResultRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChosenInlineResult,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ShippingQueryRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ShippingQuery,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  PreCheckoutQueryRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.PreCheckoutQuery,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  PaidMediaPurchaseRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.PaidMediaPurchased,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  PollRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.Poll,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  PollAnswerRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.PollAnswer,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionEmojiRoute(
    emojis: List(String),
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionPaidRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionAddedRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionRemovedRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MessageReactionCountRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.MessageReactionCountUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ChatMemberUpdatedRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChatMemberUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  MyChatMemberUpdatedRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChatMemberUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ChatJoinRequestRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChatJoinRequest,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  ChatBoostRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChatBoostUpdated,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  RemovedChatBoostRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      types.ChatBoostRemoved,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  UnknownUpdateRoute(
    handler: fn(
      bot.Context(session, error, dependencies),
      update.Update,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  CustomRoute(
    matcher: fn(update.Update) -> Bool,
    handler: fn(
      bot.Context(session, error, dependencies),
      update.Update,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
  FilteredRoute(
    filter: Filter,
    handler: fn(
      bot.Context(session, error, dependencies),
      update.Update,
    ) -> Result(bot.Context(session, error, dependencies), error),
  )
}

Constructors

A leaf router: routes, middleware, a catch handler and an optional scope.

Every on_* function registers on one of these. Compositions live in RouterTree — a separate type, so a registration on a composition is a compile error rather than a route that quietly goes nowhere.

pub opaque type Router(session, error, dependencies)

An ordered composition of leaf routers.

A tree holds no routes of its own: on_command and friends are not defined for it. Build one with tree and add leaves with append (always tried) or branch (tried only when a filter matches). Branches are consulted in the order they were added, and the first one that both passes its filter and has a route for the update handles it.

pub opaque type RouterTree(session, error, dependencies)
pub type ShippingQueryHandler(session, error, dependencies) =
  fn(
    bot.Context(session, error, dependencies),
    types.ShippingQuery,
  ) -> Result(bot.Context(session, error, dependencies), error)
pub type TextHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), String) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type VideoHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.Video) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type VoiceHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.Voice) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )
pub type WebAppDataHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), types.WebAppData) -> Result(
    bot.Context(session, error, dependencies),
    error,
  )

Values

pub fn allowed_updates(
  router: Router(session, error, dependencies),
) -> List(String)

Derive the set of Telegram update types this router actually handles, as the strings expected by allowed_updates (e.g. "message", "callback_query"). The result is deduplicated and sorted for stable output.

If the router has a fallback, custom, filtered, or on_unknown_update route, the handled set cannot be determined statically (those routes can match anything), so an empty list is returned to signal “do not restrict” — Telegram then sends its default update set. Use a manual override when you need narrowing alongside catch-all routes.

A non-empty result always contains "callback_query", even for a router with no callback route. Static derivation only sees the router: a handler that parks on bot.wait_callback is invisible to it, and narrowing the set without callback_query would leave that conversation waiting forever. Allowing it costs nothing when unused — Telegram only sends callback queries for keyboards the bot itself put on screen.

pub fn and(filters: List(Filter)) -> Filter

Combine filters with AND logic

pub fn and2(left: Filter, right: Filter) -> Filter

Combine two filters with AND logic

pub fn append(
  tree: RouterTree(session, error, dependencies),
  router: Router(session, error, dependencies),
) -> RouterTree(session, error, dependencies)

Add a leaf that is tried for every update, after the branches already added.

pub fn branch(
  tree: RouterTree(session, error, dependencies),
  when filter: Filter,
  router router: Router(session, error, dependencies),
) -> RouterTree(session, error, dependencies)

Add a leaf that is only consulted when when matches the update.

router.tree()
|> router.branch(router.is_private_chat(), private_router)
|> router.branch(router.is_group_chat(), group_router)
pub fn callback_data_starts_with(prefix: String) -> Filter

Filter for callback data that starts with prefix

pub fn chat_type(type_: String) -> Filter

Filter on the chat’s type_ verbatim: “private”, “group”, “supergroup” or “channel”. Updates that happen in no chat never match.

pub fn command_equals(cmd: String) -> Filter

Filter for specific command

pub fn compose(
  first: Router(session, error, dependencies),
  second: Router(session, error, dependencies),
) -> RouterTree(session, error, dependencies)

Compose two leaf routers into a tree. Both are tried in order.

pub fn compose_many(
  routers: List(Router(session, error, dependencies)),
) -> RouterTree(session, error, dependencies)

Compose many leaf routers into a tree, tried in order.

pub fn fallback(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Set fallback handler for unmatched updates

pub fn filter(
  name: String,
  check: fn(update.Update) -> Bool,
) -> Filter

Create a filter from a custom function

pub fn from_chats(chat_ids: List(Int)) -> Filter

Filter by multiple chat IDs. Matches when the update’s chat is one of chat_ids — a whitelist of chats. Combine with not for a blacklist:

// Only react in the support chats
router.on_filtered(router.from_chats([-100_1, -100_2]), handler)

// React everywhere except the banned chats
router.on_filtered(router.not(router.from_chats([-100_666])), handler)
pub fn from_user(user_id: Int) -> Filter

Filter by user ID

pub fn from_users(user_ids: List(Int)) -> Filter

Filter by multiple user IDs

pub fn handle(
  router: Router(session, error, dependencies),
  ctx: bot.Context(session, error, dependencies),
  update: update.Update,
) -> Result(bot.Context(session, error, dependencies), error)

Process an update through a leaf router.

pub fn handle_tree(
  tree: RouterTree(session, error, dependencies),
  ctx: bot.Context(session, error, dependencies),
  update: update.Update,
) -> Result(bot.Context(session, error, dependencies), error)

Process an update through a tree: the first branch whose filter matches and which has a route for the update handles it, otherwise the tree fallback.

pub fn has_entity(kind: String) -> Filter

The message carries an entity of the given type — "url", "mention", "hashtag", "bot_command", "spoiler", … Both the text entities and the caption entities are searched, so a captioned photo with a link matches has_entity("url") the same way a text message does.

pub fn has_media() -> Filter

Filter for media (photo, video, audio, voice)

pub fn has_media_spoiler() -> Filter

The message’s media is covered by a spoiler animation.

pub fn has_photo() -> Filter

Filter for photo messages

pub fn has_video() -> Filter

Filter for video messages

pub fn in_chat(chat_id: Int) -> Filter

Filter by chat ID

pub fn in_topic(thread_id: Int) -> Filter

The message belongs to the given forum topic / message thread.

pub fn is_automatic_forward() -> Filter

A channel post automatically forwarded to the linked discussion group.

pub fn is_callback_query() -> Filter

Filter for callback queries

pub fn is_command() -> Filter

Filter for commands

pub fn is_forwarded() -> Filter

The message was forwarded from somewhere else.

pub fn is_group_chat() -> Filter

Filter for group and supergroup chats. Channels are neither — use chat_type for those.

pub fn is_media_group() -> Filter

Filter for media group messages

pub fn is_private_chat() -> Filter

Filter for private chats.

Reads the chat’s own type_, not the sign of chat_id: an update that happens in no chat at all (an inline query, a poll answer) is not a private chat, however its stand-in chat_id is keyed.

pub fn is_reply() -> Filter

The message is a reply to another message.

pub fn is_text() -> Filter

Filter for text messages

pub fn matched_route(
  ctx: bot.Context(session, error, dependencies),
) -> option.Option(String)

The label of the route that handled the current update, as recorded by handle"command:/start", "callback:exact:menu", "text:exact:hi", "photo", "fallback", or "unmatched" when no route (and no fallback) claimed it.

This is what the route metadata of the telega.update.stop telemetry event carries; read it from a middleware or a catch handler to log or count by route. None outside of routing (a wait_* continuation, a background context).

pub fn matched_router(
  ctx: bot.Context(session, error, dependencies),
) -> option.Option(String)

The name of the leaf router whose route claimed the current update — the tree branch, for a bot built with router_tree.

pub fn matches(filter: Filter, update: update.Update) -> Bool

Evaluate a composable Filter against an update.

This is the bridge that lets the filter combinators (and/or/not, is_text, has_photo, …) be reused outside the router — most notably to drive telega.wait_filtered / telega.wait_for in conversations:

use ctx, upd <- telega.wait_for(
  ctx,
  filter: router.matches(router.or2(router.is_text(), router.has_photo()), _),
  or: None,
  timeout: None,
)
pub fn merge(
  first: Router(session, error, dependencies),
  second: Router(session, error, dependencies),
) -> Router(session, error, dependencies)

Merge two leaf routers into one. All routes are combined, with the first router’s routes taking priority in case of conflicts. Middleware and catch handlers are shared.

pub fn name(
  router: Router(session, error, dependencies),
) -> String

The router’s name, as given to new (with _scoped/+ suffixes from scope and merge).

pub fn new(name: String) -> Router(session, error, dependencies)

Create a new leaf router

pub fn not(f: Filter) -> Filter

Negate a filter

pub fn on_any_text(
  router: Router(session, error, dependencies),
  handler: fn(bot.Context(session, error, dependencies), String) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> Router(session, error, dependencies)

Add a handler for any text

pub fn on_audio(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Audio,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_business_message(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Message,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A message in a chat connected to the bot’s business account.

pub fn on_callback(
  router: Router(session, error, dependencies),
  pattern: Pattern,
  handler: fn(
    bot.Context(session, error, dependencies),
    String,
    String,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add a callback query handler with pattern

pub fn on_callback_data(
  router: Router(session, error, dependencies),
  factory: keyboard.KeyboardCallbackData(data),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.CallbackQuery,
    data,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add a callback route for one keyboard.KeyboardCallbackData factory.

The route matches exactly the payloads that factory builds (Prefix(id <> delimiter)) and the handler receives the value already decoded — no unpack_callback boilerplate, and a payload that belongs to another factory or fails to deserialize never reaches the handler.

let page = keyboard.int_callback_data("page")

router.new("bot")
|> router.on_callback_data(page, fn(ctx, _query, page_number) {
  reply.with_text(ctx, "Page " <> int.to_string(page_number))
})

A payload the factory rejects leaves the context untouched, so a sibling route or the fallback never sees it — register the factory routes you expect and a Prefix route for anything else.

pub fn on_channel_post(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Message,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A post in a channel the bot administers.

pub fn on_chat_boost(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChatBoostUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A chat boost was added or changed. The bot must be an administrator.

pub fn on_chat_join_request(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChatJoinRequest,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_chat_member_updated(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChatMemberUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Another member’s status in a chat changed.

pub fn on_chosen_inline_result(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChosenInlineResult,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_command(
  router: Router(session, error, dependencies),
  command: String,
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Command,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add a command handler

pub fn on_command_with_description(
  router: Router(session, error, dependencies),
  command: String,
  description: String,
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Command,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add a command handler together with a human-readable description.

The description is what shows up in the Telegram command menu. When the bot is started with telega.with_auto_commands, all commands registered this way are published via setMyCommands automatically, and telega_i18n can supply per-language variants. The description is ignored for routing — it only feeds command auto-synchronization.

router
|> router.on_command_with_description("start", "Start the bot", handle_start)
|> router.on_command_with_description("help", "Show help", handle_help)
pub fn on_commands(
  router: Router(session, error, dependencies),
  commands: List(String),
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Command,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add multiple commands with same handler

pub fn on_custom(
  router: Router(session, error, dependencies),
  matcher matcher: fn(update.Update) -> Bool,
  handler handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Route on a hand-written predicate over the whole update.

pub fn on_edited_channel_post(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Message,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

An edited channel post.

pub fn on_edited_message(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Message,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A message the user edited. Telegram does not send these by default — allowed_updates derivation adds "edited_message" for you.

pub fn on_filtered(
  router: Router(session, error, dependencies),
  filter: Filter,
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Route on a composable Filter.

pub fn on_inline_query(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.InlineQuery,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_media_group(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    String,
    List(types.Message),
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle a whole album as one update.

Only fires when incoming albums are buffered — see telega.with_media_group_timeout. Without it every photo of an album arrives on its own on_photo/on_video/on_audio route.

pub fn on_my_chat_member_updated(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChatMemberUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

The bot’s own status in a chat changed (blocked, added, promoted).

pub fn on_paid_media_purchase(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.PaidMediaPurchased,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A user bought paid media the bot sent with a payload.

pub fn on_paid_reaction(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle paid (star) reactions.

pub fn on_photo(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    List(types.PhotoSize),
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add handlers for media types

pub fn on_poll(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Poll,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_poll_answer(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.PollAnswer,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_pre_checkout_query(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.PreCheckoutQuery,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_reaction(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle any reaction change on a message.

pub fn on_reaction_added(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle only reaction additions.

pub fn on_reaction_count(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionCountUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle anonymous reaction counters in large chats.

pub fn on_reaction_emoji(
  router: Router(session, error, dependencies),
  emoji: String,
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle reactions with one specific emoji.

pub fn on_reaction_emojis(
  router: Router(session, error, dependencies),
  emojis: List(String),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle reactions with any of the given emojis.

pub fn on_reaction_removed(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.MessageReactionUpdated,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Handle only reaction removals.

pub fn on_removed_chat_boost(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ChatBoostRemoved,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

A chat boost was removed.

pub fn on_shipping_query(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.ShippingQuery,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_text(
  router: Router(session, error, dependencies),
  pattern: Pattern,
  handler: fn(bot.Context(session, error, dependencies), String) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> Router(session, error, dependencies)

Add a text handler with pattern

pub fn on_unknown_update(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

An update this version of the library cannot interpret: a Bot API kind it does not know yet, or one whose payload failed to decode. The handler gets the UnknownUpdate and can read update.raw.

Registering this route turns off allowed_updates narrowing — an update kind the library does not know can never be in a derived set.

pub fn on_video(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Video,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_voice(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.Voice,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)
pub fn on_web_app_data(
  router: Router(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    types.WebAppData,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Data a Mini App sent with Telegram.WebApp.sendData.

pub fn or(filters: List(Filter)) -> Filter

Combine filters with OR logic

pub fn or2(left: Filter, right: Filter) -> Filter

Combine two filters with OR logic

pub fn registered_commands(
  router: Router(session, error, dependencies),
) -> List(#(String, String))

List every command registered with a description, as #(command, description) pairs sorted by command name. Commands added with on_command (no description) are omitted.

This is what telega.with_auto_commands feeds into setMyCommands.

pub fn routable(
  router: Router(session, error, dependencies),
) -> Routable(session, error, dependencies)

Reduce a leaf router to what the bot needs from it.

pub fn scope(
  router: Router(session, error, dependencies),
  predicate: fn(update.Update) -> Bool,
) -> Router(session, error, dependencies)

Restrict a whole router to updates matching a predicate.

The predicate is also what handle and the tree consult before dispatching, so an out-of-scope update is declined rather than swallowed: the next branch of a tree gets its turn instead of the scoped router eating it.

pub fn text_contains(substring: String) -> Filter

Filter for text that contains a substring

pub fn text_equals(text: String) -> Filter

Filter for text that equals a specific value

pub fn text_starts_with(prefix: String) -> Filter

Filter for text that starts with a prefix

pub fn tree() -> RouterTree(session, error, dependencies)

An empty tree. Add leaves with append and branch.

pub fn tree_allowed_updates(
  tree: RouterTree(session, error, dependencies),
) -> List(String)

The union of every branch’s derived set. One branch that gives up on narrowing gives up for the whole tree — as does a tree fallback.

pub fn tree_fallback(
  tree: RouterTree(session, error, dependencies),
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> RouterTree(session, error, dependencies)

A handler for updates no branch claimed.

pub fn tree_name(
  tree: RouterTree(session, error, dependencies),
) -> String

The tree’s name: its branch names joined with +.

pub fn tree_registered_commands(
  tree: RouterTree(session, error, dependencies),
) -> List(#(String, String))

The union of every branch’s described commands, sorted by command name. Earlier branches win a duplicate.

pub fn tree_routable(
  tree: RouterTree(session, error, dependencies),
) -> Routable(session, error, dependencies)

Reduce a tree to what the bot needs from it.

pub fn use_middleware(
  router: Router(session, error, dependencies),
  middleware: fn(
    fn(bot.Context(session, error, dependencies), update.Update) -> Result(
      bot.Context(session, error, dependencies),
      error,
    ),
  ) -> fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Router(session, error, dependencies)

Add middleware to the router. The first middleware added is the outermost one: it runs first and sees the handler’s result last.

pub fn use_middleware_on_tree(
  tree: RouterTree(session, error, dependencies),
  middleware: fn(
    fn(bot.Context(session, error, dependencies), update.Update) -> Result(
      bot.Context(session, error, dependencies),
      error,
    ),
  ) -> fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> RouterTree(session, error, dependencies)

Push middleware into every branch. Each branch keeps its own copy, applied around its own handlers, so a branch’s catch handler still sees its errors.

pub fn via_bot() -> Filter

The message was sent through an inline bot.

pub fn via_bot_id(bot_id: Int) -> Filter

The message was sent through the inline bot with this id.

pub fn with_catch_handler(
  router: Router(session, error, dependencies),
  catch_handler: fn(error) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> Router(session, error, dependencies)

Add a catch handler to the router that handles errors from all routes

pub fn with_catch_handler_on_tree(
  tree: RouterTree(session, error, dependencies),
  catch_handler: fn(error) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> RouterTree(session, error, dependencies)

Give every branch the same catch handler. A branch that already has one keeps it.

pub fn with_filter(
  predicate: fn(update.Update) -> Bool,
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> fn(bot.Context(session, error, dependencies), update.Update) -> Result(
  bot.Context(session, error, dependencies),
  error,
)

Filter middleware - only process updates that match predicate

pub fn with_logging(
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> fn(bot.Context(session, error, dependencies), update.Update) -> Result(
  bot.Context(session, error, dependencies),
  error,
)

Logging middleware - logs update processing

pub fn with_rate_limit(
  limit limit: Int,
  window_ms window_ms: Int,
  on_limit on_limit: fn(bot.Context(session, error, dependencies)) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> fn(
  fn(bot.Context(session, error, dependencies), update.Update) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> fn(bot.Context(session, error, dependencies), update.Update) -> Result(
  bot.Context(session, error, dependencies),
  error,
)

Per-user flood control middleware: allows at most limit updates per window_ms window for each {chat_id}:{from_id} pair. Counters live in ETS, so the limit is shared across all routes of the bot.

on_limit is called instead of the handler when the limit is exceeded — pass fn(ctx) { Ok(ctx) } to drop the update silently, or reply from it to inform the user. Every rejected update emits a telega.rate_limit.hit telemetry event.

Updates without user context (e.g. poll updates, from_id is -1) are not limited.

router.new("bot")
|> router.use_middleware(router.with_rate_limit(
  limit: 5,
  window_ms: 3000,
  on_limit: fn(ctx) { Ok(ctx) },
))

Call with_rate_limit once at bot setup: the limiter’s ETS table is owned by the calling process and is deleted when that process exits.

pub fn with_recovery(
  recover: fn(error) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
  handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> fn(bot.Context(session, error, dependencies), update.Update) -> Result(
  bot.Context(session, error, dependencies),
  error,
)

Error recovery middleware

Search Document