Raxol Telegram
Telegram surface bridge for Raxol. Renders TEA apps as monospace code blocks in Telegram chats with inline keyboard navigation.
What this is for
The Telegram bot ecosystem is moving in three directions at once: bots-as-group-admins screening join requests, rich-text messages via the Bot API 10.1 sendRichMessage family, and MCP-driven bots controlled by external agents. The wrapper-library space is crowded (Telegex, GramIO, ferobot, rustigram) but they all stop at "render a message, parse an update." None of them give you a TEA application model that runs the same way in a terminal, a browser, and a chat.
raxol_telegram plugs into the bottom of your TEA stack. A Raxol.Core.Runtime.Lifecycle instance per chat renders to monospace <pre> HTML with inline keyboards instead of cells and ANSI. A Raxol.Telegram.Guardian behaviour (ADR-0014) handles the new admin-bot surface separately, so join-request screening and interactive sessions stay decoupled. For high-volume groups, gramiojs/telegram-bot-api's self-hosted Docker image bypasses the 30 req/s public API limit.
Install
{:raxol_telegram, "~> 0.2"}
For runtime Telegram API access, add:
{:telegex, "~> 1.8"}
Usage
# In your supervision tree
children = [
{Raxol.Telegram.Supervisor, app_module: MyApp.CounterApp}
]
Rich messages (Bot API 10.1)
Bot API 10.1 (released 2026-06-11) added sendRichMessage for structured content beyond MarkdownV2: tables, collapsible sections, headings, math, sub/superscript, and an expanded 32,768 character cap with a Show More boundary.
import Raxol.Telegram.RichMessage
msg = rich_message([
heading(1, "Build status"),
paragraph([bold("master"), text(" is red")]),
details([text("Show stacktrace")], [
paragraph([code("UndefinedFunctionError")])
]),
table([
[cell([bold("Module")]), cell([bold("Coverage")])],
[cell([text("Bot")]), cell([text("94%")])]
]),
math(~S"\\int_0^1 x^2 dx = \\frac{1}{3}")
])
{:ok, _result} = Raxol.Telegram.RichMessage.Sender.send(chat_id, msg)
Show More chunking wraps content past ~8,000 characters in a collapsible details block automatically. Disable with chunk: false. Content over 32,768 characters returns {:error, :too_long} rather than truncating.
HTTP transport. Telegex 1.8 predates Bot API 10.1, so the Sender uses Req (an optional dep) directly. Without Req, Sender.send/3 returns {:error, :req_not_available} and you can call RichMessage.to_payload/3 yourself to get the JSON body. For high-volume deployments (Telegram's public API caps at 30 req/s), point :api_base at a self-hosted Bot API server:
Raxol.Telegram.RichMessage.Sender.send(chat_id, msg,
bot_token: token,
api_base: "https://bot-api.internal"
)
Telemetry. [:raxol_telegram, :rich_message, :sent] and [:raxol_telegram, :rich_message, :error]. Both carry chat_id, byte_size (encoded payload), and chunked? metadata.
Wire format caveat. Bot API 10.1's RichMessage / RichText / RichBlock class names are documented; the exact JSON discriminator field convention was inferred from the existing MessageEntity precedent (snake_case type values: "bold", "table_cell", "details", etc). If Telegram's wire format differs once schemas are published in full, the only adjustment is the discriminator string in each builder.
Polls with hyperlinks
Telegram's June 2026 release surfaced hyperlinks in poll options as a
supported UX. Underlying Bot API exposed text_entities on
InputPollOption before that; Raxol.Telegram.Poll gives it a typed
Elixir surface.
import Raxol.Telegram.Poll
send_poll(chat_id, "Which doc?",
[
"Plain text option",
link_option("Read ADR-0014", "https://github.com/example/adr/0014"),
%{
text: "See the source",
entities: [link_entity(4, 3, "https://github.com/example")]
}
],
is_anonymous: false,
allows_multiple_answers: true,
bot_token: token
)
Option shapes: plain string, {:link, label, url} for an option whose
entire text is one link, or a %{text: ..., entities: [...]} map for
arbitrary entity layouts. link_entity/3 builds a text_link entity at a
specific UTF-16 offset.
Validation: option count is checked client-side (Telegram requires 2-10). Other constraints (text length, entity bounds) are left to the API.
HTTP transport. Uses Raxol.Telegram.HTTP like other 10.x endpoints,
so :bot_token, :api_base, and :post_fn work uniformly.
AI guardian (chat join request screening)
Bot API 10.0 added chat_join_request updates and 10.1 added
answerChatJoinRequestQuery. Bots that hold admin permissions in a group
can screen applicants before they're admitted, optionally pushing them
through a mini-app for verification. See
ADR-0014 for the full
design rationale.
Implement the Raxol.Telegram.Guardian behaviour with a single screen/1
callback. The return value drives what happens next.
defmodule MyApp.SpamFilter do
@behaviour Raxol.Telegram.Guardian
@impl true
def screen(applicant) do
cond do
blocked?(applicant.user_id) ->
{:decline, "user previously banned"}
missing_bio?(applicant) ->
{:ask_mini_app, "https://verify.myapp.com", "Verify"}
true ->
{:approve, nil}
end
end
end
Configure the Guardian module via app env or pass per-call:
config :raxol_telegram, guardian: MyApp.SpamFilter
# or
Raxol.Telegram.Bot.handle_update(update, guardian: MyApp.SpamFilter, bot_token: token)
The :ask_mini_app path is a hand-off: the bot sends a private message to
the applicant with a web_app inline keyboard button pointing at your
mini-app URL. Raxol.Telegram.MiniApp.build_url/2 automatically appends
chat_id, user_id, and query_id as query params so your mini-app
backend can call approveChatJoinRequest / declineChatJoinRequest /
answerChatJoinRequestQuery itself with the right context. raxol_telegram
does not host the mini-app.
Bot API path selection. When the applicant carries a query_id (Bot
API 10.1+), Guardian.apply_decision/3 uses answerChatJoinRequestQuery.
Without query_id it falls back to approveChatJoinRequest /
declineChatJoinRequest. The 10.1 path also auto-falls back on
bot_api_error responses (e.g. against an older API server).
Telemetry. [:raxol_telegram, :guardian, :received | :approved | :declined | :asked | :denied | :error]. All events carry chat_id and user_id; terminal events also carry reason (or url for :asked), source (:bot or :mcp), and error_reason for failures.
MCP exports. Raxol.Telegram.Guardian.MCPTools.register() exposes four
tools (telegram_guardian_approve, _decline, _screen, _list_pending)
through Raxol.MCP.Registry. Symmetric with ADR-0012: external agents can
observe and override Guardian decisions over MCP. Registration is opt-in
and requires raxol_mcp at runtime; without it, register/0 returns
{:error, :raxol_mcp_not_available} and the rest of the package keeps
working.
Bot integration
Wire Raxol.Telegram.Bot.handle_update/1 into your Telegex polling loop or webhook handler:
def handle_update(update) do
Raxol.Telegram.Bot.handle_update(update)
end
The bot handles /start and /stop commands. Other messages and inline keyboard taps are translated to Raxol events and routed to per-chat TEA sessions.
How it works
- Each Telegram chat gets an independent TEA lifecycle (session)
- The screen buffer renders as
<pre>HTML in Telegram messages - Navigation uses inline keyboards (arrows, tab, enter, quit)
- Button Components in the view tree become additional inline keyboard buttons
- Sessions auto-expire after 10 minutes of inactivity
- Message editing avoids spam (re-renders edit the existing message)
Session limits
The SessionRouter enforces a configurable max_sessions cap (default: 1000) to prevent resource exhaustion:
{Raxol.Telegram.SessionRouter, app_module: MyApp, max_sessions: 500}
Per-chat rate-limit cooldown entries (5s window after the last session start) are auto-purged on every new session, so memory stays bounded under high chat churn. Raxol.Telegram.SessionRouter.stats/0 reports current session count + cooldown-map size; purge_stale_cooldowns/0 is exposed as an ops tool too.
Telemetry
Attach to these events for observability:
| Event | Measurements | Metadata |
|---|---|---|
[:raxol_telegram, :bot, :received] |
system_time |
chat_id, kind: :message | :callback, byte_size | data |
[:raxol_telegram, :bot, :denied] |
system_time |
chat_id, kind |
[:raxol_telegram, :session, :started] |
system_time |
chat_id |
[:raxol_telegram, :session, :rejected] |
system_time |
chat_id, reason: :max_sessions_reached | :rate_limited |
[:raxol_telegram, :session, :stopped] |
system_time |
chat_id, reason: :explicit | :process_down (with down_reason) |
Live test
examples/telegram_demo.exs runs a real Telegram bot against a counter TEA app. Requires a token from @BotFather:
cd packages/raxol_telegram
TELEGRAM_BOT_TOKEN=<your-token> \
TELEGRAM_ALLOWED_CHAT_IDS=123456789 \
mix run --no-halt examples/telegram_demo.exs
See main docs for the full Raxol framework.
License
MIT. See LICENSE.md.