diff options
-rw-r--r-- | Cargo.lock | 57 | ||||
-rw-r--r-- | Cargo.toml | 6 | ||||
-rw-r--r-- | README.md | 33 | ||||
-rw-r--r-- | TODO | 19 | ||||
-rw-r--r-- | src/brain.rs | 123 | ||||
-rw-r--r-- | src/command/add_points.rs | 64 | ||||
-rw-r--r-- | src/command/leaderboard.rs | 91 | ||||
-rw-r--r-- | src/command/mod.rs | 367 | ||||
-rw-r--r-- | src/command/revise.rs | 82 | ||||
-rw-r--r-- | src/database.rs | 86 | ||||
-rw-r--r-- | src/import.rs | 54 | ||||
-rw-r--r-- | src/import/mod.rs | 126 | ||||
-rw-r--r-- | src/main.rs | 477 | ||||
-rw-r--r-- | src/util.rs | 97 |
14 files changed, 1196 insertions, 486 deletions
diff --git a/Cargo.lock b/Cargo.lock index 9d048cd..4202860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,12 +482,14 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "time", "tokio", "twilight-cache-inmemory", "twilight-gateway", "twilight-http", "twilight-model", "twilight-util", + "ureq", ] [[package]] @@ -726,6 +728,7 @@ version = "0.23.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -747,6 +750,15 @@ dependencies = [ ] [[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] name = "rustls-pki-types" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1256,6 +1268,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] +name = "ureq" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a3e9af6113ecd57b8c63d3cd76a385b2e3881365f1f489e54f49801d0c83ea" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadf18427d33828c311234884b7ba2afb57143e6e7e69fda7ee883b624661e36" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1296,6 +1344,15 @@ dependencies = [ ] [[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index 9ff4b6a..0671666 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,9 @@ twilight-gateway = "0.16.0" twilight-http = "0.16.0" twilight-model = "0.16.0" twilight-util = { version = "0.16.0", features = ["builder"] } +ureq = { version = "3.0.11", optional = true } +time = { version = "0.3.41", features = ["parsing", "formatting"] } + +[features] +default = ["emboard-direct"] +emboard-direct = ["ureq"] diff --git a/README.md b/README.md index e515a22..3ea8a9e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,35 @@ -EMBOARD is end-of-life and I have a need for a leaderboard bot. +EMBOARD is end-of-life and I have a need for a leaderboard bot. How hard could it be? Uh oh, the code is kinda bad. [Invite Link](https://discord.com/oauth2/authorize?client_id=1363055126264283136&permissions=2048&integration_type=0&scope=bot+applications.commands) -Requirements- We want to do what emboard can at least. here is what we do so far. -- `leaderboard`: Displays the leaderboard. -- `points [points] [user]`: Change the points of the mentioned users +<!-- [Invite Link](https://discord.com/oauth2/authorize?client_id=1363494986699771984&permissions=2048&integration_type=0&scope=bot+applications.commands) --> + +**Commands** +- `about`: Get information about the bot. +- `leaderboard`: Displays the leaderboard. With an optional `[style]` argument to change how it displays. +- `points [points] [users]`: Change the points of the mentioned users - `permission none`: No permissions are required to change the score; anyone may set the score. This command requires the Manage Server permission. -- `permission role [role]`: A specific role is required to change the score. This command requires the Manage Server permission. \ No newline at end of file +- `permission role [role]`: A specific role is required to change the score. This command requires the Manage Server permission. +- `revise [points] [users] [date]`: Change the points of the mentioned users, and make that change apply to some different date than today. + +**Leaderboard Display Options** +The leaderboard display options, the `style` argument of the leaderboard command, change how ties are displayed. Ties will always be ordered by who was there sooner. + +`Ties Equal` puts ties in the same placement. It will look like this: +```raw +1. genny: 10 +2. medley: 5 + inann: 5 +4. taiga: 3 +``` + +`Ties Broken` puts ties in different placements. It will look like this: +```raw +1. genny: 10 +2. medley: 5 +3. inann: 5 +4. taiga: 3 +``` diff --git a/TODO b/TODO index 0057cf8..aef2fc3 100644 --- a/TODO +++ b/TODO @@ -2,4 +2,21 @@ Use the cache! We do not- I mean, we set the cache /up/ but we do not make use of it kind of really at all. I think the only place we kind of need this is in `add_points` where - we get the members of the guild. \ No newline at end of file + we get the members of the guild. + +Better logging + There's like, an user now, so we should log better so we can find errors + more readily. + +Make Date Parsing Better + I think we'll have to write the parser here. Or rip it from awake, really. + + So we can have any of: + 2025-06-12 + 2025-6-12 + 2025-6-12 4:24 + 2025-6-12 4:24:43 + 2025-6-12 4:24:43am + +Wrong count of noun used when 1 point is added + "1 points added" \ No newline at end of file diff --git a/src/brain.rs b/src/brain.rs new file mode 100644 index 0000000..daa6f6f --- /dev/null +++ b/src/brain.rs @@ -0,0 +1,123 @@ +use crate::{APP_ID, database::Database}; + +use twilight_cache_inmemory::DefaultInMemoryCache; +use twilight_gateway::Event; +use twilight_http::{Client as HttpClient, client::InteractionClient}; +use twilight_model::{ + channel::message::Embed, + gateway::payload::incoming::InteractionCreate, + http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType}, + id::{ + Id, + marker::{ChannelMarker, GuildMarker, MessageMarker, UserMarker}, + }, +}; + +// A Absolutely Remarkably Terrible Thing +macro_rules! aumau { + ($meow:expr) => { + $meow.await.unwrap().model().await.unwrap() + }; +} + +pub struct Brain { + pub db: Database, + http: HttpClient, + cache: DefaultInMemoryCache, +} + +impl Brain { + pub fn new(db: Database, http: HttpClient, cache: DefaultInMemoryCache) -> Self { + Self { db, http, cache } + } + + pub fn update_cache(&self, event: &Event) { + self.cache.update(event); + } + + /// Get the interaction client. Used for responding to interactions + pub fn interaction(&self) -> InteractionClient<'_> { + self.http.interaction(Id::new(APP_ID)) + } + + /// Respond to the interaction indicated by `create`. This method will + /// respond as an `InteractionResponseType::ChannelMessageWithSource` + pub async fn interaction_respond( + &self, + create: &InteractionCreate, + data: InteractionResponseData, + ) { + self.interaction() + .create_response( + create.id, + &create.token, + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(data), + }, + ) + .await + .unwrap(); + } + + pub async fn send_message_with_embed<S: AsRef<str>>( + &self, + channel: Id<ChannelMarker>, + msg: S, + embed: Embed, + ) -> Id<MessageMarker> { + aumau!( + self.http + .create_message(channel) + .embeds(&[embed]) + .content(msg.as_ref()) + ) + .id + } + + /// Retrieve information about a member of a guild. This method checks the + /// cache first, and then makes a request if it isn't there + pub async fn guild_member(&self, guild_id: Id<GuildMarker>, user_id: Id<UserMarker>) -> Member { + let user = self.cache.user(user_id); + let member = self.cache.member(guild_id, user_id); + + match user.zip(member) { + Some((user, member)) => Member { + id: user_id, + handle: user.name.clone(), + global_name: user.global_name.clone(), + nick: member.nick().map(<_>::to_owned), + }, + None => { + let member = aumau!(self.http.guild_member(guild_id, user_id)); + + Member { + id: user_id, + handle: member.user.name, + global_name: member.user.global_name, + nick: member.nick, + } + } + } + } + + /// Check the database for a leaderboard belonging to this guild. If it + /// does not exist, create one. Returns whether or not one existed before + /// this function was called + pub fn create_leaderboard_if_not_exists(&self, guild: Id<GuildMarker>) -> bool { + if !self.db.leaderboard_exits(guild.get()) { + self.db.create_leaderboard(guild.get()).unwrap(); + false + } else { + true + } + } +} + +pub struct Member { + #[allow(dead_code)] + pub id: Id<UserMarker>, + pub handle: String, + pub global_name: Option<String>, + pub nick: Option<String>, +} diff --git a/src/command/add_points.rs b/src/command/add_points.rs new file mode 100644 index 0000000..97e3981 --- /dev/null +++ b/src/command/add_points.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use twilight_model::{ + application::interaction::application_command::CommandData, + gateway::payload::incoming::InteractionCreate, + http::interaction::InteractionResponseData, + id::{Id, marker::GuildMarker}, +}; +use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder}; + +use crate::{ + brain::Brain, + command::{check_command_permissions, parse_points_command_data}, + database::{BoardRow, Error as DbError}, +}; + +pub async fn add_points( + brain: Arc<Brain>, + guild: Id<GuildMarker>, + create: &InteractionCreate, + data: &CommandData, +) -> InteractionResponseData { + let point_data = match parse_points_command_data(data) { + Err(e) => return e, + Ok(pcd) => pcd, + }; + + brain.create_leaderboard_if_not_exists(guild); + if let Some(err) = check_command_permissions(&brain, guild, create.member.clone().unwrap()) { + return err; + } + + for user in &point_data.users { + match brain + .db + .give_user_points(guild.get(), user.get(), point_data.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: point_data.points, + }; + brain.db.add_user_to_leaderboard(guild.get(), row).unwrap(); + } + _ => (), + } + } + + let users_string = point_data.user_string(); + let points_verb = point_data.points_verb; + + let msg = format!( + "{} points {points_verb} {users_string}", + point_data.points.abs() + ); + + 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<Brain>, + guild: Id<GuildMarker>, + 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::<Vec<String>>() + .join("\n"), + Style::TiesBroken => placed + .into_iter() + .map(|placement| { + format!( + "`{:>2}` <@{}>: {}", + placement.placement, placement.row.user_id, placement.row.points + ) + }) + .collect::<Vec<String>>() + .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..c3dc7a9 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,367 @@ +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<Brain>, + guild: Id<GuildMarker>, + 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<Command> { + 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<Brain>, _guild: Id<GuildMarker>) -> 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<Brain>, + guild: Id<GuildMarker>, + 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<Brain>, guild: Id<GuildMarker>) -> 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<Brain>, + guild: Id<GuildMarker>, + 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<Brain>, + guild: Id<GuildMarker>, + 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<GuildMarker>, + member: PartialMember, +) -> Option<InteractionResponseData> { + 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<Id<UserMarker>>, + date: Result<PrimitiveDateTime, InteractionResponseData>, +} + +impl PointsCommandData { + pub fn user_string(&self) -> String { + self.users + .iter() + .map(|u| format!("<@{u}>")) + .collect::<Vec<String>>() + .join(", ") + } +} + +pub fn parse_points_command_data( + data: &CommandData, +) -> Result<PointsCommandData, InteractionResponseData> { + let mut points = None; + let mut users: Vec<Id<UserMarker>> = 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, + }) +} diff --git a/src/command/revise.rs b/src/command/revise.rs new file mode 100644 index 0000000..50b8c4e --- /dev/null +++ b/src/command/revise.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use time::format_description; +use twilight_model::{ + application::interaction::application_command::CommandData, + gateway::payload::incoming::InteractionCreate, + http::interaction::InteractionResponseData, + id::{Id, marker::GuildMarker}, +}; +use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder}; + +use crate::{ + brain::Brain, + command::{check_command_permissions, parse_points_command_data}, + database::{BoardRow, Error as DbError}, +}; + +pub async fn revise( + brain: Arc<Brain>, + guild: Id<GuildMarker>, + create: &InteractionCreate, + data: &CommandData, +) -> InteractionResponseData { + let (point_data, date) = match parse_points_command_data(data) { + Err(e) => return e, + Ok(pcd) => match pcd.date { + Err(e) => return e, + Ok(date) => (pcd, date), + }, + }; + + brain.create_leaderboard_if_not_exists(guild); + if let Some(err) = check_command_permissions(&brain, guild, create.member.clone().unwrap()) { + return err; + } + + for user in &point_data.users { + match brain + .db + .give_user_points(guild.get(), user.get(), point_data.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: point_data.points, + }; + brain.db.add_user_to_leaderboard(guild.get(), row).unwrap(); + } + _ => (), + } + + //FIXME: gen 2025-08-14: do not assume UTC! + brain + .db + .revise_last_history_date( + guild.get(), + user.get(), + point_data.points, + date.assume_utc(), + ) + .unwrap(); + } + + let datetime_fmt = + format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap(); + + let points_verb = point_data.points_verb; + let users_string = point_data.user_string(); + + let msg = format!( + "{} points {points_verb} {users_string} on date {}", + point_data.points.abs(), + date.format(&datetime_fmt).unwrap() + ); + let embed = EmbedBuilder::new().description(msg).build(); + InteractionResponseDataBuilder::new() + .embeds([embed]) + .build() +} diff --git a/src/database.rs b/src/database.rs index 0e799d3..f496e65 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,6 +1,7 @@ use std::{path::Path, sync::Mutex}; use rusqlite::{Connection, OptionalExtension, params}; +use time::{OffsetDateTime, format_description}; #[derive(Debug)] pub struct Database { @@ -66,24 +67,25 @@ impl Database { } pub fn leaderboard_exits(&self, guild_id: u64) -> bool { - self.leaderboard_id(guild_id).is_err() + self.leaderboard_id(guild_id).is_ok() } pub fn get_leaderboard(&self, guild_id: u64) -> Result<Vec<BoardRow>, Error> { // Don't deadlock! let lb = self.leaderboard_id(guild_id)?; + let query = lb.sql_leaderboard_query(); let conn = self.conn.lock().unwrap(); - let mut query = conn - .prepare(&lb.sql("SELECT * FROM leaderboard_LBID")) - .unwrap(); + // The query that get's run sorts ties by preferring who got to that score first + let mut query = conn.prepare(&lb.sql(query)).unwrap(); + let vec = query .query_map((), |row| { Ok(BoardRow { - user_id: row.get(0)?, - user_handle: row.get(1)?, - user_nickname: row.get(2)?, - points: row.get(3)?, + user_id: row.get(1)?, + user_handle: row.get(2)?, + user_nickname: row.get(3)?, + points: row.get(4)?, }) }) .optional() @@ -167,7 +169,7 @@ impl Database { let conn = self.conn.lock().unwrap(); let sql = lb.sql("SELECT * FROM leaderboard_LBID WHERE user_id=?1"); - let user_handle: String = conn + let _user_handle: String = conn .query_row(&sql, params![user], |row| row.get(1)) .map_err(|_| Error::UserNotExist)?; @@ -176,6 +178,42 @@ impl Database { Ok(()) } + + pub fn revise_last_history_date( + &self, + guild_id: u64, + user: u64, + points: i64, + date: OffsetDateTime, + ) -> Result<(), Error> { + let lb = self.leaderboard_id(guild_id)?; + let conn = self.conn.lock().unwrap(); + + let sql = lb.sql( + "SELECT id, user_id, timestamp, points FROM leaderboard_history_LBID \ + WHERE user_id=?1 AND points=?2 \ + ORDER BY timestamp DESC LIMIT 1", + ); + + let hist_id: i64 = conn + .query_row(&sql, params![user, points], |row| row.get(0)) + .unwrap(); + + println!("hist_id = {hist_id}"); + + let update_sql = lb.sql("UPDATE leaderboard_history_LBID SET timestamp = ?1 WHERE id = ?2"); + + let datetime_fmt = + format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap(); + + conn.execute( + &update_sql, + params![date.format(&datetime_fmt).unwrap(), hist_id], + ) + .unwrap(); + + Ok(()) + } } pub enum PermissionSetting { @@ -244,6 +282,19 @@ const CREATE_TRIGGER_LDBHIST_INSERT: &'static str = "\ INSERT INTO leaderboard_history_LBID(user_id, points) VALUES(new.user_id, new.points); END;"; +const LEADERBOARD_QUERY: &'static str = "\ + SELECT + history.timestamp, + leaderboard_LBID.* + FROM leaderboard_LBID + JOIN ( + SELECT user_id, max(timestamp) as timestamp + FROM leaderboard_history_LBID + GROUP BY user_id + ) history + ON history.user_id = leaderboard_LBID.user_id + ORDER BY leaderboard_LBID.points DESC, history.timestamp ASC;"; + struct LeaderboardTable { id: usize, } @@ -272,6 +323,10 @@ impl LeaderboardTable { pub fn sql_create_insert_trigger_history_table(&self) -> String { CREATE_TRIGGER_LDBHIST_INSERT.replace("LBID", &self.id.to_string()) } + + pub fn sql_leaderboard_query(&self) -> String { + LEADERBOARD_QUERY.replace("LBID", &self.id.to_string()) + } } #[derive(Clone, Debug)] @@ -282,6 +337,19 @@ pub struct BoardRow { pub points: i64, } +#[derive(Clone, Debug)] +pub struct Placement { + pub row: BoardRow, + + /// Absolute placement sorting by points and then + /// TODO: sort by date points attained + pub placement: usize, + /// Placement allowing ties, and placing ties in the same place + pub placement_tie: usize, + /// Whether or not this placement was first of a tie. True if untied. + pub placement_first_of_tie: bool, +} + #[derive(Debug)] pub enum Error { TableNotExist, diff --git a/src/import.rs b/src/import.rs deleted file mode 100644 index f7d9873..0000000 --- a/src/import.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serde::Deserialize; - -use crate::database::{BoardRow, Database, Error}; - -#[derive(Debug, Deserialize)] -pub struct Emboard { - guildName: String, - leaderboard: Vec<EmboardRow>, -} - -#[derive(Debug, Deserialize)] -pub struct EmboardRow { - guildId: String, - discordId: String, - points: String, - username: String, -} - -pub fn import(db: &Database, json: String) { - let embaord: Emboard = match serde_json::from_str(&json) { - Ok(e) => e, - Err(e) => { - panic!("{e}"); - } - }; - - let Some(first) = embaord.leaderboard.first() else { - return; - }; - - let guild_id = u64::from_str_radix(&first.guildId, 10).unwrap(); - if db.get_leaderboard(guild_id).is_err() { - db.create_leaderboard(guild_id).unwrap(); - } - - for user in embaord.leaderboard { - let user_id = u64::from_str_radix(&user.discordId, 10).unwrap(); - let points = i64::from_str_radix(&user.points, 10).unwrap(); - - let res = db.give_user_points(guild_id, user_id, points); - if let Err(Error::UserNotExist) = res { - db.add_user_to_leaderboard( - guild_id, - BoardRow { - user_id, - user_handle: user.username, - user_nickname: None, - points, - }, - ) - .unwrap(); - } - } -} diff --git a/src/import/mod.rs b/src/import/mod.rs new file mode 100644 index 0000000..03e2bed --- /dev/null +++ b/src/import/mod.rs @@ -0,0 +1,126 @@ +#![allow(non_snake_case)] +use serde::Deserialize; + +use crate::database::{BoardRow, Database, Error}; + +#[derive(Debug, Deserialize)] +pub struct Emboard { + #[allow(dead_code)] + guildName: String, + leaderboard: Vec<EmboardRow>, +} + +#[derive(Debug, Deserialize)] +pub struct EmboardRow { + guildId: String, + discordId: String, + points: String, + username: String, +} + +pub fn import(db: &Database, json: String) { + let embaord: Emboard = match serde_json::from_str(&json) { + Ok(e) => e, + Err(e) => { + panic!("{e}"); + } + }; + + let Some(first) = embaord.leaderboard.first() else { + return; + }; + + let guild_id = u64::from_str_radix(&first.guildId, 10).unwrap(); + if db.get_leaderboard(guild_id).is_err() { + db.create_leaderboard(guild_id).unwrap(); + } + + for user in embaord.leaderboard { + let user_id = u64::from_str_radix(&user.discordId, 10).unwrap(); + let points = i64::from_str_radix(&user.points, 10).unwrap(); + + let res = db.give_user_points(guild_id, user_id, points); + if let Err(Error::UserNotExist) = res { + db.add_user_to_leaderboard( + guild_id, + BoardRow { + user_id, + user_handle: user.username, + user_nickname: None, + points, + }, + ) + .unwrap(); + } + } +} + +#[cfg(feature = "emboard-direct")] +pub mod emboard_direct { + use std::sync::Arc; + + use twilight_model::{ + gateway::payload::incoming::InteractionCreate, + id::{Id, marker::GuildMarker}, + }; + use twilight_util::builder::InteractionResponseDataBuilder; + use ureq::config::Config; + + use crate::{brain::Brain, command}; + + const DATA_URL: &str = + "https://emboard.evolvedmesh.com/api/backend/dashboard/emboard_guild/leaderboard"; + + pub async fn import(brain: Arc<Brain>, guild_id: Id<GuildMarker>, create: &InteractionCreate) { + let member = create.member.clone().unwrap(); + let permissions = member.permissions.unwrap(); + if !permissions.contains(twilight_model::guild::Permissions::MANAGE_GUILD) { + brain + .interaction_respond( + create, + InteractionResponseDataBuilder::new() + .content( + "❌ You must have the Manage Server permission to perform this action", + ) + .build(), + ) + .await; + + return; + } + + let url = format!("{DATA_URL}/{guild_id}"); + let config = Config::builder() + .user_agent("leaberblord +gen@nyble.dev") + .build(); + + let data = InteractionResponseDataBuilder::new() + .content( + "⏳ waiting on emboard for the leaderboard. we'll display the leaderboard when it's imported", + ) + .build(); + + brain.interaction_respond(create, data).await; + + match config.new_agent().get(url).call() { + Err(_e) => todo!(), + Ok(mut resp) => { + let body: String = resp.body_mut().read_to_string().unwrap(); + super::import(&brain.db, body); + + let embed = command::leaderboard(brain.clone(), guild_id, None) + .embeds + .unwrap()[0] + .clone(); + + brain + .send_message_with_embed( + create.channel.as_ref().unwrap().id, + "✅ imported leaderboard from emboard", + embed, + ) + .await; + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 13bac74..d02c9c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,147 +1,26 @@ 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, client::InteractionClient}; -use twilight_model::{ - application::{ - command::{Command, CommandType}, - interaction::{ - InteractionData, - application_command::{CommandData, CommandOptionValue}, - }, - }, - gateway::payload::incoming::InteractionCreate, - http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType}, - id::{ - Id, - marker::{GuildMarker, UserMarker}, - }, -}; -use twilight_util::builder::{ - InteractionResponseDataBuilder, - command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder}, - embed::EmbedBuilder, -}; +use twilight_http::Client as HttpClient; +use twilight_model::{application::interaction::InteractionData, id::Id}; +use crate::command::Commands; + +mod brain; +mod command; mod database; mod import; +mod util; const PROD_APP_ID: u64 = 1363055126264283136; +#[allow(dead_code)] const DEV_APP_ID: u64 = 1363494986699771984; const APP_ID: u64 = PROD_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() - }; -} - -// A Absolutely Remarkably Terrible Thing -macro_rules! aumau { - ($meow:expr) => { - $meow.await.unwrap().model().await.unwrap() - }; -} - -struct Brain { - db: Database, - http: HttpClient, - cache: DefaultInMemoryCache, -} - -impl Brain { - /// Get the interaction client. Used for responding to interactions - pub fn interaction(&self) -> InteractionClient<'_> { - self.http.interaction(Id::new(APP_ID)) - } - - /// Respond to the interaction indicated by `create`. This method will - /// respond as an `InteractionResponseType::ChannelMessageWithSource` - pub async fn interaction_respond( - &self, - create: &InteractionCreate, - data: InteractionResponseData, - ) { - self.interaction() - .create_response( - create.id, - &create.token, - &InteractionResponse { - kind: InteractionResponseType::ChannelMessageWithSource, - data: Some(data), - }, - ) - .await - .unwrap(); - } - - /// Retrieve information about a member of a guild. This method checks the - /// cache first, and then makes a request if it isn't there - pub async fn guild_member(&self, guild_id: Id<GuildMarker>, user_id: Id<UserMarker>) -> Member { - let user = self.cache.user(user_id); - let member = self.cache.member(guild_id, user_id); - - match user.zip(member) { - Some((user, member)) => Member { - id: user_id, - handle: user.name.clone(), - global_name: user.global_name.clone(), - nick: member.nick().map(<_>::to_owned), - }, - None => { - let member = aumau!(self.http.guild_member(guild_id, user_id)); - - Member { - id: user_id, - handle: member.user.name, - global_name: member.user.global_name, - nick: member.nick, - } - } - } - } - - /// Check the database for a leaderboard belonging to this guild. If it - /// does not exist, create one. Returns whether or not one existed before - /// this function was called - pub fn create_leaderboard_if_not_exists(&self, guild: Id<GuildMarker>) -> bool { - if !self.db.leaderboard_exits(guild.get()) { - self.db.create_leaderboard(guild.get()).unwrap(); - false - } else { - true - } - } -} - -struct Member { - id: Id<UserMarker>, - handle: String, - global_name: Option<String>, - nick: Option<String>, -} - #[tokio::main] async fn main() -> anyhow::Result<()> { //dotenv::dotenv().ok(); @@ -156,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") => { @@ -179,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) @@ -188,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!"); @@ -212,22 +129,11 @@ async fn main() -> anyhow::Result<()> { ); } - let brain = Arc::new(Brain { db, http, cache }); - - while let Some(item) = shard.next_event(EventTypeFlags::all()).await { - let Ok(event) = item else { - eprintln!("error receiving event"); - continue; - }; - - brain.cache.update(&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<Brain>) -> Result<(), Box<dyn Error + Send + Sync>> { match event { Event::InteractionCreate(create) => { @@ -246,13 +152,16 @@ async fn handle_event(event: Event, brain: Arc<Brain>) -> Result<(), Box<dyn Err let guild = create.guild.as_ref().unwrap().id.unwrap(); let command = match cmd.name.as_str() { + "about" => Commands::About, "leaderboard" => Commands::Leaderboard, "points" => Commands::Points, + "revise" => Commands::Revise, "permission" => Commands::Permission, + "import" => Commands::Import, _ => 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) => { @@ -263,269 +172,3 @@ async fn handle_event(event: Event, brain: Arc<Brain>) -> Result<(), Box<dyn Err Ok(()) } - -async fn commands() -> Vec<Command> { - let leaderboard = CommandBuilder::new( - "leaderboard", - "View the server leaderboard", - CommandType::ChatInput, - ) - .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(); - - vec![leaderboard, points, permission] -} - -async fn command_handler( - brain: Arc<Brain>, - guild: Id<GuildMarker>, - create: &InteractionCreate, - command: Commands, - command_data: &CommandData, -) { - // Handle the command and create our interaction response data - let data = match command { - Commands::Leaderboard => get_leaderboard(brain.clone(), guild), - Commands::Points => add_points(brain.clone(), guild, create, command_data).await, - Commands::Permission => permission(brain.clone(), guild, create, command_data).await, - }; - - // Finally, send back the response - brain.interaction_respond(create, data).await; -} - -fn get_leaderboard(brain: Arc<Brain>, guild: Id<GuildMarker>) -> InteractionResponseData { - let board = brain.db.get_leaderboard(guild.get()); - - 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 str = data - .into_iter() - .enumerate() - .map(|(idx, br)| format!("{idx}. <@{}>: {}", br.user_id, br.points)) - .collect::<Vec<String>>() - .join("\n"); - - let embed = EmbedBuilder::new().description(str).build(); - InteractionResponseDataBuilder::new() - .embeds([embed]) - .build() - } - } -} - -async fn add_points( - brain: Arc<Brain>, - guild: Id<GuildMarker>, - create: &InteractionCreate, - data: &CommandData, -) -> InteractionResponseData { - let mut points = None; - let mut users: Vec<Id<UserMarker>> = 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::<Vec<String>>() - .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<Brain>, - guild: Id<GuildMarker>, - 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 command"); - } - - 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<Brain>, guild: Id<GuildMarker>) -> 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<Brain>, - guild: Id<GuildMarker>, - 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" - )) -} - -enum Commands { - Leaderboard, - Points, - Permission, -} - -fn extract_mentions(mut raw: &str) -> Vec<Id<UserMarker>> { - 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 new file mode 100644 index 0000000..a746cb3 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,97 @@ +//! 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<BoardRow>) -> Vec<Placement> { + let first = match board.first() { + Some(r) => r.clone(), + None => return vec![], + }; + + let mut last_score = first.points; + let mut last_placed = 1; + let mut placed = vec![Placement { + row: first, + placement: 1, + placement_tie: 1, + placement_first_of_tie: true, + }]; + + for (idx, row) in board.into_iter().enumerate().skip(1) { + if row.points == last_score { + placed.push(Placement { + row, + placement: idx + 1, + placement_tie: last_placed, + placement_first_of_tie: false, + }); + } else { + last_score = row.points; + last_placed = idx + 1; + + placed.push(Placement { + row, + placement: idx + 1, + placement_tie: last_placed, + placement_first_of_tie: true, + }); + } + } + + placed +} + +/// Takes a `&str` and extracts all mentions of users, sticking them in a Vec +pub fn extract_user_mentions(mut raw: &str) -> Vec<Id<UserMarker>> { + 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 +} |