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:
sent— delivered, with the value returned by the send functionblocked— HTTP 403: the user blocked the bot, was deactivated, or kicked the botfailed— everything else after retries
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
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.
Nonefor 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
-
Trueif the broadcast was stopped viacancel; 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.