-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: full image support across all channels #725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6e7d0d0
e514725
dc44466
768c23f
a50ff84
ef549d4
645e7b0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,4 +23,5 @@ bench-results/ | |
| # WASM build artifacts (loaded from disk, not bundled) | ||
| *.wasm | ||
|
|
||
| # Traces | ||
| trace_*.json | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -357,10 +357,108 @@ fn extract_slack_attachments(files: &Option<Vec<SlackFile>>) -> Vec<InboundAttac | |
| .collect() | ||
| } | ||
|
|
||
| /// Download a file from Slack using the url_private endpoint. | ||
| /// | ||
| /// Slack file downloads require Bearer auth with the bot token, which is | ||
| /// injected by the host credential system via `channel_host::http_request`. | ||
| fn download_slack_file(url: &str) -> Result<Vec<u8>, String> { | ||
| let headers = serde_json::json!({}); | ||
|
|
||
| let result = channel_host::http_request("GET", url, &headers.to_string(), None, None); | ||
|
|
||
| let response = result.map_err(|e| format!("Slack file download failed: {}", e))?; | ||
|
|
||
| if response.status != 200 { | ||
| let body_str = String::from_utf8_lossy(&response.body); | ||
| return Err(format!( | ||
| "Slack file download returned {}: {}", | ||
| response.status, body_str | ||
| )); | ||
| } | ||
|
|
||
| Ok(response.body) | ||
| } | ||
|
|
||
| /// Download file bytes and store them via the host for processing. | ||
| /// | ||
| /// Downloads all file types (images, documents, etc.) so the host-side | ||
| /// middleware can process them (vision pipeline for images, text extraction | ||
| /// for documents, transcription for audio, etc.). | ||
| /// Maximum file size to download (20 MB). Files larger than this are skipped | ||
| /// to avoid excessive memory use and slow downloads in the WASM runtime. | ||
| const MAX_DOWNLOAD_SIZE_BYTES: u64 = 20 * 1024 * 1024; | ||
|
|
||
| fn download_and_store_slack_files(attachments: &[InboundAttachment]) { | ||
| for att in attachments { | ||
| let Some(ref url) = att.source_url else { | ||
| continue; | ||
| }; | ||
|
|
||
| // Skip files that exceed the size limit | ||
| if let Some(size) = att.size_bytes { | ||
| if size > MAX_DOWNLOAD_SIZE_BYTES { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Warn, | ||
| &format!( | ||
| "Skipping Slack file download: {} bytes exceeds {} MB limit (id={})", | ||
| size, | ||
| MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024), | ||
| att.id | ||
| ), | ||
| ); | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| match download_slack_file(url) { | ||
| Ok(bytes) => { | ||
| // Post-download size guard: metadata size_bytes is optional, | ||
| // so a file with no size info could bypass the pre-download check. | ||
| if bytes.len() as u64 > MAX_DOWNLOAD_SIZE_BYTES { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Warn, | ||
| &format!( | ||
| "Discarding Slack file after download: {} bytes exceeds {} MB limit (id={})", | ||
| bytes.len(), | ||
| MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024), | ||
| att.id | ||
| ), | ||
| ); | ||
| continue; | ||
| } | ||
|
|
||
| channel_host::log( | ||
| channel_host::LogLevel::Info, | ||
| &format!( | ||
| "Downloaded Slack file: {} bytes, mime={}", | ||
| bytes.len(), | ||
| att.mime_type | ||
| ), | ||
| ); | ||
| if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Error, | ||
| &format!("Failed to store Slack file data: {}", e), | ||
| ); | ||
| } | ||
| } | ||
| Err(e) => { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Error, | ||
| &format!("Failed to download Slack file: {}", e), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+382
to
+452
|
||
| } | ||
|
|
||
| /// Handle a Slack event and emit message if applicable. | ||
| fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) { | ||
| let attachments = extract_slack_attachments(&event.files); | ||
|
|
||
| // Download and store file attachments for host-side processing | ||
| download_and_store_slack_files(&attachments); | ||
|
|
||
| match event.event_type.as_str() { | ||
| // Direct mention of the bot (always in a channel, not a DM) | ||
| "app_mention" => { | ||
|
|
@@ -722,4 +820,10 @@ mod tests { | |
| let event: SlackEvent = serde_json::from_str(json).unwrap(); | ||
| assert!(event.files.is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_max_download_size_constant() { | ||
| // Verify the constant is 20 MB | ||
| assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024); | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -878,10 +878,6 @@ fn send_message( | |
| // Voice File Download | ||
| // ============================================================================ | ||
|
|
||
| /// Download a voice file from Telegram by file_id. | ||
| /// | ||
| /// 1. Call getFile to get the file_path. | ||
| /// 2. Download the file bytes from /file/bot{TOKEN}/{file_path}. | ||
| /// Percent-encode a string for safe use as a URL query parameter value. | ||
| fn percent_encode(s: &str) -> String { | ||
| let mut out = String::with_capacity(s.len()); | ||
|
|
@@ -898,6 +894,10 @@ fn percent_encode(s: &str) -> String { | |
| out | ||
| } | ||
|
|
||
| /// Maximum file size to download (20 MB). Files larger than this are discarded | ||
| /// to avoid excessive memory use and slow downloads in the WASM runtime. | ||
| const MAX_DOWNLOAD_SIZE_BYTES: u64 = 20 * 1024 * 1024; | ||
|
|
||
| fn download_telegram_file(file_id: &str) -> Result<Vec<u8>, String> { | ||
| // Reject file_id containing curly braces to prevent credential placeholder injection | ||
| if file_id.contains('{') || file_id.contains('}') { | ||
|
|
@@ -965,6 +965,16 @@ fn download_telegram_file(file_id: &str) -> Result<Vec<u8>, String> { | |
| )); | ||
| } | ||
|
|
||
| // Post-download size guard: Telegram metadata file_size is optional, | ||
| // so enforce the limit on actual downloaded bytes. | ||
| if response.body.len() as u64 > MAX_DOWNLOAD_SIZE_BYTES { | ||
| return Err(format!( | ||
| "Downloaded file exceeds {} MB limit ({} bytes)", | ||
| MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024), | ||
| response.body.len() | ||
| )); | ||
| } | ||
|
|
||
| Ok(response.body) | ||
| } | ||
|
|
||
|
|
@@ -1535,6 +1545,39 @@ fn download_and_store_voice(attachments: &[InboundAttachment]) { | |
| } | ||
| } | ||
|
|
||
| /// Download image file bytes and store them via the host for the vision pipeline. | ||
| /// | ||
| /// Separated from `extract_attachments` so that function stays pure (no host | ||
| /// calls) and remains testable in native unit tests. | ||
| fn download_and_store_images(attachments: &[InboundAttachment]) { | ||
| for att in attachments { | ||
| if !att.mime_type.starts_with("image/") { | ||
| continue; | ||
| } | ||
|
|
||
| match download_telegram_file(&att.id) { | ||
| Ok(bytes) => { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Info, | ||
| &format!("Downloaded image file: {} bytes", bytes.len()), | ||
| ); | ||
| if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Error, | ||
| &format!("Failed to store image data: {}", e), | ||
| ); | ||
| } | ||
|
Comment on lines
+1552
to
+1569
|
||
| } | ||
| Err(e) => { | ||
| channel_host::log( | ||
| channel_host::LogLevel::Error, | ||
| &format!("Failed to download image file: {}", e), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+1552
to
+1579
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is very similar to To improve maintainability and performance, consider consolidating the download logic into a single function or a single loop in References
Comment on lines
+1548
to
+1579
|
||
|
|
||
| /// Returns true if the attachment should be downloaded for document text extraction. | ||
| /// | ||
| /// Excludes voice (handled by transcription), image (vision pipeline), | ||
|
|
@@ -1608,6 +1651,9 @@ fn handle_message(message: TelegramMessage) { | |
| // Download and store voice attachments for host-side transcription | ||
| download_and_store_voice(&attachments); | ||
|
|
||
| // Download and store image attachments for host-side vision pipeline | ||
| download_and_store_images(&attachments); | ||
|
|
||
| // Download and store document attachments for host-side text extraction | ||
| download_and_store_documents(&mut attachments); | ||
|
|
||
|
|
@@ -1681,7 +1727,7 @@ fn handle_message(message: TelegramMessage) { | |
| let username_opt = from.username.as_deref(); | ||
| let is_allowed = allowed.contains(&"*".to_string()) | ||
| || allowed.contains(&id_str) | ||
| || username_opt.map_or(false, |u| allowed.contains(&u.to_string())); | ||
| || username_opt.is_some_and(|u| allowed.contains(&u.to_string())); | ||
|
|
||
| if !is_allowed { | ||
| if is_private && dm_policy == "pairing" { | ||
|
|
@@ -2605,4 +2651,10 @@ mod tests { | |
| assert!(!is_downloadable_document(&make("audio/mpeg", Some("song.mp3")))); | ||
| assert!(!is_downloadable_document(&make("video/mp4", Some("clip.mp4")))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_max_download_size_constant() { | ||
| // Verify the constant is 20 MB, matching the Slack channel limit | ||
| assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
download_and_store_slack_filesonly skips downloads whenatt.size_bytesis present and over the limit. If Slack omits size (or reports incorrectly), this may download arbitrarily large files into WASM memory. Add a post-download check onbytes.len()(and skip/log if it exceeds the limit) to ensure the cap is enforced even when metadata is missing.