Skip to content

Commit bdcd440

Browse files
authored
Merge pull request #1143 from benhoverter/discord-file-sharing
fix(channels/discord): surface image attachments to text-only providers
2 parents 25516c7 + 701fcd8 commit bdcd440

7 files changed

Lines changed: 698 additions & 51 deletions

File tree

crates/openfang-channels/src/bridge.rs

Lines changed: 195 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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}]\nCaption: {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

Comments
 (0)