@@ -860,6 +860,93 @@ async fn dispatch_message(
860860 return ;
861861 }
862862
863+ // Multipart: flatten children into LLM content blocks. If any image
864+ // succeeds, dispatch as multimodal; otherwise fall through to the text
865+ // path (Multipart arm in the match below builds the combined descriptor).
866+ if let ChannelContent :: Multipart ( parts) = & message. content {
867+ let mut blocks: Vec < ContentBlock > = Vec :: new ( ) ;
868+ for part in parts {
869+ debug_assert ! (
870+ !matches!( part, ChannelContent :: Multipart ( _) ) ,
871+ "nested Multipart in ChannelContent — adapters should produce flat lists"
872+ ) ;
873+ match part {
874+ ChannelContent :: Text ( t) => blocks. push ( ContentBlock :: Text {
875+ text : t. clone ( ) ,
876+ provider_metadata : None ,
877+ } ) ,
878+ ChannelContent :: Image { url, caption } => {
879+ let mut img = download_image_to_blocks ( url, caption. as_deref ( ) ) . await ;
880+ blocks. append ( & mut img) ;
881+ }
882+ ChannelContent :: File { url, filename, .. } => {
883+ blocks. push ( ContentBlock :: Text {
884+ text : format ! ( "[User sent a file ({filename}): {url}]" ) ,
885+ provider_metadata : None ,
886+ } ) ;
887+ }
888+ ChannelContent :: Voice {
889+ url,
890+ duration_seconds,
891+ } => {
892+ blocks. push ( ContentBlock :: Text {
893+ text : format ! ( "[User sent a voice message ({duration_seconds}s): {url}]" ) ,
894+ provider_metadata : None ,
895+ } ) ;
896+ }
897+ ChannelContent :: Location { lat, lon } => {
898+ blocks. push ( ContentBlock :: Text {
899+ text : format ! ( "[User shared location: {lat}, {lon}]" ) ,
900+ provider_metadata : None ,
901+ } ) ;
902+ }
903+ ChannelContent :: FileData { filename, .. } => {
904+ blocks. push ( ContentBlock :: Text {
905+ text : format ! ( "[User sent a local file: {filename}]" ) ,
906+ provider_metadata : None ,
907+ } ) ;
908+ }
909+ // Commands aren't expected inside Multipart, but render as
910+ // text rather than drop the message if one slips through.
911+ ChannelContent :: Command { name, args } => {
912+ blocks. push ( ContentBlock :: Text {
913+ text : format ! ( "/{name} {}" , args. join( " " ) ) ,
914+ provider_metadata : None ,
915+ } ) ;
916+ }
917+ // Defensive: debug_assert above catches this in dev; ignore
918+ // gracefully in release.
919+ ChannelContent :: Multipart ( _) => { }
920+ }
921+ }
922+
923+ if blocks
924+ . iter ( )
925+ . any ( |b| matches ! ( b, ContentBlock :: Image { .. } ) )
926+ {
927+ let prefix_style = overrides
928+ . as_ref ( )
929+ . map ( |o| o. prefix_agent_name )
930+ . unwrap_or ( PrefixStyle :: Off ) ;
931+ dispatch_with_blocks (
932+ blocks,
933+ message,
934+ handle,
935+ router,
936+ adapter,
937+ adapter_arc,
938+ ct_str,
939+ thread_id,
940+ output_format,
941+ lifecycle_reactions,
942+ prefix_style,
943+ )
944+ . await ;
945+ return ;
946+ }
947+ // No image blocks — fall through to text path below.
948+ }
949+
863950 // For images: download, base64 encode, and send as multimodal content blocks
864951 if let ChannelContent :: Image {
865952 ref url,
@@ -911,6 +998,7 @@ async fn dispatch_message(
911998 ChannelContent :: File {
912999 ref url,
9131000 ref filename,
1001+ ..
9141002 } => {
9151003 format ! ( "[User sent a file ({filename}): {url}]" )
9161004 }
@@ -926,6 +1014,37 @@ async fn dispatch_message(
9261014 ChannelContent :: FileData { ref filename, .. } => {
9271015 format ! ( "[User sent a local file: {filename}]" )
9281016 }
1017+ ChannelContent :: Multipart ( parts) => parts
1018+ . iter ( )
1019+ . map ( |p| match p {
1020+ ChannelContent :: Text ( t) => t. clone ( ) ,
1021+ ChannelContent :: Image { url, caption } => match caption {
1022+ Some ( c) => format ! ( "[User sent a photo: {url}]\n Caption: {c}" ) ,
1023+ None => format ! ( "[User sent a photo: {url}]" ) ,
1024+ } ,
1025+ ChannelContent :: File { url, filename, .. } => {
1026+ format ! ( "[User sent a file ({filename}): {url}]" )
1027+ }
1028+ ChannelContent :: Voice {
1029+ url,
1030+ duration_seconds,
1031+ } => format ! ( "[User sent a voice message ({duration_seconds}s): {url}]" ) ,
1032+ ChannelContent :: Location { lat, lon } => {
1033+ format ! ( "[User shared location: {lat}, {lon}]" )
1034+ }
1035+ ChannelContent :: FileData { filename, .. } => {
1036+ format ! ( "[User sent a local file: {filename}]" )
1037+ }
1038+ ChannelContent :: Command { name, args } => {
1039+ format ! ( "/{name} {}" , args. join( " " ) )
1040+ }
1041+ // Nesting is rejected by adapters; emit empty so the join
1042+ // doesn't insert spurious separators.
1043+ ChannelContent :: Multipart ( _) => String :: new ( ) ,
1044+ } )
1045+ . filter ( |s| !s. is_empty ( ) )
1046+ . collect :: < Vec < _ > > ( )
1047+ . join ( "\n " ) ,
9291048 } ;
9301049
9311050 // Check if it's a slash command embedded in text (e.g. "/agents")
@@ -1385,6 +1504,10 @@ fn media_type_from_url(url: &str) -> String {
13851504
13861505/// Download an image from a URL and build content blocks for multimodal LLM input.
13871506///
1507+ /// Accepts both `http(s)://` URLs (fetched via reqwest) and `file://` URLs
1508+ /// (read from local disk — used by the channel inbox materialization path so
1509+ /// agents see a stable local path even after a Discord CDN URL has expired).
1510+ ///
13881511/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
13891512/// optionally a text block for the caption. If the download fails, returns a
13901513/// text-only block describing the failure.
@@ -1394,38 +1517,79 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
13941517 // 5 MB limit to prevent memory abuse from oversized images
13951518 const MAX_IMAGE_BYTES : usize = 5 * 1024 * 1024 ;
13961519
1397- let client = reqwest:: Client :: new ( ) ;
1398- let resp = match client. get ( url) . send ( ) . await {
1399- Ok ( r) => r,
1400- Err ( e) => {
1401- warn ! ( "Failed to download image from channel: {e}" ) ;
1402- return vec ! [ ContentBlock :: Text {
1403- text: format!( "[Image download failed: {e}]" ) ,
1404- provider_metadata: None ,
1405- } ] ;
1406- }
1407- } ;
1520+ // Branch on URL scheme: file:// reads from local disk, everything else
1521+ // goes through HTTP. We unify both paths into (bytes, header_type) before
1522+ // the size/magic-byte logic below.
1523+ let ( bytes, header_type) : ( Vec < u8 > , Option < String > ) =
1524+ if let Some ( path) = url. strip_prefix ( "file://" ) {
1525+ // file:// — local read. No content-type header to honor; magic-byte
1526+ // sniffing and URL extension fallback do all the work. We don't
1527+ // percent-decode: the inbox writer controls filenames and avoids
1528+ // characters that would need encoding.
1529+ match tokio:: fs:: read ( path) . await {
1530+ Ok ( b) => ( b, None ) ,
1531+ Err ( e) => {
1532+ warn ! ( "Failed to read image from local path {path}: {e}" ) ;
1533+ return vec ! [ ContentBlock :: Text {
1534+ text: format!( "[Image read failed: {e}]" ) ,
1535+ provider_metadata: None ,
1536+ } ] ;
1537+ }
1538+ }
1539+ } else {
1540+ // Build the client with transparent decompression DISABLED. Discord's
1541+ // CDN edges occasionally advertise `content-encoding: gzip` (or br)
1542+ // on PNG/JPEG passthroughs while the body is the raw, uncompressed
1543+ // image bytes. With the default reqwest client (gzip/deflate/brotli
1544+ // features enabled at the workspace level), this causes the
1545+ // decompression layer to choke on the image header and reqwest
1546+ // returns "error decoding response body" only on `bytes().await`,
1547+ // not on `send()`. Forcing identity encoding sidesteps the whole
1548+ // class of CDN content-encoding-flapping bugs. We also set a UA
1549+ // (some CDNs 403 clients without one) and a 30s timeout aligned
1550+ // with the upstream 5 MB cap.
1551+ let client = reqwest:: Client :: builder ( )
1552+ . no_gzip ( )
1553+ . no_deflate ( )
1554+ . no_brotli ( )
1555+ . user_agent ( "openfang/0.1 (+https://openfang.ai)" )
1556+ . timeout ( std:: time:: Duration :: from_secs ( 30 ) )
1557+ . build ( )
1558+ . unwrap_or_else ( |_| reqwest:: Client :: new ( ) ) ;
1559+ let resp = match client. get ( url) . send ( ) . await {
1560+ Ok ( r) => r,
1561+ Err ( e) => {
1562+ warn ! ( "Failed to download image from channel: {e}" ) ;
1563+ return vec ! [ ContentBlock :: Text {
1564+ text: format!( "[Image download failed: {e}]" ) ,
1565+ provider_metadata: None ,
1566+ } ] ;
1567+ }
1568+ } ;
14081569
1409- // Detect media type from Content-Type header — but only trust it if it's
1410- // actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
1411- // `application/octet-stream` for all files, which breaks vision.
1412- let header_type = resp
1413- . headers ( )
1414- . get ( "content-type" )
1415- . and_then ( |v| v. to_str ( ) . ok ( ) )
1416- . map ( |ct| ct. split ( ';' ) . next ( ) . unwrap_or ( ct) . trim ( ) . to_string ( ) )
1417- . filter ( |ct| ct. starts_with ( "image/" ) ) ;
1418-
1419- let bytes = match resp. bytes ( ) . await {
1420- Ok ( b) => b,
1421- Err ( e) => {
1422- warn ! ( "Failed to read image bytes: {e}" ) ;
1423- return vec ! [ ContentBlock :: Text {
1424- text: format!( "[Image read failed: {e}]" ) ,
1425- provider_metadata: None ,
1426- } ] ;
1427- }
1428- } ;
1570+ // Detect media type from Content-Type header — but only trust it if
1571+ // it's actually an image/* type. Many APIs (Telegram, S3 pre-signed
1572+ // URLs) return `application/octet-stream` for all files, which
1573+ // breaks vision.
1574+ let header_type = resp
1575+ . headers ( )
1576+ . get ( "content-type" )
1577+ . and_then ( |v| v. to_str ( ) . ok ( ) )
1578+ . map ( |ct| ct. split ( ';' ) . next ( ) . unwrap_or ( ct) . trim ( ) . to_string ( ) )
1579+ . filter ( |ct| ct. starts_with ( "image/" ) ) ;
1580+
1581+ let bytes = match resp. bytes ( ) . await {
1582+ Ok ( b) => b,
1583+ Err ( e) => {
1584+ warn ! ( "Failed to read image bytes: {e}" ) ;
1585+ return vec ! [ ContentBlock :: Text {
1586+ text: format!( "[Image read failed: {e}]" ) ,
1587+ provider_metadata: None ,
1588+ } ] ;
1589+ }
1590+ } ;
1591+ ( bytes. to_vec ( ) , header_type)
1592+ } ;
14291593
14301594 // Three-tier media type detection:
14311595 // 1. Trusted Content-Type header (only if image/*)
0 commit comments