Telega
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).
It provides
- an interface to the Telegram Bot HTTP-based APIs
telega/api - a client for the Telegram Bot API
telega/client - OTP supervision tree for all bot processes (bot actor, chat instances, polling)
- adapter to use with wisp
- long polling with automatic retry and exponential backoff
- session bot implementation, keyed however you like (
telega.with_session_key) - chat-, user- and bot-wide shared state beside the session (
telega/store) - delayed and recurring work, in memory or surviving a restart (
telega/jobs) - conversation implementation (multi-message flows)
- declarative single-message dialogs with widgets and sub-dialogs (
telega/dialog, guide) - per-user flood control middleware (
router.with_rate_limit) - inline mode result builders with pagination (
telega/inline_mode) - payments helpers — Telegram Stars first-class (
telega/payments) - paid media —
sendPaidMediaviaapi.send_paid_media/reply.with_paid_media - production observability via telemetry events (
telega/telemetry) - graceful shutdown via
telega.shutdown()
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
- Bot actor — dispatches incoming updates to the correct
ChatInstanceby{chat_id}:{from_id}key. - ChatInstance — one per user-chat combination; holds session state and conversation continuations. Transient restart strategy means it restarts only on abnormal exit and re-registers itself in the ETS registry automatically.
- Polling worker — long-polls the Telegram API with exponential backoff on errors.
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”:
| Conversations | Flows | Dialogs | Menu builder | |
|---|---|---|---|---|
| What it is | a handler that pauses mid-run | a persistent state machine | a set of windows compiled into a flow | a keyboard builder (deprecated) |
| UI model | the bot sends messages | you send and edit them yourself | one live message, auto edit-or-send | one menu message |
| Survives a restart | no (in-memory continuation) | yes (storage backend) | yes (via flow) | no |
| Back navigation | no | Back action, by hand | built in | built in |
| Callback data | manual | manual | generated and validated (64 bytes) | generated |
| Reusable selects / pagination | no | no | widgets (pager, radio, multiselect, calendar, …) | pagination |
| Composition | nested calls | subflows | sub-dialogs, typed results | nested menus |
| Reach for it when | a quick Q&A: “what is your name?” | a branchy process with hand-written messages | a 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:
- 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/cancelkeeps working mid-conversation, and an unanswered pre-checkout query cannot fail a payment — and the wait stays armed; everything else is consumed. - The router, in route priority: pre-router middleware, then commands, callback queries, custom routes, media, text patterns, specialized routes, fallback.
- Flow and dialog auto-resume, which are router routes like any other:
flow_registry.apply_to_routerregisters 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()
| Backend | Reach for it when |
|---|---|
| none (default) | the bot is stateless, or state lives only in a conversation that may end with the process |
telega/storage/ets | one node, state may be lost on restart — dedup windows, caches, local development |
telega_storage_sqlite | one node, state must survive a restart. A single file, no service to run: the default for small and medium bots |
telega_storage_postgres | several nodes, or the bot’s data already lives in Postgres |
telega_storage_redis | several 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:
| Package | Purpose |
|---|---|
telega_wisp | Wisp webhook adapter (endpoint handling, secret-token validation) |
telega_mist | Minimal webhook adapter directly over mist, without wisp |
telega_httpc | HTTP client adapter over Erlang httpc |
telega_hackney | HTTP client adapter over hackney |
telega_storage_postgres | PostgreSQL session/flow storage adapter |
telega_storage_sqlite | SQLite session/flow storage adapter |
telega_storage_redis | Redis/Valkey session/flow storage adapter |
telega_webapp | Telegram Mini Apps (Web App) initData validation and helpers |
telega_i18n | Internationalization: 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) | |
|---|---|---|---|---|
| Types | fully static, generated from the API spec | runtime (pydantic) | static | static |
| Concurrency | one supervised actor per chat | asyncio tasks | promises | tokio tasks |
| Failure isolation | supervised per-chat actor: a crash restarts that chat alone, and its update is dead-lettered | error handler | error handler | error handler |
| Long-running state | sessions, flows and dialogs in a pluggable storage | FSM storage | sessions plugin | dialogue storage |
| Declarative dialogs | built in (telega/dialog, widgets, sub-dialogs) | separate aiogram-dialog | — | — |
| API surface | every method of the vendored spec, CI-checked for drift | full | full | full |
| Ecosystem size | small and young | large | large | medium |
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:
00-echo-bot— Basic echo with long polling01-commands-bot— Command handling02-session-bot— Stateful sessions03-conversation-bot— Multi-message conversations04-keyboard-bot— Inline keyboards and callbacks05-media-group-bot— Media group handling06-restaurant-booking— Full-featured application with flows and database07-streaming-bot— LLM-style streaming into one growing message08-group-bot— Chat-scoped data, versioned sessions, persisted reminders09-webhook-wisp— Webhook deployment: health probe, idempotency, graceful drain10-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