diff --git a/twilight-http/src/client/mod.rs b/twilight-http/src/client/mod.rs index 0a704a0d36..7e0b87dcc1 100644 --- a/twilight-http/src/client/mod.rs +++ b/twilight-http/src/client/mod.rs @@ -15,7 +15,10 @@ use crate::request::{ GetEntitlements, GetSKUs, }, }, - guild::user::{GetCurrentUserVoiceState, GetUserVoiceState}, + guild::{ + screening::{GetGuildJoinRequests, UpdateGuildJoinRequest}, + user::{GetCurrentUserVoiceState, GetUserVoiceState}, + }, }; #[allow(deprecated)] use crate::{ @@ -119,15 +122,15 @@ use twilight_model::{ channel::{ChannelType, message::AllowedMentions}, guild::{ MfaLevel, RolePosition, auto_moderation::AutoModerationEventType, - scheduled_event::PrivacyLevel, + scheduled_event::PrivacyLevel, screening::JoinRequestStatus, }, http::{channel_position::Position, permission_overwrite::PermissionOverwrite}, id::{ Id, marker::{ ApplicationMarker, AutoModerationRuleMarker, ChannelMarker, EmojiMarker, - EntitlementMarker, GuildMarker, IntegrationMarker, MessageMarker, RoleMarker, - ScheduledEventMarker, SkuMarker, StickerMarker, UserMarker, WebhookMarker, + EntitlementMarker, GuildMarker, IntegrationMarker, JoinRequestMarker, MessageMarker, + RoleMarker, ScheduledEventMarker, SkuMarker, StickerMarker, UserMarker, WebhookMarker, }, }, }; @@ -1062,6 +1065,59 @@ impl Client { GetGuildInvites::new(self, guild_id) } + /// List join requests for guild, optionally filtered by application status. + /// + /// Requires the [`MANAGE_GUILD`] permission. + /// + /// [`MANAGE_GUILD`]: twilight_model::guild::Permissions::MANAGE_GUILD + pub const fn get_guild_join_requests( + &self, + guild_id: Id, + status: Option, + limit: Option, + before: Option>, + after: Option>, + ) -> GetGuildJoinRequests<'_> { + GetGuildJoinRequests::new(self, guild_id, status, limit, before, after) + } + + /// Approve or reject guild join request. + /// + /// Requires the [`KICK_MEMBERS`] permission. + /// + /// # Examples + /// + /// ```no_run + /// use twilight_http::Client; + /// use twilight_model::id::Id; + /// + /// # #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = Client::new("token".to_owned()); + /// + /// let guild_id = Id::new(101); + /// let request_id = Id::new(102); + /// let new_status = JoinRequestStatus::Approved; + /// let application = client.update_guild_join_request(guild_id, request_id, new_status).await?.model().await?; + /// + /// if new_status == application.application_status { + /// println!("User approved"); + /// } else { + /// println!("Failed to approve user"); + /// } + /// # Ok(()) } + /// ``` + /// + /// [`KICK_MEMBERS`]: twilight_model::guild::Permissions::KICK_MEMBERS + pub const fn update_guild_join_request( + &self, + guild_id: Id, + request_id: Id, + new_status: JoinRequestStatus, + ) -> UpdateGuildJoinRequest<'_> { + UpdateGuildJoinRequest::new(self, guild_id, request_id, new_status) + } + /// Update a guild's MFA level. pub const fn update_guild_mfa( &self, diff --git a/twilight-http/src/request/guild/mod.rs b/twilight-http/src/request/guild/mod.rs index df26f30544..7b5892298f 100644 --- a/twilight-http/src/request/guild/mod.rs +++ b/twilight-http/src/request/guild/mod.rs @@ -4,6 +4,7 @@ pub mod emoji; pub mod integration; pub mod member; pub mod role; +pub mod screening; pub mod sticker; pub mod update_guild_channel_positions; pub mod update_guild_onboarding; diff --git a/twilight-http/src/request/guild/screening/get_guild_join_requests.rs b/twilight-http/src/request/guild/screening/get_guild_join_requests.rs new file mode 100644 index 0000000000..af9a2019f5 --- /dev/null +++ b/twilight-http/src/request/guild/screening/get_guild_join_requests.rs @@ -0,0 +1,84 @@ +use std::future::IntoFuture; + +use twilight_model::{ + guild::screening::{JoinRequestList, JoinRequestStatus}, + id::{ + Id, + marker::{GuildMarker, JoinRequestMarker}, + }, +}; + +use crate::{ + Client, Error, Response, + request::{Request, TryIntoRequest}, + response::ResponseFuture, + routing::Route, +}; + +/// List join requests for guild, optionally filtered by application status. +/// +/// Requires the [`MANAGE_GUILD`] permission. +/// +/// [`MANAGE_GUILD`]: twilight_model::guild::Permissions::MANAGE_GUILD +pub struct GetGuildJoinRequests<'a> { + /// Only return requests newer than the request specified. + after: Option>, + /// Only return requests older than the request specified. + before: Option>, + /// ID of the guild. + guild_id: Id, + http: &'a Client, + /// Maximum number of requests to return. + limit: Option, + /// Only return requests with this status. + status: Option, +} + +impl<'a> GetGuildJoinRequests<'a> { + pub(crate) const fn new( + http: &'a Client, + guild_id: Id, + status: Option, + limit: Option, + before: Option>, + after: Option>, + ) -> Self { + Self { + after, + before, + guild_id, + http, + limit, + status, + } + } +} + +impl IntoFuture for GetGuildJoinRequests<'_> { + type Output = Result, Error>; + + type IntoFuture = ResponseFuture; + + fn into_future(self) -> Self::IntoFuture { + let http = self.http; + + match self.try_into_request() { + Ok(request) => http.request(request), + Err(source) => ResponseFuture::error(source), + } + } +} + +impl TryIntoRequest for GetGuildJoinRequests<'_> { + fn try_into_request(self) -> Result { + let request = Request::from_route(&Route::GetGuildJoinRequests { + guild_id: self.guild_id.get(), + status: self.status, + limit: self.limit, + before: self.before.map(Id::get), + after: self.after.map(Id::get), + }); + + Ok(request) + } +} diff --git a/twilight-http/src/request/guild/screening/mod.rs b/twilight-http/src/request/guild/screening/mod.rs new file mode 100644 index 0000000000..bb79fc9a37 --- /dev/null +++ b/twilight-http/src/request/guild/screening/mod.rs @@ -0,0 +1,7 @@ +mod get_guild_join_requests; +mod update_guild_join_request; + +pub use self::{ + get_guild_join_requests::GetGuildJoinRequests, + update_guild_join_request::UpdateGuildJoinRequest, +}; diff --git a/twilight-http/src/request/guild/screening/update_guild_join_request.rs b/twilight-http/src/request/guild/screening/update_guild_join_request.rs new file mode 100644 index 0000000000..e608b83643 --- /dev/null +++ b/twilight-http/src/request/guild/screening/update_guild_join_request.rs @@ -0,0 +1,140 @@ +use std::future::IntoFuture; + +use serde::Serialize; +use twilight_model::{ + guild::screening::{JoinRequest, JoinRequestStatus}, + id::{ + Id, + marker::{GuildMarker, JoinRequestMarker}, + }, +}; + +use crate::{ + Client, Error, Response, + request::{Request, TryIntoRequest}, + response::ResponseFuture, + routing::Route, +}; + +/// Approve or reject guild join request. +/// +/// Requires the [`KICK_MEMBERS`] permission. +/// +/// # Examples +/// +/// ```no_run +/// use twilight_http::Client; +/// use twilight_model::id::Id; +/// +/// # #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = Client::new("token".to_owned()); +/// +/// let guild_id = Id::new(101); +/// let request_id = Id::new(102); +/// let new_status = JoinRequestStatus::Approved; +/// let application = client.update_guild_join_request(guild_id, request_id, new_status).await?.model().await?; +/// +/// if new_status == application.application_status { +/// println!("User approved"); +/// } else { +/// println!("Failed to approve user"); +/// } +/// # Ok(()) } +/// ``` +/// +/// [`KICK_MEMBERS`]: twilight_model::guild::Permissions::KICK_MEMBERS +#[must_use = "application_status must be checked. Approving an already denied join request (and vice versa) can yield a success response, despite no change being applied."] +pub struct UpdateGuildJoinRequest<'a> { + /// ID of the guild. + guild_id: Id, + http: &'a Client, + /// ID of the join request. + request_id: Id, + // The new request status. + new_status: JoinRequestStatus, +} + +impl<'a> UpdateGuildJoinRequest<'a> { + pub(crate) const fn new( + http: &'a Client, + guild_id: Id, + request_id: Id, + new_status: JoinRequestStatus, + ) -> Self { + Self { + guild_id, + http, + request_id, + new_status, + } + } +} + +impl IntoFuture for UpdateGuildJoinRequest<'_> { + type Output = Result, Error>; + + type IntoFuture = ResponseFuture; + + fn into_future(self) -> Self::IntoFuture { + let http = self.http; + + match self.try_into_request() { + Ok(request) => http.request(request), + Err(source) => ResponseFuture::error(source), + } + } +} + +#[derive(Serialize)] +struct StatusPatch { + action: JoinRequestStatus, +} + +impl TryIntoRequest for UpdateGuildJoinRequest<'_> { + fn try_into_request(self) -> Result { + Request::builder(&Route::UpdateGuildJoinRequest { + guild_id: self.guild_id.get(), + request_id: self.request_id.get(), + }) + .json(&StatusPatch { + action: self.new_status, + }) + .build() + } +} + +#[cfg(test)] +mod tests { + use super::{JoinRequestStatus, StatusPatch, UpdateGuildJoinRequest}; + use crate::{ + Client, + request::{Request, TryIntoRequest}, + routing::Route, + }; + use std::error::Error; + use twilight_model::id::Id; + + #[test] + fn request() -> Result<(), Box> { + let client = Client::new("token".to_string()); + let guild_id = Id::new(101); + let request_id = Id::new(102); + let new_status = JoinRequestStatus::Approved; + + let actual = UpdateGuildJoinRequest::new(&client, guild_id, request_id, new_status) + .try_into_request()?; + + let expected = Request::builder(&Route::UpdateGuildJoinRequest { + guild_id: guild_id.into(), + request_id: request_id.into(), + }) + .json(&StatusPatch { action: new_status }) + .build()?; + + assert_eq!(expected.body(), actual.body()); + assert_eq!(expected.path(), actual.path()); + + Ok(()) + } +} diff --git a/twilight-http/src/request/try_into_request.rs b/twilight-http/src/request/try_into_request.rs index fdd0664a1f..67bae42770 100644 --- a/twilight-http/src/request/try_into_request.rs +++ b/twilight-http/src/request/try_into_request.rs @@ -79,6 +79,7 @@ mod private { CreateRole, DeleteRole, GetGuildRoleMemberCounts, GetGuildRoles, GetRole, UpdateRole, UpdateRolePositions, }, + screening::{GetGuildJoinRequests, UpdateGuildJoinRequest}, sticker::{ CreateGuildSticker, DeleteGuildSticker, GetGuildSticker, GetGuildStickers, UpdateGuildSticker, @@ -218,6 +219,7 @@ mod private { impl Sealed for GetGuildCommands<'_> {} impl Sealed for GetGuildIntegrations<'_> {} impl Sealed for GetGuildInvites<'_> {} + impl Sealed for GetGuildJoinRequests<'_> {} impl Sealed for GetGuildMembers<'_> {} impl Sealed for GetGuildOnboarding<'_> {} impl Sealed for GetGuildPreview<'_> {} @@ -282,6 +284,7 @@ mod private { impl Sealed for UpdateGuild<'_> {} impl Sealed for UpdateGuildChannelPositions<'_> {} impl Sealed for UpdateGuildCommand<'_> {} + impl Sealed for UpdateGuildJoinRequest<'_> {} impl Sealed for UpdateGuildMember<'_> {} impl Sealed for UpdateGuildMfa<'_> {} impl Sealed for UpdateGuildOnboarding<'_> {} diff --git a/twilight-http/src/routing.rs b/twilight-http/src/routing.rs index 212303a893..791ec36731 100644 --- a/twilight-http/src/routing.rs +++ b/twilight-http/src/routing.rs @@ -5,9 +5,12 @@ use crate::{ request::{Method, channel::reaction::RequestReactionType}, }; use std::fmt::{Display, Formatter, Result as FmtResult}; -use twilight_model::id::{ - Id, - marker::{RoleMarker, SkuMarker}, +use twilight_model::{ + guild::screening::JoinRequestStatus, + id::{ + Id, + marker::{RoleMarker, SkuMarker}, + }, }; #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -615,6 +618,19 @@ pub enum Route<'a> { /// The ID of the guild. guild_id: u64, }, + /// Route information to get a guild's join requests. + GetGuildJoinRequests { + /// Only return requests newer than the request specified. + after: Option, + /// Only return requests older than the request specified. + before: Option, + /// ID of the guild. + guild_id: u64, + /// Maximum number of requests to return. + limit: Option, + /// Only return requests with this status. + status: Option, + }, /// Route information to get a guild's members. GetGuildMembers { /// The minimum ID of members to get. @@ -1141,6 +1157,13 @@ pub enum Route<'a> { /// The token for the interaction. interaction_token: &'a str, }, + /// Route information to update a guild's join requests. + UpdateGuildJoinRequest { + /// ID of the guild. + guild_id: u64, + /// ID of the join request. + request_id: u64, + }, /// Route information to update a member. UpdateMember { /// The ID of the guild. @@ -1303,6 +1326,7 @@ impl Route<'_> { | Self::GetGuildCommands { .. } | Self::GetGuildIntegrations { .. } | Self::GetGuildInvites { .. } + | Self::GetGuildJoinRequests { .. } | Self::GetGuildMembers { .. } | Self::GetGuildOnboarding { .. } | Self::GetGuildPreview { .. } @@ -1362,6 +1386,7 @@ impl Route<'_> { | Self::UpdateGuildMfa { .. } | Self::UpdateGuildWidgetSettings { .. } | Self::UpdateGuildIntegration { .. } + | Self::UpdateGuildJoinRequest { .. } | Self::UpdateGuildScheduledEvent { .. } | Self::UpdateGuildSticker { .. } | Self::UpdateGuildWelcomeScreen { .. } @@ -2282,6 +2307,25 @@ impl Display for Route<'_> { f.write_str("/invites") } + Route::GetGuildJoinRequests { + after, + before, + guild_id, + limit, + status, + } => { + f.write_str("guilds/")?; + Display::fmt(guild_id, f)?; + + f.write_str("/requests")?; + + let mut query_formatter = QueryStringFormatter::new(f); + + query_formatter.write_opt_param("after", after.as_ref())?; + query_formatter.write_opt_param("before", before.as_ref())?; + query_formatter.write_opt_param("limit", limit.as_ref())?; + query_formatter.write_opt_param("status", status.as_ref()) + } Route::GetGuildMembers { after, guild_id, @@ -2726,6 +2770,16 @@ impl Display for Route<'_> { Display::fmt(user_id, f) } + Route::UpdateGuildJoinRequest { + guild_id, + request_id, + } => { + f.write_str("guilds/")?; + Display::fmt(guild_id, f)?; + + f.write_str("/requests/")?; + Display::fmt(request_id, f) + } Route::UpdateGuildMfa { guild_id, .. } => { f.write_str("guilds/")?; Display::fmt(guild_id, f)?; @@ -3924,6 +3978,21 @@ mod tests { assert_eq!(route.to_string(), format!("guilds/{GUILD_ID}/invites")); } + #[test] + fn get_guild_join_requests() { + let route = Route::GetGuildJoinRequests { + guild_id: GUILD_ID, + after: Some(123), + before: Some(456), + limit: Some(21), + status: Some(twilight_model::guild::screening::JoinRequestStatus::Submitted), + }; + assert_eq!( + route.to_string(), + format!("guilds/{GUILD_ID}/requests?after=123&before=456&limit=21&status=SUBMITTED") + ); + } + #[test] fn get_guild_preview() { let route = Route::GetGuildPreview { guild_id: GUILD_ID }; diff --git a/twilight-model/src/guild/mod.rs b/twilight-model/src/guild/mod.rs index c1bf503a62..e780fbfea0 100644 --- a/twilight-model/src/guild/mod.rs +++ b/twilight-model/src/guild/mod.rs @@ -10,6 +10,7 @@ pub mod auto_moderation; pub mod invite; pub mod onboarding; pub mod scheduled_event; +pub mod screening; pub mod template; pub mod widget; diff --git a/twilight-model/src/guild/screening/application_field.rs b/twilight-model/src/guild/screening/application_field.rs new file mode 100644 index 0000000000..96c950b7bd --- /dev/null +++ b/twilight-model/src/guild/screening/application_field.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +/// Field where applicant selects one of many options. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct MultipleChoiceFieldResponse { + /// Choices applicant can select from. + pub choices: Vec, + /// Optional helper text shown below label. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Label shown above field. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Whether applicant must fill in field. + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + /// Index of choice selected by applicant. + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// A text input field. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct TextFieldResponse { + /// Optional helper text shown below label. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Label shown above field. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Placeholder text shown in empty input + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + /// Whether applicant must fill in field. + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + /// Applicant's text response + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// A terms acceptance field. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct TermsFieldResponse { + /// Terms applicant must acknowledge. + pub values: Vec, + /// Optional helper text shown below label. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Label shown above field. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Placeholder text shown in empty input + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + /// Whether applicant must fill in field. + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + /// Whether applicant accepted terms + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// A field within a join application used for member screening. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(tag = "field_type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ApplicationFieldResponse { + /// A field that allows the user to select one of multiple options. + MultipleChoice(MultipleChoiceFieldResponse), + /// A large text field that allows the user to enter up to 1000 characters. + Paragraph(TextFieldResponse), + /// Field requiring applicant to acknowledge list of terms + Terms(TermsFieldResponse), + /// A small text field that allows the user to enter up to 150 characters. + TextInput(TextFieldResponse), +} diff --git a/twilight-model/src/guild/screening/mod.rs b/twilight-model/src/guild/screening/mod.rs new file mode 100644 index 0000000000..489a912b9e --- /dev/null +++ b/twilight-model/src/guild/screening/mod.rs @@ -0,0 +1,60 @@ +//! Types for guild screening. + +mod application_field; +mod request_status; + +use crate::{ + id::{ + Id, + marker::{GuildMarker, JoinRequestMarker, UserMarker}, + }, + user::User, + util::Timestamp, +}; +use serde::{Deserialize, Serialize}; + +pub use self::{ + application_field::{ApplicationFieldResponse, MultipleChoiceFieldResponse, TextFieldResponse}, + request_status::JoinRequestStatus, +}; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct JoinRequest { + pub application_status: JoinRequestStatus, + pub created_at: Timestamp, + /// Applicant's responses on join request form. + pub form_responses: Vec, + pub guild_id: Id, + pub id: Id, + /// Reason for rejection. Only used when action is REJECTED. + pub rejection_reason: Option, + pub reviewed_at: Option, + pub user_id: Id, + pub user: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct JoinRequestList { + /// The join requests, only returned with the `KICK_MEMBERS` permission + pub guild_join_requests: Vec, + /// Number of join requests with the given status, only returned when `status` is `SUBMITTED` or omitted. + /// Apps that only have `MANAGE_GUILD` receive the count of pending join requests without the requests themselves. + pub total: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + use std::{fmt::Debug, hash::Hash}; + + assert_impl_all!( + JoinRequestList: Clone, + Debug, + Eq, + Hash, + PartialEq, + Send, + Sync + ); +} diff --git a/twilight-model/src/guild/screening/request_status.rs b/twilight-model/src/guild/screening/request_status.rs new file mode 100644 index 0000000000..e65515667e --- /dev/null +++ b/twilight-model/src/guild/screening/request_status.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Status of a member guild application. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum JoinRequestStatus { + /// Join request approved + Approved, + /// Join request rejected + Rejected, + /// Applicant started but not yet submitted join request + Started, + /// Applicant submitted join request that is awaiting review + Submitted, +} + +impl std::fmt::Display for JoinRequestStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let string = match self { + Self::Approved => "APPROVED", + Self::Rejected => "REJECTED", + Self::Started => "STARTED", + Self::Submitted => "SUBMITTED", + }; + f.write_str(string) + } +} diff --git a/twilight-model/src/id/marker.rs b/twilight-model/src/id/marker.rs index d99741d350..88e4e0f8de 100644 --- a/twilight-model/src/id/marker.rs +++ b/twilight-model/src/id/marker.rs @@ -312,3 +312,12 @@ pub struct WebhookMarker; #[derive(Debug)] #[non_exhaustive] pub struct AvatarDecorationDataSkuMarker; + +/// Marker for guild member join requests. +/// +/// Types such as [`JoinRequest`] use this ID marker. +/// +/// [`JoinRequest`]: crate::guild::screening::JoinRequest +#[derive(Debug)] +#[non_exhaustive] +pub struct JoinRequestMarker;