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

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 CallbackQueryFilter {
  CallbackQueryFilter(re: regexp.Regexp)
}

Constructors

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

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. None keeps 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 one MediaGroupUpdate. None delivers them one by one.
    • hibernate_after — how long (ms) an instance may sit idle before it compacts its heap. None never compacts.
    • session_persistence — whether an unchanged session is written back.
    • session_key — the storage key (and chat instance identity) an update maps to. Defaults to default_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, dependencies is never persisted — it holds things like a db pool, http client, or i18n catalog. See telega.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 — unlike dependencies (services) and session (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_id are 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’s or: handler, exactly like the typed handlers above. Used by telega.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_updates to receive these updates.

pub type Hears {
  HearText(text: String)
  HearTexts(texts: List(String))
  HearRegex(regex: regexp.Regexp)
  HearRegexes(regexes: List(regexp.Regexp))
}

Constructors

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 annotations to 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 with annotation. They live for one update and are never persisted — long-lived services belong in dependencies, per-user state in the session.

    Use proceed when there is nothing to annotate.

  • Stop

    Stop 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

  • FailUpdate

    Refuse to start: the update is answered False and 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.

  • UseDefault

    Start on default_session() and persist normally. Only safe when losing a session is cheaper than dropping the update — a cache, a counter.

  • ReadOnly

    Start 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

  • PersistOnChange

    Skip persist_session when 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.

  • PersistAlways

    Call persist_session after 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

Search Document