From bc66fad8194d57ed0ac687ae4292a7f6d60a7dfa Mon Sep 17 00:00:00 2001 From: Teja Sai Charan Bellamkonda Date: Fri, 13 Mar 2026 01:17:55 +0530 Subject: [PATCH] feat: added member summary command and apply leave command --- src/commands/apply_leave.rs | 60 +++++++++++++++++ src/commands/mod.rs | 6 +- src/commands/summary.rs | 86 ++++++++++++++++++++++++ src/config.rs | 3 +- src/graphql/mod.rs | 1 + src/graphql/models.rs | 32 ++++++++- src/graphql/mutations.rs | 126 ++++++++++++++++++++++++++++++++++++ src/graphql/queries.rs | 125 ++++++++++++++++++++++++++++++++++- src/main.rs | 49 +++++++++++++- src/tasks/status_update.rs | 4 +- 10 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 src/commands/apply_leave.rs create mode 100644 src/commands/summary.rs create mode 100644 src/graphql/mutations.rs diff --git a/src/commands/apply_leave.rs b/src/commands/apply_leave.rs new file mode 100644 index 0000000..5ab644a --- /dev/null +++ b/src/commands/apply_leave.rs @@ -0,0 +1,60 @@ +use crate::{Data, Error}; +use chrono::NaiveDate; +use poise::serenity_prelude as serenity; +use serenity::all::{CreateEmbed, CreateMessage}; + +#[poise::command(slash_command)] +pub async fn apply_leave( + ctx: poise::Context<'_, Data, Error>, + #[description = "Start date (YYYY-MM-DD)"] start_date: NaiveDate, + #[description = "Duration in days"] duration: Option, + reason: String, +) -> Result<(), Error> { + let discord_id = ctx.author().id.to_string(); + + let duration = duration.unwrap_or(1); + ctx.data() + .graphql_client + .apply_leave(&discord_id, start_date, duration, reason) + .await?; + + let embed = if duration == 1 { + CreateEmbed::new() + .title("πŸ“ Leave Request") + .description(format!( + "**User :** <@{}>\n\ + **Start Date :** {}\n\ + **Duration :** {} day\n\n\ + ", + discord_id, start_date, duration + )) + } else { + CreateEmbed::new() + .title("πŸ“ Leave Request") + .description(format!( + "**User:** <@{}>\n\ + **Start Date:** {}\n\ + **Duration:** {} days\n\n\ + React with βœ… to approve.", + discord_id, start_date, duration + )) + }; + + let message = ctx + .channel_id() + .send_message(ctx, CreateMessage::new().embed(embed)) + .await?; + + if duration != 1 { + message + .react(ctx, serenity::ReactionType::Unicode("βœ…".into())) + .await?; + + message + .react(ctx, serenity::ReactionType::Unicode("❌".into())) + .await?; + } + ctx.say("Leave request submitted.").await?; + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b114346..ef8fd2c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,10 @@ +mod apply_leave; mod set_log_level; +mod summary; +use crate::commands::apply_leave::apply_leave; use crate::commands::set_log_level::set_log_level; +use crate::commands::summary::member_summary; use serenity::all::RoleId; use tracing::{debug, instrument}; @@ -31,7 +35,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(), member_summary(), apply_leave()]; debug!(commands = ?commands.iter().map(|c| &c.name).collect::>()); commands } diff --git a/src/commands/summary.rs b/src/commands/summary.rs new file mode 100644 index 0000000..e8755ab --- /dev/null +++ b/src/commands/summary.rs @@ -0,0 +1,86 @@ +use crate::graphql::models::{LeaveCountRecord, MemberSummary}; +use crate::ids::THE_LAB_CHANNEL_ID; +use crate::{Data, Error}; +use chrono::{Datelike, Local, NaiveDate}; +use poise::serenity_prelude::User; +use serenity::all::{ChannelId, CreateEmbed, CreateMessage}; + +#[poise::command(slash_command)] +pub async fn member_summary( + ctx: poise::Context<'_, Data, Error>, + #[description = "Mention the member"] member: User, + #[description = "Start Date (YYYY-MM-DD)"] start_date: Option, + #[description = "End Date (YYYY-MM-DD)"] end_date: Option, +) -> Result<(), Error> { + let discord_id = member.id.get().to_string(); + + // take present date automatically and give this month's date and summary if both + let (start_date, end_date) = match (start_date, end_date) { + (Some(s), Some(e)) => (s, e), + + (None, None) => { + let time = Local::now(); + let year = time.year(); + let month = time.month(); + let end = time.date_naive(); + + let (target_year, target_month) = if end.day() == 1 { + if month == 1 { + (year - 1, 12) + } else { + (year, month - 1) + } + } else { + (year, month) + }; + + let start = NaiveDate::from_ymd_opt(target_year, target_month, 1) + .ok_or_else(|| anyhow::anyhow!("Invalid date"))?; + + (start, end) + } + _ => { + return Err( + anyhow::anyhow!("Either provide both start and end dates, or none.").into(), + ); + } + }; + + let leaves: LeaveCountRecord = ctx + .data() + .graphql_client + .fetch_leaves(&discord_id, start_date, end_date) + .await?; + + let summary: MemberSummary = ctx + .data() + .graphql_client + .fetch_member_summary(&discord_id, start_date, end_date) + .await?; + + let embed = CreateEmbed::new() + .title("Member SummaryπŸ“‹") + .description(format!( + "**Report of** <@{}> + β€’ Period: **{} β†’ {}**\n\n\ + **Attendance πŸ“Š**\n\ + β€’ Presence: **{:.1}%**\n\n\ + **Updates πŸ“**\n\ + β€’ Consistency: **{:.1}%** \n\n\ + **Leave Summary πŸ“„**\n\ + β€’ Total Leaves: **{}**", + discord_id, + start_date, + end_date, + summary.present_percent, + summary.updates_percent, + leaves.leave_count + )); + + let lab_channel_id = ChannelId::new(THE_LAB_CHANNEL_ID); + lab_channel_id + .send_message(ctx.http(), CreateMessage::new().add_embed(embed)) + .await?; + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index fa87981..ad63e4f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,8 +47,7 @@ impl Default for Config { owner_id: parse_owner_id_env("OWNER_ID"), 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"), + api_key: std::env::var("AMD_API_KEY").expect("AMD_API_KEY was not found in env"), } } } diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index f8b6415..f7c34f9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -16,6 +16,7 @@ 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 89002a8..e61a016 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -15,8 +15,8 @@ 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 chrono::{NaiveDate, NaiveDateTime}; use serde::Deserialize; - #[derive(Clone, Debug, Deserialize)] pub struct StatusOnDate { #[serde(rename = "isSent")] @@ -63,3 +63,33 @@ pub struct AttendanceRecord { #[serde(rename = "timeIn")] pub time_in: Option, } + +#[derive(Debug, Deserialize, Clone)] +pub struct MemberSummary { + #[serde(rename = "presentPercent")] + pub present_percent: f32, + #[serde(rename = "updatesPercent")] + pub updates_percent: f32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LeaveCountRecord { + #[serde(rename = "discordId")] + pub discord_id: String, + #[serde(rename = "leaveCount")] + pub leave_count: i32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LeaveRecord { + #[serde(rename = "discordId")] + pub discord_id: String, + #[serde(rename = "fromDate")] + pub from_date: NaiveDate, + pub duration: i32, + pub reason: String, + #[serde(rename = "approvedBy")] + pub approved_by: Option, + #[serde(rename = "appliedAt")] + pub applied_at: NaiveDateTime, +} diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs new file mode 100644 index 0000000..4a0bc3e --- /dev/null +++ b/src/graphql/mutations.rs @@ -0,0 +1,126 @@ +use crate::graphql::models::LeaveRecord; +use anyhow::Context; +use serde_json::Value; +use tracing::debug; + +use super::GraphQLClient; +use chrono::{Local, NaiveDate}; + +impl GraphQLClient { + pub async fn apply_leave( + &self, + discord_id: &str, + start_date: NaiveDate, + duration: i32, + reason: String, + ) -> anyhow::Result { + let today = Local::now().naive_local(); + let query = r#" + mutation($discord_id: String!, $start_date: String!, $duration: Int!, $reason: String, $today: String) { + leaveApplication( + discordId: $discord_id, + fromDate: $start_date, + duration: $duration, + reason: $reason, + appliedAt : $today + ) { + discordId, + fromDate, + duration, + reason, + approvedBy, + appliedAt + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "start_date": start_date.format("%Y-%m-%d").to_string(), + "duration": duration, + "reason": reason, + "today" : today.format("%Y-%m-%dT%H:%M:%S").to_string() + }); + + debug!("Sending query {}", query); + debug!("With variables: {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to successfully post request")?; + + let json: Value = response + .json() + .await + .context("Failed to parse response JSON")?; + + let leave_value = json["data"]["leaveApplication"].clone(); + + let leave: LeaveRecord = + serde_json::from_value(leave_value).context("Failed to deserialize LeaveRecord")?; + + Ok(leave) + } + + pub async fn approve_leave( + &self, + discord_id: &str, + approved_by: &str, + ) -> anyhow::Result { + let query = r#" + mutation($discord_id: String!, $mentor_discord_id : String!) { + approveLeave( + discordId: $discord_id, + approvedBy: $mentor_discord_id, + + ) { + discordId, + fromDate, + duration, + reason, + approvedBy, + appliedAt + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "mentor_discord_id" : approved_by + }); + + debug!("Sending query {}", query); + debug!("With variables: {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to successfully post request")?; + + let json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + let leave_value = json["data"]["approveLeave"].clone(); + + let leave: LeaveRecord = + serde_json::from_value(leave_value).context("Failed to deserialize LeaveRecord")?; + + Ok(leave) + } +} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index e4ef140..b9b24fc 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -20,7 +20,7 @@ use chrono::{Local, NaiveDate}; use serde_json::Value; use tracing::debug; -use crate::graphql::models::{AttendanceRecord, Member}; +use crate::graphql::models::{AttendanceRecord, LeaveCountRecord, Member, MemberSummary}; use super::GraphQLClient; @@ -145,4 +145,127 @@ impl GraphQLClient { ); Ok(attendance) } + + pub async fn fetch_member_summary( + &self, + discord_id: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> anyhow::Result { + let query: &str = r#" + query($discord_id: String!, $start_date: NaiveDate!, $end_date: NaiveDate!){ + member(discordId : $discord_id){ + attendance{ + presentCount(startDate : $start_date,endDate : $end_date) + absentCount(startDate : $start_date,endDate : $end_date) + } + status{ + updateCount(startDate : $start_date,endDate : $end_date) + } + } + } + "#; + + let variables = serde_json::json!({ + "start_date": start_date.format("%Y-%m-%d").to_string(), + "end_date": end_date.format("%Y-%m-%d").to_string(), + "discord_id": discord_id + }); + + debug!("Sending query {}", query); + debug!("With variables {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ "query": query , "variables":variables})) + .send() + .await + .context("Failed to send GraphQL request")?; + debug!("Response status: {:?}", response.status()); + + let json: Value = response + .json() + .await + .context("Failed to parse response as JSON")?; + + debug!("Response JSON: {:#?}", json); + + let attendance = &json["data"]["member"]["attendance"]; + let status = &json["data"]["member"]["status"]; + + let present: i32 = attendance["presentCount"].as_i64().unwrap_or(0) as i32; + let absent: i32 = attendance["absentCount"].as_i64().unwrap_or(0) as i32; + let updates: i32 = status["updateCount"].as_i64().unwrap_or(0) as i32; + + let total_attendance = present + absent; + + let attendance_percent = if total_attendance == 0 { + 0.0 + } else { + (present as f32 * 100.0) / total_attendance as f32 + }; + + let total_days = (end_date - start_date).num_days().max(1); + + let update_percent = (updates as f32 * 100.0) / total_days as f32; + + let summary = MemberSummary { + present_percent: attendance_percent, + updates_percent: update_percent, + }; + + Ok(summary) + } + + pub async fn fetch_leaves( + &self, + discord_id: &str, + start_date: NaiveDate, + end_date: NaiveDate, + ) -> anyhow::Result { + let query = r#" + query ($discord_id: String!, $start_date: String!, $end_date: String!) { + member(discordId :$discord_id ) { + leaveCount(startDate: $start_date,endDate: $end_date) + } + } + "#; + + let variables = serde_json::json!({ + "discord_id": discord_id, + "start_date": start_date.format("%Y-%m-%d").to_string(), + "end_date": end_date.format("%Y-%m-%d").to_string(), + }); + + debug!("Sending query {}", query); + debug!("With variables {:?}", variables); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await + .context("Failed to send GraphQL request")?; + + debug!("Response status: {:?}", response.status()); + + let json: Value = response + .json() + .await + .context("Failed to parse response as JSON")?; + + let leaves: LeaveCountRecord = LeaveCountRecord { + discord_id: discord_id.to_string(), + leave_count: json["data"]["members"]["leaveCount"].as_i64().unwrap_or(0) as i32, + }; + + Ok(leaves) + } } diff --git a/src/main.rs b/src/main.rs index 2e583d0..df8ea92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,11 +155,58 @@ async fn event_handler( ) -> Result<(), Error> { match event { FullEvent::ReactionAdd { add_reaction } => { - handle_reaction(ctx, add_reaction, data, true).await?; + if add_reaction.user_id == Some(ctx.cache.current_user().id) { + return Ok(()); + } + + if add_reaction.emoji != ReactionType::Unicode("βœ…".into()) { + return Ok(()); + } + + let channel_id = add_reaction.channel_id; + let message_id = add_reaction.message_id; + + let reacted_by_id = if let Some(id) = add_reaction.user_id { + id + } else { + return Ok(()); + }; + + let message = channel_id.message(ctx, message_id).await?; + + if let Some(embed) = message.embeds.first() { + if let Some(title) = &embed.title { + if title.contains("πŸ“ Leave Request") { + if let Some(description) = &embed.description { + if let Some(line) = description.lines().find(|l| l.contains("User:")) { + let discord_id = line + .replace("**User:**", "") + .replace("<@", "") + .replace(">", "") + .trim() + .to_string(); + + data.graphql_client + .approve_leave(&discord_id, &reacted_by_id.get().to_string()) + .await?; + + message + .reply( + ctx, + format!("βœ… Leave approved by <@{}>", reacted_by_id.get()), + ) + .await?; + } + } + } + } + } } + FullEvent::ReactionRemove { removed_reaction } => { handle_reaction(ctx, removed_reaction, data, false).await?; } + _ => {} } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index c17bf35..5bcc3de 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -169,9 +169,9 @@ fn format_breaks(mut years_on_break: Vec) -> String { 1 => "First Years", 2 => "Second Years", 3 => "Third Years", - _ => return format!("Year {}", year), + _ => return format!("Year {year}"), }; - format!("- {}", year_label) + format!("- {year_label}") }) .collect::>() .join("\n");