mod add_points; mod leaderboard; mod revise; pub use add_points::add_points; pub use leaderboard::leaderboard; pub use revise::revise; use time::{Date, PrimitiveDateTime, Time, format_description}; use std::sync::Arc; use twilight_model::{ application::{ command::{Command, CommandType}, interaction::application_command::{CommandData, CommandOptionValue}, }, gateway::payload::incoming::InteractionCreate, guild::PartialMember, http::interaction::InteractionResponseData, id::{ Id, marker::{GuildMarker, UserMarker}, }, }; use twilight_util::builder::{ InteractionResponseDataBuilder, command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder}, embed::{EmbedBuilder, EmbedFieldBuilder}, }; use crate::{bail, brain::Brain, database::PermissionSetting, fail, import, success, util}; pub enum Commands { About, Leaderboard, Points, Revise, 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::Revise => revise(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 revise = CommandBuilder::new( "revise", "Change history by adding/removing points at a specific point in time", CommandType::ChatInput, ) .option(IntegerBuilder::new("points", "number of points. - or +").required(true)) .option(StringBuilder::new("users", "mention people to modify their points!!").required(true)) .option( StringBuilder::new( "date", "Date string in the YYYY-DD-MM format, with optional time component (HH:MM:SS, 24-hour time)", ) .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, revise, 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; } } /// Checks the settings of the leaderboard associated with the provided guild. /// /// Returns `None` if: /// - The guild does not have permissions set /// - The guild member has the appropriate permissions /// /// Returns `Some` with InteractionResponseData if: /// - The guild member does not have proper permissions /// - The guild is set to role required, but no role is set, which should be /// impossible to happen, but. pub fn check_command_permissions( brain: &Brain, guild: Id, member: PartialMember, ) -> Option { let settings = brain.db.get_leaderboard_settings(guild.get()).unwrap(); if let PermissionSetting::RoleRequired = settings.permission { if let Some(role) = settings.role { let member = member; let found_role = member.roles.iter().find(|vrole| vrole.get() == role); if found_role.is_none() { Some(fail!( "You do not have the right permissions to change the score" )) } else { None } } 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? Some(fail!( "Permissions set to Role Required, but no role is set. \ This shouldn't be able to happen. \ Maybe try to set the permissions again?" )) } } else { None } } pub struct PointsCommandData { points: i64, points_verb: &'static str, users: Vec>, date: Result, } impl PointsCommandData { pub fn user_string(&self) -> String { self.users .iter() .map(|u| format!("<@{u}>")) .collect::>() .join(", ") } } pub fn parse_points_command_data( data: &CommandData, ) -> Result { let mut points = None; let mut users: Vec> = vec![]; let mut date_string = None; 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!(), }, "date" => match &opt.value { CommandOptionValue::String(raw) => { date_string = Some(raw); } _ => unreachable!(), }, _ => unreachable!(), } } if users.len() == 0 { return Err(fail!("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(_) | None => unreachable!(), }; let date_fmt = format_description::parse("[year]-[month]-[day]").unwrap(); let datetime_fmt = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap(); let date = match date_string { None => Err(fail!("What date were these points earned?")), Some(str) if str.is_empty() => Err(fail!("You must specify a date!")), Some(date) => match Date::parse(date.trim(), &date_fmt) { Err(_e) => match PrimitiveDateTime::parse(date.trim(), &datetime_fmt) { Err(_e) => Err(fail!( "Cannot parse the given date/time. Did you write it like \"2025-04-13\" or, with time, \"2025-04-13 02:33\"?" )), Ok(datetime) => Ok(datetime), }, Ok(date) => Ok(PrimitiveDateTime::new( date, Time::from_hms(12, 0, 0).unwrap(), )), }, }; Ok(PointsCommandData { points, points_verb, users, date, }) }