From 31ab29c2f11041ea3de36660131ed5a2a9923100 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 26 Aug 2025 20:53:21 -0500 Subject: [PATCH] feat!: first pass re-implementation of banner, gcal command --- Cargo.lock | 106 +++++++++++ Cargo.toml | 8 +- src/app_state.rs | 24 +++ src/banner/api.rs | 320 +++++++++++++++++++++++++++++++++ src/banner/mod.rs | 19 ++ src/banner/models/common.rs | 21 +++ src/banner/models/courses.rs | 64 +++++++ src/banner/models/meetings.rs | 194 ++++++++++++++++++++ src/banner/models/mod.rs | 14 ++ src/banner/models/search.rs | 23 +++ src/banner/models/terms.rs | 76 ++++++++ src/banner/query.rs | 313 ++++++++++++++++++++++++++++++++ src/banner/scraper.rs | 290 ++++++++++++++++++++++++++++++ src/banner/session.rs | 190 ++++++++++++++++++++ src/bot/commands/gcal.rs | 323 ++++++++++++++++++++++++++++++++++ src/bot/commands/ics.rs | 25 +++ src/bot/commands/mod.rs | 13 ++ src/bot/commands/search.rs | 115 ++++++++++++ src/bot/commands/terms.rs | 26 +++ src/bot/commands/time.rs | 25 +++ src/bot/mod.rs | 28 +-- src/config/mod.rs | 20 +-- src/lib.rs | 4 + src/main.rs | 20 ++- 24 files changed, 2234 insertions(+), 27 deletions(-) create mode 100644 src/app_state.rs create mode 100644 src/banner/api.rs create mode 100644 src/banner/mod.rs create mode 100644 src/banner/models/common.rs create mode 100644 src/banner/models/courses.rs create mode 100644 src/banner/models/meetings.rs create mode 100644 src/banner/models/mod.rs create mode 100644 src/banner/models/search.rs create mode 100644 src/banner/models/terms.rs create mode 100644 src/banner/query.rs create mode 100644 src/banner/scraper.rs create mode 100644 src/banner/session.rs create mode 100644 src/bot/commands/gcal.rs create mode 100644 src/bot/commands/ics.rs create mode 100644 src/bot/commands/mod.rs create mode 100644 src/bot/commands/search.rs create mode 100644 src/bot/commands/terms.rs create mode 100644 src/bot/commands/time.rs diff --git a/Cargo.lock b/Cargo.lock index a6ea202..b77e79f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,13 +170,18 @@ dependencies = [ "anyhow", "async-trait", "axum", + "chrono", + "chrono-tz", + "compile-time", "diesel", "dotenvy", "figment", "fundu", "governor", "poise", + "rand 0.8.5", "redis", + "regex", "reqwest 0.12.23", "serde", "serde_json", @@ -185,6 +190,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -304,11 +310,35 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "combine" version = "4.6.7" @@ -334,6 +364,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "compile-time" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55ede5279d4d7c528906853743abeb26353ae1e6c440fcd6d18316c2c2dd903" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "rustc_version", + "semver", + "time", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1605,6 +1649,15 @@ 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 = "pear" version = "0.2.9" @@ -1634,6 +1687,44 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +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 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2049,6 +2140,15 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.8" @@ -2389,6 +2489,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "skeptic" version = "0.13.7" diff --git a/Cargo.toml b/Cargo.toml index 2c63ebf..435828c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,4 +21,10 @@ poise = "0.6.1" async-trait = "0.1" fundu = "2.0.1" anyhow = "1.0.99" -thiserror = "2.0.16" \ No newline at end of file +thiserror = "2.0.16" +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.8" +rand = "0.8" +regex = "1.10" +url = "2.5" +compile-time = "0.2.0" diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..7ebf422 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,24 @@ +//! Application state shared across components (bot, web, scheduler). + +use crate::banner::BannerApi; +use redis::Client; + +#[derive(Clone)] +pub struct AppState { + pub banner_api: std::sync::Arc, + pub redis_client: std::sync::Arc, +} + +impl AppState { + pub fn new( + banner_api: BannerApi, + redis_url: &str, + ) -> Result> { + let redis_client = Client::open(redis_url)?; + + Ok(Self { + banner_api: std::sync::Arc::new(banner_api), + redis_client: std::sync::Arc::new(redis_client), + }) + } +} diff --git a/src/banner/api.rs b/src/banner/api.rs new file mode 100644 index 0000000..2daffc9 --- /dev/null +++ b/src/banner/api.rs @@ -0,0 +1,320 @@ +//! Main Banner API client implementation. + +use crate::banner::{SessionManager, models::*, query::SearchQuery}; +use anyhow::{Context, Result}; +use reqwest::Client; +use serde_json; + +// use tracing::debug; + +/// Main Banner API client. +#[derive(Debug)] +pub struct BannerApi { + session_manager: SessionManager, + client: Client, + base_url: String, +} + +impl BannerApi { + /// Creates a new Banner API client. + pub fn new(base_url: String) -> Result { + let client = Client::builder() + .cookie_store(true) + .user_agent(user_agent()) + .tcp_keepalive(Some(std::time::Duration::from_secs(60 * 5))) + .read_timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + let session_manager = SessionManager::new(base_url.clone(), client.clone()); + + Ok(Self { + session_manager, + client, + base_url, + }) + } + + /// Sets up the API client by initializing session cookies. + pub async fn setup(&self) -> Result<()> { + self.session_manager.setup().await + } + + /// Retrieves a list of terms from the Banner API. + pub async fn get_terms( + &self, + search: &str, + page: i32, + max_results: i32, + ) -> Result> { + if page <= 0 { + return Err(anyhow::anyhow!("Page must be greater than 0")); + } + + let url = format!("{}/classSearch/getTerms", self.base_url); + let params = [ + ("searchTerm", search), + ("offset", &page.to_string()), + ("max", &max_results.to_string()), + ("_", ×tamp_nonce()), + ]; + + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to get terms")?; + + let terms: Vec = response + .json() + .await + .context("Failed to parse terms response")?; + + Ok(terms) + } + + /// Retrieves a list of subjects from the Banner API. + pub async fn get_subjects( + &self, + search: &str, + term: &str, + offset: i32, + max_results: i32, + ) -> Result> { + if offset <= 0 { + return Err(anyhow::anyhow!("Offset must be greater than 0")); + } + + let session_id = self.session_manager.ensure_session()?; + let url = format!("{}/classSearch/get_subject", self.base_url); + let params = [ + ("searchTerm", search), + ("term", term), + ("offset", &offset.to_string()), + ("max", &max_results.to_string()), + ("uniqueSessionId", &session_id), + ("_", ×tamp_nonce()), + ]; + + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to get subjects")?; + + let subjects: Vec = response + .json() + .await + .context("Failed to parse subjects response")?; + + Ok(subjects) + } + + /// Retrieves a list of instructors from the Banner API. + pub async fn get_instructors( + &self, + search: &str, + term: &str, + offset: i32, + max_results: i32, + ) -> Result> { + if offset <= 0 { + return Err(anyhow::anyhow!("Offset must be greater than 0")); + } + + let session_id = self.session_manager.ensure_session()?; + let url = format!("{}/classSearch/get_instructor", self.base_url); + let params = [ + ("searchTerm", search), + ("term", term), + ("offset", &offset.to_string()), + ("max", &max_results.to_string()), + ("uniqueSessionId", &session_id), + ("_", ×tamp_nonce()), + ]; + + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to get instructors")?; + + let instructors: Vec = response + .json() + .await + .context("Failed to parse instructors response")?; + + Ok(instructors) + } + + /// Retrieves a list of campuses from the Banner API. + pub async fn get_campuses( + &self, + search: &str, + term: i32, + offset: i32, + max_results: i32, + ) -> Result> { + if offset <= 0 { + return Err(anyhow::anyhow!("Offset must be greater than 0")); + } + + let session_id = self.session_manager.ensure_session()?; + let url = format!("{}/classSearch/get_campus", self.base_url); + let params = [ + ("searchTerm", search), + ("term", &term.to_string()), + ("offset", &offset.to_string()), + ("max", &max_results.to_string()), + ("uniqueSessionId", &session_id), + ("_", ×tamp_nonce()), + ]; + + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to get campuses")?; + + let campuses: Vec = response + .json() + .await + .context("Failed to parse campuses response")?; + + Ok(campuses) + } + + /// Retrieves meeting time information for a course. + pub async fn get_course_meeting_time( + &self, + term: i32, + crn: i32, + ) -> Result> { + let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url); + let params = [ + ("term", &term.to_string()), + ("courseReferenceNumber", &crn.to_string()), + ]; + + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to get meeting times")?; + + #[derive(serde::Deserialize)] + struct ResponseWrapper { + fmt: Vec, + } + + let wrapper: ResponseWrapper = response + .json() + .await + .context("Failed to parse meeting times response")?; + + Ok(wrapper.fmt) + } + + /// Performs a search for courses. + pub async fn search( + &self, + term: &str, + query: &SearchQuery, + sort: &str, + sort_descending: bool, + ) -> Result { + self.session_manager.reset_data_form().await?; + + let session_id = self.session_manager.ensure_session()?; + let mut params = query.to_params(); + + // Add additional parameters + params.insert("txt_term".to_string(), term.to_string()); + params.insert("uniqueSessionId".to_string(), session_id); + params.insert("sortColumn".to_string(), sort.to_string()); + params.insert( + "sortDirection".to_string(), + if sort_descending { "desc" } else { "asc" }.to_string(), + ); + params.insert("startDatepicker".to_string(), String::new()); + params.insert("endDatepicker".to_string(), String::new()); + + let url = format!("{}/searchResults/searchResults", self.base_url); + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .context("Failed to search courses")?; + + let search_result: SearchResult = response + .json() + .await + .context("Failed to parse search response")?; + + if !search_result.success { + return Err(anyhow::anyhow!( + "Search marked as unsuccessful by Banner API" + )); + } + + Ok(search_result) + } + + /// Selects a term for the current session. + pub async fn select_term(&self, term: &str) -> Result<()> { + self.session_manager.select_term(term).await + } + + /// Gets course details (placeholder - needs implementation). + pub async fn get_course_details(&self, term: i32, crn: i32) -> Result { + let body = serde_json::json!({ + "term": term.to_string(), + "courseReferenceNumber": crn.to_string(), + "first": "first" + }); + + let url = format!("{}/searchResults/getClassDetails", self.base_url); + let response = self + .client + .post(&url) + .json(&body) + .send() + .await + .context("Failed to get course details")?; + + let details: ClassDetails = response + .json() + .await + .context("Failed to parse course details response")?; + + Ok(details) + } +} + +/// Generates a timestamp-based nonce. +fn timestamp_nonce() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .to_string() +} + +/// Returns a browser-like user agent string. +fn user_agent() -> &'static str { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" +} diff --git a/src/banner/mod.rs b/src/banner/mod.rs new file mode 100644 index 0000000..16bf44e --- /dev/null +++ b/src/banner/mod.rs @@ -0,0 +1,19 @@ +//! Banner API module for interacting with Ellucian Banner systems. +//! +//! This module provides functionality to: +//! - Search for courses and retrieve course information +//! - Manage Banner API sessions and authentication +//! - Scrape course data and cache it in Redis +//! - Generate ICS files and calendar links + +pub mod api; +pub mod models; +pub mod query; +pub mod scraper; +pub mod session; + +pub use api::BannerApi; +pub use models::*; +pub use query::SearchQuery; +pub use scraper::CourseScraper; +pub use session::SessionManager; diff --git a/src/banner/models/common.rs b/src/banner/models/common.rs new file mode 100644 index 0000000..a3f3a69 --- /dev/null +++ b/src/banner/models/common.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +/// Represents a key-value pair from the Banner API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pair { + pub code: String, + pub description: String, +} + +/// Represents a term in the Banner system +pub type BannerTerm = Pair; + +/// Represents an instructor in the Banner system +pub type Instructor = Pair; + +impl BannerTerm { + /// Returns true if the term is in an archival (view-only) state + pub fn is_archived(&self) -> bool { + self.description.contains("View Only") + } +} diff --git a/src/banner/models/courses.rs b/src/banner/models/courses.rs new file mode 100644 index 0000000..0fac585 --- /dev/null +++ b/src/banner/models/courses.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +use super::meetings::FacultyItem; +use super::meetings::MeetingTimeResponse; + +/// Course section attribute +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SectionAttribute { + pub class: String, + pub course_reference_number: String, + pub code: String, + pub description: String, + pub term_code: String, + #[serde(rename = "isZTCAttribute")] + pub is_ztc_attribute: bool, +} + +/// Represents a single course returned from a search +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Course { + pub id: i32, + pub term: String, + pub term_desc: String, + pub course_reference_number: String, + pub part_of_term: String, + pub course_number: String, + pub subject: String, + pub subject_description: String, + pub sequence_number: String, + pub campus_description: String, + pub schedule_type_description: String, + pub course_title: String, + pub credit_hours: i32, + pub maximum_enrollment: i32, + pub enrollment: i32, + pub seats_available: i32, + pub wait_capacity: i32, + pub wait_count: i32, + pub cross_list: Option, + pub cross_list_capacity: Option, + pub cross_list_count: Option, + pub cross_list_available: Option, + pub credit_hour_high: Option, + pub credit_hour_low: Option, + pub credit_hour_indicator: Option, + pub open_section: bool, + pub link_identifier: Option, + pub is_section_linked: bool, + pub subject_course: String, + pub reserved_seat_summary: Option, + pub instructional_method: String, + pub instructional_method_description: String, + pub section_attributes: Vec, + pub faculty: Vec, + pub meetings_faculty: Vec, +} + +/// Class details (to be implemented) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassDetails { + // TODO: Implement based on Banner API response +} diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs new file mode 100644 index 0000000..0d676da --- /dev/null +++ b/src/banner/models/meetings.rs @@ -0,0 +1,194 @@ +use chrono::{NaiveDate, NaiveTime, Timelike}; +use serde::{Deserialize, Serialize}; + +use super::terms::Term; + +/// Represents a faculty member associated with a course. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FacultyItem { + pub banner_id: u32, // e.g. 150161 + pub category: Option, // zero-padded digits + pub class: String, // internal class name + pub course_reference_number: u32, // CRN, e.g. 27294 + pub display_name: String, // "LastName, FirstName" + pub email_address: String, // e.g. FirstName.LastName@utsa.edu + pub primary_indicator: bool, + pub term: Term, +} + +/// Meeting time information for a course. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeetingTime { + pub start_date: String, // MM/DD/YYYY, e.g. 08/26/2025 + pub end_date: String, // MM/DD/YYYY, e.g. 08/26/2025 + pub begin_time: String, // HHMM, e.g. 1000 + pub end_time: String, // HHMM, e.g. 1100 + pub category: String, // unknown meaning, e.g. 01, 02, etc. + pub class: String, // internal class name, e.g. net.hedtech.banner.general.overall.MeetingTimeDecorator + pub monday: bool, // true if the meeting time occurs on Monday + pub tuesday: bool, // true if the meeting time occurs on Tuesday + pub wednesday: bool, // true if the meeting time occurs on Wednesday + pub thursday: bool, // true if the meeting time occurs on Thursday + pub friday: bool, // true if the meeting time occurs on Friday + pub saturday: bool, // true if the meeting time occurs on Saturday + pub sunday: bool, // true if the meeting time occurs on Sunday + pub room: String, // e.g. 1.238 + pub term: Term, // e.g. 202510 + pub building: String, // e.g. NPB + pub building_description: String, // e.g. North Paseo Building + pub campus: String, // campus code, e.g. 11 + pub campus_description: String, // name of campus, e.g. Main Campus + pub course_reference_number: String, // CRN, e.g. 27294 + pub credit_hour_session: f64, // e.g. 3.0 + pub hours_week: f64, // e.g. 3.0 + pub meeting_schedule_type: String, // e.g. AFF + pub meeting_type: String, // e.g. HB, H2, H1, OS, OA, OH, ID, FF + pub meeting_type_description: String, +} + +/// API response wrapper for meeting times. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingTimesApiResponse { + pub fmt: Vec, +} + +/// Meeting time response wrapper. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeetingTimeResponse { + pub category: Option, + pub class: String, + pub course_reference_number: String, + pub faculty: Vec, + pub meeting_time: MeetingTime, + pub term: String, +} + +impl MeetingTimeResponse { + /// Returns a formatted string representation of the meeting time. + pub fn to_string(&self) -> String { + match self.meeting_time.meeting_type.as_str() { + "HB" | "H2" | "H1" => format!("{}\nHybrid {}", self.time_string(), self.place_string()), + "OS" => format!("{}\nOnline Only", self.time_string()), + "OA" => "No Time\nOnline Asynchronous".to_string(), + "OH" => format!("{}\nOnline Partial", self.time_string()), + "ID" => "To Be Arranged".to_string(), + "FF" => format!("{}\n{}", self.time_string(), self.place_string()), + _ => "Unknown".to_string(), + } + } + + /// Returns a formatted string of the meeting times. + pub fn time_string(&self) -> String { + let start_time = self.parse_time(&self.meeting_time.begin_time); + let end_time = self.parse_time(&self.meeting_time.end_time); + + match (start_time, end_time) { + (Some(start), Some(end)) => { + format!( + "{} {}-{}", + self.days_string(), + format_time(start), + format_time(end) + ) + } + _ => "???".to_string(), + } + } + + /// Returns a formatted string representing the location of the meeting. + pub fn place_string(&self) -> String { + if self.meeting_time.room.is_empty() { + "Online".to_string() + } else { + format!( + "{} | {} | {} {}", + self.meeting_time.campus_description, + self.meeting_time.building_description, + self.meeting_time.building, + self.meeting_time.room + ) + } + } + + /// Returns a compact string representation of meeting days. + pub fn days_string(&self) -> String { + let mut days = String::new(); + if self.meeting_time.monday { + days.push('M'); + } + if self.meeting_time.tuesday { + days.push_str("Tu"); + } + if self.meeting_time.wednesday { + days.push('W'); + } + if self.meeting_time.thursday { + days.push_str("Th"); + } + if self.meeting_time.friday { + days.push('F'); + } + if self.meeting_time.saturday { + days.push_str("Sa"); + } + if self.meeting_time.sunday { + days.push_str("Su"); + } + + if days.is_empty() { + "None".to_string() + } else if days.len() == 14 { + // All days + "Everyday".to_string() + } else { + days + } + } + + /// Parse a time string in HHMM format to NaiveTime. + fn parse_time(&self, time_str: &str) -> Option { + if time_str.is_empty() { + return None; + } + + let time_int: u32 = time_str.parse().ok()?; + let hours = time_int / 100; + let minutes = time_int % 100; + + NaiveTime::from_hms_opt(hours, minutes, 0) + } + + /// Parse a date string in MM/DD/YYYY format. + pub fn parse_date(date_str: &str) -> Option { + NaiveDate::parse_from_str(date_str, "%m/%d/%Y").ok() + } + + /// Get the start date as NaiveDate. + pub fn start_date(&self) -> Option { + Self::parse_date(&self.meeting_time.start_date) + } + + /// Get the end date as NaiveDate. + pub fn end_date(&self) -> Option { + Self::parse_date(&self.meeting_time.end_date) + } +} + +/// Format a NaiveTime in 12-hour format. +fn format_time(time: NaiveTime) -> String { + let hour = time.hour(); + let minute = time.minute(); + + if hour == 0 { + format!("12:{:02}AM", minute) + } else if hour < 12 { + format!("{}:{:02}AM", hour, minute) + } else if hour == 12 { + format!("12:{:02}PM", minute) + } else { + format!("{}:{:02}PM", hour - 12, minute) + } +} diff --git a/src/banner/models/mod.rs b/src/banner/models/mod.rs new file mode 100644 index 0000000..dbc8323 --- /dev/null +++ b/src/banner/models/mod.rs @@ -0,0 +1,14 @@ +//! Data models for the Banner API. + +pub mod common; +pub mod courses; +pub mod meetings; +pub mod search; +pub mod terms; + +// Re-export commonly used types +pub use common::*; +pub use courses::*; +pub use meetings::*; +pub use search::*; +pub use terms::*; diff --git a/src/banner/models/search.rs b/src/banner/models/search.rs new file mode 100644 index 0000000..935a0dd --- /dev/null +++ b/src/banner/models/search.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +use super::courses::Course; + +/// Search result wrapper +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + pub success: bool, + pub total_count: i32, + pub page_offset: i32, + pub page_max_size: i32, + pub path_mode: String, + pub search_results_config: Vec, + pub data: Vec, +} + +/// Search result configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResultConfig { + pub config: String, + pub display: String, +} diff --git a/src/banner/models/terms.rs b/src/banner/models/terms.rs new file mode 100644 index 0000000..c4fe60b --- /dev/null +++ b/src/banner/models/terms.rs @@ -0,0 +1,76 @@ +use std::{ops::RangeInclusive, str::FromStr}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +/// Represents a term in the Banner system +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Term { + pub year: u32, // 2024, 2025, etc + pub season: Season, +} + +/// Represents a season within a term +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Season { + Fall, + Spring, + Summer, +} + +impl Term { + pub fn to_string(&self) -> String { + format!("{}{}", self.year, self.season.to_string()) + } +} + +impl Season { + pub fn to_string(&self) -> String { + (match self { + Season::Fall => "10", + Season::Spring => "20", + Season::Summer => "30", + }) + .to_string() + } + + pub fn from_string(s: &str) -> Option { + match s { + "10" => Some(Season::Fall), + "20" => Some(Season::Spring), + "30" => Some(Season::Summer), + _ => None, + } + } +} + +const CURRENT_YEAR: u32 = compile_time::date!().year() as u32; +const VALID_YEARS: RangeInclusive = 2007..=(CURRENT_YEAR + 10); + +impl FromStr for Term { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.len() != 6 { + return Err(anyhow::anyhow!("Term string must be 6 characters")); + } + + let year = s[0..4].parse::().context("Failed to parse year")?; + if !VALID_YEARS.contains(&year) { + return Err(anyhow::anyhow!("Year out of range")); + } + + let season = + Season::from_string(&s[4..6]).ok_or_else(|| anyhow::anyhow!("Invalid season code"))?; + + Ok(Term { year, season }) + } +} + +impl FromStr for Season { + type Err = (); + + fn from_str(s: &str) -> Result { + Self::from_string(s).ok_or(()) + } +} diff --git a/src/banner/query.rs b/src/banner/query.rs new file mode 100644 index 0000000..bd8fa4a --- /dev/null +++ b/src/banner/query.rs @@ -0,0 +1,313 @@ +//! Query builder for Banner API course searches. + +use std::collections::HashMap; +use std::time::Duration; + +/// Range of two integers +#[derive(Debug, Clone)] +pub struct Range { + pub low: i32, + pub high: i32, +} + +/// Builder for constructing Banner API search queries +#[derive(Debug, Clone, Default)] +pub struct SearchQuery { + subject: Option, + title: Option, + keywords: Option>, + open_only: Option, + term_part: Option>, + campus: Option>, + instructional_method: Option>, + attributes: Option>, + instructor: Option>, + start_time: Option, + end_time: Option, + min_credits: Option, + max_credits: Option, + offset: i32, + max_results: i32, + course_number_range: Option, +} + +impl SearchQuery { + /// Creates a new SearchQuery with default values + pub fn new() -> Self { + Self { + max_results: 8, + offset: 0, + ..Default::default() + } + } + + /// Sets the subject for the query + pub fn subject>(mut self, subject: S) -> Self { + self.subject = Some(subject.into()); + self + } + + /// Sets the title for the query + pub fn title>(mut self, title: S) -> Self { + self.title = Some(title.into()); + self + } + + /// Sets the keywords for the query + pub fn keywords(mut self, keywords: Vec) -> Self { + self.keywords = Some(keywords); + self + } + + /// Adds a keyword to the query + pub fn keyword>(mut self, keyword: S) -> Self { + match &mut self.keywords { + Some(keywords) => keywords.push(keyword.into()), + None => self.keywords = Some(vec![keyword.into()]), + } + self + } + + /// Sets whether to search for open courses only + pub fn open_only(mut self, open_only: bool) -> Self { + self.open_only = Some(open_only); + self + } + + /// Sets the term part for the query + pub fn term_part(mut self, term_part: Vec) -> Self { + self.term_part = Some(term_part); + self + } + + /// Sets the campuses for the query + pub fn campus(mut self, campus: Vec) -> Self { + self.campus = Some(campus); + self + } + + /// Sets the instructional methods for the query + pub fn instructional_method(mut self, instructional_method: Vec) -> Self { + self.instructional_method = Some(instructional_method); + self + } + + /// Sets the attributes for the query + pub fn attributes(mut self, attributes: Vec) -> Self { + self.attributes = Some(attributes); + self + } + + /// Sets the instructors for the query + pub fn instructor(mut self, instructor: Vec) -> Self { + self.instructor = Some(instructor); + self + } + + /// Sets the start time for the query + pub fn start_time(mut self, start_time: Duration) -> Self { + self.start_time = Some(start_time); + self + } + + /// Sets the end time for the query + pub fn end_time(mut self, end_time: Duration) -> Self { + self.end_time = Some(end_time); + self + } + + /// Sets the credit range for the query + pub fn credits(mut self, low: i32, high: i32) -> Self { + self.min_credits = Some(low); + self.max_credits = Some(high); + self + } + + /// Sets the minimum credits for the query + pub fn min_credits(mut self, value: i32) -> Self { + self.min_credits = Some(value); + self + } + + /// Sets the maximum credits for the query + pub fn max_credits(mut self, value: i32) -> Self { + self.max_credits = Some(value); + self + } + + /// Sets the course number range for the query + pub fn course_numbers(mut self, low: i32, high: i32) -> Self { + self.course_number_range = Some(Range { low, high }); + self + } + + /// Sets the offset for pagination + pub fn offset(mut self, offset: i32) -> Self { + self.offset = offset; + self + } + + /// Sets the maximum number of results to return + pub fn max_results(mut self, max_results: i32) -> Self { + self.max_results = max_results; + self + } + + /// Converts the query into URL parameters for the Banner API + pub fn to_params(&self) -> HashMap { + let mut params = HashMap::new(); + + if let Some(ref subject) = self.subject { + params.insert("txt_subject".to_string(), subject.clone()); + } + + if let Some(ref title) = self.title { + params.insert("txt_courseTitle".to_string(), title.trim().to_string()); + } + + if let Some(ref keywords) = self.keywords { + params.insert("txt_keywordlike".to_string(), keywords.join(" ")); + } + + if self.open_only.is_some() { + params.insert("chk_open_only".to_string(), "true".to_string()); + } + + if let Some(ref term_part) = self.term_part { + params.insert("txt_partOfTerm".to_string(), term_part.join(",")); + } + + if let Some(ref campus) = self.campus { + params.insert("txt_campus".to_string(), campus.join(",")); + } + + if let Some(ref attributes) = self.attributes { + params.insert("txt_attribute".to_string(), attributes.join(",")); + } + + if let Some(ref instructor) = self.instructor { + let instructor_str = instructor + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(","); + params.insert("txt_instructor".to_string(), instructor_str); + } + + if let Some(start_time) = self.start_time { + let (hour, minute, meridiem) = format_time_parameter(start_time); + params.insert("select_start_hour".to_string(), hour); + params.insert("select_start_min".to_string(), minute); + params.insert("select_start_ampm".to_string(), meridiem); + } + + if let Some(end_time) = self.end_time { + let (hour, minute, meridiem) = format_time_parameter(end_time); + params.insert("select_end_hour".to_string(), hour); + params.insert("select_end_min".to_string(), minute); + params.insert("select_end_ampm".to_string(), meridiem); + } + + if let Some(min_credits) = self.min_credits { + params.insert("txt_credithourlow".to_string(), min_credits.to_string()); + } + + if let Some(max_credits) = self.max_credits { + params.insert("txt_credithourhigh".to_string(), max_credits.to_string()); + } + + if let Some(ref range) = self.course_number_range { + params.insert("txt_course_number_range".to_string(), range.low.to_string()); + params.insert( + "txt_course_number_range_to".to_string(), + range.high.to_string(), + ); + } + + params.insert("pageOffset".to_string(), self.offset.to_string()); + params.insert("pageMaxSize".to_string(), self.max_results.to_string()); + + params + } +} + +/// Formats a Duration into hour, minute, and meridiem strings for Banner API +fn format_time_parameter(duration: Duration) -> (String, String, String) { + let total_minutes = duration.as_secs() / 60; + let hours = total_minutes / 60; + let minutes = total_minutes % 60; + + let minute_str = minutes.to_string(); + + if hours >= 12 { + let meridiem = "PM".to_string(); + let hour_str = if hours >= 13 { + (hours - 12).to_string() + } else { + hours.to_string() + }; + (hour_str, minute_str, meridiem) + } else { + let meridiem = "AM".to_string(); + let hour_str = hours.to_string(); + (hour_str, minute_str, meridiem) + } +} + +impl std::fmt::Display for SearchQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut parts = Vec::new(); + + if let Some(ref subject) = self.subject { + parts.push(format!("subject={}", subject)); + } + if let Some(ref title) = self.title { + parts.push(format!("title={}", title.trim())); + } + if let Some(ref keywords) = self.keywords { + parts.push(format!("keywords={}", keywords.join(" "))); + } + if self.open_only.is_some() { + parts.push("openOnly=true".to_string()); + } + if let Some(ref term_part) = self.term_part { + parts.push(format!("termPart={}", term_part.join(","))); + } + if let Some(ref campus) = self.campus { + parts.push(format!("campus={}", campus.join(","))); + } + if let Some(ref attributes) = self.attributes { + parts.push(format!("attributes={}", attributes.join(","))); + } + if let Some(ref instructor) = self.instructor { + let instructor_str = instructor + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(","); + parts.push(format!("instructor={}", instructor_str)); + } + if let Some(start_time) = self.start_time { + let (hour, minute, meridiem) = format_time_parameter(start_time); + parts.push(format!("startTime={}:{}:{}", hour, minute, meridiem)); + } + if let Some(end_time) = self.end_time { + let (hour, minute, meridiem) = format_time_parameter(end_time); + parts.push(format!("endTime={}:{}:{}", hour, minute, meridiem)); + } + if let Some(min_credits) = self.min_credits { + parts.push(format!("minCredits={}", min_credits)); + } + if let Some(max_credits) = self.max_credits { + parts.push(format!("maxCredits={}", max_credits)); + } + if let Some(ref range) = self.course_number_range { + parts.push(format!("courseNumberRange={}-{}", range.low, range.high)); + } + + parts.push(format!("offset={}", self.offset)); + parts.push(format!("maxResults={}", self.max_results)); + + write!(f, "{}", parts.join(", ")) + } +} diff --git a/src/banner/scraper.rs b/src/banner/scraper.rs new file mode 100644 index 0000000..f7659d5 --- /dev/null +++ b/src/banner/scraper.rs @@ -0,0 +1,290 @@ +//! Course scraping functionality for the Banner API. + +use crate::banner::{api::BannerApi, models::*, query::SearchQuery}; +use anyhow::{Context, Result}; +use redis::AsyncCommands; +use std::time::Duration; +use tokio::time; +use tracing::{debug, error, info, warn}; + +/// Priority majors that should be scraped more frequently +const PRIORITY_MAJORS: &[&str] = &["CS", "CPE", "MAT", "EE", "IS"]; + +/// Maximum number of courses to fetch per page +const MAX_PAGE_SIZE: i32 = 500; + +/// Course scraper for Banner API +pub struct CourseScraper { + api: BannerApi, + redis_client: redis::Client, +} + +impl CourseScraper { + /// Creates a new course scraper + pub fn new(api: BannerApi, redis_url: &str) -> Result { + let redis_client = + redis::Client::open(redis_url).context("Failed to create Redis client")?; + + Ok(Self { api, redis_client }) + } + + /// Scrapes all courses and stores them in Redis + pub async fn scrape_all(&self, term: &str) -> Result<()> { + // Get all subjects + let subjects = self + .api + .get_subjects("", term, 1, 100) + .await + .context("Failed to get subjects for scraping")?; + + if subjects.is_empty() { + return Err(anyhow::anyhow!("No subjects found for term {}", term)); + } + + // Categorize subjects + let (priority_subjects, other_subjects): (Vec<_>, Vec<_>) = subjects + .into_iter() + .partition(|subject| PRIORITY_MAJORS.contains(&subject.code.as_str())); + + // Get expired subjects that need scraping + let mut expired_subjects = Vec::new(); + expired_subjects.extend(self.get_expired_subjects(&priority_subjects, term).await?); + expired_subjects.extend(self.get_expired_subjects(&other_subjects, term).await?); + + if expired_subjects.is_empty() { + info!("No expired subjects found, skipping scrape"); + return Ok(()); + } + + info!( + "Scraping {} subjects for term {}", + expired_subjects.len(), + term + ); + + // Scrape each expired subject + for subject in expired_subjects { + if let Err(e) = self.scrape_subject(&subject.code, term).await { + error!("Failed to scrape subject {}: {}", subject.code, e); + } + + // Rate limiting between subjects + time::sleep(Duration::from_secs(2)).await; + } + + Ok(()) + } + + /// Gets subjects that have expired and need to be scraped + async fn get_expired_subjects(&self, subjects: &[Pair], term: &str) -> Result> { + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await + .context("Failed to get Redis connection")?; + + let mut expired = Vec::new(); + + for subject in subjects { + let key = format!("scraped:{}:{}", subject.code, term); + let scraped: Option = conn + .get(&key) + .await + .context("Failed to check scrape status in Redis")?; + + // If not scraped or marked as expired (empty/0), add to list + if scraped.is_none() || scraped.as_deref() == Some("0") { + expired.push(subject.clone()); + } + } + + Ok(expired) + } + + /// Scrapes all courses for a specific subject + pub async fn scrape_subject(&self, subject: &str, term: &str) -> Result<()> { + let mut offset = 0; + let mut total_courses = 0; + + loop { + let query = SearchQuery::new() + .subject(subject) + .offset(offset) + .max_results(MAX_PAGE_SIZE * 2); + + let result = self + .api + .search(term, &query, "subjectDescription", false) + .await + .with_context(|| { + format!( + "Failed to search for subject {} at offset {}", + subject, offset + ) + })?; + + if !result.success { + return Err(anyhow::anyhow!( + "Search marked unsuccessful for subject {}", + subject + )); + } + + let course_count = result.data.len() as i32; + total_courses += course_count; + + debug!( + "Retrieved {} courses for subject {} at offset {}", + course_count, subject, offset + ); + + // Store each course in Redis + for course in result.data { + if let Err(e) = self.store_course(&course).await { + error!( + "Failed to store course {}: {}", + course.course_reference_number, e + ); + } + } + + // Check if we got a full page and should continue + if course_count >= MAX_PAGE_SIZE { + if course_count > MAX_PAGE_SIZE { + warn!( + "Course count {} exceeds max page size {}", + course_count, MAX_PAGE_SIZE + ); + } + + offset += MAX_PAGE_SIZE; + debug!( + "Continuing to next page for subject {} at offset {}", + subject, offset + ); + + // Rate limiting between pages + time::sleep(Duration::from_secs(3)).await; + continue; + } + + break; + } + + info!( + "Scraped {} total courses for subject {}", + total_courses, subject + ); + + // Mark subject as scraped with expiry + self.mark_subject_scraped(subject, term, total_courses) + .await?; + + Ok(()) + } + + /// Stores a course in Redis + async fn store_course(&self, course: &Course) -> Result<()> { + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await + .context("Failed to get Redis connection")?; + + let key = format!("class:{}", course.course_reference_number); + let serialized = serde_json::to_string(course).context("Failed to serialize course")?; + + let _: () = conn + .set(&key, serialized) + .await + .context("Failed to store course in Redis")?; + + Ok(()) + } + + /// Marks a subject as scraped with appropriate expiry time + async fn mark_subject_scraped( + &self, + subject: &str, + term: &str, + course_count: i32, + ) -> Result<()> { + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await + .context("Failed to get Redis connection")?; + + let key = format!("scraped:{}:{}", subject, term); + let expiry = self.calculate_expiry(subject, course_count); + + let value = if course_count == 0 { -1 } else { course_count }; + + let _: () = conn + .set_ex(&key, value, expiry.as_secs() as u64) + .await + .context("Failed to mark subject as scraped")?; + + debug!( + "Marked subject {} as scraped with {} courses, expiry: {:?}", + subject, course_count, expiry + ); + + Ok(()) + } + + /// Calculates expiry time for a scraped subject based on various factors + fn calculate_expiry(&self, subject: &str, course_count: i32) -> Duration { + // Base calculation: 1 hour per 100 courses + let mut base_expiry = Duration::from_secs(3600 * (course_count as u64 / 100).max(1)); + + // Special handling for subjects with few courses + if course_count < 50 { + // Linear interpolation: 1 course = 12 hours, 49 courses = 1 hour + let hours = 12.0 - ((course_count as f64 - 1.0) / 48.0) * 11.0; + base_expiry = Duration::from_secs((hours * 3600.0) as u64); + } + + // Priority subjects get shorter expiry (more frequent updates) + if PRIORITY_MAJORS.contains(&subject) { + base_expiry = base_expiry / 3; + } + + // Add random variance (±15%) + let variance = (base_expiry.as_secs() as f64 * 0.15) as u64; + let random_offset = (rand::random::() - 0.5) * 2.0 * variance as f64; + + let final_expiry = if random_offset > 0.0 { + base_expiry + Duration::from_secs(random_offset as u64) + } else { + base_expiry.saturating_sub(Duration::from_secs((-random_offset) as u64)) + }; + + // Ensure minimum of 1 hour + final_expiry.max(Duration::from_secs(3600)) + } + + /// Gets a course from Redis cache + pub async fn get_course(&self, crn: &str) -> Result> { + let mut conn = self + .redis_client + .get_multiplexed_async_connection() + .await + .context("Failed to get Redis connection")?; + + let key = format!("class:{}", crn); + let serialized: Option = conn + .get(&key) + .await + .context("Failed to get course from Redis")?; + + match serialized { + Some(data) => { + let course: Course = serde_json::from_str(&data) + .context("Failed to deserialize course from Redis")?; + Ok(Some(course)) + } + None => Ok(None), + } + } +} diff --git a/src/banner/session.rs b/src/banner/session.rs new file mode 100644 index 0000000..999f901 --- /dev/null +++ b/src/banner/session.rs @@ -0,0 +1,190 @@ +//! Session management for Banner API. + +use anyhow::Result; +use rand::distributions::{Alphanumeric, DistString}; +use reqwest::Client; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tracing::{debug, info}; + +/// Session manager for Banner API interactions +#[derive(Debug)] +pub struct SessionManager { + current_session: Mutex>, + base_url: String, + client: Client, +} + +#[derive(Debug, Clone)] +struct SessionData { + session_id: String, + created_at: Instant, +} + +impl SessionManager { + const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes + + /// Creates a new session manager + pub fn new(base_url: String, client: Client) -> Self { + Self { + current_session: Mutex::new(None), + base_url, + client, + } + } + + /// Ensures a valid session is available, creating one if necessary + pub fn ensure_session(&self) -> Result { + let mut session_guard = self.current_session.lock().unwrap(); + + if let Some(ref session) = *session_guard { + if session.created_at.elapsed() < Self::SESSION_EXPIRY { + return Ok(session.session_id.clone()); + } + } + + // Generate new session + let session_id = self.generate_session_id(); + *session_guard = Some(SessionData { + session_id: session_id.clone(), + created_at: Instant::now(), + }); + + debug!("Generated new Banner session: {}", session_id); + Ok(session_id) + } + + /// Generates a new session ID mimicking Banner's format + fn generate_session_id(&self) -> String { + let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), 5); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}{}", random_part, timestamp) + } + + /// Sets up initial session cookies by making required Banner API requests + pub async fn setup(&self) -> Result<()> { + info!("Setting up Banner session..."); + + let request_paths = ["/registration/registration", "/selfServiceMenu/data"]; + + for path in &request_paths { + let url = format!("{}{}", self.base_url, path); + let response = self + .client + .get(&url) + .query(&[("_", timestamp_nonce())]) + .header("User-Agent", user_agent()) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to setup session, request to {} returned {}", + path, + response.status() + )); + } + } + + // Note: Cookie validation would require additional setup in a real implementation + debug!("Session setup complete"); + Ok(()) + } + + /// Selects a term for the current session + pub async fn select_term(&self, term: &str) -> Result<()> { + let session_id = self.ensure_session()?; + + let form_data = [ + ("term", term), + ("studyPath", ""), + ("studyPathText", ""), + ("startDatepicker", ""), + ("endDatepicker", ""), + ("uniqueSessionId", &session_id), + ]; + + let url = format!("{}/term/search", self.base_url); + let response = self + .client + .post(&url) + .query(&[("mode", "search")]) + .form(&form_data) + .header("User-Agent", user_agent()) + .header("Content-Type", "application/x-www-form-urlencoded") + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to select term {}: {}", + term, + response.status() + )); + } + + #[derive(serde::Deserialize)] + struct RedirectResponse { + #[serde(rename = "fwdUrl")] + fwd_url: String, + } + + let redirect: RedirectResponse = response.json().await?; + + // Follow the redirect + let redirect_url = format!("{}{}", self.base_url, redirect.fwd_url); + let redirect_response = self + .client + .get(&redirect_url) + .header("User-Agent", user_agent()) + .send() + .await?; + + if !redirect_response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to follow redirect: {}", + redirect_response.status() + )); + } + + debug!("Successfully selected term: {}", term); + Ok(()) + } + + /// Resets the data form (required before new searches) + pub async fn reset_data_form(&self) -> Result<()> { + let url = format!("{}/classSearch/resetDataForm", self.base_url); + let response = self + .client + .post(&url) + .header("User-Agent", user_agent()) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to reset data form: {}", + response.status() + )); + } + + Ok(()) + } +} + +/// Generates a timestamp-based nonce +fn timestamp_nonce() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .to_string() +} + +/// Returns a browser-like user agent string +fn user_agent() -> &'static str { + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" +} diff --git a/src/bot/commands/gcal.rs b/src/bot/commands/gcal.rs new file mode 100644 index 0000000..a36c7ff --- /dev/null +++ b/src/bot/commands/gcal.rs @@ -0,0 +1,323 @@ +//! Google Calendar command implementation. + +use crate::banner::{Course, MeetingTime, MeetingTimeResponse}; +use crate::bot::{Context, Error}; +use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; +use std::collections::HashMap; +use tracing::{error, info}; +use url::Url; + +/// Generate a link to create a Google Calendar event for a course +#[poise::command(slash_command, prefix_command)] +pub async fn gcal( + ctx: Context<'_>, + #[description = "Course Reference Number (CRN)"] crn: i32, +) -> Result<(), Error> { + let user = ctx.author(); + info!(source = user.name, target = crn, "gcal command invoked"); + + ctx.defer().await?; + + let app_state = &ctx.data().app_state; + let banner_api = &app_state.banner_api; + + // TODO: Get current term dynamically + let term = 202610; // Hardcoded for now + + // TODO: Replace with actual course data when BannerApi::get_course is implemented + let course = Course { + id: 0, + term: term.to_string(), + term_desc: "Fall 2026".to_string(), + course_reference_number: crn.to_string(), + part_of_term: "1".to_string(), + course_number: "0000".to_string(), + subject: "CS".to_string(), + subject_description: "Computer Science".to_string(), + sequence_number: "001".to_string(), + campus_description: "Main Campus".to_string(), + schedule_type_description: "Lecture".to_string(), + course_title: "Example Course".to_string(), + credit_hours: 3, + maximum_enrollment: 30, + enrollment: 25, + seats_available: 5, + wait_capacity: 10, + wait_count: 0, + cross_list: None, + cross_list_capacity: None, + cross_list_count: None, + cross_list_available: None, + credit_hour_high: None, + credit_hour_low: None, + credit_hour_indicator: None, + open_section: true, + link_identifier: None, + is_section_linked: false, + subject_course: "CS0000".to_string(), + reserved_seat_summary: None, + instructional_method: "FF".to_string(), + instructional_method_description: "Face to Face".to_string(), + section_attributes: vec![], + faculty: vec![], + meetings_faculty: vec![], + }; + + // Get meeting times + let meeting_times = banner_api.get_course_meeting_time(term, crn).await?; + + if meeting_times.is_empty() { + ctx.say("No meeting times found for this course").await?; + return Ok(()); + } + + // Find a meeting time that actually meets (not ID or OA types) + let meeting_time = meeting_times + .iter() + .find(|mt| !matches!(mt.meeting_time.meeting_type.as_str(), "ID" | "OA")) + .ok_or("Course does not meet at a defined moment in time")?; + + // Generate the Google Calendar URL + match generate_gcal_url(&course, meeting_time) { + Ok(calendar_url) => { + ctx.say(format!("[Add to Google Calendar](<{}>)", calendar_url)) + .await?; + } + Err(e) => { + error!("Failed to generate Google Calendar URL: {}", e); + ctx.say(format!("Error generating calendar link: {}", e)) + .await?; + } + } + + info!("gcal command completed for CRN: {}", crn); + Ok(()) +} + +/// Generate Google Calendar URL for a course +fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Result { + // Get start and end dates + let start_date = meeting_time + .start_date() + .ok_or("Could not parse start date")?; + let end_date = meeting_time.end_date().ok_or("Could not parse end date")?; + + // Get start and end times - parse from the time string + let time_str = meeting_time.time_string(); + let time_parts: Vec<&str> = time_str.split(' ').collect(); + + if time_parts.len() < 2 { + return Err(format!( + "Invalid time format: expected at least 2 parts, got {} parts. Time string: '{}'", + time_parts.len(), + time_str + ) + .into()); + } + + let time_range = time_parts[1]; + let times: Vec<&str> = time_range.split('-').collect(); + + if times.len() != 2 { + return Err(format!( + "Invalid time range format: expected 2 parts, got {} parts. Time range: '{}'", + times.len(), + time_range + ) + .into()); + } + + // Create timestamps in UTC (assuming Central time) + let central_tz = chrono_tz::US::Central; + + let dt_start = central_tz + .with_ymd_and_hms( + start_date.year(), + start_date.month(), + start_date.day(), + start_time.hour(), + start_time.minute(), + 0, + ) + .unwrap() + .with_timezone(&Utc); + + let dt_end = central_tz + .with_ymd_and_hms( + end_date.year(), + end_date.month(), + end_date.day(), + end_time.hour(), + end_time.minute(), + 0, + ) + .unwrap() + .with_timezone(&Utc); + + // Format times in UTC for Google Calendar + let start_str = dt_start.format("%Y%m%dT%H%M%SZ").to_string(); + let end_str = dt_end.format("%Y%m%dT%H%M%SZ").to_string(); + + // Generate RRULE for recurrence + let rrule = generate_rrule(meeting_time, end_date); + + // Build calendar URL + let mut params = HashMap::new(); + + let course_text = format!( + "{} {} - {}", + course.subject, course.course_number, course.course_title + ); + let dates_text = format!("{}/{}", start_str, end_str); + + // Get instructor name + let instructor_name = if !course.faculty.is_empty() { + &course.faculty[0].display_name + } else { + "Unknown" + }; + + let days_text = weekdays_to_string(&meeting_time.meeting_time); + let details_text = format!( + "CRN: {}\nInstructor: {}\nDays: {}", + course.course_reference_number, instructor_name, days_text + ); + + let location_text = meeting_time.place_string(); + let recur_text = format!("RRULE:{}", rrule); + + params.insert("action", "TEMPLATE"); + params.insert("text", &course_text); + params.insert("dates", &dates_text); + params.insert("details", &details_text); + params.insert("location", &location_text); + params.insert("trp", "true"); + params.insert("ctz", "America/Chicago"); + params.insert("recur", &recur_text); + + // Build URL + let mut url = Url::parse("https://calendar.google.com/calendar/render")?; + for (key, value) in params { + url.query_pairs_mut().append_pair(key, value); + } + + Ok(url.to_string()) +} + +/// Generate RRULE for recurrence +fn generate_rrule(meeting_time: &MeetingTimeResponse, end_date: NaiveDate) -> String { + let by_day = meeting_time.days_string(); + + // Handle edge cases where days_string might return "None" or empty + let by_day = if by_day.is_empty() || by_day == "None" { + "MO".to_string() // Default to Monday + } else { + // Convert our day format to Google Calendar format + by_day + .replace("M", "MO") + .replace("Tu", "TU") + .replace("W", "WE") + .replace("Th", "TH") + .replace("F", "FR") + .replace("Sa", "SA") + .replace("Su", "SU") + }; + + // Format end date for RRULE (YYYYMMDD format) + let until = end_date.format("%Y%m%d").to_string(); + + // Build the RRULE string manually to avoid formatting issues + let mut rrule = String::new(); + rrule.push_str("FREQ=WEEKLY;BYDAY="); + rrule.push_str(&by_day); + rrule.push_str(";UNTIL="); + rrule.push_str(&until); + + rrule +} + +/// Parse time from formatted string (e.g., "8:00AM", "12:30PM") +fn parse_time_from_formatted(time_str: &str) -> Result { + let time_str = time_str.trim(); + + // Handle 12-hour format: "8:00AM", "12:30PM", etc. + if time_str.ends_with("AM") || time_str.ends_with("PM") { + let (time_part, ampm) = time_str.split_at(time_str.len() - 2); + let parts: Vec<&str> = time_part.split(':').collect(); + + if parts.len() != 2 { + return Err("Invalid time format".into()); + } + + let hour: u32 = parts[0].parse()?; + let minute: u32 = parts[1].parse()?; + + let adjusted_hour = match ampm { + "AM" => { + if hour == 12 { + 0 + } else { + hour + } + } + "PM" => { + if hour == 12 { + 12 + } else { + hour + 12 + } + } + _ => return Err("Invalid AM/PM indicator".into()), + }; + + chrono::NaiveTime::from_hms_opt(adjusted_hour, minute, 0).ok_or("Invalid time".into()) + } else { + // Handle 24-hour format: "08:00", "13:30" + let parts: Vec<&str> = time_str.split(':').collect(); + + if parts.len() != 2 { + return Err("Invalid time format".into()); + } + + let hour: u32 = parts[0].parse()?; + let minute: u32 = parts[1].parse()?; + + NaiveTime::from_hms_opt(hour, minute, 0).ok_or("Invalid time".into()) + } +} + +/// Convert weekdays to string representation +fn weekdays_to_string(meeting_time: &MeetingTime) -> String { + let mut result = String::new(); + + if meeting_time.monday { + result.push_str("M"); + } + if meeting_time.tuesday { + result.push_str("Tu"); + } + if meeting_time.wednesday { + result.push_str("W"); + } + if meeting_time.thursday { + result.push_str("Th"); + } + if meeting_time.friday { + result.push_str("F"); + } + if meeting_time.saturday { + result.push_str("Sa"); + } + if meeting_time.sunday { + result.push_str("Su"); + } + + if result.is_empty() { + "None".to_string() + } else if result.len() == 14 { + // All days + "Everyday".to_string() + } else { + result + } +} diff --git a/src/bot/commands/ics.rs b/src/bot/commands/ics.rs new file mode 100644 index 0000000..09ba6af --- /dev/null +++ b/src/bot/commands/ics.rs @@ -0,0 +1,25 @@ +//! ICS command implementation for generating calendar files. + +use crate::bot::{Context, Error}; + +/// Generate an ICS file for a course +#[poise::command(slash_command, prefix_command)] +pub async fn ics( + ctx: Context<'_>, + #[description = "Course Reference Number (CRN)"] crn: i32, +) -> Result<(), Error> { + ctx.defer().await?; + + // TODO: Get BannerApi from context or global state + // TODO: Get current term dynamically + let term = 202510; // Hardcoded for now + + // TODO: Implement actual ICS file generation + ctx.say(format!( + "ICS command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", + crn, term + )) + .await?; + + Ok(()) +} diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs new file mode 100644 index 0000000..60c2b60 --- /dev/null +++ b/src/bot/commands/mod.rs @@ -0,0 +1,13 @@ +//! Bot commands module. + +pub mod search; +pub mod terms; +pub mod time; +pub mod ics; +pub mod gcal; + +pub use search::search; +pub use terms::terms; +pub use time::time; +pub use ics::ics; +pub use gcal::gcal; diff --git a/src/bot/commands/search.rs b/src/bot/commands/search.rs new file mode 100644 index 0000000..b47d021 --- /dev/null +++ b/src/bot/commands/search.rs @@ -0,0 +1,115 @@ +//! Course search command implementation. + +use crate::banner::SearchQuery; +use crate::bot::{Context, Error}; +use regex::Regex; + +/// Search for courses with various filters +#[poise::command(slash_command, prefix_command)] +pub async fn search( + ctx: Context<'_>, + #[description = "Course title (exact, use autocomplete)"] title: Option, + #[description = "Course code (e.g. 3743, 3000-3999, 3xxx, 3000-)"] code: Option, + #[description = "Maximum number of results"] max: Option, + #[description = "Keywords in title or description (space separated)"] keywords: Option, + #[description = "Instructor name"] instructor: Option, + #[description = "Subject (e.g. Computer Science/CS, Mathematics/MAT)"] subject: Option, +) -> Result<(), Error> { + // Defer the response since this might take a while + ctx.defer().await?; + + // Build the search query + let mut query = SearchQuery::new().credits(3, 6); + + if let Some(title) = title { + query = query.title(title); + } + + if let Some(code) = code { + let (low, high) = parse_course_code(&code)?; + query = query.course_numbers(low, high); + } + + if let Some(keywords) = keywords { + let keyword_list: Vec = + keywords.split_whitespace().map(|s| s.to_string()).collect(); + query = query.keywords(keyword_list); + } + + if let Some(max_results) = max { + query = query.max_results(max_results.min(25)); // Cap at 25 + } + + // TODO: Get current term dynamically + let term = "202510"; // Hardcoded for now + + // TODO: Get BannerApi from context or global state + // For now, we'll return an error + ctx.say("Search functionality not yet implemented - BannerApi integration needed") + .await?; + + Ok(()) +} + +/// Parse course code input (e.g., "3743", "3000-3999", "3xxx", "3000-") +fn parse_course_code(input: &str) -> Result<(i32, i32), Error> { + let input = input.trim(); + + // Handle range format (e.g., "3000-3999") + if input.contains('-') { + let re = Regex::new(r"(\d{1,4})-(\d{1,4})?").unwrap(); + if let Some(captures) = re.captures(input) { + let low: i32 = captures[1].parse()?; + let high = if captures.get(2).is_some() { + captures[2].parse()? + } else { + 9999 // Open-ended range + }; + + if low > high { + return Err("Invalid range: low value greater than high value".into()); + } + + if low < 1000 || high > 9999 { + return Err("Course codes must be between 1000 and 9999".into()); + } + + return Ok((low, high)); + } + return Err("Invalid range format".into()); + } + + // Handle wildcard format (e.g., "34xx") + if input.contains('x') { + if input.len() != 4 { + return Err("Wildcard format must be exactly 4 characters".into()); + } + + let re = Regex::new(r"(\d+)(x+)").unwrap(); + if let Some(captures) = re.captures(input) { + let prefix: i32 = captures[1].parse()?; + let x_count = captures[2].len(); + + let low = prefix * 10_i32.pow(x_count as u32); + let high = low + 10_i32.pow(x_count as u32) - 1; + + if low < 1000 || high > 9999 { + return Err("Course codes must be between 1000 and 9999".into()); + } + + return Ok((low, high)); + } + return Err("Invalid wildcard format".into()); + } + + // Handle single course code + if input.len() == 4 { + let code: i32 = input.parse()?; + if code < 1000 || code > 9999 { + return Err("Course codes must be between 1000 and 9999".into()); + } + return Ok((code, code)); + } + + Err("Invalid course code format".into()) +} diff --git a/src/bot/commands/terms.rs b/src/bot/commands/terms.rs new file mode 100644 index 0000000..a51c7cc --- /dev/null +++ b/src/bot/commands/terms.rs @@ -0,0 +1,26 @@ +//! Terms command implementation. + +use crate::bot::{Context, Error}; + +/// List available terms or search for a specific term +#[poise::command(slash_command, prefix_command)] +pub async fn terms( + ctx: Context<'_>, + #[description = "Term to search for"] search: Option, + #[description = "Page number"] page: Option, +) -> Result<(), Error> { + ctx.defer().await?; + + let search_term = search.unwrap_or_default(); + let page_number = page.unwrap_or(1).max(1); + + // TODO: Get BannerApi from context or global state + // For now, we'll return a placeholder response + ctx.say(format!( + "Terms command not yet implemented - BannerApi integration needed\nSearch: '{}', Page: {}", + search_term, page_number + )) + .await?; + + Ok(()) +} diff --git a/src/bot/commands/time.rs b/src/bot/commands/time.rs new file mode 100644 index 0000000..335a9c7 --- /dev/null +++ b/src/bot/commands/time.rs @@ -0,0 +1,25 @@ +//! Time command implementation for course meeting times. + +use crate::bot::{Context, Error}; + +/// Get meeting times for a specific course +#[poise::command(slash_command, prefix_command)] +pub async fn time( + ctx: Context<'_>, + #[description = "Course Reference Number (CRN)"] crn: i32, +) -> Result<(), Error> { + ctx.defer().await?; + + // TODO: Get BannerApi from context or global state + // TODO: Get current term dynamically + let term = 202510; // Hardcoded for now + + // TODO: Implement actual meeting time retrieval + ctx.say(format!( + "Time command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", + crn, term + )) + .await?; + + Ok(()) +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 225fe64..ef80daa 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,16 +1,20 @@ -use poise::serenity_prelude as serenity; -pub struct Data {} // User data, which is stored and accessible in all command invocations +use crate::app_state::AppState; + +pub mod commands; + +pub struct Data { + pub app_state: AppState, +} // User data, which is stored and accessible in all command invocations pub type Error = Box; pub type Context<'a> = poise::Context<'a, Data, Error>; -/// Displays your or another user's account creation date -#[poise::command(slash_command, prefix_command)] -pub async fn age( - ctx: Context<'_>, - #[description = "Selected user"] user: Option, -) -> Result<(), Error> { - let u = user.as_ref().unwrap_or_else(|| ctx.author()); - let response = format!("{}'s account was created at {}", u.name, u.created_at()); - ctx.say(response).await?; - Ok(()) +/// Get all available commands +pub fn get_commands() -> Vec> { + vec![ + commands::search(), + commands::terms(), + commands::time(), + commands::ics(), + commands::gcal(), + ] } diff --git a/src/config/mod.rs b/src/config/mod.rs index e832543..533ca48 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -10,7 +10,7 @@ use fundu::{DurationParser, TimeUnit}; use serde::{Deserialize, Deserializer}; use std::time::Duration; -/// Application configuration loaded from environment variables. +/// Application configuration loaded from environment variables #[derive(Deserialize)] pub struct Config { /// Discord bot token for authentication @@ -27,8 +27,8 @@ pub struct Config { pub bot_app_id: u64, /// Graceful shutdown timeout duration /// - /// Accepts both numeric values (seconds) and duration strings. - /// Defaults to 8 seconds if not specified. + /// Accepts both numeric values (seconds) and duration strings + /// Defaults to 8 seconds if not specified #[serde( default = "default_shutdown_timeout", deserialize_with = "deserialize_duration" @@ -36,12 +36,12 @@ pub struct Config { pub shutdown_timeout: Duration, } -/// Default shutdown timeout of 8 seconds. +/// Default shutdown timeout of 8 seconds fn default_shutdown_timeout() -> Duration { Duration::from_secs(8) } -/// Duration parser configured to handle various time units with seconds as default. +/// Duration parser configured to handle various time units with seconds as default /// /// Supports: /// - Seconds (s) - default unit @@ -49,9 +49,9 @@ fn default_shutdown_timeout() -> Duration { /// - Minutes (m) /// - Hours (h) /// -/// Does not support fractions, exponents, or infinity values. -/// Allows for whitespace between the number and the time unit. -/// Allows for multiple time units to be specified (summed together, e.g. "10s 2m" = 120 + 10 = 130 seconds) +/// Does not support fractions, exponents, or infinity values +/// Allows for whitespace between the number and the time unit +/// Allows for multiple time units to be specified (summed together, e.g "10s 2m" = 120 + 10 = 130 seconds) const DURATION_PARSER: DurationParser<'static> = DurationParser::builder() .time_units(&[TimeUnit::Second, TimeUnit::MilliSecond, TimeUnit::Minute]) .parse_multiple(None) @@ -62,7 +62,7 @@ const DURATION_PARSER: DurationParser<'static> = DurationParser::builder() .default_unit(TimeUnit::Second) .build(); -/// Custom deserializer for duration fields that accepts both numeric and string values. +/// Custom deserializer for duration fields that accepts both numeric and string values /// /// This deserializer handles the flexible duration parsing by accepting: /// - Unsigned integers (interpreted as seconds) @@ -74,7 +74,7 @@ const DURATION_PARSER: DurationParser<'static> = DurationParser::builder() /// - `1` -> 1 second /// - `"30s"` -> 30 seconds /// - `"2 m"` -> 2 minutes -/// - `"1500ms"` -> 1.5 seconds +/// - `"1500ms"` -> 15 seconds fn deserialize_duration<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, diff --git a/src/lib.rs b/src/lib.rs index 1cbfd96..7bbfc0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,5 @@ +pub mod app_state; +pub mod banner; pub mod bot; +pub mod services; +pub mod web; diff --git a/src/main.rs b/src/main.rs index 095121b..0348c29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,16 @@ use tokio::signal; use tracing::{error, info, warn}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; -use crate::bot::{Data, age}; +use crate::app_state::AppState; +use crate::banner::BannerApi; +use crate::bot::{Data, get_commands}; use crate::config::Config; use crate::services::manager::ServiceManager; use crate::services::{ServiceResult, bot::BotService, run_service}; use figment::{Figment, providers::Env}; +mod app_state; +mod banner; mod bot; mod config; mod services; @@ -39,17 +43,25 @@ async fn main() { .extract() .expect("Failed to load config"); - // Configure the client with your Discord bot token in the environment. + // Create BannerApi and AppState + let banner_api = + BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi"); + + let app_state = + AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState"); + + // Configure the client with your Discord bot token in the environment let intents = GatewayIntents::non_privileged(); let bot_target_guild = config.bot_target_guild; let framework = poise::Framework::builder() .options(poise::FrameworkOptions { - commands: vec![age()], + commands: get_commands(), ..Default::default() }) .setup(move |ctx, _ready, framework| { + let app_state = app_state.clone(); Box::pin(async move { poise::builtins::register_in_guild( ctx, @@ -58,7 +70,7 @@ async fn main() { ) .await?; poise::builtins::register_globally(ctx, &framework.options().commands).await?; - Ok(Data {}) + Ok(Data { app_state }) }) }) .build();