From 76c1f83074e84a21eb5f5a69b160b1facbeb9cac Mon Sep 17 00:00:00 2001 From: gennyble Date: Sun, 3 Aug 2025 22:08:44 -0500 Subject: code cleanup; break commands into their own module --- src/command/add_points.rs | 110 +++++++++++++++++++++++ src/command/leaderboard.rs | 91 +++++++++++++++++++ src/command/mod.rs | 211 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/command/add_points.rs create mode 100644 src/command/leaderboard.rs create mode 100644 src/command/mod.rs (limited to 'src/command') diff --git a/src/command/add_points.rs b/src/command/add_points.rs new file mode 100644 index 0000000..453be2d --- /dev/null +++ b/src/command/add_points.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use twilight_model::{ + application::interaction::application_command::{CommandData, CommandOptionValue}, + gateway::payload::incoming::InteractionCreate, + http::interaction::InteractionResponseData, + id::{ + Id, + marker::{GuildMarker, UserMarker}, + }, +}; +use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder}; + +use crate::{ + bail, + brain::Brain, + database::{BoardRow, Error as DbError, PermissionSetting}, + success, util, +}; + +pub async fn add_points( + brain: Arc, + guild: Id, + create: &InteractionCreate, + data: &CommandData, +) -> InteractionResponseData { + let mut points = None; + let mut users: Vec> = vec![]; + + for opt in &data.options { + match opt.name.as_str() { + "points" => match opt.value { + CommandOptionValue::Integer(num) => points = Some(num), + _ => unreachable!(), + }, + "users" => match &opt.value { + CommandOptionValue::String(raw) => { + let mentions = util::extract_user_mentions(&raw); + users = mentions; + } + _ => unreachable!(), + }, + _ => unreachable!(), + } + } + + if users.len() == 0 { + bail!("No users mentioned! Who do we add points to?") + } + + let (points, points_display, points_verb) = match points { + Some(p) if p > 0 => (p, p, "added to"), + Some(p) if p < 0 => (p, -p, "removed from"), + Some(0) => { + return success!("adding 0 points is a no-operation! I won't do anything :)"); + } + Some(_) | None => unreachable!(), + }; + + brain.create_leaderboard_if_not_exists(guild); + + let settings = brain.db.get_leaderboard_settings(guild.get()).unwrap(); + if let PermissionSetting::RoleRequired = settings.permission { + if let Some(role) = settings.role { + let member = create.member.clone().unwrap(); + let found_role = member.roles.iter().find(|vrole| vrole.get() == role); + + if found_role.is_none() { + bail!("You do not have the right permissions to change the score"); + } + } else { + // Seeing as the role is a required input on the subcommand, this + // would only happen with direct database fiddling or like, some + // really weird arcane bullshit? + bail!( + "Permissions set to Role Required, but no role is set. \ + This shouldn't be able to happen. \ + Maybe try to set the permissions again?" + ); + } + } + + for user in &users { + match brain.db.give_user_points(guild.get(), user.get(), points) { + Err(DbError::UserNotExist) => { + let member = brain.guild_member(guild, *user).await; + let row = BoardRow { + user_id: user.get(), + user_handle: member.handle, + user_nickname: member.nick.or(member.global_name), + points, + }; + brain.db.add_user_to_leaderboard(guild.get(), row).unwrap(); + } + _ => (), + } + } + + let users_string = users + .into_iter() + .map(|u| format!("<@{u}>")) + .collect::>() + .join(", "); + + let msg = format!("{points_display} points {points_verb} {users_string}"); + let embed = EmbedBuilder::new().description(msg).build(); + InteractionResponseDataBuilder::new() + .embeds([embed]) + .build() +} diff --git a/src/command/leaderboard.rs b/src/command/leaderboard.rs new file mode 100644 index 0000000..97971f5 --- /dev/null +++ b/src/command/leaderboard.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use twilight_model::{ + application::interaction::application_command::{CommandData, CommandOptionValue}, + http::interaction::InteractionResponseData, + id::{Id, marker::GuildMarker}, +}; +use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder}; + +use crate::{brain::Brain, database::Error as DbError, fail, util}; + +enum Style { + TiesEqual, + TiesBroken, +} + +pub fn leaderboard( + brain: Arc, + guild: Id, + data: Option<&CommandData>, +) -> InteractionResponseData { + let board = brain.db.get_leaderboard(guild.get()); + + let mut style = Style::TiesBroken; + if let Some(data) = data { + for opt in &data.options { + match opt.name.as_str() { + "style" => match &opt.value { + CommandOptionValue::String(raw) => match raw.as_str() { + "equal" => style = Style::TiesEqual, + "broken" => style = Style::TiesBroken, + _ => { + return fail!(format!( + "{raw} is not a valid style option. Try 'equal' or 'broken'" + )); + } + }, + _ => unreachable!(), + }, + _ => unreachable!(), + } + } + } + + match board { + Err(DbError::TableNotExist) => { + fail!("No leaderboard exists for this server! Create one by giving someone points.") + } + Err(DbError::UserNotExist) => unreachable!(), + Ok(data) => { + let placed = util::tiebreak_shared_positions(data); + + let str = match style { + Style::TiesEqual => placed + .into_iter() + .map(|placement| { + if placement.placement_first_of_tie { + format!( + "`{:>2}` <@{}>: {}", + placement.placement_tie, + placement.row.user_id, + placement.row.points + ) + } else { + format!( + "` ` <@{}>: {}", + placement.row.user_id, placement.row.points + ) + } + }) + .collect::>() + .join("\n"), + Style::TiesBroken => placed + .into_iter() + .map(|placement| { + format!( + "`{:>2}` <@{}>: {}", + placement.placement, placement.row.user_id, placement.row.points + ) + }) + .collect::>() + .join("\n"), + }; + + let embed = EmbedBuilder::new().description(str).build(); + InteractionResponseDataBuilder::new() + .embeds([embed]) + .build() + } + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..165cdb6 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,211 @@ +mod add_points; +mod leaderboard; + +pub use add_points::add_points; +pub use leaderboard::leaderboard; + +use std::sync::Arc; + +use twilight_model::{ + application::{ + command::{Command, CommandType}, + interaction::application_command::{CommandData, CommandOptionValue}, + }, + gateway::payload::incoming::InteractionCreate, + http::interaction::InteractionResponseData, + id::{Id, marker::GuildMarker}, +}; +use twilight_util::builder::{ + InteractionResponseDataBuilder, + command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder}, + embed::{EmbedBuilder, EmbedFieldBuilder}, +}; + +use crate::{bail, brain::Brain, database::PermissionSetting, fail, import, success}; + +pub enum Commands { + About, + Leaderboard, + Points, + Permission, + Import, +} + +pub async fn handler( + brain: Arc, + guild: Id, + create: &InteractionCreate, + command: Commands, + command_data: &CommandData, +) { + // Handle the command and create our interaction response data + let data = match command { + Commands::About => about(brain.clone(), guild), + Commands::Leaderboard => leaderboard(brain.clone(), guild, Some(command_data)), + Commands::Points => add_points(brain.clone(), guild, create, command_data).await, + Commands::Permission => permission(brain.clone(), guild, create, command_data).await, + Commands::Import => { + import(brain.clone(), guild, create, command_data).await; + return; + } + }; + + // Finally, send back the response + brain.interaction_respond(create, data).await; +} + +pub async fn build() -> Vec { + let about = CommandBuilder::new( + "about", + "Get information about the bot", + CommandType::ChatInput, + ) + .build(); + + let leaderboard = CommandBuilder::new( + "leaderboard", + "View the server leaderboard", + CommandType::ChatInput, + ) + .option( + StringBuilder::new("style", "style of leaderboard to display") + .choices([("Ties Equal", "equal"), ("Ties Broken", "broken")]), + ) + .build(); + + let points = CommandBuilder::new("points", "Add and remove points!", CommandType::ChatInput) + .option(IntegerBuilder::new("points", "number of points. - or +").required(true)) + .option( + StringBuilder::new("users", "mention people to modify their points!!").required(true), + ) + .validate() + .unwrap() + .build(); + + let permission = CommandBuilder::new( + "permission", + "set who is allowed to change points", + CommandType::ChatInput, + ) + .option( + SubCommandBuilder::new("role", "require a role to change the score") + .option(RoleBuilder::new("role", "role required").required(true)), + ) + .option(SubCommandBuilder::new( + "none", + "anyone can change the score", + )) + .build(); + + let import = CommandBuilder::new( + "import", + "import this server's emboard leaderboard by adding the points in emboard, to leaberblord's", + CommandType::ChatInput, + ) + .build(); + + vec![about, leaderboard, points, permission, import] +} + +pub fn about(_brain: Arc, _guild: Id) -> InteractionResponseData { + let embed = EmbedBuilder::new() + .title("About Leaberblord") + .description( + "A single-purpose bot for keeping score. Written in Rust using the twilight set of crates.", + ).field(EmbedFieldBuilder::new("Source", "The source is available on the author's [cgit instance](https://git.dreamy.place/whimsy/leaberblord/about)")) + .field(EmbedFieldBuilder::new("Author", "Written by @gennyble. Her homepage is [dreamy.place](https://dreamy.place)")) + .color(0x33aa88).build(); + + InteractionResponseDataBuilder::new() + .embeds([embed]) + .build() +} + +pub async fn permission( + brain: Arc, + guild: Id, + create: &InteractionCreate, + data: &CommandData, +) -> InteractionResponseData { + let member = create.member.clone().unwrap(); + let permissions = member.permissions.unwrap(); + if !permissions.contains(twilight_model::guild::Permissions::MANAGE_GUILD) { + bail!("You must have the Manage Server permission to perform this action"); + } + + for opt in &data.options { + return match opt.name.as_str() { + "none" => permission_none(brain, guild), + "role" => permission_role(brain, guild, opt.value.clone()), + _ => { + eprintln!("permission command, unknown option '{}'", opt.name); + fail!("this should be unreachable. report the bug maybe? to gennyble on discord") + } + }; + } + + fail!("this should be unreachable. report the bug maybe? to gennyble on discord") +} + +fn permission_none(brain: Arc, guild: Id) -> InteractionResponseData { + if brain.create_leaderboard_if_not_exists(guild) { + // If it already existed, we might not be on default none. So set it. + brain + .db + .set_leaderboard_permission(guild.get(), PermissionSetting::None) + .unwrap(); + } + + success!("Permission for leaderboard changed to none. Anyone may now set the score.") +} + +fn permission_role( + brain: Arc, + guild: Id, + data: CommandOptionValue, +) -> InteractionResponseData { + let role = if let CommandOptionValue::SubCommand(data) = data { + match data.iter().find(|d| d.name == "role").unwrap().value { + CommandOptionValue::Role(role) => role, + _ => panic!(), + } + } else { + panic!() + }; + + brain.create_leaderboard_if_not_exists(guild); + + brain + .db + .set_leaderboard_permission(guild.get(), PermissionSetting::RoleRequired) + .unwrap(); + brain + .db + .set_leaderboard_permission_role(guild.get(), role.get()) + .unwrap(); + + success!(format!( + "Permission for leaderboard changed to Role Required. \ + Only members of <@&{role}> may now set the score" + )) +} + +pub async fn import( + brain: Arc, + guild: Id, + create: &InteractionCreate, + _data: &CommandData, +) { + let member = create.member.clone().unwrap(); + let permissions = member.permissions.unwrap(); + if !permissions.contains(twilight_model::guild::Permissions::MANAGE_GUILD) { + brain + .interaction_respond( + create, + fail!("You must have the Manage Server permission to perform this action"), + ) + .await; + } else { + import::emboard_direct::import(brain, guild, create).await; + } +} -- cgit 1.4.1-3-g733a5