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 httpx and 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.

FieldKindRequiredNotes
TELEGRAM_BOT_TOKENsecretyesBotFather token. Dashboard masks it on display; redacted from every error path.
ALLOWED_USERSlist, advancednoCSV of numeric user IDs or @usernames (case-insensitive). Empty ⇒ open.
TELEGRAM_CLEAR_DONE_REACTIONbool, advancednoWhen true, the ✅ "done" reaction clears prior reactions instead of stacking.

Capabilities

Declared in the ready event, gated by the supervisor when relevant:

CapabilityInboundOutbound
typingTypingsendChatAction
reactionReactionsetMessageReaction
interactivecallback_queryButtonCallbackInteractive / EditInteractive
threadmessage_thread_id carried in metadatamessage_thread_id end-to-end
streamingStreamStart / Delta / End → debounced editMessageText (1 s)

Architecture

The adapter is laid out in five layers:

LayerResponsibility
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.rsInbound UpdateMessageBuilder-shaped Value event.
dispatcher.rsOutbound Content → Bot API call (Text, Image, File, FileData, Voice, Video, Audio, Animation, Sticker, Location, Command, Interactive, EditInteractive, DeleteMessage, MediaGroup, Poll).
adapter.rsThe TelegramAdapter impl: produce-side long-poll, on_send / on_command dispatch, streaming-edit state map.

Inbound

  1. produce() long-polls getUpdates with allowed_updates = ["message", "edited_message", "callback_query", "poll_answer"].
  2. For each update, the access-control gate extracts a sender (from message.from, message.sender_chat, callback_query.from, or poll_answer.user) and checks AllowList::permits(user_id, username). Disallowed updates are silently skipped (no log line so the supervisor's stderr never carries sender identity).
  3. update_to_event dispatches by update kind and emits one message-shaped event. Media payloads call getFile to resolve the file_id to a public URL; on getFile failure 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.
  4. edited_message updates emit with metadata.edited = true plus metadata.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) that escape_html strips 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 &quot;-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 against https: / http: / mailto: / tg: schemes; anything else (including javascript: / 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 through BotClient::redact(s) which replaces the literal token with [REDACTED]; From<reqwest::Error> strips the URL entirely via e.without_url() before constructing Error::Http. Logs, protocol error events, and Display impls never leak the token.
  • Allowlist. AllowList::permits(user_id, username) checks numeric IDs by exact match and @usernames case-insensitively (with optional leading @). Empty allowlist ⇒ open; the gate runs against every inbound event kind including poll_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 a MediaGroup key (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 data array is decoded strictly: any element that is not a non-negative integer in [0, 255] produces Error::Other rather than silently dropping or truncating.

Rate-limiting and retry

  • call_json and send_multipart retry once on a 429 Too-Many-Requests using the server-supplied retry_after, capped at MAX_RETRY_AFTER_SECS = 300 so a multi-hour flood-wait surfaces as an error instead of stalling the produce loop indefinitely.
  • The long-poll loop in produce applies its own exponential backoff (1 s → 300 s cap) on non-timeout getUpdates failures, distinct from the per-call retry inside call_json.

Python-parity deltas

The port is feature-parity by intent but has three documented deliberate divergences:

  1. parse_command uses MessageEntity.length (UTF-16) instead of txt.split(" ", 1). /help:foo returns ("help", [":foo"]) in Rust (Bot-API-correct); Python returns ("help:foo", []) (a long-standing parser bug).
  2. MediaGroup with >10 items is chunked into batches of 10. Python raises ValueError.
  3. channel-type chats are not treated as group. Both adapters now use is_group = chat_type in {group, supergroup}; the original Rust draft included channel, 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) and docs/architecture/sidecar-channels.md.