From 2597225e306bb4eec2907901bc785d25121a1f5f Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 11 Apr 2026 11:00:55 +0000 Subject: [PATCH 1/4] feat: resize and compress images before base64 encoding Follow OpenClaw's approach to prevent large image payloads from exceeding JSON-RPC transport limits (Internal Error -32603). Changes: - Add image crate dependency (jpeg, png, gif, webp) - Resize images so longest side <= 1200px (Lanczos3) - Re-encode as JPEG at quality 75 (~200-400KB after base64) - GIFs pass through unchanged to preserve animation - Fallback to original bytes if resize fails Fixes #209 --- Cargo.toml | 1 + src/discord.rs | 110 +++++++++++++++++++++++++++++++------------------ 2 files changed, 72 insertions(+), 39 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2b56c02..c4d6351 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ anyhow = "1" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } base64 = "0.22" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } diff --git a/src/discord.rs b/src/discord.rs index 7753917..f382db0 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -5,6 +5,8 @@ use crate::format; use crate::reactions::StatusReactionController; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; +use image::ImageReader; +use std::io::Cursor; use std::sync::LazyLock; use serenity::async_trait; use serenity::model::channel::{Message, ReactionType}; @@ -233,14 +235,20 @@ impl EventHandler for Handler { } } -/// Download a Discord image attachment and encode it as an ACP image content block. -/// -/// Discord attachment URLs are temporary and expire, so we must download -/// and encode the image data immediately. The ACP ImageContent schema -/// requires `{ data: base64_string, mimeType: "image/..." }`. +/// Maximum dimension (width or height) for resized images. +/// Matches OpenClaw's DEFAULT_IMAGE_MAX_DIMENSION_PX. +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; + +/// JPEG quality for compressed output (OpenClaw uses progressive 85→35; +/// we start at 75 which is a good balance of quality vs size). +const IMAGE_JPEG_QUALITY: u8 = 75; + +/// Download a Discord image attachment, resize/compress it, then base64-encode +/// as an ACP image content block. /// -/// Security: rejects non-image attachments (by content-type or extension) -/// and files larger than 10MB to prevent OOM/abuse. +/// Large images are resized so the longest side is at most 1200px and +/// re-encoded as JPEG at quality 75. This keeps the base64 payload well +/// under typical JSON-RPC transport limits (~200-400KB after encoding). async fn download_and_encode_image(attachment: &serenity::model::channel::Attachment) -> Option { const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB @@ -267,69 +275,93 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach }) }); - // Validate that it's actually an image let Some(mime) = media_type else { - debug!(filename = %attachment.filename, "skipping non-image attachment (no matching content-type or extension)"); + debug!(filename = %attachment.filename, "skipping non-image attachment"); return None; }; - // Strip MIME type parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg") - // Downstream LLM APIs (Claude, OpenAI, Gemini) reject MIME types with parameters let mime = mime.split(';').next().unwrap_or(mime).trim(); if !mime.starts_with("image/") { debug!(filename = %attachment.filename, mime = %mime, "skipping non-image attachment"); return None; } - // Size check before downloading if u64::from(attachment.size) > MAX_SIZE { - error!( - filename = %attachment.filename, - size = attachment.size, - max = MAX_SIZE, - "image attachment exceeds 10MB limit" - ); + error!(filename = %attachment.filename, size = attachment.size, "image exceeds 10MB limit"); return None; } - // Download using the static reusable client let response = match HTTP_CLIENT.get(url).send().await { Ok(resp) => resp, - Err(e) => { - error!("failed to download image {}: {}", url, e); - return None; - } + Err(e) => { error!("download failed {url}: {e}"); return None; } }; - if !response.status().is_success() { - error!("HTTP error downloading image {}: {}", url, response.status()); + error!("HTTP {}: {url}", response.status()); return None; } - let bytes = match response.bytes().await { Ok(b) => b, + Err(e) => { error!("read failed {url}: {e}"); return None; } + }; + + // Resize and compress + let (output_bytes, output_mime) = match resize_and_compress(&bytes) { + Ok(result) => result, Err(e) => { - error!("failed to read image bytes from {}: {}", url, e); - return None; + debug!(filename = %attachment.filename, error = %e, "resize failed, using original"); + (bytes.to_vec(), mime.to_string()) } }; - // Final size check after download (defense in depth) - if bytes.len() as u64 > MAX_SIZE { - error!( - filename = %attachment.filename, - size = bytes.len(), - "downloaded image exceeds 10MB limit after decode" - ); - return None; - } + debug!( + filename = %attachment.filename, + original_size = bytes.len(), + compressed_size = output_bytes.len(), + "image processed" + ); - let encoded = BASE64.encode(bytes.as_ref()); + let encoded = BASE64.encode(&output_bytes); Some(ContentBlock::Image { - media_type: mime.to_string(), + media_type: output_mime, data: encoded, }) } +/// Resize image so longest side ≤ IMAGE_MAX_DIMENSION_PX, then encode as JPEG. +/// Returns (compressed_bytes, mime_type). GIFs are passed through unchanged +/// to preserve animation. +fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + let reader = ImageReader::new(Cursor::new(raw)) + .with_guessed_format()?; + + let format = reader.format(); + + // Pass through GIFs unchanged to preserve animation + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + + // Resize if either dimension exceeds the limit + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + img.resize( + IMAGE_MAX_DIMENSION_PX, + IMAGE_MAX_DIMENSION_PX, + image::imageops::FilterType::Lanczos3, + ) + } else { + img + }; + + // Encode as JPEG + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + async fn edit(ctx: &Context, ch: ChannelId, msg_id: MessageId, content: &str) -> serenity::Result { ch.edit_message(&ctx.http, msg_id, serenity::builder::EditMessage::new().content(content)).await } From dff4c7614d392e123137c6c9f25f85af553f2b9b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 11 Apr 2026 11:07:39 +0000 Subject: [PATCH 2/4] test: add unit tests for image resize and compression Tests cover: - Large image resized to max 1200px - Small image keeps original dimensions - Landscape/portrait aspect ratio preserved - Compressed output smaller than original - GIF passes through unchanged - Invalid data returns error --- src/discord.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/discord.rs b/src/discord.rs index f382db0..d246073 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -574,3 +574,87 @@ async fn get_or_create_thread(ctx: &Context, msg: &Message, prompt: &str) -> any Ok(thread.id.get()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_png(width: u32, height: u32) -> Vec { + let img = image::RgbImage::new(width, height); + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + buf.into_inner() + } + + #[test] + fn large_image_resized_to_max_dimension() { + let png = make_png(3000, 2000); + let (compressed, mime) = resize_and_compress(&png).unwrap(); + + assert_eq!(mime, "image/jpeg"); + let result = image::load_from_memory(&compressed).unwrap(); + assert!(result.width() <= IMAGE_MAX_DIMENSION_PX); + assert!(result.height() <= IMAGE_MAX_DIMENSION_PX); + } + + #[test] + fn small_image_keeps_original_dimensions() { + let png = make_png(800, 600); + let (compressed, mime) = resize_and_compress(&png).unwrap(); + + assert_eq!(mime, "image/jpeg"); + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.width(), 800); + assert_eq!(result.height(), 600); + } + + #[test] + fn landscape_image_respects_aspect_ratio() { + let png = make_png(4000, 2000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.width(), IMAGE_MAX_DIMENSION_PX); + assert_eq!(result.height(), 600); // 2000 * (1200/4000) + } + + #[test] + fn portrait_image_respects_aspect_ratio() { + let png = make_png(2000, 4000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + let result = image::load_from_memory(&compressed).unwrap(); + assert_eq!(result.height(), IMAGE_MAX_DIMENSION_PX); + assert_eq!(result.width(), 600); // 2000 * (1200/4000) + } + + #[test] + fn compressed_output_is_smaller_than_original() { + let png = make_png(3000, 2000); + let (compressed, _) = resize_and_compress(&png).unwrap(); + + assert!(compressed.len() < png.len(), "compressed {} should be < original {}", compressed.len(), png.len()); + } + + #[test] + fn gif_passes_through_unchanged() { + // Minimal valid GIF89a (1x1 pixel) + let gif: Vec = vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // logical screen descriptor + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // image descriptor + 0x02, 0x02, 0x44, 0x01, 0x00, // image data + 0x3B, // trailer + ]; + let (output, mime) = resize_and_compress(&gif).unwrap(); + + assert_eq!(mime, "image/gif"); + assert_eq!(output, gif); + } + + #[test] + fn invalid_data_returns_error() { + let garbage = vec![0x00, 0x01, 0x02, 0x03]; + assert!(resize_and_compress(&garbage).is_err()); + } +} From 0e49dc4645c07c80df3b3af3de1ece3aec6a2fe6 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 11 Apr 2026 11:15:02 +0000 Subject: [PATCH 3/4] fix: preserve aspect ratio on resize + add fallback size check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @the3mi: - 🔴 Fix resize() to calculate proportional dimensions instead of forcing 1200x1200 (was distorting images) - 🟡 Add 1MB size check on fallback path when resize fails - Fix portrait/landscape test assertions to match correct aspect ratios --- src/discord.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index d246073..55bcaa5 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -307,6 +307,11 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach let (output_bytes, output_mime) = match resize_and_compress(&bytes) { Ok(result) => result, Err(e) => { + // Fallback: use original bytes but reject if too large for transport + if bytes.len() > 1024 * 1024 { + error!(filename = %attachment.filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); + return None; + } debug!(filename = %attachment.filename, error = %e, "resize failed, using original"); (bytes.to_vec(), mime.to_string()) } @@ -343,13 +348,13 @@ fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageErro let img = reader.decode()?; let (w, h) = (img.width(), img.height()); - // Resize if either dimension exceeds the limit + // Resize preserving aspect ratio: scale so longest side = 1200px let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { - img.resize( - IMAGE_MAX_DIMENSION_PX, - IMAGE_MAX_DIMENSION_PX, - image::imageops::FilterType::Lanczos3, - ) + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) } else { img }; @@ -614,8 +619,8 @@ mod tests { let (compressed, _) = resize_and_compress(&png).unwrap(); let result = image::load_from_memory(&compressed).unwrap(); - assert_eq!(result.width(), IMAGE_MAX_DIMENSION_PX); - assert_eq!(result.height(), 600); // 2000 * (1200/4000) + assert_eq!(result.width(), 1200); + assert_eq!(result.height(), 600); } #[test] @@ -624,8 +629,8 @@ mod tests { let (compressed, _) = resize_and_compress(&png).unwrap(); let result = image::load_from_memory(&compressed).unwrap(); - assert_eq!(result.height(), IMAGE_MAX_DIMENSION_PX); - assert_eq!(result.width(), 600); // 2000 * (1200/4000) + assert_eq!(result.width(), 600); + assert_eq!(result.height(), 1200); } #[test] From 13210601e3633c96817d85caf54df3bd3f3c8629 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 11 Apr 2026 11:19:24 +0000 Subject: [PATCH 4/4] fix: restore post-download size check + use structured logging Address minor review feedback: - Restore defense-in-depth bytes.len() check after download - Use tracing structured fields (url = %url, error = %e) for consistency with codebase style --- src/discord.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 55bcaa5..e098acb 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -292,17 +292,23 @@ async fn download_and_encode_image(attachment: &serenity::model::channel::Attach let response = match HTTP_CLIENT.get(url).send().await { Ok(resp) => resp, - Err(e) => { error!("download failed {url}: {e}"); return None; } + Err(e) => { error!(url = %url, error = %e, "download failed"); return None; } }; if !response.status().is_success() { - error!("HTTP {}: {url}", response.status()); + error!(url = %url, status = %response.status(), "HTTP error downloading image"); return None; } let bytes = match response.bytes().await { Ok(b) => b, - Err(e) => { error!("read failed {url}: {e}"); return None; } + Err(e) => { error!(url = %url, error = %e, "read failed"); return None; } }; + // Defense-in-depth: verify actual download size + if bytes.len() as u64 > MAX_SIZE { + error!(filename = %attachment.filename, size = bytes.len(), "downloaded image exceeds limit"); + return None; + } + // Resize and compress let (output_bytes, output_mime) = match resize_and_compress(&bytes) { Ok(result) => result,