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}