From 789390818cfe5fd54c3e02035f4c938cc0fd430a Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 20:08:51 +0530 Subject: [PATCH 01/51] dependabot: group crate updates Group all crate version and security updates into a single PR for convenience. Signed-off-by: Ivin Joel Abraham --- .github/dependabot.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7df5f5d..be30283 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,12 @@ updates: open-pull-requests-limit: 1 target-branch: "develop" groups: - all-dependencies: - applies-to: [version-updates, security-updates] + version-updates: + applies-to: "version-updates" + patterns: + - "*" + + security-updates: + applies-to: "security-updates" patterns: - "*" From c960fc64e7eb2ce4c70ad0a52ffe761ca6902db6 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 20:14:11 +0530 Subject: [PATCH 02/51] chore: remove unused field from model Since the implementation of the track system, groups no longer exist and therefore is no longer used anywhere. Remove the unused field in the `Member` model. Signed-off-by: Ivin Joel Abraham --- src/graphql/models.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 9444fa8..0d55ea4 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -42,11 +42,9 @@ pub struct Member { pub name: String, #[serde(rename = "discordId")] pub discord_id: String, - #[serde(rename = "groupId")] - pub group_id: i32, #[serde(default)] pub streak: Vec, // Note that Root will NOT have multiple Streak elements but it may be an empty list which is why we use a vector here - pub track: Option + pub track: Option, } #[derive(Debug, Deserialize, Clone)] From 1d5a14065949952fad8a2f7cfe1706cdb63c6ca9 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 22:22:04 +0530 Subject: [PATCH 03/51] workflow: correct outdated branch name `main` to `production` Signed-off-by: Ivin Joel Abraham --- .github/workflows/deploy_docs.yml | 3 +-- .github/workflows/lint.yml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 40092b3..f933705 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -3,7 +3,7 @@ name: Deploy Rust Docs on: push: branches: - - main + - production jobs: deploy: @@ -23,4 +23,3 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./target/doc - diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 359e847..491cc23 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,7 @@ name: Lint on: pull_request: - branches: [ "main", "develop" ] + branches: ["production", "develop"] jobs: clippy: From 2be5167d217d45533982568cca705ba38f5e69e5 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 22:25:16 +0530 Subject: [PATCH 04/51] workflows: cache rust build in generate-release Rust builds can be cached for faster builds in subsequent jobs. Use `Swatinmen/rust-cache@v2` to automatically cache `target` and `.cargo` Signed-off-by: Ivin Joel Abraham --- .github/workflows/generate-release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/generate-release.yml b/.github/workflows/generate-release.yml index 720b29c..ae21b3b 100644 --- a/.github/workflows/generate-release.yml +++ b/.github/workflows/generate-release.yml @@ -43,6 +43,11 @@ jobs: with: targets: ${{ matrix.target }} + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Build binary run: cargo build --release --target ${{ matrix.target }} env: From 1f7a89d1561eceda8df0621df0c01712865ee025 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 22:35:18 +0530 Subject: [PATCH 05/51] chore: rustfmt Signed-off-by: Ivin Joel Abraham --- src/tasks/status_update.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 3711b3b..c069dcb 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -158,7 +158,7 @@ fn categorize_members( if sent_updates.contains(&member.discord_id) { nice_list.push(member.clone()); } else { - let track= member.track.clone(); + let track = member.track.clone(); naughty_list .entry(track) .or_insert_with(Vec::new) @@ -238,9 +238,9 @@ fn format_members(members: &[Member]) -> String { fn format_defaulters(naughty_list: &GroupedMember) -> String { let mut description = String::new(); for (track, missed_members) in naughty_list { - match track{ + match track { Some(t) => description.push_str(&format!("## Track - {}\n", t)), - None => description.push_str(&format!("## Unassigned")) + None => description.push_str(&format!("## Unassigned")), } for member in missed_members { From 922b5809ad3cb683542eab370e5861d08a6fdad7 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 13 Aug 2025 22:37:10 +0530 Subject: [PATCH 06/51] chore: fix clippy string interpolation errors A recent update to clippy introduced a new lint for strings that don't interpolate variables. Fix any offending lines. Signed-off-by: Ivin Joel Abraham --- src/commands.rs | 2 +- src/graphql/queries.rs | 5 ++--- src/tasks/lab_attendance.rs | 10 +++++----- src/tasks/status_update.rs | 19 ++++++------------- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 85ed263..4680e23 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -84,7 +84,7 @@ async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { }; if reload_handle.reload(EnvFilter::new(&new_filter)).is_ok() { - ctx.say(format!("Log level changed to **{}**", new_filter)) + ctx.say(format!("Log level changed to **{new_filter}**")) .await?; info!("Log level changed to {}", new_filter); } else { diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index e69d078..a154f8e 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -228,14 +228,13 @@ pub async fn fetch_attendance() -> anyhow::Result> { let query = format!( r#" query {{ - attendanceByDate(date: "{}") {{ + attendanceByDate(date: "{today}") {{ name, year, isPresent, timeIn, }} - }}"#, - today + }}"# ); let response = client diff --git a/src/tasks/lab_attendance.rs b/src/tasks/lab_attendance.rs index 77afd75..9d15e42 100644 --- a/src/tasks/lab_attendance.rs +++ b/src/tasks/lab_attendance.rs @@ -97,7 +97,7 @@ async fn send_lab_closed_message(ctx: SerenityContext) -> anyhow::Result<()> { .unwrap_or_else(|| bot_user.default_avatar_url()); let embed = CreateEmbed::new() - .title(format!("Presense Report - {}", today_date)) + .title(format!("Presense Report - {today_date}")) .url(TITLE_URL) .author( CreateEmbedAuthor::new("amD") @@ -156,7 +156,7 @@ async fn send_attendance_report( description.push_str(&format_attendance_list("Late", &late_list)); let embed = CreateEmbed::new() - .title(format!("Presense Report - {}", today_date)) + .title(format!("Presense Report - {today_date}")) .url(TITLE_URL) .author( CreateEmbedAuthor::new("amD") @@ -191,15 +191,15 @@ fn format_attendance_list(title: &str, list: &[AttendanceRecord]) -> String { } } - let mut result = format!("# {}\n", title); + let mut result = format!("# {title}\n"); for year in 1..=3 { if let Some(names) = by_year.get(&year) { if !names.is_empty() { - result.push_str(&format!("### Year {}\n", year)); + result.push_str(&format!("### Year {year}\n")); for name in names { - result.push_str(&format!("- {}\n", name)); + result.push_str(&format!("- {name}\n")); } result.push('\n'); } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index c069dcb..2fd1563 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -159,10 +159,7 @@ fn categorize_members( nice_list.push(member.clone()); } else { let track = member.track.clone(); - naughty_list - .entry(track) - .or_insert_with(Vec::new) - .push(member.clone()); + naughty_list.entry(track).or_default().push(member.clone()); } } @@ -196,15 +193,11 @@ async fn generate_embed( description.push_str("# Leaderboard Updates\n"); - description.push_str(&format!( - "## All-Time High Streak: {} days\n", - all_time_high - )); + description.push_str(&format!("## All-Time High Streak: {all_time_high} days\n")); description.push_str(&format_members(&all_time_high_members)); description.push_str(&format!( - "## Current Highest Streak: {} days\n", - current_highest + "## Current Highest Streak: {current_highest} days\n" )); description.push_str(&format_members(¤t_highest_members)); @@ -229,7 +222,7 @@ fn format_members(members: &[Member]) -> String { .collect::>() .join("\n"); - format!("{}\n", list) + format!("{list}\n") } else { String::from("More than five members hold this record!\n") } @@ -239,8 +232,8 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { let mut description = String::new(); for (track, missed_members) in naughty_list { match track { - Some(t) => description.push_str(&format!("## Track - {}\n", t)), - None => description.push_str(&format!("## Unassigned")), + Some(t) => description.push_str(&format!("## Track - {t}\n")), + None => description.push_str("## Unassigned"), } for member in missed_members { From 7442f63c7f9d6a64a1a2851815b249dabbbfc12b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:10:33 +0000 Subject: [PATCH 07/51] build(deps): bump the version-updates group with 8 updates Bumps the version-updates group with 8 updates: | Package | From | To | | --- | --- | --- | | [anyhow](https://github.com/dtolnay/anyhow) | `1.0.95` | `1.0.99` | | [async-trait](https://github.com/dtolnay/async-trait) | `0.1.85` | `0.1.88` | | [chrono](https://github.com/chronotope/chrono) | `0.4.39` | `0.4.41` | | [chrono-tz](https://github.com/chronotope/chrono-tz) | `0.10.1` | `0.10.4` | | [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.12` | `0.12.23` | | [serde](https://github.com/serde-rs/serde) | `1.0.217` | `1.0.219` | | [serde_json](https://github.com/serde-rs/json) | `1.0.137` | `1.0.142` | | [tokio](https://github.com/tokio-rs/tokio) | `1.43.0` | `1.47.1` | Updates `anyhow` from 1.0.95 to 1.0.99 - [Release notes](https://github.com/dtolnay/anyhow/releases) - [Commits](https://github.com/dtolnay/anyhow/compare/1.0.95...1.0.99) Updates `async-trait` from 0.1.85 to 0.1.88 - [Release notes](https://github.com/dtolnay/async-trait/releases) - [Commits](https://github.com/dtolnay/async-trait/compare/0.1.85...0.1.88) Updates `chrono` from 0.4.39 to 0.4.41 - [Release notes](https://github.com/chronotope/chrono/releases) - [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md) - [Commits](https://github.com/chronotope/chrono/compare/v0.4.39...v0.4.41) Updates `chrono-tz` from 0.10.1 to 0.10.4 - [Release notes](https://github.com/chronotope/chrono-tz/releases) - [Commits](https://github.com/chronotope/chrono-tz/compare/v0.10.1...v0.10.4) Updates `reqwest` from 0.12.12 to 0.12.23 - [Release notes](https://github.com/seanmonstar/reqwest/releases) - [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md) - [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.12...v0.12.23) Updates `serde` from 1.0.217 to 1.0.219 - [Release notes](https://github.com/serde-rs/serde/releases) - [Commits](https://github.com/serde-rs/serde/compare/v1.0.217...v1.0.219) Updates `serde_json` from 1.0.137 to 1.0.142 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.137...v1.0.142) Updates `tokio` from 1.43.0 to 1.47.1 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.43.0...tokio-1.47.1) --- updated-dependencies: - dependency-name: anyhow dependency-version: 1.0.99 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: async-trait dependency-version: 0.1.88 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: chrono dependency-version: 0.4.41 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: chrono-tz dependency-version: 0.10.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: reqwest dependency-version: 0.12.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: serde dependency-version: 1.0.219 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: serde_json dependency-version: 1.0.142 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: tokio dependency-version: 1.47.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- Cargo.lock | 220 ++++++++++++++++++++++++++++------------------------- Cargo.toml | 16 ++-- 2 files changed, 123 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bef406..c09b831 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -36,7 +36,7 @@ dependencies = [ "chrono-tz", "dotenv", "poise", - "reqwest 0.12.12", + "reqwest 0.12.23", "serde", "serde_json", "serenity", @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "arrayvec" @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.85" +version = "0.1.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", @@ -218,9 +218,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -228,30 +228,19 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "chrono-tz" -version = "0.10.1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6ac4f2c0bf0f44e9161aec9675e1050aa4a530663c4a9e37e108fa948bca9f" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "chrono-tz-build", "phf", ] -[[package]] -name = "chrono-tz-build" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" -dependencies = [ - "parse-zoneinfo", - "phf_codegen", -] - [[package]] name = "command_attr" version = "0.5.3" @@ -759,7 +748,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio", "tower-service", "tracing", @@ -768,9 +757,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -808,7 +797,7 @@ checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", "http 1.2.0", - "hyper 1.5.2", + "hyper 1.6.0", "hyper-util", "rustls 0.23.21", "rustls-pki-types", @@ -825,7 +814,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.5.2", + "hyper 1.6.0", "hyper-util", "native-tls", "tokio", @@ -835,21 +824,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http 1.2.0", "http-body 1.0.1", - "hyper 1.5.2", + "hyper 1.6.0", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1030,12 +1026,33 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.14" @@ -1066,9 +1083,9 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" @@ -1294,15 +1311,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "parse-zoneinfo" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" -dependencies = [ - "regex", -] - [[package]] name = "percent-encoding" version = "2.3.1" @@ -1311,38 +1319,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_generator", "phf_shared", ] -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ "siphasher", ] @@ -1546,7 +1534,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.21.12", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -1567,46 +1555,42 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.12" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "futures-util", "h2 0.4.7", "http 1.2.0", "http-body 1.0.1", "http-body-util", - "hyper 1.5.2", + "hyper 1.6.0", "hyper-rustls 0.27.5", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 2.2.0", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", - "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry", ] [[package]] @@ -1691,15 +1675,6 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.10.1" @@ -1817,9 +1792,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] @@ -1835,9 +1810,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1846,9 +1821,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.137" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -1977,6 +1952,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -2190,18 +2175,20 @@ dependencies = [ [[package]] name = "tokio" -version = "1.43.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "pin-project-lite", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2300,6 +2287,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.8.0", + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2694,34 +2699,39 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-registry" -version = "0.2.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ + "windows-link", "windows-result", "windows-strings", - "windows-targets 0.52.6", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-link", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ede679c..c4d1034 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,14 @@ version = "1.2.1" edition = "2021" [dependencies] -anyhow = "1.0.95" -async-trait = "0.1.83" -chrono = "0.4.38" -chrono-tz = "0.10.0" -reqwest = { version = "0.12.5", features = ["json"] } -serde = { version = "1.0.203", features = ["derive"] } -serde_json = "1.0.117" -tokio = { version = "1.26.0", features = ["rt-multi-thread", "macros"] } +anyhow = "1.0.99" +async-trait = "0.1.88" +chrono = "0.4.41" +chrono-tz = "0.10.4" +reqwest = { version = "0.12.23", features = ["json"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.142" +tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] } tracing = "0.1.37" dotenv = "0.15.0" serenity = { version = "0.12.4", features = ["chrono"] } From 0d9450e5ff78408fc788e1fbb1c99fde511e27f3 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Thu, 11 Sep 2025 20:29:38 +0530 Subject: [PATCH 08/51] refactor: extract env loading for tracing `setup_tracing` requires a few environment variables that can be refactored into a standalone struct and accompanied function that can load the required variables as well as provide sane defaults instead of simply panicking when they are not found. `crate_name` can be excluded as it is provided by Cargo. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index e1faf4c..e59fa46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,28 +52,47 @@ pub struct Data { pub log_reload_handle: ReloadHandle, } +/// Environment variables that our tracing configuration relies on +/// +/// # Fields +/// +/// * env: String that decides in what context the application will be running on i.e "production" or "development". This allows us to filter out logs from `stdout` when in production. Possible TODO: Could be replaced to a boolean `is_dev` or something similar to be more constrained than a string. +/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. +struct TracingConfig { + env: String, + enable_debug_libraries: bool, +} + +impl TracingConfig { + /// Encapsulate all the required env variables into a [`TracingConfig`] + fn load_tracing_config() -> Self { + Self { + env: std::env::var("AMD_RUST_ENV").unwrap_or("development".to_string()), + // Some Rust shenanigans to set the default value to a boolean false: + enable_debug_libraries: std::env::var("ENABLE_DEBUG_LIBRARIES") + .unwrap_or("false".to_string()) + .parse() + .unwrap_or(false), + } + } +} + fn setup_tracing() -> anyhow::Result { - let env = std::env::var("AMD_RUST_ENV").context("RUST_ENV was not found in the ENV")?; - let enable_debug_libraries_string = std::env::var("ENABLE_DEBUG_LIBRARIES") - .context("ENABLE_DEBUG_LIBRARIES was not found in the ENV")?; - let enable_debug_libraries: bool = enable_debug_libraries_string - .parse() - .context("Failed to parse ENABLE_DEBUG_LIBRARIES")?; + let config = TracingConfig::load_tracing_config(); let crate_name = env!("CARGO_CRATE_NAME"); - let (filter, reload_handle) = reload::Layer::new(EnvFilter::new( - if env == "production" && enable_debug_libraries { + if config.env == "production" && config.enable_debug_libraries { "info".to_string() - } else if env == "production" && !enable_debug_libraries { + } else if config.env == "production" && !config.enable_debug_libraries { format!("{crate_name}=info") - } else if enable_debug_libraries { + } else if config.enable_debug_libraries { "trace".to_string() } else { format!("{crate_name}=trace") }, )); - if env != "production" { + if config.env != "production" { let subscriber = tracing_subscriber::registry() .with(filter) .with(fmt::layer().pretty().with_writer(std::io::stdout)) From 491c8919d82986d788a2e1e09eb0f61a88f585f9 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Thu, 11 Sep 2025 20:56:43 +0530 Subject: [PATCH 09/51] refactor: extract filter string creation The filter string is determined by the variables in TracingConfig and can be extracted into a helper function. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index e59fa46..1cf2737 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,20 +77,23 @@ impl TracingConfig { } } +/// Return the appropriate String denoting the level and breadth of logs depending on the [`TracingConfig`] passed in. +fn build_filter_string(config: &TracingConfig) -> String { + let crate_name = env!("CARGO_CRATE_NAME"); + + match (config.env.as_str(), config.enable_debug_libraries) { + ("production", true) => "info".to_string(), + ("production", false) => format!("{crate_name}=info"), + + (_, true) => "trace".to_string(), + (_, false) => format!("{crate_name}=trace"), + } +} + fn setup_tracing() -> anyhow::Result { let config = TracingConfig::load_tracing_config(); - let crate_name = env!("CARGO_CRATE_NAME"); - let (filter, reload_handle) = reload::Layer::new(EnvFilter::new( - if config.env == "production" && config.enable_debug_libraries { - "info".to_string() - } else if config.env == "production" && !config.enable_debug_libraries { - format!("{crate_name}=info") - } else if config.enable_debug_libraries { - "trace".to_string() - } else { - format!("{crate_name}=trace") - }, - )); + let filter_string = build_filter_string(&config); + let (filter, reload_handle) = reload::Layer::new(EnvFilter::new(filter_string)); if config.env != "production" { let subscriber = tracing_subscriber::registry() From a76188ae607acbd8846da57483b2eac0add91863 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Thu, 11 Sep 2025 21:04:22 +0530 Subject: [PATCH 10/51] refactor: replace qualified path with use Signed-off-by: Ivin Joel Abraham --- src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1cf2737..3030afb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,12 @@ use serenity::{ }; use tokio::sync::RwLock; use tracing::info; -use tracing_subscriber::{fmt, layer::SubscriberExt, reload, EnvFilter, Registry}; +use tracing_subscriber::{ + fmt, + layer::SubscriberExt, + reload::{self, Layer}, + EnvFilter, Registry, +}; use std::{ collections::{HashMap, HashSet}, @@ -93,7 +98,7 @@ fn build_filter_string(config: &TracingConfig) -> String { fn setup_tracing() -> anyhow::Result { let config = TracingConfig::load_tracing_config(); let filter_string = build_filter_string(&config); - let (filter, reload_handle) = reload::Layer::new(EnvFilter::new(filter_string)); + let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); if config.env != "production" { let subscriber = tracing_subscriber::registry() From 456859243a26d39ad6d4f3974f4286bc6e9dbeae Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Fri, 12 Sep 2025 10:25:45 +0530 Subject: [PATCH 11/51] refactor: extract tracing subscriber initialization `setup_tracing` need not be concerned with how a subscriber is setup given the required configuration. Extract the initialization into a helper function that will allow for easier modification later. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 64 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3030afb..f12d80c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,35 +95,51 @@ fn build_filter_string(config: &TracingConfig) -> String { } } +/// Build a suitable subscriber based on the context of the environment (i.e production or development). The only difference in subscriber configuration is that in a production context, logs are only sent to `amd.log` and not to `stdout`. This is done on the assumption that when deployed in production, checking terminal logs is neither reliable nor convenient. +/// +/// # Arguments +/// +/// * env: A string that can be set to "production" in order to disable logging to `stdout` and when set to anything else, enable logging to `stdout`. +/// * filter: The filter used to determine the log level for this subscriber. +/// +/// Returns the initialized subscriber inside a [`Box`]. +fn build_subscriber( + env: String, + filter: L, +) -> anyhow::Result> +where + L: tracing_subscriber::Layer + Send + Sync + 'static, +{ + let file_layer = fmt::layer() + .pretty() + .with_ansi(false) + .with_writer(File::create("amd.log").context("Failed to create log file")?); + + if env != "production" { + Ok(Box::new( + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(fmt::layer().pretty().with_writer(std::io::stdout)), + )) + } else { + Ok(Box::new( + tracing_subscriber::registry().with(filter).with(file_layer), + )) + } +} + fn setup_tracing() -> anyhow::Result { let config = TracingConfig::load_tracing_config(); let filter_string = build_filter_string(&config); let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); - if config.env != "production" { - let subscriber = tracing_subscriber::registry() - .with(filter) - .with(fmt::layer().pretty().with_writer(std::io::stdout)) - .with( - fmt::layer() - .pretty() - .with_ansi(false) - .with_writer(File::create("amd.log").context("Failed to create subscriber")?), - ); - - tracing::subscriber::set_global_default(subscriber).context("Failed to set subscriber")?; - Ok(Arc::new(RwLock::new(reload_handle))) - } else { - let subscriber = tracing_subscriber::registry().with(filter).with( - fmt::layer() - .pretty() - .with_ansi(false) - .with_writer(File::create("amd.log").context("Failed to create subscriber")?), - ); - - tracing::subscriber::set_global_default(subscriber).context("Failed to set subscriber")?; - Ok(Arc::new(RwLock::new(reload_handle))) - } + let boxed_subscriber: Box = + build_subscriber(config.env, filter)?; + tracing::subscriber::set_global_default(boxed_subscriber) + .context("Failed to set subscriber")?; + + Ok(Arc::new(RwLock::new(reload_handle))) } #[tokio::main] From 01872e00c565c0b5e6fb7f014015ca4a5cce767d Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Fri, 12 Sep 2025 11:29:20 +0530 Subject: [PATCH 12/51] refactor: extract tracing configuration The main module can be stripped of all the functionality related to tracing and kept simple. Extract all functions and types that are required for configuring tracing into it's own module. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 102 ++------------------------------------------- src/trace.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 98 deletions(-) create mode 100644 src/trace.rs diff --git a/src/main.rs b/src/main.rs index f12d80c..0fea113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,11 @@ mod reaction_roles; mod scheduler; /// A trait to define a job that needs to be executed regularly, for example checking for status updates daily. mod tasks; +mod trace; mod utils; +use crate::trace::setup_tracing; +use crate::trace::ReloadHandle; use anyhow::Context as _; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; use reaction_roles::{handle_reaction, populate_data_with_reaction_roles}; @@ -33,115 +36,18 @@ use serenity::{ client::{Context as SerenityContext, FullEvent}, model::gateway::GatewayIntents, }; -use tokio::sync::RwLock; use tracing::info; -use tracing_subscriber::{ - fmt, - layer::SubscriberExt, - reload::{self, Layer}, - EnvFilter, Registry, -}; -use std::{ - collections::{HashMap, HashSet}, - fs::File, - sync::Arc, -}; +use std::collections::{HashMap, HashSet}; pub type Error = Box; pub type Context<'a> = PoiseContext<'a, Data, Error>; -pub type ReloadHandle = Arc>>; pub struct Data { pub reaction_roles: HashMap, pub log_reload_handle: ReloadHandle, } -/// Environment variables that our tracing configuration relies on -/// -/// # Fields -/// -/// * env: String that decides in what context the application will be running on i.e "production" or "development". This allows us to filter out logs from `stdout` when in production. Possible TODO: Could be replaced to a boolean `is_dev` or something similar to be more constrained than a string. -/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. -struct TracingConfig { - env: String, - enable_debug_libraries: bool, -} - -impl TracingConfig { - /// Encapsulate all the required env variables into a [`TracingConfig`] - fn load_tracing_config() -> Self { - Self { - env: std::env::var("AMD_RUST_ENV").unwrap_or("development".to_string()), - // Some Rust shenanigans to set the default value to a boolean false: - enable_debug_libraries: std::env::var("ENABLE_DEBUG_LIBRARIES") - .unwrap_or("false".to_string()) - .parse() - .unwrap_or(false), - } - } -} - -/// Return the appropriate String denoting the level and breadth of logs depending on the [`TracingConfig`] passed in. -fn build_filter_string(config: &TracingConfig) -> String { - let crate_name = env!("CARGO_CRATE_NAME"); - - match (config.env.as_str(), config.enable_debug_libraries) { - ("production", true) => "info".to_string(), - ("production", false) => format!("{crate_name}=info"), - - (_, true) => "trace".to_string(), - (_, false) => format!("{crate_name}=trace"), - } -} - -/// Build a suitable subscriber based on the context of the environment (i.e production or development). The only difference in subscriber configuration is that in a production context, logs are only sent to `amd.log` and not to `stdout`. This is done on the assumption that when deployed in production, checking terminal logs is neither reliable nor convenient. -/// -/// # Arguments -/// -/// * env: A string that can be set to "production" in order to disable logging to `stdout` and when set to anything else, enable logging to `stdout`. -/// * filter: The filter used to determine the log level for this subscriber. -/// -/// Returns the initialized subscriber inside a [`Box`]. -fn build_subscriber( - env: String, - filter: L, -) -> anyhow::Result> -where - L: tracing_subscriber::Layer + Send + Sync + 'static, -{ - let file_layer = fmt::layer() - .pretty() - .with_ansi(false) - .with_writer(File::create("amd.log").context("Failed to create log file")?); - - if env != "production" { - Ok(Box::new( - tracing_subscriber::registry() - .with(filter) - .with(file_layer) - .with(fmt::layer().pretty().with_writer(std::io::stdout)), - )) - } else { - Ok(Box::new( - tracing_subscriber::registry().with(filter).with(file_layer), - )) - } -} - -fn setup_tracing() -> anyhow::Result { - let config = TracingConfig::load_tracing_config(); - let filter_string = build_filter_string(&config); - let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); - - let boxed_subscriber: Box = - build_subscriber(config.env, filter)?; - tracing::subscriber::set_global_default(boxed_subscriber) - .context("Failed to set subscriber")?; - - Ok(Arc::new(RwLock::new(reload_handle))) -} - #[tokio::main] async fn main() -> Result<(), Error> { dotenv::dotenv().ok(); diff --git a/src/trace.rs b/src/trace.rs new file mode 100644 index 0000000..05d3a5c --- /dev/null +++ b/src/trace.rs @@ -0,0 +1,115 @@ +/* +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 . +*/ +//! This module is responsible for configuring and initializing `tracing`. +use std::{fs::File, sync::Arc}; +use tokio::sync::RwLock; + +use anyhow::Context; +use tracing_subscriber::{ + fmt, + layer::SubscriberExt, + reload::{self, Layer}, + EnvFilter, Registry, +}; + +pub type ReloadHandle = Arc>>; + +/// Environment variables that our tracing configuration relies on +/// +/// # Fields +/// +/// * env: String that decides in what context the application will be running on i.e "production" or "development". This allows us to filter out logs from `stdout` when in production. Possible TODO: Could be replaced to a boolean `is_dev` or something similar to be more constrained than a string. +/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. +struct TracingConfig { + env: String, + enable_debug_libraries: bool, +} + +impl TracingConfig { + /// Encapsulate all the required env variables into a [`TracingConfig`] + fn load_tracing_config() -> Self { + Self { + env: std::env::var("AMD_RUST_ENV").unwrap_or("development".to_string()), + // Some Rust shenanigans to set the default value to a boolean false: + enable_debug_libraries: std::env::var("ENABLE_DEBUG_LIBRARIES") + .unwrap_or("false".to_string()) + .parse() + .unwrap_or(false), + } + } +} + +/// Return the appropriate String denoting the level and breadth of logs depending on the [`TracingConfig`] passed in. +fn build_filter_string(config: &TracingConfig) -> String { + let crate_name = env!("CARGO_CRATE_NAME"); + + match (config.env.as_str(), config.enable_debug_libraries) { + ("production", true) => "info".to_string(), + ("production", false) => format!("{crate_name}=info"), + + (_, true) => "trace".to_string(), + (_, false) => format!("{crate_name}=trace"), + } +} + +/// Build a suitable subscriber based on the context of the environment (i.e production or development). The only difference in subscriber configuration is that in a production context, logs are only sent to `amd.log` and not to `stdout`. This is done on the assumption that when deployed in production, checking terminal logs is neither reliable nor convenient. +/// +/// # Arguments +/// +/// * env: A string that can be set to "production" in order to disable logging to `stdout` and when set to anything else, enable logging to `stdout`. +/// * filter: The filter used to determine the log level for this subscriber. +/// +/// Returns the initialized subscriber inside a [`Box`]. +fn build_subscriber( + env: String, + filter: L, +) -> anyhow::Result> +where + L: tracing_subscriber::Layer + Send + Sync + 'static, +{ + let file_layer = fmt::layer() + .pretty() + .with_ansi(false) + .with_writer(File::create("amd.log").context("Failed to create log file")?); + + if env != "production" { + Ok(Box::new( + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(fmt::layer().pretty().with_writer(std::io::stdout)), + )) + } else { + Ok(Box::new( + tracing_subscriber::registry().with(filter).with(file_layer), + )) + } +} + +pub fn setup_tracing() -> anyhow::Result { + let config = TracingConfig::load_tracing_config(); + let filter_string = build_filter_string(&config); + let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); + + let boxed_subscriber: Box = + build_subscriber(config.env, filter)?; + tracing::subscriber::set_global_default(boxed_subscriber) + .context("Failed to set subscriber")?; + + Ok(Arc::new(RwLock::new(reload_handle))) +} From 14934f4f27e28afecb4539ad47f18f80ea605c7e Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Fri, 12 Sep 2025 11:43:53 +0530 Subject: [PATCH 13/51] refactor!: rename and change the type of env variable The `AMD_RUST_ENV` is not an intuitive variable to set as it requires the deployer to know the correct mode is "production". Change the type and name to make it easier to get into development mode. Signed-off-by: Ivin Joel Abraham --- .env.sample | 2 +- docs/CONTRIBUTING.md | 2 +- docs/README.md | 2 +- src/trace.rs | 27 +++++++++++++++------------ 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.env.sample b/.env.sample index 61c4d8c..13e0d73 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,5 @@ DISCORD_TOKEN= ROOT_URL=https://root.amfoss.in/ OWNER_ID= -AMD_RUST_ENV=trace +DEBUG=true ENABLE_DEBUG_LIBRARIES=false diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e72aee3..2d19391 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -7,7 +7,7 @@ The rest of this document will explain the high-level details of the internals o # Documentation ## Environment Variables -`AMD_RUST_ENV`: Controls the log levels, although it can still be changed at runtime. Set to `production` to only log messages at the `INFO` level or above. If set to anything other than `production` say `dev`, tracing will also output logs to `stdout` as well to the file `amd.log`. +`DEBUG`: Controls whether logs are printed to stdout. Set to `true` to only log messages into `amd.log`. If set to `false`, tracing will also output logs to `stdout` as well to `amd.log`. `ENABLE_DEBUG_LIBRARIES`: Boolean that controls whether debug information from non-amd crates are logged. `DISCORD_TOKEN`: The token for the bot. `OWNER_ID`: The Discord User ID for a user that will be designated as the owner and will have access to certain privileged commands such as `set_log_level`. diff --git a/docs/README.md b/docs/README.md index 9ca70c7..6cbd2e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ If you want to contribute to `amD`, you'll likely need to run your own instance - [Rust](https://www.rust-lang.org/tools/install) - A Discord Bot Token from the [Discord Developer Protal](https://discord.com/developers/) . -After which, you can make your changes to the source code and modify the environment variables to have your own instance up and running. A more detailed guide to development and contributing can be found in [CONTRIBUTING.md.](/docs/CONTRIBUTING.md) +After which, you can make your changes to the source code and modify the environment variables to have your own instance up and running. A more detailed guide to development and contributing can be found in [CONTRIBUTING.md.](./docs/CONTRIBUTING.md) # License This project is licensed under the GNU General Public License v3.0. See the LICENSE file for details. diff --git a/src/trace.rs b/src/trace.rs index 05d3a5c..95ee7b1 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -33,10 +33,10 @@ pub type ReloadHandle = Arc>>; /// /// # Fields /// -/// * env: String that decides in what context the application will be running on i.e "production" or "development". This allows us to filter out logs from `stdout` when in production. Possible TODO: Could be replaced to a boolean `is_dev` or something similar to be more constrained than a string. +/// * debug: a boolean flag that decides in what context the application will be running on. When true, it is assumed to be in development. This allows us to filter out logs from `stdout` when in production. /// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. struct TracingConfig { - env: String, + debug: bool, enable_debug_libraries: bool, } @@ -44,8 +44,11 @@ impl TracingConfig { /// Encapsulate all the required env variables into a [`TracingConfig`] fn load_tracing_config() -> Self { Self { - env: std::env::var("AMD_RUST_ENV").unwrap_or("development".to_string()), // Some Rust shenanigans to set the default value to a boolean false: + debug: std::env::var("DEBUG") + .unwrap_or("false".to_string()) + .parse() + .unwrap_or(false), enable_debug_libraries: std::env::var("ENABLE_DEBUG_LIBRARIES") .unwrap_or("false".to_string()) .parse() @@ -58,12 +61,12 @@ impl TracingConfig { fn build_filter_string(config: &TracingConfig) -> String { let crate_name = env!("CARGO_CRATE_NAME"); - match (config.env.as_str(), config.enable_debug_libraries) { - ("production", true) => "info".to_string(), - ("production", false) => format!("{crate_name}=info"), + match (config.debug, config.enable_debug_libraries) { + (true, true) => "info".to_string(), + (true, false) => format!("{crate_name}=info"), - (_, true) => "trace".to_string(), - (_, false) => format!("{crate_name}=trace"), + (false, true) => "trace".to_string(), + (false, false) => format!("{crate_name}=trace"), } } @@ -71,12 +74,12 @@ fn build_filter_string(config: &TracingConfig) -> String { /// /// # Arguments /// -/// * env: A string that can be set to "production" in order to disable logging to `stdout` and when set to anything else, enable logging to `stdout`. +/// * debug: A boolean that can be set to true in order to disable logging to `stdout` and when set to false, enable logging to `stdout`. /// * filter: The filter used to determine the log level for this subscriber. /// /// Returns the initialized subscriber inside a [`Box`]. fn build_subscriber( - env: String, + debug: bool, filter: L, ) -> anyhow::Result> where @@ -87,7 +90,7 @@ where .with_ansi(false) .with_writer(File::create("amd.log").context("Failed to create log file")?); - if env != "production" { + if debug { Ok(Box::new( tracing_subscriber::registry() .with(filter) @@ -107,7 +110,7 @@ pub fn setup_tracing() -> anyhow::Result { let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); let boxed_subscriber: Box = - build_subscriber(config.env, filter)?; + build_subscriber(config.debug, filter)?; tracing::subscriber::set_global_default(boxed_subscriber) .context("Failed to set subscriber")?; From 76bcb3b0eb632a5e9a3eacd384f5ca65654740fb Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Fri, 12 Sep 2025 11:58:12 +0530 Subject: [PATCH 14/51] chore: remove unnecessary public types and structs Entities defined in the crate root, `main.rs`, do not need to be marked by `pub` in order to be visible to it's child modules. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0fea113..306d376 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,10 +40,10 @@ use tracing::info; use std::collections::{HashMap, HashSet}; -pub type Error = Box; -pub type Context<'a> = PoiseContext<'a, Data, Error>; +type Error = Box; +type Context<'a> = PoiseContext<'a, Data, Error>; -pub struct Data { +struct Data { pub reaction_roles: HashMap, pub log_reload_handle: ReloadHandle, } From c7e16e76ac52497b44d0740853309e5cd5b899c2 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 15:51:30 +0530 Subject: [PATCH 15/51] refactor: Extract bot configuration into a struct Resources required for discord such as the DISCORD_TOKEN can be encapsulated into it's own struct and have it's own helper function to load it from the environment. This avoids cluttering main and allows for easier future modification. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 47 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 306d376..6c74adb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,26 +48,51 @@ struct Data { pub log_reload_handle: ReloadHandle, } +/// Struct to hold the resources necessary for the Discord bot to operate. +/// +/// # Fields +/// +/// * discord_token: The bot's discord token obtained from the Discord Developer Portal. +/// * owner_id: Used to allow access to privileged commands to specific users. Potential TODO: It would be more useful to allow access to certain roles (such as Moderator) in the Discord server instead. Poise already supports passing multiple IDs in the owner field when setting up the bot. +struct BotConfig { + discord_token: String, + owner_id: UserId, +} + +impl BotConfig { + /// Returns a new [`BotConfig`] with variables loaded in from env. + /// + /// Panics if any of the fields are not found in the env. + fn from_env() -> anyhow::Result { + let discord_token = + std::env::var("DISCORD_TOKEN").context("DISCORD_TOKEN was not found in env")?; + let owner_id = UserId::from( + std::env::var("OWNER_ID") + .context("OWNER_ID was not found in the env")? + .parse::() + .context("Failed to parse OWNER_ID")?, + ); + + Ok(Self { + discord_token, + owner_id, + }) + } +} + #[tokio::main] async fn main() -> Result<(), Error> { dotenv::dotenv().ok(); let reload_handle = setup_tracing().context("Failed to setup tracing")?; - info!("Tracing initialized. Continuing main..."); + let mut data = Data { reaction_roles: HashMap::new(), log_reload_handle: reload_handle, }; populate_data_with_reaction_roles(&mut data); - let discord_token = - std::env::var("DISCORD_TOKEN").context("DISCORD_TOKEN was not found in the ENV")?; - let owner_id: u64 = std::env::var("OWNER_ID") - .context("OWNER_ID was not found in the ENV")? - .parse() - .context("Failed to parse owner_id")?; - let owner_user_id = UserId::from(owner_id); - + let bot_config = BotConfig::from_env().context("Failed to load BotConfig from env")?; let framework = Framework::builder() .options(FrameworkOptions { commands: commands::get_commands(), @@ -78,7 +103,7 @@ async fn main() -> Result<(), Error> { prefix: Some(String::from("$")), ..Default::default() }, - owners: HashSet::from([owner_user_id]), + owners: HashSet::from([bot_config.owner_id]), ..Default::default() }) .setup(|ctx, _ready, framework| { @@ -91,7 +116,7 @@ async fn main() -> Result<(), Error> { .build(); let mut client = serenity::client::ClientBuilder::new( - discord_token, + bot_config.discord_token, GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, ) .framework(framework) From bd356895864f42f372951958e865366a5498d5e0 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 18:00:34 +0530 Subject: [PATCH 16/51] refactor: add prefix_string to BotConfig The prefix used to issue commands to the bot is part of the bot configuration. Add it to the associated struct. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6c74adb..61eba0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,29 +54,38 @@ struct Data { /// /// * discord_token: The bot's discord token obtained from the Discord Developer Portal. /// * owner_id: Used to allow access to privileged commands to specific users. Potential TODO: It would be more useful to allow access to certain roles (such as Moderator) in the Discord server instead. Poise already supports passing multiple IDs in the owner field when setting up the bot. +/// * prefix_string: The prefix used to issue commands to the bot on Discord. +#[derive(Default)] struct BotConfig { discord_token: String, owner_id: UserId, + prefix_string: String, } impl BotConfig { - /// Returns a new [`BotConfig`] with variables loaded in from env. + fn new_with_prefix(prefix_string: String) -> anyhow::Result { + let mut bot_config = BotConfig::default(); + bot_config + .load_env_var() + .context("Failed to load environment variables for BotConfig")?; + bot_config.prefix_string = prefix_string; + + Ok(bot_config) + } + /// Loads [`BotConfig`]'s `discord_token` and `owner_id` fields from environment variables. /// /// Panics if any of the fields are not found in the env. - fn from_env() -> anyhow::Result { - let discord_token = + fn load_env_var(&mut self) -> anyhow::Result<()> { + self.discord_token = std::env::var("DISCORD_TOKEN").context("DISCORD_TOKEN was not found in env")?; - let owner_id = UserId::from( + self.owner_id = UserId::from( std::env::var("OWNER_ID") .context("OWNER_ID was not found in the env")? .parse::() .context("Failed to parse OWNER_ID")?, ); - Ok(Self { - discord_token, - owner_id, - }) + Ok(()) } } @@ -92,7 +101,8 @@ async fn main() -> Result<(), Error> { }; populate_data_with_reaction_roles(&mut data); - let bot_config = BotConfig::from_env().context("Failed to load BotConfig from env")?; + let bot_config = + BotConfig::new_with_prefix(String::from("$")).context("Failed to construct BotConfig")?; let framework = Framework::builder() .options(FrameworkOptions { commands: commands::get_commands(), From fcda6a86021bb76d5213206f4589d2768694840c Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 18:05:02 +0530 Subject: [PATCH 17/51] refactor: extract framework.build() out of main Building the Poise Framework is a long chain and can be pulled into a helper. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index 61eba0b..dc2a5ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ use crate::trace::ReloadHandle; use anyhow::Context as _; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; use reaction_roles::{handle_reaction, populate_data_with_reaction_roles}; +use serenity::client::ClientBuilder; use serenity::{ all::{ReactionType, RoleId, UserId}, client::{Context as SerenityContext, FullEvent}, @@ -89,31 +90,18 @@ impl BotConfig { } } -#[tokio::main] -async fn main() -> Result<(), Error> { - dotenv::dotenv().ok(); - let reload_handle = setup_tracing().context("Failed to setup tracing")?; - info!("Tracing initialized. Continuing main..."); - - let mut data = Data { - reaction_roles: HashMap::new(), - log_reload_handle: reload_handle, - }; - populate_data_with_reaction_roles(&mut data); - - let bot_config = - BotConfig::new_with_prefix(String::from("$")).context("Failed to construct BotConfig")?; - let framework = Framework::builder() +fn build_framework(owner_id: UserId, prefix_string: String, data: Data) -> Framework { + Framework::builder() .options(FrameworkOptions { commands: commands::get_commands(), event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data)) }, prefix_options: PrefixFrameworkOptions { - prefix: Some(String::from("$")), + prefix: Some(prefix_string), ..Default::default() }, - owners: HashSet::from([bot_config.owner_id]), + owners: HashSet::from([owner_id]), ..Default::default() }) .setup(|ctx, _ready, framework| { @@ -123,9 +111,25 @@ async fn main() -> Result<(), Error> { Ok(data) }) }) - .build(); + .build() +} - let mut client = serenity::client::ClientBuilder::new( +#[tokio::main] +async fn main() -> Result<(), Error> { + dotenv::dotenv().ok(); + let reload_handle = setup_tracing().context("Failed to setup tracing")?; + info!("Tracing initialized. Continuing main..."); + + let mut data = Data { + reaction_roles: HashMap::new(), + log_reload_handle: reload_handle, + }; + populate_data_with_reaction_roles(&mut data); + + let bot_config = + BotConfig::new_with_prefix(String::from("$")).context("Failed to construct BotConfig")?; + let framework = build_framework(bot_config.owner_id, bot_config.prefix_string, data); + let mut client = ClientBuilder::new( bot_config.discord_token, GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, ) From 93c5a5a7b067ae507a5543dbb18987beac69bcb6 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 18:14:56 +0530 Subject: [PATCH 18/51] refactor: use methods to init and populate Data The `Data` struct can be initialized using `impl` methods defined on it and the existing `populate_data_with_reaction_roles` can be a similar method as it only operates on the `Data` struct. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 21 +++++++++---- src/reaction_roles.rs | 68 ++++++++++++++++++++++--------------------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/main.rs b/src/main.rs index dc2a5ac..97b4c38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ use crate::trace::setup_tracing; use crate::trace::ReloadHandle; use anyhow::Context as _; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; -use reaction_roles::{handle_reaction, populate_data_with_reaction_roles}; +use reaction_roles::handle_reaction; use serenity::client::ClientBuilder; use serenity::{ all::{ReactionType, RoleId, UserId}, @@ -44,11 +44,22 @@ use std::collections::{HashMap, HashSet}; type Error = Box; type Context<'a> = PoiseContext<'a, Data, Error>; +/// The [`Data`] struct is kept in-memory by the Bot till it shutdowns and can be used to store session-persistent data. struct Data { pub reaction_roles: HashMap, pub log_reload_handle: ReloadHandle, } +impl Data { + /// Returns a new [`Data`] with an empty `reaction_roles` field and the passed-in `reload_handle`. + fn new_with_reload_handle(reload_handle: ReloadHandle) -> Self { + Data { + reaction_roles: HashMap::new(), + log_reload_handle: reload_handle, + } + } +} + /// Struct to hold the resources necessary for the Discord bot to operate. /// /// # Fields @@ -120,15 +131,13 @@ async fn main() -> Result<(), Error> { let reload_handle = setup_tracing().context("Failed to setup tracing")?; info!("Tracing initialized. Continuing main..."); - let mut data = Data { - reaction_roles: HashMap::new(), - log_reload_handle: reload_handle, - }; - populate_data_with_reaction_roles(&mut data); + let mut data = Data::new_with_reload_handle(reload_handle); + data.populate_with_reaction_roles(); let bot_config = BotConfig::new_with_prefix(String::from("$")).context("Failed to construct BotConfig")?; let framework = build_framework(bot_config.owner_id, bot_config.prefix_string, data); + let mut client = ClientBuilder::new( bot_config.discord_token, GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, diff --git a/src/reaction_roles.rs b/src/reaction_roles.rs index 40e603b..fbb3a36 100644 --- a/src/reaction_roles.rs +++ b/src/reaction_roles.rs @@ -11,40 +11,42 @@ use crate::{ Data, }; -pub fn populate_data_with_reaction_roles(data: &mut Data) { - let roles = [ - ( - ReactionType::Unicode("📁".to_string()), - RoleId::new(ARCHIVE_ROLE_ID), - ), - ( - ReactionType::Unicode("📱".to_string()), - RoleId::new(MOBILE_ROLE_ID), - ), - ( - ReactionType::Unicode("⚙️".to_string()), - RoleId::new(SYSTEMS_ROLE_ID), - ), - ( - ReactionType::Unicode("🤖".to_string()), - RoleId::new(AI_ROLE_ID), - ), - ( - ReactionType::Unicode("📜".to_string()), - RoleId::new(RESEARCH_ROLE_ID), - ), - ( - ReactionType::Unicode("🚀".to_string()), - RoleId::new(DEVOPS_ROLE_ID), - ), - ( - ReactionType::Unicode("🌐".to_string()), - RoleId::new(WEB_ROLE_ID), - ), - ]; +impl Data { + pub fn populate_with_reaction_roles(&mut self) { + let roles = [ + ( + ReactionType::Unicode("📁".to_string()), + RoleId::new(ARCHIVE_ROLE_ID), + ), + ( + ReactionType::Unicode("📱".to_string()), + RoleId::new(MOBILE_ROLE_ID), + ), + ( + ReactionType::Unicode("⚙️".to_string()), + RoleId::new(SYSTEMS_ROLE_ID), + ), + ( + ReactionType::Unicode("🤖".to_string()), + RoleId::new(AI_ROLE_ID), + ), + ( + ReactionType::Unicode("📜".to_string()), + RoleId::new(RESEARCH_ROLE_ID), + ), + ( + ReactionType::Unicode("🚀".to_string()), + RoleId::new(DEVOPS_ROLE_ID), + ), + ( + ReactionType::Unicode("🌐".to_string()), + RoleId::new(WEB_ROLE_ID), + ), + ]; - data.reaction_roles - .extend::>(roles.into()); + self.reaction_roles + .extend::>(roles.into()); + } } pub async fn handle_reaction( From fd246fc19eb6df4170199ace77f758eb9d7e8fa9 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 18:17:17 +0530 Subject: [PATCH 19/51] docs: add documentation for `build_framework` Signed-off-by: Ivin Joel Abraham --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 97b4c38..287d7e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,6 +101,7 @@ impl BotConfig { } } +/// Builds a [`poise::Framework`] with the given arguments and commands from [`commands::get_commands`]. fn build_framework(owner_id: UserId, prefix_string: String, data: Data) -> Framework { Framework::builder() .options(FrameworkOptions { From fc494e2e358a1683a4aa15e33b1252a272d90c6a Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 15 Sep 2025 18:25:37 +0530 Subject: [PATCH 20/51] refactor: move mutations out of queries.rs Functions that call a GraphQL mutation should be in a separate file from queries.rs which as the name implies, is for GraphQL queries. Fixes #69 Signed-off-by: Ivin Joel Abraham --- src/graphql/mod.rs | 1 + src/graphql/mutations.rs | 143 +++++++++++++++++++++++++++++++++++++ src/graphql/queries.rs | 140 +----------------------------------- src/tasks/status_update.rs | 3 +- 4 files changed, 148 insertions(+), 139 deletions(-) create mode 100644 src/graphql/mutations.rs diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index d9c1786..337e62a 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -16,4 +16,5 @@ 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; diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs new file mode 100644 index 0000000..2877a28 --- /dev/null +++ b/src/graphql/mutations.rs @@ -0,0 +1,143 @@ +use anyhow::anyhow; +use anyhow::Context as _; +use tracing::debug; + +use crate::graphql::models::Streak; + +use super::models::Member; + +pub async fn increment_streak(member: &mut Member) -> anyhow::Result<()> { + let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in ENV")?; + + let client = reqwest::Client::new(); + let mutation = format!( + r#" + mutation {{ + incrementStreak(input: {{ memberId: {} }}) {{ + currentStreak + maxStreak + }} + }}"#, + member.member_id + ); + + debug!("Sending mutation {}", mutation); + let response = client + .post(request_url) + .json(&serde_json::json!({"query": mutation})) + .send() + .await + .context("Failed to succesfully post query to Root")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + debug!("Response: {}", response_json); + + if let Some(data) = response_json + .get("data") + .and_then(|data| data.get("incrementStreak")) + { + let current_streak = + data.get("currentStreak") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; + let max_streak = + data.get("maxStreak") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; + + if member.streak.is_empty() { + member.streak.push(Streak { + current_streak, + max_streak, + }); + } else { + for streak in &mut member.streak { + streak.current_streak = current_streak; + streak.max_streak = max_streak; + } + } + } else { + return Err(anyhow!( + "Failed to access data from response: {}", + response_json + )); + } + + Ok(()) +} + +pub async fn reset_streak(member: &mut Member) -> anyhow::Result<()> { + let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in the ENV")?; + + let client = reqwest::Client::new(); + let mutation = format!( + r#" + mutation {{ + resetStreak(input: {{ memberId: {} }}) {{ + currentStreak + maxStreak + }} + }}"#, + member.member_id + ); + + debug!("Sending mutation {}", mutation); + let response = client + .post(&request_url) + .json(&serde_json::json!({ "query": mutation })) + .send() + .await + .context("Failed to succesfully post query to Root")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } + + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + debug!("Response: {}", response_json); + + if let Some(data) = response_json + .get("data") + .and_then(|data| data.get("resetStreak")) + { + let current_streak = + data.get("currentStreak") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; + let max_streak = + data.get("maxStreak") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; + + if member.streak.is_empty() { + member.streak.push(Streak { + current_streak, + max_streak, + }); + } else { + for streak in &mut member.streak { + streak.current_streak = current_streak; + streak.max_streak = max_streak; + } + } + } else { + return Err(anyhow!("Failed to access data from {}", response_json)); + } + + Ok(()) +} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index a154f8e..f67a6a4 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -20,7 +20,7 @@ use chrono::Local; use serde_json::Value; use tracing::debug; -use crate::graphql::models::{AttendanceRecord, Member, Streak}; +use crate::graphql::models::{AttendanceRecord, Member}; use super::models::StreakWithMemberId; @@ -29,7 +29,7 @@ pub async fn fetch_members() -> anyhow::Result> { let client = reqwest::Client::new(); let query = r#" - { + { members { memberId name @@ -81,142 +81,6 @@ pub async fn fetch_members() -> anyhow::Result> { Ok(members) } -pub async fn increment_streak(member: &mut Member) -> anyhow::Result<()> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in ENV")?; - - let client = reqwest::Client::new(); - let mutation = format!( - r#" - mutation {{ - incrementStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = client - .post(request_url) - .json(&serde_json::json!({"query": mutation})) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("incrementStreak")) - { - let current_streak = - data.get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; - let max_streak = - data.get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!( - "Failed to access data from response: {}", - response_json - )); - } - - Ok(()) -} - -pub async fn reset_streak(member: &mut Member) -> anyhow::Result<()> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in the ENV")?; - - let client = reqwest::Client::new(); - let mutation = format!( - r#" - mutation {{ - resetStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = client - .post(&request_url) - .json(&serde_json::json!({ "query": mutation })) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("resetStreak")) - { - let current_streak = - data.get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; - let max_streak = - data.get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!("Failed to access data from {}", response_json)); - } - - Ok(()) -} - pub async fn fetch_attendance() -> anyhow::Result> { let request_url = std::env::var("ROOT_URL").context("ROOT_URL environment variable not found")?; diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 2fd1563..d4e4c96 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -25,7 +25,8 @@ use serenity::async_trait; use super::Task; use crate::graphql::models::{Member, StreakWithMemberId}; -use crate::graphql::queries::{fetch_members, fetch_streaks, increment_streak, reset_streak}; +use crate::graphql::mutations::{increment_streak, reset_streak}; +use crate::graphql::queries::{fetch_members, fetch_streaks}; use crate::ids::{ AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, }; From b41c8d0f5710b5be4db4046c295947bf310134cd Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 16 Sep 2025 14:23:56 +0530 Subject: [PATCH 21/51] docs: move module documentation inside the module The `//!` notation can be used to denote the module documentation inside the module. Documenting near the module definition in `main` would be less visible. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 2 -- src/scheduler.rs | 1 + src/tasks/mod.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 287d7e2..0a6a9cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,9 +19,7 @@ mod commands; mod graphql; mod ids; mod reaction_roles; -/// This module is a simple cron equivalent. It spawns threads for the [`Task`]s that need to be completed. mod scheduler; -/// A trait to define a job that needs to be executed regularly, for example checking for status updates daily. mod tasks; mod trace; mod utils; diff --git a/src/scheduler.rs b/src/scheduler.rs index 83aedc5..8f2d8e9 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -15,6 +15,7 @@ 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 . */ +//! This module is a simple cron equivalent. It spawns threads for the [`Task`]s that need to be completed. use crate::tasks::{get_tasks, Task}; use serenity::client::Context as SerenityContext; diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index b856125..a1bdd0c 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -15,6 +15,7 @@ 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 . */ +//! A trait to define a job that needs to be executed regularly, for example checking for status updates daily. mod lab_attendance; mod status_update; From d45c767b82af8a26453c6ece70a1f20398c8d0ed Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 16 Sep 2025 14:57:28 +0530 Subject: [PATCH 22/51] feat: add method to check privilege Certain planned commands like toggling the status update checks and changing tracks should only be accessible to certain senior roles. Add a method that can authorize users. Signed-off-by: Ivin Joel Abraham --- src/commands.rs | 18 +++++++++++++++++- src/ids.rs | 4 ++++ src/main.rs | 3 +-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 4680e23..e4e7a78 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,10 +16,26 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ use anyhow::Context as _; +use serenity::all::RoleId; use tracing::{info, trace}; use tracing_subscriber::EnvFilter; -use crate::{Context, Data, Error}; +use crate::{ + ids::{FOURTH_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, + Context, Data, Error, +}; + +/// Checks if the author has the Fourth Year or Third Year role. Can be used as an authorization procedure for other commands. +async fn is_privileged(ctx: &Context<'_>) -> bool { + if let Some(guild_id) = ctx.guild_id() { + if let Ok(member) = guild_id.member(ctx, ctx.author().id).await { + return member.roles.contains(&RoleId::new(FOURTH_YEAR_ROLE_ID)) + || member.roles.contains(&RoleId::new(THIRD_YEAR_ROLE_ID)); + } + } + + false +} #[poise::command(prefix_command)] async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { diff --git a/src/ids.rs b/src/ids.rs index 3020a96..42c6bf6 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -18,6 +18,10 @@ along with this program. If not, see . /// Points to the Embed in the #roles channel. pub const ROLES_MESSAGE_ID: u64 = 1298636092886749294; +/// Fourth and Third Year Roles for privileged commands +pub const FOURTH_YEAR_ROLE_ID: u64 = 1135793659040772240; +pub const THIRD_YEAR_ROLE_ID: u64 = 1166292683317321738; + // Role IDs pub const ARCHIVE_ROLE_ID: u64 = 1208457364274028574; pub const MOBILE_ROLE_ID: u64 = 1298553701094395936; diff --git a/src/main.rs b/src/main.rs index 0a6a9cd..30e7b05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,8 +24,6 @@ mod tasks; mod trace; mod utils; -use crate::trace::setup_tracing; -use crate::trace::ReloadHandle; use anyhow::Context as _; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; use reaction_roles::handle_reaction; @@ -35,6 +33,7 @@ use serenity::{ client::{Context as SerenityContext, FullEvent}, model::gateway::GatewayIntents, }; +use trace::{setup_tracing, ReloadHandle}; use tracing::info; use std::collections::{HashMap, HashSet}; From f61f09b65e8ad7dba81658ed8292f731517b9540 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 17 Sep 2025 17:32:05 +0530 Subject: [PATCH 23/51] refactor: break down set_log_level command The function that handles the set_log_level command can be more concise, avoiding unnecessary pattern matching and returning if the level is properly validated. Additionally, that function could also be broken down into other functions. Signed-off-by: Ivin Joel Abraham --- src/commands.rs | 87 +++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index e4e7a78..d63cace 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -44,65 +44,52 @@ async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } -#[poise::command(prefix_command, owners_only)] -async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { - trace!("Running set_log_level command"); - let data = ctx.data(); - let reload_handle = data.log_reload_handle.write().await; +/// Returns whether the provided `level` String is a valid filter level for tracing. +fn validate_level(level: &String) -> bool { + const VALID_LEVELS: [&str; 5] = ["trace", "debug", "info", "warn", "error"]; + if !VALID_LEVELS.contains(&level.as_str()) { + true + } else { + false + } +} +fn build_filter_string(level: String) -> anyhow::Result { let enable_debug_libraries_string = std::env::var("ENABLE_DEBUG_LIBRARIES") .context("ENABLE_DEBUG_LIBRARIES was not found in the ENV")?; let enable_debug_libraries: bool = enable_debug_libraries_string .parse() .context("Failed to parse ENABLE_DEBUG_LIBRARIES")?; let crate_name = env!("CARGO_CRATE_NAME"); - let new_filter = match level.to_lowercase().as_str() { - "trace" => { - if enable_debug_libraries { - "trace".to_string() - } else { - format!("{crate_name}=trace") - } - } - "debug" => { - if enable_debug_libraries { - "debug".to_string() - } else { - format!("{crate_name}=debug") - } - } - "info" => { - if enable_debug_libraries { - "info".to_string() - } else { - format!("{crate_name}=info") - } - } - "warn" => { - if enable_debug_libraries { - "warn".to_string() - } else { - format!("{crate_name}=warn") - } - } - "error" => { - if enable_debug_libraries { - "error".to_string() - } else { - format!("{crate_name}=error") - } - } - _ => { - ctx.say("Invalid log level! Use: trace, debug, info, warn, error") - .await?; - return Ok(()); - } - }; - if reload_handle.reload(EnvFilter::new(&new_filter)).is_ok() { - ctx.say(format!("Log level changed to **{new_filter}**")) + if enable_debug_libraries { + Ok(level) + } else { + Ok(format!("{crate_name}={level}")) + } +} + +#[poise::command(prefix_command, owners_only)] +async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { + trace!("Running set_log_level command"); + if !validate_level(&level) { + ctx.say("Invalid log level! Use: trace, debug, info, warn, error") + .await?; + return Ok(()); + } + + let new_filter_level = build_filter_string(level)?; + + let data = ctx.data(); + let reload_handle = data.log_reload_handle.write().await; + + if reload_handle + .reload(EnvFilter::new(&new_filter_level)) + .is_ok() + { + ctx.say(format!("Log level changed to **{new_filter_level}**")) .await?; - info!("Log level changed to {}", new_filter); + info!("Log level changed to {}", new_filter_level); } else { ctx.say("Failed to update log level.").await?; } From 2473f6138cab9f861a46c9ec09f2b4e97e5e3f55 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Sat, 27 Sep 2025 16:39:23 +0530 Subject: [PATCH 24/51] refactor: move commands into it's own module The structure for commands needs to be scalable as it's the most common addition to the bot. Move commands into it's own directory instead of maintaining one file. Signed-off-by: Ivin Joel Abraham --- src/commands/mod.rs | 34 ++++++++++++++++++ .../set_log_level.rs} | 36 +++---------------- 2 files changed, 38 insertions(+), 32 deletions(-) create mode 100644 src/commands/mod.rs rename src/{commands.rs => commands/set_log_level.rs} (69%) diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..db785c1 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,34 @@ +mod set_log_level; + +use crate::commands::set_log_level::set_log_level; +use serenity::all::RoleId; +use tracing::trace; + +use crate::{ + ids::{FOURTH_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, + Context, Data, Error, +}; + +/// Checks if the author has the Fourth Year or Third Year role. Can be used as an authorization procedure for other commands. +async fn is_privileged(ctx: &Context<'_>) -> bool { + if let Some(guild_id) = ctx.guild_id() { + if let Ok(member) = guild_id.member(ctx, ctx.author().id).await { + return member.roles.contains(&RoleId::new(FOURTH_YEAR_ROLE_ID)) + || member.roles.contains(&RoleId::new(THIRD_YEAR_ROLE_ID)); + } + } + + false +} + +#[poise::command(prefix_command)] +async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { + trace!("Running amdctl command"); + ctx.say("amD is up and running.").await?; + Ok(()) +} + +/// Returns a vector containg [Poise Commands][`poise::Command`] +pub fn get_commands() -> Vec> { + vec![amdctl(), set_log_level()] +} diff --git a/src/commands.rs b/src/commands/set_log_level.rs similarity index 69% rename from src/commands.rs rename to src/commands/set_log_level.rs index d63cace..7fbdb0d 100644 --- a/src/commands.rs +++ b/src/commands/set_log_level.rs @@ -15,35 +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 . */ +//! Module for the set_log_level command. + +use crate::{Context, Error}; use anyhow::Context as _; -use serenity::all::RoleId; use tracing::{info, trace}; use tracing_subscriber::EnvFilter; - -use crate::{ - ids::{FOURTH_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, - Context, Data, Error, -}; - -/// Checks if the author has the Fourth Year or Third Year role. Can be used as an authorization procedure for other commands. -async fn is_privileged(ctx: &Context<'_>) -> bool { - if let Some(guild_id) = ctx.guild_id() { - if let Ok(member) = guild_id.member(ctx, ctx.author().id).await { - return member.roles.contains(&RoleId::new(FOURTH_YEAR_ROLE_ID)) - || member.roles.contains(&RoleId::new(THIRD_YEAR_ROLE_ID)); - } - } - - false -} - -#[poise::command(prefix_command)] -async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { - trace!("Running amdctl command"); - ctx.say("amD is up and running.").await?; - Ok(()) -} - /// Returns whether the provided `level` String is a valid filter level for tracing. fn validate_level(level: &String) -> bool { const VALID_LEVELS: [&str; 5] = ["trace", "debug", "info", "warn", "error"]; @@ -70,7 +47,7 @@ fn build_filter_string(level: String) -> anyhow::Result { } #[poise::command(prefix_command, owners_only)] -async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { +pub async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { trace!("Running set_log_level command"); if !validate_level(&level) { ctx.say("Invalid log level! Use: trace, debug, info, warn, error") @@ -96,8 +73,3 @@ async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { Ok(()) } - -/// Returns a vector containg [Poise Commands][`poise::Command`] -pub fn get_commands() -> Vec> { - vec![amdctl(), set_log_level()] -} From f963721faab4a0d7975b736ec4df7b8e01ebe842 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Sat, 27 Sep 2025 16:55:40 +0530 Subject: [PATCH 25/51] chore: remove fourth years from status update tracking Turn off status update tracking for the fourth years as requested by Harigovind. Signed-off-by: Ivin Joel Abraham --- src/graphql/models.rs | 1 + src/tasks/status_update.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 0d55ea4..570b3af 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -45,6 +45,7 @@ pub struct Member { #[serde(default)] pub streak: Vec, // Note that Root will NOT have multiple Streak elements but it may be an empty list which is why we use a vector here pub track: Option, + pub year: i32, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index d4e4c96..9c6e106 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -63,7 +63,8 @@ const CHANDRA_MOULI: &str = "1265880467047976970"; async fn status_update_check(ctx: Context) -> anyhow::Result<()> { let updates = get_updates(&ctx).await?; - let members = fetch_members().await?; + let mut members = fetch_members().await?; + members.retain(|member| member.year != 4); // naughty_list -> members who did not send updates let (mut naughty_list, mut nice_list) = categorize_members(&members, updates); From 5a94172e690c8de7f3631ba80df330d842a42411 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Sat, 27 Sep 2025 23:47:04 +0530 Subject: [PATCH 26/51] refactor: rename function name for clarity `load_tracing_config` should not be the name for a function that creates a new `TracingConfig`. Rename as required to improve clarity. Signed-off-by: Ivin Joel Abraham --- src/trace.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/trace.rs b/src/trace.rs index 95ee7b1..4b0344a 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -42,7 +42,7 @@ struct TracingConfig { impl TracingConfig { /// Encapsulate all the required env variables into a [`TracingConfig`] - fn load_tracing_config() -> Self { + fn build_tracing_config() -> Self { Self { // Some Rust shenanigans to set the default value to a boolean false: debug: std::env::var("DEBUG") @@ -105,7 +105,7 @@ where } pub fn setup_tracing() -> anyhow::Result { - let config = TracingConfig::load_tracing_config(); + let config = TracingConfig::build_tracing_config(); let filter_string = build_filter_string(&config); let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); From c404946f800caa92dac73d65a394323218ad9aff Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Sun, 28 Sep 2025 21:42:50 +0530 Subject: [PATCH 27/51] chore: update package version to github package version Signed-off-by: Ivin Joel Abraham --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c09b831..341c2f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ dependencies = [ [[package]] name = "amd" -version = "1.2.1" +version = "1.3.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c4d1034..77b64cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amd" -version = "1.2.1" +version = "1.3.0" edition = "2021" [dependencies] From f98d9e5d3acfe53ba249c90b4b9b36d797aed59a Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 29 Sep 2025 20:31:16 +0530 Subject: [PATCH 28/51] refactor: centralize env var Env. Variables should be dealt with once, during startup, to avoid potential panics later on. Centralize the initialization and loading of env. variables to one point and pass it to everything that needs it later. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 121 ++++++++++++++++++++++++++++++--------------------- src/trace.rs | 39 +++-------------- 2 files changed, 76 insertions(+), 84 deletions(-) diff --git a/src/main.rs b/src/main.rs index 30e7b05..6c4251a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,9 +34,9 @@ use serenity::{ model::gateway::GatewayIntents, }; use trace::{setup_tracing, ReloadHandle}; -use tracing::info; +use tracing::{error, info}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; type Error = Box; type Context<'a> = PoiseContext<'a, Data, Error>; @@ -57,49 +57,12 @@ impl Data { } } -/// Struct to hold the resources necessary for the Discord bot to operate. -/// -/// # Fields -/// -/// * discord_token: The bot's discord token obtained from the Discord Developer Portal. -/// * owner_id: Used to allow access to privileged commands to specific users. Potential TODO: It would be more useful to allow access to certain roles (such as Moderator) in the Discord server instead. Poise already supports passing multiple IDs in the owner field when setting up the bot. -/// * prefix_string: The prefix used to issue commands to the bot on Discord. -#[derive(Default)] -struct BotConfig { - discord_token: String, - owner_id: UserId, - prefix_string: String, -} - -impl BotConfig { - fn new_with_prefix(prefix_string: String) -> anyhow::Result { - let mut bot_config = BotConfig::default(); - bot_config - .load_env_var() - .context("Failed to load environment variables for BotConfig")?; - bot_config.prefix_string = prefix_string; - - Ok(bot_config) - } - /// Loads [`BotConfig`]'s `discord_token` and `owner_id` fields from environment variables. - /// - /// Panics if any of the fields are not found in the env. - fn load_env_var(&mut self) -> anyhow::Result<()> { - self.discord_token = - std::env::var("DISCORD_TOKEN").context("DISCORD_TOKEN was not found in env")?; - self.owner_id = UserId::from( - std::env::var("OWNER_ID") - .context("OWNER_ID was not found in the env")? - .parse::() - .context("Failed to parse OWNER_ID")?, - ); - - Ok(()) - } -} - /// Builds a [`poise::Framework`] with the given arguments and commands from [`commands::get_commands`]. -fn build_framework(owner_id: UserId, prefix_string: String, data: Data) -> Framework { +fn build_framework( + owners: Option, + prefix_string: String, + data: Data, +) -> Framework { Framework::builder() .options(FrameworkOptions { commands: commands::get_commands(), @@ -110,7 +73,7 @@ fn build_framework(owner_id: UserId, prefix_string: String, data: Data) -> Frame prefix: Some(prefix_string), ..Default::default() }, - owners: HashSet::from([owner_id]), + owners: owners.into_iter().collect(), ..Default::default() }) .setup(|ctx, _ready, framework| { @@ -123,21 +86,79 @@ fn build_framework(owner_id: UserId, prefix_string: String, data: Data) -> Frame .build() } +/// Environment variables for amD +/// +/// # Fields +/// +/// * debug: a boolean flag that decides in what context the application will be running on. When true, it is assumed to be in development. This allows us to filter out logs from `stdout` when in production. Defaults to false if not set. +/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. Defaults to false if not set. +/// * discord_token: The bot's discord token obtained from the Discord Developer Portal. The only mandatory variable required. +/// * owner_id: Used to allow access to privileged commands to specific users. If not passed, will set the bot to have no owners. +/// * prefix_string: The prefix used to issue commands to the bot on Discord. Always set to "$". +struct Config { + debug: bool, + enable_debug_libraries: bool, + discord_token: String, + owner_id: Option, + prefix_string: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + debug: parse_bool_env("DEBUG"), + enable_debug_libraries: parse_bool_env("ENABLE_DEBUG_LIBRARIES"), + discord_token: std::env::var("DISCORD_TOKEN") + .expect("DISCORD_TOKEN was not found in env"), + owner_id: parse_owner_id_env("OWNER_ID"), + prefix_string: String::from("$"), + } + } +} + +/// Tries to access the environment variable through the key passed in. If it is set, it will try to parse it as u64 and if that fails, it will log the error and return the default value None. If it suceeds the u64 parsing, it will convert it to a UserId and return Some(UserId). If the env. var. is not set, it will return None. +fn parse_owner_id_env(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|s| { + s.parse::() + .map_err(|_| error!("Warning: Invalid OWNER_ID value '{}', ignoring.", s)) + .ok() + }) + .map(UserId::new) +} + +/// Tries to access the environment variable through the key passed in. If it is set but an invalid boolean, it will log an error through tracing and default to false. If it is not set, it will default to false. +fn parse_bool_env(key: &str) -> bool { + std::env::var(key) + .map(|val| { + val.parse().unwrap_or_else(|_| { + error!( + "Warning: Invalid DEBUG value '{}', defaulting to false", + val + ); + false + }) + }) + .unwrap_or(false) +} + #[tokio::main] async fn main() -> Result<(), Error> { dotenv::dotenv().ok(); - let reload_handle = setup_tracing().context("Failed to setup tracing")?; + + let config = Config::default(); + let reload_handle = setup_tracing(config.debug, config.enable_debug_libraries) + .context("Failed to setup tracing")?; info!("Tracing initialized. Continuing main..."); let mut data = Data::new_with_reload_handle(reload_handle); data.populate_with_reaction_roles(); - let bot_config = - BotConfig::new_with_prefix(String::from("$")).context("Failed to construct BotConfig")?; - let framework = build_framework(bot_config.owner_id, bot_config.prefix_string, data); + let framework = build_framework(config.owner_id, config.prefix_string, data); let mut client = ClientBuilder::new( - bot_config.discord_token, + config.discord_token, GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, ) .framework(framework) diff --git a/src/trace.rs b/src/trace.rs index 4b0344a..dd95aac 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -29,39 +29,11 @@ use tracing_subscriber::{ pub type ReloadHandle = Arc>>; -/// Environment variables that our tracing configuration relies on -/// -/// # Fields -/// -/// * debug: a boolean flag that decides in what context the application will be running on. When true, it is assumed to be in development. This allows us to filter out logs from `stdout` when in production. -/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. -struct TracingConfig { - debug: bool, - enable_debug_libraries: bool, -} - -impl TracingConfig { - /// Encapsulate all the required env variables into a [`TracingConfig`] - fn build_tracing_config() -> Self { - Self { - // Some Rust shenanigans to set the default value to a boolean false: - debug: std::env::var("DEBUG") - .unwrap_or("false".to_string()) - .parse() - .unwrap_or(false), - enable_debug_libraries: std::env::var("ENABLE_DEBUG_LIBRARIES") - .unwrap_or("false".to_string()) - .parse() - .unwrap_or(false), - } - } -} - /// Return the appropriate String denoting the level and breadth of logs depending on the [`TracingConfig`] passed in. -fn build_filter_string(config: &TracingConfig) -> String { +fn build_filter_string(debug: bool, enable_debug_libraries: bool) -> String { let crate_name = env!("CARGO_CRATE_NAME"); - match (config.debug, config.enable_debug_libraries) { + match (debug, enable_debug_libraries) { (true, true) => "info".to_string(), (true, false) => format!("{crate_name}=info"), @@ -104,13 +76,12 @@ where } } -pub fn setup_tracing() -> anyhow::Result { - let config = TracingConfig::build_tracing_config(); - let filter_string = build_filter_string(&config); +pub fn setup_tracing(debug: bool, enable_debug_libraries: bool) -> anyhow::Result { + let filter_string = build_filter_string(debug, enable_debug_libraries); let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); let boxed_subscriber: Box = - build_subscriber(config.debug, filter)?; + build_subscriber(debug, filter)?; tracing::subscriber::set_global_default(boxed_subscriber) .context("Failed to set subscriber")?; From 003a704a87c6143ec4b923f67750c27b0561edfc Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 29 Sep 2025 20:36:48 +0530 Subject: [PATCH 29/51] fix: correct filter levels when debug being true Debug mode should output traces, not info. This swap between debug and production levels must have been a regression when this function was first written. Signed-off-by: Ivin Joel Abraham --- src/trace.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/trace.rs b/src/trace.rs index dd95aac..38de472 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -34,11 +34,11 @@ fn build_filter_string(debug: bool, enable_debug_libraries: bool) -> String { let crate_name = env!("CARGO_CRATE_NAME"); match (debug, enable_debug_libraries) { - (true, true) => "info".to_string(), - (true, false) => format!("{crate_name}=info"), + (true, true) => "trace".to_string(), + (true, false) => format!("{crate_name}=trace"), - (false, true) => "trace".to_string(), - (false, false) => format!("{crate_name}=trace"), + (false, true) => "info".to_string(), + (false, false) => format!("{crate_name}=info"), } } From 923d381840594ce40d273564c0d454f412571854 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Mon, 29 Sep 2025 20:41:44 +0530 Subject: [PATCH 30/51] fix: use eprintln before tracing is initialized Tracing is only initialized after loading in environment variables and any code before must use basic stdout/stderr printing instead of tracing macros. Signed-off-by: Ivin Joel Abraham --- src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6c4251a..9caf62a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ use serenity::{ model::gateway::GatewayIntents, }; use trace::{setup_tracing, ReloadHandle}; -use tracing::{error, info}; +use tracing::{error, info, instrument, trace}; use std::collections::HashMap; @@ -122,7 +122,7 @@ fn parse_owner_id_env(key: &str) -> Option { .ok() .and_then(|s| { s.parse::() - .map_err(|_| error!("Warning: Invalid OWNER_ID value '{}', ignoring.", s)) + .map_err(|_| eprintln!("WARNING: Invalid OWNER_ID value '{}', ignoring.", s)) .ok() }) .map(UserId::new) @@ -133,7 +133,7 @@ fn parse_bool_env(key: &str) -> bool { std::env::var(key) .map(|val| { val.parse().unwrap_or_else(|_| { - error!( + eprintln!( "Warning: Invalid DEBUG value '{}', defaulting to false", val ); From dfde86a266c69ed4e280a2fa4cae73de0436c576 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 01:36:04 +0530 Subject: [PATCH 31/51] refactor: log span entry and exit Signed-off-by: Ivin Joel Abraham --- src/trace.rs | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/trace.rs b/src/trace.rs index 38de472..748e444 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -57,23 +57,30 @@ fn build_subscriber( where L: tracing_subscriber::Layer + Send + Sync + 'static, { + let span_events = fmt::format::FmtSpan::NEW | fmt::format::FmtSpan::CLOSE; let file_layer = fmt::layer() .pretty() .with_ansi(false) - .with_writer(File::create("amd.log").context("Failed to create log file")?); + .with_writer(File::create("amd.log").context("Failed to create log file")?) + .with_span_events(span_events.clone()); - if debug { - Ok(Box::new( - tracing_subscriber::registry() - .with(filter) - .with(file_layer) - .with(fmt::layer().pretty().with_writer(std::io::stdout)), - )) + let stdout_layer = if debug { + Some( + fmt::layer() + .pretty() + .with_writer(std::io::stdout) + .with_span_events(span_events), + ) } else { - Ok(Box::new( - tracing_subscriber::registry().with(filter).with(file_layer), - )) - } + None + }; + + Ok(Box::new( + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(stdout_layer), + )) } pub fn setup_tracing(debug: bool, enable_debug_libraries: bool) -> anyhow::Result { From 3bf63cf4db42bec87a10b8d89ccc64688254d05c Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 01:57:18 +0530 Subject: [PATCH 32/51] refactor: extract file and stdout layer to helper functions Extract the file and stdout layers to functions that can be easily modified later. This change might be a mistake, as it is less simple and a premature optimization. Signed-off-by: Ivin Joel Abraham --- src/trace.rs | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/trace.rs b/src/trace.rs index 748e444..4efa967 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -58,19 +58,10 @@ where L: tracing_subscriber::Layer + Send + Sync + 'static, { let span_events = fmt::format::FmtSpan::NEW | fmt::format::FmtSpan::CLOSE; - let file_layer = fmt::layer() - .pretty() - .with_ansi(false) - .with_writer(File::create("amd.log").context("Failed to create log file")?) - .with_span_events(span_events.clone()); + let file_layer = file_layer(&span_events)?; let stdout_layer = if debug { - Some( - fmt::layer() - .pretty() - .with_writer(std::io::stdout) - .with_span_events(span_events), - ) + Some(stdout_layer(span_events)) } else { None }; @@ -83,6 +74,35 @@ where )) } +type StdoutLayer = fmt::Layer< + L, + fmt::format::Pretty, + fmt::format::Format, + fn() -> std::io::Stdout, +>; + +fn stdout_layer(span_events: fmt::format::FmtSpan) -> StdoutLayer { + fmt::layer() + .pretty() + .with_writer(std::io::stdout as fn() -> std::io::Stdout) + .with_span_events(span_events) +} + +type FileLayer = fmt::Layer< + tracing_subscriber::layer::Layered, + fmt::format::Pretty, + fmt::format::Format, + File, +>; + +fn file_layer(span_events: &fmt::format::FmtSpan) -> Result, anyhow::Error> { + Ok(fmt::layer() + .pretty() + .with_ansi(false) + .with_writer(File::create("amd.log").context("Failed to create log file")?) + .with_span_events(span_events.clone())) +} + pub fn setup_tracing(debug: bool, enable_debug_libraries: bool) -> anyhow::Result { let filter_string = build_filter_string(debug, enable_debug_libraries); let (filter, reload_handle) = Layer::new(EnvFilter::new(filter_string)); From 1f00722c715d3c249a4dfe74e0f50fc3b05c3d4e Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:07:19 +0530 Subject: [PATCH 33/51] refactor: log errors in handle_reaction Signed-off-by: Ivin Joel Abraham --- src/reaction_roles.rs | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/reaction_roles.rs b/src/reaction_roles.rs index fbb3a36..5491795 100644 --- a/src/reaction_roles.rs +++ b/src/reaction_roles.rs @@ -1,14 +1,14 @@ use std::collections::HashMap; use serenity::all::{Context as SerenityContext, MessageId, Reaction, ReactionType, RoleId}; -use tracing::{debug, error}; +use tracing::debug; use crate::{ ids::{ AI_ROLE_ID, ARCHIVE_ROLE_ID, DEVOPS_ROLE_ID, MOBILE_ROLE_ID, RESEARCH_ROLE_ID, ROLES_MESSAGE_ID, SYSTEMS_ROLE_ID, WEB_ROLE_ID, }, - Data, + Data, Error, }; impl Data { @@ -54,39 +54,28 @@ pub async fn handle_reaction( reaction: &Reaction, data: &Data, is_add: bool, -) { +) -> Result<(), Error> { if !is_relevant_reaction(reaction.message_id, &reaction.emoji, data) { - return; + return Ok(()); } debug!("Handling {:?} from {:?}.", reaction.emoji, reaction.user_id); - // TODO Log these errors - let Some(guild_id) = reaction.guild_id else { - return; - }; - let Some(user_id) = reaction.user_id else { - return; - }; - let Ok(member) = guild_id.member(ctx, user_id).await else { - return; - }; - let Some(role_id) = data.reaction_roles.get(&reaction.emoji) else { - return; - }; + let guild_id = reaction.guild_id.ok_or("No guild_id")?; + let user_id = reaction.user_id.ok_or("No user_id")?; + let member = guild_id.member(ctx, user_id).await?; + let role_id = data + .reaction_roles + .get(&reaction.emoji) + .ok_or("No role mapping")?; - let result = if is_add { - member.add_role(&ctx.http, *role_id).await + if is_add { + member.add_role(&ctx.http, *role_id).await?; } else { - member.remove_role(&ctx.http, *role_id).await - }; - - if let Err(e) = result { - error!( - "Could not handle {:?} from {:?}. Error: {}", - reaction.emoji, reaction.user_id, e - ); + member.remove_role(&ctx.http, *role_id).await?; } + + Ok(()) } fn is_relevant_reaction(message_id: MessageId, emoji: &ReactionType, data: &Data) -> bool { From 2a8ce1e1224f6283991d6726151f20cfbf5065cf Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:34:04 +0530 Subject: [PATCH 34/51] refactor: remove close from span event The close event seems unnecessary and clutters the log. New gives us the required information. Signed-off-by: Ivin Joel Abraham --- src/trace.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trace.rs b/src/trace.rs index 4efa967..ad9ad92 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -57,7 +57,7 @@ fn build_subscriber( where L: tracing_subscriber::Layer + Send + Sync + 'static, { - let span_events = fmt::format::FmtSpan::NEW | fmt::format::FmtSpan::CLOSE; + let span_events = fmt::format::FmtSpan::NEW; let file_layer = file_layer(&span_events)?; let stdout_layer = if debug { From 433545c985b6a64c8784af2bcde3550dcc03f816 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:45:48 +0530 Subject: [PATCH 35/51] refactor: instrument commands Signed-off-by: Ivin Joel Abraham --- src/commands/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index db785c1..02b2331 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,7 @@ mod set_log_level; use crate::commands::set_log_level::set_log_level; use serenity::all::RoleId; -use tracing::trace; +use tracing::{debug, instrument, trace}; use crate::{ ids::{FOURTH_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, @@ -22,13 +22,15 @@ async fn is_privileged(ctx: &Context<'_>) -> bool { } #[poise::command(prefix_command)] +#[instrument(level = "debug", skip(ctx))] async fn amdctl(ctx: Context<'_>) -> Result<(), Error> { - trace!("Running amdctl command"); ctx.say("amD is up and running.").await?; Ok(()) } /// Returns a vector containg [Poise Commands][`poise::Command`] pub fn get_commands() -> Vec> { - vec![amdctl(), set_log_level()] + let commands = vec![amdctl(), set_log_level()]; + debug!(commands = ?commands.iter().map(|c| &c.name).collect::>()); + commands } From c6588c87821571c8579dfd20fad3962acb227482 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:47:25 +0530 Subject: [PATCH 36/51] refactor: remove unnecessary debug info from time module Signed-off-by: Ivin Joel Abraham --- src/utils/time.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/utils/time.rs b/src/utils/time.rs index 6ec8053..145afe9 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -18,16 +18,10 @@ along with this program. If not, see . use chrono::{DateTime, Datelike, Local, TimeZone}; use chrono_tz::Asia::Kolkata; use chrono_tz::Tz; -use tracing::debug; use std::time::Duration; pub fn time_until(hour: u32, minute: u32) -> Duration { - debug!( - "time_until called with args hour: {}, minute: {}", - hour, minute - ); - let now = Local::now().with_timezone(&Kolkata); let today_run = Kolkata .with_ymd_and_hms(now.year(), now.month(), now.day(), hour, minute, 0) @@ -40,10 +34,7 @@ pub fn time_until(hour: u32, minute: u32) -> Duration { today_run + chrono::Duration::days(1) }; - debug!("now: {}, today_run: {}", now, today_run); - let duration = next_run.signed_duration_since(now); - debug!("duration: {}", duration); Duration::from_secs(duration.num_seconds().max(0) as u64) } From 080a850482ff391956a5ade65738e52c78a149f4 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:47:47 +0530 Subject: [PATCH 37/51] refactor: instrument scheduler and tasks Signed-off-by: Ivin Joel Abraham --- src/scheduler.rs | 7 +++---- src/tasks/mod.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/scheduler.rs b/src/scheduler.rs index 8f2d8e9..8c5b8fc 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -20,22 +20,21 @@ use crate::tasks::{get_tasks, Task}; use serenity::client::Context as SerenityContext; use tokio::spawn; -use tracing::{debug, error, trace}; +use tracing::{debug, error, instrument}; +#[instrument(level = "debug", skip(ctx))] pub async fn run_scheduler(ctx: SerenityContext) { - trace!("Running scheduler"); let tasks = get_tasks(); for task in tasks { - debug!("Spawing task {}", task.name()); spawn(schedule_task(ctx.clone(), task)); } } +#[instrument(level = "debug", skip(ctx))] async fn schedule_task(ctx: SerenityContext, task: Box) { loop { let next_run_in = task.run_in(); - debug!("Task {}: Next run in {:?}", task.name(), next_run_in); tokio::time::sleep(next_run_in).await; debug!("Running task {}", task.name()); diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index a1bdd0c..89a2195 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -19,12 +19,15 @@ along with this program. If not, see . mod lab_attendance; mod status_update; +use std::fmt::{self, Debug}; + use anyhow::Result; use async_trait::async_trait; use lab_attendance::PresenseReport; use serenity::client::Context; use status_update::StatusUpdateCheck; use tokio::time::Duration; +use tracing::instrument; /// A [`Task`] is any job that needs to be executed on a regular basis. /// A task has a function [`Task::run_in`] that returns the time till the @@ -36,6 +39,15 @@ pub trait Task: Send + Sync { async fn run(&self, ctx: Context) -> Result<()>; } +impl Debug for Box { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Task") + .field("name", &self.name()) + .field("run in", &self.run_in()) + .finish() + } +} + /// 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> { From 8fdea8dae9f5196f4a2a05b70e67a768c0fc91b9 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:48:06 +0530 Subject: [PATCH 38/51] refactor: instrument main Signed-off-by: Ivin Joel Abraham --- src/main.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9caf62a..666c012 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ use serenity::{ model::gateway::GatewayIntents, }; use trace::{setup_tracing, ReloadHandle}; -use tracing::{error, info, instrument, trace}; +use tracing::{info, instrument}; use std::collections::HashMap; @@ -58,6 +58,7 @@ impl Data { } /// Builds a [`poise::Framework`] with the given arguments and commands from [`commands::get_commands`]. +#[instrument(level = "debug", skip(data))] fn build_framework( owners: Option, prefix_string: String, @@ -150,7 +151,6 @@ async fn main() -> Result<(), Error> { let config = Config::default(); let reload_handle = setup_tracing(config.debug, config.enable_debug_libraries) .context("Failed to setup tracing")?; - info!("Tracing initialized. Continuing main..."); let mut data = Data::new_with_reload_handle(reload_handle); data.populate_with_reaction_roles(); @@ -165,13 +165,13 @@ async fn main() -> Result<(), Error> { .await .context("Failed to create the Serenity client")?; + info!("Starting amD..."); + client .start() .await .context("Failed to start the Serenity client")?; - info!("Starting amD..."); - Ok(()) } @@ -183,10 +183,10 @@ async fn event_handler( ) -> Result<(), Error> { match event { FullEvent::ReactionAdd { add_reaction } => { - handle_reaction(ctx, add_reaction, data, true).await; + handle_reaction(ctx, add_reaction, data, true).await?; } FullEvent::ReactionRemove { removed_reaction } => { - handle_reaction(ctx, removed_reaction, data, false).await; + handle_reaction(ctx, removed_reaction, data, false).await?; } _ => {} } From 4d5d775dc12c3e549851f63167ccdfc34f5e2e3b Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:51:24 +0530 Subject: [PATCH 39/51] refactor: instrument set_log_level command Signed-off-by: Ivin Joel Abraham --- src/commands/set_log_level.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/set_log_level.rs b/src/commands/set_log_level.rs index 7fbdb0d..be6b919 100644 --- a/src/commands/set_log_level.rs +++ b/src/commands/set_log_level.rs @@ -19,7 +19,8 @@ along with this program. If not, see . use crate::{Context, Error}; use anyhow::Context as _; -use tracing::{info, trace}; +use tracing::info; +use tracing::instrument; use tracing_subscriber::EnvFilter; /// Returns whether the provided `level` String is a valid filter level for tracing. fn validate_level(level: &String) -> bool { @@ -47,8 +48,8 @@ fn build_filter_string(level: String) -> anyhow::Result { } #[poise::command(prefix_command, owners_only)] +#[instrument(level = "debug", skip(ctx))] pub async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { - trace!("Running set_log_level command"); if !validate_level(&level) { ctx.say("Invalid log level! Use: trace, debug, info, warn, error") .await?; From c134765114e137020d22a52f33f172ed6b2a17c9 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 02:52:32 +0530 Subject: [PATCH 40/51] chore: remove unused imports Signed-off-by: Ivin Joel Abraham --- src/commands/mod.rs | 2 +- src/tasks/mod.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 02b2331..3c2176b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,7 @@ mod set_log_level; use crate::commands::set_log_level::set_log_level; use serenity::all::RoleId; -use tracing::{debug, instrument, trace}; +use tracing::{debug, instrument}; use crate::{ ids::{FOURTH_YEAR_ROLE_ID, THIRD_YEAR_ROLE_ID}, diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index 89a2195..df3ae3a 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -27,7 +27,6 @@ use lab_attendance::PresenseReport; use serenity::client::Context; use status_update::StatusUpdateCheck; use tokio::time::Duration; -use tracing::instrument; /// A [`Task`] is any job that needs to be executed on a regular basis. /// A task has a function [`Task::run_in`] that returns the time till the From 05fec612b6e1e8c4868a2dfaa47d473b3053b7ed Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 12:21:36 +0530 Subject: [PATCH 41/51] feat: set_log_level can now enable debug_libraries The motivation here is twofold. A) We don't have to access env. vars. at runtime. B) This allows for debugging libraries without restarting with changed env. vars. Signed-off-by: Ivin Joel Abraham --- src/commands/set_log_level.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/commands/set_log_level.rs b/src/commands/set_log_level.rs index be6b919..af39398 100644 --- a/src/commands/set_log_level.rs +++ b/src/commands/set_log_level.rs @@ -32,12 +32,7 @@ fn validate_level(level: &String) -> bool { } } -fn build_filter_string(level: String) -> anyhow::Result { - let enable_debug_libraries_string = std::env::var("ENABLE_DEBUG_LIBRARIES") - .context("ENABLE_DEBUG_LIBRARIES was not found in the ENV")?; - let enable_debug_libraries: bool = enable_debug_libraries_string - .parse() - .context("Failed to parse ENABLE_DEBUG_LIBRARIES")?; +fn build_filter_string(level: String, enable_debug_libraries: bool) -> anyhow::Result { let crate_name = env!("CARGO_CRATE_NAME"); if enable_debug_libraries { @@ -49,14 +44,18 @@ fn build_filter_string(level: String) -> anyhow::Result { #[poise::command(prefix_command, owners_only)] #[instrument(level = "debug", skip(ctx))] -pub async fn set_log_level(ctx: Context<'_>, level: String) -> Result<(), Error> { +pub async fn set_log_level( + ctx: Context<'_>, + level: String, + enable_debug_libraries: Option, +) -> Result<(), Error> { if !validate_level(&level) { ctx.say("Invalid log level! Use: trace, debug, info, warn, error") .await?; return Ok(()); } - let new_filter_level = build_filter_string(level)?; + let new_filter_level = build_filter_string(level, enable_debug_libraries.unwrap_or_default())?; let data = ctx.data(); let reload_handle = data.log_reload_handle.write().await; From 7a7f2a357c74be8823ae6898e715a3cf387cb106 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 20:01:39 +0530 Subject: [PATCH 42/51] refactor: introduce reusable reqwest client The primary motivation for this change is to avoid unnecessarily repeated calls to the env. var. ROOT_URL and more importantly, creating new reqwest clients for each query and mutation. This has also resulted in centralizing the ROOT_URL variable at the cost of introducing a new struct to store in Data. Signed-off-by: Ivin Joel Abraham --- src/commands/set_log_level.rs | 1 - src/graphql/mod.rs | 23 +++++++++++++++++++++++ src/graphql/mutations.rs | 20 ++++++++++---------- src/graphql/queries.rs | 29 +++++++++++------------------ src/main.rs | 17 ++++++++++++----- src/scheduler.rs | 13 ++++++++----- src/tasks/lab_attendance.rs | 14 +++++++++----- src/tasks/mod.rs | 4 +++- src/tasks/status_update.rs | 26 ++++++++++++++++---------- 9 files changed, 92 insertions(+), 55 deletions(-) diff --git a/src/commands/set_log_level.rs b/src/commands/set_log_level.rs index af39398..87ae427 100644 --- a/src/commands/set_log_level.rs +++ b/src/commands/set_log_level.rs @@ -18,7 +18,6 @@ along with this program. If not, see . //! Module for the set_log_level command. use crate::{Context, Error}; -use anyhow::Context as _; use tracing::info; use tracing::instrument; use tracing_subscriber::EnvFilter; diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 337e62a..696b649 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -18,3 +18,26 @@ along with this program. If not, see . pub mod models; pub mod mutations; pub mod queries; + +use std::sync::Arc; + +use reqwest::Client; + +#[derive(Debug, Clone)] +pub struct GraphQLClient { + http: Client, + root_url: Arc, +} + +impl GraphQLClient { + pub fn new(root_url: String) -> Self { + Self { + http: Client::new(), + root_url: Arc::new(root_url.into()), + } + } + + pub fn root_url(&self) -> &str { + &self.root_url + } +} diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs index 2877a28..5da44a4 100644 --- a/src/graphql/mutations.rs +++ b/src/graphql/mutations.rs @@ -1,15 +1,15 @@ use anyhow::anyhow; use anyhow::Context as _; use tracing::debug; +use tracing::instrument; use crate::graphql::models::Streak; use super::models::Member; +use super::GraphQLClient; -pub async fn increment_streak(member: &mut Member) -> anyhow::Result<()> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in ENV")?; - - let client = reqwest::Client::new(); +#[instrument(level = "debug")] +pub async fn increment_streak(member: &mut Member, client: GraphQLClient) -> anyhow::Result<()> { let mutation = format!( r#" mutation {{ @@ -23,7 +23,8 @@ pub async fn increment_streak(member: &mut Member) -> anyhow::Result<()> { debug!("Sending mutation {}", mutation); let response = client - .post(request_url) + .http + .post(client.root_url()) .json(&serde_json::json!({"query": mutation})) .send() .await @@ -75,10 +76,8 @@ pub async fn increment_streak(member: &mut Member) -> anyhow::Result<()> { Ok(()) } -pub async fn reset_streak(member: &mut Member) -> anyhow::Result<()> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL was not found in the ENV")?; - - let client = reqwest::Client::new(); +#[instrument(level = "debug")] +pub async fn reset_streak(member: &mut Member, client: GraphQLClient) -> anyhow::Result<()> { let mutation = format!( r#" mutation {{ @@ -92,7 +91,8 @@ pub async fn reset_streak(member: &mut Member) -> anyhow::Result<()> { debug!("Sending mutation {}", mutation); let response = client - .post(&request_url) + .http + .post(client.root_url()) .json(&serde_json::json!({ "query": mutation })) .send() .await diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index f67a6a4..a027332 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -22,12 +22,9 @@ use tracing::debug; use crate::graphql::models::{AttendanceRecord, Member}; -use super::models::StreakWithMemberId; +use super::{models::StreakWithMemberId, GraphQLClient}; -pub async fn fetch_members() -> anyhow::Result> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL not found in ENV")?; - - let client = reqwest::Client::new(); +pub async fn fetch_members(client: GraphQLClient) -> anyhow::Result> { let query = r#" { members { @@ -45,7 +42,8 @@ pub async fn fetch_members() -> anyhow::Result> { debug!("Sending query {}", query); let response = client - .post(request_url) + .http + .post(client.root_url()) .json(&serde_json::json!({"query": query})) .send() .await @@ -81,13 +79,9 @@ pub async fn fetch_members() -> anyhow::Result> { Ok(members) } -pub async fn fetch_attendance() -> anyhow::Result> { - let request_url = - std::env::var("ROOT_URL").context("ROOT_URL environment variable not found")?; - - debug!("Fetching attendance data from {}", request_url); +pub async fn fetch_attendance(client: GraphQLClient) -> anyhow::Result> { + debug!("Fetching attendance data"); - let client = reqwest::Client::new(); let today = Local::now().format("%Y-%m-%d").to_string(); let query = format!( r#" @@ -102,7 +96,8 @@ pub async fn fetch_attendance() -> anyhow::Result> { ); let response = client - .post(&request_url) + .http + .post(client.root_url()) .json(&serde_json::json!({ "query": query })) .send() .await @@ -132,10 +127,7 @@ pub async fn fetch_attendance() -> anyhow::Result> { Ok(attendance) } -pub async fn fetch_streaks() -> anyhow::Result> { - let request_url = std::env::var("ROOT_URL").context("ROOT_URL not found in ENV")?; - - let client = reqwest::Client::new(); +pub async fn fetch_streaks(client: GraphQLClient) -> anyhow::Result> { let query = r#" { streaks { @@ -148,7 +140,8 @@ pub async fn fetch_streaks() -> anyhow::Result> { debug!("Sending query {}", query); let response = client - .post(request_url) + .http + .post(client.root_url()) .json(&serde_json::json!({"query": query})) .send() .await diff --git a/src/main.rs b/src/main.rs index 666c012..10de39c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,8 +25,10 @@ mod trace; mod utils; use anyhow::Context as _; +use graphql::GraphQLClient; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; use reaction_roles::handle_reaction; +use reqwest::Client; use serenity::client::ClientBuilder; use serenity::{ all::{ReactionType, RoleId, UserId}, @@ -42,17 +44,20 @@ type Error = Box; type Context<'a> = PoiseContext<'a, Data, Error>; /// The [`Data`] struct is kept in-memory by the Bot till it shutdowns and can be used to store session-persistent data. +#[derive(Clone)] struct Data { - pub reaction_roles: HashMap, - pub log_reload_handle: ReloadHandle, + reaction_roles: HashMap, + log_reload_handle: ReloadHandle, + graphql_client: GraphQLClient, } impl Data { /// Returns a new [`Data`] with an empty `reaction_roles` field and the passed-in `reload_handle`. - fn new_with_reload_handle(reload_handle: ReloadHandle) -> Self { + fn new(reload_handle: ReloadHandle, root_url: String) -> Self { Data { reaction_roles: HashMap::new(), log_reload_handle: reload_handle, + graphql_client: GraphQLClient::new(root_url), } } } @@ -80,7 +85,7 @@ fn build_framework( .setup(|ctx, _ready, framework| { Box::pin(async move { poise::builtins::register_globally(ctx, &framework.options().commands).await?; - scheduler::run_scheduler(ctx.clone()).await; + scheduler::run_scheduler(ctx.clone(), data.graphql_client.clone()).await; Ok(data) }) }) @@ -102,6 +107,7 @@ struct Config { discord_token: String, owner_id: Option, prefix_string: String, + root_url: String, } impl Default for Config { @@ -113,6 +119,7 @@ impl Default for Config { .expect("DISCORD_TOKEN was not found in env"), 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"), } } } @@ -152,7 +159,7 @@ async fn main() -> Result<(), Error> { let reload_handle = setup_tracing(config.debug, config.enable_debug_libraries) .context("Failed to setup tracing")?; - let mut data = Data::new_with_reload_handle(reload_handle); + let mut data = Data::new(reload_handle, config.root_url); data.populate_with_reaction_roles(); let framework = build_framework(config.owner_id, config.prefix_string, data); diff --git a/src/scheduler.rs b/src/scheduler.rs index 8c5b8fc..744d06f 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -16,29 +16,32 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ //! This module is a simple cron equivalent. It spawns threads for the [`Task`]s that need to be completed. -use crate::tasks::{get_tasks, Task}; +use crate::{ + graphql::GraphQLClient, + tasks::{get_tasks, Task}, +}; use serenity::client::Context as SerenityContext; use tokio::spawn; use tracing::{debug, error, instrument}; #[instrument(level = "debug", skip(ctx))] -pub async fn run_scheduler(ctx: SerenityContext) { +pub async fn run_scheduler(ctx: SerenityContext, client: GraphQLClient) { let tasks = get_tasks(); for task in tasks { - spawn(schedule_task(ctx.clone(), task)); + spawn(schedule_task(ctx.clone(), task, client.clone())); } } #[instrument(level = "debug", skip(ctx))] -async fn schedule_task(ctx: SerenityContext, task: Box) { +async fn schedule_task(ctx: SerenityContext, task: Box, client: GraphQLClient) { loop { let next_run_in = task.run_in(); tokio::time::sleep(next_run_in).await; debug!("Running task {}", task.name()); - if let Err(e) = task.run(ctx.clone()).await { + if let Err(e) = task.run(ctx.clone(), client.clone()).await { error!("Could not run task {}, error {}", task.name(), e); } } diff --git a/src/tasks/lab_attendance.rs b/src/tasks/lab_attendance.rs index 9d15e42..0d4018d 100644 --- a/src/tasks/lab_attendance.rs +++ b/src/tasks/lab_attendance.rs @@ -21,10 +21,11 @@ use chrono::{DateTime, Datelike, Local, NaiveTime, ParseError, TimeZone, Timelik use serenity::all::{ ChannelId, Colour, Context as SerenityContext, CreateEmbed, CreateEmbedAuthor, CreateMessage, }; -use serenity::async_trait; +use serenity::{async_trait, client}; use std::collections::HashMap; use tracing::{debug, trace}; +use crate::graphql::GraphQLClient; use crate::{ graphql::{models::AttendanceRecord, queries::fetch_attendance}, ids::THE_LAB_CHANNEL_ID, @@ -46,14 +47,17 @@ impl Task for PresenseReport { time_until(18, 00) } - async fn run(&self, ctx: SerenityContext) -> anyhow::Result<()> { - check_lab_attendance(ctx).await + async fn run(&self, ctx: SerenityContext, client: GraphQLClient) -> anyhow::Result<()> { + check_lab_attendance(ctx, client).await } } -pub async fn check_lab_attendance(ctx: SerenityContext) -> anyhow::Result<()> { +pub async fn check_lab_attendance( + ctx: SerenityContext, + client: GraphQLClient, +) -> anyhow::Result<()> { trace!("Starting lab attendance check"); - let attendance = fetch_attendance() + let attendance = fetch_attendance(client) .await .context("Failed to fetch attendance from Root")?; diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index df3ae3a..a08946c 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -28,6 +28,8 @@ use serenity::client::Context; use status_update::StatusUpdateCheck; use tokio::time::Duration; +use crate::graphql::GraphQLClient; + /// A [`Task`] is any job that needs to be executed on a regular basis. /// A task has a function [`Task::run_in`] that returns the time till the /// next ['Task::run`] is run. @@ -35,7 +37,7 @@ use tokio::time::Duration; pub trait Task: Send + Sync { fn name(&self) -> &str; fn run_in(&self) -> Duration; - async fn run(&self, ctx: Context) -> Result<()>; + async fn run(&self, ctx: Context, client: GraphQLClient) -> Result<()>; } impl Debug for Box { diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 9c6e106..ca5c519 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -22,11 +22,13 @@ use serenity::all::{ CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GetMessages, Message, }; use serenity::async_trait; +use tracing::instrument; use super::Task; use crate::graphql::models::{Member, StreakWithMemberId}; use crate::graphql::mutations::{increment_streak, reset_streak}; use crate::graphql::queries::{fetch_members, fetch_streaks}; +use crate::graphql::GraphQLClient; use crate::ids::{ AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, }; @@ -45,8 +47,8 @@ impl Task for StatusUpdateCheck { time_until(5, 00) } - async fn run(&self, ctx: Context) -> anyhow::Result<()> { - status_update_check(ctx).await + async fn run(&self, ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { + status_update_check(ctx, client).await } } @@ -61,16 +63,17 @@ struct ReportConfig { const AMAN_SHAFEEQ: &str = "767636699077410837"; const CHANDRA_MOULI: &str = "1265880467047976970"; -async fn status_update_check(ctx: Context) -> anyhow::Result<()> { +#[instrument(level = "debug", skip(ctx))] +async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { let updates = get_updates(&ctx).await?; - let mut members = fetch_members().await?; + let mut members = fetch_members(client.clone()).await?; members.retain(|member| member.year != 4); // naughty_list -> members who did not send updates let (mut naughty_list, mut nice_list) = categorize_members(&members, updates); - update_streaks_for_members(&mut naughty_list, &mut nice_list).await?; + update_streaks_for_members(client.clone(), &mut naughty_list, &mut nice_list).await?; - let embed = generate_embed(members, naughty_list).await?; + let embed = generate_embed(client, members, naughty_list).await?; let msg = CreateMessage::new().embed(embed); let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID); @@ -169,16 +172,17 @@ fn categorize_members( } async fn update_streaks_for_members( + client: GraphQLClient, naughty_list: &mut GroupedMember, nice_list: &mut Vec, ) -> anyhow::Result<()> { for member in nice_list { - increment_streak(member).await?; + increment_streak(member, client.clone()).await?; } for members in naughty_list.values_mut() { for member in members { - reset_streak(member).await?; + reset_streak(member, client.clone()).await?; } } @@ -186,11 +190,12 @@ async fn update_streaks_for_members( } async fn generate_embed( + client: GraphQLClient, members: Vec, naughty_list: GroupedMember, ) -> anyhow::Result { let (all_time_high, all_time_high_members, current_highest, current_highest_members) = - get_leaderboard_stats(members).await?; + get_leaderboard_stats(client, members).await?; let mut description = String::new(); description.push_str("# Leaderboard Updates\n"); @@ -252,9 +257,10 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } async fn get_leaderboard_stats( + client: GraphQLClient, members: Vec, ) -> anyhow::Result<(i32, Vec, i32, Vec)> { - let streaks = fetch_streaks().await?; + let streaks = fetch_streaks(client).await?; let member_map: HashMap = members.iter().map(|m| (m.member_id, m)).collect(); let (all_time_high, all_time_high_members) = find_highest_streak(&streaks, &member_map, true); From 8d55bdb98e2393e5a50784679e798c24669e1959 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Tue, 30 Sep 2025 20:35:21 +0530 Subject: [PATCH 43/51] refactor: extract config module and refactor main The configuration logic can be kept in its own module. Main can then be refactored to be more concise and contain less inline logic. Tracing logs are the final cherry on top. Signed-off-by: Ivin Joel Abraham --- src/config.rs | 78 ++++++++++++++++++++++++++++++++++++ src/main.rs | 107 ++++++++++++++++---------------------------------- 2 files changed, 112 insertions(+), 73 deletions(-) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..41a5511 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,78 @@ +/* +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 serenity::all::UserId; + +/// Environment variables for amD +/// +/// # Fields +/// +/// * debug: a boolean flag that decides in what context the application will be running on. When true, it is assumed to be in development. This allows us to filter out logs from `stdout` when in production. Defaults to false if not set. +/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. Defaults to false if not set. +/// * discord_token: The bot's discord token obtained from the Discord Developer Portal. The only mandatory variable required. +/// * owner_id: Used to allow access to privileged commands to specific users. If not passed, will set the bot to have no owners. +/// * prefix_string: The prefix used to issue commands to the bot on Discord. Always set to "$". +pub struct Config { + pub debug: bool, + pub enable_debug_libraries: bool, + pub discord_token: String, + pub owner_id: Option, + pub prefix_string: String, + pub root_url: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + debug: parse_bool_env("DEBUG"), + enable_debug_libraries: parse_bool_env("ENABLE_DEBUG_LIBRARIES"), + discord_token: std::env::var("DISCORD_TOKEN") + .expect("DISCORD_TOKEN was not found in env"), + 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"), + } + } +} + +/// Tries to access the environment variable through the key passed in. If it is set, it will try to parse it as u64 and if that fails, it will log the error and return the default value None. If it suceeds the u64 parsing, it will convert it to a UserId and return Some(UserId). If the env. var. is not set, it will return None. +fn parse_owner_id_env(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|s| { + s.parse::() + .map_err(|_| eprintln!("WARNING: Invalid OWNER_ID value '{}', ignoring.", s)) + .ok() + }) + .map(UserId::new) +} + +/// Tries to access the environment variable through the key passed in. If it is set but an invalid boolean, it will log an error through tracing and default to false. If it is not set, it will default to false. +fn parse_bool_env(key: &str) -> bool { + std::env::var(key) + .map(|val| { + val.parse().unwrap_or_else(|_| { + eprintln!( + "Warning: Invalid DEBUG value '{}', defaulting to false", + val + ); + false + }) + }) + .unwrap_or(false) +} diff --git a/src/main.rs b/src/main.rs index 10de39c..5137f7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ mod commands; +mod config; mod graphql; mod ids; mod reaction_roles; @@ -25,25 +26,26 @@ mod trace; mod utils; use anyhow::Context as _; +use config::Config; use graphql::GraphQLClient; use poise::{Context as PoiseContext, Framework, FrameworkOptions, PrefixFrameworkOptions}; use reaction_roles::handle_reaction; -use reqwest::Client; use serenity::client::ClientBuilder; +use serenity::Client; use serenity::{ all::{ReactionType, RoleId, UserId}, client::{Context as SerenityContext, FullEvent}, model::gateway::GatewayIntents, }; use trace::{setup_tracing, ReloadHandle}; -use tracing::{info, instrument}; +use tracing::{debug, info, instrument}; use std::collections::HashMap; type Error = Box; type Context<'a> = PoiseContext<'a, Data, Error>; -/// The [`Data`] struct is kept in-memory by the Bot till it shutdowns and can be used to store session-persistent data. +/// The [`Data`] struct is kept in-memory by the Bot till it shutsdown and can be used to store session-persistent data. #[derive(Clone)] struct Data { reaction_roles: HashMap, @@ -92,87 +94,46 @@ fn build_framework( .build() } -/// Environment variables for amD -/// -/// # Fields -/// -/// * debug: a boolean flag that decides in what context the application will be running on. When true, it is assumed to be in development. This allows us to filter out logs from `stdout` when in production. Defaults to false if not set. -/// * enable_debug_libraries: Boolean flag that controls whether tracing will output logs from other crates used in the project. This is only needed for really serious bugs. Defaults to false if not set. -/// * discord_token: The bot's discord token obtained from the Discord Developer Portal. The only mandatory variable required. -/// * owner_id: Used to allow access to privileged commands to specific users. If not passed, will set the bot to have no owners. -/// * prefix_string: The prefix used to issue commands to the bot on Discord. Always set to "$". -struct Config { - debug: bool, - enable_debug_libraries: bool, - discord_token: String, - owner_id: Option, - prefix_string: String, - root_url: String, -} - -impl Default for Config { - fn default() -> Self { - Self { - debug: parse_bool_env("DEBUG"), - enable_debug_libraries: parse_bool_env("ENABLE_DEBUG_LIBRARIES"), - discord_token: std::env::var("DISCORD_TOKEN") - .expect("DISCORD_TOKEN was not found in env"), - 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"), - } - } -} - -/// Tries to access the environment variable through the key passed in. If it is set, it will try to parse it as u64 and if that fails, it will log the error and return the default value None. If it suceeds the u64 parsing, it will convert it to a UserId and return Some(UserId). If the env. var. is not set, it will return None. -fn parse_owner_id_env(key: &str) -> Option { - std::env::var(key) - .ok() - .and_then(|s| { - s.parse::() - .map_err(|_| eprintln!("WARNING: Invalid OWNER_ID value '{}', ignoring.", s)) - .ok() - }) - .map(UserId::new) +fn prepare_data(config: &Config, reload_handle: ReloadHandle) -> Data { + let mut data = Data::new(reload_handle, config.root_url.clone()); + data.populate_with_reaction_roles(); + data } -/// Tries to access the environment variable through the key passed in. If it is set but an invalid boolean, it will log an error through tracing and default to false. If it is not set, it will default to false. -fn parse_bool_env(key: &str) -> bool { - std::env::var(key) - .map(|val| { - val.parse().unwrap_or_else(|_| { - eprintln!( - "Warning: Invalid DEBUG value '{}', defaulting to false", - val - ); - false - }) - }) - .unwrap_or(false) +async fn build_client(config: &Config, data: Data) -> Result { + ClientBuilder::new( + config.discord_token.clone(), + GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, + ) + .framework(build_framework( + config.owner_id, + config.prefix_string.clone(), + data, + )) + .await + .context("Failed to create the Serenity client") } #[tokio::main] async fn main() -> Result<(), Error> { dotenv::dotenv().ok(); - let config = Config::default(); + let reload_handle = setup_tracing(config.debug, config.enable_debug_libraries) .context("Failed to setup tracing")?; - let mut data = Data::new(reload_handle, config.root_url); - data.populate_with_reaction_roles(); - - let framework = build_framework(config.owner_id, config.prefix_string, data); - - let mut client = ClientBuilder::new( - config.discord_token, - GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT, - ) - .framework(framework) - .await - .context("Failed to create the Serenity client")?; - - info!("Starting amD..."); + info!( + "Starting {} v{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + ); + debug!( + "Configuration loaded: debug={}, enable_debug_libraries={}, owner_id={:?}, prefix_string={}, root_url={}", + config.debug, config.enable_debug_libraries, config.owner_id, config.prefix_string, config.root_url + ); + + let data = prepare_data(&config, reload_handle); + let mut client = build_client(&config, data).await?; client .start() From 949ec3a061745266866db6e081d0e22ad4439e06 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 1 Oct 2025 11:41:49 +0530 Subject: [PATCH 44/51] refactor: move queries and mutations to methods on client Following OOPS principles, it would be more convenient for the user and developer to use the GraphQL interface if both data and methods were encapsulated into the struct itself. Signed-off-by: Ivin Joel Abraham --- src/graphql/mod.rs | 4 + src/graphql/mutations.rs | 206 +++++++++++++++++---------------- src/graphql/queries.rs | 219 ++++++++++++++++++------------------ src/scheduler.rs | 1 + src/tasks/lab_attendance.rs | 7 +- src/tasks/status_update.rs | 14 +-- 6 files changed, 233 insertions(+), 218 deletions(-) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 696b649..ff362a6 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -40,4 +40,8 @@ impl GraphQLClient { pub fn root_url(&self) -> &str { &self.root_url } + + pub fn http(&self) -> Client { + self.http.clone() + } } diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs index 5da44a4..4654d39 100644 --- a/src/graphql/mutations.rs +++ b/src/graphql/mutations.rs @@ -8,136 +8,142 @@ use crate::graphql::models::Streak; use super::models::Member; use super::GraphQLClient; -#[instrument(level = "debug")] -pub async fn increment_streak(member: &mut Member, client: GraphQLClient) -> anyhow::Result<()> { - let mutation = format!( - r#" +impl GraphQLClient { + #[instrument(level = "debug")] + pub async fn increment_streak(&self, member: &mut Member) -> anyhow::Result<()> { + let mutation = format!( + r#" mutation {{ incrementStreak(input: {{ memberId: {} }}) {{ currentStreak maxStreak }} }}"#, - member.member_id - ); + member.member_id + ); - debug!("Sending mutation {}", mutation); - let response = client - .http - .post(client.root_url()) - .json(&serde_json::json!({"query": mutation})) - .send() - .await - .context("Failed to succesfully post query to Root")?; + debug!("Sending mutation {}", mutation); + let response = self + .http() + .post(self.root_url()) + .json(&serde_json::json!({"query": mutation})) + .send() + .await + .context("Failed to succesfully post query to Root")?; - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + debug!("Response: {}", response_json); - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("incrementStreak")) - { - let current_streak = - data.get("currentStreak") + if let Some(data) = response_json + .get("data") + .and_then(|data| data.get("incrementStreak")) + { + let current_streak = data + .get("currentStreak") .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; - let max_streak = - data.get("maxStreak") + .ok_or_else(|| anyhow!("current_streak was parsed as None"))? + as i32; + let max_streak = data + .get("maxStreak") .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; + .ok_or_else(|| anyhow!("max_streak was parsed as None"))? + as i32; - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; + if member.streak.is_empty() { + member.streak.push(Streak { + current_streak, + max_streak, + }); + } else { + for streak in &mut member.streak { + streak.current_streak = current_streak; + streak.max_streak = max_streak; + } } + } else { + return Err(anyhow!( + "Failed to access data from response: {}", + response_json + )); } - } else { - return Err(anyhow!( - "Failed to access data from response: {}", - response_json - )); - } - Ok(()) -} + Ok(()) + } -#[instrument(level = "debug")] -pub async fn reset_streak(member: &mut Member, client: GraphQLClient) -> anyhow::Result<()> { - let mutation = format!( - r#" + #[instrument(level = "debug")] + pub async fn reset_streak(&self, member: &mut Member) -> anyhow::Result<()> { + let mutation = format!( + r#" mutation {{ resetStreak(input: {{ memberId: {} }}) {{ currentStreak maxStreak }} }}"#, - member.member_id - ); + member.member_id + ); - debug!("Sending mutation {}", mutation); - let response = client - .http - .post(client.root_url()) - .json(&serde_json::json!({ "query": mutation })) - .send() - .await - .context("Failed to succesfully post query to Root")?; + debug!("Sending mutation {}", mutation); + let response = self + .http() + .post(self.root_url()) + .json(&serde_json::json!({ "query": mutation })) + .send() + .await + .context("Failed to succesfully post query to Root")?; - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to parse response JSON")?; + debug!("Response: {}", response_json); - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("resetStreak")) - { - let current_streak = - data.get("currentStreak") + if let Some(data) = response_json + .get("data") + .and_then(|data| data.get("resetStreak")) + { + let current_streak = data + .get("currentStreak") .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? as i32; - let max_streak = - data.get("maxStreak") + .ok_or_else(|| anyhow!("current_streak was parsed as None"))? + as i32; + let max_streak = data + .get("maxStreak") .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? as i32; + .ok_or_else(|| anyhow!("max_streak was parsed as None"))? + as i32; - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; + if member.streak.is_empty() { + member.streak.push(Streak { + current_streak, + max_streak, + }); + } else { + for streak in &mut member.streak { + streak.current_streak = current_streak; + streak.max_streak = max_streak; + } } + } else { + return Err(anyhow!("Failed to access data from {}", response_json)); } - } else { - return Err(anyhow!("Failed to access data from {}", response_json)); - } - Ok(()) + Ok(()) + } } diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index a027332..9608d42 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -24,8 +24,9 @@ use crate::graphql::models::{AttendanceRecord, Member}; use super::{models::StreakWithMemberId, GraphQLClient}; -pub async fn fetch_members(client: GraphQLClient) -> anyhow::Result> { - let query = r#" +impl GraphQLClient { + pub async fn fetch_members(&self) -> anyhow::Result> { + let query = r#" { members { memberId @@ -40,51 +41,52 @@ pub async fn fetch_members(client: GraphQLClient) -> anyhow::Result> } }"#; - debug!("Sending query {}", query); - let response = client - .http - .post(client.root_url()) - .json(&serde_json::json!({"query": query})) - .send() - .await - .context("Failed to successfully post request")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } + debug!("Sending query {}", query); + let response = self + .http() + .post(self.root_url()) + .json(&serde_json::json!({"query": query})) + .send() + .await + .context("Failed to successfully post request")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to serialize response")?; - - debug!("Response: {}", response_json); - let members = response_json - .get("data") - .and_then(|data| data.get("members")) - .and_then(|members| members.as_array()) - .ok_or_else(|| { - anyhow::anyhow!( - "Malformed response: Could not access Members from {}", - response_json - ) - })?; - - let members: Vec = serde_json::from_value(serde_json::Value::Array(members.clone())) - .context("Failed to parse 'members' into Vec")?; - - Ok(members) -} + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to serialize response")?; + + debug!("Response: {}", response_json); + let members = response_json + .get("data") + .and_then(|data| data.get("members")) + .and_then(|members| members.as_array()) + .ok_or_else(|| { + anyhow::anyhow!( + "Malformed response: Could not access Members from {}", + response_json + ) + })?; + + let members: Vec = + serde_json::from_value(serde_json::Value::Array(members.clone())) + .context("Failed to parse 'members' into Vec")?; + + Ok(members) + } -pub async fn fetch_attendance(client: GraphQLClient) -> anyhow::Result> { - debug!("Fetching attendance data"); + pub async fn fetch_attendance(&self) -> anyhow::Result> { + debug!("Fetching attendance data"); - let today = Local::now().format("%Y-%m-%d").to_string(); - let query = format!( - r#" + let today = Local::now().format("%Y-%m-%d").to_string(); + let query = format!( + r#" query {{ attendanceByDate(date: "{today}") {{ name, @@ -93,42 +95,42 @@ pub async fn fetch_attendance(client: GraphQLClient) -> anyhow::Result = attendance_array - .iter() - .map(|entry| { - serde_json::from_value(entry.clone()).context("Failed to parse attendance record") - }) - .collect::>>()?; - - debug!( - "Successfully fetched {} attendance records", - attendance.len() - ); - Ok(attendance) -} + ); + + let response = self + .http() + .post(self.root_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 = attendance_array + .iter() + .map(|entry| { + serde_json::from_value(entry.clone()).context("Failed to parse attendance record") + }) + .collect::>>()?; + + debug!( + "Successfully fetched {} attendance records", + attendance.len() + ); + Ok(attendance) + } -pub async fn fetch_streaks(client: GraphQLClient) -> anyhow::Result> { - let query = r#" + pub async fn fetch_streaks(&self) -> anyhow::Result> { + let query = r#" { streaks { memberId @@ -138,33 +140,36 @@ pub async fn fetch_streaks(client: GraphQLClient) -> anyhow::Result>(streaks.clone()).ok()) - .context("Failed to parse streaks data")?; + debug!("Sending query {}", query); + let response = self + .http() + .post(self.root_url()) + .json(&serde_json::json!({"query": query})) + .send() + .await + .context("Failed to successfully post request")?; + + if !response.status().is_success() { + return Err(anyhow!( + "Server responded with an error: {:?}", + response.status() + )); + } - Ok(streaks) + let response_json: serde_json::Value = response + .json() + .await + .context("Failed to serialize response")?; + + debug!("Response: {}", response_json); + let streaks = response_json + .get("data") + .and_then(|data| data.get("streaks")) + .and_then(|streaks| { + serde_json::from_value::>(streaks.clone()).ok() + }) + .context("Failed to parse streaks data")?; + + Ok(streaks) + } } diff --git a/src/scheduler.rs b/src/scheduler.rs index 744d06f..3d61a1c 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -30,6 +30,7 @@ pub async fn run_scheduler(ctx: SerenityContext, client: GraphQLClient) { let tasks = get_tasks(); for task in tasks { + // TODO: Panics in this thread might be silent and won't be noticed. It should be caught, safely unwinded and ideally reported. spawn(schedule_task(ctx.clone(), task, client.clone())); } } diff --git a/src/tasks/lab_attendance.rs b/src/tasks/lab_attendance.rs index 0d4018d..e34e90b 100644 --- a/src/tasks/lab_attendance.rs +++ b/src/tasks/lab_attendance.rs @@ -21,13 +21,13 @@ use chrono::{DateTime, Datelike, Local, NaiveTime, ParseError, TimeZone, Timelik use serenity::all::{ ChannelId, Colour, Context as SerenityContext, CreateEmbed, CreateEmbedAuthor, CreateMessage, }; -use serenity::{async_trait, client}; +use serenity::async_trait; use std::collections::HashMap; use tracing::{debug, trace}; use crate::graphql::GraphQLClient; use crate::{ - graphql::{models::AttendanceRecord, queries::fetch_attendance}, + graphql::models::AttendanceRecord, ids::THE_LAB_CHANNEL_ID, utils::time::{get_five_forty_five_pm_timestamp, time_until}, }; @@ -57,7 +57,8 @@ pub async fn check_lab_attendance( client: GraphQLClient, ) -> anyhow::Result<()> { trace!("Starting lab attendance check"); - let attendance = fetch_attendance(client) + let attendance = client + .fetch_attendance() .await .context("Failed to fetch attendance from Root")?; diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index ca5c519..1c23f6a 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -26,8 +26,6 @@ use tracing::instrument; use super::Task; use crate::graphql::models::{Member, StreakWithMemberId}; -use crate::graphql::mutations::{increment_streak, reset_streak}; -use crate::graphql::queries::{fetch_members, fetch_streaks}; use crate::graphql::GraphQLClient; use crate::ids::{ AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, @@ -66,12 +64,12 @@ const CHANDRA_MOULI: &str = "1265880467047976970"; #[instrument(level = "debug", skip(ctx))] async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { let updates = get_updates(&ctx).await?; - let mut members = fetch_members(client.clone()).await?; + let mut members = client.fetch_members().await?; members.retain(|member| member.year != 4); // naughty_list -> members who did not send updates let (mut naughty_list, mut nice_list) = categorize_members(&members, updates); - update_streaks_for_members(client.clone(), &mut naughty_list, &mut nice_list).await?; + update_streaks_for_members(&client, &mut naughty_list, &mut nice_list).await?; let embed = generate_embed(client, members, naughty_list).await?; let msg = CreateMessage::new().embed(embed); @@ -172,17 +170,17 @@ fn categorize_members( } async fn update_streaks_for_members( - client: GraphQLClient, + client: &GraphQLClient, naughty_list: &mut GroupedMember, nice_list: &mut Vec, ) -> anyhow::Result<()> { for member in nice_list { - increment_streak(member, client.clone()).await?; + client.increment_streak(member).await?; } for members in naughty_list.values_mut() { for member in members { - reset_streak(member, client.clone()).await?; + client.reset_streak(member).await?; } } @@ -260,7 +258,7 @@ async fn get_leaderboard_stats( client: GraphQLClient, members: Vec, ) -> anyhow::Result<(i32, Vec, i32, Vec)> { - let streaks = fetch_streaks(client).await?; + let streaks = client.fetch_streaks().await?; let member_map: HashMap = members.iter().map(|m| (m.member_id, m)).collect(); let (all_time_high, all_time_high_members) = find_highest_streak(&streaks, &member_map, true); From 06d4d157cc5c9e85462f952bd3c882010278a027 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 1 Oct 2025 18:15:58 +0530 Subject: [PATCH 45/51] refactor: use slice in place of String Signed-off-by: Ivin Joel Abraham --- src/commands/set_log_level.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/commands/set_log_level.rs b/src/commands/set_log_level.rs index 87ae427..d2f24b0 100644 --- a/src/commands/set_log_level.rs +++ b/src/commands/set_log_level.rs @@ -22,13 +22,9 @@ use tracing::info; use tracing::instrument; use tracing_subscriber::EnvFilter; /// Returns whether the provided `level` String is a valid filter level for tracing. -fn validate_level(level: &String) -> bool { +fn validate_level(level: &str) -> bool { const VALID_LEVELS: [&str; 5] = ["trace", "debug", "info", "warn", "error"]; - if !VALID_LEVELS.contains(&level.as_str()) { - true - } else { - false - } + !VALID_LEVELS.contains(&level) } fn build_filter_string(level: String, enable_debug_libraries: bool) -> anyhow::Result { From 4fcd482a7ed8b58cd9b6bd8ccfae972080f2c931 Mon Sep 17 00:00:00 2001 From: Ivin Joel Abraham Date: Wed, 1 Oct 2025 18:20:00 +0530 Subject: [PATCH 46/51] refactor: fix clippy lints Signed-off-by: Ivin Joel Abraham --- src/commands/mod.rs | 1 + src/config.rs | 7 ++----- src/graphql/mod.rs | 2 +- src/ids.rs | 2 ++ 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3c2176b..b114346 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ use crate::{ }; /// Checks if the author has the Fourth Year or Third Year role. Can be used as an authorization procedure for other commands. +#[allow(dead_code)] async fn is_privileged(ctx: &Context<'_>) -> bool { if let Some(guild_id) = ctx.guild_id() { if let Ok(member) = guild_id.member(ctx, ctx.author().id).await { diff --git a/src/config.rs b/src/config.rs index 41a5511..e8a4c30 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,7 +56,7 @@ fn parse_owner_id_env(key: &str) -> Option { .ok() .and_then(|s| { s.parse::() - .map_err(|_| eprintln!("WARNING: Invalid OWNER_ID value '{}', ignoring.", s)) + .map_err(|_| eprintln!("WARNING: Invalid OWNER_ID value '{s}', ignoring.")) .ok() }) .map(UserId::new) @@ -67,10 +67,7 @@ fn parse_bool_env(key: &str) -> bool { std::env::var(key) .map(|val| { val.parse().unwrap_or_else(|_| { - eprintln!( - "Warning: Invalid DEBUG value '{}', defaulting to false", - val - ); + eprintln!("Warning: Invalid DEBUG value '{val}', defaulting to false"); false }) }) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index ff362a6..0476ee9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -33,7 +33,7 @@ impl GraphQLClient { pub fn new(root_url: String) -> Self { Self { http: Client::new(), - root_url: Arc::new(root_url.into()), + root_url: Arc::new(root_url), } } diff --git a/src/ids.rs b/src/ids.rs index 42c6bf6..d34dfcc 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -19,7 +19,9 @@ along with this program. If not, see . pub const ROLES_MESSAGE_ID: u64 = 1298636092886749294; /// Fourth and Third Year Roles for privileged commands +#[allow(dead_code)] pub const FOURTH_YEAR_ROLE_ID: u64 = 1135793659040772240; +#[allow(dead_code)] pub const THIRD_YEAR_ROLE_ID: u64 = 1166292683317321738; // Role IDs From c60caf05729bf618d9d8e365965753547371603d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:02:11 +0000 Subject: [PATCH 47/51] build(deps): bump the version-updates group across 1 directory with 6 updates Bumps the version-updates group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [anyhow](https://github.com/dtolnay/anyhow) | `1.0.99` | `1.0.100` | | [async-trait](https://github.com/dtolnay/async-trait) | `0.1.88` | `0.1.89` | | [chrono](https://github.com/chronotope/chrono) | `0.4.41` | `0.4.42` | | [serde](https://github.com/serde-rs/serde) | `1.0.219` | `1.0.228` | | [serde_json](https://github.com/serde-rs/json) | `1.0.142` | `1.0.145` | | [tracing-subscriber](https://github.com/tokio-rs/tracing) | `0.3.19` | `0.3.20` | Updates `anyhow` from 1.0.99 to 1.0.100 - [Release notes](https://github.com/dtolnay/anyhow/releases) - [Commits](https://github.com/dtolnay/anyhow/compare/1.0.99...1.0.100) Updates `async-trait` from 0.1.88 to 0.1.89 - [Release notes](https://github.com/dtolnay/async-trait/releases) - [Commits](https://github.com/dtolnay/async-trait/compare/0.1.88...0.1.89) Updates `chrono` from 0.4.41 to 0.4.42 - [Release notes](https://github.com/chronotope/chrono/releases) - [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md) - [Commits](https://github.com/chronotope/chrono/compare/v0.4.41...v0.4.42) Updates `serde` from 1.0.219 to 1.0.228 - [Release notes](https://github.com/serde-rs/serde/releases) - [Commits](https://github.com/serde-rs/serde/compare/v1.0.219...v1.0.228) Updates `serde_json` from 1.0.142 to 1.0.145 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.142...v1.0.145) Updates `tracing-subscriber` from 0.3.19 to 0.3.20 - [Release notes](https://github.com/tokio-rs/tracing/releases) - [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.19...tracing-subscriber-0.3.20) --- updated-dependencies: - dependency-name: anyhow dependency-version: 1.0.100 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: async-trait dependency-version: 0.1.89 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: chrono dependency-version: 0.4.42 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: serde dependency-version: 1.0.228 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: serde_json dependency-version: 1.0.145 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates - dependency-name: tracing-subscriber dependency-version: 0.3.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: version-updates ... Signed-off-by: dependabot[bot] --- Cargo.lock | 124 +++++++++++++++++++---------------------------------- Cargo.toml | 12 +++--- 2 files changed, 51 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 341c2f4..d2370d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,12 +45,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -62,9 +56,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arrayvec" @@ -77,9 +71,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -218,17 +212,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -1117,11 +1110,11 @@ checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -1200,12 +1193,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -1282,12 +1274,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking_lot" version = "0.12.3" @@ -1473,17 +1459,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1494,15 +1471,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -1792,9 +1763,19 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] @@ -1810,9 +1791,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1821,14 +1802,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2363,14 +2345,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -2659,22 +2641,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.9" @@ -2684,12 +2650,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.52.0" @@ -2705,13 +2665,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-registry" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -2722,7 +2688,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2731,7 +2697,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 77b64cc..241d9dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,16 @@ version = "1.3.0" edition = "2021" [dependencies] -anyhow = "1.0.99" -async-trait = "0.1.88" -chrono = "0.4.41" +anyhow = "1.0.100" +async-trait = "0.1.89" +chrono = "0.4.42" chrono-tz = "0.10.4" reqwest = { version = "0.12.23", features = ["json"] } -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.142" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] } tracing = "0.1.37" dotenv = "0.15.0" serenity = { version = "0.12.4", features = ["chrono"] } poise = "0.6.1" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } From d7b914f3b279499766b7a963aa76f074be0e9bdd Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Fri, 10 Oct 2025 21:15:00 +0530 Subject: [PATCH 48/51] refactor(MVP): migrate from discord channel to root based status update This commit removes the current discord based status update functionality from amD and instead uses the updated root queries to generate the status update report for the day. Currently an MVP, the code needs some more cleanup for a full clean migration. --- src/graphql/mod.rs | 1 - src/graphql/models.rs | 24 ++++- src/graphql/mutations.rs | 149 ----------------------------- src/graphql/queries.rs | 36 ++++--- src/tasks/mod.rs | 4 +- src/tasks/status_update.rs | 190 +++++++++++-------------------------- 6 files changed, 101 insertions(+), 303 deletions(-) delete mode 100644 src/graphql/mutations.rs diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 0476ee9..5267712 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -16,7 +16,6 @@ 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 570b3af..3486a44 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -28,11 +28,26 @@ pub struct StreakWithMemberId { } #[derive(Clone, Debug, Deserialize)] -pub struct Streak { +pub struct StatusOnDate { + #[serde(rename = "isSent")] + pub is_sent: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct StatusStreak { #[serde(rename = "currentStreak")] - pub current_streak: i32, + pub current_streak: Option, #[serde(rename = "maxStreak")] - pub max_streak: i32, + pub max_streak: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MemberStatus { + #[serde(rename = "onDate")] + pub on_date: Option, + pub streak: Option, + #[serde(rename = "consecutiveMisses")] + pub consecutive_misses: Option, } #[derive(Clone, Debug, Deserialize)] @@ -42,10 +57,9 @@ pub struct Member { pub name: String, #[serde(rename = "discordId")] pub discord_id: String, - #[serde(default)] - pub streak: Vec, // Note that Root will NOT have multiple Streak elements but it may be an empty list which is why we use a vector here pub track: Option, pub year: i32, + pub status: Option, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/graphql/mutations.rs b/src/graphql/mutations.rs deleted file mode 100644 index 4654d39..0000000 --- a/src/graphql/mutations.rs +++ /dev/null @@ -1,149 +0,0 @@ -use anyhow::anyhow; -use anyhow::Context as _; -use tracing::debug; -use tracing::instrument; - -use crate::graphql::models::Streak; - -use super::models::Member; -use super::GraphQLClient; - -impl GraphQLClient { - #[instrument(level = "debug")] - pub async fn increment_streak(&self, member: &mut Member) -> anyhow::Result<()> { - let mutation = format!( - r#" - mutation {{ - incrementStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({"query": mutation})) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("incrementStreak")) - { - let current_streak = data - .get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? - as i32; - let max_streak = data - .get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? - as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!( - "Failed to access data from response: {}", - response_json - )); - } - - Ok(()) - } - - #[instrument(level = "debug")] - pub async fn reset_streak(&self, member: &mut Member) -> anyhow::Result<()> { - let mutation = format!( - r#" - mutation {{ - resetStreak(input: {{ memberId: {} }}) {{ - currentStreak - maxStreak - }} - }}"#, - member.member_id - ); - - debug!("Sending mutation {}", mutation); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({ "query": mutation })) - .send() - .await - .context("Failed to succesfully post query to Root")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to parse response JSON")?; - debug!("Response: {}", response_json); - - if let Some(data) = response_json - .get("data") - .and_then(|data| data.get("resetStreak")) - { - let current_streak = data - .get("currentStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("current_streak was parsed as None"))? - as i32; - let max_streak = data - .get("maxStreak") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow!("max_streak was parsed as None"))? - as i32; - - if member.streak.is_empty() { - member.streak.push(Streak { - current_streak, - max_streak, - }); - } else { - for streak in &mut member.streak { - streak.current_streak = current_streak; - streak.max_streak = max_streak; - } - } - } else { - return Err(anyhow!("Failed to access data from {}", response_json)); - } - - Ok(()) - } -} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index 9608d42..29b2add 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -16,7 +16,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ use anyhow::{anyhow, Context}; -use chrono::Local; +use chrono::{Local, NaiveDate}; use serde_json::Value; use tracing::debug; @@ -25,27 +25,41 @@ use crate::graphql::models::{AttendanceRecord, Member}; use super::{models::StreakWithMemberId, GraphQLClient}; impl GraphQLClient { - pub async fn fetch_members(&self) -> anyhow::Result> { + pub async fn fetch_member_data(&self, date: NaiveDate) -> anyhow::Result> { let query = r#" - { - members { + query($date: NaiveDate!) { + allMembers { memberId name discordId groupId - streak { - currentStreak - maxStreak + status { + onDate(date: $date) { + isSent + } + streak { + currentStreak, + maxStreak + } + consecutiveMisses } track - } - }"#; + year + } + }"#; debug!("Sending query {}", query); + + let variables = serde_json::json!({ + "date": date.format("%Y-%m-%d").to_string() + }); + + debug!("With variables: {}", variables); + let response = self .http() .post(self.root_url()) - .json(&serde_json::json!({"query": query})) + .json(&serde_json::json!({"query": query, "variables":variables})) .send() .await .context("Failed to successfully post request")?; @@ -65,7 +79,7 @@ impl GraphQLClient { debug!("Response: {}", response_json); let members = response_json .get("data") - .and_then(|data| data.get("members")) + .and_then(|data| data.get("allMembers")) .and_then(|members| members.as_array()) .ok_or_else(|| { anyhow::anyhow!( diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index a08946c..f4f1b8b 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -25,7 +25,7 @@ use anyhow::Result; use async_trait::async_trait; use lab_attendance::PresenseReport; use serenity::client::Context; -use status_update::StatusUpdateCheck; +use status_update::StatusUpdateReport; use tokio::time::Duration; use crate::graphql::GraphQLClient; @@ -52,5 +52,5 @@ impl Debug for Box { /// 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> { - vec![Box::new(StatusUpdateCheck), Box::new(PresenseReport)] + vec![Box::new(PresenseReport), Box::new(StatusUpdateReport)] } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 1c23f6a..ad82fda 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -16,8 +16,9 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ use std::collections::{HashMap, HashSet}; +use std::time::Duration; -use chrono::{DateTime, Utc}; +use chrono::DateTime; use serenity::all::{ CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GetMessages, Message, }; @@ -33,16 +34,17 @@ use crate::ids::{ use crate::utils::time::time_until; /// Checks for status updates daily at 5 AM. -pub struct StatusUpdateCheck; +pub struct StatusUpdateReport; #[async_trait] -impl Task for StatusUpdateCheck { +impl Task for StatusUpdateReport { fn name(&self) -> &str { - "Status Update Check" + "Status Update Report" } fn run_in(&self) -> tokio::time::Duration { - time_until(5, 00) + time_until(5, 15) + // Duration::from_secs(1) // for development } async fn run(&self, ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { @@ -58,20 +60,18 @@ struct ReportConfig { special_authors: Vec<&'static str>, } -const AMAN_SHAFEEQ: &str = "767636699077410837"; -const CHANDRA_MOULI: &str = "1265880467047976970"; - #[instrument(level = "debug", skip(ctx))] -async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { - let updates = get_updates(&ctx).await?; - let mut members = client.fetch_members().await?; +pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { + let now = chrono::Utc::now().with_timezone(&chrono_tz::Asia::Kolkata); + let yesterday = now.date_naive() - chrono::Duration::days(1); + + let mut members = client.fetch_member_data(yesterday).await?; members.retain(|member| member.year != 4); // naughty_list -> members who did not send updates - let (mut naughty_list, mut nice_list) = categorize_members(&members, updates); - update_streaks_for_members(&client, &mut naughty_list, &mut nice_list).await?; + let naughty_list = categorize_members(&members); - let embed = generate_embed(client, members, naughty_list).await?; + let embed = generate_embed(members, naughty_list).await?; let msg = CreateMessage::new().embed(embed); let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID); @@ -80,20 +80,6 @@ async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Res Ok(()) } -async fn get_updates(ctx: &Context) -> anyhow::Result> { - let channel_ids = get_channel_ids(); - let mut updates = Vec::new(); - - let get_messages_builder = GetMessages::new().limit(100); - for channel in channel_ids { - let messages = channel.messages(ctx.http(), get_messages_builder).await?; - let valid_updates = messages.into_iter().filter(is_valid_status_update); - updates.extend(valid_updates); - } - - Ok(updates) -} - // TODO: Replace hardcoded set with configurable list fn get_channel_ids() -> Vec { vec![ @@ -104,96 +90,32 @@ fn get_channel_ids() -> Vec { ] } -fn is_valid_status_update(msg: &Message) -> bool { - let report_config = get_report_config(); - let content = msg.content.to_lowercase(); - - let is_within_timeframe = DateTime::::from_timestamp(msg.timestamp.timestamp(), 0) - .expect("Valid timestamp") - .with_timezone(&chrono_tz::Asia::Kolkata) - >= report_config.time_valid_from; - - let has_required_keywords = report_config - .keywords - .iter() - .all(|keyword| content.contains(keyword)); - let is_special_author = report_config - .special_authors - .contains(&msg.author.id.to_string().as_str()); - let is_valid_content = - has_required_keywords || (is_special_author && content.contains("regards")); - - is_within_timeframe && is_valid_content -} - -// TODO: Parts of this could also be removed from code like channel_ids -fn get_report_config() -> ReportConfig { - let now = chrono::Utc::now().with_timezone(&chrono_tz::Asia::Kolkata); - let yesterday = now.date_naive() - chrono::Duration::days(1); - let time_valid_from = yesterday - .and_hms_opt(20, 0, 0) - .expect("Valid timestamp") - .and_local_timezone(chrono_tz::Asia::Kolkata) - .earliest() - .expect("Valid timezone conversion"); - - ReportConfig { - time_valid_from, - keywords: vec!["namah shivaya", "regards"], - special_authors: vec![AMAN_SHAFEEQ, CHANDRA_MOULI], - } -} - -fn categorize_members( - members: &Vec, - updates: Vec, -) -> (GroupedMember, Vec) { - let mut nice_list = vec![]; +fn categorize_members(members: &Vec) -> GroupedMember { let mut naughty_list: HashMap, Vec> = HashMap::new(); - let mut sent_updates: HashSet = HashSet::new(); - - for message in updates.iter() { - sent_updates.insert(message.author.id.to_string()); - } - for member in members { - if sent_updates.contains(&member.discord_id) { - nice_list.push(member.clone()); - } else { + let Some(status) = &member.status else { + continue; + }; + let Some(on_date) = &status.on_date else { + continue; + }; + + if !on_date.is_sent { let track = member.track.clone(); naughty_list.entry(track).or_default().push(member.clone()); } } - (naughty_list, nice_list) -} - -async fn update_streaks_for_members( - client: &GraphQLClient, - naughty_list: &mut GroupedMember, - nice_list: &mut Vec, -) -> anyhow::Result<()> { - for member in nice_list { - client.increment_streak(member).await?; - } - - for members in naughty_list.values_mut() { - for member in members { - client.reset_streak(member).await?; - } - } - - Ok(()) + naughty_list } async fn generate_embed( - client: GraphQLClient, members: Vec, naughty_list: GroupedMember, ) -> anyhow::Result { let (all_time_high, all_time_high_members, current_highest, current_highest_members) = - get_leaderboard_stats(client, members).await?; + get_leaderboard_stats(members).await?; let mut description = String::new(); description.push_str("# Leaderboard Updates\n"); @@ -242,9 +164,11 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } for member in missed_members { - let status = match member.streak[0].current_streak { - 0 => ":x:", - -1 => ":x::x:", + 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:", _ => ":headstone:", }; description.push_str(&format!("- {} | {}\n", member.name, status)); @@ -255,15 +179,10 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } async fn get_leaderboard_stats( - client: GraphQLClient, members: Vec, ) -> anyhow::Result<(i32, Vec, i32, Vec)> { - let streaks = client.fetch_streaks().await?; - let member_map: HashMap = members.iter().map(|m| (m.member_id, m)).collect(); - - let (all_time_high, all_time_high_members) = find_highest_streak(&streaks, &member_map, true); - let (current_highest, current_highest_members) = - find_highest_streak(&streaks, &member_map, false); + let (all_time_high, all_time_high_members) = find_highest_streak(&members, true); + let (current_highest, current_highest_members) = find_highest_streak(&members, false); Ok(( all_time_high, @@ -273,33 +192,34 @@ async fn get_leaderboard_stats( )) } -fn find_highest_streak( - streaks: &[StreakWithMemberId], - member_map: &HashMap, - is_all_time: bool, -) -> (i32, Vec) { +fn find_highest_streak(members: &Vec, is_all_time: bool) -> (i32, Vec) { let mut highest = 0; let mut highest_members = Vec::new(); - for streak in streaks { - if let Some(member) = member_map.get(&streak.member_id) { - let streak_value = if is_all_time { - streak.max_streak - } else { - streak.current_streak - }; - - match streak_value.cmp(&highest) { - std::cmp::Ordering::Greater => { - highest = streak_value; - highest_members.clear(); - highest_members.push((*member).clone()); - } - std::cmp::Ordering::Equal => { - highest_members.push((*member).clone()); + for member in members { + let streak_value = member + .status + .as_ref() + .and_then(|s| s.streak.as_ref()) + .and_then(|streak| { + if is_all_time { + streak.max_streak + } else { + streak.current_streak } - _ => {} + }) + .unwrap_or(0); // default to 0 if no streak info + + match streak_value.cmp(&highest) { + std::cmp::Ordering::Greater => { + highest = streak_value; + highest_members.clear(); + highest_members.push(member.clone()); + } + std::cmp::Ordering::Equal => { + highest_members.push(member.clone()); } + _ => {} } } From 7b71891d3a8d7756e049dda0f9fb1393165e89ab Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Sat, 11 Oct 2025 22:11:52 +0530 Subject: [PATCH 49/51] refactor: remove unused queries and models --- src/graphql/models.rs | 10 --------- src/graphql/queries.rs | 46 +------------------------------------- src/tasks/status_update.rs | 2 +- 3 files changed, 2 insertions(+), 56 deletions(-) diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 3486a44..64ad894 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -17,16 +17,6 @@ along with this program. If not, see . */ use serde::Deserialize; -#[derive(Clone, Debug, Deserialize)] -pub struct StreakWithMemberId { - #[serde(rename = "memberId")] - pub member_id: i32, - #[serde(rename = "currentStreak")] - pub current_streak: i32, - #[serde(rename = "maxStreak")] - pub max_streak: i32, -} - #[derive(Clone, Debug, Deserialize)] pub struct StatusOnDate { #[serde(rename = "isSent")] diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index 29b2add..18050f6 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -22,7 +22,7 @@ use tracing::debug; use crate::graphql::models::{AttendanceRecord, Member}; -use super::{models::StreakWithMemberId, GraphQLClient}; +use super::GraphQLClient; impl GraphQLClient { pub async fn fetch_member_data(&self, date: NaiveDate) -> anyhow::Result> { @@ -142,48 +142,4 @@ impl GraphQLClient { ); Ok(attendance) } - - pub async fn fetch_streaks(&self) -> anyhow::Result> { - let query = r#" - { - streaks { - memberId - currentStreak - maxStreak - } - } - "#; - - debug!("Sending query {}", query); - let response = self - .http() - .post(self.root_url()) - .json(&serde_json::json!({"query": query})) - .send() - .await - .context("Failed to successfully post request")?; - - if !response.status().is_success() { - return Err(anyhow!( - "Server responded with an error: {:?}", - response.status() - )); - } - - let response_json: serde_json::Value = response - .json() - .await - .context("Failed to serialize response")?; - - debug!("Response: {}", response_json); - let streaks = response_json - .get("data") - .and_then(|data| data.get("streaks")) - .and_then(|streaks| { - serde_json::from_value::>(streaks.clone()).ok() - }) - .context("Failed to parse streaks data")?; - - Ok(streaks) - } } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index ad82fda..f8233c9 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -26,7 +26,7 @@ use serenity::async_trait; use tracing::instrument; use super::Task; -use crate::graphql::models::{Member, StreakWithMemberId}; +use crate::graphql::models::Member; use crate::graphql::GraphQLClient; use crate::ids::{ AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, From 183427762df4e1b1951960c87f3028af8285187d Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Sat, 11 Oct 2025 22:24:13 +0530 Subject: [PATCH 50/51] fix: warnings and dead code --- src/ids.rs | 5 ----- src/tasks/status_update.rs | 28 +++------------------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/ids.rs b/src/ids.rs index d34dfcc..fbf20d3 100644 --- a/src/ids.rs +++ b/src/ids.rs @@ -33,10 +33,5 @@ pub const RESEARCH_ROLE_ID: u64 = 1298553855474270219; pub const DEVOPS_ROLE_ID: u64 = 1298553883169132554; pub const WEB_ROLE_ID: u64 = 1298553910167994428; -// Channel IDs for status updates -pub const SYSTEMS_CHANNEL_ID: u64 = 1378426650152271902; -pub const MOBILE_CHANNEL_ID: u64 = 1378685538835365960; -pub const WEB_CHANNEL_ID: u64 = 1378685360133115944; -pub const AI_CHANNEL_ID: u64 = 1343489220068507649; pub const STATUS_UPDATE_CHANNEL_ID: u64 = 764575524127244318; pub const THE_LAB_CHANNEL_ID: u64 = 1208438766893670451; diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index f8233c9..0f2b19e 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -15,22 +15,16 @@ 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 std::collections::{HashMap, HashSet}; -use std::time::Duration; +use std::collections::HashMap; -use chrono::DateTime; -use serenity::all::{ - CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GetMessages, Message, -}; +use serenity::all::{CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage}; use serenity::async_trait; use tracing::instrument; use super::Task; use crate::graphql::models::Member; use crate::graphql::GraphQLClient; -use crate::ids::{ - AI_CHANNEL_ID, MOBILE_CHANNEL_ID, STATUS_UPDATE_CHANNEL_ID, SYSTEMS_CHANNEL_ID, WEB_CHANNEL_ID, -}; +use crate::ids::STATUS_UPDATE_CHANNEL_ID; use crate::utils::time::time_until; /// Checks for status updates daily at 5 AM. @@ -54,12 +48,6 @@ impl Task for StatusUpdateReport { type GroupedMember = HashMap, Vec>; -struct ReportConfig { - time_valid_from: DateTime, - keywords: Vec<&'static str>, - special_authors: Vec<&'static str>, -} - #[instrument(level = "debug", skip(ctx))] pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow::Result<()> { let now = chrono::Utc::now().with_timezone(&chrono_tz::Asia::Kolkata); @@ -80,16 +68,6 @@ pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow: Ok(()) } -// TODO: Replace hardcoded set with configurable list -fn get_channel_ids() -> Vec { - vec![ - ChannelId::new(SYSTEMS_CHANNEL_ID), - ChannelId::new(MOBILE_CHANNEL_ID), - ChannelId::new(WEB_CHANNEL_ID), - ChannelId::new(AI_CHANNEL_ID), - ] -} - fn categorize_members(members: &Vec) -> GroupedMember { let mut naughty_list: HashMap, Vec> = HashMap::new(); From efea35c26e2fe10d7bfe5462045ee155e9a25275 Mon Sep 17 00:00:00 2001 From: Hridesh MG Date: Tue, 14 Oct 2025 13:48:37 +0530 Subject: [PATCH 51/51] fix: add check for status update breaks --- src/graphql/models.rs | 2 ++ src/graphql/queries.rs | 1 + src/tasks/status_update.rs | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 64ad894..519e77f 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -21,6 +21,8 @@ use serde::Deserialize; pub struct StatusOnDate { #[serde(rename = "isSent")] pub is_sent: bool, + #[serde(rename = "onBreak")] + pub on_break: bool, } #[derive(Clone, Debug, Deserialize)] diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index 18050f6..0cb6a6f 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -36,6 +36,7 @@ impl GraphQLClient { status { onDate(date: $date) { isSent + onBreak } streak { currentStreak, diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 0f2b19e..23e9128 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -79,7 +79,7 @@ fn categorize_members(members: &Vec) -> GroupedMember { continue; }; - if !on_date.is_sent { + if !on_date.on_break && !on_date.is_sent { let track = member.track.clone(); naughty_list.entry(track).or_default().push(member.clone()); }