diff --git a/Cargo.toml b/Cargo.toml index 8fadf71..1f30f7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,18 @@ edition = "2021" [dependencies] anyhow = "1.0.66" +async-trait = "0.1.83" chrono = "0.4.38" -chrono-tz = "0.9.0" -poise = "0.6.1" -reqwest = { version = "0.12.5", features = ["blocking", "json"] } +chrono-tz = "0.10.0" +poise = { git = "https://github.com/serenity-rs/poise", branch = "current" } +reqwest = { version = "0.12.5", features = ["json"] } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" -serenity = { version = "0.12.0", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] } -shuttle-runtime = "0.47.0" -shuttle-serenity = "0.47.0" +serenity = { git = "https://github.com/serenity-rs/serenity", branch = "current" } +shuttle-runtime = "0.48.0" +shuttle-serenity = "0.48.0" tokio = "1.26.0" tracing = "0.1.37" + +[patch.crates-io] +serenity = { git = "https://github.com/serenity-rs/serenity", branch = "current" } diff --git a/src/graphql.rs b/src/graphql.rs new file mode 100644 index 0000000..4d4bcf7 --- /dev/null +++ b/src/graphql.rs @@ -0,0 +1,47 @@ +/* +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 . +*/ +use serde_json::Value; + +const REQUEST_URL: &str = "https://root.shuttleapp.rs/"; + +pub async fn fetch_members() -> Result, reqwest::Error> { + let client = reqwest::Client::new(); + let query = r#" + query { + getMember { + name + } + }"#; + + let response = client + .post(REQUEST_URL) + .json(&serde_json::json!({"query": query})) + .send() + .await?; + + let json: Value = response.json().await?; + + let member_names: Vec = json["data"]["getMember"] + .as_array() + .unwrap() + .iter() + .map(|member| member["name"].as_str().unwrap().to_string()) + .collect(); + + Ok(member_names) +} diff --git a/src/main.rs b/src/main.rs index 28af8ed..5b40c5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,10 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ mod commands; +mod graphql; +mod scheduler; +mod tasks; +mod utils; use anyhow::Context as _; use std::collections::HashMap; @@ -77,6 +81,8 @@ async fn main( (ReactionType::Unicode("📁".to_string()), role_id), ); + crate::scheduler::run_scheduler(ctx.clone()).await; + Ok(data) }) }) @@ -126,7 +132,8 @@ async fn event_handler( if &removed_reaction.emoji == expected_reaction { if let Some(guild_id) = removed_reaction.guild_id { if let Ok(member) = guild_id - .member(ctx, removed_reaction.user_id.unwrap()).await + .member(ctx, removed_reaction.user_id.unwrap()) + .await { if let Err(e) = member.remove_role(&ctx.http, *role_id).await { eprintln!("Error: {:?}", e); diff --git a/src/scheduler.rs b/src/scheduler.rs new file mode 100644 index 0000000..7f701d4 --- /dev/null +++ b/src/scheduler.rs @@ -0,0 +1,38 @@ +/* +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 . +*/ +use crate::tasks::{get_tasks, Task}; +use serenity::client::Context as SerenityContext; + +use tokio::spawn; + +pub async fn run_scheduler(ctx: SerenityContext) { + let tasks = get_tasks(); + + for task in tasks { + spawn(schedule_task(ctx.clone(), task)); + } +} + +async fn schedule_task(ctx: SerenityContext, task: Box) { + loop { + let next_run_in = task.interval(); + tokio::time::sleep(next_run_in).await; + + task.run(ctx.clone()).await; + } +} diff --git a/src/tasks.rs b/src/tasks.rs new file mode 100644 index 0000000..5a3811e --- /dev/null +++ b/src/tasks.rs @@ -0,0 +1,134 @@ +/* +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 . +*/ +use crate::{ + graphql::fetch_members, + utils::{get_five_am_timestamp, time_until}, +}; +use async_trait::async_trait; +use serenity::{ + all::{ChannelId, Message}, + client::Context, +}; + +use tokio::time::Duration; + +const GROUP_ONE_CHANNEL_ID: u64 = 1225098248293716008; +const GROUP_TWO_CHANNEL_ID: u64 = 1225098298935738489; +const GROUP_THREE_CHANNEL_ID: u64 = 1225098353378070710; +const GROUP_FOUR_CHANNEL_ID: u64 = 1225098407216156712; +const STATUS_UPDATE_CHANNEL_ID: u64 = 764575524127244318; + +#[async_trait] +pub trait Task: Send + Sync { + fn name(&self) -> &'static str; + fn interval(&self) -> Duration; + async fn run(&self, ctx: Context); +} + +pub struct StatusUpdateCheck; + +#[async_trait] +impl Task for StatusUpdateCheck { + fn name(&self) -> &'static str { + "StatusUpdateCheck" + } + + fn interval(&self) -> Duration { + time_until(5, 0) + } + + async fn run(&self, ctx: Context) { + let members = fetch_members().await.expect("Root must be up."); + + let channel_ids: Vec = vec![ + ChannelId::new(GROUP_ONE_CHANNEL_ID), + ChannelId::new(GROUP_TWO_CHANNEL_ID), + ChannelId::new(GROUP_THREE_CHANNEL_ID), + ChannelId::new(GROUP_FOUR_CHANNEL_ID), + ]; + + let time = chrono::Local::now().with_timezone(&chrono_tz::Asia::Kolkata); + let today_five_am = get_five_am_timestamp(time); + let yesterday_five_am = today_five_am - chrono::Duration::hours(24); + + let mut valid_updates: Vec = vec![]; + + for &channel_id in &channel_ids { + let builder = serenity::builder::GetMessages::new().limit(50); + match channel_id.messages(&ctx.http, builder).await { + Ok(messages) => { + let filtered_messages: Vec = messages + .into_iter() + .filter(|msg| { + msg.timestamp >= yesterday_five_am.into() + && msg.timestamp < today_five_am.into() + && msg.content.to_lowercase().contains("namah shivaya") + && msg.content.to_lowercase().contains("regards") + }) + .collect(); + + valid_updates.extend(filtered_messages); + } + Err(e) => println!("ERROR: {:?}", e), + } + } + + let mut naughty_list: Vec = vec![]; + + for member in &members { + let name_parts: Vec<&str> = member.split_whitespace().collect(); + let first_name = name_parts.get(0).unwrap_or(&""); + let last_name = name_parts.get(1).unwrap_or(&""); + let has_sent_update = valid_updates + .iter() + .any(|msg| msg.content.contains(first_name) || msg.content.contains(last_name)); + + if !has_sent_update { + naughty_list.push(member.to_string()); + } + } + + let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID); + + if naughty_list.is_empty() { + status_update_channel + .say(ctx.http, "Everyone sent their update today!") + .await; + } else { + let formatted_list = naughty_list + .iter() + .enumerate() + .map(|(i, member)| format!("{}. {:?}", i + 1, member)) + .collect::>() + .join("\n"); + status_update_channel + .say( + ctx.http, + format!( + "These members did not send their updates:\n{}", + formatted_list + ), + ) + .await; + } + } +} + +pub fn get_tasks() -> Vec> { + vec![Box::new(StatusUpdateCheck)] +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..7409f79 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,41 @@ +/* +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 . +*/ +use chrono::{DateTime, Datelike, Local, TimeZone}; +use chrono_tz::Tz; +use tokio::time::Duration; + +pub fn time_until(hour: u32, minute: u32) -> Duration { + let now = chrono::Local::now().with_timezone(&chrono_tz::Asia::Kolkata); + let today_run = now.date().and_hms(hour, minute, 0); + + let next_run = if now < today_run { + today_run + } else { + today_run + chrono::Duration::days(1) + }; + + let time_until = (next_run - now).to_std().unwrap(); + Duration::from_secs(time_until.as_secs()) +} + +pub fn get_five_am_timestamp(now: DateTime) -> DateTime { + chrono::Local + .ymd(now.year(), now.month(), now.day()) + .and_hms_opt(5, 0, 0) + .expect("Chrono must work.") +}