telega

Telega — the Telegram Bot API library for the BEAM.

This module is the entry point: it builds a bot, starts its supervision tree, and hands handlers everything they need through a Context.

telega.new(api_client)
|> telega.dependencies(deps)   // optional; must precede `router`
|> telega.session(settings)    // optional; `Nil` session without it
|> telega.router(router)
|> telega.polling(polling.default_settings())  // or `telega.webhook(...)`
|> telega.start()

One constructor, a mode step, one terminal. Bare verbs are the pipeline (dependencies, session, router, router_tree, polling, webhook, start, supervised); every option is a with_*. Coming from 2.x? See the v3 migration guide.

Which module do I want?

ModuleReach for it when
telegabuilding, starting and shutting down the bot; health, dead letters, background contexts
telega/routerdeciding which handler sees an update — routes, filters, middleware, composition
telega/replyanswering the update you are handling (the shortcut layer over api)
telega/apicalling any Bot API method directly
telega/clientHTTP behaviour: retries, rate limits, transformers, a custom endpoint
telega/botpausing a handler mid-run (wait_*), sessions, pre-router middleware, chat-instance settings
telega/flow/buildera persistent, branchy state machine that survives a restart
telega/dialoga screen-like UI in one live message: windows, widgets, sub-dialogs
telega/keyboardbuilding reply and inline keyboards, and typed callback payloads
telega/formatMarkdownV2/HTML text, or entity-based formatting with no escaping
telega/updateinspecting the typed update you were handed
telega/storechat-, user- or bot-wide state that is not the session
telega/scopescratch space that lives exactly as long as one update
telega/jobswork the bot does later, in memory or across restarts
telega/storagewiring a session/flow backend (Postgres, SQLite, Redis, ETS)
telega/errorreacting to a failed API call — error.classify reads the prose for you
telega/telemetrymetrics and tracing for everything above
telega/pollingtuning long polling
telega/webhook_replyanswering an update inside the webhook HTTP response
telega/idempotencydropping updates Telegram re-delivers
telega/rolesadmin/owner checks and guards
telega/paymentsinvoices, Telegram Stars, pre-checkout and shipping answers
telega/inline_modeanswering inline queries, with pagination
telega/media_groupsending albums (and receiving them as one update)
telega/fileuploading and downloading files
telega/broadcastmass messaging with pacing and delivery reports
telega/deep_linkt.me start links and their payloads
telega/chat_actionkeeping “typing…” alive through a long handler
telega/testing/*testing without a network: mock clients, factories, the dialog driver, graph export

Guides

Router · Client · Replies · Conversation · Flows · Dialogs · Sessions · Dependency injection · Testing · Deployment

Types

Builder state: a router (or another handler typed by session / dependencies) is registered, which pins both type parameters.

pub type Configured

Builder state: nothing typed by session or dependencies has been registered yet, so both type parameters are still free to be fixed.

dependencies and session are callable only in this state. That is what makes it impossible for either of them to silently drop a router that was typed against the parameters they replace — the mistake is now a compile error instead of a bot with no routes.

pub type Fresh

What the bot reports about itself, for a /healthz endpoint or a readiness probe.

The three healthy-ish variants carry the same two numbers so a probe can log them whatever the verdict; Unavailable carries none because the bot actor did not answer, which is the whole finding.

pub type Health {
  Healthy(in_flight: Int, chat_instances: Int)
  Draining(in_flight: Int, chat_instances: Int)
  Overloaded(
    in_flight: Int,
    chat_instances: Int,
    max_in_flight: Int,
  )
  Unavailable
}

Constructors

  • Healthy(in_flight: Int, chat_instances: Int)

    The bot answered and is accepting updates.

  • Draining(in_flight: Int, chat_instances: Int)

    A graceful shutdown is under way — updates are being drained, not taken.

  • Overloaded(
      in_flight: Int,
      chat_instances: Int,
      max_in_flight: Int,
    )

    In-flight work has reached the cap set by with_max_in_flight.

  • Unavailable

    The bot actor did not answer within the timeout: dead, wedged, or too busy to reply. All three mean “do not send traffic here”.

pub type ReplayReport {
  ReplayReport(
    replayed: Int,
    failed: List(String),
    unreadable: List(String),
  )
}

Constructors

  • ReplayReport(
      replayed: Int,
      failed: List(String),
      unreadable: List(String),
    )

    Arguments

    replayed

    Updates re-dispatched and handled; their entries were dropped.

    failed

    Keys the bot declined or failed again, left in the queue.

    unreadable

    Keys whose stored payload could not be read back, left in the queue.

pub opaque type Telega(session, error, dependencies)
pub opaque type TelegaBuilder(session, error, dependencies, state)

What the webhook HTTP endpoint should answer for an update processed with handle_update_webhook.

pub type WebhookResponse {
  EmptyResponse
  JsonResponse(body: String)
}

Constructors

  • EmptyResponse

    Answer with an empty 200 OK; every bot API call went (or will go) over HTTP as usual.

  • JsonResponse(body: String)

    Answer with this JSON body ({"method": "...", ...}) and Content-Type: application/json — Telegram executes the embedded API call, saving one HTTP round-trip.

Values

pub fn background_context(
  telega telega: Telega(session, error, dependencies),
  chat_id chat_id: Int,
  user_id user_id: Int,
) -> Result(
  bot.Context(session, error, dependencies),
  error.TelegaError,
)

Build a Context for work happening outside an update: a background job that finished, a cron tick, a webhook from another service.

The context carries the same config, injected dependencies and bot info a handler would see, and the user’s persisted session — so a render or a reply.* call behaves as it does inside an update.

Two things it cannot carry, because there is no chat instance behind it:

  • ctx.update is a placeholder holding only chat_id/from_id (an UnknownUpdate). Anything reading the update’s content sees nothing.
  • wait_* and cancel_conversation_in have no instance to suspend, so they do nothing. Start conversations from a real update.

Fails when the session cannot be read — same rule as a chat instance start: acting on a default here would persist it over the real session.

// Refresh a user's open dialog once their export finishes.
let assert Ok(ctx) = telega.background_context(bot, chat_id:, user_id:)
let _ = dialog.refresh(ctx, registry, dialog_id: "export")
pub fn cancel_conversation(
  telega telega: Telega(session, error, dependencies),
  key key: String,
) -> Nil

Cancel the conversation a chat is currently waiting in.

key is the session key of the chat instance — the "{chat_id}:{from_id}" string available in any handler as ctx.key. The pending wait_* continuation is dropped, so the next update from that chat is routed normally again. The chat instance itself and its session are untouched, and cancelling a chat that is not waiting for anything does nothing.

pub fn constant_time_compare(left: String, right: String) -> Bool

Compare two secrets without giving away where they first differ.

== on binaries stops at the first differing byte, so the response time reveals how long a shared prefix is and the secret can be guessed one byte at a time. Use this in your own webhook adapter for any value an attacker gets to retry.

pub fn dead_letters(
  telega: Telega(session, error, dependencies),
) -> Result(#(List(dead_letter.DeadLetter), List(String)), String)

Read the dead-letter queue without touching it.

Error when the bot has no queue configured or the backend could not be read; the second element of the pair lists entries that would not decode.

pub const default_health_timeout: Int

Default time (ms) health waits for the bot actor to answer.

pub fn dependencies(
  builder builder: TelegaBuilder(
    session,
    error,
    old_dependencies,
    Fresh,
  ),
  dependencies dependencies: dependencies,
) -> TelegaBuilder(session, error, dependencies, Fresh)

Inject typed, non-persisted dependencies (services) available in every handler via ctx.dependencies (or get_dependencies).

Use this for things that are not user state and must not be persisted — a database pool, an http client, an i18n catalog, configuration. The rule of thumb: session is the user’s state (persisted), dependencies is the bot’s services (set once at startup, never persisted).

Only callable while the builder is Fresh, i.e. before router or any other handler that is typed against dependencies. Calling it later is a compile error rather than a silently dropped router.

telega.new(api_client)
|> telega.dependencies(Dependencies(db:, catalog:))
|> telega.router(router)
|> telega.start()
pub fn drop_dead_letter(
  telega: Telega(session, error, dependencies),
  key key: String,
) -> Result(Nil, String)

Forget one dead letter by its storage key, without replaying it.

pub fn get_api_config(
  telega: Telega(session, error, dependencies),
) -> client.TelegramClient

Helper to get the config for API requests.

pub fn get_dependencies(
  ctx: bot.Context(session, error, dependencies),
) -> dependencies

Get the injected dependencies (services) for the current context.

dependencies is set once at bot init via with_dependencies and is never persisted. See the session vs dependencies distinction in with_dependencies.

pub fn get_me(
  telega: Telega(session, error, dependencies),
) -> types.User

Get the bot’s information.

pub fn get_session(
  ctx: bot.Context(session, error, dependencies),
) -> session

Get session for the current context.

pub fn get_supervisor_pid(
  telega: Telega(session, error, dependencies),
) -> process.Pid

Get the supervisor PID for the running bot instance.

pub fn handle_update(
  telega: Telega(session, error, dependencies),
  raw_update: types.Update,
) -> Bool

Handle an update.

This function is useful when you want to handle updates in your own way.

pub fn handle_update_webhook(
  telega telega: Telega(session, error, dependencies),
  update raw_update: types.Update,
  timeout timeout: Int,
) -> WebhookResponse

Handle an update, allowing the handler to answer it directly in the webhook HTTP response body (webhook reply).

Waits up to timeout ms for the handler to either claim an eligible API call (see telega/webhook_reply) or finish. On a claim it returns JsonResponse for the adapter to send back; otherwise EmptyResponse. After the timeout the handler keeps running in the background and all its API calls go over regular HTTP.

Pick a timeout safely below Telegram’s webhook timeout — e.g. 5000 ms. Adapters expose this as handle_bot_with_reply; use this function directly only when implementing your own adapter.

⚠️ A claimed call resolves to a synthetic stub in the handler: True for boolean methods, a fake Message (message_id: -1, date: 0) for sendMessage. Full guide in the telega/webhook_reply module docs.

pub fn health(
  telega: Telega(session, error, dependencies),
) -> Health

Ask the running bot how it is doing.

The answer comes from the bot actor’s own mailbox, so an actor that has died or wedged reports Unavailable rather than a stale snapshot — which is exactly what a load balancer needs to know.

case telega.health(bot) {
  telega.Healthy(..) -> wisp.ok()
  _ -> wisp.response(503)
}

The webhook adapters (telega_wisp.handle_health, telega_mist.handle_health) wrap this into a ready-made /healthz endpoint.

pub fn health_status_code(health: Health) -> Int

The HTTP status a health endpoint should answer with: 200 when healthy, 503 otherwise.

pub fn health_to_json(health: Health) -> String

A JSON body for a health endpoint: {"status":"healthy","in_flight":3,"chat_instances":41}.

status is one of healthy, draining, overloaded, unavailable.

pub fn health_within(
  telega: Telega(session, error, dependencies),
  timeout: Int,
) -> Health

Like health, with an explicit timeout (ms) for the actor’s reply.

pub fn is_draining(
  telega: Telega(session, error, dependencies),
) -> Bool

Whether the bot is currently draining and no longer accepting updates.

Webhook adapters should answer 503 when this is True so Telegram retries the update after the deploy instead of it being dropped.

pub fn is_healthy(health: Health) -> Bool

Whether the bot is ready to take another update.

pub fn is_secret_token_valid(
  telega: Telega(session, error, dependencies),
  token: String,
) -> Bool

Check if a secret token is valid.

Useful if you plan to implement own adapter.

pub fn is_webhook_path(
  telega: Telega(session, error, dependencies),
  path: String,
) -> Bool

Check if a path is the webhook path for the bot.

Useful if you plan to implement own adapter.

pub fn log_context(
  ctx: bot.Context(session, error, dependencies),
  prefix: String,
  fun: fn(bot.Context(session, error, dependencies)) -> Result(
    bot.Context(session, error, dependencies),
    error,
  ),
) -> Result(bot.Context(session, error, dependencies), error)

Run fun with a logging context: every log line it writes is prefixed with prefix and carries the update’s identifiers as structured logger metadata.

The metadata fields are telega_prefix, chat_id, from_id, update_id and session_key. They are set as Erlang logger process metadata, so they reach any handler or formatter that reads it — including log lines written by libraries that know nothing about telega:

%% sys.config
{kernel, [{logger, [{handler, default, logger_std_h,
  #{formatter => {logger_formatter,
    #{template => [time," ",level," chat=",chat_id," upd=",update_id," ",msg,"\n"],
      single_line => true}}}}]}]}

The previous metadata is restored when fun returns, so nested contexts stack and unwind.

use ctx <- telega.log_context(ctx, "checkout")
telega.log_info(ctx, "starting")
reply.text(ctx, "One moment…")
pub fn log_error(
  ctx: bot.Context(session, error, dependencies),
  message: String,
) -> Nil
pub fn log_info(
  ctx: bot.Context(session, error, dependencies),
  message: String,
) -> Nil

Context helpers for logging

pub fn new(
  api_client api_client: client.TelegramClient,
) -> TelegaBuilder(Nil, error, Nil, Fresh)

Start building a bot from an API client.

The client comes from an adapter package like telega_httpc or telega_hackney. The builder starts out in long-polling mode with a Nil session and no injected dependencies; polling, webhook, session and dependencies change that.

telega.new(api_client)
|> telega.dependencies(Dependencies(db:, catalog:))
|> telega.session(session_settings)
|> telega.router(router)
|> telega.start()

dependencies and session fix type parameters the router is typed against, so they only compile before router (see Fresh).

pub fn polling(
  builder: TelegaBuilder(session, error, dependencies, state),
  settings settings: polling.PollingSettings,
) -> TelegaBuilder(session, error, dependencies, state)

Receive updates by long polling (the default) with explicit settings.

telega.new(api_client)
|> telega.router(router)
|> telega.polling(polling.PollingSettings(
  ..polling.default_settings(),
  limit: 10,
))
|> telega.start()

A builder that calls neither polling nor webhook polls with polling.default_settings().

pub fn replay_dead_letters(
  telega: Telega(session, error, dependencies),
) -> Result(ReplayReport, String)

Feed every stored dead letter back through the bot, dropping each entry the bot handles.

Run it once the bug that crashed the handler is fixed — a replayed update that crashes again is dead-lettered afresh under the same key, so the queue does not grow. Updates are replayed oldest update_id first, one at a time, and each is dispatched exactly like an update arriving from Telegram (the same routing, the same session, the same pre-router middleware). An update the bot declines (False) keeps its entry.

This is deliberately a manual operation: replaying a queue full of updates the users have long since moved past is rarely what you want automatically.

pub fn router(
  builder: TelegaBuilder(session, error, dependencies, state),
  router router_: router.Router(session, error, dependencies),
) -> TelegaBuilder(session, error, dependencies, Configured)

Set the router that handles updates.

This is the primary way to handle updates — build one with router.new() and register commands, text handlers, middleware on it. For a composition built with router.compose/router.branch, use router_tree.

pub fn router_tree(
  builder: TelegaBuilder(session, error, dependencies, state),
  tree tree: router.RouterTree(session, error, dependencies),
) -> TelegaBuilder(session, error, dependencies, Configured)

Set a composed router.RouterTree that handles updates.

let tree =
  router.tree()
  |> router.branch(router.is_private_chat(), private_router)
  |> router.branch(router.is_group_chat(), group_router)

telega.new(api_client)
|> telega.router_tree(tree)
pub fn session(
  builder builder: TelegaBuilder(
    old_session,
    error,
    dependencies,
    Fresh,
  ),
  settings settings: bot.SessionSettings(session, error),
) -> TelegaBuilder(session, error, dependencies, Fresh)

Give the bot a persisted session.

Without this call the bot runs on the stateless Nil session — there is no separate “nil session” constructor to remember. Storage adapters build the settings for you (storage.session_settings_from_storage and friends).

Like dependencies, it fixes a type parameter the router is typed against, so it only compiles while the builder is Fresh.

telega.new(api_client)
|> telega.session(storage.session_settings_from_storage(
  storage:,
  encode: encode_session,
  decode: session_decoder(),
  default: fn() { Session(count: 0) },
))
|> telega.router(router)
|> telega.start()
pub fn shutdown(
  telega: Telega(session, error, dependencies),
) -> Nil

Graceful shutdown with in-flight draining.

  1. Emits [telega, shutdown, start].
  2. Stops intake — for polling, tells the worker to stop fetching updates (Telegram re-delivers unconfirmed updates on the next start); for webhook, the bot starts rejecting updates and is_draining reports True so adapters can answer 503.
  3. Waits up to drain_timeout for in-flight updates to finish.
  4. Runs the on_shutdown hook.
  5. Emits [telega, shutdown, stop] with the number of drained updates.
  6. Stops the supervisor, cascading to all children (polling → bot → chat_factory).
pub fn start(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> Result(
  Telega(session, error, dependencies),
  error.TelegaError,
)

Start the bot.

Builds the supervision tree (chat instance factory → bot, plus a polling worker in polling mode) and, in webhook mode, registers the webhook with Telegram. Fails when the router is missing, the token is rejected, or a child cannot start.

let assert Ok(bot) =
  telega.new(api_client)
  |> telega.router(router)
  |> telega.start()
pub fn start_polling_default(
  telega: Telega(session, error, dependencies),
) -> Result(polling.Poller, error.TelegaError)

Start polling with default configuration for a Telega instance. This is useful when you want to manually start polling outside the supervision tree.

pub fn supervised(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> supervision.ChildSpecification(
  Telega(session, error, dependencies),
)

Run the bot as a child of your own supervision tree.

Wraps start into a ChildSpecification: telega still builds and owns its internal chat factory → bot (→ polling) tree, but that tree’s root becomes a child of YOUR supervisor. If the bot tree dies, your supervisor restarts it by re-running startsetWebhook (webhook mode), getMe and a fresh internal tree.

Add it after the resources the bot depends on (database pool, caches) — with a RestForOne strategy a dead dependency restarts the bot too:

let bot_ready = process.new_subject()
let bot_child =
  telega.new(api_client)
  |> telega.webhook(url:, path:, secret_token:)
  |> telega.router(router)
  |> telega.with_on_start(fn(bot) { Ok(process.send(bot_ready, bot)) })
  |> telega.supervised()

let assert Ok(_) =
  static_supervisor.new(static_supervisor.RestForOne)
  |> static_supervisor.add(db_pool_child)
  |> static_supervisor.add(bot_child)
  |> static_supervisor.start

// Webhook adapters need the instance — receive it from the hook.
let assert Ok(bot) = process.receive(bot_ready, within: 10_000)

The started Telega instance is the child’s data; supervisors don’t hand child data back, so capture it in with_on_start as above when you need it outside the tree (webhook adapters, manual shutdown). Stopping your tree stops the bot the standard OTP way (no drain); for a drained stop use shutdown or with_signal_handlers.

pub fn use_pre_handler(
  builder: TelegaBuilder(session, error, dependencies, state),
  pre_handler: fn(bot.PreContext(dependencies)) -> bot.PreRouterResult,
) -> TelegaBuilder(session, error, dependencies, Configured)

Register a global pre-router middleware (bot.PreHandler).

Pre-router middleware runs once per update inside the bot actor, before routing and before any chat instance is spawned or session loaded. Use it for cross-cutting concerns that apply to every update: anti-spam, analytics, and update deduplication. Returning bot.Stop drops the update before routing; bot.Continue lets it through to the next pre-handler and the router. Handlers run in the order they are registered, and the first Stop short-circuits the rest. Because they all run sequentially in the single bot actor, read-then-write logic (like dedup) is race-free across updates.

// Drop updates from a banned chat before they reach any handler.
telega.new(api_client)
|> telega.use_pre_handler(fn(pre) {
  case pre.update.chat_id == banned_chat {
    True -> bot.Stop
    False -> bot.proceed()
  }
})
|> telega.router(router)

// Webhook idempotency: drop updates Telegram re-delivers on retry.
|> telega.use_pre_handler(idempotency.deduplicate(storage:, ttl_ms: 3600_000))
pub fn wait_any(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue handler: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for any update. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_audio(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    types.Audio,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for an audio message. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_callback_query(
  ctx ctx: bot.Context(session, error, dependencies),
  filter filter: option.Option(bot.CallbackQueryFilter),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    String,
    String,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a callback query. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_choice(
  ctx ctx: bot.Context(session, error, dependencies),
  text text: String,
  options options: List(#(String, a)),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    a,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Wait for user choice from inline keyboard.

This function sends text with an inline keyboard built from options and waits for user to select one.

If the prompt cannot be sent (network error, bot blocked, empty text), the error is logged and the conversation is not started: the function returns Ok(ctx) instead of waiting for a press that can never come.

Examples

use ctx, color <- wait_choice(
  ctx,
  text: "Pick a color",
  options: [
    #("🔴 Red", Red),
    #("🔵 Blue", Blue),
    #("🟢 Green", Green),
  ],
  or: None,
  timeout: None,
)

See conversation

pub fn wait_command(
  ctx ctx: bot.Context(session, error, dependencies),
  command command: String,
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    update.Command,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a specific command. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_commands(
  ctx ctx: bot.Context(session, error, dependencies),
  commands commands: List(String),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    update.Command,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for one of the specified commands. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_email(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    String,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Wait for email with validation.

This function waits for user to send text that matches email pattern.

If validation fails and or handler is provided, it will be called. Otherwise, the function will keep waiting for valid input.

Examples

use ctx, email <- wait_email(
  ctx,
  or: Some(bot.HandleText(fn(ctx, invalid) {
    reply.with_text(ctx, "Invalid email format. Try again.")
  })),
  timeout: None,
)

See conversation

pub fn wait_filtered(
  ctx ctx: bot.Context(session, error, dependencies),
  filter filter: router.Filter,
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for an update that matches a composable router.Filter.

Unlike the typed waiters (wait_text, wait_photos, …) which each listen for a single update type, this accepts the router’s filter combinators, so a single continuation can wait for several types at once. Combine with router.or/router.or2 (any), router.and/router.and2 (all) and router.not:

use ctx, upd <- wait_filtered(
  ctx,
  filter: router.or2(router.is_text(), router.has_photo()),
  or: None,
  timeout: None,
)
case upd {
  update.TextUpdate(text:, ..) -> // ...
  update.PhotoUpdate(photos:, ..) -> // ...
  _ -> // ...
}

wait_for remains the escape hatch for a raw fn(Update) -> Bool predicate.

pub fn wait_for(
  ctx ctx: bot.Context(session, error, dependencies),
  filter filter: fn(update.Update) -> Bool,
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    update.Update,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Wait for update matching custom filter.

This function waits for any update that passes the provided filter function.

Examples

use ctx, photo_update <- wait_for(
  ctx,
  filter: fn(upd) {
    case upd {
      update.PhotoUpdate(..) -> True
      _ -> False
    }
  },
  or: Some(bot.HandleAll(fn(ctx, wrong_update) {
    reply.with_text(ctx, "Please send a photo")
  })),
  timeout: Some(60_000),
)

See conversation

pub fn wait_hears(
  ctx ctx: bot.Context(session, error, dependencies),
  hears hears: bot.Hears,
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    String,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a message that matches the given Hears. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_message(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    types.Message,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for any message. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_number(
  ctx ctx: bot.Context(session, error, dependencies),
  min min: option.Option(Int),
  max max: option.Option(Int),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    Int,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Wait for a number with validation.

This function waits for user to send text that can be parsed as an integer, with optional min/max validation.

If validation fails and or handler is provided, it will be called. Otherwise, the function will keep waiting for valid input.

Examples

use ctx, age <- wait_number(
  ctx,
  min: Some(0),
  max: Some(120),
  or: Some(bot.HandleText(fn(ctx, invalid) {
    reply.with_text(ctx, "Please enter age between 0 and 120")
  })),
  timeout: None,
)

See conversation

pub fn wait_photos(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    List(types.PhotoSize),
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for photos. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_text(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    String,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a text message. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_video(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    types.Video,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a video message. Other chats and users continue to be handled concurrently.

See conversation

pub fn wait_voice(
  ctx ctx: bot.Context(session, error, dependencies),
  or handle_else: option.Option(
    bot.Handler(session, error, dependencies),
  ),
  timeout timeout: option.Option(Int),
  continue continue: fn(
    bot.Context(session, error, dependencies),
    types.Voice,
  ) -> Result(bot.Context(session, error, dependencies), error),
) -> Result(bot.Context(session, error, dependencies), error)

Pauses the current chat actor’s handler and waits for a voice message. Other chats and users continue to be handled concurrently.

See conversation

pub fn webhook(
  builder: TelegaBuilder(session, error, dependencies, state),
  url server_url: String,
  path webhook_path: String,
  secret_token secret_token: option.Option(String),
) -> TelegaBuilder(session, error, dependencies, state)

Receive updates through a webhook, registered with setWebhook on start.

url is the public base URL of your server and path the route your web adapter serves; Telegram is told to POST to url <> "/" <> path. A secret_token of None generates a random one — adapters compare it for you (is_secret_token_valid).

telega.new(api_client)
|> telega.webhook(
  url: "https://bot.example.com",
  path: "webhook",
  secret_token: Some(secret),
)
|> telega.router(router)
|> telega.start()
pub fn with_allowed_updates(
  builder: TelegaBuilder(session, error, dependencies, state),
  updates updates: List(String),
) -> TelegaBuilder(session, error, dependencies, state)

Restrict the update types Telegram sends, by hand.

Always wins over with_auto_allowed_updates — this is the escape hatch for when derivation is not what you want. A name this Bot API version does not have is kept (Telegram is the authority) but logged, since the usual reason for one is a typo that quietly costs the bot an update kind.

pub fn with_auto_allowed_updates(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> TelegaBuilder(session, error, dependencies, state)

Derive allowed_updates from the router’s registered routes.

Telegram then sends only the update types the bot actually handles, cutting out traffic for routes you never registered. A manual with_allowed_updates always wins (the escape hatch). If the router has a fallback, custom, or filtered route — which can match anything — derivation can’t narrow safely and falls back to Telegram’s default update set.

pub fn with_auto_commands(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> TelegaBuilder(session, error, dependencies, state)

Publish the router’s commands to Telegram on start.

Every command registered with router.on_command_with_description is sent via setMyCommands once the bot is up, so the Telegram client shows them in the command menu without a manual call. Commands added with plain router.on_command (no description) are not published.

For localized descriptions use with_command_translations instead — it turns this on as well.

telega.new(api_client)
|> telega.router(router)
|> telega.with_auto_commands()
|> telega.start()
pub fn with_catch_handler(
  builder: TelegaBuilder(session, error, dependencies, state),
  catch_handler: fn(
    bot.Context(session, error, dependencies),
    error,
  ) -> Result(Nil, error),
) -> TelegaBuilder(session, error, dependencies, Configured)

Set the catch handler for system errors (like session persistence failures) and conversation errors.

This is different from the router’s catch handler, which handles route errors.

pub fn with_certificate(
  builder: TelegaBuilder(session, error, dependencies, state),
  certificate certificate: types.File,
) -> TelegaBuilder(session, error, dependencies, state)

Upload a self-signed certificate along with the webhook.

pub fn with_chat_hibernate_after(
  builder: TelegaBuilder(session, error, dependencies, state),
  after after: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Compact a chat instance’s heap once it has been quiet for after milliseconds (default: bot.default_hibernate_after, one minute).

An instance that handled a burst of messages keeps the heap that burst grew until it is evicted. One full garbage collection after the chat goes quiet gives it back, which for a bot holding many idle instances is the difference between kilobytes and tens of kilobytes each.

pub fn with_chat_idle_timeout(
  builder: TelegaBuilder(session, error, dependencies, state),
  timeout timeout: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Stop chat instances that have been idle for timeout milliseconds.

One ChatInstance process is started per {chat_id}:{from_id}, so a bot used by many distinct people accumulates one process (plus its session and any suspended conversation) per person. They are evicted after half an hour of silence by default — bot.default_chat_idle_timeout — and this changes that bound.

An instance that has received nothing for that long is deregistered and stopped by the bot actor. The next update from that user simply starts a fresh instance, which re-reads the session from storage — so nothing persisted is lost.

A pending conversation (wait_*) lives only in the instance’s memory, so it is dropped along with the instance. Pick an idle timeout comfortably larger than the conversation timeouts you use.

telega.new(api_client)
|> telega.router(router)
// reclaim a user's process after five minutes of silence
|> telega.with_chat_idle_timeout(1000 * 60 * 5)
|> telega.start()
pub fn with_chat_init_timeout(
  builder: TelegaBuilder(session, error, dependencies, state),
  timeout timeout: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Set how long (ms) a chat instance may take to start, which includes loading its session from storage (default: 10 000).

pub fn with_chat_restart_tolerance(
  builder: TelegaBuilder(session, error, dependencies, state),
  intensity intensity: Int,
  period period: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Set how tolerant the chat instance factory supervisor is of restarts: at most intensity restarts within period seconds (default: 5 in 10).

pub fn with_command_translations(
  builder: TelegaBuilder(session, error, dependencies, state),
  locales locales: List(String),
  translate translate: fn(String, String) -> option.Option(String),
) -> TelegaBuilder(session, error, dependencies, state)

Publish localized command descriptions on start.

Implies with_auto_commands: the default-language commands are published first, then for every locale in locales a setMyCommands(language_code:) call is made. translate(command, locale) supplies the per-language text; returning None keeps the router’s default description for that command.

telega_i18n provides a convenience wrapper that builds translate from a translation catalog, so you usually call this through it.

telega.new(api_client)
|> telega.router(router)
|> telega.with_command_translations(
  locales: ["en", "ru"],
  translate: fn(command, locale) { lookup_description(command, locale) },
)
|> telega.start()
pub fn with_dead_letters(
  builder: TelegaBuilder(session, error, dependencies, state),
  letters letters: dead_letter.DeadLetters,
) -> TelegaBuilder(session, error, dependencies, state)

Keep updates whose chat instance crashed, so a panic costs a log line and a stored update instead of the update itself.

See telega/dead_letter for what is stored and replay_dead_letters for feeding them back in.

telega.new(api_client)
|> telega.router(router)
|> telega.with_dead_letters(storage.dead_letters_from_storage(
  storage,
  retention_ms: Some(7 * 24 * 60 * 60 * 1000),
))
|> telega.start()
pub fn with_drain_timeout(
  builder: TelegaBuilder(session, error, dependencies, state),
  timeout timeout: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Set the maximum time (in milliseconds) shutdown waits for in-flight updates to finish before forcibly stopping the supervision tree.

Defaults to 5000ms.

pub fn with_drop_pending_updates(
  builder: TelegaBuilder(session, error, dependencies, state),
  drop drop: Bool,
) -> TelegaBuilder(session, error, dependencies, state)

Drop the updates Telegram accumulated while the bot was down (webhook mode).

pub fn with_extra_allowed_updates(
  builder: TelegaBuilder(session, error, dependencies, state),
  updates updates: List(String),
) -> TelegaBuilder(session, error, dependencies, state)

Add update types to the auto-derived allowed_updates.

Derivation only sees the router. Updates a conversation or a flow waits for are invisible to it: a bot whose router registers only commands, but whose handlers use wait_callback, derives ["message"] — and then waits forever for a callback_query Telegram was never asked to send.

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

Has no effect when derivation already returns “do not restrict” (a router with a fallback, custom or filtered route), and none when with_allowed_updates set the list manually.

pub fn with_ip_address(
  builder: TelegaBuilder(session, error, dependencies, state),
  ip ip: String,
) -> TelegaBuilder(session, error, dependencies, state)

Set the fixed IP address Telegram sends webhook updates to.

pub fn with_max_connections(
  builder: TelegaBuilder(session, error, dependencies, state),
  max max: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Set the maximum number of simultaneous webhook connections Telegram opens.

pub fn with_max_in_flight(
  builder: TelegaBuilder(session, error, dependencies, state),
  limit limit: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Cap how much work the bot admits to before it reports itself overloaded.

The bot actor counts updates currently being handled by chat instances. Once that count reaches limit, health answers Overloaded and the webhook adapters (telega_wisp, telega_mist) answer 503 — so Telegram backs off and redelivers instead of piling more work onto a bot that is already behind. Long polling needs no such cap: its worker already stops fetching while limit updates are in flight.

Pick it from what the bot can actually keep up with; there is no useful default, so without this call overload is never reported.

telega.new(api_client)
|> telega.router(router)
|> telega.webhook(url:, path:, secret_token:)
|> telega.with_max_in_flight(500)
|> telega.start()
pub fn with_media_group_timeout(
  builder: TelegaBuilder(session, error, dependencies, state),
  timeout timeout: Int,
) -> TelegaBuilder(session, error, dependencies, state)

Gather the messages of an album into a single MediaGroupUpdate.

Telegram delivers an album as separate messages that share a media_group_id, so without this setting they arrive one by one on on_photo / on_video / on_audio and router.on_media_group never fires. With it, a chat instance holds them back until timeout milliseconds pass without another message of the same album (1000 is a good starting point) and then routes them together.

The individual messages are not delivered as well — a bot that turns this on handles albums in on_media_group and single media in on_photo and friends. Messages that arrive while a wait_* conversation is pending are left alone, since the waiting handler expects them one at a time.

telega.new(api_client)
|> telega.router(router)
|> telega.with_media_group_timeout(1000)
|> telega.start()
pub fn with_on_shutdown(
  builder: TelegaBuilder(session, error, dependencies, state),
  on_shutdown on_shutdown: fn() -> Nil,
) -> TelegaBuilder(session, error, dependencies, state)

Set a hook to run during shutdown, after in-flight updates have drained and before the supervision tree is stopped. Use it to release resources (close pools, flush buffers, deregister from a service discovery, …).

pub fn with_on_start(
  builder: TelegaBuilder(session, error, dependencies, state),
  on_start on_start: fn(Telega(session, error, dependencies)) -> Result(
    Nil,
    error.TelegaError,
  ),
) -> TelegaBuilder(session, error, dependencies, Configured)

Set a hook to run once the bot has fully started.

Runs after the supervision tree is up and the Telega instance is built, so you can use it for warming caches, registering commands via the API, etc. Returning Error aborts startup and tears the supervision tree back down.

telega.new(api_client)
|> telega.router(router)
|> telega.with_on_start(fn(bot) {
  // register commands, warm caches...
  Ok(Nil)
})
|> telega.start()
pub fn with_session_key(
  builder: TelegaBuilder(session, error, dependencies, state),
  key key: fn(update.Update) -> String,
) -> TelegaBuilder(session, error, dependencies, state)

Choose what an update is keyed by — its session and the chat instance that handles it.

The default is bot.default_session_key: "{chat_id}:{from_id}", one session per user per chat. bot.chat_session_key gives a group one shared session (and one instance, so members are serialized through it); bot.user_session_key follows a user across chats. Anything else is a function of the update:

// One session per forum topic rather than per chat.
telega.with_session_key(builder, fn(update) {
  case update.thread_id {
    Some(thread) -> int.to_string(update.chat_id) <> ":t" <> int.to_string(thread)
    None -> bot.default_session_key(update)
  }
})

Two updates that map to the same key share one process and one session, so the key decides both concurrency and isolation. The trade-offs of the built-in keys — anonymous group admins, inline-message callbacks, updates with no user — are in docs/session-serialization.md.

pub fn with_session_load_error(
  builder: TelegaBuilder(session, error, dependencies, state),
  policy policy: bot.SessionLoadError,
) -> TelegaBuilder(session, error, dependencies, state)

Choose what happens when the session cannot be read from storage.

The default is bot.FailUpdate: the chat instance refuses to start and the update is reported unhandled, because serving it on a default session would let the first handler persist that default over the real, still-stored data. bot.ReadOnly keeps the bot answering while the backend is down (handlers run on the default session, every write is skipped with a warning); bot.UseDefault is the old, lossy behaviour — pick it only when losing a session costs less than dropping the update.

telega.new(api_client)
|> telega.session(settings)
|> telega.with_session_load_error(bot.ReadOnly)
pub fn with_session_persistence(
  builder: TelegaBuilder(session, error, dependencies, state),
  persistence persistence: bot.SessionPersistence,
) -> TelegaBuilder(session, error, dependencies, state)

Choose whether a session no handler changed is still written back.

The default is bot.PersistOnChange: a handler that returns the session it was given costs no storage write at all, which for a bot whose handlers mostly read is most of its writes. Switch to bot.PersistAlways when the write itself does something you rely on — refreshing an expiry, touching a “last seen” column.

telega.new(api_client)
|> telega.session(settings)
|> telega.with_session_persistence(bot.PersistAlways)
pub fn with_signal_handlers(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> TelegaBuilder(session, error, dependencies, state)

Install an OS signal handler (SIGTERM) that runs a graceful shutdown and then halts the VM.

This makes the bot survive rolling deploys on platforms like fly.io or Kubernetes: on SIGTERM the bot stops accepting new updates, drains in-flight work (bounded by with_drain_timeout), runs the on_shutdown hook, and stops cleanly. The handler replaces the runtime’s default signal behavior.

Only SIGTERM is handled — BEAM reserves SIGINT for its interactive break handler, so it cannot be intercepted this way.

pub fn without_chat_hibernation(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> TelegaBuilder(session, error, dependencies, state)

Never compact an idle chat instance’s heap.

pub fn without_chat_idle_timeout(
  builder: TelegaBuilder(session, error, dependencies, state),
) -> TelegaBuilder(session, error, dependencies, state)

Keep every chat instance alive for as long as the bot runs.

This undoes the default half-hour eviction. Only worth it when a bot serves a small, known set of chats and wants their in-memory conversations to survive any amount of silence — an open-ended process per user is exactly how a busy bot runs out of BEAM processes.

Search Document