diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 0476ee9..5267712 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -16,7 +16,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ pub mod models; -pub mod mutations; pub mod queries; use std::sync::Arc; diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 570b3af..519e77f 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -18,21 +18,28 @@ along with this program. If not, see . use serde::Deserialize; #[derive(Clone, Debug, Deserialize)] -pub struct StreakWithMemberId { - #[serde(rename = "memberId")] - pub member_id: i32, - #[serde(rename = "currentStreak")] - pub current_streak: i32, - #[serde(rename = "maxStreak")] - pub max_streak: i32, +pub struct StatusOnDate { + #[serde(rename = "isSent")] + pub is_sent: bool, + #[serde(rename = "onBreak")] + pub on_break: bool, } #[derive(Clone, Debug, Deserialize)] -pub struct Streak { +pub struct StatusStreak { #[serde(rename = "currentStreak")] - pub current_streak: i32, + pub current_streak: Option, #[serde(rename = "maxStreak")] - pub max_streak: i32, + pub max_streak: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MemberStatus { + #[serde(rename = "onDate")] + pub on_date: Option, + pub streak: Option, + #[serde(rename = "consecutiveMisses")] + pub consecutive_misses: Option, } #[derive(Clone, Debug, Deserialize)] @@ -42,10 +49,9 @@ pub struct Member { pub name: String, #[serde(rename = "discordId")] pub discord_id: String, - #[serde(default)] - pub streak: Vec, // Note that Root will NOT have multiple Streak elements but it may be an empty list which is why we use a vector here pub track: Option, pub year: i32, + pub status: Option, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs deleted file mode 100644 index 4654d39..0000000 --- a/src/graphql/mutations.rs +++ /dev/null @@ -1,149 +0,0 @@ -use anyhow::anyhow; -use anyhow::Context as _; -use tracing::debug; -use tracing::instrument; - -use crate::graphql::models::Streak; - -use super::models::Member; -use super::GraphQLClient; - -impl GraphQLClient { - #[instrument(level = "debug")] - pub async fn increment_streak(&self, member: &mut Member) -> anyhow::Result<()> { - let mutation = format!( - r#" - mutation {{ - incrementStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({"query": mutation})) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("incrementStreak")) - { - let current_streak = data - .get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? - as i32; - let max_streak = data - .get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? - as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!( - "Failed to access data from response: {}", - response_json - )); - } - - Ok(()) - } - - #[instrument(level = "debug")] - pub async fn reset_streak(&self, member: &mut Member) -> anyhow::Result<()> { - let mutation = format!( - r#" - mutation {{ - resetStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({ "query": mutation })) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("resetStreak")) - { - let current_streak = data - .get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? - as i32; - let max_streak = data - .get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? - as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!("Failed to access data from {}", response_json)); - } - - Ok(()) - } -} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index 9608d42..0cb6a6f 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -16,36 +16,51 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ use anyhow::{anyhow, Context}; -use chrono::Local; +use chrono::{Local, NaiveDate}; use serde_json::Value; use tracing::debug; use crate::graphql::models::{AttendanceRecord, Member}; -use super::{models::StreakWithMemberId, GraphQLClient}; +use super::GraphQLClient; impl GraphQLClient { - pub async fn fetch_members(&self) -> anyhow::Result> { + pub async fn fetch_member_data(&self, date: NaiveDate) -> anyhow::Result> { let query = r#" - { - members { + query($date: NaiveDate!) { + allMembers { memberId name discordId groupId - streak { - currentStreak - maxStreak + status { + onDate(date: $date) { + isSent + onBreak + } + streak { + currentStreak, + maxStreak + } + consecutiveMisses } track - } - }"#; + year + } + }"#; debug!("Sending query {}", query); + + let variables = serde_json::json!({ + "date": date.format("%Y-%m-%d").to_string() + }); + + debug!("With variables: {}", variables); + let response = self .http() .post(self.root_url()) - .json(&serde_json::json!({"query": query})) + .json(&serde_json::json!({"query": query, "variables":variables})) .send() .await .context("Failed to successfully post request")?; @@ -65,7 +80,7 @@ impl GraphQLClient { debug!("Response: {}", response_json); let members = response_json .get("data") - .and_then(|data| data.get("members")) + .and_then(|data| data.get("allMembers")) .and_then(|members| members.as_array()) .ok_or_else(|| { anyhow::anyhow!( @@ -128,48 +143,4 @@ impl GraphQLClient { ); Ok(attendance) } - - pub async fn fetch_streaks(&self) -> anyhow::Result> { - let query = r#" - { - streaks { - memberId - currentStreak - maxStreak - } - } - "#; - - debug!("Sending query {}", query); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({"query": query})) - .send() - .await - .context("Failed to successfully post request")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to serialize response")?; - - debug!("Response: {}", response_json); - let streaks = response_json - .get("data") - .and_then(|data| data.get("streaks")) - .and_then(|streaks| { - serde_json::from_value::>(streaks.clone()).ok() - }) - .context("Failed to parse streaks data")?; - - Ok(streaks) - } } diff --git a/src/ids.rs b/src/ids.rs index d34dfcc..fbf20d3 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -33,10 +33,5 @@ pub const RESEARCH_ROLE_ID: u64 = 1298553855474270219; pub const DEVOPS_ROLE_ID: u64 = 1298553883169132554; pub const WEB_ROLE_ID: u64 = 1298553910167994428; -// Channel IDs for status updates -pub const SYSTEMS_CHANNEL_ID: u64 = 1378426650152271902; -pub const MOBILE_CHANNEL_ID: u64 = 1378685538835365960; -pub const WEB_CHANNEL_ID: u64 = 1378685360133115944; -pub const AI_CHANNEL_ID: u64 = 1343489220068507649; pub const STATUS_UPDATE_CHANNEL_ID: u64 = 764575524127244318; pub const THE_LAB_CHANNEL_ID: u64 = 1208438766893670451; diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index a08946c..f4f1b8b 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -25,7 +25,7 @@ use anyhow::Result; use async_trait::async_trait; use lab_attendance::PresenseReport; use serenity::client::Context; -use status_update::StatusUpdateCheck; +use status_update::StatusUpdateReport; use tokio::time::Duration; use crate::graphql::GraphQLClient; @@ -52,5 +52,5 @@ impl Debug for Box { /// Analogous to [`crate::commands::get_commands`], every task that is defined /// must be included in the returned vector in order for it to be scheduled. pub fn get_tasks() -> Vec> { - vec![Box::new(StatusUpdateCheck), Box::new(PresenseReport)] + vec![Box::new(PresenseReport), Box::new(StatusUpdateReport)] } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 1c23f6a..23e9128 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -15,34 +15,30 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; -use chrono::{DateTime, Utc}; -use serenity::all::{ - CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GetMessages, Message, -}; +use serenity::all::{CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage}; use serenity::async_trait; use tracing::instrument; use super::Task; -use crate::graphql::models::{Member, StreakWithMemberId}; +use crate::graphql::models::Member; use crate::graphql::GraphQLClient; -use crate::ids::{ - AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, -}; +use crate::ids::STATUS_UPDATE_CHANNEL_ID; use crate::utils::time::time_until; /// Checks for status updates daily at 5 AM. -pub struct StatusUpdateCheck; +pub struct StatusUpdateReport; #[async_trait] -impl Task for StatusUpdateCheck { +impl Task for StatusUpdateReport { fn name(&self) -> &str { - "Status Update Check" + "Status Update Report" } fn run_in(&self) -> tokio::time::Duration { - time_until(5, 00) + time_until(5, 15) + // Duration::from_secs(1) // for development } async fn run(&self, ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { @@ -52,26 +48,18 @@ impl Task for StatusUpdateCheck { type GroupedMember = HashMap, Vec>; -struct ReportConfig { - time_valid_from: DateTime, - keywords: Vec<&'static str>, - special_authors: Vec<&'static str>, -} - -const AMAN_SHAFEEQ: &str = "767636699077410837"; -const CHANDRA_MOULI: &str = "1265880467047976970"; - #[instrument(level = "debug", skip(ctx))] -async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { - let updates = get_updates(&ctx).await?; - let mut members = client.fetch_members().await?; +pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { + let now = chrono::Utc::now().with_timezone(&chrono_tz::Asia::Kolkata); + let yesterday = now.date_naive() - chrono::Duration::days(1); + + let mut members = client.fetch_member_data(yesterday).await?; members.retain(|member| member.year != 4); // naughty_list -> members who did not send updates - let (mut naughty_list, mut nice_list) = categorize_members(&members, updates); - update_streaks_for_members(&client, &mut naughty_list, &mut nice_list).await?; + let naughty_list = categorize_members(&members); - let embed = generate_embed(client, members, naughty_list).await?; + let embed = generate_embed(members, naughty_list).await?; let msg = CreateMessage::new().embed(embed); let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID); @@ -80,120 +68,32 @@ async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Res Ok(()) } -async fn get_updates(ctx: &Context) -> anyhow::Result> { - let channel_ids = get_channel_ids(); - let mut updates = Vec::new(); - - let get_messages_builder = GetMessages::new().limit(100); - for channel in channel_ids { - let messages = channel.messages(ctx.http(), get_messages_builder).await?; - let valid_updates = messages.into_iter().filter(is_valid_status_update); - updates.extend(valid_updates); - } - - Ok(updates) -} - -// TODO: Replace hardcoded set with configurable list -fn get_channel_ids() -> Vec { - vec![ - ChannelId::new(SYSTEMS_CHANNEL_ID), - ChannelId::new(MOBILE_CHANNEL_ID), - ChannelId::new(WEB_CHANNEL_ID), - ChannelId::new(AI_CHANNEL_ID), - ] -} - -fn is_valid_status_update(msg: &Message) -> bool { - let report_config = get_report_config(); - let content = msg.content.to_lowercase(); - - let is_within_timeframe = DateTime::::from_timestamp(msg.timestamp.timestamp(), 0) - .expect("Valid timestamp") - .with_timezone(&chrono_tz::Asia::Kolkata) - >= report_config.time_valid_from; - - let has_required_keywords = report_config - .keywords - .iter() - .all(|keyword| content.contains(keyword)); - let is_special_author = report_config - .special_authors - .contains(&msg.author.id.to_string().as_str()); - let is_valid_content = - has_required_keywords || (is_special_author && content.contains("regards")); - - is_within_timeframe && is_valid_content -} - -// TODO: Parts of this could also be removed from code like channel_ids -fn get_report_config() -> ReportConfig { - let now = chrono::Utc::now().with_timezone(&chrono_tz::Asia::Kolkata); - let yesterday = now.date_naive() - chrono::Duration::days(1); - let time_valid_from = yesterday - .and_hms_opt(20, 0, 0) - .expect("Valid timestamp") - .and_local_timezone(chrono_tz::Asia::Kolkata) - .earliest() - .expect("Valid timezone conversion"); - - ReportConfig { - time_valid_from, - keywords: vec!["namah shivaya", "regards"], - special_authors: vec![AMAN_SHAFEEQ, CHANDRA_MOULI], - } -} - -fn categorize_members( - members: &Vec, - updates: Vec, -) -> (GroupedMember, Vec) { - let mut nice_list = vec![]; +fn categorize_members(members: &Vec) -> GroupedMember { let mut naughty_list: HashMap, Vec> = HashMap::new(); - let mut sent_updates: HashSet = HashSet::new(); - - for message in updates.iter() { - sent_updates.insert(message.author.id.to_string()); - } - for member in members { - if sent_updates.contains(&member.discord_id) { - nice_list.push(member.clone()); - } else { + let Some(status) = &member.status else { + continue; + }; + let Some(on_date) = &status.on_date else { + continue; + }; + + if !on_date.on_break && !on_date.is_sent { let track = member.track.clone(); naughty_list.entry(track).or_default().push(member.clone()); } } - (naughty_list, nice_list) -} - -async fn update_streaks_for_members( - client: &GraphQLClient, - naughty_list: &mut GroupedMember, - nice_list: &mut Vec, -) -> anyhow::Result<()> { - for member in nice_list { - client.increment_streak(member).await?; - } - - for members in naughty_list.values_mut() { - for member in members { - client.reset_streak(member).await?; - } - } - - Ok(()) + naughty_list } async fn generate_embed( - client: GraphQLClient, members: Vec, naughty_list: GroupedMember, ) -> anyhow::Result { let (all_time_high, all_time_high_members, current_highest, current_highest_members) = - get_leaderboard_stats(client, members).await?; + get_leaderboard_stats(members).await?; let mut description = String::new(); description.push_str("# Leaderboard Updates\n"); @@ -242,9 +142,11 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } for member in missed_members { - let status = match member.streak[0].current_streak { - 0 => ":x:", - -1 => ":x::x:", + let status = match member.status.as_ref().and_then(|s| s.consecutive_misses) { + None => ":zzz:", + Some(1) => ":x:", + Some(2) => ":x::x:", + Some(3) => ":x::x::x:", _ => ":headstone:", }; description.push_str(&format!("- {} | {}\n", member.name, status)); @@ -255,15 +157,10 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } async fn get_leaderboard_stats( - client: GraphQLClient, members: Vec, ) -> anyhow::Result<(i32, Vec, i32, Vec)> { - let streaks = client.fetch_streaks().await?; - let member_map: HashMap = members.iter().map(|m| (m.member_id, m)).collect(); - - let (all_time_high, all_time_high_members) = find_highest_streak(&streaks, &member_map, true); - let (current_highest, current_highest_members) = - find_highest_streak(&streaks, &member_map, false); + let (all_time_high, all_time_high_members) = find_highest_streak(&members, true); + let (current_highest, current_highest_members) = find_highest_streak(&members, false); Ok(( all_time_high, @@ -273,33 +170,34 @@ async fn get_leaderboard_stats( )) } -fn find_highest_streak( - streaks: &[StreakWithMemberId], - member_map: &HashMap, - is_all_time: bool, -) -> (i32, Vec) { +fn find_highest_streak(members: &Vec, is_all_time: bool) -> (i32, Vec) { let mut highest = 0; let mut highest_members = Vec::new(); - for streak in streaks { - if let Some(member) = member_map.get(&streak.member_id) { - let streak_value = if is_all_time { - streak.max_streak - } else { - streak.current_streak - }; - - match streak_value.cmp(&highest) { - std::cmp::Ordering::Greater => { - highest = streak_value; - highest_members.clear(); - highest_members.push((*member).clone()); - } - std::cmp::Ordering::Equal => { - highest_members.push((*member).clone()); + for member in members { + let streak_value = member + .status + .as_ref() + .and_then(|s| s.streak.as_ref()) + .and_then(|streak| { + if is_all_time { + streak.max_streak + } else { + streak.current_streak } - _ => {} + }) + .unwrap_or(0); // default to 0 if no streak info + + match streak_value.cmp(&highest) { + std::cmp::Ordering::Greater => { + highest = streak_value; + highest_members.clear(); + highest_members.push(member.clone()); + } + std::cmp::Ordering::Equal => { + highest_members.push(member.clone()); } + _ => {} } }