Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/graphql/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub struct MemberStatus {
pub streak: Option<StatusStreak>,
#[serde(rename = "consecutiveMisses")]
pub consecutive_misses: Option<i32>,
#[serde(rename = "lifeStatus")]
pub life_status: Option<LifeStatus>,
}

#[derive(Clone, Debug, Deserialize)]
Expand All @@ -66,3 +68,16 @@ pub struct AttendanceRecord {
#[serde(rename = "timeIn")]
pub time_in: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct LifeStatus {
#[serde(rename = "memberId")]
pub member_id: i32,
pub lives: i32,
#[serde(rename = "recoveryStreak")]
pub recovery_streak: i32,
#[serde(rename = "isProbation")]
pub is_probation: bool,
#[serde(rename = "lastResetMonth")]
pub last_reset_month: i32,
}
73 changes: 72 additions & 1 deletion src/graphql/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, LifeStatus, Member};

use super::GraphQLClient;

Expand All @@ -43,6 +43,13 @@ impl GraphQLClient {
maxStreak
}
consecutiveMisses
lifeStatus {
memberId
lives
recoveryStreak
isProbation
lastResetMonth
}
}
track
year
Expand Down Expand Up @@ -225,4 +232,68 @@ impl GraphQLClient {
.collect();
Ok((exists, roles))
}

pub async fn update_life_status(
&self,
member_id: i32,
lives: i32,
recovery_streak: i32,
is_probation: bool,
last_reset_month: i32,
) -> anyhow::Result<LifeStatus> {
let query = r#"
mutation($memberId: Int!, $lives: Int!, $recoveryStreak: Int!, $isProbation: Boolean!, $lastResetMonth: Int!) {
updateLifeStatus(input: {
memberId: $memberId
lives: $lives
recoveryStreak: $recoveryStreak
isProbation: $isProbation
lastResetMonth: $lastResetMonth
}) {
memberId
lives
recoveryStreak
isProbation
lastResetMonth
}
}"#;

let variables = serde_json::json!({
"memberId": member_id,
"lives": lives,
"recoveryStreak": recovery_streak,
"isProbation": is_probation,
"lastResetMonth": last_reset_month,
});

let response = self
.http()
.post(self.root_url())
.bearer_auth(self.api_key())
.json(&serde_json::json!({
"query": query,
"variables": variables
}))
.send()
.await?
.json::<serde_json::Value>()
.await?;

if response.get("errors").is_some() {
anyhow::bail!("GraphQL error: {:?}", response["errors"]);
}

let data = response
.get("data")
.and_then(|data| data.get("updateLifeStatus"))
.ok_or_else(|| {
anyhow::anyhow!(
"Malformed response: Could not access updateLifeStatus from {}",
response
)
})?;

let status: LifeStatus = serde_json::from_value(data.clone())?;
Ok(status)
}
}
113 changes: 100 additions & 13 deletions src/tasks/status_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::collections::HashMap;

use chrono::Datelike;
use serenity::all::{CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GuildId};
use serenity::async_trait;
use tracing::instrument;
Expand Down Expand Up @@ -57,11 +58,12 @@ pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow:
let mut members = client.fetch_member_data(yesterday).await?;
members.retain(|member| !matches!(member.year, Some(4) | None));

let processed_members = process_lives(&client, &members, now).await?;
// naughty_list -> members who did not send updates
let (naughty_list, years_on_break) = categorize_members(&members);
let (naughty_list, years_on_break) = categorize_members(&processed_members);

kick_lazy_bums(&ctx, naughty_list.values().flatten().cloned().collect()).await;
let embed = generate_embed(members, naughty_list, years_on_break).await?;
let embed = generate_embed(processed_members, naughty_list, years_on_break).await?;
let msg = CreateMessage::new().embed(embed);

let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID);
Expand All @@ -70,6 +72,85 @@ pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow:
Ok(())
}

async fn process_lives(
client: &GraphQLClient,
members: &[Member],
now: chrono::DateTime<chrono_tz::Tz>,
) -> anyhow::Result<Vec<Member>> {
let current_month = now.month() as i32;
let mut processed = Vec::new();

for member in members {
let Some(status) = &member.status else {
continue;
};
let Some(on_date) = &status.on_date else {
continue;
};

let (mut lives, mut recovery_streak, mut is_probation, mut last_month) =
if let Some(life) = &status.life_status {
(
life.lives,
life.recovery_streak,
life.is_probation,
life.last_reset_month,
)
} else {
(3, 0, false, current_month)
};

// 1. Monthly reset logic: if a new month has started, reset to 3 lives
if last_month != current_month {
lives = 3;
recovery_streak = 0;
is_probation = false;
last_month = current_month;
}

// 2. Evaluate yesterday's status update if not on status break
if !on_date.on_break {
if on_date.is_sent {
recovery_streak += 1;
if recovery_streak == 4 {
recovery_streak = 0;
if lives < 3 && !is_probation {
lives += 1;
}
}
} else {
recovery_streak = 0;
if lives > 0 {
lives -= 1;
if lives == 0 {
is_probation = true;
}
}
}
}

// 3. Save new life status back to Root
let updated_life = client
.update_life_status(
member.member_id,
lives,
recovery_streak,
is_probation,
last_month,
)
.await?;

// 4. Update the Member object
let mut updated_member = member.clone();
if let Some(s) = &mut updated_member.status {
s.life_status = Some(updated_life);
}
processed.push(updated_member);
}

Ok(processed)
}

fn categorize_members(members: &Vec<Member>) -> (GroupedMember, Vec<i32>) {
let mut naughty_list: HashMap<Option<String>, Vec<Member>> = HashMap::new();
let mut members_by_year: HashMap<i32, Vec<bool>> = HashMap::new();
Expand Down Expand Up @@ -182,13 +263,14 @@ async fn kick_lazy_bums(ctx: &Context, naughty_list: Vec<Member>) {
let guild_id = GuildId::new(AMFOSS_GUILD_ID);

for member in naughty_list {
let consecutive_misses = member
let is_probation = member
.status
.as_ref()
.and_then(|s| s.consecutive_misses)
.unwrap_or(0);
.and_then(|s| s.life_status.as_ref())
.map(|l| l.is_probation)
.unwrap_or(false);

if consecutive_misses > 3 {
if is_probation {
let Some(id_str) = member.discord_id.as_deref() else {
warn!("Cannot kick {}: Missing Discord ID", member.name);
return;
Expand All @@ -204,15 +286,15 @@ async fn kick_lazy_bums(ctx: &Context, naughty_list: Vec<Member>) {
return;
}
};
let reason = "You have been kicked for not sending status updates, reach out to a mentor for further details.";
let reason = "You have been kicked for not sending status updates and losing all 3 lives. Reach out to a mentor for further details.";

match guild_id
.kick_with_reason(ctx.http(), discord_id, reason)
.await
{
Ok(_) => {
info!(
"Kicked Member: {}, ID: {} for failing to send updates.",
"Kicked Member: {}, ID: {} for failing to send updates (lost all lives).",
member.name, member.member_id
)
}
Expand All @@ -236,11 +318,16 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String {
}

for member in missed_members {
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:",
let lives = member
.status
.as_ref()
.and_then(|s| s.life_status.as_ref())
.map(|l| l.lives)
.unwrap_or(3);
let status = match lives {
3 => ":heart::heart::heart:",
2 => ":heart::heart:",
1 => ":heart:",
_ => ":headstone:",
};
description.push_str(&format!("- {} | {}\n", member.name, status));
Expand Down
Loading