Telega

Package Version Hex Docs

A Gleam library for the Telegram Bot API on BEAM.

Bot API version: Bot API 10.3 (the model layer, the per-method retry table and the update-kind table are generated from the vendored spec codegen/api.json).

Telega

It provides

Quick start

If you are new to Telegram bots, read the official Introduction for Developers written by the Telegram team.

First, visit @BotFather to create a new bot. Copy the token and save it for later.

Initiate a gleam project and add telega as a dependency:

$ gleam new first_tg_bot
$ cd first_tg_bot
$ gleam add telega gleam_erlang telega_httpc envoy

Replace the first_tg_bot.gleam file content with the following code:

import envoy
import gleam/erlang/process
import telega
import telega/reply
import telega/router
import telega/update
import telega_httpc

fn handle_text(ctx, text) {
  use ctx <- telega.log_context(ctx, "echo_text")
  // `reply.text` sends the message and hands the context back, so a handler
  // that only replies is one line.
  reply.text(ctx, text)
}

fn handle_command(ctx, command: update.Command) {
  use ctx <- telega.log_context(ctx, "echo_command")
  reply.text(ctx, "Command: " <> command.text)
}

pub fn main() {
  let router = router.new("echo_bot")
  |> router.on_any_text(handle_text)
  |> router.on_commands(["start", "help"], handle_command)

  // Never hardcode the token: anyone who reads your repository can take over
  // the bot with it.
  let assert Ok(token) = envoy.get("BOT_TOKEN")
  let client = telega_httpc.new(token)

  let assert Ok(_bot) =
    telega.new(client)
    |> telega.router(router)
    |> telega.start()

  process.sleep_forever()
}

Put the token from BotFather in the environment and run the bot:

$ export BOT_TOKEN="123456:your-token-here"
$ gleam run

And it will echo all received text messages.

Congratulations! You just wrote a Telegram bot :)

The builder is one constructor plus a mode step: telega.new(api_client), then telega.polling(...) (the default) or telega.webhook(url:, path:, secret_token:), then telega.start(). Optional services and session go in before the router (telega.dependencies, telega.session), and everything else is a with_* setting. Coming from 2.x? See the v3 migration guide.

Webhook instead of long polling

Long polling needs no public address, which is why the quick start uses it. In production you usually want a webhook: Telegram POSTs each update to your server, so there is no idle request holding a connection open. Swap the mode step and serve the path with an adapter:

import telega
import telega_wisp
import wisp

fn handle_request(bot, req) {
  use <- telega_wisp.handle_health(telega: bot, req:, path: telega_wisp.default_health_path)
  use <- telega_wisp.handle_bot(telega: bot, req:)
  wisp.not_found()
}

pub fn main() {
  let assert Ok(bot) =
    telega.new(client)
    |> telega.webhook(
      url: "https://bot.example.com",   // your public base URL
      path: "webhook",                  // Telegram POSTs to <url>/<path>
      secret_token: Some(secret),       // None generates one for you
    )
    |> telega.router(router)
    |> telega.start()                   // calls setWebhook for you

  // ... start wisp/mist with `handle_request(bot, _)`
}

telega.start() registers the webhook with Telegram; the adapter validates the secret token on every request and answers 503 while the bot is draining or overloaded, so Telegram redelivers instead of losing the update. Two things are worth adding on a webhook: telega/idempotency (Telegram retries an update it did not get a 200 for) and handle_health above (a readiness probe your load balancer can use). Both, plus TLS, deploys and drain behaviour, are covered in the deployment guide; example 09-webhook-wisp is the whole thing wired up.

Architecture

Calling telega.start() starts an OTP supervision tree:

TelegaRootSupervisor (OneForOne)
├── ChatInstances (factory_supervisor, Transient children)
│   ├── ChatInstance {chat1:user1}
│   ├── ChatInstance {chat2:user2}
│   └── ...
├── Bot actor (Permanent)
└── Polling worker (Permanent) — only in polling mode

Each telega.start() call creates an independent tree with its own ETS registry, so multiple bot instances don’t conflict.

Running under your own supervision tree

telega.supervised wraps telega.start into a ChildSpecification, so the bot’s tree becomes a child of your application’s supervisor — a crashed bot is re-initialized by your tree, and ordering against the resources it needs (a database pool, caches) is expressed as child order:

import gleam/otp/static_supervisor as supervisor

let assert Ok(_) =
  supervisor.new(supervisor.RestForOne)
  |> supervisor.add(db_pool_child)
  |> supervisor.add(
    telega.new(api_client)
    |> telega.router(router)
    |> telega.supervised(),
  )
  |> supervisor.start

Supervisors don’t hand child data back; when you need the Telega instance outside the tree (webhook adapters, manual shutdown), capture it from the with_on_start hook — see the telega.supervised documentation for the pattern.

Graceful shutdown

telega.shutdown(bot)

Sends an OTP shutdown signal to the root supervisor, which stops children in reverse start order (polling → bot → chat factory).

Multi-step interactions

Four layers, from “ask one question” to “a screen the user navigates”:

ConversationsFlowsDialogsMenu builder
What it isa handler that pauses mid-runa persistent state machinea set of windows compiled into a flowa keyboard builder (deprecated)
UI modelthe bot sends messagesyou send and edit them yourselfone live message, auto edit-or-sendone menu message
Survives a restartno (in-memory continuation)yes (storage backend)yes (via flow)no
Back navigationnoBack action, by handbuilt inbuilt in
Callback datamanualmanualgenerated and validated (64 bytes)generated
Reusable selects / paginationnonowidgets (pager, radio, multiselect, calendar, …)pagination
Compositionnested callssubflowssub-dialogs, typed resultsnested menus
Reach for it whena quick Q&A: “what is your name?”a branchy process with hand-written messagesa screen-like UI: settings, wizards, catalogs— use a dialog
// Conversation: pause inside a handler.
use ctx, name <- bot.wait_text(ctx, or: None, timeout: None)

// Flow: a named step that parks until the next update.
builder.add_step(AskName, fn(ctx, instance) { action.wait(ctx, instance) })

// Dialog: a window that renders itself and reacts to presses.
dialog.window(id: "menu", render: render_menu, on_action: handle_menu)

menu_builder is deprecated: a dialog window with widget.select or widget.paged_select does the same thing and keeps its state.

Who gets the update

Exactly one of these handles any given update, in this order:

  1. A pending conversation continuation. The chat instance checks its own wait_* continuation before it routes anything. A command, and a pre-checkout or shipping query, that the wait did not ask for falls through to the router — so /cancel keeps working mid-conversation, and an unanswered pre-checkout query cannot fail a payment — and the wait stays armed; everything else is consumed.
  2. The router, in route priority: pre-router middleware, then commands, callback queries, custom routes, media, text patterns, specialized routes, fallback.
  3. Flow and dialog auto-resume, which are router routes like any other: flow_registry.apply_to_router registers them for text, callbacks and media after your own routes, so a command or an exact text route you registered still wins over a waiting flow.

The two waiting mechanisms cannot share an update: a wait_* called from inside a flow step would swallow the very update the flow is parked on, so the library logs a warning and emits ["telega", "flow", "wait_in_step"] when it sees one. Park the step instead — action.wait in a flow, on_text / on_message in a dialog.

Persisting state

One KeyValueStorage backs everything the bot remembers — sessions, flow and dialog instances, telega/store values, persisted jobs, dead letters, webhook idempotency. Pick the backend, wire it once, and every subsystem uses it:

import telega
import telega/storage
import telega_storage_sqlite

let assert Ok(_) = telega_storage_sqlite.migrate(conn)
let kv = telega_storage_sqlite.new(conn)

telega.new(client)
|> telega.session(storage.session_settings_from_storage(
  storage: kv,
  default_session: fn() { MySession(..) },
  encode: encode_session,
  decode: session_decoder(),
))
|> telega.router(router)
|> telega.start()
BackendReach for it when
none (default)the bot is stateless, or state lives only in a conversation that may end with the process
telega/storage/etsone node, state may be lost on restart — dedup windows, caches, local development
telega_storage_sqliteone node, state must survive a restart. A single file, no service to run: the default for small and medium bots
telega_storage_postgresseveral nodes, or the bot’s data already lives in Postgres
telega_storage_redisseveral nodes and high write rates, and losing state to a Redis flush is acceptable

Sessions hold per-user state; telega/store holds chat-, user- and bot-wide state that several people write (store.update is read-modify-write and not atomic — key the session by chat when that matters); dependencies hold services and are never serialized. Details, including versioned sessions and what happens when a stored session cannot be read, are in the session guide.

Dependency injection

Handlers reach shared services — a database pool, an HTTP client, an i18n catalog — through the typed, non-persisted dependencies slot on Context. It is set once at startup and is never serialized, unlike session (which holds per-user state):

pub type Dependencies {
  Dependencies(db: Connection, catalog: Catalog)
}

telega.new(client)
|> telega.dependencies(Dependencies(db:, catalog:))
|> telega.router(router)
|> telega.start()

// in any handler / flow step / middleware:
fn my_bookings(ctx: Context(Nil, String, Dependencies), _cmd) {
  let bookings = db.list_bookings(ctx.dependencies.db, ctx.update.from_id)
  reply.with_text(ctx, format_bookings(bookings))
}

Bots that need no services pay nothing: dependencies defaults to Nil. See the Dependency injection guide.

Testing

Telega ships with a testing toolkit under telega/testing/ — mock clients, data factories, and a declarative conversation DSL. No real Telegram API calls needed.

import telega/testing/conversation

pub fn greeting_flow_test() {
  conversation.conversation_test()
  |> conversation.send("/start")
  |> conversation.expect_reply_containing("Hello")
  |> conversation.send("Alice")
  |> conversation.expect_reply_containing("Alice")
  |> conversation.run(build_router(), fn() { MySession(name: "") })
}

See the full Testing guide for handler isolation, mock clients, media assertions, and more.

Ecosystem

Telega is a monorepo. The core telega package is HTTP-client- and storage-agnostic; pick the adapters you need:

PackagePurpose
telega_wispWisp webhook adapter (endpoint handling, secret-token validation)
telega_mistMinimal webhook adapter directly over mist, without wisp
telega_httpcHTTP client adapter over Erlang httpc
telega_hackneyHTTP client adapter over hackney
telega_storage_postgresPostgreSQL session/flow storage adapter
telega_storage_sqliteSQLite session/flow storage adapter
telega_storage_redisRedis/Valkey session/flow storage adapter
telega_webappTelegram Mini Apps (Web App) initData validation and helpers
telega_i18nInternationalization: TOML/JSON catalogs, locale middleware, interpolation, CLDR pluralization

How Telega compares

Telega is closest in spirit to aiogram — routers, filters, middleware, FSM-style flows and declarative dialogs — with everything that BEAM gives for free on top.

Telega (Gleam)aiogram (Python)grammY (TS)teloxide (Rust)
Typesfully static, generated from the API specruntime (pydantic)staticstatic
Concurrencyone supervised actor per chatasyncio taskspromisestokio tasks
Failure isolationsupervised per-chat actor: a crash restarts that chat alone, and its update is dead-letterederror handlererror handlererror handler
Long-running statesessions, flows and dialogs in a pluggable storageFSM storagesessions plugindialogue storage
Declarative dialogsbuilt in (telega/dialog, widgets, sub-dialogs)separate aiogram-dialog
API surfaceevery method of the vendored spec, CI-checked for driftfullfullfull
Ecosystem sizesmall and younglargelargemedium

Choose Telega if you want Gleam’s type system and OTP supervision for a bot that has to stay up; choose one of the others if ecosystem breadth or your team’s language matters more.

Examples

Progressive examples in the examples directory:

  1. 00-echo-bot — Basic echo with long polling
  2. 01-commands-bot — Command handling
  3. 02-session-bot — Stateful sessions
  4. 03-conversation-bot — Multi-message conversations
  5. 04-keyboard-bot — Inline keyboards and callbacks
  6. 05-media-group-bot — Media group handling
  7. 06-restaurant-booking — Full-featured application with flows and database
  8. 07-streaming-bot — LLM-style streaming into one growing message
  9. 08-group-bot — Chat-scoped data, versioned sessions, persisted reminders
  10. 09-webhook-wisp — Webhook deployment: health probe, idempotency, graceful drain
  11. 10-inline-and-payments — Inline mode with pagination, and Telegram Stars

Development

gleam build  # Build the project
gleam test   # Run the tests
gleam format # Format code
gleam shell  # Run an Erlang shell
Search Document