From ec37d1289886f21beae862ed82616c350757398c Mon Sep 17 00:00:00 2001 From: egenvall Date: Mon, 23 Feb 2026 08:18:59 +0100 Subject: [PATCH 1/4] Add processing for slack image messages --- src/messaging/slack.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 22f6596b7..311c01384 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -133,9 +133,15 @@ async fn handle_message_event( client: Arc, states: SlackClientEventsUserState, ) -> UserCallbackResult<()> { - // Skip message edits / deletes / bot_message subtypes - if msg_event.subtype.is_some() { - return Ok(()); + // Skip message edits / deletes / bot_message subtypes, but allow file-related + // subtypes so user-uploaded images and documents are processed. + if let Some(ref subtype) = msg_event.subtype { + if !matches!( + subtype, + SlackMessageEventType::FileShare | SlackMessageEventType::FileShared + ) { + return Ok(()); + } } let state_guard = states.read().await; From 66cc498473afcedb015c61eaafc2c575110eb348 Mon Sep 17 00:00:00 2001 From: egenvall Date: Sun, 22 Feb 2026 13:54:04 +0100 Subject: [PATCH 2/4] Add auth header --- src/agent/channel.rs | 18 +++++++++++++++--- src/lib.rs | 3 +++ src/messaging/discord.rs | 1 + src/messaging/slack.rs | 8 ++++++-- src/messaging/telegram.rs | 5 +++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 7c3c08193..842c1c8a3 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1872,7 +1872,11 @@ async fn download_image_attachment( http: &reqwest::Client, attachment: &crate::Attachment, ) -> UserContent { - let response = match http.get(&attachment.url).send().await { + let mut request = http.get(&attachment.url); + if let Some(ref auth) = attachment.auth_header { + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + let response = match request.send().await { Ok(r) => r, Err(error) => { tracing::warn!(%error, filename = %attachment.filename, "failed to download image"); @@ -1914,7 +1918,11 @@ async fn transcribe_audio_attachment( http: &reqwest::Client, attachment: &crate::Attachment, ) -> UserContent { - let response = match http.get(&attachment.url).send().await { + let mut request = http.get(&attachment.url); + if let Some(ref auth) = attachment.auth_header { + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + let response = match request.send().await { Ok(r) => r, Err(error) => { tracing::warn!(%error, filename = %attachment.filename, "failed to download audio"); @@ -2138,7 +2146,11 @@ async fn download_text_attachment( http: &reqwest::Client, attachment: &crate::Attachment, ) -> UserContent { - let response = match http.get(&attachment.url).send().await { + let mut request = http.get(&attachment.url); + if let Some(ref auth) = attachment.auth_header { + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + let response = match request.send().await { Ok(r) => r, Err(error) => { tracing::warn!(%error, filename = %attachment.filename, "failed to download text file"); diff --git a/src/lib.rs b/src/lib.rs index 98b4eac3f..a3dba17c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -289,6 +289,9 @@ pub struct Attachment { pub mime_type: String, pub url: String, pub size_bytes: Option, + /// Optional auth header value for private URLs (e.g. Slack's `url_private`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_header: Option, } /// Outbound response to messaging platforms. diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index ab433dd3a..5857c3eba 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -787,6 +787,7 @@ fn extract_content(message: &Message) -> MessageContent { mime_type: attachment.content_type.clone().unwrap_or_default(), url: attachment.url.clone(), size_bytes: Some(attachment.size as u64), + auth_header: None, }) .collect(); diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 311c01384..62efc3dd5 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -204,7 +204,7 @@ async fn handle_message_event( format!("slack:{}:{}", team_id_str, channel_id) }; - let content = extract_message_content(&msg_event.content); + let content = extract_message_content(&msg_event.content, &adapter_state.bot_token); let (metadata, formatted_author) = build_metadata_and_author( &team_id_str, @@ -1226,7 +1226,10 @@ fn markdown_content(text: impl Into) -> SlackMessageContent { } /// Extract `MessageContent` from an optional `SlackMessageContent`. -fn extract_message_content(content: &Option) -> MessageContent { +fn extract_message_content( + content: &Option, + bot_token: &str, +) -> MessageContent { let Some(msg_content) = content else { return MessageContent::Text(String::new()); }; @@ -1241,6 +1244,7 @@ fn extract_message_content(content: &Option) -> MessageCont mime_type: f.mimetype.as_ref().map(|m| m.0.clone()).unwrap_or_default(), url: url.to_string(), size_bytes: None, + auth_header: Some(format!("Bearer {}", bot_token)), }) }) .collect(); diff --git a/src/messaging/telegram.rs b/src/messaging/telegram.rs index 47c1bda48..4cbe816e9 100644 --- a/src/messaging/telegram.rs +++ b/src/messaging/telegram.rs @@ -627,6 +627,7 @@ fn extract_attachments(message: &teloxide::types::Message) -> Vec { mime_type: "image/jpeg".into(), url: largest.file.id.to_string(), size_bytes: Some(largest.file.size as u64), + auth_header: None, }); } } @@ -645,6 +646,7 @@ fn extract_attachments(message: &teloxide::types::Message) -> Vec { .unwrap_or_else(|| "application/octet-stream".into()), url: doc.document.file.id.to_string(), size_bytes: Some(doc.document.file.size as u64), + auth_header: None, }); } MediaKind::Video(video) => { @@ -662,6 +664,7 @@ fn extract_attachments(message: &teloxide::types::Message) -> Vec { .unwrap_or_else(|| "video/mp4".into()), url: video.video.file.id.to_string(), size_bytes: Some(video.video.file.size as u64), + auth_header: None, }); } MediaKind::Voice(voice) => { @@ -675,6 +678,7 @@ fn extract_attachments(message: &teloxide::types::Message) -> Vec { .unwrap_or_else(|| "audio/ogg".into()), url: voice.voice.file.id.to_string(), size_bytes: Some(voice.voice.file.size as u64), + auth_header: None, }); } MediaKind::Audio(audio) => { @@ -692,6 +696,7 @@ fn extract_attachments(message: &teloxide::types::Message) -> Vec { .unwrap_or_else(|| "audio/mpeg".into()), url: audio.audio.file.id.to_string(), size_bytes: Some(audio.audio.file.size as u64), + auth_header: None, }); } _ => {} From 353260f5b0ae003a430c354d44fb26042a7885f1 Mon Sep 17 00:00:00 2001 From: egenvall Date: Mon, 23 Feb 2026 09:21:11 +0100 Subject: [PATCH 3/4] Preserve Auth Headers for Slack --- src/agent/channel.rs | 128 +++++++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 48 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 842c1c8a3..eb9878af5 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1867,30 +1867,87 @@ async fn download_attachments( parts } -/// Download an image attachment and encode it as base64 for the LLM. -async fn download_image_attachment( +/// Download raw bytes from an attachment URL, including auth if present. +/// +/// When `auth_header` is set (Slack), uses a no-redirect client and manually +/// follows redirects so the `Authorization` header isn't silently stripped on +/// cross-origin redirects. For public URLs (Discord/Telegram), uses a plain GET. +async fn download_attachment_bytes( http: &reqwest::Client, attachment: &crate::Attachment, -) -> UserContent { - let mut request = http.get(&attachment.url); - if let Some(ref auth) = attachment.auth_header { - request = request.header(reqwest::header::AUTHORIZATION, auth); +) -> std::result::Result, String> { + if attachment.auth_header.is_some() { + download_attachment_bytes_with_auth(attachment).await + } else { + let response = http + .get(&attachment.url) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + response + .bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| e.to_string()) } - let response = match request.send().await { - Ok(r) => r, - Err(error) => { - tracing::warn!(%error, filename = %attachment.filename, "failed to download image"); - return UserContent::text(format!( - "[Failed to download image: {}]", - attachment.filename - )); +} + +/// Slack-specific download: manually follows redirects to preserve the auth header. +async fn download_attachment_bytes_with_auth( + attachment: &crate::Attachment, +) -> std::result::Result, String> { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let auth = attachment.auth_header.as_deref().unwrap_or_default(); + let mut url = attachment.url.clone(); + + for _ in 0..5 { + let response = client + .get(&url) + .header(reqwest::header::AUTHORIZATION, auth) + .send() + .await + .map_err(|e| e.to_string())?; + let status = response.status(); + + if status.is_redirection() { + if let Some(location) = response.headers().get(reqwest::header::LOCATION) { + url = location.to_str().unwrap_or_default().to_string(); + continue; + } + return Err(format!("redirect without Location header ({})", status)); } - }; - let bytes = match response.bytes().await { + if !status.is_success() { + return Err(format!("HTTP {}", status)); + } + + return response + .bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| e.to_string()); + } + + Err("too many redirects".into()) +} + +/// Download an image attachment and encode it as base64 for the LLM. +async fn download_image_attachment( + http: &reqwest::Client, + attachment: &crate::Attachment, +) -> UserContent { + let bytes = match download_attachment_bytes(http, attachment).await { Ok(b) => b, Err(error) => { - tracing::warn!(%error, filename = %attachment.filename, "failed to read image bytes"); + tracing::warn!(%error, filename = %attachment.filename, "failed to download image"); return UserContent::text(format!( "[Failed to download image: {}]", attachment.filename @@ -1918,25 +1975,10 @@ async fn transcribe_audio_attachment( http: &reqwest::Client, attachment: &crate::Attachment, ) -> UserContent { - let mut request = http.get(&attachment.url); - if let Some(ref auth) = attachment.auth_header { - request = request.header(reqwest::header::AUTHORIZATION, auth); - } - let response = match request.send().await { - Ok(r) => r, - Err(error) => { - tracing::warn!(%error, filename = %attachment.filename, "failed to download audio"); - return UserContent::text(format!( - "[Failed to download audio: {}]", - attachment.filename - )); - } - }; - - let bytes = match response.bytes().await { + let bytes = match download_attachment_bytes(http, attachment).await { Ok(b) => b, Err(error) => { - tracing::warn!(%error, filename = %attachment.filename, "failed to read audio bytes"); + tracing::warn!(%error, filename = %attachment.filename, "failed to download audio"); return UserContent::text(format!( "[Failed to download audio: {}]", attachment.filename @@ -2018,7 +2060,7 @@ async fn transcribe_audio_attachment( "temperature": 0 }); - let response = match http + let response = match deps.llm_manager.http_client() .post(&endpoint) .header("authorization", format!("Bearer {}", provider.api_key)) .header("content-type", "application/json") @@ -2146,12 +2188,8 @@ async fn download_text_attachment( http: &reqwest::Client, attachment: &crate::Attachment, ) -> UserContent { - let mut request = http.get(&attachment.url); - if let Some(ref auth) = attachment.auth_header { - request = request.header(reqwest::header::AUTHORIZATION, auth); - } - let response = match request.send().await { - Ok(r) => r, + let bytes = match download_attachment_bytes(http, attachment).await { + Ok(b) => b, Err(error) => { tracing::warn!(%error, filename = %attachment.filename, "failed to download text file"); return UserContent::text(format!( @@ -2161,13 +2199,7 @@ async fn download_text_attachment( } }; - let content = match response.text().await { - Ok(c) => c, - Err(error) => { - tracing::warn!(%error, filename = %attachment.filename, "failed to read text file"); - return UserContent::text(format!("[Failed to read file: {}]", attachment.filename)); - } - }; + let content = String::from_utf8_lossy(&bytes).into_owned(); // Truncate very large files to avoid blowing up context let truncated = if content.len() > 50_000 { From 7f4d712cc3a1a89b4abec967ec8bb44baea2b862 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 23 Feb 2026 16:47:47 -0800 Subject: [PATCH 4/4] Fix formatting and clippy collapsible_if warning --- src/agent/channel.rs | 4 +++- src/messaging/slack.rs | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index e60f642c3..a4e5586d6 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2162,7 +2162,9 @@ async fn transcribe_audio_attachment( "temperature": 0 }); - let response = match deps.llm_manager.http_client() + let response = match deps + .llm_manager + .http_client() .post(&endpoint) .header("authorization", format!("Bearer {}", provider.api_key)) .header("content-type", "application/json") diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 412b33fad..e41b2995a 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -152,13 +152,13 @@ async fn handle_message_event( ) -> UserCallbackResult<()> { // Skip message edits / deletes / bot_message subtypes, but allow file-related // subtypes so user-uploaded images and documents are processed. - if let Some(ref subtype) = msg_event.subtype { - if !matches!( + if let Some(ref subtype) = msg_event.subtype + && !matches!( subtype, SlackMessageEventType::FileShare | SlackMessageEventType::FileShared - ) { - return Ok(()); - } + ) + { + return Ok(()); } let state_guard = states.read().await;