Skip to content

Commit f0f7941

Browse files
Integration with Presense. (#46)
* feat: Integrate with Presense Sends an attendance report to #the-lab channel at 18:00. --------- Co-authored-by: Ivin <[email protected]>
1 parent d877698 commit f0f7941

File tree

6 files changed

+314
-3
lines changed

6 files changed

+314
-3
lines changed

src/graphql/models.rs

+11
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,14 @@ pub struct Member {
4747
#[serde(default)]
4848
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
4949
}
50+
51+
#[derive(Debug, Deserialize, Clone)]
52+
pub struct AttendanceRecord {
53+
#[serde(rename = "memberId")]
54+
pub name: String,
55+
pub year: i32,
56+
#[serde(rename = "isPresent")]
57+
pub is_present: bool,
58+
#[serde(rename = "timeIn")]
59+
pub time_in: Option<String>,
60+
}

src/graphql/queries.rs

+55-1
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ You should have received a copy of the GNU General Public License
1616
along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
1818
use anyhow::{anyhow, Context};
19+
use chrono::Local;
20+
use serde_json::Value;
1921
use tracing::debug;
2022

21-
use crate::graphql::models::{Member, Streak};
23+
use crate::graphql::models::{AttendanceRecord, Member, Streak};
2224

2325
use super::models::StreakWithMemberId;
2426

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

219+
pub async fn fetch_attendance() -> anyhow::Result<Vec<AttendanceRecord>> {
220+
let request_url =
221+
std::env::var("ROOT_URL").context("ROOT_URL environment variable not found")?;
222+
223+
debug!("Fetching attendance data from {}", request_url);
224+
225+
let client = reqwest::Client::new();
226+
let today = Local::now().format("%Y-%m-%d").to_string();
227+
let query = format!(
228+
r#"
229+
query {{
230+
attendanceByDate(date: "{}") {{
231+
name,
232+
year,
233+
isPresent,
234+
timeIn,
235+
}}
236+
}}"#,
237+
today
238+
);
239+
240+
let response = client
241+
.post(&request_url)
242+
.json(&serde_json::json!({ "query": query }))
243+
.send()
244+
.await
245+
.context("Failed to send GraphQL request")?;
246+
debug!("Response status: {:?}", response.status());
247+
248+
let json: Value = response
249+
.json()
250+
.await
251+
.context("Failed to parse response as JSON")?;
252+
253+
let attendance_array = json["data"]["attendanceByDate"]
254+
.as_array()
255+
.context("Missing or invalid 'data.attendanceByDate' array in response")?;
256+
257+
let attendance: Vec<AttendanceRecord> = attendance_array
258+
.iter()
259+
.map(|entry| {
260+
serde_json::from_value(entry.clone()).context("Failed to parse attendance record")
261+
})
262+
.collect::<anyhow::Result<Vec<_>>>()?;
263+
264+
debug!(
265+
"Successfully fetched {} attendance records",
266+
attendance.len()
267+
);
268+
Ok(attendance)
269+
}
270+
217271
pub async fn fetch_streaks() -> anyhow::Result<Vec<StreakWithMemberId>> {
218272
let request_url = std::env::var("ROOT_URL").context("ROOT_URL not found in ENV")?;
219273

src/ids.rs

+1
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ pub const GROUP_TWO_CHANNEL_ID: u64 = 1225098298935738489;
3333
pub const GROUP_THREE_CHANNEL_ID: u64 = 1225098353378070710;
3434
pub const GROUP_FOUR_CHANNEL_ID: u64 = 1225098407216156712;
3535
pub const STATUS_UPDATE_CHANNEL_ID: u64 = 764575524127244318;
36+
pub const THE_LAB_CHANNEL_ID: u64 = 1208438766893670451;

src/tasks/lab_attendance.rs

+230
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
amFOSS Daemon: A discord bot for the amFOSS Discord server.
3+
Copyright (C) 2024 amFOSS
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
use super::Task;
19+
use anyhow::Context as _;
20+
use chrono::{DateTime, Datelike, Local, NaiveTime, ParseError, TimeZone, Timelike, Utc};
21+
use serenity::all::{
22+
ChannelId, Colour, Context as SerenityContext, CreateEmbed, CreateEmbedAuthor, CreateMessage,
23+
};
24+
use serenity::async_trait;
25+
use std::collections::HashMap;
26+
use tracing::{debug, trace};
27+
28+
use crate::{
29+
graphql::{models::AttendanceRecord, queries::fetch_attendance},
30+
ids::THE_LAB_CHANNEL_ID,
31+
utils::time::{get_five_forty_five_pm_timestamp, time_until},
32+
};
33+
34+
const TITLE_URL: &str = "https://www.amfoss.in/";
35+
const AUTHOR_URL: &str = "https://github.com/amfoss/amd";
36+
37+
pub struct PresenseReport;
38+
39+
#[async_trait]
40+
impl Task for PresenseReport {
41+
fn name(&self) -> &str {
42+
"Lab Attendance Check"
43+
}
44+
45+
fn run_in(&self) -> tokio::time::Duration {
46+
time_until(18, 00)
47+
}
48+
49+
async fn run(&self, ctx: SerenityContext) -> anyhow::Result<()> {
50+
check_lab_attendance(ctx).await
51+
}
52+
}
53+
54+
pub async fn check_lab_attendance(ctx: SerenityContext) -> anyhow::Result<()> {
55+
trace!("Starting lab attendance check");
56+
let attendance = fetch_attendance()
57+
.await
58+
.context("Failed to fetch attendance from Root")?;
59+
60+
let time = Local::now().with_timezone(&chrono_tz::Asia::Kolkata);
61+
let threshold_time = get_five_forty_five_pm_timestamp(time);
62+
63+
let mut absent_list = Vec::new();
64+
let mut late_list = Vec::new();
65+
66+
for record in &attendance {
67+
debug!("Checking attendance for member: {}", record.name);
68+
if !record.is_present || record.time_in.is_none() {
69+
absent_list.push(record.clone());
70+
debug!("Member {} marked as absent", record.name);
71+
} else if let Some(time_str) = &record.time_in {
72+
if let Ok(time) = parse_time(time_str) {
73+
if time > threshold_time {
74+
late_list.push(record.clone());
75+
debug!("Member {} marked as late", record.name);
76+
}
77+
}
78+
}
79+
}
80+
81+
if absent_list.len() == attendance.len() {
82+
send_lab_closed_message(ctx).await?;
83+
} else {
84+
send_attendance_report(ctx, absent_list, late_list, attendance.len()).await?;
85+
}
86+
87+
trace!("Completed lab attendance check");
88+
Ok(())
89+
}
90+
91+
async fn send_lab_closed_message(ctx: SerenityContext) -> anyhow::Result<()> {
92+
let today_date = Utc::now().format("%B %d, %Y").to_string();
93+
94+
let bot_user = ctx.http.get_current_user().await?;
95+
let bot_avatar_url = bot_user
96+
.avatar_url()
97+
.unwrap_or_else(|| bot_user.default_avatar_url());
98+
99+
let embed = CreateEmbed::new()
100+
.title(format!("Presense Report - {}", today_date))
101+
.url(TITLE_URL)
102+
.author(
103+
CreateEmbedAuthor::new("amD")
104+
.url(AUTHOR_URL)
105+
.icon_url(bot_avatar_url),
106+
)
107+
.color(Colour::RED)
108+
.description("Uh-oh, seems like the lab is closed today! 🏖️ Everyone is absent!")
109+
.timestamp(Utc::now());
110+
111+
ChannelId::new(THE_LAB_CHANNEL_ID)
112+
.send_message(&ctx.http, CreateMessage::new().embed(embed))
113+
.await
114+
.context("Failed to send lab closed message")?;
115+
116+
Ok(())
117+
}
118+
119+
async fn send_attendance_report(
120+
ctx: SerenityContext,
121+
absent_list: Vec<AttendanceRecord>,
122+
late_list: Vec<AttendanceRecord>,
123+
total_count: usize,
124+
) -> anyhow::Result<()> {
125+
let today_date = Utc::now().format("%B %d, %Y").to_string();
126+
127+
let present = total_count - absent_list.len();
128+
let attendance_percentage = if total_count > 0 {
129+
(present as f32 / total_count as f32) * 100.0
130+
} else {
131+
0.0
132+
};
133+
134+
let bot_user = ctx.http.get_current_user().await?;
135+
let bot_avatar_url = bot_user
136+
.avatar_url()
137+
.unwrap_or_else(|| bot_user.default_avatar_url());
138+
139+
let embed_color = if attendance_percentage > 75.0 {
140+
Colour::DARK_GREEN
141+
} else if attendance_percentage > 50.0 {
142+
Colour::GOLD
143+
} else {
144+
Colour::RED
145+
};
146+
147+
let mut description = format!(
148+
"# Stats\n- Present: {} ({}%)\n- Absent: {}\n- Late: {}\n\n",
149+
present,
150+
attendance_percentage.round() as i32,
151+
absent_list.len(),
152+
late_list.len()
153+
);
154+
155+
description.push_str(&format_attendance_list("Absent", &absent_list));
156+
description.push_str(&format_attendance_list("Late", &late_list));
157+
158+
let embed = CreateEmbed::new()
159+
.title(format!("Presense Report - {}", today_date))
160+
.url(TITLE_URL)
161+
.author(
162+
CreateEmbedAuthor::new("amD")
163+
.url(AUTHOR_URL)
164+
.icon_url(bot_avatar_url),
165+
)
166+
.color(embed_color)
167+
.description(description)
168+
.timestamp(Utc::now());
169+
170+
ChannelId::new(THE_LAB_CHANNEL_ID)
171+
.send_message(&ctx.http, CreateMessage::new().embed(embed))
172+
.await
173+
.context("Failed to send attendance report")?;
174+
175+
Ok(())
176+
}
177+
178+
fn format_attendance_list(title: &str, list: &[AttendanceRecord]) -> String {
179+
if list.is_empty() {
180+
return format!(
181+
"**{}**\nNo one is {} today! 🎉\n\n",
182+
title,
183+
title.to_lowercase()
184+
);
185+
}
186+
187+
let mut by_year: HashMap<i32, Vec<&str>> = HashMap::new();
188+
for record in list {
189+
if record.year >= 1 && record.year <= 3 {
190+
by_year.entry(record.year).or_default().push(&record.name);
191+
}
192+
}
193+
194+
let mut result = format!("# {}\n", title);
195+
196+
for year in 1..=3 {
197+
if let Some(names) = by_year.get(&year) {
198+
if !names.is_empty() {
199+
result.push_str(&format!("### Year {}\n", year));
200+
201+
for name in names {
202+
result.push_str(&format!("- {}\n", name));
203+
}
204+
result.push('\n');
205+
}
206+
}
207+
}
208+
209+
result
210+
}
211+
212+
fn parse_time(time_str: &str) -> Result<DateTime<Local>, ParseError> {
213+
let time_only = time_str.split('.').next().unwrap();
214+
let naive_time = NaiveTime::parse_from_str(time_only, "%H:%M:%S")?;
215+
let now = Local::now();
216+
217+
let result = Local
218+
.with_ymd_and_hms(
219+
now.year(),
220+
now.month(),
221+
now.day(),
222+
naive_time.hour(),
223+
naive_time.minute(),
224+
naive_time.second(),
225+
)
226+
.single()
227+
.expect("Valid datetime must be created");
228+
229+
Ok(result)
230+
}

src/tasks/mod.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ GNU General Public License for more details.
1515
You should have received a copy of the GNU General Public License
1616
along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
18+
mod lab_attendance;
1819
mod status_update;
1920

2021
use anyhow::Result;
2122
use async_trait::async_trait;
23+
use lab_attendance::PresenseReport;
2224
use serenity::client::Context;
2325
use status_update::StatusUpdateCheck;
2426
use tokio::time::Duration;
@@ -36,5 +38,5 @@ pub trait Task: Send + Sync {
3638
/// Analogous to [`crate::commands::get_commands`], every task that is defined
3739
/// must be included in the returned vector in order for it to be scheduled.
3840
pub fn get_tasks() -> Vec<Box<dyn Task>> {
39-
vec![Box::new(StatusUpdateCheck)]
41+
vec![Box::new(StatusUpdateCheck), Box::new(PresenseReport)]
4042
}

src/utils/time.rs

+14-1
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ GNU General Public License for more details.
1515
You should have received a copy of the GNU General Public License
1616
along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
18-
use chrono::{Datelike, Local, TimeZone};
18+
use chrono::{DateTime, Datelike, Local, TimeZone};
1919
use chrono_tz::Asia::Kolkata;
20+
use chrono_tz::Tz;
2021
use tracing::debug;
2122

2223
use std::time::Duration;
@@ -45,3 +46,15 @@ pub fn time_until(hour: u32, minute: u32) -> Duration {
4546
debug!("duration: {}", duration);
4647
Duration::from_secs(duration.num_seconds().max(0) as u64)
4748
}
49+
50+
pub fn get_five_forty_five_pm_timestamp(now: DateTime<Tz>) -> DateTime<Local> {
51+
let date =
52+
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), now.day()).expect("Invalid date");
53+
let time = chrono::NaiveTime::from_hms_opt(17, 45, 0).expect("Invalid time");
54+
let naive_dt = date.and_time(time);
55+
56+
chrono::Local
57+
.from_local_datetime(&naive_dt)
58+
.single()
59+
.expect("Chrono must work.")
60+
}

0 commit comments

Comments
 (0)