telega/scope

Per-update scratch space carried by Context.

Some things a handler does have to be visible to code that never receives the handler’s return value: the dialog engine has to know whether the user’s on_action already answered the callback query, an API transformer deep inside the client has to know whether this particular call opted out of the webhook-reply claim, an i18n middleware has to hand the resolved locale to t(ctx, ...) calls nested anywhere below it.

A Scope is the explicit place for exactly that. It is created once per update, travels in ctx.scope (so every copy of the context — including Context(..ctx, session:) — shares it), and is dropped when the update is handled. Slots live in the process dictionary of the chat instance under a namespace unique to the scope, which is what makes a leftover flag from an earlier update unreadable rather than merely unlikely.

A slot is named by a typed Key, so a read and a write agree on the value’s type by construction:

const locale_key: scope.Key(String) = scope.Key("my_bot/locale")

pub fn set_locale(ctx: Context(s, e, d), locale: String) -> Nil {
  scope.put(ctx.scope, locale_key, locale)
}

pub fn locale(ctx: Context(s, e, d)) -> String {
  scope.get(ctx.scope, locale_key) |> result.unwrap("en")
}

Name keys after the module that owns them ("my_bot/locale", not "locale"): a scope is one flat namespace shared by the library, your bot, and any middleware, and one name used at two different types is the single way to get a value back that is not the one you stored.

A scope is not a place for application state: the session is per-user persisted state, telega/store is chat/user/global state, dependencies are services. It holds only what is true of the update being handled, and only for as long as that update is.

A scope belongs to the process handling the update. Reading it from a process you spawned yourself finds nothing rather than what the spawning handler put there.

Types

The name of one slot, together with the type of what it holds.

Build it as a constant next to the code that owns the slot, so nothing else can name it at another type:

const answered_key: scope.Key(String) = scope.Key("dialog/answered")
pub type Key(value) {
  Key(name: String)
}

Constructors

  • Key(name: String)

A per-update key/value namespace. Opaque: the runtime builds one per update, and put / get are how it is read and written.

pub opaque type Scope

Values

pub fn clear(scope scope: Scope) -> Nil

Empty the whole scope. The runtime calls this once the update is handled, which is what keeps a long-lived chat instance’s process dictionary from growing one namespace per update. A cleared scope is still usable.

pub fn erase(scope scope: Scope, key key: Key(value)) -> Nil

Empty one slot.

pub fn get(
  scope scope: Scope,
  key key: Key(value),
) -> Result(value, Nil)

Read key’s slot back. Error(Nil) when this scope never filled it.

pub fn has(scope scope: Scope, key key: Key(value)) -> Bool

Whether this scope has filled key’s slot.

pub fn new() -> Scope

A fresh scope, sharing slots with no other scope alive or dead.

The runtime creates one per update; call this yourself only when you build a Context by hand.

pub fn put(
  scope scope: Scope,
  key key: Key(value),
  value value: value,
) -> Nil

Store value in key’s slot, replacing whatever was there.

Search Document