feat(tool/bluebubbles): send_attachment tool for iMessage media#2582
feat(tool/bluebubbles): send_attachment tool for iMessage media#2582maxtongwang wants to merge 84 commits intozeroclaw-labs:mainfrom
Conversation
Adds @claude mention support on PRs using anthropics/claude-code-action@v1. Mirrors the workflow from maxtongwang/openclaw. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds use_sticky_comment: true to claude-review workflow so all review updates are consolidated into a single comment instead of flooding the PR with multiple comments. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds BlueBubblesChannel — iMessage via self-hosted BlueBubbles macOS server. - Webhook mode: receives new-message events at POST /bluebubbles - Sender normalization: strips imessage:/sms:/auto: prefixes, lowercases emails - fromMe caching: FIFO VecDeque+HashMap cache with reply context injection - Optional inbound webhook auth: Authorization: Bearer via webhook_secret config - Outbound send: POST /api/v1/message/text with ?password= query param + tempGuid - Health check: GET /api/v1/ping (matches OpenClaw probeBlueBubbles) - constant_time_eq() for secret comparison; manual Debug redacts password Closes #1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a second BlueBubbles endpoint for a personal Apple ID that stores incoming iMessages to shared memory but never calls the LLM or sends a reply. This enables multi-tenant iMessage setups where one ZeroClaw process handles both a bot account (POST /bluebubbles) and a personal account (POST /bluebubbles-personal) from two BlueBubbles instances. Changes: - src/config/schema.rs: add bluebubbles_personal: Option<BlueBubblesConfig> to ChannelsConfig (reuses existing BlueBubblesConfig struct) - src/gateway/mod.rs: add bluebubbles_personal field to AppState, wire construction, register POST /bluebubbles-personal route, and implement handle_bluebubbles_personal_webhook handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- [build] rustc-wrapper = "sccache" in .cargo/config.toml — caches compiled crates across clean builds; sccache auto-starts on first use - [profile.dev] split-debuginfo = "unpacked" in Cargo.toml — skips dsymutil on macOS, eliminating post-link debug-symbol packaging delay Note: mold 2.40.4 dropped Mach-O support so it cannot be used as a macOS linker; split-debuginfo is the primary link-time win on macOS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Convert Discord-style markdown in LLM responses to BB Private API
attributedBody segments for iMessage rendering:
- **bold** → {bold: true}
- *italic* → {italic: true}
- ~~strikethrough~~ → {strikethrough: true}
- __underline__ → {underline: true}
Markers nest arbitrarily; plain-text fallback strips markers.
Update system prompt to instruct LLM to use rich markdown + emoji.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add start_typing/stop_typing to BlueBubblesChannel using BB Private API
POST /api/v1/chat/{chatGuid}/typing. Indicator refreshes every 4s since
BB typing events expire in ~5s. Gateway handler starts indicator before
LLM call and stops it (both success and error paths) before sending reply.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- markdown_to_attributed_body: convert **bold**, *italic*, ~~strike~~, __underline__, `code` (→bold), # headers (→bold), ``` blocks (plain) into BB Private API attributedBody segments - start_typing/stop_typing: background loop refreshing BB typing indicator every 4s while LLM processes; aborted on response - EFFECT_MAP + extract_effect(): LLM can append [EFFECT:name] tags (slam, loud, gentle, invisible-ink, confetti, balloons, fireworks, lasers, love, celebration, echo, spotlight); stripped from content, passed as effectId to BB Private API - Updated channel_delivery_instructions with full style + effect guide - 38 unit tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ignore_senders to BlueBubblesConfig and BlueBubblesChannel - Personal webhook handler skips storing messages where sender is in ignore_senders - Configure ignore_senders = ["tongtong901005@gmail.com"] in config.toml - Fix release-fast profile: lto=false, codegen-units=16, opt-level=2 for fast local builds with sccache support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Direct LLM to use web_fetch on canonical sources (ESPN, wttr.in, etc.) for sports, weather, news — not just web_search snippets - Remove "be concise" instruction that was cutting the tool loop short - Instruct bot to complete full research before replying - Keep all text style and effect guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l endpoint Port all three CodeRabbit improvements from feat/channel-bluebubbles to fork main and extend them consistently to the bluebubbles_personal endpoint: - typing_handles: replace single Mutex<Option<JoinHandle>> with Mutex<HashMap<String, JoinHandle>> keyed by chat GUID so concurrent conversations do not cancel each other's typing indicators - is_sender_ignored(): new method checked before is_sender_allowed() in parse_webhook_payload so ignore_senders always takes precedence over the allowlist; removes the now-redundant inline ignore check from the personal handler since parse_webhook_payload handles it upstream - Secret wiring: add bluebubbles and bluebubbles_personal password + webhook_secret to decrypt_channel_secrets and encrypt_channel_secrets - Debug impl: add ignore_senders field to BlueBubblesConfig debug output - Tests: add three unit tests covering ignore_senders exact match, precedence over allowlist, and empty-list no-op behaviour (41 total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Conflicts resolved: - .gitignore: keep fork's test-config.toml entry alongside upstream result - Cargo.lock: accept upstream (no new deps from BB channel) - src/config/schema.rs: keep BlueBubblesConfig + bluebubbles/personal fields alongside upstream GitHubConfig; wire both into encrypt/decrypt - src/gateway/mod.rs: keep BB + personal handlers alongside upstream GitHub handler; update run_gateway_chat_with_tools calls to 3-arg signature; add missing BB fields to new GitHub test fixtures Also fixes: - src/providers/cursor.rs: add missing quota_metadata field (pre-existing upstream bug now caught by stricter ChatResponse struct) - BB handler: update sanitize_gateway_response to 3-arg form matching upstream API change (adds leak_guard parameter) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BlueBubbles channel now downloads and transcribes audio attachments when `[transcription]` is configured. iMessage sends voice memos in Core Audio Format (CAF) which Groq Whisper does not accept natively; CAF files are converted to WAV via ffmpeg before upload. Changes: - transcription.rs: add `convert_caf_to_wav` — detects CAF by extension, writes to temp files (seekable input required), runs `ffmpeg -ar 16000 -ac 1`, cleans up on all exit paths; returns Ok(None) for non-CAF so callers reuse original bytes - bluebubbles.rs: add `transcription` field, `with_transcription` builder, `AudioAttachment` struct, `extract_audio_attachments`, `download_attachment` (percent-encoding inline), `download_and_transcribe`, and public async `parse_webhook_payload_with_transcription`; gracefully falls back to `<media:audio>` placeholder when transcription is disabled, ffmpeg is absent, or the API call fails - gateway/mod.rs: wire `config.transcription.clone()` into both BB channel constructions; replace synchronous `parse_webhook_payload` call with async `parse_webhook_payload_with_transcription` in both webhook handlers No new Cargo dependencies — uses tokio::process and tokio::fs already present. No new config keys — wired through the existing `[transcription]` block. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors OpenClaw approach: read attachment from `original_path` in BB webhook payload (local filesystem, no REST download) and shell out to the `whisper` CLI. Python whisper handles CAF (iMessage voice memos) natively via ffmpeg — no pre-conversion step needed. - Remove convert_caf_to_wav + ffmpeg + Groq HTTP download path - Add transcribe_audio_local: resolves whisper in PATH and common Homebrew/system paths, runs with --model turbo --output_format txt, reads <tmpdir>/<stem>.txt, cleans up on all exit paths - extract_audio_attachments: use originalPath instead of guid - transcribe_local: read file directly, no REST API call - Fix pre-existing build break: add OauthConfig/GoogleOauthConfig schema types + re-export so oauth.rs compiles Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
BB webhook attachments do not include originalPath; extract guid instead
and download bytes via /api/v1/attachment/{guid}/download, then write to
a temp file and pass to the local whisper CLI for transcription.
BB converts CAF→MP3 before webhooking so the download is typically
audio/mpeg — no pre-conversion needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes:
1. Instead of REST API download, read the BB-converted MP3 directly from
the local filesystem at the predictable path:
~/Library/Application Support/bluebubbles-server/Convert/{guid}/{name}.mp3
BB converts CAF→MP3 before sending the webhook; since ZeroClaw runs on
the same Mac as BB, no network call is needed.
2. Spawn webhook processing in background (tokio::spawn) so the handler
returns 200 immediately. BB times out webhooks at 30 s, but whisper
transcription takes longer — caused 408 timeouts with the previous
synchronous approach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BB's transferName in webhooks is inconsistent — sometimes includes .mp3
already, causing triple-extension paths. Instead of constructing the
filename from transferName, scan the Convert/{guid}/ directory for the
first audio file (mp3/m4a/aac/wav/ogg). This is robust to any BB
filename convention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace incorrect \!escaped.contains('&') assertion — HTML entity encoding uses & characters (&, <, etc.), so this always failed. Assert contains("&") instead.
Replaces broken filesystem-based audio transcription with BB REST API download. Adds dual-backend transcription (local whisper CLI primary, Groq API fallback), inbound tapback surfacing via updated-message webhooks, and outbound reactions via add_reaction/remove_reaction on the Channel trait. Key changes: - download_attachment_bytes: 25 MB cap (Content-Length + body), 30s HTTP timeout - resolve_whisper_bin: OnceLock cache — binary search runs once at startup - parse_tapback_event: surfaces ❤️/👍/👎/😂/‼️ /❓ as [Tapback] system messages - add_reaction/remove_reaction: BB Private API /api/v1/message/react - 58 tests, all green
fetch_with_http_provider used Policy::none() but returned Ok(redirected_url) instead of fetching the target. Replace response with the fetched redirect target and fall through to shared body processing.
Brings in upstream commits through 3726d82. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> # Conflicts: # .cargo/config.toml # .gitignore # Cargo.toml # src/channels/bluebubbles.rs # src/channels/mod.rs # src/config/mod.rs # src/config/schema.rs # src/gateway/mod.rs
…nc parse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng indicator (#14) - Replace slow Python whisper (~30–90 s) with whisper-cli (whisper.cpp, Metal-accelerated, ~1–2 s) for local audio transcription. - Pre-convert all non-WAV audio to 16 kHz mono WAV via ffmpeg; the brew build of whisper-cli only reliably reads WAV. - Use OnceLock for binary/model path resolution to avoid repeated lookups. - Start typing indicator before transcription runs so users see feedback immediately rather than waiting for the full whisper phase. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…k reactions, and whisper-cli fast-path Extends the BlueBubbles iMessage channel with the following capabilities on top of the existing base implementation: - REST API audio download: fetches voice memo attachments directly from the BlueBubbles server via its REST API, enabling reliable access without filesystem access. - Tapback reactions: parses and surfaces reaction events (heart, thumbs up, thumbs down, ha ha, !!, ?) so the agent can understand user sentiment and react appropriately. - Rich text / attributed body: parses Apple's attributedBody format to extract plain text from styled iMessage content, improving message fidelity for formatted messages and inline attachments. - Typing indicator before transcription: sends a typing indicator immediately when a voice memo is detected, giving users real-time feedback before the potentially slower transcription step. - Local whisper-cli fast-path (whisper.cpp): prefers the locally installed `whisper-cli` (from whisper.cpp, e.g. via `brew install whisper-cpp`) over the Groq API when available. On Apple Silicon with Metal acceleration this cuts transcription latency from 30-90 s to ~1-2 s. WAV files are passed directly; non-WAV audio is converted via ffmpeg before passing to whisper-cli. Falls back to the Groq API automatically when whisper-cli is not installed. - Stderr diagnostics: surfaces whisper-cli stderr output in agent logs when transcription fails, making it easier to debug environment issues. Files changed: - src/channels/bluebubbles.rs: AudioAttachment struct, REST download, tapback parsing, rich text extraction, typing indicator wiring - src/channels/transcription.rs: local whisper-cli fast-path with ffmpeg pre-conversion and graceful Groq API fallback - src/gateway/mod.rs: wire .with_transcription() into channel construction; call parse_webhook_payload_with_transcription (async)
- Redact reqwest password URL from error messages with `.without_url()` in download_attachment_bytes, add_reaction, and remove_reaction - Replace racy `as_nanos()` temp-file names with `uuid::Uuid::new_v4()` in bluebubbles.rs (audio download) and transcription.rs (ffmpeg/whisper) - Gate typing indicator on fromMe/allowlist checks before `start_typing()` to avoid leaking presence for ignored/disallowed senders - Add `tokio::time::timeout(120s)` + `kill_on_drop(true)` for ffmpeg, whisper-cli, and python whisper subprocesses to bound execution time - Add Intel Homebrew model paths (`/usr/local/opt/whisper-cpp/...`) to MODELS array alongside Apple Silicon paths - Move transcription+LLM loop into `tokio::spawn` background task and return 202 ACCEPTED immediately to stay within gateway timeout budget Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion on non-empty password - Replace `expect()` in `BlueBubblesGroupTool::new` with `unwrap_or_else` to fall back to `reqwest::Client::new()` on (theoretical) TLS init failure, preventing a startup panic (CodeRabbit finding, zeroclaw-labs#2532). - Validate `password` in tool factory alongside `server_url` so the group tool is not registered when password is blank/whitespace — blank password would cause all API calls to fail at execution time anyway (CodeRabbit, zeroclaw-labs#2532).
Replace ?-propagated anyhow errors on .send() failures with explicit
Ok(ToolResult { success: false, ... }) returns so transport errors are
surfaced to the LLM agent rather than crashing the tool call chain.
Fixes CodeRabbit finding on bluebubbles_group.rs transport error handling.
…e64 len The previous check tested `icon_b64.len() > 5*1024*1024` which limits the base64 string length (≈ 3.75 MiB decoded) rather than the actual image size. Moved the size check after base64 decode to enforce the limit on the decoded byte count, so a 5 MiB image is accepted where the base64 string is ~6.7 MiB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… gating; preserve raw password
…t build and icon match arm
Adds BlueBubblesSendAttachmentTool allowing the LLM to send images, audio, and documents via iMessage through the BB Private API multipart endpoint (/api/v1/message/attachment). Parameters: chat_guid, filename, data_base64, mime_type, caption (optional), as_voice (optional). Base64 is decoded before upload. Invalid base64 returns a structured error without making a network call. Registered in all_tools_with_runtime alongside BlueBubblesGroupTool when channels_config.bluebubbles is present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add 50 MiB size cap on data_base64 before base64 decode (OOM guard) - MIME type failure is now a hard error (was: silent empty-file fallback) - Remove mime_type from schema required[] — it defaults to octet-stream - Update mime_type description to document the default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace .expect() with .unwrap_or_else(|_| reqwest::Client::new()) so client-build failure (TLS misconfiguration) degrades gracefully instead of panicking at tool registration time - Add .trim() to chat_guid and filename validation to reject whitespace- only inputs consistently with other BB tool validators Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Result on transport error
… and mime_type inputs
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
BlueBubbles' Private API voice memo flag expects an audio attachment. Validate mime_type starts with "audio/" when as_voice=true and return a clear ToolResult error instead of silently sending an invalid request.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
♻️ Duplicate comments (6)
src/tools/bluebubbles_send_attachment.rs (1)
93-107:⚠️ Potential issue | 🟠 MajorCharge rate-limit only after attachment inputs are fully validated.
At Line 101,
record_action()is called before validatingchat_guid,filename,data_base64, andas_voice/mime_typeconsistency. Bad payloads can burn quota without any external send.🔧 Suggested reordering
if !self.security.can_act() { return Ok(ToolResult { success: false, output: String::new(), error: Some("Action blocked: read-only autonomy level".into()), }); } - if !self.security.record_action() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Rate limit exceeded: too many actions in the last hour".into()), - }); - } let chat_guid = ... let filename = ... let data_b64 = ... let mime_type = ... let as_voice = ... + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: too many actions in the last hour".into()), + }); + } let resp = match self .clientAs per coding guidelines
src/tools/**/*.rs: “Validate and sanitize all inputs. Return structuredToolResult; avoid panics in runtime path.”🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/bluebubbles_send_attachment.rs` around lines 93 - 107, The current execute method charges the rate-limit via self.security.record_action() before input validation, which can consume quota for malformed requests; move the record_action() call to after full validation and sanitization of args (check presence and types of chat_guid, filename, data_base64, and validate consistency between as_voice and mime_type) and only call self.security.record_action() if validation passes; keep the early can_act() check, return the same structured ToolResult error responses for validation failures, and ensure record_action() failure still returns the rate-limit ToolResult as before.src/tools/bluebubbles_group.rs (1)
99-113:⚠️ Potential issue | 🟠 MajorMove rate-limit charging after action-specific input validation.
record_action()at Line 107 executes before validating per-action required fields (display_name,address,icon_base64). Invalid payloads can consume quota without any outbound mutation.🔧 Representative fix pattern
- if !self.security.record_action() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Rate limit exceeded: too many actions in the last hour".into()), - }); - } let action = match args.get("action").and_then(|v| v.as_str()) { ... }; let chat_guid = ... let encoded_guid = ... match action.as_str() { "rename_group" => { let name = ... + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: too many actions in the last hour".into()), + }); + } let url = self.api_url(&format!("/api/v1/chat/{encoded_guid}")); ... } // repeat same placement for add/remove/leave/set_group_icon branchesAs per coding guidelines
src/tools/**/*.rs: “Validate and sanitize all inputs. Return structuredToolResult; avoid panics in runtime path.”🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/bluebubbles_group.rs` around lines 99 - 113, The execute method currently calls self.security.record_action() before validating action-specific inputs, allowing invalid payloads to consume rate-limit quota; update execute (in BlueBubblesGroup::execute) to first check self.security.can_act(), then validate and sanitize the incoming serde_json::Value for required fields (e.g., display_name, address, icon_base64) and return a structured ToolResult error for missing/invalid fields, and only after all validations pass call self.security.record_action(); ensure validation logic is performed before any quota-affecting call and that failing validation returns Ok(ToolResult { success: false, error: Some(...), output: "" }) without calling record_action().src/gateway/mod.rs (3)
2377-2378:⚠️ Potential issue | 🟡 MinorFix stale inline status-code comment (200 vs 202).
Line 2377 says “Returning 200 immediately…”, but the handler returns
StatusCode::ACCEPTED(202) at Line 2485. Please keep the comment aligned with runtime behavior.Also applies to: 2484-2486
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/gateway/mod.rs` around lines 2377 - 2378, The inline comment that says “Returning 200 immediately…” is out of sync with the actual returned status (StatusCode::ACCEPTED / 202); update that comment to reflect 202/ACCEPTED and any wording about HTTP lifecycle/timeout budget accordingly, and make the same correction for the duplicated comment near the block that returns StatusCode::ACCEPTED so both comments align with the runtime behavior (reference StatusCode::ACCEPTED in the comment).
2381-2482:⚠️ Potential issue | 🟠 MajorAdd local timeouts in the background BlueBubbles worker to prevent permit starvation.
The semaphore permit is held for the full spawned task, but transcription/LLM/send calls in this block have no local timeout. A stuck downstream call can pin permits and force prolonged
503responses.🛡️ Suggested timeout guards
+const BB_WORKER_STEP_TIMEOUT_SECS: u64 = 120; ... tokio::spawn(async move { let _permit = permit; // released when this task completes - let messages = bb.parse_webhook_payload_with_transcription(&payload).await; + let messages = match tokio::time::timeout( + Duration::from_secs(BB_WORKER_STEP_TIMEOUT_SECS), + bb.parse_webhook_payload_with_transcription(&payload), + ) + .await + { + Ok(messages) => messages, + Err(_) => { + tracing::error!("BlueBubbles webhook timed out during payload parsing"); + return; + } + }; ... - match run_gateway_chat_with_tools(&state_bg, &msg.content, Some(&session_id)).await { + let llm_result = tokio::time::timeout( + Duration::from_secs(BB_WORKER_STEP_TIMEOUT_SECS), + run_gateway_chat_with_tools(&state_bg, &msg.content, Some(&session_id)), + ) + .await; + match llm_result { + Ok(Ok(response)) => { + ... + } + Ok(Err(e)) => { + ... + } + Err(_) => { + let _ = bb.stop_typing(&msg.reply_target).await; + tracing::error!("BlueBubbles message processing timed out"); + let _ = bb.send(&SendMessage::new( + "Sorry, processing timed out. Please try again.", + &msg.reply_target, + )).await; + } + }As per coding guidelines
src/{security,runtime,gateway,tools}/**/*.rs: “For security/runtime/gateway/tools changes, include at least one boundary/failure-mode validation.”🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/gateway/mod.rs` around lines 2381 - 2482, The spawned BlueBubbles worker currently holds the semaphore permit for the entire task and awaits long-running calls (parse_webhook_payload_with_transcription, run_gateway_chat_with_tools, bb.send, bb.mark_read, start_typing/stop_typing) with no timeouts, which can deadlock permits; wrap each external/LLM/transcription/send call with tokio::time::timeout (e.g., a configurable small boundary like 5–30s) and handle timeout errors explicitly (log, increment a metric, return early or send a pragmatic failure reply) so the permit is dropped promptly; specifically, apply timeout guards around bb.parse_webhook_payload_with_transcription(&payload).await, bb.mark_read(...).await, bb.start_typing/stop_typing, run_gateway_chat_with_tools(...).await, and bb.send(...).await, and ensure any timeout path stops further processing for that message and returns a clear failure-mode behavior (log and optional user-facing notice) to satisfy the boundary/failure-mode validation.
562-591:⚠️ Potential issue | 🟠 MajorDon’t silently disable BlueBubbles when initialization fails.
Line 590-Line 591 (
.ok().flatten()) swallows BlueBubbles init errors and continues startup with the channel disabled, even when it is configured.🔧 Fail-fast patch
- let bluebubbles_channel: Option<Arc<BlueBubblesChannel>> = config + let bluebubbles_channel: Option<Arc<BlueBubblesChannel>> = config .channels_config .bluebubbles .as_ref() .map(|bb| { ... }) .transpose() .map_err(|e| { tracing::error!("Failed to initialize BlueBubbles channel: {e}"); e - }) - .ok() - .flatten(); + })?;As per coding guidelines
src/**/*.rs: “Prefer explicitbail!/errors for unsupported or unsafe states; never silently broaden permissions/capabilities.”🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/gateway/mod.rs` around lines 562 - 591, The initialization currently swallows BlueBubbles errors via `.ok().flatten()`, so change the tail of the chain to propagate failures instead of silencing them: replace `.transpose().map_err(|e| { tracing::error!("Failed to initialize BlueBubbles channel: {e}"); e }).ok().flatten()` with a `?`-based propagation such as `.transpose().map_err(|e| { tracing::error!("Failed to initialize BlueBubbles channel: {e}"); e })?` so that errors from BlueBubblesChannel::new / with_policies bubble up (causing startup to fail) while preserving the tracing::error log; this targets the bluebubbles_channel initialization expression (BlueBubblesChannel, bluebubbles_channel, transpose/map_err chain).src/channels/bluebubbles.rs (1)
639-646:⚠️ Potential issue | 🟠 MajorEnforce attachment size cap before appending each chunk.
Line 644 appends chunk bytes first, then Line 645 checks the cap. A single oversized chunk can still push memory over the intended limit before bailing.
🔧 Proposed fix
while let Some(chunk) = resp .chunk() .await .map_err(|e| anyhow::anyhow!("BB attachment body read failed: {}", e.without_url()))? { - buf.extend_from_slice(&chunk); - if buf.len() > MAX_BYTES { + if buf.len().saturating_add(chunk.len()) > MAX_BYTES { anyhow::bail!("BB attachment too large (>{MAX_BYTES} bytes)"); } + buf.extend_from_slice(&chunk); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/channels/bluebubbles.rs` around lines 639 - 646, Issue: the loop that reads response chunks appends chunk data to buf before checking the MAX_BYTES limit, allowing a single large chunk to exceed memory cap; fix by checking the chunk size plus current buf.len() before calling buf.extend_from_slice. Locate the while loop using resp.chunk().await and the variables buf and MAX_BYTES, and change the logic to compute prospective_len = buf.len() + chunk.len() and bail with anyhow::bail! if prospective_len > MAX_BYTES (keeping the same error message), only calling buf.extend_from_slice(&chunk) after that check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/channels/bluebubbles.rs`:
- Around line 639-646: Issue: the loop that reads response chunks appends chunk
data to buf before checking the MAX_BYTES limit, allowing a single large chunk
to exceed memory cap; fix by checking the chunk size plus current buf.len()
before calling buf.extend_from_slice. Locate the while loop using
resp.chunk().await and the variables buf and MAX_BYTES, and change the logic to
compute prospective_len = buf.len() + chunk.len() and bail with anyhow::bail! if
prospective_len > MAX_BYTES (keeping the same error message), only calling
buf.extend_from_slice(&chunk) after that check.
In `@src/gateway/mod.rs`:
- Around line 2377-2378: The inline comment that says “Returning 200
immediately…” is out of sync with the actual returned status
(StatusCode::ACCEPTED / 202); update that comment to reflect 202/ACCEPTED and
any wording about HTTP lifecycle/timeout budget accordingly, and make the same
correction for the duplicated comment near the block that returns
StatusCode::ACCEPTED so both comments align with the runtime behavior (reference
StatusCode::ACCEPTED in the comment).
- Around line 2381-2482: The spawned BlueBubbles worker currently holds the
semaphore permit for the entire task and awaits long-running calls
(parse_webhook_payload_with_transcription, run_gateway_chat_with_tools, bb.send,
bb.mark_read, start_typing/stop_typing) with no timeouts, which can deadlock
permits; wrap each external/LLM/transcription/send call with
tokio::time::timeout (e.g., a configurable small boundary like 5–30s) and handle
timeout errors explicitly (log, increment a metric, return early or send a
pragmatic failure reply) so the permit is dropped promptly; specifically, apply
timeout guards around
bb.parse_webhook_payload_with_transcription(&payload).await,
bb.mark_read(...).await, bb.start_typing/stop_typing,
run_gateway_chat_with_tools(...).await, and bb.send(...).await, and ensure any
timeout path stops further processing for that message and returns a clear
failure-mode behavior (log and optional user-facing notice) to satisfy the
boundary/failure-mode validation.
- Around line 562-591: The initialization currently swallows BlueBubbles errors
via `.ok().flatten()`, so change the tail of the chain to propagate failures
instead of silencing them: replace `.transpose().map_err(|e| {
tracing::error!("Failed to initialize BlueBubbles channel: {e}"); e
}).ok().flatten()` with a `?`-based propagation such as
`.transpose().map_err(|e| { tracing::error!("Failed to initialize BlueBubbles
channel: {e}"); e })?` so that errors from BlueBubblesChannel::new /
with_policies bubble up (causing startup to fail) while preserving the
tracing::error log; this targets the bluebubbles_channel initialization
expression (BlueBubblesChannel, bluebubbles_channel, transpose/map_err chain).
In `@src/tools/bluebubbles_group.rs`:
- Around line 99-113: The execute method currently calls
self.security.record_action() before validating action-specific inputs, allowing
invalid payloads to consume rate-limit quota; update execute (in
BlueBubblesGroup::execute) to first check self.security.can_act(), then validate
and sanitize the incoming serde_json::Value for required fields (e.g.,
display_name, address, icon_base64) and return a structured ToolResult error for
missing/invalid fields, and only after all validations pass call
self.security.record_action(); ensure validation logic is performed before any
quota-affecting call and that failing validation returns Ok(ToolResult {
success: false, error: Some(...), output: "" }) without calling record_action().
In `@src/tools/bluebubbles_send_attachment.rs`:
- Around line 93-107: The current execute method charges the rate-limit via
self.security.record_action() before input validation, which can consume quota
for malformed requests; move the record_action() call to after full validation
and sanitization of args (check presence and types of chat_guid, filename,
data_base64, and validate consistency between as_voice and mime_type) and only
call self.security.record_action() if validation passes; keep the early
can_act() check, return the same structured ToolResult error responses for
validation failures, and ensure record_action() failure still returns the
rate-limit ToolResult as before.
ℹ️ Review info
Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 94a10cff-cedd-4246-af73-68d5239ab122
📒 Files selected for processing (5)
src/channels/bluebubbles.rssrc/gateway/mod.rssrc/tools/bluebubbles_group.rssrc/tools/bluebubbles_send_attachment.rssrc/tools/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/tools/mod.rs
…eturn bypass - pkce_path: include state token in filename to prevent concurrent flow collisions - auth_check: remove explicit 'return true' when pairing disabled; always delegate to is_authenticated so the guard handles the disabled-pairing case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- gateway/oauth: remove unused redirect field from OAuthStartQuery, fix TOCTOU race in revoke path by removing exists() pre-check and handling NotFound from remove_file, log Google token revocation failures instead of silently ignoring them - gateway/mod: hash sender PII before logging, fire-and-forget mark_read calls to avoid blocking the LLM handler on the 30 s BlueBubbles timeout
…ync cleanup, i18n - Move record_action() after input validation in BlueBubblesSendAttachmentTool and BlueBubblesGroupTool so invalid payloads do not consume quota - Replace std::fs::remove_dir_all with tokio::fs::remove_dir_all in transcribe_with_whisper_cpp error path to avoid blocking the executor - Add BlueBubbles config/policy section to el and vi channels-reference.md - Add BlueBubbles log-signal row to vi §7.2 and update stale iMessage row
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (4)
src/tools/bluebubbles_group.rs (1)
134-143:⚠️ Potential issue | 🟠 MajorMove quota charging after action-specific validation.
At Line 135,
record_action()runs before required per-action inputs are validated (e.g.,display_name,address,icon_base64), so invalid payloads still consume rate-limit budget.🔧 Suggested fix
- // All common inputs validated; charge rate-limit only before mutation. - if !self.security.record_action() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Rate limit exceeded: too many actions in the last hour".into()), - }); - } - match action.as_str() { "rename_group" => { let name = match args.get("display_name").and_then(|v| v.as_str()) { Some(n) if !n.trim().is_empty() => n.trim().to_string(), _ => { @@ }) } }; + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: too many actions in the last hour".into()), + }); + } let url = self.api_url(&format!("/api/v1/chat/{encoded_guid}"));Apply the same placement in each mutating action branch after that branch’s required-input validation.
As per coding guidelines
src/tools/**/*.rs: “Validate and sanitize all inputs” and return structuredToolResultpaths.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/bluebubbles_group.rs` around lines 134 - 143, The current rate-limit call self.security.record_action() is executed before action-specific required-input validation, causing invalid payloads to consume quota; update the code so that record_action() is only invoked inside each mutating action branch (the branches of match action.as_str()) immediately after that branch's required-input validation (e.g., after checks for display_name, address, icon_base64) and before performing the mutation and returning a ToolResult; ensure you keep the same structured ToolResult error paths for validation failures and only call record_action() on the successful-per-validation path within functions/methods handling the mutating actions.src/gateway/mod.rs (2)
585-591:⚠️ Potential issue | 🟠 MajorDon’t swallow BlueBubbles initialization failures.
At Lines 590-591,
.ok().flatten()converts a configured-channel init error intoNone, leaving startup running with integration silently disabled.🔧 Suggested fix
- .transpose() - .map_err(|e| { - tracing::error!("Failed to initialize BlueBubbles channel: {e}"); - e - }) - .ok() - .flatten(); + .transpose() + .map_err(|e| { + tracing::error!("Failed to initialize BlueBubbles channel: {e}"); + e + })?;As per coding guidelines
src/**/*.rs: prefer explicit error paths and avoid silent fallback that broadens/changes capability behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/gateway/mod.rs` around lines 585 - 591, The BlueBubbles channel init is currently swallowing errors by ending the chain with .ok().flatten(); instead, propagate failures explicitly: remove the trailing .ok().flatten() on the expression that calls .transpose().map_err(|e| { tracing::error!("Failed to initialize BlueBubbles channel: {e}"); e }) and return or propagate the Result (e.g. use ? on the Result or convert into an explicit Err path) so initialization errors are surfaced to the caller instead of being silently turned into None.
2381-2489:⚠️ Potential issue | 🟠 MajorBound worker steps with local timeouts to avoid permit starvation.
The semaphore permit is held for the full spawned task, but Lines 2383 and 2463 run without local timeout guards. A stalled transcription/LLM step can pin permits and keep
/bluebubblesin prolonged 503 mode.⏱️ Suggested hardening
+const BB_WORKER_STEP_TIMEOUT_SECS: u64 = 120; ... tokio::spawn(async move { let _permit = permit; // released when this task completes - let messages = bb.parse_webhook_payload_with_transcription(&payload).await; + let messages = match tokio::time::timeout( + Duration::from_secs(BB_WORKER_STEP_TIMEOUT_SECS), + bb.parse_webhook_payload_with_transcription(&payload), + ) + .await + { + Ok(messages) => messages, + Err(_) => { + tracing::error!("BlueBubbles webhook parsing timed out"); + return; + } + }; ... - match run_gateway_chat_with_tools(&state_bg, &msg.content, Some(&session_id)).await { - Ok(response) => { + match tokio::time::timeout( + Duration::from_secs(BB_WORKER_STEP_TIMEOUT_SECS), + run_gateway_chat_with_tools(&state_bg, &msg.content, Some(&session_id)), + ) + .await + { + Ok(Ok(response)) => { ... } - Err(e) => { + Ok(Err(e)) => { ... } + Err(_) => { + let _ = bb.stop_typing(&msg.reply_target).await; + tracing::error!("BlueBubbles message processing timed out"); + } } } });Based on learnings: for high-risk gateway/runtime flows, include boundary/failure-mode protection and validation.
src/tools/bluebubbles_send_attachment.rs (1)
175-211:⚠️ Potential issue | 🟡 MinorCharge rate limit after full local validation.
record_action()on Line 175 runs before base64 decode (Line 184) and MIME validation (Line 201), so invalid payloads still burn quota. Move it to immediately before the outbound.send()call.🔧 Suggested reordering
- // All inputs validated; charge rate-limit only if we are about to mutate. - if !self.security.record_action() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Rate limit exceeded: too many actions in the last hour".into()), - }); - } - let file_bytes = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data_b64) { Ok(b) => b, Err(e) => { return Ok(ToolResult { @@ if as_voice { form = form.text("isAudioMessage", "true"); } + + // Charge rate-limit only once all local validation has passed. + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: too many actions in the last hour".into()), + }); + } let resp = match self .client🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/bluebubbles_send_attachment.rs` around lines 175 - 211, The current flow calls self.security.record_action() before validating the payload, causing invalid requests to consume quota; move the record_action() invocation from its current position (before base64::Engine::decode) to just prior to the outbound HTTP send (immediately before the reqwest client .send() call that submits the multipart built with reqwest::multipart::Part::bytes and mime_str), so base64::Engine::decode and the reqwest::multipart::Part creation/mime validation run first and only successful, fully validated requests decrement the rate limit.
🧹 Nitpick comments (2)
src/channels/transcription.rs (1)
478-482: Make the new test assert a real invariant.At Line 479, the test currently only verifies “no panic,” so it can’t fail for regressions in behavior.
♻️ Optional tightening
#[test] fn resolve_whisper_bin_returns_str_or_none() { - // Just assert the function doesn't panic; result depends on local install. - let _ = resolve_whisper_bin(); + let resolved = resolve_whisper_bin(); + assert!(resolved.is_none() || resolved.is_some_and(|path| !path.trim().is_empty())); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/channels/transcription.rs` around lines 478 - 482, The test resolve_whisper_bin_returns_str_or_none currently only ensures no panic; change it to assert a real invariant by matching on the result of resolve_whisper_bin(): if it returns Some(path) assert the Path exists and is a file (e.g. path.exists() && path.is_file()), otherwise allow None; keep the test name resolve_whisper_bin_returns_str_or_none and use resolve_whisper_bin() as the unique symbol to locate and update the assertion logic so regressions where the function returns an invalid path will fail the test.src/tools/bluebubbles_send_attachment.rs (1)
154-221: Trimcaptionbefore emptiness check.Whitespace-only captions currently pass through as a message. Trim first, then include only if non-empty.
As per coding guidelines `src/tools/**/*.rs`: “validate and sanitize all inputs.”🧹 Suggested tweak
- let caption = args + let caption = args .get("caption") .and_then(|v| v.as_str()) .unwrap_or("") + .trim() .to_string();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools/bluebubbles_send_attachment.rs` around lines 154 - 221, Trim the caption input immediately after extraction so whitespace-only captions are treated as empty: when you build caption from args.get("caption") update it to be trimmed (e.g., use .trim()/trimmed string) and then use that trimmed value in the subsequent emptiness check and when adding the "message" field to the multipart form (replace uses of caption in the if !caption.is_empty() branch and form.text("message", caption) with the trimmed caption variable).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/gateway/mod.rs`:
- Around line 2377-2378: Update the inline comment that currently says
“Returning 200 immediately” to reflect the actual status code returned (202
Accepted) by the handler; locate the comment near the response handling block in
src/gateway/mod.rs that explains returning immediately to keep the HTTP
lifecycle within the timeout budget and change the text to “Returning 202
Accepted immediately” (or equivalent) so the comment matches the handler's
returned status.
---
Duplicate comments:
In `@src/gateway/mod.rs`:
- Around line 585-591: The BlueBubbles channel init is currently swallowing
errors by ending the chain with .ok().flatten(); instead, propagate failures
explicitly: remove the trailing .ok().flatten() on the expression that calls
.transpose().map_err(|e| { tracing::error!("Failed to initialize BlueBubbles
channel: {e}"); e }) and return or propagate the Result (e.g. use ? on the
Result or convert into an explicit Err path) so initialization errors are
surfaced to the caller instead of being silently turned into None.
In `@src/tools/bluebubbles_group.rs`:
- Around line 134-143: The current rate-limit call self.security.record_action()
is executed before action-specific required-input validation, causing invalid
payloads to consume quota; update the code so that record_action() is only
invoked inside each mutating action branch (the branches of match
action.as_str()) immediately after that branch's required-input validation
(e.g., after checks for display_name, address, icon_base64) and before
performing the mutation and returning a ToolResult; ensure you keep the same
structured ToolResult error paths for validation failures and only call
record_action() on the successful-per-validation path within functions/methods
handling the mutating actions.
In `@src/tools/bluebubbles_send_attachment.rs`:
- Around line 175-211: The current flow calls self.security.record_action()
before validating the payload, causing invalid requests to consume quota; move
the record_action() invocation from its current position (before
base64::Engine::decode) to just prior to the outbound HTTP send (immediately
before the reqwest client .send() call that submits the multipart built with
reqwest::multipart::Part::bytes and mime_str), so base64::Engine::decode and the
reqwest::multipart::Part creation/mime validation run first and only successful,
fully validated requests decrement the rate limit.
---
Nitpick comments:
In `@src/channels/transcription.rs`:
- Around line 478-482: The test resolve_whisper_bin_returns_str_or_none
currently only ensures no panic; change it to assert a real invariant by
matching on the result of resolve_whisper_bin(): if it returns Some(path) assert
the Path exists and is a file (e.g. path.exists() && path.is_file()), otherwise
allow None; keep the test name resolve_whisper_bin_returns_str_or_none and use
resolve_whisper_bin() as the unique symbol to locate and update the assertion
logic so regressions where the function returns an invalid path will fail the
test.
In `@src/tools/bluebubbles_send_attachment.rs`:
- Around line 154-221: Trim the caption input immediately after extraction so
whitespace-only captions are treated as empty: when you build caption from
args.get("caption") update it to be trimmed (e.g., use .trim()/trimmed string)
and then use that trimmed value in the subsequent emptiness check and when
adding the "message" field to the multipart form (replace uses of caption in the
if !caption.is_empty() branch and form.text("message", caption) with the trimmed
caption variable).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 562fd4f1-65ba-46a0-a9ea-1652b312f01c
📒 Files selected for processing (7)
docs/i18n/el/channels-reference.mddocs/i18n/vi/channels-reference.mdsrc/channels/transcription.rssrc/gateway/mod.rssrc/gateway/oauth.rssrc/tools/bluebubbles_group.rssrc/tools/bluebubbles_send_attachment.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- docs/i18n/vi/channels-reference.md
- src/gateway/oauth.rs
…it ordering - gateway/mod: fix stale "200" comment to "202 Accepted" in webhook handler - tools/bluebubbles_send_attachment: move record_action() to after base64 decode and multipart building — only fully-valid requests consume quota - tools/bluebubbles_group: move record_action() into each match arm, after all action-specific input validation, so unknown actions and missing required params do not consume rate-limit quota Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Closing — no longer needed. |
Summary
mainbluebubbles_send_attachmenttool that POSTs multipart form-data to the BlueBubbles/api/v1/message/attachmentprivate-API endpoint. Tool acceptschat_guid,filename,data_base64,mime_type, optionalcaption, and optionalas_voiceflag. Registered in the tool factory alongside the existing group tool when a BB config is present.Label Snapshot (required)
risk: mediumsize: Stooltool: bluebubblesChange Metadata
featuretoolLinked Issue
Supersede Attribution (required when
Supersedes #is used)N/A — this is a new feature, not superseding any PR.
Validation Evidence (required)
maxtongwang/zeroclawafter full CI green + CodeRabbit review + human review.feat/bb-issue4-upstream-pr(stacked on feat(tool/bluebubbles): group management tool — rename, add/remove participants, icon, leave #2532).Security Impact (required)
/api/v1/message/attachment.passwordfield from BB config, same as group tool.dm_policy/group_policyallowlist gating on inbound messages and by the fact that the agent only acts on explicit user instruction.Privacy and Data Hygiene (required)
Compatibility / Migration
i18n Follow-Through (required when docs or user-facing wording changes)
mod.rs; no user-facing docs updated.Human Verification (required)
chat_guid, missingfilename, oversized payload (>25 MB base64), invalid base64,as_voiceflag on non-audio mime type.Side Effects / Blast Radius (required)
src/tools/(new file + mod.rs registration),src/gateway/mod.rs(no logic change, tool factory call unchanged).ToolResult { success: false }with sanitized error.Agent Collaboration Notes (recommended)
AGENTS.md+CONTRIBUTING.md.Rollback Plan (required)
src/tools/bluebubbles_send_attachment.rs+ two-line mod.rs change.[channels_config.bluebubbles]in config.BlueBubblesSendAttachmentToolabsent fromzeroclaw tools list; multipart POST failures surface asToolResult { success: false }.Risks and Mitigations
data_base64input checked before decoding; returnsToolResult { success: false }on oversize.Summary by CodeRabbit
New Features
Documentation
Review Fixes (Round 2)
Propagated docs fix from base branch
feat/bluebubbles-transcription-tapbackvia rebase:Chinese command text in English docs:
docs/channels-reference.mdnatural-language approval command examples updated from授权工具 shelltoapprove tool shell.Review Fixes (Round 3)
No-panic constructor + trim string inputs:
bluebubbles_send_attachment.rsreplaced.expect("valid reqwest client config")with.unwrap_or_else(|_| reqwest::Client::new())to prevent panic on TLS misconfiguration. Added.trim()tochat_guidandfilenamevalidation to consistently reject whitespace-only inputs.Review Fixes (Round 6)
oauth.rs: propagate HTTP client build failure — no silent timeout lossoauth.rs: validate PKCE state before consuming session file (CSRF fix)oauth.rs: use POST form body for token revocation (RFC 7009 compliance)transcription.rs: explicit fail on non-UTF-8 temp paths, not empty-string passthroughtranscription.rs: clean up partial ffmpeg WAV on conversion failurebluebubbles.rs: remove attachment GUID from error logclaude-review.yml: fix step-level PR condition (!= ''→!= null)Review Fixes (Round 7)
bluebubbles_send_attachment.rs: explicitanyhow::Result<Self>constructorbluebubbles_send_attachment.rs: attachment cap corrected from 50 MiB to 34 MiB b64 (≈ 25 MiB decoded, matching stated guardrail)bluebubbles_send_attachment.rs: transport error returnsOk(ToolResult { success: false })instead of propagatingErrsrc/tools/mod.rs: registration updated to handleanyhow::ResultconstructorReview Fixes (Round 8)
bluebubbles_send_attachment.rs: injectArc<SecurityPolicy>— addedcan_act()andrecord_action()checks matching the pattern used by all other side-effect toolsbluebubbles_send_attachment.rs:data_base64trimmed via.map(str::trim)before emptiness check — whitespace-only inputs now fail early with a clear error rather than a confusing base64 decode errorbluebubbles_send_attachment.rs:mime_typetrimmed via.map(str::trim).filter(|v| !v.is_empty())— whitespace-only mime_type now falls back toapplication/octet-streamcorrectlyReview Fixes (cargo fmt)
cargo fmt --allto files changed in this PR's diff — collapsed single-use iterator chains and reformatted match arm bodies to comply with project rustfmt settings