diff --git a/.env.sample b/.env.sample index e7ce847..1101911 100644 --- a/.env.sample +++ b/.env.sample @@ -3,6 +3,7 @@ ROOT_URL=https://root.amfoss.in/ OWNER_ID= DEBUG=true ENABLE_DEBUG_LIBRARIES=false +RECENT_PICKS_PATH= AMD_API_KEY= AMD_APP_PASSWORD= AMD_EMAIL_ID= diff --git a/Cargo.lock b/Cargo.lock index b8cb852..e795852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,7 @@ dependencies = [ "imap", "mailparse", "poise", + "rand", "reqwest 0.12.23", "serde", "serde_json", @@ -86,7 +87,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -350,7 +351,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -361,7 +362,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -423,7 +424,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -566,7 +567,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -721,7 +722,7 @@ dependencies = [ "markup5ever", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1047,7 +1048,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1400,7 +1401,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1442,7 +1443,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1574,7 +1575,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1615,7 +1616,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "version_check", "yansi", ] @@ -2031,7 +2032,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2246,9 +2247,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.96" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2278,7 +2279,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2371,7 +2372,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2451,7 +2452,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2589,7 +2590,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2702,7 +2703,7 @@ checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2830,7 +2831,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2865,7 +2866,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3172,7 +3173,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "synstructure", ] @@ -3194,7 +3195,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -3214,7 +3215,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "synstructure", ] @@ -3243,5 +3244,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index 12a811c..ca65bce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,5 @@ poise = "0.6.1" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } imap = "3.0.0-alpha.12" mailparse = "0.15" -html2text = "0.12" \ No newline at end of file +html2text = "0.12" +rand = "0.8.5" \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b114346..12daea8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,7 @@ +mod random; mod set_log_level; +use crate::commands::random::random; use crate::commands::set_log_level::set_log_level; use serenity::all::RoleId; use tracing::{debug, instrument}; @@ -31,7 +33,7 @@ async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { /// Returns a vector containg [Poise Commands][`poise::Command`] pub fn get_commands() -> Vec> { - let commands = vec![amdctl(), set_log_level()]; + let commands = vec![amdctl(), set_log_level(), random()]; debug!(commands = ?commands.iter().map(|c| &c.name).collect::>()); commands } diff --git a/src/commands/random.rs b/src/commands/random.rs new file mode 100644 index 0000000..43cf149 --- /dev/null +++ b/src/commands/random.rs @@ -0,0 +1,112 @@ +use crate::{ + ids::{FIRST_YEAR_ROLE_ID, SECOND_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, + Context, Error, +}; +use rand::seq::IteratorRandom; +use serenity::all::{Mentionable as _, Role, RoleId, UserId}; +use std::collections::HashSet; + +#[poise::command(slash_command)] +pub async fn random( + ctx: Context<'_>, + count: Option, + role1: Option, + role2: Option, + role3: Option, +) -> Result<(), Error> { + let guild = ctx.guild_id().ok_or("No guild id")?; + let members = guild.members(ctx.http(), None, None).await?; + + let count = count.unwrap_or(3) as usize; + let mut selected_roles: HashSet = [role1, role2, role3] + .into_iter() + .flatten() + .map(|role| role.id) + .collect(); + + if selected_roles.is_empty() { + selected_roles.extend([ + RoleId::new(FIRST_YEAR_ROLE_ID), + RoleId::new(SECOND_YEAR_ROLE_ID), + RoleId::new(THIRD_YEAR_ROLE_ID), + ]); + } + + let eligible_members: Vec<_> = members // Filtering out bots and other ineligible members + .into_iter() + .filter(|m| !m.user.bot && (m.roles.iter().any(|role| selected_roles.contains(role)))) + .collect(); + + if eligible_members.is_empty() { + ctx.say("No eligible members found.").await?; + return Ok(()); + } + + let recent_picks = { + // Accessing recently picked members to avoid repetition + let data = ctx.data(); + let recent_random_picks = data.recent_random_picks.lock().unwrap(); + recent_random_picks.clone() + }; + + let available_members: Vec<_> = eligible_members // Members who haven't been picked recently + .iter() + .filter(|member| !recent_picks.contains(&member.user.id)) + .collect(); + + /* Since ThreadRng is not Send, keeping it alive across an .await causes command future to become non-Send. + So, we are enclosing the selection in it's own scope so the ThreadRng is dropped before we hit any .await, + allowing the command future to remain Send + */ + + let selected = { + let mut rng = rand::thread_rng(); + + let mut selected: Vec<_> = available_members + .into_iter() + .choose_multiple(&mut rng, count); + + if selected.len() < count { + // If not enough members are available, fetch recently picked members + let remaining_needed = count - selected.len(); + + let selected_ids: HashSet = selected.iter().map(|m| m.user.id).collect(); + + let additional: Vec<_> = eligible_members + .iter() + .filter(|m| !selected_ids.contains(&m.user.id)) + .choose_multiple(&mut rng, remaining_needed); + + selected.extend(additional); + } + selected + }; + + let ping_message = selected + .iter() + .map(|m| m.user.mention().to_string()) + .collect::>() + .join("\n"); + + { + let data = ctx.data(); + let mut recent_random_picks = data.recent_random_picks.lock().unwrap(); + recent_random_picks.extend(selected.iter().map(|m| m.user.id)); // Adding selected members to recently picked set + + let eligible_ids: HashSet = eligible_members.iter().map(|m| m.user.id).collect(); + recent_random_picks.retain(|user_id| eligible_ids.contains(user_id)); + if recent_random_picks.len() >= eligible_ids.len() { + // If all eligible members have been picked atleast once, clear the recently picked set + recent_random_picks.clear(); + } + } // guard dropped here, mutex unlocked + ctx.data().save_recent_picks(); + ctx.say(format!( + "Pinging {} members: {}", + selected.len(), + ping_message + )) + .await?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index ad63e4f..66df091 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,7 @@ pub struct Config { pub prefix_string: String, pub root_url: String, pub api_key: String, + pub recent_picks_path: String, } impl Default for Config { @@ -48,6 +49,8 @@ impl Default for Config { prefix_string: String::from("$"), root_url: std::env::var("ROOT_URL").expect("ROOT_URL was not found in env"), api_key: std::env::var("AMD_API_KEY").expect("AMD_API_KEY was not found in env"), + recent_picks_path: std::env::var("RECENT_PICKS_PATH") + .unwrap_or_else(|_| String::from("data/recent_picks.json")), } } } diff --git a/src/ids.rs b/src/ids.rs index df3780d..220f8eb 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -47,3 +47,6 @@ pub const GROUP_ONE_STATUS_UPDATE_CHANNEL_ID: u64 = 1225098248293716008; pub const GROUP_TWO_STATUS_UPDATE_CHANNEL_ID: u64 = 1225098298935738489; pub const GROUP_THREE_STATUS_UPDATE_CHANNEL_ID: u64 = 1225098353378070710; pub const GROUP_FOUR_STATUS_UPDATE_CHANNEL_ID: u64 = 1225098407216156712; + +pub const FIRST_YEAR_ROLE_ID: u64 = 1283689015450669108; +pub const SECOND_YEAR_ROLE_ID: u64 = 1288903184990994522; diff --git a/src/main.rs b/src/main.rs index ca94b3a..de181b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,11 @@ use serenity::{ use trace::{setup_tracing, ReloadHandle}; use tracing::{debug, info, instrument}; -use std::collections::HashMap; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + sync::{Arc, Mutex}, +}; type Error = Box; type Context<'a> = PoiseContext<'a, Data, Error>; @@ -49,19 +53,48 @@ type Context<'a> = PoiseContext<'a, Data, Error>; #[derive(Clone)] struct Data { reaction_roles: HashMap, + recent_random_picks: Arc>>, + recent_picks_path: PathBuf, log_reload_handle: ReloadHandle, graphql_client: GraphQLClient, } impl Data { /// Returns a new [`Data`] with an empty `reaction_roles` field and the passed-in `reload_handle`. - fn new(reload_handle: ReloadHandle, root_url: String, api_key: String) -> Self { + fn new( + reload_handle: ReloadHandle, + root_url: String, + api_key: String, + recent_picks_path: String, + ) -> Self { + let recent_picks_path = PathBuf::from(recent_picks_path); + let recent_random_picks = Arc::new(Mutex::new(Self::load_recent_picks(&recent_picks_path))); Data { reaction_roles: HashMap::new(), + recent_random_picks, + recent_picks_path, log_reload_handle: reload_handle, graphql_client: GraphQLClient::new(root_url, api_key), } } + + fn load_recent_picks(path: &PathBuf) -> HashSet { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn save_recent_picks(&self) { + let picks = self.recent_random_picks.lock().unwrap(); + + if let Some(parent) = self.recent_picks_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + + let json = serde_json::to_string(&*picks).unwrap(); + std::fs::write(&self.recent_picks_path, json).unwrap(); + } } /// Builds a [`poise::Framework`] with the given arguments and commands from [`commands::get_commands`]. @@ -99,6 +132,7 @@ fn prepare_data(config: &Config, reload_handle: ReloadHandle) -> Data { reload_handle, config.root_url.clone(), config.api_key.clone(), + config.recent_picks_path.clone(), ); data.populate_with_reaction_roles(); data