telega/bot
Core bot actor and chat instance management.
This module implements the actor-based architecture for handling Telegram updates.
It contains the Bot actor (the central dispatcher) and ChatInstance actors
(one per unique {chat_id}:{from_id} combination).
Supervision tree
Both the Bot actor and ChatInstance actors run inside a supervision tree
created by telega.start() or telega.start():
TelegaRootSupervisor (static_supervisor, OneForOne)
├── ChatInstances (factory_supervisor, Transient children)
│ ├── ChatInstance {chat1:user1}
│ ├── ChatInstance {chat2:user2}
│ └── ...
├── Bot actor (worker, Permanent)
└── Polling worker (worker, Permanent) — only for polling mode
- The
Botactor is aPermanentworker — it always restarts on crash. ChatInstanceactors areTransient— they restart only on abnormal exit, not on normal shutdown. On restart aChatInstancere-registers itself in the ETS registry, overwriting the stale subject.- The
Botcreates newChatInstanceactors viafactory_supervisor.start_child, which ensures they are supervised from the moment they start.
Chat instance lifetime
A ChatInstance is started on the first update of a {chat_id}:{from_id}
pair and is evicted after half an hour of silence (ChatSettings.idle_timeout,
set with telega.with_chat_idle_timeout and lifted with
telega.without_chat_idle_timeout): an instance that has received nothing
for that long asks the Bot actor to evict it. The bot — the only process
that dispatches updates — deregisters the key first and only then tells the
instance to stop, so an update can never be delivered to an instance on its
way out. The next update simply starts a fresh instance that re-reads the
session from storage; only an in-memory conversation continuation is lost.
Long before that, an instance that has been quiet for hibernate_after
compacts itself: one full garbage collection shrinks its heap back to
roughly what a freshly started instance uses, so the memory a burst of
traffic left behind is not held for the rest of the idle window.
Handler pattern
All handlers follow this signature:
fn handler(ctx: Context(session, error, dependencies), data: Type) -> Result(Context(session, error, dependencies), error)
Always return the updated context — it carries the (potentially modified) session.
Conversation API
The wait_handler function and the Handler type enable multi-message
conversations: the chat instance suspends its main handler and waits for a
specific update type. See telega.wait_text, telega.wait_command, etc.
Types
Stores information about running bot instance
pub opaque type Bot(session, error, dependencies)
What the bot actor reports about itself.
draining— a graceful shutdown has started; the bot no longer accepts updates.in_flight— updates currently being handled by chat instances.chat_instances— chat instances registered right now.
Read it with health; telega.health wraps it with the
liveness of the actor itself.
pub type BotHealth {
BotHealth(draining: Bool, in_flight: Int, chat_instances: Int)
}
Constructors
-
BotHealth(draining: Bool, in_flight: Int, chat_instances: Int)
pub opaque type BotMessage
pub type BotSubject =
process.Subject(BotMessage)
pub type CallbackQueryFilter {
CallbackQueryFilter(re: regexp.Regexp)
}
Constructors
-
CallbackQueryFilter(re: regexp.Regexp)
Handler called when an error occurs in handler
If handler returns Error, the bot will be stopped and the error will be logged
The default handler is fn(_) -> Ok(Nil), which will do nothing if handler returns an error
pub type CatchHandler(session, error, dependencies) =
fn(Context(session, error, dependencies), error) -> Result(
Nil,
error,
)
Arguments for starting a chat instance via factory supervisor.
pub type ChatInstanceArgs(session, error, dependencies) {
ChatInstanceArgs(
key: String,
config: @internal Config,
session_settings: SessionSettings(session, error),
catch_handler: fn(
Context(session, error, dependencies),
error,
) -> Result(Nil, error),
dependencies: dependencies,
router_handler: fn(
Context(session, error, dependencies),
update.Update,
) -> Result(Context(session, error, dependencies), error),
bot_info: types.User,
registry: @internal Registry(
ChatInstanceMessage(session, error, dependencies),
),
bot_subject: process.Subject(BotMessage),
settings: ChatSettings,
)
}
Constructors
-
ChatInstanceArgs( key: String, config: @internal Config, session_settings: SessionSettings(session, error), catch_handler: fn(Context(session, error, dependencies), error) -> Result( Nil, error, ), dependencies: dependencies, router_handler: fn( Context(session, error, dependencies), update.Update, ) -> Result(Context(session, error, dependencies), error), bot_info: types.User, registry: @internal Registry( ChatInstanceMessage(session, error, dependencies), ), bot_subject: process.Subject(BotMessage), settings: ChatSettings, )
pub opaque type ChatInstanceMessage(session, error, dependencies)
pub type ChatInstanceSubject(session, error, dependencies) =
process.Subject(
ChatInstanceMessage(session, error, dependencies),
)
How a chat instance is keyed, how long it lives, and how it writes its session.
Built with default_chat_settings and overridden field by field; the
telega builder (with_chat_idle_timeout, with_media_group_timeout,
with_session_persistence, with_session_key, …) is the usual way to
reach it.
pub type ChatSettings {
ChatSettings(
idle_timeout: option.Option(Int),
init_timeout: Int,
media_group_timeout: option.Option(Int),
hibernate_after: option.Option(Int),
session_persistence: SessionPersistence,
session_key: fn(update.Update) -> String,
on_load_error: SessionLoadError,
)
}
Constructors
-
ChatSettings( idle_timeout: option.Option(Int), init_timeout: Int, media_group_timeout: option.Option(Int), hibernate_after: option.Option(Int), session_persistence: SessionPersistence, session_key: fn(update.Update) -> String, on_load_error: SessionLoadError, )idle_timeout— how long (ms) an instance may sit idle before asking the bot to stop it.Nonekeeps every instance alive for the bot’s lifetime.init_timeout— how long (ms) the initialiser, which loads the session from storage, may take before the start counts as failed.media_group_timeout— debounce (ms) for gathering the separate messages of an album into oneMediaGroupUpdate.Nonedelivers them one by one.hibernate_after— how long (ms) an instance may sit idle before it compacts its heap.Nonenever compacts.session_persistence— whether an unchanged session is written back.session_key— the storage key (and chat instance identity) an update maps to. Defaults todefault_session_key.on_load_error— what happens when the session cannot be read.
Context holds information needed for the bot instance and the current update.
pub type Context(session, error, dependencies) {
Context(
key: String,
update: update.Update,
config: @internal Config,
session: session,
dependencies: dependencies,
chat_subject: process.Subject(
ChatInstanceMessage(session, error, dependencies),
),
start_time: option.Option(timestamp.Timestamp),
log_prefix: option.Option(String),
bot_info: types.User,
annotations: dict.Dict(String, dynamic.Dynamic),
scope: scope.Scope,
)
}
Constructors
-
Context( key: String, update: update.Update, config: @internal Config, session: session, dependencies: dependencies, chat_subject: process.Subject( ChatInstanceMessage(session, error, dependencies), ), start_time: option.Option(timestamp.Timestamp), log_prefix: option.Option(String), bot_info: types.User, annotations: dict.Dict(String, dynamic.Dynamic), scope: scope.Scope, )Arguments
- dependencies
-
Non-persisted services/dependencies injected at bot init (DI container). Unlike
session,dependenciesis never persisted — it holds things like a db pool, http client, or i18n catalog. Seetelega.with_dependencies. - start_time
-
Used to calculate the duration of the conversation in logs
- annotations
-
What the pre-router middleware attached to this update, read back with
annotation. Scoped to one update and never persisted — unlikedependencies(services) andsession(per-user state). - scope
-
Scratch space for this update, shared by every copy of the context and dropped once the update is handled. Where the dialog engine keeps its “callback already answered” flag and its widget stash, and where a middleware can hand a resolved locale to handlers nested below it. See
telega/scope.
pub type Handler(session, error, dependencies) {
HandleAll(
handler: fn(
Context(session, error, dependencies),
update.Update,
) -> Result(Context(session, error, dependencies), error),
)
HandleCommand(
command: String,
handler: fn(
Context(session, error, dependencies),
update.Command,
) -> Result(Context(session, error, dependencies), error),
)
HandleCommands(
commands: List(String),
handler: fn(
Context(session, error, dependencies),
update.Command,
) -> Result(Context(session, error, dependencies), error),
)
HandleText(
handler: fn(Context(session, error, dependencies), String) -> Result(
Context(session, error, dependencies),
error,
),
)
HandleHears(
hears: Hears,
handler: fn(Context(session, error, dependencies), String) -> Result(
Context(session, error, dependencies),
error,
),
)
HandleMessage(
handler: fn(
Context(session, error, dependencies),
types.Message,
) -> Result(Context(session, error, dependencies), error),
)
HandleVoice(
handler: fn(
Context(session, error, dependencies),
types.Voice,
) -> Result(Context(session, error, dependencies), error),
)
HandleAudio(
handler: fn(
Context(session, error, dependencies),
types.Audio,
) -> Result(Context(session, error, dependencies), error),
)
HandleVideo(
handler: fn(
Context(session, error, dependencies),
types.Video,
) -> Result(Context(session, error, dependencies), error),
)
HandlePhotos(
handler: fn(
Context(session, error, dependencies),
List(types.PhotoSize),
) -> Result(Context(session, error, dependencies), error),
)
HandleWebAppData(
handler: fn(
Context(session, error, dependencies),
types.WebAppData,
) -> Result(Context(session, error, dependencies), error),
)
HandleCallbackQuery(
filter: CallbackQueryFilter,
handler: fn(
Context(session, error, dependencies),
String,
String,
) -> Result(Context(session, error, dependencies), error),
)
HandleFiltered(
filter: fn(update.Update) -> Bool,
handler: fn(
Context(session, error, dependencies),
update.Update,
) -> Result(Context(session, error, dependencies), error),
)
HandleChatMember(
handler: fn(
Context(session, error, dependencies),
types.ChatMemberUpdated,
) -> Result(Context(session, error, dependencies), error),
)
}
Constructors
-
HandleAll( handler: fn( Context(session, error, dependencies), update.Update, ) -> Result(Context(session, error, dependencies), error), )Handle all messages.
-
HandleCommand( command: String, handler: fn( Context(session, error, dependencies), update.Command, ) -> Result(Context(session, error, dependencies), error), )Handle a specific command.
-
HandleCommands( commands: List(String), handler: fn( Context(session, error, dependencies), update.Command, ) -> Result(Context(session, error, dependencies), error), )Handle multiple commands.
-
HandleText( handler: fn(Context(session, error, dependencies), String) -> Result( Context(session, error, dependencies), error, ), )Handle text messages.
-
HandleHears( hears: Hears, handler: fn(Context(session, error, dependencies), String) -> Result( Context(session, error, dependencies), error, ), )Handle text message with a specific substring.
-
HandleMessage( handler: fn( Context(session, error, dependencies), types.Message, ) -> Result(Context(session, error, dependencies), error), )Handle any message.
-
HandleVoice( handler: fn(Context(session, error, dependencies), types.Voice) -> Result( Context(session, error, dependencies), error, ), )Handle voice messages.
-
HandleAudio( handler: fn(Context(session, error, dependencies), types.Audio) -> Result( Context(session, error, dependencies), error, ), )Handle audio messages.
-
HandleVideo( handler: fn(Context(session, error, dependencies), types.Video) -> Result( Context(session, error, dependencies), error, ), )Handle video messages.
-
HandlePhotos( handler: fn( Context(session, error, dependencies), List(types.PhotoSize), ) -> Result(Context(session, error, dependencies), error), )Handle photo messages.
-
HandleWebAppData( handler: fn( Context(session, error, dependencies), types.WebAppData, ) -> Result(Context(session, error, dependencies), error), )Handle web app data messages.
-
HandleCallbackQuery( filter: CallbackQueryFilter, handler: fn( Context(session, error, dependencies), String, String, ) -> Result(Context(session, error, dependencies), error), )Handle callback query. Context, data from callback query and
callback_query_idare passed to the handler. -
HandleFiltered( filter: fn(update.Update) -> Bool, handler: fn( Context(session, error, dependencies), update.Update, ) -> Result(Context(session, error, dependencies), error), )Handle any update that satisfies
filter. Updates that fail the filter fall through to the conversation’sor:handler, exactly like the typed handlers above. Used bytelega.wait_for/telega.wait_filtered. -
HandleChatMember( handler: fn( Context(session, error, dependencies), types.ChatMemberUpdated, ) -> Result(Context(session, error, dependencies), error), )Handle chat member update (when user joins/leaves a group). The bot must be an administrator in the chat and must explicitly specify “chat_member” in the list of
allowed_updatesto receive these updates.
pub type Hears {
HearText(text: String)
HearTexts(texts: List(String))
HearRegex(regex: regexp.Regexp)
HearRegexes(regexes: List(regexp.Regexp))
}
Constructors
-
HearText(text: String) -
HearTexts(texts: List(String)) -
HearRegex(regex: regexp.Regexp) -
HearRegexes(regexes: List(regexp.Regexp))
Limited context handed to pre-router middleware. A PreHandler runs once per
incoming update inside the Bot actor — before any chat instance is
spawned or session is loaded — so it only carries update-level data, not a
session. Use it for cross-cutting concerns that apply to every update:
anti-spam, analytics, and update deduplication (telega/idempotency).
pub type PreContext(dependencies) {
PreContext(
update: update.Update,
config: @internal Config,
dependencies: dependencies,
bot_info: types.User,
annotations: dict.Dict(String, dynamic.Dynamic),
)
}
Constructors
-
PreContext( update: update.Update, config: @internal Config, dependencies: dependencies, bot_info: types.User, annotations: dict.Dict(String, dynamic.Dynamic), )Arguments
- dependencies
-
The same injected services available to handlers via
Context. - annotations
-
What the pre-handlers before this one annotated the update with.
Pre-router middleware: a single global pass over every update, run before
routing. Registered with telega.use_pre_handler and executed in the order
added; the first one that returns Stop short-circuits the rest and the
router. Because they run sequentially inside the single Bot actor,
read-then-write logic (e.g. dedup) is race-free across concurrent updates.
pub type PreHandler(dependencies) =
fn(PreContext(dependencies)) -> PreRouterResult
Decision returned by a PreHandler: keep processing the update through the
router, or stop it here (drop it before routing).
pub type PreRouterResult {
Continue(annotations: dict.Dict(String, dynamic.Dynamic))
Stop
}
Constructors
-
Continue(annotations: dict.Dict(String, dynamic.Dynamic))Continue to the next pre-router middleware and, eventually, the router, attaching
annotationsto this update.Annotations are merged into what earlier pre-handlers set (a repeated key takes the newer value) and reach every handler as
ctx.annotations, read back withannotation. They live for one update and are never persisted — long-lived services belong independencies, per-user state in the session.Use
proceedwhen there is nothing to annotate. -
StopStop processing this update. The webhook/poller is told the update was acknowledged (so Telegram does not retry it) but no handler runs.
What a chat instance does when get_session returns an Error.
A read that failed is not the same as “this user has no session yet”, so
none of these are the Ok(None) path — that one always uses
default_session().
pub type SessionLoadError {
FailUpdate
UseDefault
ReadOnly
}
Constructors
-
FailUpdateRefuse to start: the update is answered
Falseand nothing is written. The default, because handling the update on a default session would let the first handler persist that default over the real, still-stored data. -
UseDefaultStart on
default_session()and persist normally. Only safe when losing a session is cheaper than dropping the update — a cache, a counter. -
ReadOnlyStart on
default_session()but never write: handlers run, every persist is skipped with a warning. The stored session survives a backend that is briefly unreadable, and the next update tries the read again.
Whether the session is written back after an update that did not change it.
pub type SessionPersistence {
PersistOnChange
PersistAlways
}
Constructors
-
PersistOnChangeSkip
persist_sessionwhen the handler returned the session it was given. A chat that only reads its session — most of them, most of the time — then costs no storage write at all. -
PersistAlwaysCall
persist_sessionafter every handled update, changed or not. Pick this when writing has a side effect you rely on, such as refreshing an expiry or a “last seen” column.
pub type SessionSettings(session, error) {
SessionSettings(
persist_session: fn(String, session) -> Result(session, error),
get_session: fn(String) -> Result(
option.Option(session),
error,
),
default_session: fn() -> session,
)
}
Constructors
-
SessionSettings( persist_session: fn(String, session) -> Result(session, error), get_session: fn(String) -> Result(option.Option(session), error), default_session: fn() -> session, )
Values
pub fn annotation(
ctx: Context(session, error, dependencies),
key: String,
decoder: decode.Decoder(a),
) -> Result(a, Nil)
Read one pre-router annotation, decoded.
Error(Nil) when the key was never set or the value does not decode as
decoder expects, so a handler can fall back with result.unwrap:
let locale =
bot.annotation(ctx, "locale", decode.string)
|> result.unwrap("en")
pub fn cancel_conversation(
bot bot: Bot(session, error, dependencies),
key key: String,
) -> Nil
Stops waiting for any handler for specific key (chat_id)
pub fn cancel_conversation_for(
bot_subject bot_subject: process.Subject(BotMessage),
key key: String,
) -> Nil
Drop the pending conversation continuation of one chat instance.
The instance itself keeps running (with its loaded session) — only the
suspended wait_* handler is forgotten, so the next update is routed
normally again. The registry is deliberately left alone: unregistering a
live instance would orphan the process and leak it forever.
pub fn cancel_conversation_in(
ctx ctx: Context(session, error, dependencies),
) -> Nil
Drop this chat’s pending wait_* continuation from inside a handler.
The companion to commands reaching the router while a conversation waits:
this is how a /cancel route ends the conversation it interrupted. The
chat instance and its session are untouched — only the suspended handler is
forgotten, so the next update routes normally.
pub fn chat_session_key(update: update.Update) -> String
One session per chat, shared by everyone in it: "chat:{chat_id}".
The natural key for a group bot whose state is about the chat rather than the member — a shared counter, the group’s language. Note that it also makes the chat a single instance, so members’ updates are serialized through one process (which is what makes a shared counter safe).
pub const default_chat_idle_timeout: Int
One process per {chat_id}:{from_id} adds up: a bot with a million users
that never evicts anything runs out of BEAM processes. Half an hour is far
longer than any conversation timeout a bot is likely to use, and a chat that
comes back simply starts a fresh instance from the stored session.
pub const default_chat_init_timeout: Int
How long a chat instance may take to start, session load included.
pub fn default_chat_settings() -> ChatSettings
Evict a chat instance after half an hour of silence, compact its heap after a minute of it, and skip writing a session no handler changed.
pub const default_hibernate_after: Int
A minute of silence is long enough that the instance is unlikely to be in the middle of anything, and short enough that the heap a burst grew is not carried for the rest of the idle window.
pub fn default_session_key(update: update.Update) -> String
The key an update maps to unless with_session_key says otherwise:
"{chat_id}:{from_id}", one session (and one chat instance) per user per
chat.
Alternatives ship beside it — chat_session_key for
one shared session per chat, user_session_key for
one per user across chats. The caveats of each are in
docs/session-serialization.md.
pub fn drain(
bot_subject bot_subject: process.Subject(BotMessage),
timeout timeout: Int,
) -> Int
Begin a graceful drain of the bot.
Stops accepting new updates and blocks until all in-flight updates finish or
timeout milliseconds elapse. Returns the number of updates that were
in-flight when the drain started, or -1 if the timeout was reached before
draining completed.
pub fn get_session(
session_settings: SessionSettings(session, error),
update: update.Update,
) -> Result(option.Option(session), error)
Read the session an update maps to under the default key.
A bot that changed its key with telega.with_session_key has to read
through its own key function instead — this helper predates the setting and
cannot see it.
pub fn health(
bot_subject bot_subject: process.Subject(BotMessage),
timeout timeout: Int,
) -> option.Option(BotHealth)
Ask the bot actor for its current state. None when it does not answer
within timeout — it is dead, wedged, or too busy to reply, all of which
a load balancer should read as “do not send traffic here”.
Unlike every other query in this module this one is answered from the bot actor’s own mailbox, so a bot whose actor is blocked reports unhealthy instead of reporting stale numbers.
pub const health_timeout: Int
Default time (ms) a health query waits for the bot actor.
pub fn is_draining(
bot_subject bot_subject: process.Subject(BotMessage),
) -> Bool
Whether the bot is currently draining (no longer accepting new updates).
Webhook adapters use this to answer 503 so Telegram retries the update
after the deploy instead of dropping it.
pub fn next_session(
ctx ctx: Context(session, error, dependencies),
session session: session,
) -> Result(Context(session, error, dependencies), error)
pub fn proceed() -> PreRouterResult
Continue without annotating the update — Continue(dict.new()).
pub fn start(
registry registry: @internal Registry(
ChatInstanceMessage(session, error, dependencies),
),
config config: @internal Config,
bot_info bot_info: types.User,
router_handler router_handler: fn(
Context(session, error, dependencies),
update.Update,
) -> Result(Context(session, error, dependencies), error),
pre_handlers pre_handlers: List(
fn(PreContext(dependencies)) -> PreRouterResult,
),
session_settings session_settings: SessionSettings(
session,
error,
),
catch_handler catch_handler: fn(
Context(session, error, dependencies),
error,
) -> Result(Nil, error),
dependencies dependencies: dependencies,
chat_factory chat_factory: factory_supervisor.Supervisor(
ChatInstanceArgs(session, error, dependencies),
process.Subject(
ChatInstanceMessage(session, error, dependencies),
),
),
chat_settings chat_settings: ChatSettings,
dead_letters dead_letters: option.Option(
dead_letter.DeadLetters,
),
name name: option.Option(process.Name(BotMessage)),
) -> Result(
actor.Started(process.Subject(BotMessage)),
actor.StartError,
)
pub fn start_chat_instance(
args: ChatInstanceArgs(session, error, dependencies),
) -> Result(
actor.Started(
process.Subject(
ChatInstanceMessage(session, error, dependencies),
),
),
actor.StartError,
)
Start a chat instance. Used as the template function for factory_supervisor. Self-registers in the registry on start (handles both first start and restart after crash).
The session is loaded inside the initialiser, so it runs in the instance process: a slow storage backend delays only this chat, and a crash there fails this one start instead of taking the factory supervisor — and every other chat instance with it — down.
pub const update_dispatch_timeout: Int
How long a caller waits for an update to be handled before it stops blocking on the answer.
A chat instance that dies is answered straight away (the bot monitors it), and every handled-error path answers too; this is the last-resort backstop for a handler that is alive but wedged. The handler keeps running — only the caller gives up waiting, and reports the update as unhandled.
pub fn user_session_key(update: update.Update) -> String
One session per user, shared across every chat they write in:
"user:{from_id}". Careful with updates that carry no user (a poll update
keys as user:-1).
pub fn wait_handler(
ctx ctx: Context(session, error, dependencies),
handler handler: Handler(session, error, dependencies),
handle_else handle_else: option.Option(
Handler(session, error, dependencies),
),
timeout timeout: option.Option(Int),
) -> Result(Context(session, error, dependencies), error)
Pass any handler to start waiting
or - calls if there are any other updates
timeout - the conversation will be canceled after this timeout