use super::{
ComponentValidationError, ComponentValidationErrorType, action_row, button,
component_custom_id, select_menu, text_input,
};
use twilight_model::channel::message::Component;
use twilight_model::channel::message::component::{
ComponentType, Container, FileUpload, Label, MediaGallery, MediaGalleryItem, Section,
TextDisplay, Thumbnail,
};
pub const TEXT_DISPLAY_CONTENT_LENGTH_MAX: usize = 2000;
pub const MEDIA_GALLERY_ITEMS_MIN: usize = 1;
pub const MEDIA_GALLERY_ITEMS_MAX: usize = 10;
pub const MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX: usize = 1024;
pub const SECTION_COMPONENTS_MIN: usize = 1;
pub const SECTION_COMPONENTS_MAX: usize = 3;
pub const THUMBNAIL_DESCRIPTION_LENGTH_MAX: usize = 1024;
pub const LABEL_LABEL_LENGTH_MAX: usize = 45;
pub const LABEL_DESCRIPTION_LENGTH_MAX: usize = 100;
pub const FILE_UPLOAD_MAXIMUM_VALUES_LIMIT: u8 = 10;
pub const FILE_UPLOAD_MINIMUM_VALUES_LIMIT: u8 = 10;
pub fn component_v2(component: &Component) -> Result<(), ComponentValidationError> {
match component {
Component::ActionRow(ar) => action_row(ar, true)?,
Component::Label(l) => label(l)?,
Component::Button(b) => button(b)?,
Component::Container(c) => container(c)?,
Component::MediaGallery(mg) => media_gallery(mg)?,
Component::Section(s) => section(s)?,
Component::SelectMenu(sm) => select_menu(sm, true)?,
Component::TextDisplay(td) => text_display(td)?,
Component::TextInput(_) | Component::FileUpload(_) => {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::InvalidRootComponent {
kind: ComponentType::TextInput,
},
});
}
Component::Thumbnail(t) => thumbnail(t)?,
Component::Separator(_) | Component::File(_) | Component::Unknown(_) => (),
}
Ok(())
}
pub fn label(label: &Label) -> Result<(), ComponentValidationError> {
self::label_label(&label.label)?;
if let Some(description) = &label.description {
self::label_description(description)?;
}
match &*label.component {
Component::ActionRow(_) | Component::Label(_) => Err(ComponentValidationError {
kind: ComponentValidationErrorType::InvalidChildComponent {
kind: label.component.kind(),
},
}),
Component::SelectMenu(select_menu) => self::select_menu(select_menu, false),
Component::TextInput(text_input) => self::text_input(text_input, false),
Component::FileUpload(file_upload) => self::file_upload(file_upload),
Component::Unknown(unknown) => Err(ComponentValidationError {
kind: ComponentValidationErrorType::InvalidChildComponent {
kind: ComponentType::Unknown(*unknown),
},
}),
Component::Button(_)
| Component::TextDisplay(_)
| Component::MediaGallery(_)
| Component::Separator(_)
| Component::File(_)
| Component::Section(_)
| Component::Container(_)
| Component::Thumbnail(_) => Err(ComponentValidationError {
kind: ComponentValidationErrorType::DisallowedChildren,
}),
}
}
pub const fn text_display(text_display: &TextDisplay) -> Result<(), ComponentValidationError> {
let content_len = text_display.content.len();
if content_len > TEXT_DISPLAY_CONTENT_LENGTH_MAX {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::TextDisplayContentTooLong { len: content_len },
});
}
Ok(())
}
pub fn media_gallery(media_gallery: &MediaGallery) -> Result<(), ComponentValidationError> {
let items = media_gallery.items.len();
if !(MEDIA_GALLERY_ITEMS_MIN..=MEDIA_GALLERY_ITEMS_MAX).contains(&items) {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange { count: items },
});
}
for item in &media_gallery.items {
media_gallery_item(item)?;
}
Ok(())
}
pub fn section(section: &Section) -> Result<(), ComponentValidationError> {
let components = section.components.len();
if !(SECTION_COMPONENTS_MIN..=SECTION_COMPONENTS_MAX).contains(&components) {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::SectionComponentCountOutOfRange {
count: components,
},
});
}
for component in §ion.components {
match component {
Component::TextDisplay(td) => text_display(td)?,
_ => {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::DisallowedChildren,
});
}
}
}
match section.accessory.as_ref() {
Component::Button(b) => button(b)?,
Component::Thumbnail(t) => thumbnail(t)?,
_ => {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::DisallowedChildren,
});
}
}
Ok(())
}
pub fn container(container: &Container) -> Result<(), ComponentValidationError> {
for component in &container.components {
match component {
Component::ActionRow(ar) => action_row(ar, true)?,
Component::TextDisplay(td) => text_display(td)?,
Component::Section(s) => section(s)?,
Component::MediaGallery(mg) => media_gallery(mg)?,
Component::Separator(_) | Component::File(_) => (),
_ => {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::DisallowedChildren,
});
}
}
}
Ok(())
}
pub const fn thumbnail(thumbnail: &Thumbnail) -> Result<(), ComponentValidationError> {
let Some(Some(desc)) = thumbnail.description.as_ref() else {
return Ok(());
};
let len = desc.len();
if len > THUMBNAIL_DESCRIPTION_LENGTH_MAX {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::ThumbnailDescriptionTooLong { len },
});
}
Ok(())
}
pub fn file_upload(file_upload: &FileUpload) -> Result<(), ComponentValidationError> {
component_custom_id(&file_upload.custom_id)?;
if let Some(min_values) = file_upload.min_values {
component_file_upload_min_values(min_values)?;
}
if let Some(max_value) = file_upload.max_values {
component_file_upload_max_values(max_value)?;
}
Ok(())
}
pub const fn media_gallery_item(item: &MediaGalleryItem) -> Result<(), ComponentValidationError> {
let Some(desc) = item.description.as_ref() else {
return Ok(());
};
let len = desc.len();
if len > MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong { len },
});
}
Ok(())
}
fn label_label(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
let chars = value.as_ref().chars().count();
if chars <= LABEL_LABEL_LENGTH_MAX {
Ok(())
} else {
Err(ComponentValidationError {
kind: ComponentValidationErrorType::LabelLabelTooLong { len: chars },
})
}
}
fn label_description(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
let chars = value.as_ref().chars().count();
if chars <= LABEL_DESCRIPTION_LENGTH_MAX {
Ok(())
} else {
Err(ComponentValidationError {
kind: ComponentValidationErrorType::LabelDescriptionTooLong { len: chars },
})
}
}
const fn component_file_upload_max_values(count: u8) -> Result<(), ComponentValidationError> {
if count > FILE_UPLOAD_MAXIMUM_VALUES_LIMIT {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::FileUploadMaximumValuesCount { count },
});
}
Ok(())
}
const fn component_file_upload_min_values(count: u8) -> Result<(), ComponentValidationError> {
if count > FILE_UPLOAD_MINIMUM_VALUES_LIMIT {
return Err(ComponentValidationError {
kind: ComponentValidationErrorType::FileUploadMinimumValuesCount { count },
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::iter;
use twilight_model::channel::message::Component;
use twilight_model::channel::message::component::{
Button, ButtonStyle, Label, SelectMenu, SelectMenuType, TextInput, TextInputStyle,
};
fn wrap_in_label(component: Component) -> Component {
Component::Label(Label {
id: None,
label: "label".to_owned(),
description: None,
component: Box::new(component),
})
}
#[test]
fn component_label() {
let button = Component::Button(Button {
custom_id: None,
disabled: false,
emoji: None,
label: Some("Press me".to_owned()),
style: ButtonStyle::Danger,
url: None,
sku_id: None,
id: None,
});
let text_display = Component::TextDisplay(TextDisplay {
id: None,
content: "Text display".to_owned(),
});
let valid_select_menu = SelectMenu {
channel_types: None,
custom_id: "my_select".to_owned(),
default_values: None,
disabled: false,
kind: SelectMenuType::User,
max_values: None,
min_values: None,
options: None,
placeholder: None,
id: None,
required: None,
};
let disabled_select_menu = SelectMenu {
disabled: true,
..valid_select_menu.clone()
};
let valid_label = Label {
id: None,
label: "Label".to_owned(),
description: Some("This is a description".to_owned()),
component: Box::new(Component::SelectMenu(valid_select_menu)),
};
let label_invalid_child_button = Label {
component: Box::new(button),
..valid_label.clone()
};
let label_invalid_child_text_display = Label {
id: None,
label: "Another label".to_owned(),
description: None,
component: Box::new(text_display),
};
let label_invalid_child_disabled_select = Label {
component: Box::new(Component::SelectMenu(disabled_select_menu)),
..valid_label.clone()
};
let label_too_long_description = Label {
description: Some(iter::repeat_n('a', 101).collect()),
..valid_label.clone()
};
let label_too_long_label = Label {
label: iter::repeat_n('a', 46).collect(),
..valid_label.clone()
};
assert!(label(&valid_label).is_ok());
assert!(component_v2(&Component::Label(valid_label)).is_ok());
assert!(label(&label_invalid_child_button).is_err());
assert!(component_v2(&Component::Label(label_invalid_child_button)).is_err());
assert!(label(&label_invalid_child_text_display).is_err());
assert!(component_v2(&Component::Label(label_invalid_child_text_display)).is_err());
assert!(label(&label_invalid_child_disabled_select).is_err());
assert!(component_v2(&Component::Label(label_invalid_child_disabled_select)).is_err());
assert!(label(&label_too_long_description).is_err());
assert!(component_v2(&Component::Label(label_too_long_description)).is_err());
assert!(label(&label_too_long_label).is_err());
assert!(component_v2(&Component::Label(label_too_long_label)).is_err());
}
#[test]
fn no_text_input_label_in_label_component() {
#[allow(deprecated)]
let text_input_with_label = Component::TextInput(TextInput {
id: None,
custom_id: "The custom id".to_owned(),
label: Some("The text input label".to_owned()),
max_length: None,
min_length: None,
placeholder: None,
required: None,
style: TextInputStyle::Short,
value: None,
});
let invalid_label_component = Label {
id: None,
label: "Label".to_owned(),
description: None,
component: Box::new(text_input_with_label),
};
assert!(label(&invalid_label_component).is_err());
assert!(component_v2(&Component::Label(invalid_label_component)).is_err());
}
#[test]
fn component_file_upload() {
let valid = FileUpload {
id: Some(42),
custom_id: "custom_id".to_owned(),
max_values: Some(10),
min_values: Some(10),
required: None,
};
assert!(file_upload(&valid).is_ok());
assert!(component_v2(&wrap_in_label(Component::FileUpload(valid.clone()))).is_ok());
let invalid_custom_id = FileUpload {
custom_id: iter::repeat_n('a', 101).collect(),
..valid.clone()
};
assert!(file_upload(&invalid_custom_id).is_err());
assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_custom_id))).is_err());
let invalid_min_values = FileUpload {
min_values: Some(11),
..valid.clone()
};
assert!(file_upload(&invalid_min_values).is_err());
assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_min_values))).is_err());
let invalid_max_values_too_high = FileUpload {
max_values: Some(11),
..valid
};
assert!(file_upload(&invalid_max_values_too_high).is_err());
assert!(
component_v2(&wrap_in_label(Component::FileUpload(
invalid_max_values_too_high
)))
.is_err()
);
}
}