Replies: shortcuts, entities and streaming

telega/reply sends things to the chat of the current update. Most of it is a thin, complete wrapper over the corresponding telega/api call — with_photo, with_poll, with_invoice and friends take the full parameter record. This guide covers the parts that are not thin: the shortcuts a handler reaches for every time, the escape-free way to format text, and streaming an answer into a single message.

Shortcuts

reply.text sends a message and hands the context straight back, which is the shape a handler wants:

fn handle_text(ctx, text) {
  reply.text(ctx, "You said: " <> text)
}

No let assert, no Message to thread through a handler that does not need one. The failure is a TelegaError, so a router built out of these shortcuts has TelegaError as its error type; a bot with an error type of its own keeps using with_text and maps the error itself.

ShortcutWhat it does
reply.text(ctx, text)Send, return the context unchanged
reply.quote(ctx, text)Send as a reply to the incoming message
reply.remove_keyboard(ctx, text)Send and take the custom keyboard off screen
reply.edit_callback_message(ctx, text)Replace the text of the message the pressed button sits on
reply.edit_callback_markup(ctx, text, markup)The same, and replace the inline keyboard
reply.answer_toast(ctx, text)Answer the callback query with a notification
reply.answer_alert(ctx, text)Answer it with a modal the user must dismiss
reply.answer_quietly(ctx)Answer it with nothing — just stop the spinner

The four callback shortcuts need a callback query in ctx.update and return an error otherwise; silently sending a new message instead would be worse than refusing. Every callback query must be answered, or the client keeps a spinner on the button for a minute.

quote on an update that is not about a message (an inline query, a poll answer) has nothing to quote, so it sends the text plainly.

Entities instead of a parse mode

A parse mode makes characters special, and anything a user typed then has to be escaped — one missed _ breaks the whole message. The alternative is to send the text raw and describe the formatting positionally:

import telega/format as fmt

let document =
  fmt.build()
  |> fmt.text("Found: ")
  |> fmt.bold_text(whatever_the_user_typed)
  |> fmt.to_formatted()

reply.with_entities(ctx, document)

No parse mode is sent, so no character in the text is special. format.entities does the same conversion outside of reply:

let #(text, entities) = format.entities(document)

Offsets and lengths are in UTF-16 code units, as the Bot API requires — an emoji outside the BMP counts as two. A zero-length segment produces no entity (Telegram rejects those), a Mention renders as @username with a mention entity over it, and a nested group contributes its children’s entities without one of its own.

Streaming

An LLM produces tokens faster than Telegram lets anyone edit a message. reply.stream_text collects them and flushes on a clock:

import gleam/yielder

fn handle_prompt(ctx, prompt) {
  use _ <- result.try(reply.stream_text(
    ctx,
    chunks: llm_tokens(prompt),
    every_ms: 700,
  ))
  Ok(ctx)
}

chunks is any Yielder(String), pulled to exhaustion. The first chunk sends a message at once — the user should not sit through a window of silence — and after that the message is edited at most once per every_ms, with a cursor appended so it reads as “still writing”. A final edit drops the cursor and shows the complete text. Four hundred tokens cost a handful of API calls, not four hundred.

reply.stream_into fills in a message that already exists, which is the shape a real assistant wants: answer immediately so the user knows they were heard, then write into that same message.

use placeholder <- result.try(reply.with_text(ctx, "Thinking…"))
use _ <- result.try(reply.stream_into(
  ctx,
  message_id: placeholder.message_id,
  chunks: llm_tokens(prompt),
  every_ms: 700,
))

Pick every_ms above Telegram’s per-chat pacing. Below ~500 ms in a private chat the edits queue up behind the rate limiter and the animation stutters; 700 ms is a good default.

Edits that fail while the stream runs are swallowed: a flood wait halfway through must not cost the user the answer, and the next flush carries the text the failed one would have. The first send and the final edit are not — their failure is returned. A stream that produces no text sends nothing and is an error, since Telegram has no empty message to return.

Working example: examples/07-streaming-bot.

Search Document