Rust Telegram sidecar adapter
Status: shipped in #5831. Crate:
sdk/rust/librefang-sidecar-telegram/. Parity reference:sdk/python/librefang/sidecar/adapters/telegram.py.
sdk/rust/librefang-sidecar-telegram/ is the first-party Telegram channel adapter built against the Rust sidecar SDK.
It is a feature-parity port of the Python Telegram adapter — same wire shapes, same Schema, same access-control semantics, same emoji-reaction translation map — packaged as a standalone binary.
When to pick this over the Python adapter
Both adapters speak the same protocol and the supervisor cannot tell them apart. Pick the Rust binary when:
- the host has no Python runtime (minimal container, Alpine-based image, distroless deploy);
- per-respawn startup latency matters (the Rust binary is ready in ~10 ms; the Python interpreter spends 100-300 ms on import even for an empty adapter);
- the deployment is bandwidth-constrained or memory-constrained and the ~3 MB stripped Rust binary beats a Python image plus
httpxand friends.
Otherwise the Python adapter is fine; both ship the same capability set and the same security model.
Configure
[[sidecar_channels]]
name = "telegram"
command = "/abs/path/to/target/release/librefang-sidecar-telegram"
args = []
restart = true
[sidecar_channels.secrets]
TELEGRAM_BOT_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
[sidecar_channels.env]
ALLOWED_USERS = "123456789, @your_username" # optional, empty ⇒ open
TELEGRAM_CLEAR_DONE_REACTION = "true" # optional, default false
The dashboard's configure form is populated from the schema the binary serves via --describe, so operators set the bot token and ALLOWED_USERS through the UI without hand-editing config.toml.
| Field | Kind | Required | Notes |
|---|---|---|---|
TELEGRAM_BOT_TOKEN | secret | yes | BotFather token. Dashboard masks it on display; redacted from every error path. |
ALLOWED_USERS | list, advanced | no | CSV of numeric user IDs or @usernames (case-insensitive). Empty ⇒ open. |
TELEGRAM_CLEAR_DONE_REACTION | bool, advanced | no | When true, the ✅ "done" reaction clears prior reactions instead of stacking. |
Capabilities
Declared in the ready event, gated by the supervisor when relevant:
| Capability | Inbound | Outbound |
|---|---|---|
typing | — | Typing → sendChatAction |
reaction | — | Reaction → setMessageReaction |
interactive | callback_query → ButtonCallback | Interactive / EditInteractive |
thread | message_thread_id carried in metadata | message_thread_id end-to-end |
streaming | — | StreamStart / Delta / End → debounced editMessageText (1 s) |
Architecture
The adapter is laid out in five layers:
| Layer | Responsibility |
|---|---|
api/ | Bot API client (reqwest + rustls), value types (Update, Message, User, all media variants), typed error. |
format/ | Markdown → Telegram HTML converter (markdown.rs), HTML sanitiser with tag allowlist (sanitize.rs), UTF-16 chunker with tag-aware rebalancing (chunk.rs). |
translator.rs | Inbound Update → MessageBuilder-shaped Value event. |
dispatcher.rs | Outbound Content → Bot API call (Text, Image, File, FileData, Voice, Video, Audio, Animation, Sticker, Location, Command, Interactive, EditInteractive, DeleteMessage, MediaGroup, Poll). |
adapter.rs | The TelegramAdapter impl: produce-side long-poll, on_send / on_command dispatch, streaming-edit state map. |
Inbound
produce()long-pollsgetUpdateswithallowed_updates = ["message", "edited_message", "callback_query", "poll_answer"].- For each update, the access-control gate extracts a sender (from
message.from,message.sender_chat,callback_query.from, orpoll_answer.user) and checksAllowList::permits(user_id, username). Disallowed updates are silently skipped (no log line so the supervisor's stderr never carries sender identity). update_to_eventdispatches by update kind and emits onemessage-shaped event. Media payloads callgetFileto resolve thefile_idto a public URL; ongetFilefailure the path falls back to a[Photo received: <cap>]/[Document received: <filename>]/[Voice message, Ns]text placeholder matching the Python adapter byte-for-byte, so the user's caption survives even when the URL doesn't.edited_messageupdates emit withmetadata.edited = trueplusmetadata.edit_date(when Telegram provides it) so the supervisor can dedupe or treat as an edit; without this they would be indistinguishable from a fresh turn (Telegram reuses the original message_id).
Outbound
dispatch_content matches the externally-tagged ChannelContent JSON shape and calls the appropriate Bot API method.
Captioned media (Image / Voice / Video / Audio / Animation) run captions through the same format_and_sanitize pipeline as text, with a can't parse entities plain-text fallback so a malformed sanitiser output never silently drops the media.
MediaGroup is atomic on Telegram's side: 1-item groups fall back to single sends, >10-item groups are chunked into batches of 10, and nested MediaGroups (recursive payloads) are rejected before the recursion can happen.
Streaming
A single placeholder message (…) is sent on StreamStart; subsequent StreamDelta events accumulate into a per-stream buffer and edit that placeholder via editMessageText, debounced to one edit per second.
StreamEnd flushes the final buffer.
Edit failures are silently tolerated for message is not modified (debounce ticked without new content) and fall back to plain text on can't parse entities; everything else is logged.
Empty/whitespace bodies short-circuit so StreamStart followed by StreamEnd with no deltas doesn't trigger Telegram's 400 message text is empty.
Text-rendering pipeline
raw text ──▶ markdown_to_telegram_html ──▶ sanitize_telegram_html ──▶ split_to_utf16_chunks ──▶ sendMessage(parse_mode=HTML)
│
└─ on 400 "can't parse entities":
html_to_plain(chunk) ──▶ sendMessage(parse_mode=None)
- Markdown subset. Code fences, headings (
#through######), blockquotes, ordered / unordered lists, bold (**…**), italic (*…*), inline code (`…`), links ([label](url)). Inline-code placeholders use Private-Use-Area sentinels (U+E000 / U+E001) thatescape_htmlstrips from input, so an adversarial message containing those bytes cannot collide with the placeholder scheme and inject<code>past the sanitiser's tag allowlist. Link URLs are"-escaped before being inserted into the<a href="…">attribute, so a"in a query string doesn't terminate the attribute early. - HTML sanitiser. Allowlist of
b,i,u,s,em,strong,a,code,pre,blockquote,tg-spoiler,tg-emoji— matches Telegram's documented HTML subset.<a href>is enforced againsthttps:/http:/mailto:/tg:schemes; anything else (includingjavascript:/data:) drops the tag entirely. Unclosed tags are auto-balanced at end-of-input. - UTF-16 chunker (4096-unit Telegram limit). Telegram counts code units, not bytes or Unicode scalars; non-BMP characters count as 2.
The chunker is tag-aware: an
<a href="…">opened in one chunk has matching</a>appended AND<a href="…">re-emitted at the start of the next chunk, with the full attribute string preserved, so the user's formatting carries across boundaries.
Security
- Bot token. Stored in
BotClient.api_root/file_root(baked into the request URL). Any error path that returns the URL or response body to the operator goes throughBotClient::redact(s)which replaces the literal token with[REDACTED];From<reqwest::Error>strips the URL entirely viae.without_url()before constructingError::Http. Logs, protocol error events, andDisplayimpls never leak the token. - Allowlist.
AllowList::permits(user_id, username)checks numeric IDs by exact match and@usernamescase-insensitively (with optional leading@). Empty allowlist ⇒ open; the gate runs against every inbound event kind includingpoll_answer(skipping it would have let any Telegram user vote in the bot's polls and have the PollAnswer event reach the agent). - MediaGroup recursion. Each
MediaGroup.items[i]is checked for aMediaGroupkey (across all keys, not just the first) before any recursive dispatch; the heap-allocated future stack cannot be blown by an adversarial nested payload. - FileData byte decode. The JSON
dataarray is decoded strictly: any element that is not a non-negative integer in[0, 255]producesError::Otherrather than silently dropping or truncating.
Rate-limiting and retry
call_jsonandsend_multipartretry once on a 429 Too-Many-Requests using the server-suppliedretry_after, capped atMAX_RETRY_AFTER_SECS = 300so a multi-hour flood-wait surfaces as an error instead of stalling the produce loop indefinitely.- The long-poll loop in
produceapplies its own exponential backoff (1 s → 300 s cap) on non-timeoutgetUpdatesfailures, distinct from the per-call retry insidecall_json.
Python-parity deltas
The port is feature-parity by intent but has three documented deliberate divergences:
parse_commandusesMessageEntity.length(UTF-16) instead oftxt.split(" ", 1)./help:fooreturns("help", [":foo"])in Rust (Bot-API-correct); Python returns("help:foo", [])(a long-standing parser bug).MediaGroupwith >10 items is chunked into batches of 10. Python raisesValueError.channel-type chats are not treated as group. Both adapters now useis_group = chat_type in {group, supergroup}; the original Rust draft includedchannel, the parity fix narrowed it.
Cross-language tests pin the rest of the wire shape (media_placeholder_matches_python_labels covers all eight placeholder variants byte-for-byte).
Verification
# In the sanctioned dev container (named volumes keep the cargo target isolated from the host).
docker build -t librefang-rust-dev:latest -f Dockerfile.rust-dev .
docker run --rm \
-v "$(git rev-parse --show-toplevel)":/work \
-v librefang-cargo:/cargo -v librefang-target:/target \
-e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target \
-w /work/sdk/rust/librefang-sidecar-telegram \
librefang-rust-dev:latest \
sh -c 'export PATH=/usr/local/cargo/bin:$PATH; cargo test && cargo clippy --all-targets -- -D warnings && cargo fmt --check'
40 unit tests, zero clippy warnings, formatter clean.
Live LLM testing (against a real Bot API + librefang start) is human-only.
See also
- Rust sidecar SDK — the SDK this adapter is built against.
- Channel adapters — the operator-facing channel directory.
- In-repo:
docs/architecture/rust-telegram-sidecar.md(the canonical contributor reference) anddocs/architecture/sidecar-channels.md.