twilight_http/request/channel/webhook/
execute_webhook.rs

1use crate::{
2    client::Client,
3    error::Error,
4    request::{
5        attachment::{AttachmentManager, PartialAttachment},
6        channel::webhook::ExecuteWebhookAndWait,
7        Nullable, Request, TryIntoRequest,
8    },
9    response::{marker::EmptyBody, Response, ResponseFuture},
10    routing::Route,
11};
12use serde::Serialize;
13use std::future::IntoFuture;
14use twilight_model::{
15    channel::message::{AllowedMentions, Component, Embed, MessageFlags},
16    http::attachment::Attachment,
17    id::{
18        marker::{ChannelMarker, WebhookMarker},
19        Id,
20    },
21};
22use twilight_validate::{
23    message::{
24        attachment as validate_attachment, components as validate_components,
25        content as validate_content, embeds as validate_embeds, MessageValidationError,
26        MessageValidationErrorType,
27    },
28    request::webhook_username as validate_webhook_username,
29};
30
31#[derive(Serialize)]
32pub(crate) struct ExecuteWebhookFields<'a> {
33    #[serde(skip_serializing_if = "Option::is_none")]
34    allowed_mentions: Option<Nullable<&'a AllowedMentions>>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    attachments: Option<Vec<PartialAttachment<'a>>>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    avatar_url: Option<&'a str>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    components: Option<&'a [Component]>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    content: Option<&'a str>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    embeds: Option<&'a [Embed]>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    flags: Option<MessageFlags>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    payload_json: Option<&'a [u8]>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    thread_name: Option<&'a str>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    tts: Option<bool>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    username: Option<&'a str>,
55}
56
57/// Execute a webhook, sending a message to its channel.
58///
59/// The message must include at least one of [`attachments`], [`components`],
60/// [`content`], or [`embeds`].
61///
62/// # Examples
63///
64/// ```no_run
65/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
66/// use twilight_http::Client;
67/// use twilight_model::id::Id;
68///
69/// let client = Client::new("my token".to_owned());
70/// let id = Id::new(432);
71///
72/// client
73///     .execute_webhook(id, "webhook token")
74///     .content("Pinkie...")
75///     .await?;
76/// # Ok(()) }
77/// ```
78///
79/// [`attachments`]: Self::attachments
80/// [`components`]: Self::components
81/// [`content`]: Self::content
82/// [`embeds`]: Self::embeds
83#[must_use = "requests must be configured and executed"]
84pub struct ExecuteWebhook<'a> {
85    attachment_manager: AttachmentManager<'a>,
86    fields: Result<ExecuteWebhookFields<'a>, MessageValidationError>,
87    http: &'a Client,
88    thread_id: Option<Id<ChannelMarker>>,
89    token: &'a str,
90    wait: bool,
91    webhook_id: Id<WebhookMarker>,
92}
93
94impl<'a> ExecuteWebhook<'a> {
95    pub(crate) const fn new(
96        http: &'a Client,
97        webhook_id: Id<WebhookMarker>,
98        token: &'a str,
99    ) -> Self {
100        Self {
101            attachment_manager: AttachmentManager::new(),
102            fields: Ok(ExecuteWebhookFields {
103                attachments: None,
104                avatar_url: None,
105                components: None,
106                content: None,
107                embeds: None,
108                flags: None,
109                payload_json: None,
110                thread_name: None,
111                tts: None,
112                username: None,
113                allowed_mentions: None,
114            }),
115            http,
116            thread_id: None,
117            token,
118            wait: false,
119            webhook_id,
120        }
121    }
122
123    /// Specify the [`AllowedMentions`] for the message.
124    ///
125    /// Unless otherwise called, the request will use the client's default
126    /// allowed mentions. Set to `None` to ignore this default.
127    pub fn allowed_mentions(mut self, allowed_mentions: Option<&'a AllowedMentions>) -> Self {
128        if let Ok(fields) = self.fields.as_mut() {
129            fields.allowed_mentions = Some(Nullable(allowed_mentions));
130        }
131
132        self
133    }
134
135    /// Attach multiple files to the message.
136    ///
137    /// Calling this method will clear any previous calls.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error of type [`AttachmentDescriptionTooLarge`] if
142    /// the attachments's description is too large.
143    ///
144    /// Returns an error of type [`AttachmentFilename`] if any filename is
145    /// invalid.
146    ///
147    /// [`AttachmentDescriptionTooLarge`]: twilight_validate::message::MessageValidationErrorType::AttachmentDescriptionTooLarge
148    /// [`AttachmentFilename`]: twilight_validate::message::MessageValidationErrorType::AttachmentFilename
149    pub fn attachments(mut self, attachments: &'a [Attachment]) -> Self {
150        if self.fields.is_ok() {
151            if let Err(source) = attachments.iter().try_for_each(validate_attachment) {
152                self.fields = Err(source);
153            } else {
154                self.attachment_manager = self
155                    .attachment_manager
156                    .set_files(attachments.iter().collect());
157            }
158        }
159
160        self
161    }
162
163    /// The URL of the avatar of the webhook.
164    pub fn avatar_url(mut self, avatar_url: &'a str) -> Self {
165        if let Ok(fields) = self.fields.as_mut() {
166            fields.avatar_url = Some(avatar_url);
167        }
168
169        self
170    }
171
172    /// Set the message's list of [`Component`]s.
173    ///
174    /// Calling this method will clear previous calls.
175    ///
176    /// Requires a webhook owned by the application.
177    ///
178    /// # Errors
179    ///
180    /// Refer to the errors section of
181    /// [`twilight_validate::component::component`] for a list of errors that
182    /// may be returned as a result of validating each provided component.
183    pub fn components(mut self, components: &'a [Component]) -> Self {
184        self.fields = self.fields.and_then(|mut fields| {
185            validate_components(
186                components,
187                fields
188                    .flags
189                    .is_some_and(|flags| flags.contains(MessageFlags::IS_COMPONENTS_V2)),
190            )?;
191            fields.components = Some(components);
192
193            Ok(fields)
194        });
195
196        self
197    }
198
199    /// Set the message's content.
200    ///
201    /// The maximum length is 2000 UTF-16 characters.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error of type [`ContentInvalid`] if the content length is too
206    /// long.
207    ///
208    /// [`ContentInvalid`]: twilight_validate::message::MessageValidationErrorType::ContentInvalid
209    pub fn content(mut self, content: &'a str) -> Self {
210        self.fields = self.fields.and_then(|mut fields| {
211            validate_content(content)?;
212            fields.content = Some(content);
213
214            Ok(fields)
215        });
216
217        self
218    }
219
220    /// Set the message's list of embeds.
221    ///
222    /// Calling this method will clear previous calls.
223    ///
224    /// The amount of embeds must not exceed [`EMBED_COUNT_LIMIT`]. The total
225    /// character length of each embed must not exceed [`EMBED_TOTAL_LENGTH`]
226    /// characters. Additionally, the internal fields also have character
227    /// limits. Refer to [Discord Docs/Embed Limits] for more information.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error of type [`TooManyEmbeds`] if there are too many embeds.
232    ///
233    /// Otherwise, refer to the errors section of
234    /// [`twilight_validate::embed::embed`] for a list of errors that may occur.
235    ///
236    /// [Discord Docs/Embed Limits]: https://discord.com/developers/docs/resources/channel#embed-limits
237    /// [`EMBED_COUNT_LIMIT`]: twilight_validate::message::EMBED_COUNT_LIMIT
238    /// [`EMBED_TOTAL_LENGTH`]: twilight_validate::embed::EMBED_TOTAL_LENGTH
239    /// [`TooManyEmbeds`]: twilight_validate::message::MessageValidationErrorType::TooManyEmbeds
240    pub fn embeds(mut self, embeds: &'a [Embed]) -> Self {
241        self.fields = self.fields.and_then(|mut fields| {
242            validate_embeds(embeds)?;
243            fields.embeds = Some(embeds);
244
245            Ok(fields)
246        });
247
248        self
249    }
250
251    /// Set the message's flags.
252    ///
253    /// The only supported flags are [`SUPPRESS_EMBEDS`], [`SUPPRESS_NOTIFICATIONS`] and
254    /// [`IS_COMPONENTS_V2`]
255    ///
256    /// [`SUPPRESS_EMBEDS`]: MessageFlags::SUPPRESS_EMBEDS
257    /// [`SUPPRESS_NOTIFICATIONS`]: MessageFlags::SUPPRESS_NOTIFICATIONS
258    /// [`IS_COMPONENTS_V2`]: MessageFlags::IS_COMPONENTS_V2
259    pub fn flags(mut self, flags: MessageFlags) -> Self {
260        if let Ok(fields) = self.fields.as_mut() {
261            fields.flags = Some(flags);
262        }
263
264        self
265    }
266
267    /// JSON encoded body of any additional request fields.
268    ///
269    /// If this method is called, all other fields are ignored, except for
270    /// [`attachments`]. See [Discord Docs/Uploading Files].
271    ///
272    /// Without [`payload_json`]:
273    ///
274    /// ```no_run
275    /// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
276    /// use twilight_http::Client;
277    /// use twilight_model::id::Id;
278    /// use twilight_util::builder::embed::EmbedBuilder;
279    ///
280    /// let client = Client::new("token".to_owned());
281    ///
282    /// let message = client
283    ///     .execute_webhook(Id::new(1), "token here")
284    ///     .content("some content")
285    ///     .embeds(&[EmbedBuilder::new().title("title").validate()?.build()])
286    ///     .wait()
287    ///     .await?
288    ///     .model()
289    ///     .await?;
290    ///
291    /// assert_eq!(message.content, "some content");
292    /// # Ok(()) }
293    /// ```
294    ///
295    /// With [`payload_json`]:
296    ///
297    /// ```no_run
298    /// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
299    /// use twilight_http::Client;
300    /// use twilight_model::id::Id;
301    /// use twilight_util::builder::embed::EmbedBuilder;
302    ///
303    /// let client = Client::new("token".to_owned());
304    ///
305    /// let message = client
306    ///     .execute_webhook(Id::new(1), "token here")
307    ///     .content("some content")
308    ///     .payload_json(br#"{ "content": "other content", "embeds": [ { "title": "title" } ] }"#)
309    ///     .wait()
310    ///     .await?
311    ///     .model()
312    ///     .await?;
313    ///
314    /// assert_eq!(message.content, "other content");
315    /// # Ok(()) }
316    /// ```
317    ///
318    /// [Discord Docs/Uploading Files]: https://discord.com/developers/docs/reference#uploading-files
319    /// [`attachments`]: Self::attachments
320    /// [`payload_json`]: Self::payload_json
321    pub fn payload_json(mut self, payload_json: &'a [u8]) -> Self {
322        if let Ok(fields) = self.fields.as_mut() {
323            fields.payload_json = Some(payload_json);
324        }
325
326        self
327    }
328
329    /// Execute in a thread belonging to the channel instead of the channel itself.
330    pub fn thread_id(mut self, thread_id: Id<ChannelMarker>) -> Self {
331        self.thread_id.replace(thread_id);
332
333        self
334    }
335
336    /// Set the name of the created thread when used in a forum channel.
337    pub fn thread_name(mut self, thread_name: &'a str) -> Self {
338        self.fields = self.fields.map(|mut fields| {
339            fields.thread_name = Some(thread_name);
340
341            fields
342        });
343
344        self
345    }
346
347    /// Specify true if the message is TTS.
348    pub fn tts(mut self, tts: bool) -> Self {
349        if let Ok(fields) = self.fields.as_mut() {
350            fields.tts = Some(tts);
351        }
352
353        self
354    }
355
356    /// Specify the username of the webhook's message.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error of type [`WebhookUsername`] if the webhook's name is
361    /// invalid.
362    ///
363    /// [`WebhookUsername`]: twilight_validate::request::ValidationErrorType::WebhookUsername
364    pub fn username(mut self, username: &'a str) -> Self {
365        self.fields = self.fields.and_then(|mut fields| {
366            validate_webhook_username(username).map_err(|source| {
367                MessageValidationError::from_validation_error(
368                    MessageValidationErrorType::WebhookUsername,
369                    source,
370                )
371            })?;
372            fields.username = Some(username);
373
374            Ok(fields)
375        });
376
377        self
378    }
379
380    /// Wait for the message to send before sending a response. See
381    /// [Discord Docs/Execute Webhook].
382    ///
383    /// Using this will result in receiving the created message.
384    ///
385    /// [Discord Docs/Execute Webhook]: https://discord.com/developers/docs/resources/webhook#execute-webhook-querystring-params
386    pub const fn wait(mut self) -> ExecuteWebhookAndWait<'a> {
387        self.wait = true;
388
389        ExecuteWebhookAndWait::new(self.http, self)
390    }
391}
392
393impl IntoFuture for ExecuteWebhook<'_> {
394    type Output = Result<Response<EmptyBody>, Error>;
395
396    type IntoFuture = ResponseFuture<EmptyBody>;
397
398    fn into_future(self) -> Self::IntoFuture {
399        let http = self.http;
400
401        match self.try_into_request() {
402            Ok(request) => http.request(request),
403            Err(source) => ResponseFuture::error(source),
404        }
405    }
406}
407
408impl TryIntoRequest for ExecuteWebhook<'_> {
409    fn try_into_request(self) -> Result<Request, Error> {
410        let mut fields = self.fields.map_err(Error::validation)?;
411        let mut request = Request::builder(&Route::ExecuteWebhook {
412            thread_id: self.thread_id.map(Id::get),
413            token: self.token,
414            wait: Some(self.wait),
415            with_components: Some(
416                fields
417                    .components
418                    .is_some_and(|components| !components.is_empty()),
419            ),
420            webhook_id: self.webhook_id.get(),
421        });
422
423        // Webhook executions don't need the authorization token, only the
424        // webhook token.
425        request = request.use_authorization_token(false);
426
427        // Set the default allowed mentions if required.
428        if fields.allowed_mentions.is_none() {
429            if let Some(allowed_mentions) = self.http.default_allowed_mentions() {
430                fields.allowed_mentions = Some(Nullable(Some(allowed_mentions)));
431            }
432        }
433
434        // Determine whether we need to use a multipart/form-data body or a JSON
435        // body.
436        if !self.attachment_manager.is_empty() {
437            let form = if let Some(payload_json) = fields.payload_json {
438                self.attachment_manager.build_form(payload_json)
439            } else {
440                fields.attachments = Some(self.attachment_manager.get_partial_attachments());
441
442                let fields = crate::json::to_vec(&fields).map_err(Error::json)?;
443
444                self.attachment_manager.build_form(fields.as_ref())
445            };
446
447            request = request.form(form);
448        } else if let Some(payload_json) = fields.payload_json {
449            request = request.body(payload_json.to_vec());
450        } else {
451            request = request.json(&fields);
452        }
453
454        request.build()
455    }
456}