Skip to content

Commit 5647deb

Browse files
committed
Add support for sending more media types
1 parent b047586 commit 5647deb

File tree

5 files changed

+143
-28
lines changed

5 files changed

+143
-28
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,6 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] }
7373

7474
# Lightweight regex for Slack markdown conversion
7575
regex-lite = "0.1"
76+
77+
# MIME type detection for file uploads
78+
mime_guess = "2"

src/channels/mod.rs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -255,21 +255,24 @@ pub async fn execute_action(
255255
}
256256
}
257257

258-
/// Extract image paths from Claude's response text.
258+
/// Extract media file paths from Claude's response text.
259259
///
260-
/// Looks for file paths in the response that point to image files.
261-
/// Specifically looks for paths in the skills/nano-banana/generated/ directory.
262-
fn extract_image_attachments(response: &str) -> Vec<PathBuf> {
260+
/// Looks for file paths in the response that point to image or video files.
261+
fn extract_media_attachments(response: &str) -> Vec<PathBuf> {
263262
let mut attachments = Vec::new();
264263

265-
// Look for file paths that end in image extensions
266-
let image_extensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
264+
// Look for file paths that end in media extensions
265+
let media_extensions = [
266+
// Images
267+
".png", ".jpg", ".jpeg", ".gif", ".webp", // Videos
268+
".mp4", ".mov", ".webm", ".avi",
269+
];
267270

268271
for line in response.lines() {
269272
let line = line.trim();
270273

271274
// Check if line contains a file path
272-
for ext in &image_extensions {
275+
for ext in &media_extensions {
273276
if line.contains(ext) {
274277
// Try to extract the path - look for paths starting with /Users/
275278
if let Some(start) = line.find("/Users/") {
@@ -279,7 +282,7 @@ fn extract_image_attachments(response: &str) -> Vec<PathBuf> {
279282
let path_str = &line[start..end_pos];
280283
if std::path::Path::new(path_str).exists() {
281284
attachments.push(PathBuf::from(path_str));
282-
break; // Found the image on this line, move to next line
285+
break;
283286
}
284287
}
285288
}
@@ -293,17 +296,19 @@ fn extract_image_attachments(response: &str) -> Vec<PathBuf> {
293296
/// Remove lines from the response that contain file paths.
294297
///
295298
/// This cleans up responses to avoid showing technical file paths to the user
296-
/// when images are being sent as attachments.
299+
/// when media files are being sent as attachments.
297300
fn remove_file_path_lines(response: &str) -> String {
298301
let lines: Vec<&str> = response
299302
.lines()
300303
.filter(|line| {
301304
let trimmed = line.trim();
302-
// Skip lines that contain /Users/ (file paths)
303-
// Skip lines that are just "The image has been saved to:" or similar
305+
let lower = trimmed.to_lowercase();
306+
// Skip lines that contain file paths or mention saving files
304307
!trimmed.contains("/Users/")
305-
&& !trimmed.to_lowercase().contains("saved to")
306-
&& !trimmed.to_lowercase().contains("image has been saved")
308+
&& !lower.contains("saved to")
309+
&& !lower.contains("image has been saved")
310+
&& !lower.contains("video has been saved")
311+
&& !lower.contains("file has been saved")
307312
&& !trimmed.is_empty()
308313
})
309314
.collect();
@@ -368,8 +373,8 @@ pub async fn execute_claude_query(channel: Arc<dyn Channel>, user_id: &str, mess
368373
}
369374
};
370375

371-
// Extract any image attachments from the response
372-
let attachments = extract_image_attachments(&response);
376+
// Extract any media attachments (images, videos) from the response
377+
let attachments = extract_media_attachments(&response);
373378

374379
// Send response with attachments if any
375380
if !attachments.is_empty() {

src/channels/slack.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,88 @@ impl Channel for SlackChannel {
221221
}
222222
}
223223

224+
async fn send_message_with_attachments(
225+
&self,
226+
message: &str,
227+
attachment_paths: &[PathBuf],
228+
) -> Result<()> {
229+
// If no attachments, just send the text message
230+
if attachment_paths.is_empty() {
231+
return self.send_message(message).await;
232+
}
233+
234+
let session = self.client.open_session(&self.token);
235+
236+
let mut uploaded_files = Vec::new();
237+
238+
for path in attachment_paths {
239+
if !path.exists() {
240+
warn!("Attachment path does not exist: {:?}", path);
241+
continue;
242+
}
243+
244+
let file_bytes = std::fs::read(path)?;
245+
let filename = path
246+
.file_name()
247+
.and_then(|n| n.to_str())
248+
.unwrap_or("file")
249+
.to_string();
250+
251+
let get_url_req =
252+
SlackApiFilesGetUploadUrlExternalRequest::new(filename.clone(), file_bytes.len());
253+
254+
let get_url_resp = session
255+
.get_upload_url_external(&get_url_req)
256+
.await
257+
.map_err(|e| anyhow::anyhow!("Failed to get upload URL: {}", e))?;
258+
259+
let content_type = mime_guess::from_path(path)
260+
.first_or_octet_stream()
261+
.to_string();
262+
263+
let upload_req = SlackApiFilesUploadViaUrlRequest::new(
264+
get_url_resp.upload_url,
265+
file_bytes,
266+
content_type,
267+
);
268+
269+
session
270+
.files_upload_via_url(&upload_req)
271+
.await
272+
.map_err(|e| anyhow::anyhow!("Failed to upload file: {}", e))?;
273+
274+
uploaded_files
275+
.push(SlackApiFilesComplete::new(get_url_resp.file_id).with_title(filename));
276+
}
277+
278+
if uploaded_files.is_empty() {
279+
if !message.is_empty() {
280+
return self.send_message(message).await;
281+
}
282+
return Ok(());
283+
}
284+
285+
let mut complete_req = SlackApiFilesCompleteUploadExternalRequest::new(uploaded_files)
286+
.with_channel_id(self.channel_id.clone());
287+
288+
if !message.is_empty() {
289+
let mrkdwn_message = markdown_to_mrkdwn(message);
290+
complete_req = complete_req.with_initial_comment(mrkdwn_message);
291+
}
292+
293+
if let Some(ts) = &self.thread_ts {
294+
complete_req = complete_req.with_thread_ts(ts.clone());
295+
}
296+
297+
session
298+
.files_complete_upload_external(&complete_req)
299+
.await
300+
.map_err(|e| anyhow::anyhow!("Failed to complete file upload: {}", e))?;
301+
302+
info!("Sent message with attachments to Slack");
303+
Ok(())
304+
}
305+
224306
fn start_typing(&self) -> TypingGuard {
225307
// For Slack AI assistants, we use assistant.threads.setStatus
226308
// to show a "thinking" indicator

src/channels/telegram.rs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,30 +59,40 @@ impl Channel for TelegramChannel {
5959
return self.send_message(message).await;
6060
}
6161

62-
// Send images as photos
62+
let is_first_attachment = |path: &PathBuf| -> bool {
63+
attachment_paths.first().map(|p| p == path).unwrap_or(false)
64+
};
65+
66+
// Send each attachment using the appropriate Telegram method
6367
for path in attachment_paths {
6468
if !path.exists() {
6569
warn!("Attachment path does not exist: {:?}", path);
6670
continue;
6771
}
6872

69-
// Create InputFile from path
7073
let input_file = InputFile::file(path);
74+
let caption = if is_first_attachment(path) && !message.is_empty() {
75+
Some(message)
76+
} else {
77+
None
78+
};
7179

72-
// Send the photo with the message as caption (only on first photo)
73-
if path == attachment_paths.first().unwrap() && !message.is_empty() {
74-
// First photo gets the caption
75-
self.bot
76-
.send_photo(self.chat_id, input_file)
77-
.caption(message)
78-
.await?;
80+
if is_video_file(path) {
81+
let mut req = self.bot.send_video(self.chat_id, input_file);
82+
if let Some(caption) = caption {
83+
req = req.caption(caption);
84+
}
85+
req.await?;
7986
} else {
80-
// Subsequent photos without caption
81-
self.bot.send_photo(self.chat_id, input_file).await?;
87+
let mut req = self.bot.send_photo(self.chat_id, input_file);
88+
if let Some(caption) = caption {
89+
req = req.caption(caption);
90+
}
91+
req.await?;
8292
}
8393
}
8494

85-
// If message exists but all photos failed, send just the text
95+
// If message exists but all attachments were missing, send just the text
8696
if !message.is_empty() && attachment_paths.iter().all(|p| !p.exists()) {
8797
self.send_message(message).await?;
8898
}
@@ -118,9 +128,23 @@ impl Channel for TelegramChannel {
118128
}
119129

120130
// ============================================================================
121-
// Photo Handling
131+
// Media Handling
122132
// ============================================================================
123133

134+
/// Video file extensions supported for sending
135+
const VIDEO_EXTENSIONS: &[&str] = &[".mp4", ".mov", ".webm", ".avi"];
136+
137+
/// Check if a file path points to a video based on its extension
138+
fn is_video_file(path: &std::path::Path) -> bool {
139+
path.extension()
140+
.and_then(|ext| ext.to_str())
141+
.map(|ext| {
142+
let dot_ext = format!(".{}", ext.to_lowercase());
143+
VIDEO_EXTENSIONS.contains(&dot_ext.as_str())
144+
})
145+
.unwrap_or(false)
146+
}
147+
124148
/// Get the directory where Telegram attachments are stored
125149
fn get_telegram_attachments_dir() -> Result<PathBuf> {
126150
let paths = config::paths()?;

0 commit comments

Comments
 (0)