diff --git a/src/banner/api.rs b/src/banner/api.rs index 6228eb0..67521f3 100644 --- a/src/banner/api.rs +++ b/src/banner/api.rs @@ -1,6 +1,6 @@ //! Main Banner API client implementation. -use crate::banner::{SessionManager, models::*, query::SearchQuery}; +use crate::banner::{models::*, query::SearchQuery, session::SessionManager, util::user_agent}; use anyhow::{Context, Result}; use axum::http::HeaderValue; use reqwest::Client; @@ -166,7 +166,7 @@ impl BannerApi { pub async fn get_campuses( &self, search: &str, - term: i32, + term: &str, offset: i32, max_results: i32, ) -> Result> { @@ -178,7 +178,7 @@ impl BannerApi { let url = format!("{}/classSearch/get_campus", self.base_url); let params = [ ("searchTerm", search), - ("term", &term.to_string()), + ("term", term), ("offset", &offset.to_string()), ("max", &max_results.to_string()), ("uniqueSessionId", &session_id), @@ -205,10 +205,10 @@ impl BannerApi { pub async fn get_course_meeting_time( &self, term: &str, - crn: i32, + crn: &str, ) -> Result> { let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url); - let params = [("term", term), ("courseReferenceNumber", &crn.to_string())]; + let params = [("term", term), ("courseReferenceNumber", crn)]; let response = self .client @@ -242,14 +242,14 @@ impl BannerApi { )); } - #[derive(serde::Deserialize)] - struct ResponseWrapper { - fmt: Vec, - } + let response: MeetingTimesApiResponse = + response.json().await.context("Failed to parse response")?; - let wrapper: ResponseWrapper = response.json().await.context("Failed to parse response")?; - - Ok(wrapper.fmt.into_iter().map(|m| m.schedule_info()).collect()) + Ok(response + .fmt + .into_iter() + .map(|m| m.schedule_info()) + .collect()) } /// Performs a search for courses. @@ -357,10 +357,10 @@ impl BannerApi { } /// Gets course details (placeholder - needs implementation). - pub async fn get_course_details(&self, term: i32, crn: i32) -> Result { + pub async fn get_course_details(&self, term: &str, crn: &str) -> Result { let body = serde_json::json!({ - "term": term.to_string(), - "courseReferenceNumber": crn.to_string(), + "term": term, + "courseReferenceNumber": crn, "first": "first" }); @@ -382,11 +382,6 @@ impl BannerApi { } } -/// 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" -} - /// Attempt to parse JSON and, on failure, include a contextual snippet around the error location fn parse_json_with_context(body: &str) -> Result { match serde_json::from_str::(body) { diff --git a/src/banner/mod.rs b/src/banner/mod.rs index 29be4cf..ba2a728 100644 --- a/src/banner/mod.rs +++ b/src/banner/mod.rs @@ -1,3 +1,5 @@ +#![allow(unused_imports)] + //! Banner API module for interacting with Ellucian Banner systems. //! //! This module provides functionality to: @@ -11,6 +13,7 @@ pub mod models; pub mod query; pub mod scraper; pub mod session; +pub mod util; pub use api::*; pub use models::*; diff --git a/src/banner/models/courses.rs b/src/banner/models/courses.rs index 8b409ea..e168994 100644 --- a/src/banner/models/courses.rs +++ b/src/banner/models/courses.rs @@ -59,6 +59,24 @@ pub struct Course { pub meetings_faculty: Vec, } +impl Course { + /// Returns the course title in the format "SUBJ #### - Course Title" + pub fn display_title(&self) -> String { + format!( + "{} {} - {}", + self.subject, self.course_number, self.course_title + ) + } + + /// Returns the name of the primary instructor, or "Unknown" if not available + pub fn primary_instructor_name(&self) -> &str { + self.faculty + .first() + .map(|f| f.display_name.as_str()) + .unwrap_or("Unknown") + } +} + /// Class details (to be implemented) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClassDetails { diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index b8ac460..139d777 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -1,7 +1,7 @@ use bitflags::{Flags, bitflags}; use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc}; use serde::{Deserialize, Deserializer, Serialize}; -use std::{cmp::Ordering, fmt::Display, str::FromStr}; +use std::{cmp::Ordering, collections::HashSet, fmt::Display, str::FromStr}; use super::terms::Term; @@ -159,6 +159,19 @@ impl DayOfWeek { DayOfWeek::Sunday => "Su", } } + + /// Convert to full string representation + pub fn to_full_string(self) -> &'static str { + match self { + DayOfWeek::Monday => "Monday", + DayOfWeek::Tuesday => "Tuesday", + DayOfWeek::Wednesday => "Wednesday", + DayOfWeek::Thursday => "Thursday", + DayOfWeek::Friday => "Friday", + DayOfWeek::Saturday => "Saturday", + DayOfWeek::Sunday => "Sunday", + } + } } impl TryFrom for DayOfWeek { @@ -423,18 +436,36 @@ impl MeetingScheduleInfo { } /// Get formatted days string - pub fn days_string(&self) -> String { + pub fn days_string(&self) -> Option { if self.days.is_empty() { - "None".to_string() - } else if self.days.is_all() { - "Everyday".to_string() - } else { - self.days_of_week() - .iter() - .map(|day| day.to_short_string()) - .collect::>() - .join("") + return None; } + if self.days.is_all() { + return Some("Everyday".to_string()); + } + + let days_of_week = self.days_of_week(); + if days_of_week.len() == 1 { + return Some(days_of_week[0].to_full_string().to_string()); + } + + // Mapper function to get the short string representation of the day of week + let mapper = { + let ambiguous = self.days.intersects( + MeetingDays::Tuesday + | MeetingDays::Thursday + | MeetingDays::Saturday + | MeetingDays::Sunday, + ); + + if ambiguous { + |day: &DayOfWeek| day.to_short_string().to_string() + } else { + |day: &DayOfWeek| day.to_short_string().chars().next().unwrap().to_string() + } + }; + + Some(days_of_week.iter().map(mapper).collect::()) } /// Returns a formatted string representing the location of the meeting diff --git a/src/banner/session.rs b/src/banner/session.rs index c1f0953..f3d7d5a 100644 --- a/src/banner/session.rs +++ b/src/banner/session.rs @@ -1,5 +1,6 @@ //! Session management for Banner API. +use crate::banner::util::user_agent; use anyhow::Result; use rand::distributions::{Alphanumeric, DistString}; use reqwest::Client; @@ -195,8 +196,3 @@ impl SessionManager { .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/util.rs b/src/banner/util.rs new file mode 100644 index 0000000..4e6faa7 --- /dev/null +++ b/src/banner/util.rs @@ -0,0 +1,6 @@ +//! Utility functions for the Banner module. + +/// Returns a browser-like user agent string. +pub 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 index ae80d6e..aa9bb26 100644 --- a/src/bot/commands/gcal.rs +++ b/src/bot/commands/gcal.rs @@ -1,7 +1,7 @@ //! Google Calendar command implementation. -use crate::banner::{Course, DayOfWeek, MeetingScheduleInfo, Term}; -use crate::bot::{Context, Error}; +use crate::banner::{Course, DayOfWeek, MeetingScheduleInfo}; +use crate::bot::{Context, Error, utils}; use chrono::NaiveDate; use std::collections::HashMap; use tracing::{error, info}; @@ -18,34 +18,21 @@ pub async fn gcal( ctx.defer().await?; - let app_state = &ctx.data().app_state; - let banner_api = &app_state.banner_api; - - // Get current term dynamically - let current_term_status = Term::get_current(); - let term = current_term_status.inner(); - - // Fetch live course data from Redis cache via AppState - let course = match app_state - .get_course_or_fetch(&term.to_string(), &crn.to_string()) - .await - { - Ok(course) => course, - Err(e) => { - error!(%e, crn, "failed to fetch course data"); - return Err(Error::from(e)); - } - }; + let course = utils::get_course_by_crn(&ctx, crn).await?; + let term = course.term.clone(); // Get meeting times - let meeting_times = match banner_api - .get_course_meeting_time(&term.to_string(), crn) + let meeting_times = match ctx + .data() + .app_state + .banner_api + .get_course_meeting_time(&term, &crn.to_string()) .await { Ok(meeting_time) => meeting_time, Err(e) => { error!("failed to get meeting times: {}", e); - return Err(Error::from(e)); + return Err(e); } }; @@ -74,8 +61,10 @@ pub async fn gcal( .map(|m| { let link = generate_gcal_url(&course, m)?; let detail = match &m.time_range { - Some(range) => format!("{} {}", m.days_string(), range.format_12hr()), - None => m.days_string(), + Some(range) => { + format!("{} {}", m.days_string().unwrap(), range.format_12hr()) + } + None => m.days_string().unwrap(), }; Ok(LinkDetail { link, detail }) }) @@ -104,10 +93,7 @@ fn generate_gcal_url( course: &Course, meeting_time: &MeetingScheduleInfo, ) -> Result { - let course_text = format!( - "{} {} - {}", - course.subject, course.course_number, course.course_title - ); + let course_text = course.display_title(); let dates_text = { let (start, end) = meeting_time.datetime_range(); @@ -119,18 +105,14 @@ fn generate_gcal_url( }; // Get instructor name - let instructor_name = if !course.faculty.is_empty() { - &course.faculty[0].display_name - } else { - "Unknown" - }; + let instructor_name = course.primary_instructor_name(); // The event description let details_text = format!( "CRN: {}\nInstructor: {}\nDays: {}", course.course_reference_number, instructor_name, - meeting_time.days_string() + meeting_time.days_string().unwrap() ); // The event location diff --git a/src/bot/commands/ics.rs b/src/bot/commands/ics.rs index 09ba6af..3ecfe9a 100644 --- a/src/bot/commands/ics.rs +++ b/src/bot/commands/ics.rs @@ -1,6 +1,7 @@ //! ICS command implementation for generating calendar files. -use crate::bot::{Context, Error}; +use crate::bot::{Context, Error, utils}; +use tracing::info; /// Generate an ICS file for a course #[poise::command(slash_command, prefix_command)] @@ -10,16 +11,15 @@ pub async fn ics( ) -> Result<(), Error> { ctx.defer().await?; - // TODO: Get BannerApi from context or global state - // TODO: Get current term dynamically - let term = 202510; // Hardcoded for now + let course = utils::get_course_by_crn(&ctx, crn).await?; // TODO: Implement actual ICS file generation ctx.say(format!( - "ICS command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", - crn, term + "ICS generation for '{}' is not yet implemented.", + course.display_title() )) .await?; + info!("ics command completed for CRN: {}", crn); Ok(()) } diff --git a/src/bot/commands/search.rs b/src/bot/commands/search.rs index 2097944..1dd6b70 100644 --- a/src/bot/commands/search.rs +++ b/src/bot/commands/search.rs @@ -1,8 +1,10 @@ //! Course search command implementation. -use crate::banner::SearchQuery; +use crate::banner::{SearchQuery, Term}; use crate::bot::{Context, Error}; +use anyhow::anyhow; use regex::Regex; +use tracing::info; /// Search for courses with various filters #[poise::command(slash_command, prefix_command)] @@ -40,12 +42,37 @@ pub async fn search( query = query.max_results(max_results.min(25)); // Cap at 25 } - // TODO: Get current term dynamically - // 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") + let term = Term::get_current().inner().to_string(); + let search_result = ctx + .data() + .app_state + .banner_api + .search(&term, &query, "subjectDescription", false) .await?; + let response = if let Some(courses) = search_result.data { + if courses.is_empty() { + "No courses found with the specified criteria.".to_string() + } else { + courses + .iter() + .map(|course| { + format!( + "**{}**: {} ({})", + course.display_title(), + course.primary_instructor_name(), + course.course_reference_number + ) + }) + .collect::>() + .join("\n") + } + } else { + "No courses found with the specified criteria.".to_string() + }; + + ctx.say(response).await?; + info!("search command completed"); Ok(()) } @@ -65,22 +92,24 @@ fn parse_course_code(input: &str) -> Result<(i32, i32), Error> { }; if low > high { - return Err("Invalid range: low value greater than high value".into()); + return Err(anyhow!( + "Invalid range: low value greater than high value" + )); } if low < 1000 || high > 9999 { - return Err("Course codes must be between 1000 and 9999".into()); + return Err(anyhow!("Course codes must be between 1000 and 9999")); } return Ok((low, high)); } - return Err("Invalid range format".into()); + return Err(anyhow!("Invalid range format")); } // Handle wildcard format (e.g, "34xx") if input.contains('x') { if input.len() != 4 { - return Err("Wildcard format must be exactly 4 characters".into()); + return Err(anyhow!("Wildcard format must be exactly 4 characters")); } let re = Regex::new(r"(\d+)(x+)").unwrap(); @@ -92,22 +121,22 @@ fn parse_course_code(input: &str) -> Result<(i32, i32), Error> { 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 Err(anyhow!("Course codes must be between 1000 and 9999")); } return Ok((low, high)); } - return Err("Invalid wildcard format".into()); + return Err(anyhow!("Invalid wildcard format")); } // Handle single course code if input.len() == 4 { let code: i32 = input.parse()?; if !(1000..=9999).contains(&code) { - return Err("Course codes must be between 1000 and 9999".into()); + return Err(anyhow!("Course codes must be between 1000 and 9999")); } return Ok((code, code)); } - Err("Invalid course code format".into()) + Err(anyhow!("Invalid course code format")) } diff --git a/src/bot/commands/terms.rs b/src/bot/commands/terms.rs index a51c7cc..a423d19 100644 --- a/src/bot/commands/terms.rs +++ b/src/bot/commands/terms.rs @@ -1,6 +1,8 @@ //! Terms command implementation. +use crate::banner::{BannerTerm, Term}; use crate::bot::{Context, Error}; +use tracing::info; /// List available terms or search for a specific term #[poise::command(slash_command, prefix_command)] @@ -13,14 +15,40 @@ pub async fn terms( let search_term = search.unwrap_or_default(); let page_number = page.unwrap_or(1).max(1); + let max_results = 10; - // 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?; + let terms = ctx + .data() + .app_state + .banner_api + .get_terms(&search_term, page_number, max_results) + .await?; + let response = if terms.is_empty() { + "No terms found.".to_string() + } else { + let current_term_code = Term::get_current().inner().to_string(); + terms + .iter() + .map(|term| format_term(term, ¤t_term_code)) + .collect::>() + .join("\n") + }; + + ctx.say(response).await?; + info!("terms command completed"); Ok(()) } + +fn format_term(term: &BannerTerm, current_term_code: &str) -> String { + let is_current = if term.code == current_term_code { + " (current)" + } else { + "" + }; + let is_archived = if term.is_archived() { " (archived)" } else { "" }; + format!( + "- `{}`: {}{}{}", + term.code, term.description, is_current, is_archived + ) +} diff --git a/src/bot/commands/time.rs b/src/bot/commands/time.rs index 335a9c7..e1abcf5 100644 --- a/src/bot/commands/time.rs +++ b/src/bot/commands/time.rs @@ -1,6 +1,7 @@ //! Time command implementation for course meeting times. -use crate::bot::{Context, Error}; +use crate::bot::{utils, Context, Error}; +use tracing::info; /// Get meeting times for a specific course #[poise::command(slash_command, prefix_command)] @@ -10,16 +11,15 @@ pub async fn time( ) -> Result<(), Error> { ctx.defer().await?; - // TODO: Get BannerApi from context or global state - // TODO: Get current term dynamically - let term = 202510; // Hardcoded for now + let course = utils::get_course_by_crn(&ctx, crn).await?; - // TODO: Implement actual meeting time retrieval + // TODO: Implement actual meeting time retrieval and display ctx.say(format!( - "Time command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", - crn, term + "Meeting time display for '{}' is not yet implemented.", + course.display_title() )) .await?; + info!("time command completed for CRN: {}", crn); Ok(()) } diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 175a0f9..252b69d 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,12 +1,13 @@ use crate::app_state::AppState; +use crate::error::Error; pub mod commands; +pub mod utils; #[derive(Debug)] 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>; /// Get all available commands diff --git a/src/bot/utils.rs b/src/bot/utils.rs new file mode 100644 index 0000000..3e9934b --- /dev/null +++ b/src/bot/utils.rs @@ -0,0 +1,24 @@ +//! Bot command utilities. + +use crate::banner::{Course, Term}; +use crate::bot::Context; +use crate::error::Result; +use tracing::error; + +/// Gets a course by its CRN for the current term. +pub async fn get_course_by_crn(ctx: &Context<'_>, crn: i32) -> Result { + let app_state = &ctx.data().app_state; + + // Get current term dynamically + let current_term_status = Term::get_current(); + let term = current_term_status.inner(); + + // Fetch live course data from Redis cache via AppState + app_state + .get_course_or_fetch(&term.to_string(), &crn.to_string()) + .await + .map_err(|e| { + error!(%e, crn, "failed to fetch course data"); + e + }) +} diff --git a/src/error.rs b/src/error.rs index e69de29..b4c5f25 100644 --- a/src/error.rs +++ b/src/error.rs @@ -0,0 +1,4 @@ +//! Application-specific error types. + +pub type Error = anyhow::Error; +pub type Result = anyhow::Result; diff --git a/src/lib.rs b/src/lib.rs index 7bbfc0d..03a21aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod app_state; pub mod banner; pub mod bot; +pub mod error; pub mod services; pub mod web; diff --git a/src/main.rs b/src/main.rs index 59973ed..de29a02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod app_state; mod banner; mod bot; mod config; +mod error; mod services; mod web; diff --git a/src/services/web.rs b/src/services/web.rs index ef60592..e36c853 100644 --- a/src/services/web.rs +++ b/src/services/web.rs @@ -1,5 +1,5 @@ use super::Service; -use crate::web::{BannerState, create_banner_router}; +use crate::web::{BannerState, create_router}; use std::net::SocketAddr; use tokio::net::TcpListener; use tokio::sync::broadcast; @@ -30,7 +30,7 @@ impl Service for WebService { async fn run(&mut self) -> Result<(), anyhow::Error> { // Create the main router with Banner API routes - let app = create_banner_router(self.banner_state.clone()); + let app = create_router(self.banner_state.clone()); let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); info!( diff --git a/src/web/routes.rs b/src/web/routes.rs index 4a10703..b866272 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -3,7 +3,7 @@ use axum::{Router, extract::State, response::Json, routing::get}; use serde_json::{Value, json}; use std::sync::Arc; -use tracing::{debug, info}; +use tracing::info; /// Shared application state for web server #[derive(Clone)] @@ -13,7 +13,7 @@ pub struct BannerState { } /// Creates the web server router -pub fn create_banner_router(state: BannerState) -> Router { +pub fn create_router(state: BannerState) -> Router { Router::new() .route("/", get(root)) .route("/health", get(health)) @@ -22,9 +22,7 @@ pub fn create_banner_router(state: BannerState) -> Router { .with_state(state) } -/// Root endpoint - shows API info async fn root() -> Json { - debug!("root endpoint accessed"); Json(json!({ "message": "Banner Discord Bot API", "version": "0.1.0",