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 +++++++++++++++++++ src/import/mod.rs | 4 +- src/main.rs | 501 +++++---------------------------------------- src/util.rs | 55 +++++ 6 files changed, 522 insertions(+), 450 deletions(-) create mode 100644 src/command/add_points.rs create mode 100644 src/command/leaderboard.rs create mode 100644 src/command/mod.rs 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; + } +} diff --git a/src/import/mod.rs b/src/import/mod.rs index f1f2406..03e2bed 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -66,7 +66,7 @@ pub mod emboard_direct { use twilight_util::builder::InteractionResponseDataBuilder; use ureq::config::Config; - use crate::brain::Brain; + use crate::{brain::Brain, command}; const DATA_URL: &str = "https://emboard.evolvedmesh.com/api/backend/dashboard/emboard_guild/leaderboard"; @@ -108,7 +108,7 @@ pub mod emboard_direct { let body: String = resp.body_mut().read_to_string().unwrap(); super::import(&brain.db, body); - let embed = crate::get_leaderboard(brain.clone(), guild_id, None) + let embed = command::leaderboard(brain.clone(), guild_id, None) .embeds .unwrap()[0] .clone(); diff --git a/src/main.rs b/src/main.rs index e7de9d5..5877d48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,17 @@ -use std::{env, error::Error, ops::Deref, path::PathBuf, sync::Arc}; +use std::{env, error::Error, path::PathBuf, sync::Arc}; use brain::Brain; use confindent::Confindent; -use database::{BoardRow, Database, Error as DbError, PermissionSetting}; +use database::Database; use twilight_cache_inmemory::{DefaultInMemoryCache, ResourceType}; use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; use twilight_http::Client as HttpClient; -use twilight_model::{ - application::{ - command::{Command, CommandType}, - interaction::{ - InteractionData, - application_command::{CommandData, CommandOptionValue}, - }, - }, - gateway::payload::incoming::InteractionCreate, - http::interaction::InteractionResponseData, - id::{ - Id, - marker::{GuildMarker, UserMarker}, - }, -}; -use twilight_util::builder::{ - InteractionResponseDataBuilder, - command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder}, - embed::{EmbedBuilder, EmbedFieldBuilder}, -}; +use twilight_model::{application::interaction::InteractionData, id::Id}; + +use crate::command::Commands; mod brain; +mod command; mod database; mod import; mod util; @@ -37,30 +21,6 @@ const PROD_APP_ID: u64 = 1363055126264283136; const DEV_APP_ID: u64 = 1363494986699771984; const APP_ID: u64 = DEV_APP_ID; -macro_rules! bail { - ($msg:expr) => { - return InteractionResponseDataBuilder::new() - .content(&format!("❌ {}", $msg)) - .build() - }; -} - -macro_rules! fail { - ($msg:expr) => { - InteractionResponseDataBuilder::new() - .content(&format!("❌ {}", $msg)) - .build() - }; -} - -macro_rules! success { - ($msg:expr) => { - InteractionResponseDataBuilder::new() - .content(&format!("✅ {}", $msg)) - .build() - }; -} - #[tokio::main] async fn main() -> anyhow::Result<()> { //dotenv::dotenv().ok(); @@ -75,13 +35,12 @@ async fn main() -> anyhow::Result<()> { let db_dir = conf .child_owned("Database") .unwrap_or("/var/leaberblord/leaberblord.sqlite".to_string()); - let token = env::var("DISCORD_TOKEN") - .ok() - .unwrap_or_else(|| conf.child_owned("DiscordToken").unwrap()); let db = Database::new(db_dir); db.create_tables(); + // Look for an `import` CLI-thing so we know if we should impor and emboard + // database from local let arg = std::env::args().nth(1); match arg.as_deref() { Some("import") => { @@ -98,7 +57,46 @@ async fn main() -> anyhow::Result<()> { _ => (), } - let mut shard = Shard::new(ShardId::ONE, token.clone(), Intents::GUILD_MESSAGES); + // Finally, setup and start the bot itself + let TwilightStuff { + mut shard, + http, + cache, + } = setup_twilight(&conf).await; + let brain = Arc::new(Brain::new(db, http, cache)); + + while let Some(item) = shard.next_event(EventTypeFlags::all()).await { + let Ok(event) = item else { + eprintln!("error receiving event"); + continue; + }; + + brain.update_cache(&event); + + tokio::spawn(handle_event(event, Arc::clone(&brain))); + } + + Ok(()) +} + +/// A more proper container than a tuple for everything we get from [setup_twilight] +struct TwilightStuff { + shard: Shard, + http: HttpClient, + cache: DefaultInMemoryCache, +} + +/// Configure enough to get off the ground with twilight. +/// - Get the `DISCORD_TOKEN` from the environment or `DiscordToken` from the conf file, with that preference +/// - Setup ourself as Shard 1 and take the `GUILD_MESSAGES` intent +/// - Register with the API and create our `InMemoryCache` +/// - Register all of our commands +async fn setup_twilight(conf: &Confindent) -> TwilightStuff { + let token = env::var("DISCORD_TOKEN") + .ok() + .unwrap_or_else(|| conf.child_owned("DiscordToken").unwrap()); + + let shard = Shard::new(ShardId::ONE, token.clone(), Intents::GUILD_MESSAGES); let http = HttpClient::new(token); let cache = DefaultInMemoryCache::builder() .resource_types(ResourceType::GUILD) @@ -107,7 +105,7 @@ async fn main() -> anyhow::Result<()> { .build(); let iclient = http.interaction(Id::new(APP_ID)); - let commands_vec = commands().await; + let commands_vec = command::build().await; iclient.set_global_commands(&commands_vec).await.unwrap(); println!("Set commands!"); @@ -131,22 +129,11 @@ async fn main() -> anyhow::Result<()> { ); } - let brain = Arc::new(Brain::new(db, http, cache)); - - while let Some(item) = shard.next_event(EventTypeFlags::all()).await { - let Ok(event) = item else { - eprintln!("error receiving event"); - continue; - }; - - brain.update_cache(&event); - - tokio::spawn(handle_event(event, Arc::clone(&brain))); - } - - Ok(()) + TwilightStuff { shard, http, cache } } +/// Handle all of our events. This is our main work function and what main +/// tends to branch into, but we don't loop in the function itself. async fn handle_event(event: Event, brain: Arc) -> Result<(), Box> { match event { Event::InteractionCreate(create) => { @@ -173,7 +160,7 @@ async fn handle_event(event: Event, brain: Arc) -> Result<(), Box panic!("'{}' is not a command", cmd.name), }; - command_handler(brain, guild, &create, command, cmd).await; + command::handler(brain, guild, &create, command, cmd).await; } } Event::GatewayClose(close) => { @@ -184,385 +171,3 @@ async fn handle_event(event: Event, brain: Arc) -> Result<(), Box 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] -} - -async fn command_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 => get_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_cmd(brain.clone(), guild, create, command_data).await; - return; - } - }; - - // Finally, send back the response - brain.interaction_respond(create, data).await; -} - -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() -} - -enum Style { - TiesEqual, - TiesBroken, -} - -fn get_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() - } - } -} - -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) => { - println!("raw: {raw}"); - let mentions = extract_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() -} - -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()), - _ => panic!(), - }; - } - - 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" - )) -} - -async fn import_cmd( - 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; - } -} - -enum Commands { - About, - Leaderboard, - Points, - Permission, - Import, -} - -fn extract_mentions(mut raw: &str) -> Vec> { - let mut ret = vec![]; - - loop { - let Some(start) = raw.find("<@") else { - break; - }; - - let Some(end) = raw.find('>') else { - break; - }; - - let id_str = &raw[start + 2..end]; - raw = &raw[end + 1..]; - - let Ok(id) = u64::from_str_radix(id_str, 10) else { - continue; - }; - - ret.push(Id::new(id)); - } - - ret -} diff --git a/src/util.rs b/src/util.rs index a05eeb9..a746cb3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,7 +1,36 @@ //! Uh-oh, a `util.rs` file. That can't be good. +use twilight_model::id::{Id, marker::UserMarker}; + use crate::database::{BoardRow, Placement}; +#[macro_export] +macro_rules! bail { + ($msg:expr) => { + return twilight_util::builder::InteractionResponseDataBuilder::new() + .content(&format!("❌ {}", $msg)) + .build() + }; +} + +#[macro_export] +macro_rules! fail { + ($msg:expr) => { + twilight_util::builder::InteractionResponseDataBuilder::new() + .content(&format!("❌ {}", $msg)) + .build() + }; +} + +#[macro_export] +macro_rules! success { + ($msg:expr) => { + twilight_util::builder::InteractionResponseDataBuilder::new() + .content(&format!("✅ {}", $msg)) + .build() + }; +} + pub fn tiebreak_shared_positions(board: Vec) -> Vec { let first = match board.first() { Some(r) => r.clone(), @@ -40,3 +69,29 @@ pub fn tiebreak_shared_positions(board: Vec) -> Vec { placed } + +/// Takes a `&str` and extracts all mentions of users, sticking them in a Vec +pub fn extract_user_mentions(mut raw: &str) -> Vec> { + let mut ret = vec![]; + + loop { + let Some(start) = raw.find("<@") else { + break; + }; + + let Some(end) = raw.find('>') else { + break; + }; + + let id_str = &raw[start + 2..end]; + raw = &raw[end + 1..]; + + let Ok(id) = u64::from_str_radix(id_str, 10) else { + continue; + }; + + ret.push(Id::new(id)); + } + + ret +} -- cgit 1.4.1-3-g733a5