From 64d106135307a4a27d4b8a02bf978419280f84a8 Mon Sep 17 00:00:00 2001 From: Kappeh Date: Sat, 8 Feb 2025 05:02:53 +0000 Subject: [PATCH] Update profile show and edit commands using database schema for profiles and discord interactions and modals --- .../src/repository/profile_repository.rs | 2 + cipher_discord_bot/src/commands/mod.rs | 1 - cipher_discord_bot/src/commands/profile.rs | 739 ++++++++++++++++++ .../src/commands/profile/codes.rs | 294 ------- .../src/commands/profile/edit.rs | 174 ----- .../src/commands/profile/mod.rs | 112 --- 6 files changed, 741 insertions(+), 581 deletions(-) create mode 100644 cipher_discord_bot/src/commands/profile.rs delete mode 100644 cipher_discord_bot/src/commands/profile/codes.rs delete mode 100644 cipher_discord_bot/src/commands/profile/edit.rs delete mode 100644 cipher_discord_bot/src/commands/profile/mod.rs diff --git a/cipher_core/src/repository/profile_repository.rs b/cipher_core/src/repository/profile_repository.rs index 1d9f478..2113459 100644 --- a/cipher_core/src/repository/profile_repository.rs +++ b/cipher_core/src/repository/profile_repository.rs @@ -99,6 +99,7 @@ pub trait ProfileRepository { async fn set_active_profile(&mut self, user_id: i32, profile_id: i32) -> Result>; } +#[derive(Debug, Clone)] pub struct Profile { pub id: i32, pub user_id: i32, @@ -122,6 +123,7 @@ pub struct Profile { pub is_active: bool, } +#[derive(Debug, Clone, Default)] pub struct NewProfile { pub user_id: i32, diff --git a/cipher_discord_bot/src/commands/mod.rs b/cipher_discord_bot/src/commands/mod.rs index e9b5596..a806d40 100644 --- a/cipher_discord_bot/src/commands/mod.rs +++ b/cipher_discord_bot/src/commands/mod.rs @@ -17,7 +17,6 @@ where pokeapi::pokeapi(), profile::profile(), profile::cmu_profile_show(), - profile::cmu_profile_edit(), ] } diff --git a/cipher_discord_bot/src/commands/profile.rs b/cipher_discord_bot/src/commands/profile.rs new file mode 100644 index 0000000..4759ada --- /dev/null +++ b/cipher_discord_bot/src/commands/profile.rs @@ -0,0 +1,739 @@ +use std::time::Duration; + +use cipher_core::repository::profile_repository::NewProfile; +use cipher_core::repository::profile_repository::Profile; +use cipher_core::repository::profile_repository::ProfileRepository; +use cipher_core::repository::user_repository::NewUser; +use cipher_core::repository::user_repository::UserRepository; +use cipher_core::repository::RepositoryProvider; +use poise::CreateReply; +use poise::ReplyHandle; +use serenity::all::ButtonStyle; +use serenity::all::Color; +use serenity::all::ComponentInteractionCollector; +use serenity::all::CreateActionRow; +use serenity::all::CreateButton; +use serenity::all::CreateEmbed; +use serenity::all::CreateEmbedAuthor; +use serenity::all::CreateInteractionResponse; +use serenity::all::Member; +use serenity::all::User; +use uuid::Uuid; + +use crate::app::AppContext; +use crate::app::AppError; + +/// Edit and show profiles. +#[poise::command( + slash_command, + subcommands( + "edit", + "overwrite", + "show", + ), +)] +pub async fn profile( + _ctx: AppContext<'_, R, R::BackendError>, +) -> Result<(), AppError> { + Ok(()) +} + +#[poise::command( + context_menu_command = "Show User Profile", + guild_only, +)] +pub async fn cmu_profile_show( + ctx: AppContext<'_, R, R::BackendError>, + user: User, +) -> Result<(), AppError> { + let guild = match ctx.guild_id() { + Some(guild) => guild, + None => return Ok(()), + }; + + let member = guild.member(ctx, user.id).await?; + + show_inner(ctx, member, true).await +} + +/// Show your profile or someone else's. +#[poise::command( + slash_command, + guild_only, +)] +async fn show( + ctx: AppContext<'_, R, R::BackendError>, + #[rename = "member"] + #[description = "The profile to show."] + option_member: Option, + #[description = "Hide reply from other users. Defaults to True."] + ephemeral: Option, +) -> Result<(), AppError> { + let member = match option_member { + Some(member) => member, + None => ctx.author_member().await.ok_or(AppError::UnknownCacheOrHttpError)?.into_owned(), + }; + + show_inner(ctx, member, ephemeral.unwrap_or(true)).await +} + +#[poise::command( + slash_command, + guild_only, +)] +async fn edit(ctx: AppContext<'_, R, R::BackendError>) -> Result<(), AppError> { + let member = match ctx.author_member().await { + Some(member) => member, + None => { + let embed = CreateEmbed::new() + .title("Guild Only Command") + .description("This command can only be used in guilds.") + .color(crate::utils::bot_color(&ctx).await); + + let reply = CreateReply::default() + .embed(embed) + .ephemeral(true); + + ctx.send(reply).await?; + + return Ok(()); + }, + }; + + edit_inner(ctx, member.into_owned()).await?; + + Ok(()) +} + + +#[poise::command( + slash_command, + guild_only, + hide_in_help, + check = "crate::checks::is_staff", +)] +async fn overwrite( + ctx: AppContext<'_, R, R::BackendError>, + member: Member, +) -> Result<(), AppError> { + edit_inner(ctx, member).await?; + + Ok(()) +} + +async fn show_inner(ctx: AppContext<'_, R, R::BackendError>, member: Member, ephemeral: bool) -> Result<(), AppError> +where + R: RepositoryProvider + Send + Sync, +{ + let mut repo = ctx.data.repository().await?; + + let option_profile = repo.active_profile_by_discord_id(member.user.id.get()).await?; + let embed = ProfileEmbed::from_profile(&ctx, &member, option_profile.as_ref()).await.into_embed(); + + let reply = CreateReply::default() + .embed(embed) + .ephemeral(ephemeral); + + ctx.send(reply).await?; + + Ok(()) +} + +async fn edit_inner(ctx: AppContext<'_, R, R::BackendError>, member: Member) -> Result<(), AppError> +where + R: RepositoryProvider + Send + Sync, +{ + let mut repo = ctx.data.repository().await?; + + let mut option_reply_handle: Option = None; + let mut option_profile = repo.active_profile_by_discord_id(member.user.id.get()).await?.map(Profile::into_new); + + 'update_reply: loop { + let embed = ProfileEmbed::from_new_profile(&ctx, &member, option_profile.as_ref()).await.into_embed(); + + let pokemon_info_button_id = Uuid::new_v4().to_string(); + let personal_info_button_id = Uuid::new_v4().to_string(); + let friend_codes_button_id = Uuid::new_v4().to_string(); + let images_button_id = Uuid::new_v4().to_string(); + let save_button_id = Uuid::new_v4().to_string(); + let buttons = CreateActionRow::Buttons(vec![ + CreateButton::new(&pokemon_info_button_id).label("Edit Pokémon Info").style(ButtonStyle::Secondary), + CreateButton::new(&personal_info_button_id).label("Edit Personal Info").style(ButtonStyle::Secondary), + CreateButton::new(&friend_codes_button_id).label("Edit Friend Codes").style(ButtonStyle::Secondary), + CreateButton::new(&images_button_id).label("Edit Images").style(ButtonStyle::Secondary), + CreateButton::new(&save_button_id).label("Save").style(ButtonStyle::Primary), + ]); + + let reply = CreateReply::default() + .embed(embed) + .components(vec![buttons]) + .ephemeral(true); + + let reply_handle = match option_reply_handle { + Some(reply_handle) => { + reply_handle.edit(ctx.into(), reply).await?; + reply_handle + }, + None => ctx.send(reply).await?, + }; + + 'interaction_response: loop { + let collector = ComponentInteractionCollector::new(ctx) + .author_id(ctx.author().id) + .channel_id(ctx.channel_id()) + .timeout(Duration::from_secs(60)); + + let mci = match collector.await { + Some(mci) => mci, + None => { + let embed = CreateEmbed::new() + .title("Editor Timed Out") + .description("Your changes have not been saved. Please use `/profile edit` again to continue.") + .color(crate::utils::bot_color(&ctx).await); + + let reply = CreateReply::default() + .embed(embed) + .components(vec![]) + .ephemeral(true); + + reply_handle.edit(ctx.into(), reply).await?; + + break 'update_reply; + }, + }; + + if mci.data.custom_id == pokemon_info_button_id { + let option_defaults = option_profile.clone().map(|profile| EditPokemonInfoModal { + trainer_class: profile.trainer_class, + nature: profile.nature, + partner_pokemon: profile.partner_pokemon, + starting_region: profile.starting_region, + }); + + let data = match poise::execute_modal_on_component_interaction(ctx, mci.clone(), option_defaults, None).await? { + Some(data) => data, + None => continue 'interaction_response, + }; + + let profile = match option_profile { + Some(mut profile) => { + profile.trainer_class = data.trainer_class; + profile.nature = data.nature; + profile.partner_pokemon = data.partner_pokemon; + profile.starting_region = data.starting_region; + profile + }, + None => NewProfile { + trainer_class: data.trainer_class, + nature: data.nature, + partner_pokemon: data.partner_pokemon, + starting_region: data.starting_region, + ..Default::default() + }, + }; + + option_profile = Some(profile); + break 'interaction_response; + } + + if mci.data.custom_id == personal_info_button_id { + let option_defaults = option_profile.clone().map(|profile| EditPersonalInfoModal { + favourite_food: profile.favourite_food, + likes: profile.likes, + quotes: profile.quotes, + }); + + let data = match poise::execute_modal_on_component_interaction(ctx, mci.clone(), option_defaults, None).await? { + Some(data) => data, + None => continue 'interaction_response, + }; + + let profile = match option_profile { + Some(mut profile) => { + profile.favourite_food = data.favourite_food; + profile.likes = data.likes; + profile.quotes = data.quotes; + profile + }, + None => NewProfile { + favourite_food: data.favourite_food, + likes: data.likes, + quotes: data.quotes, + ..Default::default() + }, + }; + + option_profile = Some(profile); + break 'interaction_response; + } + + if mci.data.custom_id == friend_codes_button_id { + let option_defaults = option_profile.clone().map(|profile| EditCodesModal { + pokemon_go_code: profile.pokemon_go_code, + pokemon_pocket_code: profile.pokemon_pocket_code, + switch_code: profile.switch_code, + }); + + let mut data = match poise::execute_modal_on_component_interaction(ctx, mci.clone(), option_defaults, None).await? { + Some(data) => data, + None => continue 'interaction_response, + }; + + if let Err(errors) = data.validate() { + let mut embed_description = String::new(); + + for error in errors { + embed_description.push_str(&error); + embed_description.push('\n'); + } + embed_description.pop(); + + let embed = CreateEmbed::new() + .title("Validation Error") + .description(embed_description) + .color(Color::RED); + + let reply = CreateReply::default() + .embed(embed) + .ephemeral(true); + + ctx.send(reply).await?; + + continue 'interaction_response; + } + + let profile = match option_profile { + Some(mut profile) => { + profile.pokemon_go_code = data.pokemon_go_code; + profile.pokemon_pocket_code = data.pokemon_pocket_code; + profile.switch_code = data.switch_code; + profile + }, + None => NewProfile { + pokemon_go_code: data.pokemon_go_code, + pokemon_pocket_code: data.pokemon_pocket_code, + switch_code: data.switch_code, + ..Default::default() + }, + }; + + option_profile = Some(profile); + break 'interaction_response; + } + + if mci.data.custom_id == images_button_id { + let option_defaults = option_profile.clone().map(|profile| EditImagesModal { + thumbnail_url: profile.thumbnail_url, + image_url: profile.image_url, + }); + + let data = match poise::execute_modal_on_component_interaction(ctx, mci.clone(), option_defaults, None).await? { + Some(data) => data, + None => continue 'interaction_response, + }; + + let profile = match option_profile { + Some(mut profile) => { + profile.thumbnail_url = data.thumbnail_url; + profile.image_url = data.image_url; + profile + }, + None => NewProfile { + thumbnail_url: data.thumbnail_url, + image_url: data.image_url, + ..Default::default() + }, + }; + + option_profile = Some(profile); + break 'interaction_response; + } + + if mci.data.custom_id == save_button_id { + let mut new_profile = match option_profile { + Some(new_profile) => new_profile, + None => break, + }; + + let discord_user_id = member.user.id.get(); + let user = match repo.user_by_discord_user_id(discord_user_id).await? { + Some(user) => user, + None => repo.insert_user(NewUser { discord_user_id }).await?, + }; + + new_profile.user_id = user.id; + + repo.insert_profile(new_profile).await?; + + let embed = CreateEmbed::new() + .title("Saved") + .description("Your changes have been saved successfully!") + .color(crate::utils::bot_color(&ctx).await); + + let reply = CreateReply::default() + .embed(embed) + .components(vec![]) + .ephemeral(true); + + reply_handle.edit(ctx.into(), reply).await?; + + break 'update_reply; + } + + mci.create_response(ctx, CreateInteractionResponse::Acknowledge).await?; + } + + option_reply_handle = Some(reply_handle); + } + + Ok(()) +} + +#[derive(Default)] +struct ProfileEmbed { + color: Color, + author_display_name: String, + author_icon_url: String, + + thumbnail_url: Option, + image_url: Option, + + trainer_class: Option, + nature: Option, + partner_pokemon: Option, + favourite_food: Option, + starting_region: Option, + likes: Option, + quotes: Option, + + pokemon_go_code: Option, + pokemon_pocket_code: Option, + switch_code: Option, +} + +impl ProfileEmbed { + async fn from_profile( + ctx: &AppContext<'_, R, R::BackendError>, + member: &Member, + option_profile: Option<&Profile>, + ) -> ProfileEmbed + where + R: RepositoryProvider + Send + Sync, + { + let avatar_url = crate::utils::member_avatar_url(member); + + let embed_color = match member.colour(ctx) { + Some(color) => color, + None => crate::utils::bot_color(ctx).await, + }; + + match option_profile.cloned() { + Some(profile) => ProfileEmbed { + color: embed_color, + author_display_name: member.display_name().to_string(), + author_icon_url: avatar_url, + + thumbnail_url: profile.thumbnail_url, + image_url: profile.image_url, + + trainer_class: profile.trainer_class, + nature: profile.nature, + partner_pokemon: profile.partner_pokemon, + favourite_food: profile.favourite_food, + starting_region: profile.starting_region, + likes: profile.likes, + quotes: profile.quotes, + + pokemon_go_code: profile.pokemon_go_code, + pokemon_pocket_code: profile.pokemon_pocket_code, + switch_code: profile.switch_code, + }, + None => ProfileEmbed { + color: embed_color, + author_display_name: member.display_name().to_string(), + author_icon_url: avatar_url, + ..Default::default() + }, + } + } + + async fn from_new_profile( + ctx: &AppContext<'_, R, R::BackendError>, + member: &Member, + option_profile: Option<&NewProfile>, + ) -> ProfileEmbed + where + R: RepositoryProvider + Send + Sync, + { + let avatar_url = crate::utils::member_avatar_url(member); + + let embed_color = match member.colour(ctx) { + Some(color) => color, + None => crate::utils::bot_color(ctx).await, + }; + + match option_profile.cloned() { + Some(profile) => ProfileEmbed { + color: embed_color, + author_display_name: member.display_name().to_string(), + author_icon_url: avatar_url, + + thumbnail_url: profile.thumbnail_url, + image_url: profile.image_url, + + trainer_class: profile.trainer_class, + nature: profile.nature, + partner_pokemon: profile.partner_pokemon, + favourite_food: profile.favourite_food, + starting_region: profile.starting_region, + likes: profile.likes, + quotes: profile.quotes, + + pokemon_go_code: profile.pokemon_go_code, + pokemon_pocket_code: profile.pokemon_pocket_code, + switch_code: profile.switch_code, + }, + None => ProfileEmbed { + color: embed_color, + author_display_name: member.display_name().to_string(), + author_icon_url: avatar_url, + ..Default::default() + }, + } + } + + pub fn into_embed(self) -> CreateEmbed { + let embed_author = CreateEmbedAuthor::new(self.author_display_name) + .icon_url(self.author_icon_url); + + let mut embed = CreateEmbed::new() + .author(embed_author) + .color(self.color); + + if let Some(thumbnail_url) = self.thumbnail_url { + embed = embed.thumbnail(thumbnail_url); + } + if let Some(image_url) = self.image_url { + embed = embed.image(image_url) + } + + let mut is_profile_empty = true; + if let Some(trainer_class) = self.trainer_class { + embed = embed.field("Trainer Class", trainer_class, true); + is_profile_empty = false; + } + if let Some(nature) = self.nature { + embed = embed.field("Nature", nature, true); + is_profile_empty = false; + } + if let Some(partner_pokemon) = self.partner_pokemon { + embed = embed.field("Partner Pokémon", partner_pokemon, true); + is_profile_empty = false; + } + if let Some(favourite_food) = self.favourite_food { + embed = embed.field("Favourite Food", favourite_food, true); + is_profile_empty = false; + } + if let Some(starting_region) = self.starting_region { + embed = embed.field("Starting Region", starting_region, true); + is_profile_empty = false; + } + if let Some(likes) = self.likes { + embed = embed.field("Likes", likes, true); + is_profile_empty = false; + } + if let Some(quotes) = self.quotes { + embed = embed.field("Quotes", quotes, false); + is_profile_empty = false; + } + + let is_codes_empty + = self.pokemon_go_code.is_none() + && self.pokemon_pocket_code.is_none() + && self.switch_code.is_none(); + + match (is_profile_empty, is_codes_empty) { + (true, true) => embed = embed.description("No information to show."), + (false, true) => embed = embed.description("**User Profile**"), + (true, false) => embed = embed.description("**Friend Codes**"), + (false, false) => { + embed = embed + .description("**User Profile**") + .field("\u{200E}", "**Friend Codes**", false); // Invisible character to use title as a spacer + }, + } + + if let Some(pokemon_go_code) = self.pokemon_go_code { + embed = embed.field(":PokemonGo: Pokémon Go Friend Code", pokemon_go_code, false); + } + if let Some(pokemon_pocket_code) = self.pokemon_pocket_code { + embed = embed.field(":Pokeball: Pokémon TCG Pocket Friend Code", pokemon_pocket_code, false); + } + if let Some(switch_code) = self.switch_code { + embed = embed.field(":switch: Nintendo Switch Friend Code", switch_code, false); + } + + embed + } +} + +#[derive(Debug, Clone, Default, poise::Modal)] +struct EditPokemonInfoModal { + #[name = "Trainer Class"] + trainer_class: Option, + #[name = "Nature"] + nature: Option, + #[name = "Partner Pokémon"] + partner_pokemon: Option, + #[name = "Starting Region"] + starting_region: Option, +} + +#[derive(Debug, Clone, Default, poise::Modal)] +struct EditPersonalInfoModal { + #[name = "Favourite Food"] + favourite_food: Option, + #[name = "Likes"] + likes: Option, + #[name = "Quotes"] + #[paragraph] + quotes: Option, +} + +#[derive(Debug, Clone, Default, poise::Modal)] +#[name = "Edit Friend Codes"] +struct EditCodesModal { + #[name = "Pokémon Go Friend Code"] + #[placeholder = "0000 0000 0000"] + pokemon_go_code: Option, + #[name = "Pokémon TCG Pocket Friend Code"] + #[placeholder = "0000 0000 0000 0000"] + pokemon_pocket_code: Option, + #[name = "Nintendo Switch Friend Code"] + #[placeholder = "SW-0000-0000-0000"] + switch_code: Option, +} + +#[derive(Debug, Clone, Default, poise::Modal)] +struct EditImagesModal { + #[name = "Thumbnail Image URL"] + thumbnail_url: Option, + #[name = "Footer Image URL"] + image_url: Option, +} + +fn parse_pokemon_go_code(code: &str) -> Option { + let mut chars = code.chars().peekable(); + + let mut parsed = String::new(); + + for i in 0..3 { + if i > 0 { + let c = *chars.peek()?; + if c == '-' || c == ' ' { + chars.next(); + } + parsed.push(' '); + } + + for _ in 0..4 { + let c = chars.next()?; + if !c.is_numeric() { + return None; + } + parsed.push(c); + } + } + + chars.next().is_none().then_some(parsed) +} + +fn parse_pokemon_pocket_code(code: &str) -> Option { + let mut chars = code.chars().peekable(); + + let mut parsed = String::new(); + + for i in 0..4 { + if i > 0 { + let c = *chars.peek()?; + if c == '-' || c == ' ' { + chars.next(); + } + parsed.push(' '); + } + + for _ in 0..4 { + let c = chars.next()?; + if !c.is_numeric() { + return None; + } + parsed.push(c); + } + } + + chars.next().is_none().then_some(parsed) +} + +fn parse_switch_code(code: &str) -> Option { + let mut chars = code.chars().map(|c| c.to_ascii_uppercase()).peekable(); + + let mut parsed = String::from("SW"); + + if *chars.peek()? == 'S' { + chars.next(); + if chars.next()? != 'W' { + return None; + } + } + + for _ in 0..3 { + let c = *chars.peek()?; + if c == '-' || c == ' ' { + chars.next(); + } + parsed.push('-'); + + for _ in 0..4 { + let c = chars.next()?; + if !c.is_numeric() { + return None; + } + parsed.push(c); + } + } + + chars.next().is_none().then_some(parsed) +} + +impl EditCodesModal { + fn validate(&mut self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + if let Some(code) = &self.pokemon_go_code { + match parse_pokemon_go_code(code) { + Some(parsed) => self.pokemon_go_code = Some(parsed), + None => errors.push(format!("`{}` is not a valid Pokémon Go friend code.", code)), + }; + } + + if let Some(code) = &self.pokemon_pocket_code { + match parse_pokemon_pocket_code(code) { + Some(parsed) => self.pokemon_pocket_code = Some(parsed), + None => errors.push(format!("`{}` is not a valid Pokémon TCG Pocket friend code.", code)), + }; + } + + if let Some(code) = &self.switch_code { + match parse_switch_code(code) { + Some(parsed) => self.switch_code = Some(parsed), + None => errors.push(format!("`{}` is not a valid switch friend code.", code)), + }; + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} diff --git a/cipher_discord_bot/src/commands/profile/codes.rs b/cipher_discord_bot/src/commands/profile/codes.rs deleted file mode 100644 index a6721f1..0000000 --- a/cipher_discord_bot/src/commands/profile/codes.rs +++ /dev/null @@ -1,294 +0,0 @@ -use cipher_core::repository::profile_repository::NewProfile; -use cipher_core::repository::profile_repository::ProfileRepository; -use cipher_core::repository::user_repository::NewUser; -use cipher_core::repository::user_repository::UserRepository; -use cipher_core::repository::RepositoryError; -use cipher_core::repository::RepositoryProvider; -use poise::CreateReply; -use poise::Modal; -use serenity::all::Color; -use serenity::all::CreateEmbed; -use serenity::all::Member; - -use crate::app::AppContext; -use crate::app::AppError; - -/// Manage friend codes. -#[poise::command( - slash_command, - guild_only, - subcommands( - "edit", - "overwrite", - ), -)] -pub async fn codes( - _ctx: AppContext<'_, R, R::BackendError>, -) -> Result<(), AppError> { - Ok(()) -} - -#[derive(Debug, poise::Modal)] -#[name = "Edit Friend Codes"] -struct EditCodesModal { - #[name = "Pokémon Go Friend Code"] - #[placeholder = "0000 0000 0000"] - pokemon_go_code: Option, - #[name = "Pokémon TCG Pocket Friend Code"] - #[placeholder = "0000 0000 0000 0000"] - pokemon_pocket_code: Option, - #[name = "Nintendo Switch Friend Code"] - #[placeholder = "SW-0000-0000-0000"] - switch_code: Option, -} - -fn parse_pokemon_go_code(code: &str) -> Option { - let mut chars = code.chars().peekable(); - - let mut parsed = String::new(); - - for i in 0..3 { - if i > 0 { - let c = *chars.peek()?; - if c == '-' || c == ' ' { - chars.next(); - } - parsed.push(' '); - } - - for _ in 0..4 { - let c = chars.next()?; - if !c.is_numeric() { - return None; - } - parsed.push(c); - } - } - - chars.next().is_none().then_some(parsed) -} - -fn parse_pokemon_pocket_code(code: &str) -> Option { - let mut chars = code.chars().peekable(); - - let mut parsed = String::new(); - - for i in 0..4 { - if i > 0 { - let c = *chars.peek()?; - if c == '-' || c == ' ' { - chars.next(); - } - parsed.push(' '); - } - - for _ in 0..4 { - let c = chars.next()?; - if !c.is_numeric() { - return None; - } - parsed.push(c); - } - } - - chars.next().is_none().then_some(parsed) -} - -fn parse_switch_code(code: &str) -> Option { - let mut chars = code.chars().map(|c| c.to_ascii_uppercase()).peekable(); - - let mut parsed = String::from("SW"); - - if *chars.peek()? == 'S' { - chars.next(); - if chars.next()? != 'W' { - return None; - } - } - - for _ in 0..3 { - let c = *chars.peek()?; - if c == '-' || c == ' ' { - chars.next(); - } - parsed.push('-'); - - for _ in 0..4 { - let c = chars.next()?; - if !c.is_numeric() { - return None; - } - parsed.push(c); - } - } - - chars.next().is_none().then_some(parsed) -} - -impl EditCodesModal { - fn validate(&mut self) -> Result<(), Vec> { - let mut errors = Vec::new(); - - if let Some(code) = &self.pokemon_go_code { - match parse_pokemon_go_code(code) { - Some(parsed) => self.pokemon_go_code = Some(parsed), - None => errors.push(format!("`{}` is not a valid Pokémon Go friend code.", code)), - }; - } - - if let Some(code) = &self.pokemon_pocket_code { - match parse_pokemon_pocket_code(code) { - Some(parsed) => self.pokemon_pocket_code = Some(parsed), - None => errors.push(format!("`{}` is not a valid Pokémon TCG Pocket friend code.", code)), - }; - } - - if let Some(code) = &self.switch_code { - match parse_switch_code(code) { - Some(parsed) => self.switch_code = Some(parsed), - None => errors.push(format!("`{}` is not a valid switch friend code.", code)), - }; - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } - } -} - -#[derive(Debug, thiserror::Error)] -enum EditError { - #[error(transparent)] - SerenityError(#[from] serenity::Error), - #[error(transparent)] - RepositoryError(#[from] RepositoryError), - #[error("Validation error")] - ValidationError(Vec), -} - -async fn execute_edit_codes_modal( - ctx: AppContext<'_, R, R::BackendError>, - discord_user_id: u64, -) -> Result<(), EditError> -where - R: RepositoryProvider + Send + Sync, -{ - let mut repo = ctx.data.repository().await?; - - let user = match repo.user_by_discord_user_id(discord_user_id).await? { - Some(user) => user, - None => repo.insert_user(NewUser { discord_user_id }).await?, - }; - - if let Some(mut profile) = repo.active_profile_by_discord_id(discord_user_id).await? { - let defaults = EditCodesModal { - pokemon_go_code: profile.pokemon_go_code.clone(), - pokemon_pocket_code: profile.pokemon_pocket_code.clone(), - switch_code: profile.switch_code.clone(), - }; - - let mut data = match EditCodesModal::execute_with_defaults(ctx, defaults).await? { - Some(data) => data, - None => return Ok(()), - }; - - data.validate().map_err(EditError::ValidationError)?; - - profile.pokemon_go_code = data.pokemon_go_code; - profile.pokemon_pocket_code = data.pokemon_pocket_code; - profile.switch_code = data.switch_code; - - repo.insert_profile(profile.into_new()).await?; - } else { - let mut data = match EditCodesModal::execute(ctx).await? { - Some(data) => data, - None => return Ok(()), - }; - - data.validate().map_err(EditError::ValidationError)?; - - let new_profile = NewProfile { - user_id: user.id, - - thumbnail_url: None, - image_url: None, - - trainer_class: None, - nature: None, - partner_pokemon: None, - starting_region: None, - favourite_food: None, - likes: None, - quotes: None, - - pokemon_go_code: data.pokemon_go_code, - pokemon_pocket_code: data.pokemon_pocket_code, - switch_code: data.switch_code, - }; - - repo.insert_profile(new_profile).await?; - } - - Ok(()) -} - -async fn edit_inner( - ctx: AppContext<'_, R, R::BackendError>, - user: &serenity::all::User, -) -> Result<(), AppError> { - let embed = match execute_edit_codes_modal(ctx, user.id.get()).await { - Ok(()) => CreateEmbed::new() - .title("Changes Saved") - .description("Your changes have been saved successfully.") - .color(crate::utils::bot_color(&ctx).await), - Err(EditError::ValidationError(errors)) => CreateEmbed::new() - .title("Validation Error") - .description(errors.join("\n")) - .color(Color::RED), - Err(EditError::SerenityError(err)) => return Err(AppError::from(err)), - Err(EditError::RepositoryError(err)) => return Err(AppError::from(err)), - }; - - let reply = CreateReply::default() - .embed(embed) - .ephemeral(true); - - ctx.send(reply).await?; - - Ok(()) -} - -#[poise::command(context_menu_command = "Edit Friend Codes", guild_only)] -pub async fn cmu_profile_edit( - ctx: AppContext<'_, R, R::BackendError>, - user: serenity::all::User, -) -> Result<(), AppError> { - if ctx.author().id != user.id && !crate::checks::is_staff(ctx.into()).await? { - return Err(AppError::StaffOnly { command_name: ctx.command().qualified_name.clone() }); - } - - edit_inner(ctx, &user).await -} - -/// Edit your friend codes. -#[poise::command(slash_command, guild_only)] -async fn edit(ctx: AppContext<'_, R, R::BackendError>) -> Result<(), AppError> { - edit_inner(ctx, ctx.author()).await -} - -/// Edit any user's friend codes. -#[poise::command( - slash_command, - guild_only, - hide_in_help, - check = "crate::checks::is_staff", -)] -async fn overwrite( - ctx: AppContext<'_, R, R::BackendError>, - #[description = "The profile to edit."] - member: Member, -) -> Result<(), AppError> { - edit_inner(ctx, &member.user).await -} diff --git a/cipher_discord_bot/src/commands/profile/edit.rs b/cipher_discord_bot/src/commands/profile/edit.rs deleted file mode 100644 index 47fa8c1..0000000 --- a/cipher_discord_bot/src/commands/profile/edit.rs +++ /dev/null @@ -1,174 +0,0 @@ -use cipher_core::repository::RepositoryProvider; -use poise::CreateReply; -use serenity::all::Color; -use serenity::all::CreateActionRow; -use serenity::all::CreateButton; -use serenity::all::CreateEmbed; -use serenity::all::CreateEmbedAuthor; -use serenity::all::Member; -use uuid::Uuid; - -use crate::app::AppContext; -use crate::app::AppError; - -#[poise::command(slash_command, guild_only)] -pub async fn edit( - ctx: AppContext<'_, R, R::BackendError>, - option_member: Option, -) -> Result<(), AppError> { - let member = match option_member { - Some(member) => { - if member.user.id != ctx.author().id { - match crate::checks::is_staff(ctx.into()).await { - Ok(true) => {}, - Ok(false) | Err(AppError::StaffOnly { command_name: _ }) => { - - } - Err(err) => return Err(err) - } - } - member - } - None => ctx - .author_member() - .await - .ok_or(AppError::UnknownCacheOrHttpError)? - .into_owned(), - }; - - let profile = Profile { - color: member.colour(ctx).unwrap_or(crate::utils::bot_color(&ctx).await), - author_display_name: member.display_name().to_string(), - author_icon_url: crate::utils::member_avatar_url(&member), - - thumbnail_url: None, - image_url: None, - - trainer_class: None, - nature: None, - partner_pokemon: None, - favourite_food: None, - starting_region: None, - likes: None, - quotes: None, - - pokemon_go_code: Some("0000 0000 0000".to_string()), - pokemon_pocket_code: Some("0000 0000 0000 0000".to_string()), - switch_code: Some("SW-0000-0000-0000".to_string()), - }; - - let pokemon_info_button_id = Uuid::new_v4().to_string(); - let personal_info_button_id = Uuid::new_v4().to_string(); - let codes_button_id = Uuid::new_v4().to_string(); - let edit_buttons = CreateActionRow::Buttons(vec![ - CreateButton::new(&pokemon_info_button_id).label("Edit Pokémon Info"), - CreateButton::new(&personal_info_button_id).label("Edit Personal Info"), - CreateButton::new(&codes_button_id).label("Edit Friend Codes"), - ]); - - let reply = CreateReply::default() - .embed(profile.create_embed()) - .components(vec![edit_buttons]) - .ephemeral(true); - - ctx.send(reply).await?; - - Ok(()) -} - -struct Profile { - color: Color, - author_display_name: String, - author_icon_url: String, - - thumbnail_url: Option, - image_url: Option, - - trainer_class: Option, - nature: Option, - partner_pokemon: Option, - favourite_food: Option, - starting_region: Option, - likes: Option, - quotes: Option, - - pokemon_go_code: Option, - pokemon_pocket_code: Option, - switch_code: Option, -} - -impl Profile { - pub fn create_embed(self) -> CreateEmbed { - let embed_author = CreateEmbedAuthor::new(self.author_display_name) - .icon_url(self.author_icon_url); - - let mut embed = CreateEmbed::new() - .author(embed_author) - .color(self.color); - - if let Some(thumbnail_url) = self.thumbnail_url { - embed = embed.thumbnail(thumbnail_url); - } - if let Some(image_url) = self.image_url { - embed = embed.image(image_url) - } - - let mut is_profile_empty = true; - if let Some(trainer_class) = self.trainer_class { - embed = embed.field("Trainer Class", trainer_class, true); - is_profile_empty = false; - } - if let Some(nature) = self.nature { - embed = embed.field("Nature", nature, true); - is_profile_empty = false; - } - if let Some(partner_pokemon) = self.partner_pokemon { - embed = embed.field("Partner Pokémon", partner_pokemon, true); - is_profile_empty = false; - } - if let Some(favourite_food) = self.favourite_food { - embed = embed.field("Favourite Food", favourite_food, true); - is_profile_empty = false; - } - if let Some(starting_region) = self.starting_region { - embed = embed.field("Starting Region", starting_region, true); - is_profile_empty = false; - } - if let Some(likes) = self.likes { - embed = embed.field("Likes", likes, true); - is_profile_empty = false; - } - if let Some(quotes) = self.quotes { - embed = embed.field("Quotes", quotes, false); - is_profile_empty = false; - } - - let is_codes_empty - = self.pokemon_go_code.is_none() - && self.pokemon_pocket_code.is_none() - && self.switch_code.is_none(); - - match (is_profile_empty, is_codes_empty) { - (true, true) => embed = embed.description("No information to show."), - (false, true) => embed = embed.description("**User Profile**"), - (true, false) => embed = embed.description("**Friend Codes**"), - (false, false) => { - embed = embed - .description("**User Profile**") - .field("\u{200E}", "**Friend Codes**", false); // Invisible character to use title as a spacer - }, - } - - if let Some(pokemon_go_code) = self.pokemon_go_code { - embed = embed.field(":PokemonGo: Pokémon Go Friend Code", pokemon_go_code, false); - } - if let Some(pokemon_pocket_code) = self.pokemon_pocket_code { - embed = embed.field(":Pokeball: Pokémon TCG Pocket Friend Code", pokemon_pocket_code, false); - } - if let Some(switch_code) = self.switch_code { - embed = embed.field(":switch: Nintendo Switch Friend Code", switch_code, false); - } - - embed - } -} diff --git a/cipher_discord_bot/src/commands/profile/mod.rs b/cipher_discord_bot/src/commands/profile/mod.rs deleted file mode 100644 index c5c427e..0000000 --- a/cipher_discord_bot/src/commands/profile/mod.rs +++ /dev/null @@ -1,112 +0,0 @@ -use cipher_core::repository::profile_repository::ProfileRepository; -use cipher_core::repository::RepositoryProvider; -use poise::CreateReply; -use serenity::all::CreateEmbed; -use serenity::all::Member; -use serenity::all::User; - -use crate::app::AppContext; -use crate::app::AppError; - -mod codes; -mod edit; - -pub use codes::cmu_profile_edit; - -/// Edit and show profiles. -#[poise::command( - slash_command, - subcommands( - "codes::codes", - "show", - "edit::edit", - ), -)] -pub async fn profile( - _ctx: AppContext<'_, R, R::BackendError>, -) -> Result<(), AppError> { - Ok(()) -} - -#[poise::command(context_menu_command = "Show User Profile", guild_only)] -pub async fn cmu_profile_show( - ctx: AppContext<'_, R, R::BackendError>, - user: User, -) -> Result<(), AppError> { - let guild = match ctx.guild_id() { - Some(guild) => guild, - None => return Ok(()), - }; - - let member = guild.member(ctx, user.id).await?; - - show_inner(ctx, member, true).await -} - -/// Show your profile or someone else's. -#[poise::command(slash_command, guild_only)] -async fn show( - ctx: AppContext<'_, R, R::BackendError>, - #[rename = "member"] - #[description = "The profile to show."] - option_member: Option, - #[description = "Hide reply from other users. Defaults to True."] - ephemeral: Option, -) -> Result<(), AppError> { - let member = match option_member { - Some(member) => member, - None => ctx.author_member().await.ok_or(AppError::UnknownCacheOrHttpError)?.into_owned(), - }; - - show_inner(ctx, member, ephemeral.unwrap_or(true)).await -} - -async fn show_inner( - ctx: AppContext<'_, R, R::BackendError>, - member: Member, - ephemeral: bool, -) -> Result<(), AppError> { - let avatar_url = crate::utils::member_avatar_url(&member); - - let embed_color = match member.colour(ctx) { - Some(color) => color, - None => crate::utils::bot_color(&ctx).await, - }; - - let mut embed = CreateEmbed::new() - .title(member.display_name()) - .thumbnail(avatar_url) - .color(embed_color); - - let mut is_profile_empty = true; - - let mut repo = ctx.data.repository().await?; - if let Some(profile) = repo.active_profile_by_discord_id(member.user.id.get()).await? { - if let Some(code) = profile.pokemon_go_code { - embed = embed.field("Pokémon Go Friend Code", code, false); - is_profile_empty = false; - } - - if let Some(code) = profile.pokemon_pocket_code { - embed = embed.field("Pokémon TCG Pocket Friend Code", code, false); - is_profile_empty = false; - } - - if let Some(code) = profile.switch_code { - embed = embed.field("Nintendo Switch Friend Code", code, false); - is_profile_empty = false; - } - }; - - if is_profile_empty { - embed = embed.description("No information to show."); - } - - let reply = CreateReply::default() - .embed(embed) - .ephemeral(ephemeral); - - ctx.send(reply).await?; - - Ok(()) -}