feat(channels): Rust Telegram sidecar adapter (parity with Python)#5831
Merged
Conversation
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.
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 `"`-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.
Deploying librefang-docs with
|
| Latest commit: |
1600ad6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://03daa95b.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-rust-sidecar-telegram.librefang-docs-web.pages.dev |
…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.
This was referenced 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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
First-party Rust Telegram sidecar adapter at
sdk/rust/librefang-sidecar-telegram/— feature-parity port ofsdk/python/librefang/sidecar/adapters/telegram.pywritten against the Rustlibrefang-sidecarSDK that merged in #5821.Crate layout
Independent cargo workspace (sibling of
sdk/rust/librefang-sidecar/) so its owntarget/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— InboundUpdate→ LibreFangmessageevent (file_id → URL viagetFile,from+sender_chatresolution,[Replying to ...]reply-context prefix,bot_commandentity →Content::Command).src/dispatcher.rs— OutboundContent→ 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.rs—TelegramAdapterimpl: 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.rs—TELEGRAM_BOT_TOKEN(secret, required),ALLOWED_USERS(list, advanced),TELEGRAM_CLEAR_DONE_REACTION(bool, advanced).src/access.rs—ALLOWED_USERSparser (numeric IDs exact-match,@usernamescase-insensitive with optional leading@).src/reaction.rs— emoji translation map matching Python (⏳ → 👀, ⚙️ → ⚡, ✅ → 🎉 or cleared depending onTELEGRAM_CLEAR_DONE_REACTION, ❌ → 👎).src/main.rs—run_stdio_main(schema_fn, build_fn)so the dashboard's--describediscovery serves the config form before the adapter constructs (which requiresTELEGRAM_BOT_TOKEN).Capabilities declared
typing→sendChatActionreaction→setMessageReactioninteractive→sendMessagewithinline_keyboardthread→message_thread_idpropagated end-to-end (forum topics)streaming→StreamStart/StreamDelta/StreamEnd→ debouncededitMessageText(1s interval)Bot API surface covered
getUpdates(long-poll)allowed_updates = [message, edited_message, callback_query, poll_answer]sendMessageeditMessageTextdeleteMessagesendChatActionsetMessageReactionsendPhoto/sendDocument/sendVoice/sendAudio/sendVideo/sendAnimation/sendSticker/sendLocationsendMediaGroupsendPollcorrect_option_id+ explanationgetFileContent::FileDatainline bytes (OggOpus auto-detect → sendVoice else sendDocument)Configure
Or use the dashboard's configure form — the schema is served via
--describeso the fields are discovered automatically.Verification (in librefang-rust-dev:latest container)
cargo test→ 19 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 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 amessageevent with the correct content variant.Out of scope (deliberate)
qr_ready/qr_status— Telegram bots use static tokens, not QR login.FileDatacovers the inline-content case.getFileis called fresh per inbound media event (matches Python's behaviour).