telega/broadcast

Mass messaging with pacing, result classification and reports — the answer to “how do I send a message to all my users?” without tripping over Telegram’s rate limits or losing track of who actually got the message.

A broadcast sends to a list (or a stream) of chat ids sequentially, paced below Telegram’s limit, and classifies every result:

import telega/broadcast

let assert Ok(report) =
  broadcast.send_text(client:, chat_ids:, text: "Big news!")
  |> broadcast.run

// 403s are users who blocked the bot — stop sending to them
mark_as_dead(report.blocked)

Telegram’s limits

Telegram allows bots roughly 30 messages per second across all chats (and ~20 messages per minute into the same group). Exceeding it earns HTTP 429 responses and, if you keep pushing, longer and longer cooldowns.

The broadcast default is 25 messages per 1000 ms — a deliberate safety margin. Tune it with with_rate:

broadcast.send_text(client:, chat_ids:, text:)
|> broadcast.with_rate(rate: 20, window_ms: 1000)
|> broadcast.run

If the client also has a request queue configured (client.new_with_queue / client.set_request_queue), broadcast calls go through it too, so the effective rate is the min of the two limits. The broadcast’s own pacing exists so that mass sends are throttled even on clients without a queue — and so a broadcast never starves interactive traffic by monopolizing the queue’s default rule.

On 429: the client itself retries honoring parameters.retry_after. A 429 that still reaches the broadcast means Telegram is pushing back hard — the broadcast pauses for one full window and retries that chat id once, then reports it as failed.

Custom payloads

send_text is a convenience over api.send_message. For anything else — photos, invoices, per-user personalization — pass your own send function:

let send_promo = fn(client, chat_id) {
  api.send_photo(client, parameters: promo_photo_for(chat_id))
}

let assert Ok(report) =
  broadcast.new(client:, chat_ids:, send: send_promo)
  |> broadcast.run

The function’s success value ends up in report.sent, so you can keep the returned Message for later edits or deletion.

Sends are sequential by design: one send at a time, inside the broadcast actor. Concurrency would break pacing.

Streaming recipients from a database

For large audiences, don’t load every chat id into memory — stream them in chunks. The broadcast pulls the next chunk when the current one is exhausted; return None (or an empty chunk) to signal the end:

let next_page = fn() {
  case load_subscriber_page(db) {
    [] -> None
    chat_ids -> Some(chat_ids)
  }
}

let assert Ok(report) =
  broadcast.new_from_iterator(client:, next_chunk: next_page, send: send_promo)
  |> broadcast.run

With an iterator source, BroadcastProgress.total is None — the size is unknown upfront.

Background broadcasts: progress and cancellation

run is fine for scripts. In a bot you usually want to start the broadcast, answer the admin immediately, and check on it later:

let assert Ok(handle) =
  broadcast.send_text(client:, chat_ids:, text:)
  |> broadcast.start

// From any process, at any time:
let progress = broadcast.progress(handle)
broadcast.cancel(handle)
let assert Ok(report) = broadcast.await(handle, timeout: 60_000)

For live progress messages (“Sending… 250/1000”), register a callback with with_on_progress. It runs inside the broadcast actor after every processed chat id — keep it cheap, a slow callback slows the whole broadcast down.

Blocked-user hygiene

A 403 (Forbidden: bot was blocked by the user and friends) is permanent until the user comes back on their own. Every broadcast to a dead chat id wastes your rate budget, so treat report.blocked as a to-do list: mark those chat ids as inactive in your storage, exclude them from future broadcasts, and re-activate a user when they message the bot again (/start).

failed is different — those are transient errors (network, server-side 5xx, a 429 that survived retries). Keep those ids and retry them in a later broadcast.

Types

A configured broadcast, ready to start or run.

pub opaque type Broadcast(a)

Handle to a started broadcast, safe to share between processes.

pub opaque type BroadcastHandle(a)

Snapshot of a running (or finished) broadcast.

pub type BroadcastProgress {
  BroadcastProgress(
    total: option.Option(Int),
    done: Int,
    sent: Int,
    blocked: Int,
    failed: Int,
  )
}

Constructors

  • BroadcastProgress(
      total: option.Option(Int),
      done: Int,
      sent: Int,
      blocked: Int,
      failed: Int,
    )

    Arguments

    total

    Total number of recipients. None for iterator sources — the size is not known upfront.

    done

    Number of processed chat ids: sent + blocked + failed.

Final report of a broadcast, in recipient order.

pub type BroadcastReport(a) {
  BroadcastReport(
    sent: List(#(Int, a)),
    blocked: List(Int),
    failed: List(#(Int, error.TelegaError)),
    duration_ms: Int,
    cancelled: Bool,
  )
}

Constructors

  • BroadcastReport(
      sent: List(#(Int, a)),
      blocked: List(Int),
      failed: List(#(Int, error.TelegaError)),
      duration_ms: Int,
      cancelled: Bool,
    )

    Arguments

    sent

    Successfully delivered, with the send function’s return value.

    blocked

    HTTP 403: bot blocked / kicked / user deactivated.

    failed

    Everything else after retries.

    cancelled

    True if the broadcast was stopped via cancel; remaining recipients were not contacted.

Values

pub fn await(
  handle handle: BroadcastHandle(a),
  timeout timeout: Int,
) -> Result(BroadcastReport(a), error.TelegaError)

Wait for the broadcast to finish and return the report. Returns an error if it does not finish within timeout milliseconds (the broadcast itself keeps running).

pub fn cancel(handle handle: BroadcastHandle(a)) -> Nil

Stop the broadcast. Recipients not yet contacted stay untouched, the report is finalized with cancelled: True. Cancelling a finished broadcast is a no-op.

pub fn new(
  client client: client.TelegramClient,
  chat_ids chat_ids: List(Int),
  send send: fn(client.TelegramClient, Int) -> Result(
    a,
    error.TelegaError,
  ),
) -> Broadcast(a)

Create a broadcast for a known list of chat ids.

The send function is called once per chat id (twice on a 429 that survived the client’s retries) inside the broadcast actor.

pub fn new_from_iterator(
  client client: client.TelegramClient,
  next_chunk next_chunk: fn() -> option.Option(List(Int)),
  send send: fn(client.TelegramClient, Int) -> Result(
    a,
    error.TelegaError,
  ),
) -> Broadcast(a)

Create a broadcast that pulls chat ids in chunks — for streaming millions of recipients from a database without loading them all into memory.

The next chunk is requested (inside the broadcast actor) when the current one is exhausted. Return None — or an empty chunk — to signal the end of the stream.

pub fn progress(
  handle handle: BroadcastHandle(a),
) -> BroadcastProgress

Get a progress snapshot of the broadcast.

pub fn run(
  broadcast broadcast: Broadcast(a),
) -> Result(BroadcastReport(a), error.TelegaError)

Run the broadcast to completion: start + await forever. Convenient for scripts and one-off jobs.

pub fn send_text(
  client client: client.TelegramClient,
  chat_ids chat_ids: List(Int),
  text text: String,
) -> Broadcast(types.Message)

Convenience broadcast sending the same text to every chat id via sendMessage.

pub fn start(
  broadcast broadcast: Broadcast(a),
) -> Result(BroadcastHandle(a), error.TelegaError)

Start the broadcast in a background actor and return a handle.

pub fn with_on_progress(
  broadcast broadcast: Broadcast(a),
  on_progress on_progress: fn(BroadcastProgress) -> Nil,
) -> Broadcast(a)

Set a progress callback, called from the broadcast actor after every processed chat id. Keep it cheap — a slow callback slows the broadcast.

pub fn with_rate(
  broadcast broadcast: Broadcast(a),
  rate rate: Int,
  window_ms window_ms: Int,
) -> Broadcast(a)

Set the pacing: at most rate sends per window_ms milliseconds. Default is 25 per 1000 ms. Values below 1 are clamped to 1.

Search Document