diff --git a/src/builder/edit_onboarding/mod.rs b/src/builder/edit_onboarding/mod.rs new file mode 100644 index 00000000000..b4dcaef6c32 --- /dev/null +++ b/src/builder/edit_onboarding/mod.rs @@ -0,0 +1,196 @@ +#[cfg(feature = "http")] +use super::Builder; +#[cfg(feature = "http")] +use crate::http::CacheHttp; +#[cfg(feature = "http")] +use crate::internal::prelude::*; +use crate::model::guild::{Onboarding, OnboardingMode}; +use crate::model::prelude::*; +#[cfg(feature = "http")] +use crate::model::Permissions; + +mod prompt_option_structure; +mod prompt_structure; + +pub use prompt_option_structure::CreatePromptOption; +pub use prompt_structure::CreateOnboardingPrompt; + +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsPrompts; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsChannels; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsEnabled; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsMode; +#[derive(serde::Serialize, Clone, Debug)] +pub struct Ready; + +mod sealed { + use super::*; + pub trait Sealed {} + + impl Sealed for NeedsPrompts {} + impl Sealed for NeedsChannels {} + impl Sealed for NeedsEnabled {} + impl Sealed for NeedsMode {} + impl Sealed for Ready {} +} + +use sealed::*; + +#[derive(serde::Serialize, Clone, Debug)] +#[must_use = "Builders do nothing unless built"] +pub struct EditOnboarding<'a, Stage: Sealed> { + prompts: Vec>, + default_channel_ids: Vec, + enabled: bool, + mode: OnboardingMode, + + #[serde(skip)] + audit_log_reason: Option<&'a str>, + + #[serde(skip)] + _stage: Stage, +} + +impl<'a> Default for EditOnboarding<'a, NeedsPrompts> { + /// See the documentation of [`Self::new`]. + fn default() -> Self { + // Producing dummy values is okay as we must transition through all `Stage`s before firing, + // which fills in the values with real values. + Self { + prompts: Vec::new(), + default_channel_ids: Vec::new(), + enabled: true, + mode: OnboardingMode::default(), + audit_log_reason: None, + + _stage: NeedsPrompts, + } + } +} + +impl<'a> EditOnboarding<'a, NeedsPrompts> { + pub fn new() -> Self { + Self::default() + } + + /// The onboarding prompts that users can select the options of. + pub fn prompts( + self, + prompts: Vec>, + ) -> EditOnboarding<'a, NeedsChannels> { + EditOnboarding { + prompts, + default_channel_ids: self.default_channel_ids, + enabled: self.enabled, + mode: self.mode, + audit_log_reason: self.audit_log_reason, + + _stage: NeedsChannels, + } + } +} + +impl<'a> EditOnboarding<'a, NeedsChannels> { + /// The list of default channels the user will have regardless of the answers given. + /// + /// There are restrictions that apply only when onboarding is enabled, but these vary depending + /// on the current [Self::mode]. + /// + /// If the default mode is set, you must provide at least 7 channels, 5 of which must allow + /// @everyone to read and send messages. if advanced is set, the restrictions apply across the + /// default channels and the [Self::prompts], provided that they supply the remaining required + /// channels. + pub fn default_channels( + self, + default_channel_ids: Vec, + ) -> EditOnboarding<'a, NeedsEnabled> { + EditOnboarding { + prompts: self.prompts, + default_channel_ids, + enabled: self.enabled, + mode: self.mode, + audit_log_reason: self.audit_log_reason, + + _stage: NeedsEnabled, + } + } +} + +impl<'a> EditOnboarding<'a, NeedsEnabled> { + /// Whether onboarding is enabled or not. + pub fn enabled(self, enabled: bool) -> EditOnboarding<'a, NeedsMode> { + EditOnboarding { + prompts: self.prompts, + default_channel_ids: self.default_channel_ids, + enabled, + mode: self.mode, + audit_log_reason: self.audit_log_reason, + + _stage: NeedsMode, + } + } +} + +impl<'a> EditOnboarding<'a, NeedsMode> { + /// The current onboarding mode that controls where the readable channels are set. + /// + /// If the default mode is set, you must provide at least 7 channels, 5 of which must allow + /// @everyone to read and send messages. if advanced is set, the restrictions apply across the + /// default channels and the [Self::prompts], provided that they supply the remaining required + /// channels. + pub fn mode(self, mode: OnboardingMode) -> EditOnboarding<'a, Ready> { + EditOnboarding { + prompts: self.prompts, + default_channel_ids: self.default_channel_ids, + enabled: self.enabled, + mode, + audit_log_reason: self.audit_log_reason, + + _stage: Ready, + } + } +} + +impl<'a, Stage: Sealed> EditOnboarding<'a, Stage> { + /// Sets the request's audit log reason. + pub fn audit_log_reason(mut self, audit_log_reason: &'a str) -> Self { + self.audit_log_reason = Some(audit_log_reason); + self + } +} + +#[cfg(feature = "http")] +#[async_trait::async_trait] +impl<'a> Builder for EditOnboarding<'a, Ready> { + type Context<'ctx> = GuildId; + type Built = Onboarding; + + /// Sets [`Onboarding`] in the guild. + /// + /// **Note**: Requires the [Manage Roles] and [Manage Guild] permissions. + /// + /// # Errors + /// + /// If the `cache` is enabled, returns a [`ModelError::InvalidPermissions`] if the current user + /// lacks permission. Otherwise returns [`Error::Http`], as well as if invalid data is given. + /// + /// [Manage Roles]: Permissions::MANAGE_ROLES + /// [Manage Guild]: Permissions::MANAGE_GUILD + async fn execute( + mut self, + cache_http: impl CacheHttp, + ctx: Self::Context<'_>, + ) -> Result { + #[cfg(feature = "cache")] + crate::utils::user_has_guild_perms( + &cache_http, + ctx, + Permissions::MANAGE_GUILD | Permissions::MANAGE_ROLES, + )?; + + cache_http.http().set_guild_onboarding(ctx, &self, self.audit_log_reason).await + } +} diff --git a/src/builder/edit_onboarding/prompt_option_structure.rs b/src/builder/edit_onboarding/prompt_option_structure.rs new file mode 100644 index 00000000000..c893fe3160b --- /dev/null +++ b/src/builder/edit_onboarding/prompt_option_structure.rs @@ -0,0 +1,153 @@ +use crate::model::channel::ReactionType; +use crate::model::id::{ChannelId, RoleId}; + +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsChannels; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsRoles; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsTitle; +#[derive(serde::Serialize, Clone, Debug)] +pub struct Ready; + +mod sealed { + use super::*; + pub trait Sealed {} + + impl Sealed for NeedsChannels {} + impl Sealed for NeedsRoles {} + impl Sealed for NeedsTitle {} + impl Sealed for Ready {} +} + +use sealed::*; + +#[derive(Clone, Debug)] +#[must_use = "Builders do nothing unless built"] +pub struct CreatePromptOption { + channel_ids: Vec, + role_ids: Vec, + title: String, + description: Option, + emoji: Option, + _stage: Stage, +} + +impl Default for CreatePromptOption { + /// See the documentation of [`Self::new`]. + fn default() -> Self { + // Producing dummy values is okay as we must transition through all `Stage`s before firing, + // which fills in the values with real values. + Self { + channel_ids: Vec::new(), + role_ids: Vec::new(), + emoji: None, + title: String::new(), + description: None, + + _stage: NeedsChannels, + } + } +} + +impl CreatePromptOption { + pub fn new() -> Self { + Self::default() + } + + /// The channels that become visible when selecting this option. + /// + /// At least one channel or role must be selected or the option will not be valid. + pub fn channels(self, channel_ids: Vec) -> CreatePromptOption { + CreatePromptOption { + channel_ids, + role_ids: self.role_ids, + emoji: self.emoji, + title: self.title, + description: self.description, + + _stage: NeedsRoles, + } + } +} + +impl CreatePromptOption { + /// The roles granted from selecting this option. + /// + /// At least one channel or role must be selected or the option will not be valid. + pub fn roles(self, role_ids: Vec) -> CreatePromptOption { + CreatePromptOption { + channel_ids: self.channel_ids, + role_ids, + emoji: self.emoji, + title: self.title, + description: self.description, + + _stage: NeedsTitle, + } + } +} + +impl CreatePromptOption { + /// The title of the option. + pub fn title(self, title: impl Into) -> CreatePromptOption { + CreatePromptOption { + channel_ids: self.channel_ids, + role_ids: self.role_ids, + emoji: self.emoji, + title: title.into(), + description: self.description, + + _stage: Ready, + } + } +} + +impl CreatePromptOption { + /// The emoji to appear alongside the option. + pub fn emoji(mut self, emoji: ReactionType) -> Self { + self.emoji = Some(emoji); + self + } + /// The description of the option. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +use serde::ser::{Serialize, SerializeStruct, Serializer}; + +// This implementation allows us to put the emoji fields on without storing duplicate values. +impl Serialize for CreatePromptOption { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("CreatePromptOption", 4)?; + + state.serialize_field("channel_ids", &self.channel_ids)?; + state.serialize_field("role_ids", &self.role_ids)?; + state.serialize_field("title", &self.title)?; + state.serialize_field("description", &self.description)?; + + if let Some(ref emoji) = self.emoji { + match emoji { + ReactionType::Custom { + animated, + id, + name, + } => { + state.serialize_field("emoji_animated", animated)?; + state.serialize_field("emoji_id", id)?; + state.serialize_field("emoji_name", name)?; + }, + ReactionType::Unicode(name) => { + state.serialize_field("emoji_name", name)?; + }, + } + } + + state.end() + } +} diff --git a/src/builder/edit_onboarding/prompt_structure.rs b/src/builder/edit_onboarding/prompt_structure.rs new file mode 100644 index 00000000000..9e1cf52e68a --- /dev/null +++ b/src/builder/edit_onboarding/prompt_structure.rs @@ -0,0 +1,183 @@ +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsPromptType; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsPromptOptions; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsTitle; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsSingleSelect; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsRequired; +#[derive(serde::Serialize, Clone, Debug)] +pub struct NeedsInOnboarding; +#[derive(serde::Serialize, Clone, Debug)] +pub struct Ready; + +use super::prompt_option_structure::{self, CreatePromptOption}; + +mod sealed { + use super::*; + pub trait Sealed {} + + impl Sealed for NeedsPromptType {} + impl Sealed for NeedsPromptOptions {} + impl Sealed for NeedsTitle {} + impl Sealed for NeedsSingleSelect {} + impl Sealed for NeedsRequired {} + impl Sealed for NeedsInOnboarding {} + impl Sealed for Ready {} +} + +use sealed::*; + +use crate::all::OnboardingPromptType; + +#[derive(serde::Serialize, Clone, Debug)] +#[must_use = "Builders do nothing unless built"] +pub struct CreateOnboardingPrompt { + // we must provide an ID because of a discord bug. + // see https://github.com/discord/discord-api-docs/issues/6320 + id: u64, + prompt_type: OnboardingPromptType, + options: Vec>, + title: String, + single_select: bool, + required: bool, + in_onboarding: bool, + + #[serde(skip)] + _stage: Stage, +} + +impl Default for CreateOnboardingPrompt { + /// See the documentation of [`Self::new`]. + fn default() -> Self { + // Producing dummy values is okay as we must transition through all `Stage`s before firing, + // which fills in the values with real values. + Self { + id: 0, + prompt_type: OnboardingPromptType::Dropdown, + options: Vec::new(), + title: String::new(), + single_select: true, + required: true, + in_onboarding: true, + + _stage: NeedsPromptType, + } + } +} + +impl CreateOnboardingPrompt { + pub fn new() -> Self { + Self::default() + } + + /// The type of prompt provided to the user. + pub fn prompt_type( + self, + prompt_type: OnboardingPromptType, + ) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type, + options: self.options, + title: self.title, + single_select: self.single_select, + required: self.required, + in_onboarding: self.in_onboarding, + + _stage: NeedsPromptOptions, + } + } +} + +impl CreateOnboardingPrompt { + /// The options users can select for the prompt. + /// + /// Each option must provide at least one role or channel. + pub fn options( + self, + options: Vec>, + ) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type: self.prompt_type, + options, + title: self.title, + single_select: self.single_select, + required: self.required, + in_onboarding: self.in_onboarding, + + _stage: NeedsTitle, + } + } +} + +impl CreateOnboardingPrompt { + /// Sets the title of the prompt. + pub fn title(self, title: impl Into) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type: self.prompt_type, + options: self.options, + title: title.into(), + single_select: self.single_select, + required: self.required, + in_onboarding: self.in_onboarding, + + _stage: NeedsSingleSelect, + } + } +} + +impl CreateOnboardingPrompt { + /// Controls if the user can select multiple options of the prompt. + pub fn single_select(self, single_select: bool) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type: self.prompt_type, + options: self.options, + title: self.title, + single_select, + required: self.required, + in_onboarding: self.in_onboarding, + + _stage: NeedsRequired, + } + } +} + +impl CreateOnboardingPrompt { + /// Controls if the user is required to answer the question before completing onboarding. + pub fn required(self, required: bool) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type: self.prompt_type, + options: self.options, + title: self.title, + single_select: self.single_select, + required, + in_onboarding: self.in_onboarding, + + _stage: NeedsInOnboarding, + } + } +} + +impl CreateOnboardingPrompt { + /// Controls if the prompt is visible in onboarding, or only in the Channels & Roles tab. + pub fn in_onboarding(self, in_onboarding: bool) -> CreateOnboardingPrompt { + CreateOnboardingPrompt { + id: self.id, + prompt_type: self.prompt_type, + options: self.options, + title: self.title, + single_select: self.single_select, + required: self.required, + in_onboarding, + + _stage: Ready, + } + } +} diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 52b89e43fde..68a4dbbdddc 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -65,6 +65,7 @@ mod edit_guild_widget; mod edit_interaction_response; mod edit_member; mod edit_message; +pub mod edit_onboarding; mod edit_profile; mod edit_role; mod edit_scheduled_event; @@ -106,6 +107,7 @@ pub use edit_guild_widget::*; pub use edit_interaction_response::*; pub use edit_member::*; pub use edit_message::*; +pub use edit_onboarding::{CreateOnboardingPrompt, CreatePromptOption, EditOnboarding}; pub use edit_profile::*; pub use edit_role::*; pub use edit_scheduled_event::*; diff --git a/src/http/client.rs b/src/http/client.rs index 6df6ce84838..044ec39c891 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -4671,6 +4671,43 @@ impl Http { .await } + /// Returns the Onboarding object for the guild. + pub async fn get_guild_onboarding(&self, guild_id: GuildId) -> Result { + self.fire(Request { + body: None, + multipart: None, + headers: None, + method: LightMethod::Get, + route: Route::GuildOnboarding { + guild_id, + }, + params: None, + }) + .await + } + + /// Sets the onboarding configuration for the guild. + pub async fn set_guild_onboarding( + &self, + guild_id: GuildId, + map: &impl serde::Serialize, + audit_log_reason: Option<&str>, + ) -> Result { + let body = to_vec(map)?; + + self.fire(Request { + body: Some(body), + multipart: None, + headers: audit_log_reason.map(reason_into_header), + method: LightMethod::Put, + route: Route::GuildOnboarding { + guild_id, + }, + params: None, + }) + .await + } + /// Fires off a request, deserializing the response reader via the given type bound. /// /// If you don't need to deserialize the response and want the response instance itself, use diff --git a/src/http/routing.rs b/src/http/routing.rs index 13e047a47be..caa7a7bdb0f 100644 --- a/src/http/routing.rs +++ b/src/http/routing.rs @@ -210,6 +210,10 @@ routes! ('a, { api!("/guilds/{}/audit-logs", guild_id), Some(RatelimitingKind::PathAndId(guild_id.into())); + GuildOnboarding { guild_id: GuildId }, + api!("/guilds/{}/onboarding", guild_id), + Some(RatelimitingKind::PathAndId(guild_id.into())); + GuildAutomodRule { guild_id: GuildId, rule_id: RuleId }, api!("/guilds/{}/auto-moderation/rules/{}", guild_id, rule_id), Some(RatelimitingKind::PathAndId(guild_id.into())); diff --git a/src/model/guild/mod.rs b/src/model/guild/mod.rs index 8be9e25e14e..b2df6f2fe8b 100644 --- a/src/model/guild/mod.rs +++ b/src/model/guild/mod.rs @@ -7,6 +7,7 @@ mod guild_id; mod guild_preview; mod integration; mod member; +mod onboarding; mod partial_guild; mod premium_tier; mod role; @@ -25,6 +26,7 @@ pub use self::guild_id::*; pub use self::guild_preview::*; pub use self::integration::*; pub use self::member::*; +pub use self::onboarding::*; pub use self::partial_guild::*; pub use self::premium_tier::*; pub use self::role::*; diff --git a/src/model/guild/onboarding.rs b/src/model/guild/onboarding.rs new file mode 100644 index 00000000000..d35511c65d3 --- /dev/null +++ b/src/model/guild/onboarding.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Deserializer}; + +use crate::all::ReactionType; +use crate::model::id::{ChannelId, EmojiId, GenericId, GuildId, RoleId}; + +/// Onboarding information for a [`Guild`]. +/// +/// [Discord docs](https://discord.com/developers/docs/resources/guild#guild-onboarding-object). +/// +/// [`Guild`]: super::Guild +#[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] +pub struct Onboarding { + /// The unique Id of the guild that this object belongs to. + pub guild_id: GuildId, + /// A list of prompts associated with the onboarding process. + pub prompts: Vec, + /// If onboarding is enabled, these channels will be visible by the user regardless of what + /// they select in onboarding. + pub default_channel_ids: Vec, + /// Controls if onboarding is enabled, if onboarding is disabled, onboarding requirements are + /// not applied. + pub enabled: bool, + /// Specifies the behaviour of onboarding. + pub mode: OnboardingMode, +} + +/// An onboarding prompt, otherwise known as a question. +/// +/// At least one option is required, and there could be up to 50 options. +/// +/// [Discord docs](https://discord.com/developers/docs/resources/guild#guild-onboarding-object). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] +pub struct OnboardingPrompt { + /// The unique Id that references this prompt. + pub id: GenericId, + /// The type of onboarding prompt. + #[serde(rename = "type")] + pub prompt_type: OnboardingPromptType, + /// The list of options that users can select. + pub options: Vec, + /// The title of the prompt. + pub title: String, + /// Controls if the user can select multiple options. + pub single_select: bool, + /// Controls if the prompt must be answered before onboarding can finish. + pub required: bool, + /// Controls if this prompt is visible in onboarding or only in the Channels & Roles tab. + pub in_onboarding: bool, +} + +enum_number! { + /// [Discord docs](https://discord.com/developers/docs/resources/guild#guild-onboarding-object-prompt-types). + #[derive(Debug, Clone, Deserialize, Serialize)] + #[serde(from = "u8", into = "u8")] + #[non_exhaustive] + pub enum OnboardingPromptType { + MultipleChoice = 0, + Dropdown = 1, + _ => Unknown(u8), + } +} + +/// An option, otherwise known as an answer, for an onboarding prompt. +/// +/// An answer must provide at least 1 channel or role to be visible. +/// +/// [Discord docs](https://discord.com/developers/docs/resources/guild#guild-onboarding-object-prompt-option-structure). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] +pub struct OnboardingPromptOption { + /// The unique Id that references this option. + pub id: GenericId, + /// The list of channels that will be provided to the user if this option is picked. + pub channel_ids: Vec, + /// The list of roles that will be provided to the user if this option is picked. + pub role_ids: Vec, + /// The reaction that will be visible next to the option. + // This deserializes another way because discord sends a silly object instead of null. + #[serde(default, deserialize_with = "onboarding_reaction")] + pub emoji: Option, + /// The title of the option. + pub title: String, + /// The optional description for this option. + pub description: Option, +} + +enum_number! { + /// Defines the criteria used to satisfy Onboarding constraints that are required for enabling. + /// + /// Currently only controls what channels count towards the constraints for enabling. + /// + /// [Discord docs](https://discord.com/developers/docs/resources/guild#guild-onboarding-object-onboarding-mode). + #[derive(Default, Debug, Clone, Deserialize, Serialize)] + #[serde(from = "u8", into = "u8")] + #[non_exhaustive] + pub enum OnboardingMode { + /// You must provide at least 7 default channels, 5 of which must allow @everyone to read + /// and send messages. + #[default] + OnboardingDefault = 0, + /// The above constraints are split between the default channels and the ones provided by + /// prompt options. + OnboardingAdvanced = 1, + _ => Unknown(u8), + } +} + +/// This exists to handle the weird case where discord decides to send every field as null +/// instead of sending the emoji as null itself. +fn onboarding_reaction<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + struct PartialEmoji { + #[serde(default)] + animated: bool, + id: Option, + name: Option, + } + let emoji = PartialEmoji::deserialize(deserializer)?; + Ok(match (emoji.id, emoji.name) { + (Some(id), name) => Some(ReactionType::Custom { + animated: emoji.animated, + id, + name, + }), + (None, Some(name)) => Some(ReactionType::Unicode(name)), + (None, None) => return Ok(None), + }) +}