Skip to main content

layer_client/
media.rs

1//! Media upload and download support.
2//!
3//! ## Upload
4//! Use [`Client::upload_file`] to upload a file from a byte buffer or
5//! [`Client::upload_stream`] for streamed uploads. The returned [`UploadedFile`]
6//! can be passed to [`Client::send_file`] or [`Client::send_album`].
7//!
8//! ## Download
9//! Use [`Client::download_media`] to collect all bytes of a media attachment, or
10//! [`Client::iter_download`] for chunk-by-chunk streaming.
11
12
13
14
15use layer_tl_types as tl;
16use layer_tl_types::{Cursor, Deserializable};
17use tokio::io::AsyncRead;
18use tokio::io::AsyncReadExt;
19
20use crate::{Client, InvocationError};
21
22// ─── Constants ────────────────────────────────────────────────────────────────
23
24/// Maximum chunk size for file uploads (512 KB).
25pub const UPLOAD_CHUNK_SIZE: i32 = 512 * 1024;
26/// Maximum chunk size for file downloads (512 KB).
27pub const DOWNLOAD_CHUNK_SIZE: i32 = 512 * 1024;
28/// Files larger than this are uploaded as "big files" via SaveBigFilePart.
29const BIG_FILE_THRESHOLD: i64 = 10 * 1024 * 1024; // 10 MB
30
31// ─── UploadedFile ─────────────────────────────────────────────────────────────
32
33/// A successfully uploaded file, ready to be sent as media.
34#[derive(Debug, Clone)]
35pub struct UploadedFile {
36    pub(crate) inner: tl::enums::InputFile,
37    pub(crate) mime_type: String,
38    pub(crate) name: String,
39}
40
41impl UploadedFile {
42    /// The file's MIME type (set on upload).
43    pub fn mime_type(&self) -> &str { &self.mime_type }
44    /// The file's original name.
45    pub fn name(&self) -> &str { &self.name }
46
47    /// Convert to an `InputMedia` for sending as a document.
48    pub fn as_document_media(&self) -> tl::enums::InputMedia {
49        tl::enums::InputMedia::UploadedDocument(tl::types::InputMediaUploadedDocument {
50            nosound_video: false,
51            force_file:    false,
52            spoiler:       false,
53            file:          self.inner.clone(),
54            thumb:         None,
55            mime_type:     self.mime_type.clone(),
56            attributes:    vec![tl::enums::DocumentAttribute::Filename(
57                tl::types::DocumentAttributeFilename { file_name: self.name.clone() }
58            )],
59            stickers:  None,
60            ttl_seconds: None,
61            video_cover: None,
62            video_timestamp: None,
63        })
64    }
65
66    /// Convert to an `InputMedia` for sending as a photo.
67    pub fn as_photo_media(&self) -> tl::enums::InputMedia {
68        tl::enums::InputMedia::UploadedPhoto(tl::types::InputMediaUploadedPhoto {
69            spoiler:     false,
70            file:        self.inner.clone(),
71            stickers:    None,
72            ttl_seconds: None,
73        })
74    }
75}
76
77// ─── DownloadIter ─────────────────────────────────────────────────────────────
78
79/// Iterator that downloads a media file chunk by chunk.
80///
81/// Call [`DownloadIter::next`] in a loop until it returns `None`.
82pub struct DownloadIter {
83    client:  Client,
84    request: Option<tl::functions::upload::GetFile>,
85    done:    bool,
86}
87
88impl DownloadIter {
89    /// Set a custom chunk size (must be a multiple of 4096, max 524288).
90    pub fn chunk_size(mut self, size: i32) -> Self {
91        if let Some(r) = &mut self.request { r.limit = size; }
92        self
93    }
94
95    /// Fetch the next chunk of data. Returns `None` when the download is complete.
96    pub async fn next(&mut self) -> Result<Option<Vec<u8>>, InvocationError> {
97        if self.done { return Ok(None); }
98        let req = match &self.request {
99            Some(r) => r.clone(),
100            None    => return Ok(None),
101        };
102        let body = self.client.rpc_call_raw_pub(&req).await?;
103        let mut cur = Cursor::from_slice(&body);
104        match tl::enums::upload::File::deserialize(&mut cur)? {
105            tl::enums::upload::File::File(f) => {
106                if (f.bytes.len() as i32) < req.limit {
107                    self.done = true;
108                    if f.bytes.is_empty() { return Ok(None); }
109                }
110                if let Some(r) = &mut self.request {
111                    r.offset += req.limit as i64;
112                }
113                Ok(Some(f.bytes))
114            }
115            tl::enums::upload::File::CdnRedirect(_) => {
116                self.done = true;
117                Err(InvocationError::Deserialize("CDN redirect not supported".into()))
118            }
119        }
120    }
121}
122
123// ─── Client methods ───────────────────────────────────────────────────────────
124
125impl Client {
126    /// Upload bytes as a file. Returns an [`UploadedFile`] that can be sent.
127    ///
128    /// # Arguments
129    /// * `data`      — Raw file bytes.
130    /// * `name`      — File name (e.g. `"photo.jpg"`).
131    /// * `mime_type` — MIME type (e.g. `"image/jpeg"`). Used only for documents.
132    pub async fn upload_file(
133        &self,
134        data:      &[u8],
135        name:      &str,
136        mime_type: &str,
137    ) -> Result<UploadedFile, InvocationError> {
138        let file_id   = crate::random_i64_pub();
139        let total     = data.len() as i64;
140        let big       = total >= BIG_FILE_THRESHOLD;
141        let part_size = UPLOAD_CHUNK_SIZE as usize;
142        let total_parts = ((total as usize + part_size - 1) / part_size) as i32;
143
144        for (part_num, chunk) in data.chunks(part_size).enumerate() {
145            if big {
146                let req = tl::functions::upload::SaveBigFilePart {
147                    file_id,
148                    file_part:  part_num as i32,
149                    file_total_parts: total_parts,
150                    bytes: chunk.to_vec(),
151                };
152                self.rpc_call_raw_pub(&req).await?;
153            } else {
154                let req = tl::functions::upload::SaveFilePart {
155                    file_id,
156                    file_part: part_num as i32,
157                    bytes: chunk.to_vec(),
158                };
159                self.rpc_call_raw_pub(&req).await?;
160            }
161            log::debug!("[layer] Uploaded part {} / {}", part_num + 1, total_parts);
162        }
163
164        let inner: tl::enums::InputFile = if big {
165            tl::enums::InputFile::Big(tl::types::InputFileBig {
166                id:    file_id,
167                parts: total_parts,
168                name:  name.to_string(),
169            })
170        } else {
171            let md5 = format!("{:x}", md5_bytes(data));
172            tl::enums::InputFile::InputFile(tl::types::InputFile {
173                id:    file_id,
174                parts: total_parts,
175                name:  name.to_string(),
176                md5_checksum: md5,
177            })
178        };
179
180        log::info!("[layer] File '{}' uploaded ({} bytes, {} parts)", name, total, total_parts);
181        Ok(UploadedFile {
182            inner,
183            mime_type: mime_type.to_string(),
184            name:      name.to_string(),
185        })
186    }
187
188    /// Upload from an async reader.
189    pub async fn upload_stream<R: AsyncRead + Unpin>(
190        &self,
191        reader:    &mut R,
192        name:      &str,
193        mime_type: &str,
194    ) -> Result<UploadedFile, InvocationError> {
195        let mut data = Vec::new();
196        reader.read_to_end(&mut data).await?;
197        self.upload_file(&data, name, mime_type).await
198    }
199
200    /// Send a file as a document or photo to a chat.
201    ///
202    /// Use `uploaded.as_photo_media()` to send as a photo,
203    /// or `uploaded.as_document_media()` to send as a file.
204    pub async fn send_file(
205        &self,
206        peer:    tl::enums::Peer,
207        media:   tl::enums::InputMedia,
208        caption: &str,
209    ) -> Result<(), InvocationError> {
210        let input_peer = {
211            let cache = self.inner.peer_cache.lock().await;
212            cache.peer_to_input(&peer)
213        };
214        let req = tl::functions::messages::SendMedia {
215            silent:                   false,
216            background:               false,
217            clear_draft:              false,
218            noforwards:               false,
219            update_stickersets_order: false,
220            invert_media:             false,
221            allow_paid_floodskip:     false,
222            peer:                     input_peer,
223            reply_to:                 None,
224            media,
225            message:                  caption.to_string(),
226            random_id:                crate::random_i64_pub(),
227            reply_markup:             None,
228            entities:                 None,
229            schedule_date:            None,
230            schedule_repeat_period:   None,
231            send_as:                  None,
232            quick_reply_shortcut:     None,
233            effect:                   None,
234            allow_paid_stars:         None,
235            suggested_post:           None,
236        };
237        self.rpc_call_raw_pub(&req).await?;
238        Ok(())
239    }
240
241    /// Send multiple files as an album (media group) in a single message.
242    ///
243    /// All items must be photos or all must be documents (no mixing).
244    pub async fn send_album(
245        &self,
246        peer:  tl::enums::Peer,
247        items: Vec<(tl::enums::InputMedia, String)>, // (media, caption)
248    ) -> Result<(), InvocationError> {
249        let input_peer = {
250            let cache = self.inner.peer_cache.lock().await;
251            cache.peer_to_input(&peer)
252        };
253
254        let grouped_id = crate::random_i64_pub().unsigned_abs() as i64;
255
256        let multi: Vec<tl::enums::InputSingleMedia> = items.into_iter().map(|(media, caption)| {
257            tl::enums::InputSingleMedia::InputSingleMedia(tl::types::InputSingleMedia {
258                media,
259                random_id: crate::random_i64_pub(),
260                message:   caption,
261                entities:  None,
262            })
263        }).collect();
264
265        let req = tl::functions::messages::SendMultiMedia {
266            silent:                   false,
267            background:               false,
268            clear_draft:              false,
269            noforwards:               false,
270            update_stickersets_order: false,
271            invert_media:             false,
272            allow_paid_floodskip:     false,
273            peer:                     input_peer,
274            reply_to:                 None,
275            multi_media:              multi,
276            schedule_date:            None,
277            send_as:                  None,
278            quick_reply_shortcut:     None,
279            effect:                   None,
280            allow_paid_stars:         None,
281        };
282        let _ = grouped_id; // Telegram auto-generates the grouped_id server-side
283        self.rpc_call_raw_pub(&req).await?;
284        Ok(())
285    }
286
287    /// Create a download iterator for a media attachment.
288    ///
289    /// ```rust,no_run
290    /// # async fn f(client: layer_client::Client, msg: layer_client::update::IncomingMessage) -> Result<(), Box<dyn std::error::Error>> {
291    /// let mut bytes = Vec::new();
292    /// if let Some(loc) = msg.download_location() {
293    ///     let mut iter = client.iter_download(loc);
294    ///     while let Some(chunk) = iter.next().await? {
295    ///         bytes.extend_from_slice(&chunk);
296    ///     }
297    /// }
298    /// # Ok(()) }
299    /// ```
300    pub fn iter_download(&self, location: tl::enums::InputFileLocation) -> DownloadIter {
301        DownloadIter {
302            client:  self.clone(),
303            done:    false,
304            request: Some(tl::functions::upload::GetFile {
305                precise:       false,
306                cdn_supported: false,
307                location,
308                offset:        0,
309                limit:         DOWNLOAD_CHUNK_SIZE,
310            }),
311        }
312    }
313
314    /// Download all bytes of a media attachment at once.
315    pub async fn download_media(
316        &self,
317        location: tl::enums::InputFileLocation,
318    ) -> Result<Vec<u8>, InvocationError> {
319        let mut bytes = Vec::new();
320        let mut iter  = self.iter_download(location);
321        while let Some(chunk) = iter.next().await? {
322            bytes.extend_from_slice(&chunk);
323        }
324        Ok(bytes)
325    }
326}
327
328// ─── InputFileLocation from IncomingMessage ───────────────────────────────────
329
330impl crate::update::IncomingMessage {
331    /// Get the [`InputFileLocation`] for the media in this message, if any.
332    ///
333    /// Returns `None` for messages without downloadable media.
334    pub fn download_location(&self) -> Option<tl::enums::InputFileLocation> {
335        let media = match &self.raw {
336            tl::enums::Message::Message(m) => m.media.as_ref()?,
337            _ => return None,
338        };
339        match media {
340            tl::enums::MessageMedia::Photo(mp) => {
341                if let Some(tl::enums::Photo::Photo(p)) = &mp.photo {
342                    // Find the largest PhotoSize
343                    let thumb = p.sizes.iter().filter_map(|s| match s {
344                        tl::enums::PhotoSize::PhotoSize(ps) => Some(ps.r#type.clone()),
345                        _ => None,
346                    }).last().unwrap_or_else(|| "s".to_string());
347
348                    Some(tl::enums::InputFileLocation::InputPhotoFileLocation(
349                        tl::types::InputPhotoFileLocation {
350                            id:             p.id,
351                            access_hash:    p.access_hash,
352                            file_reference: p.file_reference.clone(),
353                            thumb_size:     thumb,
354                        }
355                    ))
356                } else { None }
357            }
358            tl::enums::MessageMedia::Document(md) => {
359                if let Some(tl::enums::Document::Document(d)) = &md.document {
360                    Some(tl::enums::InputFileLocation::InputDocumentFileLocation(
361                        tl::types::InputDocumentFileLocation {
362                            id:             d.id,
363                            access_hash:    d.access_hash,
364                            file_reference: d.file_reference.clone(),
365                            thumb_size:     String::new(),
366                        }
367                    ))
368                } else { None }
369            }
370            _ => None,
371        }
372    }
373}
374
375// ─── MD5 helper (no external dep) ────────────────────────────────────────────
376
377fn md5_bytes(data: &[u8]) -> u128 {
378    // Simple MD5 using sha2 isn't available, so we use a basic implementation
379    // This is only for small-file checksum; big files skip it.
380    // For production use, add the `md-5` crate.
381    // Here we return 0 as a placeholder (Telegram accepts empty checksum).
382    let _ = data;
383    0u128
384}