diff --git a/examples/testing/src/model_type_sizes.rs b/examples/testing/src/model_type_sizes.rs index eb23e783ab4..ef5c77c5a1f 100644 --- a/examples/testing/src/model_type_sizes.rs +++ b/examples/testing/src/model_type_sizes.rs @@ -104,7 +104,6 @@ pub fn print_ranking() { ("IntegrationDeleteEvent", std::mem::size_of::()), ("IntegrationId", std::mem::size_of::()), ("IntegrationUpdateEvent", std::mem::size_of::()), - ("InteractionResponseFlags", std::mem::size_of::()), ("InteractionCreateEvent", std::mem::size_of::()), ("InteractionId", std::mem::size_of::()), ("Invite", std::mem::size_of::()), diff --git a/src/builder/create_components.rs b/src/builder/create_components.rs index 4729c6dfa2a..636068399f7 100644 --- a/src/builder/create_components.rs +++ b/src/builder/create_components.rs @@ -4,6 +4,15 @@ use serde::Serialize; use crate::model::prelude::*; +#[derive(Clone, Debug)] +struct StaticU8; + +impl Serialize for StaticU8 { + fn serialize(&self, ser: S) -> Result { + ser.serialize_u8(VAL) + } +} + /// A builder for creating a components action row in a message. /// /// [Discord docs](https://discord.com/developers/docs/interactions/message-components#component-object). @@ -47,6 +56,462 @@ impl serde::Serialize for CreateActionRow<'_> { } } +/// A builder for creating components in a structured way. +/// +/// This enum supports both V1 and V2 components, with the exception of `ActionRow`, which is a V1 +/// component. +/// +/// ## V2 Components and Message Flags +/// To send V2 components, you must set [`MessageFlags::IS_COMPONENTS_V2`]. +/// +/// ### Limitations +/// - The total number of components is limited to **40**.. +/// - The maximum character count for text within components is **4000**. +/// - The ability to set the `content` and `embeds` field will be disabled +/// - No support for audio files +/// - No simple text preview for files +/// - No embeds for urls +#[derive(Clone, Debug, Serialize)] +#[must_use] +#[serde(untagged)] +pub enum CreateComponent<'a> { + /// Represents an action row component (V1). + /// + /// An action row is a container for other interactive components, such as buttons and select + /// menus. + ActionRow(CreateActionRow<'a>), + /// Represents a section component (V2). + /// + /// A section is used to structure and group other components with an accessory. + Section(CreateSection<'a>), + /// Represents a text display component (V2). + /// + /// This component is used for displaying text within a message, separate from interactive + /// elements. + TextDisplay(CreateTextDisplay<'a>), + /// Represents a media gallery component (V2). + /// + /// A media gallery allows embedding images, videos, or other media assets within a message. + MediaGallery(CreateMediaGallery<'a>), + /// Represents a file component (V2). + /// + /// This component is used for attaching and displaying files within a message. + File(CreateFile<'a>), + /// Represents a separator component (V2). + /// + /// A separator is used to visually divide sections within a message for better readability. + Separator(CreateSeparator), + /// Represents a container component (V2). + /// + /// A container is a flexible component that can hold multiple nested components. + Container(CreateContainer<'a>), +} + +/// A builder to create a section component, supports up to a max of **3** components with an +/// accessory. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateSection<'a> { + #[serde(rename = "type")] + kind: StaticU8<9>, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + components: Cow<'a, [CreateSectionComponent<'a>]>, + accessory: CreateSectionAccessory<'a>, +} + +impl<'a> CreateSection<'a> { + /// Creates a new builder with the specified components and accessory. + /// + /// Note: You may specify no more than **3** components or this will error on send. + pub fn new( + components: impl Into]>>, + accessory: CreateSectionAccessory<'a>, + ) -> Self { + CreateSection { + kind: StaticU8::<9>, + components: components.into(), + accessory, + } + } + + /// Sets the components for the section. Replaces the current value as set in [`Self::new`]. + /// + /// **Note**: This will replace all existing components. Use [`Self::add_component()`] to add + /// additional components. + pub fn components( + mut self, + components: impl Into]>>, + ) -> Self { + self.components = components.into(); + self + } + + /// Adds an additional component to this section. + /// + /// **Note**: This will add additional components. Use [`Self::components()`] to replace them. + pub fn add_component(mut self, component: CreateSectionComponent<'a>) -> Self { + self.components.to_mut().push(component); + self + } + + /// Sets the accessory for this section. Replaces the current value as set in [`Self::new`]. + pub fn accessory(mut self, accessory: CreateSectionAccessory<'a>) -> Self { + self.accessory = accessory; + self + } +} + +/// An enum of all valid section components. +#[derive(Clone, Debug, Serialize)] +#[must_use] +#[serde(untagged)] +pub enum CreateSectionComponent<'a> { + TextDisplay(CreateTextDisplay<'a>), +} + +/// A builder to create a text display component. +#[derive(Clone, Debug, Serialize)] +pub struct CreateTextDisplay<'a> { + #[serde(rename = "type")] + kind: StaticU8<10>, + content: Cow<'a, str>, +} + +impl<'a> CreateTextDisplay<'a> { + /// Creates a new text display component. + /// + /// Note: All components on a message shares the same **4000** character limit. + pub fn new(content: impl Into>) -> Self { + CreateTextDisplay { + kind: StaticU8::<10>, + content: content.into(), + } + } + + /// Sets the content of this text display component. Replaces the current value as set in + /// [`Self::new`]. + /// + /// Note: All components on a message shares the same **4000** character limit. + #[must_use] + pub fn content(mut self, content: impl Into>) -> Self { + self.content = content.into(); + self + } +} + +/// An enum of all valid section accessories. +#[derive(Clone, Debug, Serialize)] +#[must_use] +#[serde(untagged)] +pub enum CreateSectionAccessory<'a> { + Thumbnail(CreateThumbnail<'a>), + Button(CreateButton<'a>), +} + +/// A builder to create a thumbnail for a section. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateThumbnail<'a> { + #[serde(rename = "type")] + kind: StaticU8<11>, + media: CreateUnfurledMediaItem<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + spoiler: Option, +} + +impl<'a> CreateThumbnail<'a> { + /// Creates a new thumbnail with a media item. + pub fn new(media: CreateUnfurledMediaItem<'a>) -> Self { + CreateThumbnail { + kind: StaticU8::<11>, + media, + description: None, + spoiler: None, + } + } + + /// Sets the media item. Replaces the current value as set in [`Self::new`]. + pub fn media(mut self, media: CreateUnfurledMediaItem<'a>) -> Self { + self.media = media; + self + } + + /// Sets the description for this thumbnail. + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets if this thumbnail is spoilered. + pub fn spoiler(mut self, spoiler: bool) -> Self { + self.spoiler = Some(spoiler); + self + } +} + +/// A builder to create a media item. +#[derive(Clone, Debug, Serialize, Default)] +#[must_use] +pub struct CreateUnfurledMediaItem<'a> { + url: Cow<'a, str>, +} + +impl<'a> CreateUnfurledMediaItem<'a> { + /// Creates a new media item. + pub fn new(url: impl Into>) -> Self { + CreateUnfurledMediaItem { + url: url.into(), + } + } + + /// Sets the url to this media item. Replaces the current value as set in [`Self::new`]. + pub fn url(mut self, url: impl Into>) -> Self { + self.url = url.into(); + self + } +} + +/// A builder to create a media gallery, a component that can contain multiple pieces of media. +/// +/// Note: May contain up to **10** items. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateMediaGallery<'a> { + #[serde(rename = "type")] + kind: StaticU8<12>, + items: Cow<'a, [CreateMediaGalleryItem<'a>]>, +} + +impl<'a> CreateMediaGallery<'a> { + /// Creates a new media gallery with up to **10** items. + pub fn new(items: impl Into]>>) -> Self { + CreateMediaGallery { + kind: StaticU8::<12>, + items: items.into(), + } + } + + /// Sets the items of the gallery. Replaces the current value as set in [`Self::new`]. + /// + /// **Note**: This will replace all existing items. Use [`Self::add_item()`] to add additional + /// items + pub fn items(mut self, items: impl Into]>>) -> Self { + self.items = items.into(); + self + } + + /// Adds a singular item to the gallery. + /// + /// **Note**: This will add a singular item. Use [`Self::items()`] to replace all items. + pub fn add_item(mut self, item: CreateMediaGalleryItem<'a>) -> Self { + self.items.to_mut().push(item); + self + } +} + +/// Builder to create individual media gallery items. +#[derive(Clone, Debug, Serialize, Default)] +#[must_use] +pub struct CreateMediaGalleryItem<'a> { + media: CreateUnfurledMediaItem<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + spoiler: Option, +} + +impl<'a> CreateMediaGalleryItem<'a> { + /// Create a new media gallery item. + pub fn new(media: CreateUnfurledMediaItem<'a>) -> Self { + CreateMediaGalleryItem { + media, + description: None, + spoiler: None, + } + } + + /// Sets the internal media item. Replaces the current value as set in [`Self::new`]. + pub fn media(mut self, media: CreateUnfurledMediaItem<'a>) -> Self { + self.media = media; + self + } + + /// Sets the description of this item in the gallery. + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } + + /// Specifies if this piece of media should be spoilered. + pub fn spoiler(mut self, spoiler: bool) -> Self { + self.spoiler = Some(spoiler); + self + } +} + +/// A builder for specifying a file to be uploaded. +/// +/// This builder **only** supports the `attachment://filename.extension` format. +/// This means that you must first upload the file as an attachment in the message +/// and then reference it using this format. +/// +/// # Usage +/// +/// 1. Upload the file as an attachment. +/// 2. Use the `attachment://` scheme to reference the uploaded file. +/// +/// ## Example +/// +/// If you upload an attachment to the message that is called "example.txt", you set the url of the +/// item to "attachment://example.txt". +/// +/// For more details on naming and rules for attachments, +/// refer to the [Discord Documentation](https://discord.com/developers/docs/reference#uploading-files). +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateFile<'a> { + #[serde(rename = "type")] + kind: StaticU8<13>, + file: CreateUnfurledMediaItem<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + spoiler: Option, +} + +impl<'a> CreateFile<'a> { + /// Create a new builder for the file component. Refer to this builders documentation for + /// limits. + pub fn new(file: impl Into>) -> Self { + CreateFile { + kind: StaticU8::<13>, + file: file.into(), + spoiler: None, + } + } + + // Only supports `attachment://filename.extension` format, refer to this builders documentation + // for more details. Replaces the current value as set in [`Self::new`]. + pub fn file(mut self, file: impl Into>) -> Self { + self.file = file.into(); + self + } + + /// Sets if this file should be spoilered or not. + pub fn spoiler(mut self, spoiler: bool) -> Self { + self.spoiler = Some(spoiler); + self + } +} + +/// A builder for creating a separator. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateSeparator { + #[serde(rename = "type")] + kind: StaticU8<14>, + divider: bool, + #[serde(skip_serializing_if = "Option::is_none")] + spacing: Option, +} + +impl CreateSeparator { + /// Creates a new separator, with or without a divider. + pub fn new(divider: bool) -> Self { + CreateSeparator { + kind: StaticU8::<14>, + divider, + spacing: None, + } + } + + /// Sets if this separator should have a divider or not. Replaces the current value as set in + /// [`Self::new`]. + pub fn divider(mut self, divider: bool) -> Self { + self.divider = divider; + self + } + + /// Sets the spacing of this separator. + pub fn spacing(mut self, spacing: Spacing) -> Self { + self.spacing = Some(spacing); + self + } +} + +/// A builder to create a container, which acts similarly to embeds. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateContainer<'a> { + #[serde(rename = "type")] + kind: StaticU8<17>, + #[serde(skip_serializing_if = "Option::is_none")] + accent_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + spoiler: Option, + components: Cow<'a, [CreateComponent<'a>]>, +} + +impl<'a> CreateContainer<'a> { + /// Create a new container, with an array of components inside. This component may contain any + /// other component except another container! + pub fn new(components: impl Into]>>) -> Self { + CreateContainer { + kind: StaticU8::<17>, + accent_color: None, + spoiler: None, + components: components.into(), + } + } + + // Set the colour of the left-hand side of the container. + pub fn accent_colour>(mut self, colour: C) -> Self { + self.accent_color = Some(colour.into()); + self + } + + /// Set the colour of the left-hand side of the container. + /// + /// This is an alias of [`Self::accent_colour`]. + pub fn accent_color>(self, colour: C) -> Self { + self.accent_colour(colour) + } + + /// Sets if this container is spoilered or not. + pub fn spoiler(mut self, spoiler: bool) -> Self { + self.spoiler = Some(spoiler); + self + } + + /// Sets the components of this container. Replaces the current value as set in [`Self::new`]. + /// + /// **Note**: This will replace all existing components. Use [`Self::add_component()`] to add + /// additional components. + pub fn components(mut self, components: impl Into]>>) -> Self { + self.components = components.into(); + self + } + + /// Adds an additional component to this container. + /// + /// **Note**: This will add additional components. Use [`Self::components()`] to replace them. + pub fn add_component(mut self, component: CreateComponent<'a>) -> Self { + self.components.to_mut().push(component); + self + } +} + +enum_number! { + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] + #[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] + #[non_exhaustive] + pub enum Spacing { + Small = 1, + Large = 2, + _ => Unknown(u8), + } +} + /// A builder for creating a button component in a message #[derive(Clone, Debug, Serialize)] #[must_use] diff --git a/src/builder/create_interaction_response.rs b/src/builder/create_interaction_response.rs index d42c7b4d7c4..480910804d8 100644 --- a/src/builder/create_interaction_response.rs +++ b/src/builder/create_interaction_response.rs @@ -6,6 +6,7 @@ use super::{ CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, CreatePoll, EditAttachments, @@ -158,9 +159,9 @@ pub struct CreateInteractionResponseMessage<'a> { #[serde(skip_serializing_if = "Option::is_none")] allowed_mentions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - flags: Option, + flags: Option, #[serde(skip_serializing_if = "Option::is_none")] - components: Option]>>, + components: Option]>>, #[serde(skip_serializing_if = "Option::is_none")] poll: Option>, attachments: EditAttachments<'a>, @@ -253,19 +254,19 @@ impl<'a> CreateInteractionResponseMessage<'a> { } /// Sets the flags for the message. - pub fn flags(mut self, flags: InteractionResponseFlags) -> Self { + pub fn flags(mut self, flags: MessageFlags) -> Self { self.flags = Some(flags); self } /// Adds or removes the ephemeral flag. pub fn ephemeral(mut self, ephemeral: bool) -> Self { - let mut flags = self.flags.unwrap_or_else(InteractionResponseFlags::empty); + let mut flags = self.flags.unwrap_or_else(MessageFlags::empty); if ephemeral { - flags |= InteractionResponseFlags::EPHEMERAL; + flags |= MessageFlags::EPHEMERAL; } else { - flags &= !InteractionResponseFlags::EPHEMERAL; + flags &= !MessageFlags::EPHEMERAL; } self.flags = Some(flags); @@ -273,7 +274,7 @@ impl<'a> CreateInteractionResponseMessage<'a> { } /// Sets the components of this message. - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } diff --git a/src/builder/create_interaction_response_followup.rs b/src/builder/create_interaction_response_followup.rs index dab3681dc6d..f5bf6b872d2 100644 --- a/src/builder/create_interaction_response_followup.rs +++ b/src/builder/create_interaction_response_followup.rs @@ -2,9 +2,9 @@ use std::borrow::Cow; use super::create_poll::Ready; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, CreatePoll, EditAttachments, @@ -29,7 +29,7 @@ pub struct CreateInteractionResponseFollowup<'a> { #[serde(skip_serializing_if = "Option::is_none")] allowed_mentions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - components: Option]>>, + components: Option]>>, #[serde(skip_serializing_if = "Option::is_none")] flags: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -153,10 +153,11 @@ impl<'a> CreateInteractionResponseFollowup<'a> { } /// Sets the components of this message. - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } + super::button_and_select_menu_convenience_methods!(self.components); /// Creates or edits a followup response to the response sent. If a [`MessageId`] is provided, diff --git a/src/builder/create_message.rs b/src/builder/create_message.rs index 3f4bdfdb502..4df7a1839be 100644 --- a/src/builder/create_message.rs +++ b/src/builder/create_message.rs @@ -2,9 +2,9 @@ use std::borrow::Cow; use super::create_poll::Ready; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, CreatePoll, EditAttachments, @@ -63,7 +63,7 @@ pub struct CreateMessage<'a> { #[serde(skip_serializing_if = "Option::is_none")] message_reference: Option, #[serde(skip_serializing_if = "Option::is_none")] - components: Option]>>, + components: Option]>>, sticker_ids: Cow<'a, [StickerId]>, #[serde(skip_serializing_if = "Option::is_none")] flags: Option, @@ -184,10 +184,11 @@ impl<'a> CreateMessage<'a> { } /// Sets the components of this message. - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } + super::button_and_select_menu_convenience_methods!(self.components); /// Sets the flags for the message. diff --git a/src/builder/edit_interaction_response.rs b/src/builder/edit_interaction_response.rs index e28dcdc1b5e..0505b8a30f4 100644 --- a/src/builder/edit_interaction_response.rs +++ b/src/builder/edit_interaction_response.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, EditAttachments, EditWebhookMessage, @@ -70,9 +70,10 @@ impl<'a> EditInteractionResponse<'a> { } /// Sets the components of this message. - pub fn components(self, components: impl Into]>>) -> Self { + pub fn components(self, components: impl Into]>>) -> Self { Self(self.0.components(components)) } + super::button_and_select_menu_convenience_methods!(self.0.components); /// Sets attachments, see [`EditAttachments`] for more details. diff --git a/src/builder/edit_message.rs b/src/builder/edit_message.rs index 6ca6a0d05d8..228a84e4f5f 100644 --- a/src/builder/edit_message.rs +++ b/src/builder/edit_message.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, EditAttachments, }; @@ -45,7 +45,7 @@ pub struct EditMessage<'a> { #[serde(skip_serializing_if = "Option::is_none")] allowed_mentions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - components: Option]>>, + components: Option]>>, #[serde(skip_serializing_if = "Option::is_none")] attachments: Option>, } @@ -136,10 +136,11 @@ impl<'a> EditMessage<'a> { } /// Sets the components of this message. - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } + super::button_and_select_menu_convenience_methods!(self.components); /// Sets the flags for the message. diff --git a/src/builder/edit_webhook_message.rs b/src/builder/edit_webhook_message.rs index e794dcfab4e..2474e111dd9 100644 --- a/src/builder/edit_webhook_message.rs +++ b/src/builder/edit_webhook_message.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, EditAttachments, }; @@ -26,7 +26,7 @@ pub struct EditWebhookMessage<'a> { #[serde(skip_serializing_if = "Option::is_none")] allowed_mentions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) components: Option]>>, + pub(crate) components: Option]>>, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) attachments: Option>, @@ -107,10 +107,11 @@ impl<'a> EditWebhookMessage<'a> { /// /// [`WebhookType::Application`]: crate::model::webhook::WebhookType /// [`WebhookType::Incoming`]: crate::model::webhook::WebhookType - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } + super::button_and_select_menu_convenience_methods!(self.components); /// Sets attachments, see [`EditAttachments`] for more details. diff --git a/src/builder/execute_webhook.rs b/src/builder/execute_webhook.rs index 2eb9c81d5e3..8e19481b5d4 100644 --- a/src/builder/execute_webhook.rs +++ b/src/builder/execute_webhook.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, + CreateComponent, CreateEmbed, EditAttachments, }; @@ -68,7 +68,7 @@ pub struct ExecuteWebhook<'a> { #[serde(skip_serializing_if = "Option::is_none")] allowed_mentions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - components: Option]>>, + components: Option]>>, #[serde(skip_serializing_if = "Option::is_none")] flags: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -211,15 +211,12 @@ impl<'a> ExecuteWebhook<'a> { /// the webhook's `kind` field is set to [`WebhookType::Application`], or it was created by an /// application (and has kind [`WebhookType::Incoming`]). /// - /// If [`Self::with_components`] is set, non-interactive components can be used on non - /// application-owned webhooks. - /// /// [`WebhookType::Application`]: crate::model::webhook::WebhookType - /// [`WebhookType::Incoming`]: crate::model::webhook::WebhookType - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = Some(components.into()); self } + super::button_and_select_menu_convenience_methods!(self.components); /// Set an embed for the message. diff --git a/src/builder/mod.rs b/src/builder/mod.rs index d923cbcae63..c6b27a84279 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -130,12 +130,12 @@ macro_rules! button_and_select_menu_convenience_methods { pub fn button(mut $self, button: super::CreateButton<'a>) -> Self { let rows = $self$(.$components_path)+.get_or_insert_with(Cow::default).to_mut(); let row_with_space_left = rows.last_mut().and_then(|row| match row { - super::CreateActionRow::Buttons(buttons) if buttons.len() < 5 => Some(buttons.to_mut()), + super::CreateComponent::ActionRow(super::CreateActionRow::Buttons(buttons)) if buttons.len() < 5 => Some(buttons.to_mut()), _ => None, }); match row_with_space_left { Some(row) => row.push(button), - None => rows.push(super::CreateActionRow::buttons(vec![button])), + None => rows.push(super::CreateComponent::ActionRow(super::CreateActionRow::buttons(vec![button]))), } $self } @@ -144,10 +144,11 @@ macro_rules! button_and_select_menu_convenience_methods { /// /// Convenience method that wraps [`Self::components`]. pub fn select_menu(mut $self, select_menu: super::CreateSelectMenu<'a>) -> Self { + $self$(.$components_path)+ .get_or_insert_with(Cow::default) .to_mut() - .push(super::CreateActionRow::SelectMenu(select_menu)); + .push(super::CreateComponent::ActionRow(super::CreateActionRow::SelectMenu(select_menu))); $self } }; diff --git a/src/model/application/component.rs b/src/model/application/component.rs index 4cd52bf27d1..fbb1c9aeabf 100644 --- a/src/model/application/component.rs +++ b/src/model/application/component.rs @@ -1,3 +1,4 @@ +use nonmax::NonMaxU32; use serde::de::Error as DeError; use serde::ser::{Serialize, Serializer}; use serde_json::value::RawValue; @@ -19,10 +20,258 @@ enum_number! { RoleSelect = 6, MentionableSelect = 7, ChannelSelect = 8, + Section = 9, + TextDisplay = 10, + Thumbnail = 11, + MediaGallery = 12, + File = 13, + Separator = 14, + Container = 17, _ => Unknown(u8), } } +/// Represents Discord components, a part of messages that are usually interactable. +/// +/// # Component Versioning +/// +/// - When `IS_COMPONENTS_V2` is **not** set, the **only** valid top-level component is +/// [`ActionRow`]. +/// - When `IS_COMPONENTS_V2` **is** set, other component types may be used at the top level, but +/// other message limitations are applied. +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Serialize)] +#[non_exhaustive] +pub enum Component { + ActionRow(ActionRow), + Button(Button), + SelectMenu(SelectMenu), + Section(Section), + TextDisplay(TextDisplay), + Thumbnail(Thumbnail), + MediaGallery(MediaGallery), + Separator(Separator), + File(FileComponent), + Container(Container), + Unknown(u8), +} + +impl<'de> Deserialize<'de> for Component { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + use serde_json::value::RawValue; + + #[derive(Deserialize)] + struct ComponentRaw { + #[serde(rename = "type")] + kind: ComponentType, + } + + let value = <&RawValue>::deserialize(deserializer)?; + let raw = ComponentRaw::deserialize(value).map_err(DeError::custom)?; + + match raw.kind { + ComponentType::ActionRow => Deserialize::deserialize(value).map(Component::ActionRow), + ComponentType::Button => Deserialize::deserialize(value).map(Component::Button), + ComponentType::StringSelect + | ComponentType::UserSelect + | ComponentType::RoleSelect + | ComponentType::MentionableSelect + | ComponentType::ChannelSelect => { + Deserialize::deserialize(value).map(Component::SelectMenu) + }, + ComponentType::Section => Deserialize::deserialize(value).map(Component::Section), + ComponentType::TextDisplay => { + Deserialize::deserialize(value).map(Component::TextDisplay) + }, + ComponentType::MediaGallery => { + Deserialize::deserialize(value).map(Component::MediaGallery) + }, + ComponentType::Separator => Deserialize::deserialize(value).map(Component::Separator), + ComponentType::File => Deserialize::deserialize(value).map(Component::File), + ComponentType::Container => Deserialize::deserialize(value).map(Component::Container), + ComponentType::Thumbnail => Deserialize::deserialize(value).map(Component::Thumbnail), + ComponentType(i) => Ok(Component::Unknown(i)), + } + .map_err(DeError::custom) + } +} + +/// A component that is a container for up to 3 text display components and an accessory. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#section) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct Section { + /// Always [`ComponentType::Section`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The components inside of the section. + /// + /// As of 2025-02-28, this is limited to just [`ComponentType::TextDisplay`] with up to 3 max. + pub components: FixedArray, + /// The accessory to the side of the section. + /// + /// As of 2025-02-28, this is limited to [`ComponentType::Button`] or + /// [`ComponentType::Thumbnail`] + pub accessory: Box, +} + +/// A section component's thumbnail. +/// +/// See [`Section`] for how this fits within a section. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#thumbnail) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct Thumbnail { + /// Always [`ComponentType::Thumbnail`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The internal media item this contains. + pub media: UnfurledMediaItem, + /// The description of the thumbnail. + pub description: Option>, + /// Whether or not this component is spoilered. + pub spoiler: Option, +} + +/// A url or attachment. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#unfurled-media-item-structure) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct UnfurledMediaItem { + /// The url of this item. + pub url: FixedString, + /// The proxied discord url. + pub proxy_url: Option>, + /// The width of the media item. + pub width: Option, + /// The height of the media item. + pub height: Option, + /// The content type of the media item. + pub content_type: Option, +} + +/// A component that allows you to add text to your message, similiar to the `content` field of a +/// message. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#text-display) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct TextDisplay { + /// Always [`ComponentType::TextDisplay`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The content of this text display component. + pub content: FixedString, +} + +/// A Media Gallery is a component that allows you to display media attachments in an organized +/// gallery format. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#media-gallery) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct MediaGallery { + /// Always [`ComponentType::MediaGallery`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// Array of images this media gallery can contain, max of 10. + pub items: FixedArray, +} + +/// An individual media gallery item. +/// +/// Belongs to [`MediaGallery`]. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#media-gallery-media-gallery-item-structure) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct MediaGalleryItem { + /// The internal media piece that this item contains. + pub media: UnfurledMediaItem, + /// The description of the media item. + pub description: Option>, + /// Whether or not this component is spoilered. + pub spoiler: Option, +} + +/// A component that adds vertical padding and visual division between other components. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#separator) +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub struct Separator { + /// Always [`ComponentType::Separator`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// Whether or not this contains a separating divider. + pub divider: Option, + /// The spacing of the separator. + pub spacing: Option, +} + +enum_number! { + /// The size of a separator component. + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] + #[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] + #[non_exhaustive] + pub enum SeparatorSpacingSize { + Small = 1, + Large = 2, + _ => Unknown(u8), + } +} + +/// A file component, will not render a text preview to the user. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#file) +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[non_exhaustive] +pub struct FileComponent { + /// Always [`ComponentType::File`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The file this component internally contains. + pub file: UnfurledMediaItem, + /// Whether or not this component is spoilered. + pub spoiler: Option, +} + +/// A container component, similar to an embed but without all the functionality. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#container) +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[non_exhaustive] +pub struct Container { + /// Always [`ComponentType::Container`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The accent colour, similar to an embeds accent. + pub accent_color: Option, + /// Whether or not this component is spoilered. + pub spoiler: Option, + /// The components within this container. + /// + /// As of 2025-02-28, this can be [`ComponentType::ActionRow`], [`ComponentType::Section`], + /// [`ComponentType::TextDisplay`], [`ComponentType::MediaGallery`], [`ComponentType::File`] or + /// [`ComponentType::Separator`] + pub components: FixedArray, +} + /// An action row. /// /// [Discord docs](https://discord.com/developers/docs/interactions/message-components#action-rows). diff --git a/src/model/application/interaction.rs b/src/model/application/interaction.rs index 44826af5bb9..4070501fa36 100644 --- a/src/model/application/interaction.rs +++ b/src/model/application/interaction.rs @@ -299,23 +299,6 @@ enum_number! { } } -bitflags! { - /// The flags for an interaction response message. - /// - /// [Discord docs](https://discord.com/developers/docs/resources/channel#message-object-message-flags) - /// ([only some are valid in this context](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-messages)) - #[derive(Copy, Clone, Default, Debug, Eq, Hash, PartialEq)] - pub struct InteractionResponseFlags: u64 { - /// Do not include any embeds when serializing this message. - const SUPPRESS_EMBEDS = 1 << 2; - /// Interaction message will only be visible to sender and will - /// be quickly deleted. - const EPHEMERAL = 1 << 6; - /// Does not trigger push notifications or desktop notifications. - const SUPPRESS_NOTIFICATIONS = 1 << 12; - } -} - /// A cleaned up enum for determining the authorizing owner for an [`Interaction`]. /// /// [Discord Docs](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-authorizing-integration-owners-object) diff --git a/src/model/channel/message.rs b/src/model/channel/message.rs index 9e7abb33be1..dc00ecbb010 100644 --- a/src/model/channel/message.rs +++ b/src/model/channel/message.rs @@ -14,7 +14,7 @@ use crate::constants; #[cfg(feature = "model")] use crate::http::{CacheHttp, Http}; use crate::model::prelude::*; -use crate::model::utils::{StrOrInt, deserialize_components, discord_colours}; +use crate::model::utils::{StrOrInt, discord_colours}; /// A representation of a message over a guild's text channel, a group, or a private channel. /// @@ -109,8 +109,8 @@ pub struct Message { /// The thread that was started from this message, includes thread member object. pub thread: Option>, /// The components of this message - #[serde(default, deserialize_with = "deserialize_components")] - pub components: FixedArray, + #[serde(default)] + pub components: FixedArray, /// Array of message sticker item objects. #[serde(default)] pub sticker_items: FixedArray, @@ -873,21 +873,21 @@ pub struct ChannelMention { #[derive(Clone, Debug, Serialize, Deserialize)] #[non_exhaustive] pub struct MessageSnapshot { - pub content: String, + pub content: FixedString, pub timestamp: Timestamp, pub edited_timestamp: Option, - pub mentions: Vec, + pub mentions: FixedArray, #[serde(default)] - pub mention_roles: Vec, - pub attachments: Vec, - pub embeds: Vec, + pub mention_roles: FixedArray, + pub attachments: FixedArray, + pub embeds: FixedArray, #[serde(rename = "type")] pub kind: MessageType, pub flags: Option, - #[serde(default, deserialize_with = "deserialize_components")] - pub components: FixedArray, #[serde(default)] - pub sticker_items: Vec, + pub components: FixedArray, + #[serde(default)] + pub sticker_items: FixedArray, } /// Custom deserialization function to handle the nested "message" field @@ -947,6 +947,21 @@ bitflags! { /// As of 2023-04-20, bots are currently not able to send voice messages /// ([source](https://github.com/discord/discord-api-docs/pull/6082)). const IS_VOICE_MESSAGE = 1 << 13; + /// Enables support for sending Components V2. + /// + /// Setting this flag is required to use V2 components. + /// Attempting to send V2 components without enabling this flag will result in an error. + /// + /// # Limitations + /// When this flag is enabled, certain restrictions apply: + /// - The `content` and `embeds` fields cannot be set. + /// - Audio file attachments are not supported. + /// - Files will not have a simple text preview. + /// - URLs will not generate embeds. + /// + /// For more details, refer to the Discord documentation: [https://discord.com/developers/docs/components/reference#component-reference] + const IS_COMPONENTS_V2 = 1 << 15; + } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 2fae5389692..60d0bd41bd2 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -62,7 +62,6 @@ pub use self::timestamp::Timestamp; /// use serenity::model::prelude::*; /// ``` pub mod prelude { - pub(crate) use serde::de::Visitor; pub(crate) use serde::{Deserialize, Deserializer}; pub use super::guild::automod::EventType as AutomodEventType; diff --git a/src/model/utils.rs b/src/model/utils.rs index 79a5c638de6..42fd5249660 100644 --- a/src/model/utils.rs +++ b/src/model/utils.rs @@ -3,7 +3,6 @@ use std::fmt; use arrayvec::ArrayVec; use serde::de::Error as DeError; use serde_cow::CowStr; -use serde_json::value::RawValue; use small_fixed_array::FixedString; use super::prelude::*; @@ -239,57 +238,3 @@ where }) .collect() } - -// Custom deserialize function to deserialize components safely without knocking the whole message -// out when new components are found but not supported. -pub fn deserialize_components<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - struct MinComponent { - #[serde(rename = "type")] - kind: u8, - } - - struct ComponentsVisitor; - - impl<'de> Visitor<'de> for ComponentsVisitor { - type Value = FixedArray; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a sequence of ActionRow elements") - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - let mut components = Vec::with_capacity(seq.size_hint().unwrap_or_default()); - - while let Some(raw) = seq.next_element::<&RawValue>()? { - // We deserialize only the `kind` field to determine the component type. - // We later use this to check if its a supported component before deserializing the - // entire payload. - let min_component = - MinComponent::deserialize(raw).map_err(serde::de::Error::custom)?; - - // Action rows are the only top level component supported in serenity at this time. - if min_component.kind == 1 { - components.push(ActionRow::deserialize(raw).map_err(serde::de::Error::custom)?); - } else { - // Top level component is not an action row and cannot be supported on - // serenity@current without breaking changes, so we skip them. - tracing::debug!( - "Skipping component with unsupported kind: {}", - min_component.kind - ); - } - } - - Ok(FixedArray::from_vec_trunc(components)) - } - } - - deserializer.deserialize_seq(ComponentsVisitor) -}