Skip to main content

serenity/builder/
create_attachment.rs

1use std::path::Path;
2
3use tokio::fs::File;
4use tokio::io::AsyncReadExt;
5#[cfg(feature = "http")]
6use url::Url;
7
8use crate::all::Message;
9#[cfg(feature = "http")]
10use crate::error::Error;
11use crate::error::Result;
12#[cfg(feature = "http")]
13use crate::http::Http;
14use crate::model::id::AttachmentId;
15
16/// A builder for creating a new attachment from a file path, file data, or URL.
17///
18/// [Discord docs](https://discord.com/developers/docs/resources/channel#attachment-object-attachment-structure).
19#[derive(Clone, Debug, Serialize, PartialEq)]
20#[non_exhaustive]
21#[must_use]
22pub struct CreateAttachment {
23    pub(crate) id: u64, // Placeholder ID will be filled in when sending the request
24    pub filename: String,
25    pub description: Option<String>,
26
27    #[serde(skip)]
28    pub data: Vec<u8>,
29}
30
31impl CreateAttachment {
32    /// Builds an [`CreateAttachment`] from the raw attachment data.
33    pub fn bytes(data: impl Into<Vec<u8>>, filename: impl Into<String>) -> CreateAttachment {
34        CreateAttachment {
35            data: data.into(),
36            filename: filename.into(),
37            description: None,
38            id: 0,
39        }
40    }
41
42    /// Builds an [`CreateAttachment`] by reading a local file.
43    ///
44    /// # Errors
45    ///
46    /// [`Error::Io`] if reading the file fails.
47    pub async fn path(path: impl AsRef<Path>) -> Result<CreateAttachment> {
48        let mut file = File::open(path.as_ref()).await?;
49        let mut data = Vec::new();
50        file.read_to_end(&mut data).await?;
51
52        let filename = path
53            .as_ref()
54            .file_name()
55            .ok_or_else(|| std::io::Error::other("attachment path must not be a directory"))?;
56
57        Ok(CreateAttachment::bytes(data, filename.to_string_lossy().to_string()))
58    }
59
60    /// Builds an [`CreateAttachment`] by reading from a file handler.
61    ///
62    /// # Errors
63    ///
64    /// [`Error::Io`] error if reading the file fails.
65    pub async fn file(file: &File, filename: impl Into<String>) -> Result<CreateAttachment> {
66        let mut data = Vec::new();
67        file.try_clone().await?.read_to_end(&mut data).await?;
68
69        Ok(CreateAttachment::bytes(data, filename))
70    }
71
72    /// Builds an [`CreateAttachment`] by downloading attachment data from a URL.
73    ///
74    /// # Errors
75    ///
76    /// [`Error::Url`] if the URL is invalid, [`Error::Http`] if downloading the data fails.
77    #[cfg(feature = "http")]
78    pub async fn url(http: impl AsRef<Http>, url: &str) -> Result<CreateAttachment> {
79        let url = Url::parse(url).map_err(|_| Error::Url(url.to_string()))?;
80
81        let response = http.as_ref().client.get(url.clone()).send().await?;
82        let data = response.bytes().await?.to_vec();
83
84        let filename = url
85            .path_segments()
86            .and_then(Iterator::last)
87            .ok_or_else(|| Error::Url(url.to_string()))?;
88
89        Ok(CreateAttachment::bytes(data, filename))
90    }
91
92    /// Converts the stored data to the base64 representation.
93    ///
94    /// This is used in the library internally because Discord expects image data as base64 in many
95    /// places.
96    #[must_use]
97    pub fn to_base64(&self) -> String {
98        use base64::engine::{Config, Engine};
99
100        const PREFIX: &str = "data:image/png;base64,";
101
102        let engine = base64::prelude::BASE64_STANDARD;
103        let encoded_size = base64::encoded_len(self.data.len(), engine.config().encode_padding())
104            .and_then(|len| len.checked_add(PREFIX.len()))
105            .expect("buffer capacity overflow");
106
107        let mut encoded = String::with_capacity(encoded_size);
108        encoded.push_str(PREFIX);
109        engine.encode_string(&self.data, &mut encoded);
110        encoded
111    }
112
113    /// Sets a description for the file (max 1024 characters).
114    pub fn description(mut self, description: impl Into<String>) -> Self {
115        self.description = Some(description.into());
116        self
117    }
118}
119
120#[derive(Debug, Clone, serde::Serialize, PartialEq)]
121struct ExistingAttachment {
122    id: AttachmentId,
123}
124
125#[derive(Debug, Clone, serde::Serialize, PartialEq)]
126#[serde(untagged)]
127enum NewOrExisting {
128    New(CreateAttachment),
129    Existing(ExistingAttachment),
130}
131
132/// You can add new attachments and edit existing ones using this builder.
133///
134/// When this builder is _not_ supplied in a message edit, Discord keeps the attachments intact.
135/// However, as soon as a builder is supplied, Discord removes all attachments from the message. If
136/// you want to keep old attachments, you must specify this either using [`Self::keep_all`], or
137/// individually for each attachment using [`Self::keep`].
138///
139/// # Examples
140///
141/// ## Removing all attachments
142///
143/// ```rust,no_run
144/// # use serenity::all::*;
145/// # async fn foo_(ctx: Http, mut msg: Message) -> Result<(), Error> {
146/// msg.edit(ctx, EditMessage::new().attachments(EditAttachments::new())).await?;
147/// # Ok(()) }
148/// ```
149///
150/// ## Adding a new attachment without deleting existing attachments
151///
152/// ```rust,no_run
153/// # use serenity::all::*;
154/// # async fn foo_(ctx: Http, mut msg: Message, my_attachment: CreateAttachment) -> Result<(), Error> {
155/// msg.edit(ctx, EditMessage::new().attachments(
156///     EditAttachments::keep_all(&msg).add(my_attachment)
157/// )).await?;
158/// # Ok(()) }
159/// ```
160///
161/// ## Delete all but the first attachment
162///
163/// ```rust,no_run
164/// # use serenity::all::*;
165/// # async fn foo_(ctx: Http, mut msg: Message, my_attachment: CreateAttachment) -> Result<(), Error> {
166/// msg.edit(ctx, EditMessage::new().attachments(
167///     EditAttachments::new().keep(msg.attachments[0].id)
168/// )).await?;
169/// # Ok(()) }
170/// ```
171///
172/// ## Delete only the first attachment
173///
174/// ```rust,no_run
175/// # use serenity::all::*;
176/// # async fn foo_(ctx: Http, mut msg: Message, my_attachment: CreateAttachment) -> Result<(), Error> {
177/// msg.edit(ctx, EditMessage::new().attachments(
178///     EditAttachments::keep_all(&msg).remove(msg.attachments[0].id)
179/// )).await?;
180/// # Ok(()) }
181/// ```
182///
183/// # Notes
184///
185/// Internally, this type is used not just for message editing endpoints, but also for message
186/// creation endpoints.
187#[derive(Default, Debug, Clone, serde::Serialize, PartialEq)]
188#[serde(transparent)]
189#[must_use]
190pub struct EditAttachments {
191    new_and_existing_attachments: Vec<NewOrExisting>,
192}
193
194impl EditAttachments {
195    /// An empty attachments builder.
196    ///
197    /// Existing attachments are not kept by default, either. See [`Self::keep_all()`] or
198    /// [`Self::keep()`].
199    pub fn new() -> Self {
200        Self::default()
201    }
202
203    /// Creates a new attachments builder that keeps all existing attachments.
204    ///
205    /// Shorthand for [`Self::new()`] and calling [`Self::keep()`] for every [`AttachmentId`] in
206    /// [`Message::attachments`].
207    ///
208    /// If you only want to keep a subset of attachments from the message, either implement this
209    /// method manually, or use [`Self::remove()`].
210    ///
211    /// **Note: this EditAttachments must be run on the same message as is supplied here, or else
212    /// Discord will throw an error!**
213    pub fn keep_all(msg: &Message) -> Self {
214        Self {
215            new_and_existing_attachments: msg
216                .attachments
217                .iter()
218                .map(|a| {
219                    NewOrExisting::Existing(ExistingAttachment {
220                        id: a.id,
221                    })
222                })
223                .collect(),
224        }
225    }
226
227    /// This method adds an existing attachment to the list of attachments that are kept after
228    /// editing.
229    ///
230    /// Opposite of [`Self::remove`].
231    pub fn keep(mut self, id: AttachmentId) -> Self {
232        self.new_and_existing_attachments.push(NewOrExisting::Existing(ExistingAttachment {
233            id,
234        }));
235        self
236    }
237
238    /// This method removes an existing attachment from the list of attachments that are kept after
239    /// editing.
240    ///
241    /// Opposite of [`Self::keep`].
242    pub fn remove(mut self, id: AttachmentId) -> Self {
243        #[allow(clippy::match_like_matches_macro)] // `matches!` is less clear here
244        self.new_and_existing_attachments.retain(|a| match a {
245            NewOrExisting::Existing(a) if a.id == id => false,
246            _ => true,
247        });
248        self
249    }
250
251    /// Adds a new attachment to the attachment list.
252    #[allow(clippy::should_implement_trait)] // Clippy thinks add == std::ops::Add::add
253    pub fn add(mut self, attachment: CreateAttachment) -> Self {
254        self.new_and_existing_attachments.push(NewOrExisting::New(attachment));
255        self
256    }
257
258    /// Clones all new attachments into a new Vec, keeping only data and filename, because those
259    /// are needed for the multipart form data. The data is taken out of `self` in the process, so
260    /// this method can only be called once.
261    pub(crate) fn take_files(&mut self) -> Vec<CreateAttachment> {
262        let mut id_placeholder = 0;
263
264        let mut files = Vec::new();
265        for attachment in &mut self.new_and_existing_attachments {
266            if let NewOrExisting::New(attachment) = attachment {
267                let mut cloned_attachment = CreateAttachment::bytes(
268                    std::mem::take(&mut attachment.data),
269                    attachment.filename.clone(),
270                );
271
272                // Assign placeholder IDs so Discord can match metadata to file contents
273                attachment.id = id_placeholder;
274                cloned_attachment.id = id_placeholder;
275                files.push(cloned_attachment);
276
277                id_placeholder += 1;
278            }
279        }
280        files
281    }
282
283    #[cfg(feature = "cache")]
284    pub(crate) fn is_empty(&self) -> bool {
285        self.new_and_existing_attachments.is_empty()
286    }
287}