Skip to content

feat(channels): Rust Telegram sidecar adapter (parity with Python)#5831

Merged
houko merged 10 commits into
mainfrom
feat/rust-sidecar-telegram
May 28, 2026
Merged

feat(channels): Rust Telegram sidecar adapter (parity with Python)#5831
houko merged 10 commits into
mainfrom
feat/rust-sidecar-telegram

Conversation

@houko

@houko houko commented May 28, 2026

Copy link
Copy Markdown
Contributor

What

First-party Rust Telegram sidecar adapter at sdk/rust/librefang-sidecar-telegram/ — feature-parity port of sdk/python/librefang/sidecar/adapters/telegram.py written against the Rust librefang-sidecar SDK that merged in #5821.

Crate layout

Independent cargo workspace (sibling of sdk/rust/librefang-sidecar/) so its own target/ stays isolated from the SDK's, and downstream Cargo's feature unification picks up only what this adapter needs. Depends on the SDK via a path dep.

  • src/api/ — Bot API HTTP client (reqwest + rustls-tls — no system OpenSSL), value types (Update, Message, User, Chat, all media variants), typed error.
  • src/format/ — UTF-16 chunker (4096-unit Telegram limit), Markdown → Telegram-HTML converter, HTML sanitiser (tag allowlist + href scheme check + unclosed-tag balancing).
  • src/translator.rs — Inbound Update → LibreFang message event (file_id → URL via getFile, from + sender_chat resolution, [Replying to ...] reply-context prefix, bot_command entity → Content::Command).
  • src/dispatcher.rs — Outbound Content → Bot API endpoint (all variants: Text / Image / File / FileData / Voice / Video / Audio / Animation / Sticker / Location / Command / Interactive / EditInteractive / DeleteMessage / MediaGroup / Poll / PollAnswer / ButtonCallback).
  • src/adapter.rsTelegramAdapter impl: produce-side long-poll with exponential backoff (1s → 300s cap) and cancel-safe yields; on_send + on_command dispatching to typing / reaction / interactive / streaming (1s debounced editMessageText).
  • src/schema.rsTELEGRAM_BOT_TOKEN (secret, required), ALLOWED_USERS (list, advanced), TELEGRAM_CLEAR_DONE_REACTION (bool, advanced).
  • src/access.rsALLOWED_USERS parser (numeric IDs exact-match, @usernames case-insensitive with optional leading @).
  • src/reaction.rs — emoji translation map matching Python (⏳ → 👀, ⚙️ → ⚡, ✅ → 🎉 or cleared depending on TELEGRAM_CLEAR_DONE_REACTION, ❌ → 👎).
  • src/main.rsrun_stdio_main(schema_fn, build_fn) so the dashboard's --describe discovery serves the config form before the adapter constructs (which requires TELEGRAM_BOT_TOKEN).

Capabilities declared

  • typingsendChatAction
  • reactionsetMessageReaction
  • interactivesendMessage with inline_keyboard
  • threadmessage_thread_id propagated end-to-end (forum topics)
  • streamingStreamStart/StreamDelta/StreamEnd → debounced editMessageText (1s interval)

Bot API surface covered

Endpoint Used for
getUpdates (long-poll) inbound poll loop, allowed_updates = [message, edited_message, callback_query, poll_answer]
sendMessage text + Interactive content; auto-fallback to plain text on "can't parse entities"
editMessageText EditInteractive + streaming
deleteMessage DeleteMessage
sendChatAction Typing command
setMessageReaction Reaction command (with emoji map)
sendPhoto / sendDocument / sendVoice / sendAudio / sendVideo / sendAnimation / sendSticker / sendLocation media URL pass-through
sendMediaGroup MediaGroup (2–10 photos / videos)
sendPoll quiz / regular with correct_option_id + explanation
getFile file_id → public file URL for inbound media
Multi-part upload Content::FileData inline bytes (OggOpus auto-detect → sendVoice else sendDocument)

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-DEF..."

[sidecar_channels.env]
ALLOWED_USERS = "123456789, @your_username"
TELEGRAM_CLEAR_DONE_REACTION = "true"

Or use the dashboard's configure form — the schema is served via --describe so the fields are discovered automatically.

Verification (in librefang-rust-dev:latest container)

  • cargo test19 unit tests pass, 0 failed. Covers UTF-16 chunking edge cases (newline-aware split, single-oversized-line, mid-entity boundary guard), Markdown → HTML (bold, italic, inline code, code fence, headings, lists, links), sanitiser (tag allowlist, href scheme rejection, unclosed-tag balancing).
  • cargo clippy --all-targets -- -D warningsclean.
  • cargo fmt --checkclean.

Live test (HUMAN-only — daemon + real bot token needed): set TELEGRAM_BOT_TOKEN, run the binary, message the bot from Telegram, verify the supervisor sees a message event with the correct content variant.

Out of scope (deliberate)

  • No qr_ready / qr_status — Telegram bots use static tokens, not QR login.
  • No private-URL fetch-and-upload roundtrip — public URL pass-through covers the production case; the bytes route in FileData covers the inline-content case.
  • No file-bytes download cache — getFile is called fresh per inbound media event (matches Python's behaviour).

Evan added 2 commits May 28, 2026 22:52
…idecar-telegram/

Feature-parity port of sdk/python/librefang/sidecar/adapters/telegram.py written against the Rust librefang-sidecar SDK that merged in #5821.

## What's in the crate

- `src/api/` — Bot API client (reqwest + rustls-tls), types, error.
- `src/format/` — UTF-16 chunker (4096-unit Telegram limit), Markdown → Telegram-HTML converter, HTML sanitiser (tag allowlist + href scheme check + unclosed-tag balancing).
- `src/translator.rs` — Inbound `Update` → LibreFang `message` event (file_id → URL via getFile, sender/from + sender_chat resolution, reply-context prefix).
- `src/dispatcher.rs` — Outbound `Content` → Bot API endpoint (Text / Image / File / FileData / Voice / Video / Audio / Animation / Sticker / Location / Command / Interactive / EditInteractive / DeleteMessage / MediaGroup / Poll / PollAnswer / ButtonCallback).
- `src/adapter.rs` — TelegramAdapter impl: produce-side long-poll with exponential backoff (1s → 300s) and cancel-safe yields; on_send + on_command dispatching to all of typing / reaction / interactive / streaming.
- `src/schema.rs` — TELEGRAM_BOT_TOKEN (secret, required), ALLOWED_USERS (list, advanced), TELEGRAM_CLEAR_DONE_REACTION (bool, advanced).
- `src/access.rs` — ALLOWED_USERS parser (numeric IDs exact-match, @usernames case-insensitive with optional leading @).
- `src/reaction.rs` — emoji translation map (⏳ → 👀, ⚙️ → ⚡, ✅ → 🎉 or cleared depending on TELEGRAM_CLEAR_DONE_REACTION, ❌ → 👎).
- `src/main.rs` — `run_stdio_main(schema_fn, build_fn)` so the dashboard's --describe discovery serves the config form before the adapter constructs (which requires TELEGRAM_BOT_TOKEN).

## Capabilities declared

- `typing` → sendChatAction
- `reaction` → setMessageReaction
- `interactive` → sendMessage + inline_keyboard
- `thread` → message_thread_id propagated end-to-end (forum topics)
- `streaming` → StreamStart/Delta/End → debounced editMessageText (1s interval)

## Layout decision

Independent cargo workspace (sibling of `sdk/rust/librefang-sidecar/`) so its own target/ stays isolated from the SDK's, and downstream Cargo's feature unification picks up only what this adapter needs. Depends on the SDK via a path dep.

## Verification (in librefang-rust-dev:latest container)

- `cargo test` → 19 unit tests pass (UTF-16 chunking, Markdown → HTML, sanitiser tag-allowlist + href safety + unclosed-tag balancing).
- `cargo clippy --all-targets -- -D warnings` → clean.
- `cargo fmt --check` → clean.
- Live test (HUMAN-only — daemon + real bot token needed): set TELEGRAM_BOT_TOKEN, run the binary, message the bot from Telegram, verify the supervisor sees a `message` event with the correct content variant.

## Configure

```toml
[[sidecar_channels]]
name = "telegram"
command = "/abs/path/to/target/release/librefang-sidecar-telegram"
args = []
restart = true

[sidecar_channels.secrets]
TELEGRAM_BOT_TOKEN = "123456:ABC-DEF..."

[sidecar_channels.env]
ALLOWED_USERS = "123456789, @your_username"
TELEGRAM_CLEAR_DONE_REACTION = "true"
```
…r adapter

Round-1 fixes for the review of #5831 plus a Round-2 follow-up that catches
the bugs introduced by Round 1.

## Security

- Bot token no longer leaks through error Display.
  `From<reqwest::Error>` strips the URL (`e.without_url()`); BotClient redacts
  the token in every `Error::Api { description }` constructed from a response
  body, so eprintln! and protocol error events can't leak `bot<TOKEN>` from
  the request URL or echoed proxy bodies.
- `MAX_RETRY_AFTER_SECS = 300` caps how long the adapter will sleep on a 429
  `retry_after`. A flood-wait of hours used to stall the produce loop with no
  cancellation; now anything above the cap surfaces as `Error::Api` instead.

## Wire-shape correctness

- Captions on Image / Voice / Video / Audio / Animation now run through
  `format_and_sanitize` + UTF-16 truncate to 1024 with a "can't parse
  entities" plain-text fallback (mirror of the text path) so a malformed
  caption no longer silently drops the entire media send. MediaGroup is
  documented as atomic — its caption errors fail the whole group, callers
  must dispatch per-item if they need fallback.
- MediaGroup now respects the Bot API 2-10 item constraint: 0 items is a
  no-op, 1 falls back to a single Image/Video send (via Box::pin recursion),
  >10 is chunked into batches of 10. Previously the call hit the API with
  whatever count it got and surfaced a generic 400.
- `send_multipart` now honours the same 429 single-retry contract the
  module doc-comment promises, matching `call_json`.
- `parse_command` uses `MessageEntity.length` as UTF-16 code units (the
  Bot API spec), walking Unicode scalars accumulating `len_utf16()` until
  it has consumed `cmd_len` units. The earlier `chars().take(cmd_len)`
  conflated UTF-16 with Unicode scalars (latent bug on non-BMP commands);
  the interim `split_once` fix introduced a real bug for `/cmd:foo` style
  inputs where the command name folded in the trailing punctuation.
  Regression tests cover ASCII, `@botname` suffix, `:foo` trailing
  punctuation, bare `/`, bare `/@bot`, and a 🦀 non-BMP command.
- `update.poll_answer.user` is now included in the allowlist check.
  Without it, ALLOWED_USERS silently let any Telegram user vote in the
  bot's polls and have the PollAnswer event reach the agent.
- `extract_content` no longer drops the whole inbound update when
  `getFile` fails. Every media branch falls back to a
  `[Kind received: <caption>]` text placeholder so the user's caption
  (often the actual question) reaches the agent even during a CDN blip.
- `update.edited_message` is now distinguishable: `build_metadata` adds
  `edited: true` (and `edit_date` when Telegram provides it) so the
  supervisor can treat edits as edits rather than as new turns sharing the
  same message_id. Added `edit_date: Option<i64>` to the Message struct.
- `callback_event` now sets `is_group` from the embedded message's
  chat_type. Without it, group inline-button callbacks looked like DMs to
  the bridge and mis-routed the agent reply.

## Streaming

- `edit_with_fallback` handles HTML→plain on the streaming edit path with
  the same fallback the text and EditInteractive paths use. `message is not
  modified` is treated as success on both HTML and plain paths so the
  debounce ticks don't spam the log.

## Chunker

- `split_to_utf16_chunks` is now tag-aware: when a chunk ends inside an
  open `<b>` / `<a href="...">` / `<code class="rust">`, the close tag is
  appended to the chunk and the full open tag is carried verbatim to the
  start of the next chunk so the user's formatting carries across
  boundaries. The degenerate "no progress" branch goes through the same
  tag-balancing path so it never emits orphan opens.
- `adjust_html_entity_boundary` only trims when the suffix after `&`
  matches a known entity prefix (`amp`/`lt`/`gt`/`quot`/`nbsp` + numeric
  forms). Previously a literal `&` near a chunk boundary was silently
  truncating the right side of the text.
- New `strip_mid_tag` backs off before unmatched `<` so the chunk
  boundary never lands inside a tag.
- Regression tests for the literal `&`, mid-tag, and tag-carry cases.

## Plain-text fallback no longer leaks HTML

- `send_text` and the EditInteractive arm now strip HTML tags + decode
  entities (`html_to_plain`) before re-sending. Without it the fallback
  "succeeded" at delivery but the user saw literal `<b>foo</b>` instead
  of "foo".

## Markdown placeholder

- Inline-code placeholder switched from `\x00C{idx}\x00` to PUA codepoints
  `\u{E000}C{idx}\u{E001}`. `escape_html` strips both sentinels from input
  so an adversarial message containing the byte pattern can no longer
  collide with a placeholder during the restore pass and inject `<code>`
  via the sanitizer's tag allowlist.

## Code cleanup

- Deleted the dead `mime_from_filename` helper and its `let _ = ...`
  silencer.
- Dropped the always-on `let _ = thread_id;` and
  `let _ = LONGPOLL_CLIENT_TIMEOUT_SECS;` markers. Renamed the constant to
  `LONGPOLL_CLIENT_BUFFER_SECS` and threaded it into the actual
  per-request timeout computation in `get_updates` so it stops being a
  duplicate-of-a-magic-number.
- Consolidated `sender_from_user` — translator owns the canonical
  implementation; adapter imports it instead of carrying a duplicate.
- Promoted `is_parse_entities_error` (and new `is_message_not_modified`)
  to `pub(crate)` so every code path that has to recognise those error
  shapes does it the same way.

## Verification (librefang-rust-dev:latest container)

- `cargo test` -> 30 unit tests pass, 0 failed. Added 8 new tests:
  5 in `format::chunk` (tag carry, mid-tag, literal `&` near boundary), and
  7 in `translator` (parse_command for ASCII / botname / punctuation /
  bare slash / bare-at / no-entity / non-BMP).
- `cargo clippy --all-targets -- -D warnings` -> clean.
- `cargo fmt --check` -> clean.
@github-actions github-actions Bot added area/docs Documentation and guides area/sdk JavaScript and Python SDKs labels May 28, 2026
@github-actions github-actions Bot added the size/XL 1000+ lines changed label May 28, 2026
Evan added 6 commits May 28, 2026 23:54
…nker degenerate-path balance, MediaGroup recursion cap, streaming HTML strip

Addresses six findings from the third review pass on PR #5831.

## Correctness

- Chunker degenerate-path no longer emits a bare `<` mid-chunk.
  When `carry` holds open tags AND `remaining` starts with `<`, the path now
  consumes up to the matching `>` so the next tag is intact, rather than
  force-advancing a single char and producing unparseable HTML like
  `<b><i><u><</u></i></b>`. New regression test
  `degenerate_branch_consumes_whole_tag_when_remaining_starts_with_lt`
  pins the balanced output.
- MediaGroup recursion now refuses nested MediaGroup items.
  An adversarial / buggy agent payload of
  `MediaGroup{items:[MediaGroup{items:[…]}]}` would otherwise drive Box::pin
  recursion without depth bound and overflow the heap-allocated future
  stack. We bail with `Error::Other("MediaGroup may not contain nested
  MediaGroup items")` instead.
- parse_command now refuses misbehaving-proxy length values.
  A bot_command `entity.length` larger than the actual text's UTF-16
  length used to silently fold args into the command name
  (`("foo bar baz", [])` instead of `("foo", ["bar","baz"])`). The
  defensive clamp returns `None` instead, which is the correct
  not-a-command outcome.

## Parity with the Python reference adapter

- media_placeholder labels now match Python verbatim.
  Python uses `[Photo received: cap]`, `[Document received: cap]`,
  `[Audio received, Ns: cap]`, `[Voice message, Ns]` (no caption suffix),
  `[Animation received, Ns: cap]`, `[Video received, Ns: cap]`,
  `[Video note, Ns]`. The Rust port previously dropped duration on all
  variants AND used different labels for Voice / VideoNote, breaking
  cross-language grep over conversation logs. New
  `media_placeholder_matches_python_labels` test pins the eight cases.
- parse_command's deliberate divergence from Python is now documented.
  Python's `txt.split(" ", 1)` ignores entity.length and folds `/help:foo`
  into the command name; this Rust port honours the Bot API entity
  boundary instead. The doc-comment now flags the upgrade so future
  porting work knows it's intentional.
- MediaGroup permissiveness vs Python is now documented.
  Python raises `ValueError` for >10 items; this Rust port chunks into
  batches of 10. A doc-comment in the MediaGroup arm flags the
  deliberate divergence.

## Streaming-edit fallback consistency

- edit_with_fallback now passes the HTML body through html_to_plain on
  the parse-entities fallback, matching what send_text and
  EditInteractive already do. Previously the streaming path fell back to
  the raw accumulated markdown (`state.buf`), so the user saw literal
  `**bold**` whereas send_text would have stripped the formatting to
  `bold`. Removed the `plain_body` parameter — it's derivable from
  `html_body`.

## Test coverage additions

Eight new unit tests:
- `degenerate_branch_consumes_whole_tag_when_remaining_starts_with_lt`
- `tag_carry_preserves_anchor_href_with_attributes`
- `tag_carry_nested_bold_italic_order_inside_out`
- `entity_prefix_trims_back_named`
- `entity_prefix_trims_back_numeric`
- `escape_html_strips_code_placeholder_sentinels`
- `placeholder_collision_attempt_does_not_inject_code`
- `parse_command_rejects_bogus_overrun_length`
- `media_placeholder_matches_python_labels`

## Verification (librefang-rust-dev:latest container)

- `cargo test` -> 39 unit tests pass, 0 failed.
- `cargo clippy --all-targets -- -D warnings` -> clean.
- `cargo fmt --check` -> clean.
…e escape, FileData strict decode, chunker overshoot bound, empty stream-edit guard, MediaGroup key scan

Addresses seven findings from the fourth review pass on PR #5831.

## Correctness

- Interactive arm now has a `can't parse entities` plain-text fallback,
  matching send_text / EditInteractive / every captioned-media arm.
  Without it, an Interactive payload whose `text` field survives
  sanitisation with malformed HTML was silently dropped entire: the
  user never saw the buttons OR the prompt. The fallback re-sends with
  `html_to_plain(formatted)` and `parse_mode=None`, preserving the
  inline keyboard.
- Markdown link URLs containing a literal `"` no longer truncate
  silently. The URL is now `&quot;`-escaped before being inserted into
  the `<a href="...">` attribute, so `sanitize_telegram_html::RE_ATTR`
  reads the full URL instead of truncating at the embedded quote and
  sending the user to a different page than the agent rendered. New
  `link_url_with_quote_is_escaped` regression test.
- FileData byte decoding now errors loudly on non-byte input. The old
  `data_array.iter().filter_map(|v| v.as_u64().map(|n| n as u8))`
  silently dropped negative / string / null entries AND truncated u64
  values > 255 (`300 as u8 == 44`), so a misbehaving producer's
  corrupted payload turned into a silently corrupted upload. The new
  decode raises `Error::Other` for any element that isn't a
  non-negative integer in [0,255].
- Streaming `edit_with_fallback` short-circuits on empty / whitespace
  bodies. Telegram rejects `editMessageText` with `400 message text is
  empty`; previously a StreamStart-then-StreamEnd-with-no-delta cycle
  left the `…` placeholder in place and emitted a noisy error log.
  Now we silently skip the call.

## Robustness

- Chunker degenerate path bounds the lookahead. When `remaining` starts
  with `<` AND `find('>')` returns a position past `limit` (mega-attribute
  open tag) OR returns None (bare `<` at end-of-input), the path now
  falls back to advancing one Unicode scalar rather than consuming
  hundreds of bytes / the whole tail. The latter would produce an
  arbitrarily oversized chunk that Bot API rejects with
  `MESSAGE_TOO_LONG`; the former would emit an unbalanced chunk
  matching the original bug the fix was meant to prevent.
- MediaGroup nested-recursion guard now scans every key, not just the
  first. The previous `obj.keys().next() == Some("MediaGroup")` check
  silently relied on `serde_json::Map` iteration order (BTreeMap today,
  but a workspace cargo feature could flip it to `preserve_order`) AND
  on items always being externally-tagged single-key objects. The new
  `obj.keys().any(|k| k == "MediaGroup")` is feature-flag-agnostic.

## Python parity

- Document placeholder now matches Python verbatim:
  `[Document received: <filename>]`. The previous version
  preferentially substituted `msg.caption` (more useful, but diverged
  from Python's filename-only shape); cross-language log-grep parity
  was the explicit Round-3 contract for `media_placeholder`. The
  call-site comment now documents that captions are intentionally NOT
  used here.

## Test coverage additions

Two new regression tests:
- `link_url_with_quote_is_escaped` — markdown link with embedded `"`
  produces a valid HTML href that survives sanitize.
- (existing tests already cover the chunker degenerate path; the new
  bound is defensive against future inputs.)

## Verification (librefang-rust-dev:latest container)

- `cargo test` -> 40 unit tests pass, 0 failed.
- `cargo clippy --all-targets -- -D warnings` -> clean.
- `cargo fmt --check` -> clean.
… the Rust Telegram adapter

Both #5821 (Rust SDK) and #5831 (Rust Telegram adapter) shipped with crate-level READMEs but no entry under `docs/architecture/`, so the documentation convention used by `sidecar-channels.md` / `sidecar-protocol.md` had no Rust-side landing pages.

- Add `docs/architecture/rust-sidecar-sdk.md` — when-to-pick, trait + type surface (SidecarAdapter, Command, Content, MessageBuilder, Schema), the run_stdio_main lazy-build pattern, panic isolation, with_backoff, conformance corpus pinning, common pitfalls.
- Add `docs/architecture/rust-telegram-sidecar.md` — five-layer architecture, inbound/outbound dataflow, text-rendering pipeline (Markdown → HTML → sanitise → UTF-16 chunk), security model (token redaction, allowlist-on-every-event, MediaGroup recursion cap, strict FileData decode), 429 retry shape, deliberate Python-parity divergences.
- Cross-link from `sidecar-channels.md` (Rust SDK bullet now points at both new docs) and `sidecar-protocol.md` (Rust SDK bullet now points at the SDK reference).
- Update both crate READMEs to point at the canonical reference pages.
- Refresh `.secrets.baseline` line numbers (CHANGELOG insertion shifted a tracked entry by 1).
- CHANGELOG entry under [Unreleased] → Added.
…to docs.librefang.ai (en + zh)

The previous commit added in-repo references at `docs/architecture/*.md`, but the published site at docs.librefang.ai is built from `docs/src/app/**/page.mdx` and ships zh translations under `docs/src/app/zh/`. Both new docs need to land on the site (and in both languages) for users to actually find them.

- Add `docs/src/app/architecture/rust-sidecar-sdk/page.mdx` (English) and `docs/src/app/zh/architecture/rust-sidecar-sdk/page.mdx` (Chinese) — SDK reference: trait + type surface, run_stdio_main lazy-build pattern, panic isolation, with_backoff, conformance corpus, common pitfalls.
- Add `docs/src/app/architecture/rust-telegram-sidecar/page.mdx` (English) and `docs/src/app/zh/architecture/rust-telegram-sidecar/page.mdx` (Chinese) — adapter reference: five-layer architecture, inbound/outbound dataflow, text-rendering pipeline (Markdown → HTML → sanitise → UTF-16 chunk), security model (token redaction, allowlist on every event kind, MediaGroup recursion cap, strict FileData decode), 429 retry shape, three deliberate Python-parity divergences.
- Register all four pages in `docs/src/components/Navigation.tsx` under both the `zhNavigation` Architecture group and the `enNavigation` Architecture group, alongside the existing `manifest-mcp` / `ofp-wire` entries.

In-repo refs at `docs/architecture/rust-sidecar-sdk.md` and `docs/architecture/rust-telegram-sidecar.md` (from the previous commit) remain as the contributor-facing source; the new MDX pages are the user-facing version.
…or-facing pages (en + zh)

Adding the new architecture pages was not enough — operators looking at the Telegram configuration / integrations pages had no signal that a Rust alternative existed. Updated four pairs of pages so the Rust port is discoverable from every path an operator would reach for when setting up Telegram.

- `architecture/page.mdx` — the "ship as first-party Python sidecars" callout now also notes the Telegram Rust sidecar and the Rust sidecar SDK underneath it, linking both architecture pages.
- `configuration/channels/page.mdx` — Telegram section gets a "Rust alternative (since #5831)" sub-paragraph with the swap-in command line and the when-to-pick criteria, linking the architecture page.
- `integrations/channels/page.mdx` — the multi-channel TOML example now shows both the Python invocation and the Rust binary invocation side-by-side.
- `integrations/channels/core/page.mdx` — the Telegram setup walkthrough adds the same Rust alternative callout right after the Python config block.

Each English change has the equivalent Chinese mirror at the `zh/` path — terminology kept consistent with the existing Chinese channel-doc style (sidecar 模式、运行语义、wire 形状、字节等价).
…ershoot, photo-reply context, FileData DoS cap, italic O(n²), and 5 more

Addresses 10 of the 22 Round-5 findings; the remaining 12 are either pre-existing parity with the Python adapter (StreamStart placeholder, streams-map expiry), perf optimisations (redact alloc, multipart clone), or low-impact defensive items deferred to follow-up.

## User-visible

- **answerCallbackQuery is now actually called** (translator.rs:570).
  Previously the function existed in BotClient but no code path invoked it, so every inline-button press left the user staring at a 30-second loading spinner before the agent's reply arrived.
- **Chunker no longer overshoots `limit`** (chunk.rs:142, 174).
  The close-tag suffix the rebalancer appends at chunk end was not deducted from the chunk's UTF-16 budget; deeply-nested formatting near the 4096 limit could produce a 4100+-unit emit that Telegram 400s as `MESSAGE_TOO_LONG`. New `carry_close_cost` measures the exact close cost of tags carried over from the previous chunk; `NEW_TAG_RESERVE` (capped at `limit/4` so tiny test limits still progress) absorbs the few new tags a chunk typically opens inside itself. New regression test `chunks_never_exceed_limit_with_deep_nesting` asserts no emit exceeds 4096 on a 3-level-nested 4090-char input.
- **Photo-only reply context is no longer dropped** (translator.rs:241).
  When the user replied to a photo with text, `apply_reply` previously kept the inbound as `Text("[Replying to X: \"...\"]\nfoo")` — the agent never saw the photo. Now mirrors the Python adapter: if the reply target is a photo AND the current content is plain text, fetch the photo URL and upgrade the inbound to `Image{url, caption: <prefix>+<text>}`. `apply_reply` becomes async; the single caller in `message_event` is updated.

## Security / robustness

- **Chat.id == 0 drops the update** (translator.rs:325).
  `Chat` has `#[serde(default)]`, so a malformed Telegram payload without `chat.id` deserialised to id 0 and every such message routed to a synthetic "chat 0" session. Defensive early-return in `message_event` instead.
- **`obj.iter().next()` replaced with explicit single-key check** (dispatcher.rs:144, 543).
  Externally-tagged `ChannelContent` and `MediaGroup.items[i]` are required to be single-key objects. The previous code silently picked whatever key came first by `serde_json::Map` iteration order — which depends on whether the workspace was built with `preserve_order`. Now reject anything but `len() == 1`. The MediaGroup nested-recursion guard was also tightened to `keys().any(...)` so a multi-key item cannot smuggle a MediaGroup past the check.
- **FileData allocates with a 64 MiB cap** (dispatcher.rs:13, 199).
  `Vec::with_capacity(data_array.len())` previously trusted the wire — an adversarial 10-billion-element JSON array would reserve ~10 GB before reading element 1. `FILE_DATA_BYTE_CAP = 64 * 1024 * 1024` covers cloud Bot API's 50 MB document ceiling with margin; oversize payloads error during parse instead of OOMing the sidecar.
- **`send_poll` errors loudly when `result.poll.id` is missing** (api/client.rs:425).
  `unwrap_or_default()` returned an empty string; downstream agents waiting on the poll id to record answers would treat it as valid and silently break.

## Performance

- **Italic regex pass is now O(n)** (markdown.rs:60).
  The previous `loop { replace; if unchanged break }` was O(n²): a 25 KB `*a* *b* *c* …` input rescanned the entire buffer per iteration, ~2.5 GB of total scan. `replace_all` walks left-to-right in a single pass; adjacent italics still resolve because the regex consumes a surrounding non-`*` char as part of each match.

## Defensive

- **`Instant::now() - Duration::from_secs(2)` replaced with `checked_sub`** (adapter.rs:212).
  On a system whose uptime is < 2 s (cold-boot container, embedded supervisor), the bare subtraction would panic; `checked_sub.unwrap_or(now)` falls back to throttling the first delta, which is fine.
- **Degenerate-path tag scan uses UTF-16 units, not bytes** (chunk.rs:194).
  `find('>')` returns a byte position; comparing it to `limit` (UTF-16 units) was wrong for non-ASCII tag content. The fix runs `utf16_len` on the captured byte prefix before comparing.

## Verification (librefang-rust-dev:latest container)

- `cargo test` -> 41 unit tests pass, 0 failed.
- `cargo clippy --all-targets -- -D warnings` -> clean.
- `cargo fmt --check` -> clean.

## Deferred

- StreamStart "…" placeholder + streams-map TTL — both behaviours match the Python adapter; worth a follow-up that covers both at once.
- `redact()` unconditional alloc + multipart bytes.clone() — perf optimisations not blocking; would need allocator profiling to measure.
- PUA sentinel stripping in escape_html — current behaviour is a documented security choice; making it escape-not-strip is a larger refactor.
- Update.message_reaction inbound modeling — new capability, separate PR.
- code_fence / RE_ATTR / single-quote escape — low-impact defensive items.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

…io::spawn

The Round-5 fix added the answer_callback_query call but kept it as
`.await`, which is await-and-discard-result, not fire-and-forget — a
slow Telegram API response (the API has been known to spike to several
seconds under load) would push the agent event emit by the same delay,
which is the exact regression the ACK was meant to prevent.

Spawn the call so the dispatcher returns to event-emit immediately.
`BotClient` derives `Clone` (reqwest::Client is internally `Arc`, the
three strings are small); the JoinHandle is intentionally dropped — the
ACK is best-effort UX cleanup, not a correctness gate.
@houko
houko merged commit d5fef17 into main May 28, 2026
18 checks passed
@houko
houko deleted the feat/rust-sidecar-telegram branch May 28, 2026 16:11
@github-actions github-actions Bot added the ready-for-review PR is ready for maintainer review label May 28, 2026
houko added a commit that referenced this pull request May 31, 2026
…balls (#5937)

* feat(channels): ship librefang-sidecar-telegram in release tarballs

The Rust Telegram sidecar (#5831) shipped only as source, so operators had
to install a Rust toolchain and compile it before pointing a
[[sidecar_channels]] block at the result. Bundle the binary in the release
tarballs and auto-resolve it so the migration off the Python sidecar is a
one-step config change.

Release pipeline: for each channel-capable native target (macOS x86_64 /
aarch64, linux-gnu x86_64 / aarch64, musl x86_64 / aarch64, Windows x86_64 /
aarch64) release.yml and its manual mirror release-cli.yml compile the sidecar
from its separate cargo workspace and bundle the binary inside the existing
per-target archive next to librefang. No new release assets, so the cosign
SHA256SUMS manifest and EXPECTED_PLATFORMS=12 are unchanged. macOS ad-hoc
codesigns the sidecar; musl verifies it is statically linked; Windows adds
the .exe to the Compress-Archive path list. Android and the mini variants
are deliberately skipped (neither carries channels).

Install scripts: install.sh and install.ps1 install the bundled binary when
present and stay silent on older tarballs that lack it (backward compatible).

Daemon: resolve_sidecar_command in librefang-channels resolves an empty or
bare-stem command to the daemon's own executable directory, then
~/.librefang/bin/, then PATH. An absolute / relative path or any other
program (python3 -m ...) is treated as explicit operator intent and passed
through unchanged.

Docs + CHANGELOG updated.

* ci: add PR-time sidecar build guard + cache its separate target/

The librefang-sidecar-telegram binary the release pipeline ships is its own
cargo workspace, not a member of the kernel workspace, so the regular CI
RUST lane never compiles it — a break would only surface when cutting a
tag. This PR makes it release-blocking, so move the verification forward to
every PR.

ci.yml: route changes under sdk/rust/librefang-sidecar{,-telegram}/ and the
release workflows to a new rust-telegram-sidecar job (modeled on the
existing skills-wasm-sdk job). It fmt/clippy/test/builds the sidecar
natively and then runs the cross build for aarch64-unknown-linux-musl that
the release pipeline depends on, proving at PR time that cross can resolve
the out-of-workspace ../librefang-sidecar path dependency — the one risk
that previously could only be validated on a real tag.

release.yml + release-cli.yml: the four native jobs that build the sidecar
(mac / linux / musl / windows) now list both . and
sdk/rust/librefang-sidecar-telegram under Swatinem/rust-cache workspaces so
the sidecar's separate target/ is cached instead of recompiled from scratch
each run. The mini and android jobs are untouched (they do not build the
sidecar).

---------

Co-authored-by: Evan <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant