telega/jobs

Work the bot does later: a reminder in an hour, a nightly digest, a retry after the rate limit clears.

Two kinds of job, and the difference is what happens when the bot restarts:

import telega/jobs

let assert Ok(scheduler) =
  jobs.new(bot)
  |> jobs.with_storage(storage)
  |> jobs.with_handler("reminder", fn(ctx, payload) {
    let text = decode.run(payload, decode.at(["text"], decode.string))
    let _ = reply.with_text(ctx, result.unwrap(text, "⏰"))
    Nil
  })
  |> jobs.start()

// ...from a handler, an hour from now:
jobs.persisted(
  scheduler,
  id: "reminder:" <> int.to_string(ctx.update.chat_id),
  handler: "reminder",
  chat_id: ctx.update.chat_id,
  user_id: ctx.update.from_id,
  at: timestamp.add(timestamp.system_time(), duration.hours(1)),
  payload: json.object([#("text", json.string("stand up"))]),
)

A persisted job runs against a telega.background_context: the session is loaded, dependencies are injected, and everything a handler can do — reply, edit, dialog.refresh — works. What does not is wait_*: there is no chat instance to suspend.

Ids, and what they cost

A persisted job’s id is its identity in storage. Scheduling twice with the same id replaces the first — which is what you want for “remind me at 9”, and not what you want for a queue of independent reminders. Include whatever makes them distinct ("digest:" <> chat, "reminder:" <> chat <> ":" <> nonce). cancel takes the same id.

Guarantees

At-most-once, not exactly-once. A job whose handler crashes mid-run is not retried — it has already been taken out of storage. A job whose context cannot be built (an unreadable session) is retried a few times and then dropped with an error log, since retrying forever would be a louder failure than losing the job. A job whose handler name is not registered stays in storage untouched, so the deploy that adds the handler picks it up at start.

Every run emits ["telega", "job", "run"]; a failure to run one emits ["telega", "job", "error"].

Types

Scheduler configuration, built by new and started by start.

pub opaque type Builder(session, error, dependencies)

What a persisted job runs: the update-less context for its chat and the payload it was scheduled with, still undecoded.

pub type JobHandler(session, error, dependencies) =
  fn(bot.Context(session, error, dependencies), dynamic.Dynamic) -> Nil

The scheduler actor’s protocol. Opaque — every message has a function that sends it; the type is public only so it can be named.

pub opaque type Message(session, error, dependencies)

A running scheduler. Hand it to handlers through dependencies, or name it with with_name and rebuild the handle with from_name.

pub opaque type Scheduler(session, error, dependencies)

Values

pub fn cancel(
  scheduler scheduler: Scheduler(session, error, dependencies),
  id id: String,
) -> Nil

Forget a job, in memory or in storage. Unknown ids are ignored.

pub fn from_name(
  name: process.Name(Message(session, error, dependencies)),
) -> Scheduler(session, error, dependencies)

A handle for a scheduler started under name.

Nothing checks that it is running — a send to a dead name is dropped, the same as any named subject.

pub const job_retry_delay_ms: Int

How long a job waits before its context is tried again.

pub const max_job_attempts: Int

How many times a job whose context cannot be built is put back before it is given up on.

pub fn new(
  telega telega: telega.Telega(session, error, dependencies),
) -> Builder(session, error, dependencies)

Start configuring a scheduler for a started bot.

pub fn pending(
  scheduler scheduler: Scheduler(session, error, dependencies),
  timeout timeout: Int,
) -> Result(List(String), Nil)

The ids the scheduler is currently holding, in no particular order.

Mostly for tests and health endpoints. A scheduler that is gone, or does not answer within timeout milliseconds, reports Error(Nil) rather than taking the caller down with it.

pub fn persisted(
  scheduler scheduler: Scheduler(session, error, dependencies),
  id id: String,
  handler handler: String,
  chat_id chat_id: Int,
  user_id user_id: Int,
  at at: timestamp.Timestamp,
  payload payload: json.Json,
) -> Nil

Schedule a job that survives a restart: run handler for this chat at at, with payload.

Scheduling an id that already exists replaces it. A time already in the past runs as soon as the scheduler sees it.

pub fn persisted_every(
  scheduler scheduler: Scheduler(session, error, dependencies),
  id id: String,
  handler handler: String,
  chat_id chat_id: Int,
  user_id user_id: Int,
  interval_ms interval_ms: Int,
  payload payload: json.Json,
) -> Nil

Schedule a repeating job that survives a restart: run handler for this chat every interval_ms, starting one interval from now.

Each run schedules the next one and writes it before it dispatches, so a restart mid-run resumes the series rather than ending it.

pub fn run_after(
  scheduler scheduler: Scheduler(session, error, dependencies),
  delay_ms delay_ms: Int,
  job job: fn(client.TelegramClient) -> Nil,
) -> String

Run job once, delay_ms from now, and forget it if the bot restarts first. Returns the id cancel takes.

pub fn run_every(
  scheduler scheduler: Scheduler(session, error, dependencies),
  interval_ms interval_ms: Int,
  job job: fn(client.TelegramClient) -> Nil,
) -> String

Run job every interval_ms, starting one interval from now, until the bot stops or cancel is called with the returned id.

The next run is armed after the previous one is dispatched, so a job that takes longer than its interval does not pile up.

pub fn start(
  builder builder: Builder(session, error, dependencies),
) -> Result(
  Scheduler(session, error, dependencies),
  error.TelegaError,
)

Start the scheduler.

Persisted jobs are read back from storage here: everything already due runs straight away, the rest is armed for its due time.

pub fn with_handler(
  builder builder: Builder(session, error, dependencies),
  name name: String,
  run run: fn(
    bot.Context(session, error, dependencies),
    dynamic.Dynamic,
  ) -> Nil,
) -> Builder(session, error, dependencies)

Register what a persisted job named name does.

The name is what is written to storage in place of the closure, so it has to mean the same thing across deploys. Registering the same name twice keeps the last one.

pub fn with_name(
  builder builder: Builder(session, error, dependencies),
  name name: process.Name(Message(session, error, dependencies)),
) -> Builder(session, error, dependencies)

Register the scheduler actor under a process name, so handlers can reach it with from_name instead of being handed the value.

pub fn with_storage(
  builder builder: Builder(session, error, dependencies),
  storage storage: storage.KeyValueStorage(storage_error),
) -> Builder(session, error, dependencies)

Give the scheduler somewhere to keep persisted jobs.

Without it persisted has nowhere to write and logs an error instead; the in-memory jobs work either way. Use the same backend the sessions and flows use — jobs live under their own job: namespace.

Search Document