twilight_validate/
message.rs

1//! Constants, error types, and functions for validating [`Message`] fields.
2//!
3//! [`Message`]: twilight_model::channel::Message
4
5use crate::{
6    component::{ComponentValidationErrorType, COMPONENT_COUNT},
7    embed::{chars as embed_chars, EmbedValidationErrorType, EMBED_TOTAL_LENGTH},
8    request::ValidationError,
9};
10use std::{
11    error::Error,
12    fmt::{Display, Formatter, Result as FmtResult},
13};
14use twilight_model::{
15    channel::message::{Component, Embed},
16    http::attachment::Attachment,
17    id::{marker::StickerMarker, Id},
18};
19
20/// Maximum length of an attachment's description.
21pub const ATTACHMENT_DESCIPTION_LENGTH_MAX: usize = 1024;
22
23/// Maximum number of embeds that a message may have.
24pub const EMBED_COUNT_LIMIT: usize = 10;
25
26/// Maximum length of message content.
27pub const MESSAGE_CONTENT_LENGTH_MAX: usize = 2000;
28
29/// Maximum amount of stickers.
30pub const STICKER_MAX: usize = 3;
31
32/// ASCII dash.
33const DASH: char = '-';
34
35/// ASCII dot.
36const DOT: char = '.';
37
38/// ASCII underscore.
39const UNDERSCORE: char = '_';
40
41/// A message is not valid.
42#[derive(Debug)]
43pub struct MessageValidationError {
44    /// Type of error that occurred.
45    kind: MessageValidationErrorType,
46    /// Source of the error, if any.
47    source: Option<Box<dyn Error + Send + Sync>>,
48}
49
50impl MessageValidationError {
51    /// Immutable reference to the type of error that occurred.
52    #[must_use = "retrieving the type has no effect if left unused"]
53    pub const fn kind(&self) -> &MessageValidationErrorType {
54        &self.kind
55    }
56
57    /// Consume the error, returning the source error if there is any.
58    #[must_use = "consuming the error and retrieving the source has no effect if left unused"]
59    pub fn into_source(self) -> Option<Box<dyn Error + Send + Sync>> {
60        self.source
61    }
62
63    /// Consume the error, returning the owned error type and the source error.
64    #[must_use = "consuming the error into its parts has no effect if left unused"]
65    pub fn into_parts(
66        self,
67    ) -> (
68        MessageValidationErrorType,
69        Option<Box<dyn Error + Send + Sync>>,
70    ) {
71        (self.kind, self.source)
72    }
73
74    /// Create a [`MessageValidationError`] from a [`ValidationError`].
75    #[must_use = "has no effect if unused"]
76    pub fn from_validation_error(
77        kind: MessageValidationErrorType,
78        source: ValidationError,
79    ) -> Self {
80        Self {
81            kind,
82            source: Some(Box::new(source)),
83        }
84    }
85}
86
87impl Display for MessageValidationError {
88    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
89        match &self.kind {
90            MessageValidationErrorType::AttachmentDescriptionTooLarge { chars } => {
91                f.write_str("the attachment description is ")?;
92                Display::fmt(chars, f)?;
93                f.write_str(" characters long, but the max is ")?;
94
95                Display::fmt(&ATTACHMENT_DESCIPTION_LENGTH_MAX, f)
96            }
97            MessageValidationErrorType::AttachmentFilename { filename } => {
98                f.write_str("attachment filename `")?;
99                Display::fmt(filename, f)?;
100
101                f.write_str("`is invalid")
102            }
103            MessageValidationErrorType::ComponentCount { count } => {
104                Display::fmt(count, f)?;
105                f.write_str(" components were provided, but only ")?;
106                Display::fmt(&COMPONENT_COUNT, f)?;
107
108                f.write_str(" root components are allowed")
109            }
110            MessageValidationErrorType::ComponentInvalid { .. } => {
111                f.write_str("a provided component is invalid")
112            }
113            MessageValidationErrorType::ContentInvalid => f.write_str("message content is invalid"),
114            MessageValidationErrorType::EmbedInvalid { idx, .. } => {
115                f.write_str("embed at index ")?;
116                Display::fmt(idx, f)?;
117
118                f.write_str(" is invalid")
119            }
120            MessageValidationErrorType::StickersInvalid { len } => {
121                f.write_str("amount of stickers provided is ")?;
122                Display::fmt(len, f)?;
123                f.write_str(" but it must be at most ")?;
124
125                Display::fmt(&STICKER_MAX, f)
126            }
127            MessageValidationErrorType::TooManyEmbeds => f.write_str("message has too many embeds"),
128            MessageValidationErrorType::WebhookUsername => {
129                if let Some(source) = self.source() {
130                    Display::fmt(&source, f)
131                } else {
132                    f.write_str("webhook username is invalid")
133                }
134            }
135        }
136    }
137}
138
139impl Error for MessageValidationError {}
140
141/// Type of [`MessageValidationError`] that occurred.
142#[derive(Debug)]
143pub enum MessageValidationErrorType {
144    /// Attachment filename is not valid.
145    AttachmentFilename {
146        /// Invalid filename.
147        filename: String,
148    },
149    /// Attachment description is too large.
150    AttachmentDescriptionTooLarge {
151        /// Provided number of codepoints.
152        chars: usize,
153    },
154    /// Too many message components were provided.
155    ComponentCount {
156        /// Number of components that were provided.
157        count: usize,
158    },
159    /// An invalid message component was provided.
160    ComponentInvalid {
161        /// Index of the component.
162        idx: usize,
163        /// Additional details about the validation failure type.
164        kind: ComponentValidationErrorType,
165    },
166    /// Returned when the content is over 2000 UTF-16 characters.
167    ContentInvalid,
168    /// Returned when the embed is invalid.
169    EmbedInvalid {
170        /// Index of the embed.
171        idx: usize,
172        /// Additional details about the validation failure type.
173        kind: EmbedValidationErrorType,
174    },
175    /// Amount of stickers provided is invalid.
176    StickersInvalid {
177        /// Invalid length.
178        len: usize,
179    },
180    /// Too many embeds were provided.
181    ///
182    /// A followup message can have up to 10 embeds.
183    TooManyEmbeds,
184    /// Provided webhook username was invalid.
185    WebhookUsername,
186}
187
188/// Ensure an attachment is correct.
189///
190/// # Errors
191///
192/// Returns an error of type [`AttachmentDescriptionTooLarge`] if
193/// the attachments's description is too large.
194///
195/// Returns an error of type [`AttachmentFilename`] if the
196/// filename is invalid.
197///
198/// [`AttachmentDescriptionTooLarge`]: MessageValidationErrorType::AttachmentDescriptionTooLarge
199/// [`AttachmentFilename`]: MessageValidationErrorType::AttachmentFilename
200pub fn attachment(attachment: &Attachment) -> Result<(), MessageValidationError> {
201    attachment_filename(&attachment.filename)?;
202
203    if let Some(description) = &attachment.description {
204        attachment_description(description)?;
205    }
206
207    Ok(())
208}
209
210/// Ensure an attachment's description is correct.
211///
212/// # Errors
213///
214/// Returns an error of type [`AttachmentDescriptionTooLarge`] if
215/// the attachment's description is too large.
216///
217/// [`AttachmentDescriptionTooLarge`]: MessageValidationErrorType::AttachmentDescriptionTooLarge
218pub fn attachment_description(description: impl AsRef<str>) -> Result<(), MessageValidationError> {
219    let chars = description.as_ref().chars().count();
220    if chars <= ATTACHMENT_DESCIPTION_LENGTH_MAX {
221        Ok(())
222    } else {
223        Err(MessageValidationError {
224            kind: MessageValidationErrorType::AttachmentDescriptionTooLarge { chars },
225            source: None,
226        })
227    }
228}
229
230/// Ensure an attachment's description is correct.
231///
232/// The filename can contain ASCII alphanumeric characters, dots, dashes, and
233/// underscores.
234///
235/// # Errors
236///
237/// Returns an error of type [`AttachmentFilename`] if the filename is invalid.
238///
239/// [`AttachmentFilename`]: MessageValidationErrorType::AttachmentFilename
240pub fn attachment_filename(filename: impl AsRef<str>) -> Result<(), MessageValidationError> {
241    if filename
242        .as_ref()
243        .chars()
244        .all(|c| (c.is_ascii_alphanumeric() || c == DOT || c == DASH || c == UNDERSCORE))
245    {
246        Ok(())
247    } else {
248        Err(MessageValidationError {
249            kind: MessageValidationErrorType::AttachmentFilename {
250                filename: filename.as_ref().to_string(),
251            },
252            source: None,
253        })
254    }
255}
256
257/// Ensure a list of components is correct.
258///
259/// # Errors
260///
261/// Returns a [`ComponentValidationErrorType::ComponentCount`] if there are
262/// too many components in the provided list.
263///
264/// Refer to the errors section of [`component`] for a list of errors that may
265/// be returned as a result of validating each provided component.
266///
267/// [`component`]: crate::component::component
268pub fn components(components: &[Component]) -> Result<(), MessageValidationError> {
269    let count = components.len();
270
271    if count > COMPONENT_COUNT {
272        Err(MessageValidationError {
273            kind: MessageValidationErrorType::ComponentCount { count },
274            source: None,
275        })
276    } else {
277        for (idx, component) in components.iter().enumerate() {
278            crate::component::component(component).map_err(|source| {
279                let (kind, source) = source.into_parts();
280
281                MessageValidationError {
282                    kind: MessageValidationErrorType::ComponentInvalid { idx, kind },
283                    source,
284                }
285            })?;
286        }
287
288        Ok(())
289    }
290}
291
292/// Ensure a message's content is correct.
293///
294/// # Errors
295///
296/// Returns an error of type [`ContentInvalid`] if the message's content is
297/// invalid.
298///
299/// [`ContentInvalid`]: MessageValidationErrorType::ContentInvalid
300pub fn content(value: impl AsRef<str>) -> Result<(), MessageValidationError> {
301    // <https://discordapp.com/developers/docs/resources/channel#create-message-params>
302    if value.as_ref().chars().count() <= MESSAGE_CONTENT_LENGTH_MAX {
303        Ok(())
304    } else {
305        Err(MessageValidationError {
306            kind: MessageValidationErrorType::ContentInvalid,
307            source: None,
308        })
309    }
310}
311
312/// Ensure a list of embeds is correct.
313///
314/// # Errors
315///
316/// Returns an error of type [`TooManyEmbeds`] if there are too many embeds.
317///
318/// Otherwise, refer to the errors section of [`embed`] for a list of errors
319/// that may occur.
320///
321/// [`TooManyEmbeds`]: MessageValidationErrorType::TooManyEmbeds
322/// [`embed`]: crate::embed::embed
323pub fn embeds(embeds: &[Embed]) -> Result<(), MessageValidationError> {
324    if embeds.len() > EMBED_COUNT_LIMIT {
325        Err(MessageValidationError {
326            kind: MessageValidationErrorType::TooManyEmbeds,
327            source: None,
328        })
329    } else {
330        let mut chars = 0;
331        for (idx, embed) in embeds.iter().enumerate() {
332            chars += embed_chars(embed);
333
334            if chars > EMBED_TOTAL_LENGTH {
335                return Err(MessageValidationError {
336                    kind: MessageValidationErrorType::EmbedInvalid {
337                        idx,
338                        kind: EmbedValidationErrorType::EmbedTooLarge { chars },
339                    },
340                    source: None,
341                });
342            }
343
344            crate::embed::embed(embed).map_err(|source| {
345                let (kind, source) = source.into_parts();
346
347                MessageValidationError {
348                    kind: MessageValidationErrorType::EmbedInvalid { idx, kind },
349                    source,
350                }
351            })?;
352        }
353
354        Ok(())
355    }
356}
357
358/// Ensure that the amount of stickers in a message is correct.
359///
360/// There must be at most [`STICKER_MAX`] stickers. This is based on [this
361/// documentation entry].
362///
363/// # Errors
364///
365/// Returns an error of type [`StickersInvalid`] if the length is invalid.
366///
367/// [`StickersInvalid`]: MessageValidationErrorType::StickersInvalid
368/// [this documentation entry]: https://discord.com/developers/docs/resources/channel#create-message-jsonform-params
369pub fn sticker_ids(sticker_ids: &[Id<StickerMarker>]) -> Result<(), MessageValidationError> {
370    let len = sticker_ids.len();
371
372    if len <= STICKER_MAX {
373        Ok(())
374    } else {
375        Err(MessageValidationError {
376            kind: MessageValidationErrorType::StickersInvalid { len },
377            source: None,
378        })
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn attachment_description_limit() {
388        assert!(attachment_description("").is_ok());
389        assert!(attachment_description(str::repeat("a", 1024)).is_ok());
390
391        assert!(matches!(
392            attachment_description(str::repeat("a", 1025))
393                .unwrap_err()
394                .kind(),
395            MessageValidationErrorType::AttachmentDescriptionTooLarge { chars: 1025 }
396        ));
397    }
398
399    #[test]
400    fn attachment_allowed_filename() {
401        assert!(attachment_filename("one.jpg").is_ok());
402        assert!(attachment_filename("two.png").is_ok());
403        assert!(attachment_filename("three.gif").is_ok());
404        assert!(attachment_filename(".dots-dashes_underscores.gif").is_ok());
405
406        assert!(attachment_filename("????????").is_err());
407    }
408
409    #[test]
410    fn content_length() {
411        assert!(content("").is_ok());
412        assert!(content("a".repeat(2000)).is_ok());
413
414        assert!(content("a".repeat(2001)).is_err());
415    }
416}