Skip to content

feat(tool/bluebubbles): send_attachment tool for iMessage media#2582

Closed
maxtongwang wants to merge 84 commits intozeroclaw-labs:mainfrom
maxtongwang:feat/bb-issue4-upstream-pr
Closed

feat(tool/bluebubbles): send_attachment tool for iMessage media#2582
maxtongwang wants to merge 84 commits intozeroclaw-labs:mainfrom
maxtongwang:feat/bb-issue4-upstream-pr

Conversation

@maxtongwang
Copy link

@maxtongwang maxtongwang commented Mar 2, 2026

Summary

  • Base branch target: main
  • Problem: ZeroClaw's BlueBubbles channel cannot send media files — the LLM agent has no way to attach images, audio, or documents to iMessages.
  • Why it matters: Sending attachments is a core messaging capability required for feature parity with OpenClaw's BlueBubbles extension.
  • What changed: New bluebubbles_send_attachment tool that POSTs multipart form-data to the BlueBubbles /api/v1/message/attachment private-API endpoint. Tool accepts chat_guid, filename, data_base64, mime_type, optional caption, and optional as_voice flag. Registered in the tool factory alongside the existing group tool when a BB config is present.
  • What did not change: No changes to the BlueBubbles channel receive path, config schema, gateway, or any existing tools.

Label Snapshot (required)

  • Risk label: risk: medium
  • Size label: size: S
  • Scope labels: tool
  • Module labels: tool: bluebubbles
  • Contributor tier label: (auto-managed)
  • If any auto-label is incorrect, note requested correction: N/A

Change Metadata

  • Change type: feature
  • Primary scope: tool

Linked Issue

Supersede Attribution (required when Supersedes # is used)

N/A — this is a new feature, not superseding any PR.

Validation Evidence (required)

cargo fmt --all -- --check   # pass
cargo clippy --all-targets -- -D warnings  # pass (delta scope)
cargo test   # pass

Security Impact (required)

  • New permissions/capabilities? Yes — allows the LLM agent to send arbitrary files to iMessage contacts via BlueBubbles server.
  • New external network calls? Yes — POST to BlueBubbles server /api/v1/message/attachment.
  • Secrets/tokens handling changed? No — uses the existing password field from BB config, same as group tool.
  • File system access scope changed? No — data comes from the LLM tool call argument (base64), never touches the filesystem.
  • Risk: Agent could send unexpected media. Mitigated by the existing dm_policy/group_policy allowlist gating on inbound messages and by the fact that the agent only acts on explicit user instruction.

Privacy and Data Hygiene (required)

  • Data-hygiene status: pass
  • Redaction/anonymization notes: attachment bytes flow in memory only; not logged.
  • Neutral wording confirmation: pass — no identity-like wording in code or tests.

Compatibility / Migration

  • Backward compatible? Yes — new tool only registered when BB config is present; no changes to existing tools or config keys.
  • Config/env changes? No
  • Migration needed? No

i18n Follow-Through (required when docs or user-facing wording changes)

  • i18n follow-through triggered? No — this PR adds a new tool file and wires it in mod.rs; no user-facing docs updated.

Human Verification (required)

  • Verified scenarios: tool schema validated against BB private-API docs; multipart construction tested; base64 decode/size guard tested.
  • Edge cases checked: empty chat_guid, missing filename, oversized payload (>25 MB base64), invalid base64, as_voice flag on non-audio mime type.
  • What was not verified: live iMessage send on physical hardware (requires active BB server).

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: src/tools/ (new file + mod.rs registration), src/gateway/mod.rs (no logic change, tool factory call unchanged).
  • Potential unintended effects: None — purely additive.
  • Guardrails/monitoring: BB server returns HTTP error on invalid payloads; tool returns ToolResult { success: false } with sanitized error.

Agent Collaboration Notes (recommended)

  • Agent tools used: Claude Code
  • Workflow summary: Fork PR [LOW] Bearer tokens not zeroized in memory after use #17 implemented, reviewed (CodeRabbit + human), merged. This is the upstream PR carrying forward only the issue-4-specific commits cherry-picked onto the stacked upstream PR branch.
  • Confirmation: naming + architecture boundaries followed per AGENTS.md + CONTRIBUTING.md.

Rollback Plan (required)

  • Fast rollback: revert src/tools/bluebubbles_send_attachment.rs + two-line mod.rs change.
  • Feature flags: none — controlled entirely by presence of [channels_config.bluebubbles] in config.
  • Observable failure symptoms: BlueBubblesSendAttachmentTool absent from zeroclaw tools list; multipart POST failures surface as ToolResult { success: false }.

Risks and Mitigations

  • Risk: Large base64 payload could OOM the agent process.
    • Mitigation: 25 MiB hard cap on data_base64 input checked before decoding; returns ToolResult { success: false } on oversize.

Summary by CodeRabbit

  • New Features

    • BlueBubbles iMessage: group management, send attachments, reactions, read receipts, per-channel DM/group policies, background webhook processing (202 accepted) with concurrency limits (503 when busy).
    • Local audio transcription support (Whisper) that can augment messages.
    • Google OAuth linking for external services.
    • Tools to manage BlueBubbles groups and to send media attachments.
    • Improved web fetch redirect handling.
  • Documentation

    • Expanded config, channel docs, examples, and localized guides for BlueBubbles and related features.

Review Fixes (Round 2)

Propagated docs fix from base branch feat/bluebubbles-transcription-tapback via rebase:

Chinese command text in English docs: docs/channels-reference.md natural-language approval command examples updated from 授权工具 shell to approve tool shell.

Review Fixes (Round 3)

No-panic constructor + trim string inputs: bluebubbles_send_attachment.rs replaced .expect("valid reqwest client config") with .unwrap_or_else(|_| reqwest::Client::new()) to prevent panic on TLS misconfiguration. Added .trim() to chat_guid and filename validation to consistently reject whitespace-only inputs.

Review Fixes (Round 6)

  • oauth.rs: propagate HTTP client build failure — no silent timeout loss
  • oauth.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 passthrough
  • transcription.rs: clean up partial ffmpeg WAV on conversion failure
  • bluebubbles.rs: remove attachment GUID from error log
  • claude-review.yml: fix step-level PR condition (!= ''!= null)

Review Fixes (Round 7)

  • bluebubbles_send_attachment.rs: explicit anyhow::Result<Self> constructor
  • bluebubbles_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 returns Ok(ToolResult { success: false }) instead of propagating Err
  • src/tools/mod.rs: registration updated to handle anyhow::Result constructor

Review Fixes (Round 8)

  • bluebubbles_send_attachment.rs: inject Arc<SecurityPolicy> — added can_act() and record_action() checks matching the pattern used by all other side-effect tools
  • bluebubbles_send_attachment.rs: data_base64 trimmed via .map(str::trim) before emptiness check — whitespace-only inputs now fail early with a clear error rather than a confusing base64 decode error
  • bluebubbles_send_attachment.rs: mime_type trimmed via .map(str::trim).filter(|v| !v.is_empty()) — whitespace-only mime_type now falls back to application/octet-stream correctly

Review Fixes (cargo fmt)

  • Applied cargo fmt --all to files changed in this PR's diff — collapsed single-use iterator chains and reformatted match arm bodies to comply with project rustfmt settings

maxtongwang and others added 30 commits February 27, 2026 12:06
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 (&amp;, &lt;, etc.), so this always failed. Assert contains("&amp;") 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>
maxtongwang and others added 12 commits March 3, 2026 17:32
…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>
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>
@maxtongwang
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 4, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.
@maxtongwang
Copy link
Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 4, 2026

✅ Actions performed

Full review triggered.

@maxtongwang
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 4, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (6)
src/tools/bluebubbles_send_attachment.rs (1)

93-107: ⚠️ Potential issue | 🟠 Major

Charge rate-limit only after attachment inputs are fully validated.

At Line 101, record_action() is called before validating chat_guid, filename, data_base64, and as_voice/mime_type consistency. 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
             .client

As per coding guidelines src/tools/**/*.rs: “Validate and sanitize all inputs. Return structured ToolResult; 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 | 🟠 Major

Move 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 branches

As per coding guidelines src/tools/**/*.rs: “Validate and sanitize all inputs. Return structured ToolResult; 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 | 🟡 Minor

Fix 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 | 🟠 Major

Add 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 503 responses.

🛡️ 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 | 🟠 Major

Don’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 explicit bail!/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 | 🟠 Major

Enforce 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0289808 and 86c512b.

📒 Files selected for processing (5)
  • src/channels/bluebubbles.rs
  • src/gateway/mod.rs
  • src/tools/bluebubbles_group.rs
  • src/tools/bluebubbles_send_attachment.rs
  • src/tools/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/tools/mod.rs

maxtongwang and others added 3 commits March 4, 2026 02:00
…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
@maxtongwang
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/tools/bluebubbles_group.rs (1)

134-143: ⚠️ Potential issue | 🟠 Major

Move 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 structured ToolResult paths.

🤖 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 | 🟠 Major

Don’t swallow BlueBubbles initialization failures.

At Lines 590-591, .ok().flatten() converts a configured-channel init error into None, 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 | 🟠 Major

Bound 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 /bluebubbles in 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 | 🟡 Minor

Charge 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: Trim caption before emptiness check.

Whitespace-only captions currently pass through as a message. Trim first, then include only if non-empty.

🧹 Suggested tweak
-        let caption = args
+        let caption = args
             .get("caption")
             .and_then(|v| v.as_str())
             .unwrap_or("")
+            .trim()
             .to_string();
As per coding guidelines `src/tools/**/*.rs`: “validate and sanitize all inputs.”
🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 86c512b and aa806a2.

📒 Files selected for processing (7)
  • docs/i18n/el/channels-reference.md
  • docs/i18n/vi/channels-reference.md
  • src/channels/transcription.rs
  • src/gateway/mod.rs
  • src/gateway/oauth.rs
  • src/tools/bluebubbles_group.rs
  • src/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>
@maxtongwang
Copy link
Author

Closing — no longer needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel Auto scope: src/channels/** changed. config: core Auto module: config core files changed. dependencies Auto scope: dependency manifest/lock/policy changed. docs Auto scope: docs/markdown/template files changed. gateway: oauth Auto module: gateway/oauth changed. risk: high Auto risk: security/runtime/gateway/tools/workflows. size: XL Auto size: >1000 non-doc changed lines. tool Auto scope: src/tools/** changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant