Skip to content

Commit

Permalink
Integration with Presense. (#46)
Browse files Browse the repository at this point in the history
* feat: Integrate with Presense

Sends an attendance report to #the-lab channel at 18:00.
---------

Co-authored-by: Ivin <[email protected]>
  • Loading branch information
chimnayajith and ivinjabraham committed Mar 5, 2025
1 parent d877698 commit f0f7941
Show file tree
Hide file tree
Showing 6 changed files with 314 additions and 3 deletions.
11 changes: 11 additions & 0 deletions src/graphql/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,14 @@ pub struct Member {
#[serde(default)]
pub streak: Vec<Streak>, // Note that Root will NOT have multiple Streak elements but it may be an empty list which is why we use a vector here
}

#[derive(Debug, Deserialize, Clone)]
pub struct AttendanceRecord {
#[serde(rename = "memberId")]
pub name: String,
pub year: i32,
#[serde(rename = "isPresent")]
pub is_present: bool,
#[serde(rename = "timeIn")]
pub time_in: Option<String>,
}
56 changes: 55 additions & 1 deletion src/graphql/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use anyhow::{anyhow, Context};
use chrono::Local;
use serde_json::Value;
use tracing::debug;

use crate::graphql::models::{Member, Streak};
use crate::graphql::models::{AttendanceRecord, Member, Streak};

use super::models::StreakWithMemberId;

Expand Down Expand Up @@ -214,6 +216,58 @@ pub async fn reset_streak(member: &mut Member) -> anyhow::Result<()> {
Ok(())
}

pub async fn fetch_attendance() -> anyhow::Result<Vec<AttendanceRecord>> {
let request_url =
std::env::var("ROOT_URL").context("ROOT_URL environment variable not found")?;

debug!("Fetching attendance data from {}", request_url);

let client = reqwest::Client::new();
let today = Local::now().format("%Y-%m-%d").to_string();
let query = format!(
r#"
query {{
attendanceByDate(date: "{}") {{
name,
year,
isPresent,
timeIn,
}}
}}"#,
today
);

let response = client
.post(&request_url)
.json(&serde_json::json!({ "query": query }))
.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 attendance_array = json["data"]["attendanceByDate"]
.as_array()
.context("Missing or invalid 'data.attendanceByDate' array in response")?;

let attendance: Vec<AttendanceRecord> = attendance_array
.iter()
.map(|entry| {
serde_json::from_value(entry.clone()).context("Failed to parse attendance record")
})
.collect::<anyhow::Result<Vec<_>>>()?;

debug!(
"Successfully fetched {} attendance records",
attendance.len()
);
Ok(attendance)
}

pub async fn fetch_streaks() -> anyhow::Result<Vec<StreakWithMemberId>> {
let request_url = std::env::var("ROOT_URL").context("ROOT_URL not found in ENV")?;

Expand Down
1 change: 1 addition & 0 deletions src/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ pub const GROUP_TWO_CHANNEL_ID: u64 = 1225098298935738489;
pub const GROUP_THREE_CHANNEL_ID: u64 = 1225098353378070710;
pub const GROUP_FOUR_CHANNEL_ID: u64 = 1225098407216156712;
pub const STATUS_UPDATE_CHANNEL_ID: u64 = 764575524127244318;
pub const THE_LAB_CHANNEL_ID: u64 = 1208438766893670451;
230 changes: 230 additions & 0 deletions src/tasks/lab_attendance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
amFOSS Daemon: A discord bot for the amFOSS Discord server.
Copyright (C) 2024 amFOSS
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
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 <https://www.gnu.org/licenses/>.
*/
use super::Task;
use anyhow::Context as _;
use chrono::{DateTime, Datelike, Local, NaiveTime, ParseError, TimeZone, Timelike, Utc};
use serenity::all::{
ChannelId, Colour, Context as SerenityContext, CreateEmbed, CreateEmbedAuthor, CreateMessage,
};
use serenity::async_trait;
use std::collections::HashMap;
use tracing::{debug, trace};

use crate::{
graphql::{models::AttendanceRecord, queries::fetch_attendance},
ids::THE_LAB_CHANNEL_ID,
utils::time::{get_five_forty_five_pm_timestamp, time_until},
};

const TITLE_URL: &str = "https://www.amfoss.in/";
const AUTHOR_URL: &str = "https://github.com/amfoss/amd";

pub struct PresenseReport;

#[async_trait]
impl Task for PresenseReport {
fn name(&self) -> &str {
"Lab Attendance Check"
}

fn run_in(&self) -> tokio::time::Duration {
time_until(18, 00)
}

async fn run(&self, ctx: SerenityContext) -> anyhow::Result<()> {
check_lab_attendance(ctx).await
}
}

pub async fn check_lab_attendance(ctx: SerenityContext) -> anyhow::Result<()> {
trace!("Starting lab attendance check");
let attendance = fetch_attendance()
.await
.context("Failed to fetch attendance from Root")?;

let time = Local::now().with_timezone(&chrono_tz::Asia::Kolkata);
let threshold_time = get_five_forty_five_pm_timestamp(time);

let mut absent_list = Vec::new();
let mut late_list = Vec::new();

for record in &attendance {
debug!("Checking attendance for member: {}", record.name);
if !record.is_present || record.time_in.is_none() {
absent_list.push(record.clone());
debug!("Member {} marked as absent", record.name);
} else if let Some(time_str) = &record.time_in {
if let Ok(time) = parse_time(time_str) {
if time > threshold_time {
late_list.push(record.clone());
debug!("Member {} marked as late", record.name);
}
}
}
}

if absent_list.len() == attendance.len() {
send_lab_closed_message(ctx).await?;
} else {
send_attendance_report(ctx, absent_list, late_list, attendance.len()).await?;
}

trace!("Completed lab attendance check");
Ok(())
}

async fn send_lab_closed_message(ctx: SerenityContext) -> anyhow::Result<()> {
let today_date = Utc::now().format("%B %d, %Y").to_string();

let bot_user = ctx.http.get_current_user().await?;
let bot_avatar_url = bot_user
.avatar_url()
.unwrap_or_else(|| bot_user.default_avatar_url());

let embed = CreateEmbed::new()
.title(format!("Presense Report - {}", today_date))
.url(TITLE_URL)
.author(
CreateEmbedAuthor::new("amD")
.url(AUTHOR_URL)
.icon_url(bot_avatar_url),
)
.color(Colour::RED)
.description("Uh-oh, seems like the lab is closed today! 🏖️ Everyone is absent!")
.timestamp(Utc::now());

ChannelId::new(THE_LAB_CHANNEL_ID)
.send_message(&ctx.http, CreateMessage::new().embed(embed))
.await
.context("Failed to send lab closed message")?;

Ok(())
}

async fn send_attendance_report(
ctx: SerenityContext,
absent_list: Vec<AttendanceRecord>,
late_list: Vec<AttendanceRecord>,
total_count: usize,
) -> anyhow::Result<()> {
let today_date = Utc::now().format("%B %d, %Y").to_string();

let present = total_count - absent_list.len();
let attendance_percentage = if total_count > 0 {
(present as f32 / total_count as f32) * 100.0
} else {
0.0
};

let bot_user = ctx.http.get_current_user().await?;
let bot_avatar_url = bot_user
.avatar_url()
.unwrap_or_else(|| bot_user.default_avatar_url());

let embed_color = if attendance_percentage > 75.0 {
Colour::DARK_GREEN
} else if attendance_percentage > 50.0 {
Colour::GOLD
} else {
Colour::RED
};

let mut description = format!(
"# Stats\n- Present: {} ({}%)\n- Absent: {}\n- Late: {}\n\n",
present,
attendance_percentage.round() as i32,
absent_list.len(),
late_list.len()
);

description.push_str(&format_attendance_list("Absent", &absent_list));
description.push_str(&format_attendance_list("Late", &late_list));

let embed = CreateEmbed::new()
.title(format!("Presense Report - {}", today_date))
.url(TITLE_URL)
.author(
CreateEmbedAuthor::new("amD")
.url(AUTHOR_URL)
.icon_url(bot_avatar_url),
)
.color(embed_color)
.description(description)
.timestamp(Utc::now());

ChannelId::new(THE_LAB_CHANNEL_ID)
.send_message(&ctx.http, CreateMessage::new().embed(embed))
.await
.context("Failed to send attendance report")?;

Ok(())
}

fn format_attendance_list(title: &str, list: &[AttendanceRecord]) -> String {
if list.is_empty() {
return format!(
"**{}**\nNo one is {} today! 🎉\n\n",
title,
title.to_lowercase()
);
}

let mut by_year: HashMap<i32, Vec<&str>> = HashMap::new();
for record in list {
if record.year >= 1 && record.year <= 3 {
by_year.entry(record.year).or_default().push(&record.name);
}
}

let mut result = format!("# {}\n", title);

for year in 1..=3 {
if let Some(names) = by_year.get(&year) {
if !names.is_empty() {
result.push_str(&format!("### Year {}\n", year));

for name in names {
result.push_str(&format!("- {}\n", name));
}
result.push('\n');
}
}
}

result
}

fn parse_time(time_str: &str) -> Result<DateTime<Local>, ParseError> {
let time_only = time_str.split('.').next().unwrap();
let naive_time = NaiveTime::parse_from_str(time_only, "%H:%M:%S")?;
let now = Local::now();

let result = Local
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
naive_time.hour(),
naive_time.minute(),
naive_time.second(),
)
.single()
.expect("Valid datetime must be created");

Ok(result)
}
4 changes: 3 additions & 1 deletion src/tasks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ 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 <https://www.gnu.org/licenses/>.
*/
mod lab_attendance;
mod status_update;

use anyhow::Result;
use async_trait::async_trait;
use lab_attendance::PresenseReport;
use serenity::client::Context;
use status_update::StatusUpdateCheck;
use tokio::time::Duration;
Expand All @@ -36,5 +38,5 @@ pub trait Task: Send + Sync {
/// 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<Box<dyn Task>> {
vec![Box::new(StatusUpdateCheck)]
vec![Box::new(StatusUpdateCheck), Box::new(PresenseReport)]
}
15 changes: 14 additions & 1 deletion src/utils/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ 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 <https://www.gnu.org/licenses/>.
*/
use chrono::{Datelike, Local, TimeZone};
use chrono::{DateTime, Datelike, Local, TimeZone};
use chrono_tz::Asia::Kolkata;
use chrono_tz::Tz;
use tracing::debug;

use std::time::Duration;
Expand Down Expand Up @@ -45,3 +46,15 @@ pub fn time_until(hour: u32, minute: u32) -> Duration {
debug!("duration: {}", duration);
Duration::from_secs(duration.num_seconds().max(0) as u64)
}

pub fn get_five_forty_five_pm_timestamp(now: DateTime<Tz>) -> DateTime<Local> {
let date =
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), now.day()).expect("Invalid date");
let time = chrono::NaiveTime::from_hms_opt(17, 45, 0).expect("Invalid time");
let naive_dt = date.and_time(time);

chrono::Local
.from_local_datetime(&naive_dt)
.single()
.expect("Chrono must work.")
}

0 comments on commit f0f7941

Please sign in to comment.