Compare commits

...

4 Commits

27 changed files with 684 additions and 347 deletions

View File

@@ -5,24 +5,24 @@ use crate::banner::Course;
use anyhow::Result; use anyhow::Result;
use redis::AsyncCommands; use redis::AsyncCommands;
use redis::Client; use redis::Client;
use serde_json; use std::sync::Arc;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AppState { pub struct AppState {
pub banner_api: std::sync::Arc<BannerApi>, pub banner_api: Arc<BannerApi>,
pub redis: std::sync::Arc<Client>, pub redis: Arc<Client>,
} }
impl AppState { impl AppState {
pub fn new( pub fn new(
banner_api: BannerApi, banner_api: Arc<BannerApi>,
redis_url: &str, redis_url: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let redis_client = Client::open(redis_url)?; let redis_client = Client::open(redis_url)?;
Ok(Self { Ok(Self {
banner_api: std::sync::Arc::new(banner_api), banner_api,
redis: std::sync::Arc::new(redis_client), redis: Arc::new(redis_client),
}) })
} }
@@ -30,7 +30,7 @@ impl AppState {
pub async fn get_course_or_fetch(&self, term: &str, crn: &str) -> Result<Course> { pub async fn get_course_or_fetch(&self, term: &str, crn: &str) -> Result<Course> {
let mut conn = self.redis.get_multiplexed_async_connection().await?; let mut conn = self.redis.get_multiplexed_async_connection().await?;
let key = format!("class:{}", crn); let key = format!("class:{crn}");
if let Some(serialized) = conn.get::<_, Option<String>>(&key).await? { if let Some(serialized) = conn.get::<_, Option<String>>(&key).await? {
let course: Course = serde_json::from_str(&serialized)?; let course: Course = serde_json::from_str(&serialized)?;
return Ok(course); return Ok(course);
@@ -43,6 +43,6 @@ impl AppState {
return Ok(course); return Ok(course);
} }
Err(anyhow::anyhow!("Course not found for CRN {}", crn)) Err(anyhow::anyhow!("Course not found for CRN {crn}"))
} }
} }

View File

@@ -1,12 +1,12 @@
//! Main Banner API client implementation. //! 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 anyhow::{Context, Result};
use axum::http::HeaderValue; use axum::http::HeaderValue;
use reqwest::Client; use reqwest::Client;
use serde_json; use serde_json;
// use tracing::debug; use tracing::{error, info};
/// Main Banner API client. /// Main Banner API client.
#[derive(Debug)] #[derive(Debug)]
@@ -40,7 +40,13 @@ impl BannerApi {
/// Sets up the API client by initializing session cookies. /// Sets up the API client by initializing session cookies.
pub async fn setup(&self) -> Result<()> { pub async fn setup(&self) -> Result<()> {
self.session_manager.setup().await info!(base_url = self.base_url, "setting up banner api client");
let result = self.session_manager.setup().await;
match &result {
Ok(()) => info!("banner api client setup completed successfully"),
Err(e) => error!(error = ?e, "banner api client setup failed"),
}
result
} }
/// Retrieves a list of terms from the Banner API. /// Retrieves a list of terms from the Banner API.
@@ -59,7 +65,7 @@ impl BannerApi {
("searchTerm", search), ("searchTerm", search),
("offset", &page.to_string()), ("offset", &page.to_string()),
("max", &max_results.to_string()), ("max", &max_results.to_string()),
("_", &timestamp_nonce()), ("_", &SessionManager::nonce()),
]; ];
let response = self let response = self
@@ -98,7 +104,7 @@ impl BannerApi {
("offset", &offset.to_string()), ("offset", &offset.to_string()),
("max", &max_results.to_string()), ("max", &max_results.to_string()),
("uniqueSessionId", &session_id), ("uniqueSessionId", &session_id),
("_", &timestamp_nonce()), ("_", &SessionManager::nonce()),
]; ];
let response = self let response = self
@@ -137,7 +143,7 @@ impl BannerApi {
("offset", &offset.to_string()), ("offset", &offset.to_string()),
("max", &max_results.to_string()), ("max", &max_results.to_string()),
("uniqueSessionId", &session_id), ("uniqueSessionId", &session_id),
("_", &timestamp_nonce()), ("_", &SessionManager::nonce()),
]; ];
let response = self let response = self
@@ -160,7 +166,7 @@ impl BannerApi {
pub async fn get_campuses( pub async fn get_campuses(
&self, &self,
search: &str, search: &str,
term: i32, term: &str,
offset: i32, offset: i32,
max_results: i32, max_results: i32,
) -> Result<Vec<Pair>> { ) -> Result<Vec<Pair>> {
@@ -172,11 +178,11 @@ impl BannerApi {
let url = format!("{}/classSearch/get_campus", self.base_url); let url = format!("{}/classSearch/get_campus", self.base_url);
let params = [ let params = [
("searchTerm", search), ("searchTerm", search),
("term", &term.to_string()), ("term", term),
("offset", &offset.to_string()), ("offset", &offset.to_string()),
("max", &max_results.to_string()), ("max", &max_results.to_string()),
("uniqueSessionId", &session_id), ("uniqueSessionId", &session_id),
("_", &timestamp_nonce()), ("_", &SessionManager::nonce()),
]; ];
let response = self let response = self
@@ -199,10 +205,10 @@ impl BannerApi {
pub async fn get_course_meeting_time( pub async fn get_course_meeting_time(
&self, &self,
term: &str, term: &str,
crn: i32, crn: &str,
) -> Result<Vec<MeetingScheduleInfo>> { ) -> Result<Vec<MeetingScheduleInfo>> {
let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url); 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 let response = self
.client .client
@@ -236,14 +242,14 @@ impl BannerApi {
)); ));
} }
#[derive(serde::Deserialize)] let response: MeetingTimesApiResponse =
struct ResponseWrapper { response.json().await.context("Failed to parse response")?;
fmt: Vec<MeetingTimeResponse>,
}
let wrapper: ResponseWrapper = response.json().await.context("Failed to parse response")?; Ok(response
.fmt
Ok(wrapper.fmt.into_iter().map(|m| m.schedule_info()).collect()) .into_iter()
.map(|m| m.schedule_info())
.collect())
} }
/// Performs a search for courses. /// Performs a search for courses.
@@ -351,10 +357,10 @@ impl BannerApi {
} }
/// Gets course details (placeholder - needs implementation). /// Gets course details (placeholder - needs implementation).
pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> { pub async fn get_course_details(&self, term: &str, crn: &str) -> Result<ClassDetails> {
let body = serde_json::json!({ let body = serde_json::json!({
"term": term.to_string(), "term": term,
"courseReferenceNumber": crn.to_string(), "courseReferenceNumber": crn,
"first": "first" "first": "first"
}); });
@@ -376,33 +382,15 @@ impl BannerApi {
} }
} }
/// 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"
}
/// Attempt to parse JSON and, on failure, include a contextual snippet around the error location /// Attempt to parse JSON and, on failure, include a contextual snippet around the error location
fn parse_json_with_context<T: serde::de::DeserializeOwned>(body: &str) -> Result<T> { fn parse_json_with_context<T: serde::de::DeserializeOwned>(body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) { match serde_json::from_str::<T>(body) {
Ok(value) => Ok(value), Ok(value) => Ok(value),
Err(err) => { Err(err) => {
let (line, column) = (err.line(), err.column()); let (line, column) = (err.line(), err.column());
let snippet = build_error_snippet(body, line as usize, column as usize, 120); let snippet = build_error_snippet(body, line, column, 120);
Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"{} at line {}, column {}\nSnippet:\n{}", "{err} at line {line}, column {column}\nSnippet:\n{snippet}",
err,
line,
column,
snippet
)) ))
} }
} }
@@ -424,5 +412,5 @@ fn build_error_snippet(body: &str, line: usize, column: usize, max_len: usize) -
indicator.push('^'); indicator.push('^');
} }
format!("{}\n{}", slice, indicator) format!("{slice}\n{indicator}")
} }

View File

@@ -1,3 +1,5 @@
#![allow(unused_imports)]
//! Banner API module for interacting with Ellucian Banner systems. //! Banner API module for interacting with Ellucian Banner systems.
//! //!
//! This module provides functionality to: //! This module provides functionality to:
@@ -11,6 +13,7 @@ pub mod models;
pub mod query; pub mod query;
pub mod scraper; pub mod scraper;
pub mod session; pub mod session;
pub mod util;
pub use api::*; pub use api::*;
pub use models::*; pub use models::*;

View File

@@ -59,6 +59,24 @@ pub struct Course {
pub meetings_faculty: Vec<MeetingTimeResponse>, pub meetings_faculty: Vec<MeetingTimeResponse>,
} }
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) /// Class details (to be implemented)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassDetails { pub struct ClassDetails {

View File

@@ -1,7 +1,7 @@
use bitflags::{Flags, bitflags}; use bitflags::{Flags, bitflags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc}; use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc};
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, str::FromStr}; use std::{cmp::Ordering, collections::HashSet, fmt::Display, str::FromStr};
use super::terms::Term; use super::terms::Term;
@@ -148,20 +148,20 @@ pub enum DayOfWeek {
impl DayOfWeek { impl DayOfWeek {
/// Convert to short string representation /// Convert to short string representation
pub fn to_short_string(&self) -> &'static str { pub fn to_short_string(self) -> &'static str {
match self { match self {
DayOfWeek::Monday => "M", DayOfWeek::Monday => "Mo",
DayOfWeek::Tuesday => "Tu", DayOfWeek::Tuesday => "Tu",
DayOfWeek::Wednesday => "W", DayOfWeek::Wednesday => "We",
DayOfWeek::Thursday => "Th", DayOfWeek::Thursday => "Th",
DayOfWeek::Friday => "F", DayOfWeek::Friday => "Fr",
DayOfWeek::Saturday => "Sa", DayOfWeek::Saturday => "Sa",
DayOfWeek::Sunday => "Su", DayOfWeek::Sunday => "Su",
} }
} }
/// Convert to full string representation /// Convert to full string representation
pub fn to_string(&self) -> &'static str { pub fn to_full_string(self) -> &'static str {
match self { match self {
DayOfWeek::Monday => "Monday", DayOfWeek::Monday => "Monday",
DayOfWeek::Tuesday => "Tuesday", DayOfWeek::Tuesday => "Tuesday",
@@ -196,10 +196,9 @@ impl TryFrom<MeetingDays> for DayOfWeek {
}); });
} }
return Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"Cannot convert multiple days to a single day: {:?}", "Cannot convert multiple days to a single day: {days:?}"
days ))
));
} }
} }
@@ -252,15 +251,8 @@ impl TimeRange {
let hour = time.hour(); let hour = time.hour();
let minute = time.minute(); let minute = time.minute();
if hour == 0 { let meridiem = if hour < 12 { "AM" } else { "PM" };
format!("12:{:02}AM", minute) format!("{hour}:{minute:02}{meridiem}")
} 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)
}
} }
/// Get duration in minutes /// Get duration in minutes
@@ -376,15 +368,20 @@ impl MeetingLocation {
is_online, is_online,
} }
} }
}
/// Convert to formatted string impl Display for MeetingLocation {
pub fn to_string(&self) -> String { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_online { if self.is_online {
"Online".to_string() write!(f, "Online")
} else { } else {
format!( write!(
"{} | {} | {} {}", f,
self.campus, self.building_description, self.building, self.room "{campus} | {building_name} | {building_code} {room}",
campus = self.campus,
building_name = self.building_description,
building_code = self.building,
room = self.room
) )
} }
} }
@@ -439,18 +436,36 @@ impl MeetingScheduleInfo {
} }
/// Get formatted days string /// Get formatted days string
pub fn days_string(&self) -> String { pub fn days_string(&self) -> Option<String> {
if self.days.is_empty() { if self.days.is_empty() {
"None".to_string() return None;
} else if self.days.is_all() {
"Everyday".to_string()
} else {
self.days_of_week()
.iter()
.map(|day| day.to_short_string())
.collect::<Vec<_>>()
.join("")
} }
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::<String>())
} }
/// Returns a formatted string representing the location of the meeting /// Returns a formatted string representing the location of the meeting

View File

@@ -46,7 +46,7 @@ impl Term {
/// Returns the current term status for a specific date /// Returns the current term status for a specific date
pub fn get_status_for_date(date: NaiveDate) -> TermPoint { pub fn get_status_for_date(date: NaiveDate) -> TermPoint {
let literal_year = date.year() as u32; let literal_year = date.year() as u32;
let day_of_year = date.ordinal() as u32; let day_of_year = date.ordinal();
let ranges = Self::get_season_ranges(literal_year); let ranges = Self::get_season_ranges(literal_year);
// If we're past the end of the summer term, we're 'in' the next school year. // If we're past the end of the summer term, we're 'in' the next school year.
@@ -115,22 +115,22 @@ impl Term {
fn get_season_ranges(year: u32) -> SeasonRanges { fn get_season_ranges(year: u32) -> SeasonRanges {
let spring_start = NaiveDate::from_ymd_opt(year as i32, 1, 14) let spring_start = NaiveDate::from_ymd_opt(year as i32, 1, 14)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
let spring_end = NaiveDate::from_ymd_opt(year as i32, 5, 1) let spring_end = NaiveDate::from_ymd_opt(year as i32, 5, 1)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
let summer_start = NaiveDate::from_ymd_opt(year as i32, 5, 25) let summer_start = NaiveDate::from_ymd_opt(year as i32, 5, 25)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
let summer_end = NaiveDate::from_ymd_opt(year as i32, 8, 15) let summer_end = NaiveDate::from_ymd_opt(year as i32, 8, 15)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
let fall_start = NaiveDate::from_ymd_opt(year as i32, 8, 18) let fall_start = NaiveDate::from_ymd_opt(year as i32, 8, 18)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
let fall_end = NaiveDate::from_ymd_opt(year as i32, 12, 10) let fall_end = NaiveDate::from_ymd_opt(year as i32, 12, 10)
.unwrap() .unwrap()
.ordinal() as u32; .ordinal();
SeasonRanges { SeasonRanges {
spring: YearDayRange { spring: YearDayRange {
@@ -179,10 +179,15 @@ struct YearDayRange {
end: u32, end: u32,
} }
impl ToString for Term { impl std::fmt::Display for Term {
/// Returns the term in the format YYYYXX, where YYYY is the year and XX is the season code /// Returns the term in the format YYYYXX, where YYYY is the year and XX is the season code
fn to_string(&self) -> String { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
format!("{}{}", self.year, self.season.to_str()) write!(
f,
"{year}{season}",
year = self.year,
season = self.season.to_str()
)
} }
} }
@@ -215,7 +220,7 @@ impl FromStr for Season {
"10" => Season::Fall, "10" => Season::Fall,
"20" => Season::Spring, "20" => Season::Spring,
"30" => Season::Summer, "30" => Season::Summer,
_ => return Err(anyhow::anyhow!("Invalid season: {}", s)), _ => return Err(anyhow::anyhow!("Invalid season: {s}")),
}; };
Ok(season) Ok(season)
} }

View File

@@ -270,7 +270,7 @@ impl std::fmt::Display for SearchQuery {
let mut parts = Vec::new(); let mut parts = Vec::new();
if let Some(ref subject) = self.subject { if let Some(ref subject) = self.subject {
parts.push(format!("subject={}", subject)); parts.push(format!("subject={subject}"));
} }
if let Some(ref title) = self.title { if let Some(ref title) = self.title {
parts.push(format!("title={}", title.trim())); parts.push(format!("title={}", title.trim()));
@@ -296,21 +296,21 @@ impl std::fmt::Display for SearchQuery {
.map(|i| i.to_string()) .map(|i| i.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
parts.push(format!("instructor={}", instructor_str)); parts.push(format!("instructor={instructor_str}"));
} }
if let Some(start_time) = self.start_time { if let Some(start_time) = self.start_time {
let (hour, minute, meridiem) = format_time_parameter(start_time); let (hour, minute, meridiem) = format_time_parameter(start_time);
parts.push(format!("startTime={}:{}:{}", hour, minute, meridiem)); parts.push(format!("startTime={hour}:{minute}:{meridiem}"));
} }
if let Some(end_time) = self.end_time { if let Some(end_time) = self.end_time {
let (hour, minute, meridiem) = format_time_parameter(end_time); let (hour, minute, meridiem) = format_time_parameter(end_time);
parts.push(format!("endTime={}:{}:{}", hour, minute, meridiem)); parts.push(format!("endTime={hour}:{minute}:{meridiem}"));
} }
if let Some(min_credits) = self.min_credits { if let Some(min_credits) = self.min_credits {
parts.push(format!("minCredits={}", min_credits)); parts.push(format!("minCredits={min_credits}"));
} }
if let Some(max_credits) = self.max_credits { if let Some(max_credits) = self.max_credits {
parts.push(format!("maxCredits={}", max_credits)); parts.push(format!("maxCredits={max_credits}"));
} }
if let Some(ref range) = self.course_number_range { if let Some(ref range) = self.course_number_range {
parts.push(format!("courseNumberRange={}-{}", range.low, range.high)); parts.push(format!("courseNumberRange={}-{}", range.low, range.high));

View File

@@ -3,6 +3,7 @@
use crate::banner::{api::BannerApi, models::*, query::SearchQuery}; use crate::banner::{api::BannerApi, models::*, query::SearchQuery};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use redis::AsyncCommands; use redis::AsyncCommands;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::time; use tokio::time;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -15,13 +16,13 @@ const MAX_PAGE_SIZE: i32 = 500;
/// Course scraper for Banner API /// Course scraper for Banner API
pub struct CourseScraper { pub struct CourseScraper {
api: BannerApi, api: Arc<BannerApi>,
redis_client: redis::Client, redis_client: redis::Client,
} }
impl CourseScraper { impl CourseScraper {
/// Creates a new course scraper /// Creates a new course scraper
pub fn new(api: BannerApi, redis_url: &str) -> Result<Self> { pub fn new(api: Arc<BannerApi>, redis_url: &str) -> Result<Self> {
let redis_client = let redis_client =
redis::Client::open(redis_url).context("Failed to create Redis client")?; redis::Client::open(redis_url).context("Failed to create Redis client")?;
@@ -38,7 +39,7 @@ impl CourseScraper {
.context("Failed to get subjects for scraping")?; .context("Failed to get subjects for scraping")?;
if subjects.is_empty() { if subjects.is_empty() {
return Err(anyhow::anyhow!("No subjects found for term {}", term)); return Err(anyhow::anyhow!("no subjects found for term {term}"));
} }
// Categorize subjects // Categorize subjects
@@ -52,20 +53,22 @@ impl CourseScraper {
expired_subjects.extend(self.get_expired_subjects(&other_subjects, term).await?); expired_subjects.extend(self.get_expired_subjects(&other_subjects, term).await?);
if expired_subjects.is_empty() { if expired_subjects.is_empty() {
info!("No expired subjects found, skipping scrape"); info!("no expired subjects found, skipping scrape");
return Ok(()); return Ok(());
} }
info!( info!(
"Scraping {} subjects for term {}", "scraping {count} subjects for term {term}",
expired_subjects.len(), count = expired_subjects.len()
term
); );
// Scrape each expired subject // Scrape each expired subject
for subject in expired_subjects { for subject in expired_subjects {
if let Err(e) = self.scrape_subject(&subject.code, term).await { if let Err(e) = self.scrape_subject(&subject.code, term).await {
error!("Failed to scrape subject {}: {}", subject.code, e); error!(
"failed to scrape subject {subject}: {e}",
subject = subject.code
);
} }
// Rate limiting between subjects // Rate limiting between subjects
@@ -86,7 +89,7 @@ impl CourseScraper {
let mut expired = Vec::new(); let mut expired = Vec::new();
for subject in subjects { for subject in subjects {
let key = format!("scraped:{}:{}", subject.code, term); let key = format!("scraped:{code}:{term}", code = subject.code);
let scraped: Option<String> = conn let scraped: Option<String> = conn
.get(&key) .get(&key)
.await .await
@@ -120,16 +123,12 @@ impl CourseScraper {
.search(term, &query, "subjectDescription", false) .search(term, &query, "subjectDescription", false)
.await .await
.with_context(|| { .with_context(|| {
format!( format!("failed to search for subject {subject} at offset {offset}")
"Failed to search for subject {} at offset {}",
subject, offset
)
})?; })?;
if !result.success { if !result.success {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Search marked unsuccessful for subject {}", "search marked unsuccessful for subject {subject}"
subject
)); ));
} }
@@ -137,16 +136,16 @@ impl CourseScraper {
total_courses += course_count; total_courses += course_count;
debug!( debug!(
"Retrieved {} courses for subject {} at offset {}", "retrieved {count} courses for subject {subject} at offset {offset}",
course_count, subject, offset count = course_count
); );
// Store each course in Redis // Store each course in Redis
for course in result.data.unwrap_or_default() { for course in result.data.unwrap_or_default() {
if let Err(e) = self.store_course(&course).await { if let Err(e) = self.store_course(&course).await {
error!( error!(
"Failed to store course {}: {}", "failed to store course {crn}: {e}",
course.course_reference_number, e crn = course.course_reference_number
); );
} }
} }
@@ -155,16 +154,14 @@ impl CourseScraper {
if course_count >= MAX_PAGE_SIZE { if course_count >= MAX_PAGE_SIZE {
if course_count > MAX_PAGE_SIZE { if course_count > MAX_PAGE_SIZE {
warn!( warn!(
"Course count {} exceeds max page size {}", "course count {count} exceeds max page size {max_page_size}",
course_count, MAX_PAGE_SIZE count = course_count,
max_page_size = MAX_PAGE_SIZE
); );
} }
offset += MAX_PAGE_SIZE; offset += MAX_PAGE_SIZE;
debug!( debug!("continuing to next page for subject {subject} at offset {offset}");
"Continuing to next page for subject {} at offset {}",
subject, offset
);
// Rate limiting between pages // Rate limiting between pages
time::sleep(Duration::from_secs(3)).await; time::sleep(Duration::from_secs(3)).await;
@@ -175,8 +172,8 @@ impl CourseScraper {
} }
info!( info!(
"Scraped {} total courses for subject {}", "scraped {count} total courses for subject {subject}",
total_courses, subject count = total_courses
); );
// Mark subject as scraped with expiry // Mark subject as scraped with expiry
@@ -194,7 +191,7 @@ impl CourseScraper {
.await .await
.context("Failed to get Redis connection")?; .context("Failed to get Redis connection")?;
let key = format!("class:{}", course.course_reference_number); let key = format!("class:{crn}", crn = course.course_reference_number);
let serialized = serde_json::to_string(course).context("Failed to serialize course")?; let serialized = serde_json::to_string(course).context("Failed to serialize course")?;
let _: () = conn let _: () = conn
@@ -218,19 +215,21 @@ impl CourseScraper {
.await .await
.context("Failed to get Redis connection")?; .context("Failed to get Redis connection")?;
let key = format!("scraped:{}:{}", subject, term); let key = format!("scraped:{subject}:{term}", subject = subject);
let expiry = self.calculate_expiry(subject, course_count); let expiry = self.calculate_expiry(subject, course_count);
let value = if course_count == 0 { -1 } else { course_count }; let value = if course_count == 0 { -1 } else { course_count };
let _: () = conn let _: () = conn
.set_ex(&key, value, expiry.as_secs() as u64) .set_ex(&key, value, expiry.as_secs())
.await .await
.context("Failed to mark subject as scraped")?; .context("Failed to mark subject as scraped")?;
debug!( debug!(
"Marked subject {} as scraped with {} courses, expiry: {:?}", "marked subject {subject} as scraped with {count} courses, expiry: {expiry:?}",
subject, course_count, expiry subject = subject,
count = course_count,
expiry = expiry
); );
Ok(()) Ok(())
@@ -250,7 +249,7 @@ impl CourseScraper {
// Priority subjects get shorter expiry (more frequent updates) // Priority subjects get shorter expiry (more frequent updates)
if PRIORITY_MAJORS.contains(&subject) { if PRIORITY_MAJORS.contains(&subject) {
base_expiry = base_expiry / 3; base_expiry /= 3;
} }
// Add random variance (±15%) // Add random variance (±15%)
@@ -275,7 +274,7 @@ impl CourseScraper {
.await .await
.context("Failed to get Redis connection")?; .context("Failed to get Redis connection")?;
let key = format!("class:{}", crn); let key = format!("class:{crn}");
let serialized: Option<String> = conn let serialized: Option<String> = conn
.get(&key) .get(&key)
.await .await

View File

@@ -1,5 +1,6 @@
//! Session management for Banner API. //! Session management for Banner API.
use crate::banner::util::user_agent;
use anyhow::Result; use anyhow::Result;
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use reqwest::Client; use reqwest::Client;
@@ -35,13 +36,20 @@ impl SessionManager {
/// Ensures a valid session is available, creating one if necessary /// Ensures a valid session is available, creating one if necessary
pub fn ensure_session(&self) -> Result<String> { pub fn ensure_session(&self) -> Result<String> {
let start_time = std::time::Instant::now();
let mut session_guard = self.current_session.lock().unwrap(); let mut session_guard = self.current_session.lock().unwrap();
if let Some(ref session) = *session_guard { if let Some(ref session) = *session_guard
if session.created_at.elapsed() < Self::SESSION_EXPIRY { && session.created_at.elapsed() < Self::SESSION_EXPIRY
{
let elapsed = start_time.elapsed();
debug!(
session_id = session.session_id,
elapsed = format!("{:.2?}", elapsed),
"reusing existing banner session"
);
return Ok(session.session_id.clone()); return Ok(session.session_id.clone());
} }
}
// Generate new session // Generate new session
let session_id = self.generate_session_id(); let session_id = self.generate_session_id();
@@ -50,7 +58,12 @@ impl SessionManager {
created_at: Instant::now(), created_at: Instant::now(),
}); });
debug!("Generated new Banner session: {}", session_id); let elapsed = start_time.elapsed();
debug!(
session_id = session_id,
elapsed = format!("{:.2?}", elapsed),
"generated new banner session"
);
Ok(session_id) Ok(session_id)
} }
@@ -66,7 +79,7 @@ impl SessionManager {
/// Sets up initial session cookies by making required Banner API requests /// Sets up initial session cookies by making required Banner API requests
pub async fn setup(&self) -> Result<()> { pub async fn setup(&self) -> Result<()> {
info!("Setting up Banner session..."); info!("setting up banner session...");
let request_paths = ["/registration/registration", "/selfServiceMenu/data"]; let request_paths = ["/registration/registration", "/selfServiceMenu/data"];
@@ -75,7 +88,7 @@ impl SessionManager {
let response = self let response = self
.client .client
.get(&url) .get(&url)
.query(&[("_", timestamp_nonce())]) .query(&[("_", Self::nonce())])
.header("User-Agent", user_agent()) .header("User-Agent", user_agent())
.send() .send()
.await?; .await?;
@@ -90,7 +103,7 @@ impl SessionManager {
} }
// Note: Cookie validation would require additional setup in a real implementation // Note: Cookie validation would require additional setup in a real implementation
debug!("Session setup complete"); debug!("session setup complete");
Ok(()) Ok(())
} }
@@ -150,7 +163,7 @@ impl SessionManager {
)); ));
} }
debug!("Successfully selected term: {}", term); debug!("successfully selected term: {}", term);
Ok(()) Ok(())
} }
@@ -173,18 +186,13 @@ impl SessionManager {
Ok(()) Ok(())
} }
}
/// Generates a timestamp-based nonce /// Generates a timestamp-based nonce
fn timestamp_nonce() -> String { pub fn nonce() -> String {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() .as_millis()
.to_string() .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"
} }

6
src/banner/util.rs Normal file
View File

@@ -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"
}

View File

@@ -1,7 +1,7 @@
//! Google Calendar command implementation. //! Google Calendar command implementation.
use crate::banner::{Course, DayOfWeek, MeetingScheduleInfo, Term}; use crate::banner::{Course, DayOfWeek, MeetingScheduleInfo};
use crate::bot::{Context, Error}; use crate::bot::{Context, Error, utils};
use chrono::NaiveDate; use chrono::NaiveDate;
use std::collections::HashMap; use std::collections::HashMap;
use tracing::{error, info}; use tracing::{error, info};
@@ -18,34 +18,21 @@ pub async fn gcal(
ctx.defer().await?; ctx.defer().await?;
let app_state = &ctx.data().app_state; let course = utils::get_course_by_crn(&ctx, crn).await?;
let banner_api = &app_state.banner_api; let term = course.term.clone();
// 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));
}
};
// Get meeting times // Get meeting times
let meeting_times = match banner_api let meeting_times = match ctx
.get_course_meeting_time(&term.to_string(), crn) .data()
.app_state
.banner_api
.get_course_meeting_time(&term, &crn.to_string())
.await .await
{ {
Ok(meeting_time) => meeting_time, Ok(meeting_time) => meeting_time,
Err(e) => { Err(e) => {
error!("Failed to get meeting times: {}", 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| { .map(|m| {
let link = generate_gcal_url(&course, m)?; let link = generate_gcal_url(&course, m)?;
let detail = match &m.time_range { let detail = match &m.time_range {
Some(range) => format!("{} {}", m.days_string(), range.format_12hr()), Some(range) => {
None => m.days_string(), format!("{} {}", m.days_string().unwrap(), range.format_12hr())
}
None => m.days_string().unwrap(),
}; };
Ok(LinkDetail { link, detail }) Ok(LinkDetail { link, detail })
}) })
@@ -104,10 +93,7 @@ fn generate_gcal_url(
course: &Course, course: &Course,
meeting_time: &MeetingScheduleInfo, meeting_time: &MeetingScheduleInfo,
) -> Result<String, anyhow::Error> { ) -> Result<String, anyhow::Error> {
let course_text = format!( let course_text = course.display_title();
"{} {} - {}",
course.subject, course.course_number, course.course_title
);
let dates_text = { let dates_text = {
let (start, end) = meeting_time.datetime_range(); let (start, end) = meeting_time.datetime_range();
@@ -119,18 +105,14 @@ fn generate_gcal_url(
}; };
// Get instructor name // Get instructor name
let instructor_name = if !course.faculty.is_empty() { let instructor_name = course.primary_instructor_name();
&course.faculty[0].display_name
} else {
"Unknown"
};
// The event description // The event description
let details_text = format!( let details_text = format!(
"CRN: {}\nInstructor: {}\nDays: {}", "CRN: {}\nInstructor: {}\nDays: {}",
course.course_reference_number, course.course_reference_number,
instructor_name, instructor_name,
meeting_time.days_string() meeting_time.days_string().unwrap()
); );
// The event location // The event location

View File

@@ -1,6 +1,7 @@
//! ICS command implementation for generating calendar files. //! 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 /// Generate an ICS file for a course
#[poise::command(slash_command, prefix_command)] #[poise::command(slash_command, prefix_command)]
@@ -10,16 +11,15 @@ pub async fn ics(
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
// TODO: Get BannerApi from context or global state let course = utils::get_course_by_crn(&ctx, crn).await?;
// TODO: Get current term dynamically
let term = 202510; // Hardcoded for now
// TODO: Implement actual ICS file generation // TODO: Implement actual ICS file generation
ctx.say(format!( ctx.say(format!(
"ICS command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", "ICS generation for '{}' is not yet implemented.",
crn, term course.display_title()
)) ))
.await?; .await?;
info!("ics command completed for CRN: {}", crn);
Ok(()) Ok(())
} }

View File

@@ -1,8 +1,10 @@
//! Course search command implementation. //! Course search command implementation.
use crate::banner::SearchQuery; use crate::banner::{SearchQuery, Term};
use crate::bot::{Context, Error}; use crate::bot::{Context, Error};
use anyhow::anyhow;
use regex::Regex; use regex::Regex;
use tracing::info;
/// Search for courses with various filters /// Search for courses with various filters
#[poise::command(slash_command, prefix_command)] #[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 query = query.max_results(max_results.min(25)); // Cap at 25
} }
// TODO: Get current term dynamically let term = Term::get_current().inner().to_string();
// TODO: Get BannerApi from context or global state let search_result = ctx
// For now, we'll return an error .data()
ctx.say("Search functionality not yet implemented - BannerApi integration needed") .app_state
.banner_api
.search(&term, &query, "subjectDescription", false)
.await?; .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::<Vec<_>>()
.join("\n")
}
} else {
"No courses found with the specified criteria.".to_string()
};
ctx.say(response).await?;
info!("search command completed");
Ok(()) Ok(())
} }
@@ -65,22 +92,24 @@ fn parse_course_code(input: &str) -> Result<(i32, i32), Error> {
}; };
if low > high { 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 { 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 Ok((low, high));
} }
return Err("Invalid range format".into()); return Err(anyhow!("Invalid range format"));
} }
// Handle wildcard format (e.g, "34xx") // Handle wildcard format (e.g, "34xx")
if input.contains('x') { if input.contains('x') {
if input.len() != 4 { 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(); 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; let high = low + 10_i32.pow(x_count as u32) - 1;
if low < 1000 || high > 9999 { 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 Ok((low, high));
} }
return Err("Invalid wildcard format".into()); return Err(anyhow!("Invalid wildcard format"));
} }
// Handle single course code // Handle single course code
if input.len() == 4 { if input.len() == 4 {
let code: i32 = input.parse()?; let code: i32 = input.parse()?;
if code < 1000 || code > 9999 { 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)); return Ok((code, code));
} }
Err("Invalid course code format".into()) Err(anyhow!("Invalid course code format"))
} }

View File

@@ -1,6 +1,8 @@
//! Terms command implementation. //! Terms command implementation.
use crate::banner::{BannerTerm, Term};
use crate::bot::{Context, Error}; use crate::bot::{Context, Error};
use tracing::info;
/// List available terms or search for a specific term /// List available terms or search for a specific term
#[poise::command(slash_command, prefix_command)] #[poise::command(slash_command, prefix_command)]
@@ -13,14 +15,40 @@ pub async fn terms(
let search_term = search.unwrap_or_default(); let search_term = search.unwrap_or_default();
let page_number = page.unwrap_or(1).max(1); let page_number = page.unwrap_or(1).max(1);
let max_results = 10;
// TODO: Get BannerApi from context or global state let terms = ctx
// For now, we'll return a placeholder response .data()
ctx.say(format!( .app_state
"Terms command not yet implemented - BannerApi integration needed\nSearch: '{}', Page: {}", .banner_api
search_term, page_number .get_terms(&search_term, page_number, max_results)
))
.await?; .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, &current_term_code))
.collect::<Vec<_>>()
.join("\n")
};
ctx.say(response).await?;
info!("terms command completed");
Ok(()) 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
)
}

View File

@@ -1,6 +1,7 @@
//! Time command implementation for course meeting times. //! 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 /// Get meeting times for a specific course
#[poise::command(slash_command, prefix_command)] #[poise::command(slash_command, prefix_command)]
@@ -10,16 +11,15 @@ pub async fn time(
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?; ctx.defer().await?;
// TODO: Get BannerApi from context or global state let course = utils::get_course_by_crn(&ctx, crn).await?;
// TODO: Get current term dynamically
let term = 202510; // Hardcoded for now
// TODO: Implement actual meeting time retrieval // TODO: Implement actual meeting time retrieval and display
ctx.say(format!( ctx.say(format!(
"Time command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}", "Meeting time display for '{}' is not yet implemented.",
crn, term course.display_title()
)) ))
.await?; .await?;
info!("time command completed for CRN: {}", crn);
Ok(()) Ok(())
} }

View File

@@ -1,12 +1,13 @@
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::error::Error;
pub mod commands; pub mod commands;
pub mod utils;
#[derive(Debug)] #[derive(Debug)]
pub struct Data { pub struct Data {
pub app_state: AppState, pub app_state: AppState,
} // User data, which is stored and accessible in all command invocations } // User data, which is stored and accessible in all command invocations
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Context<'a> = poise::Context<'a, Data, Error>; pub type Context<'a> = poise::Context<'a, Data, Error>;
/// Get all available commands /// Get all available commands

24
src/bot/utils.rs Normal file
View File

@@ -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<Course> {
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
})
}

View File

@@ -3,8 +3,6 @@
//! This module handles loading and parsing configuration from environment variables //! This module handles loading and parsing configuration from environment variables
//! using the figment crate. It supports flexible duration parsing that accepts both //! using the figment crate. It supports flexible duration parsing that accepts both
//! numeric values (interpreted as seconds) and duration strings with units. //! numeric values (interpreted as seconds) and duration strings with units.
//!
//! All configuration is loaded from environment variables with the `APP_` prefix:
use fundu::{DurationParser, TimeUnit}; use fundu::{DurationParser, TimeUnit};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
@@ -15,6 +13,9 @@ use std::time::Duration;
pub struct Config { pub struct Config {
/// Discord bot token for authentication /// Discord bot token for authentication
pub bot_token: String, pub bot_token: String,
/// Port for the web server
#[serde(default = "default_port")]
pub port: u16,
/// Database connection URL /// Database connection URL
pub database_url: String, pub database_url: String,
/// Redis connection URL /// Redis connection URL
@@ -36,6 +37,11 @@ pub struct Config {
pub shutdown_timeout: Duration, pub shutdown_timeout: Duration,
} }
/// Default port of 3000
fn default_port() -> u16 {
3000
}
/// Default shutdown timeout of 8 seconds /// Default shutdown timeout of 8 seconds
fn default_shutdown_timeout() -> Duration { fn default_shutdown_timeout() -> Duration {
Duration::from_secs(8) Duration::from_secs(8)

View File

@@ -0,0 +1,4 @@
//! Application-specific error types.
pub type Error = anyhow::Error;
pub type Result<T, E = Error> = anyhow::Result<T, E>;

View File

@@ -1,5 +1,6 @@
pub mod app_state; pub mod app_state;
pub mod banner; pub mod banner;
pub mod bot; pub mod bot;
pub mod error;
pub mod services; pub mod services;
pub mod web; pub mod web;

View File

@@ -1,21 +1,26 @@
use serenity::all::{ClientBuilder, GatewayIntents}; use serenity::all::{ClientBuilder, GatewayIntents};
use tokio::signal; use tokio::signal;
use tracing::{debug, error, info, warn}; use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, FmtSubscriber}; use tracing_subscriber::{EnvFilter, FmtSubscriber};
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::banner::BannerApi; use crate::banner::BannerApi;
use crate::banner::scraper::CourseScraper;
use crate::bot::{Data, get_commands}; use crate::bot::{Data, get_commands};
use crate::config::Config; use crate::config::Config;
use crate::services::manager::ServiceManager; use crate::services::manager::ServiceManager;
use crate::services::{ServiceResult, bot::BotService, run_service}; use crate::services::{ServiceResult, bot::BotService, web::WebService};
use crate::web::routes::BannerState;
use figment::{Figment, providers::Env}; use figment::{Figment, providers::Env};
use std::sync::Arc;
mod app_state; mod app_state;
mod banner; mod banner;
mod bot; mod bot;
mod config; mod config;
mod error;
mod services; mod services;
mod web;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -35,14 +40,33 @@ async fn main() {
} }
} }
.with_env_filter(filter) .with_env_filter(filter)
.with_target(true)
.finish(); .finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
// Log application startup context
info!(
version = env!("CARGO_PKG_VERSION"),
environment = if cfg!(debug_assertions) {
"development"
} else {
"production"
},
"starting banner system"
);
let config: Config = Figment::new() let config: Config = Figment::new()
.merge(Env::prefixed("APP_")) .merge(Env::prefixed("APP_"))
.extract() .extract()
.expect("Failed to load config"); .expect("Failed to load config");
info!(
port = config.port,
shutdown_timeout = format!("{:.2?}", config.shutdown_timeout),
banner_base_url = config.banner_base_url,
"configuration loaded"
);
// Create BannerApi and AppState // Create BannerApi and AppState
let banner_api = let banner_api =
BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi"); BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi");
@@ -51,8 +75,19 @@ async fn main() {
.await .await
.expect("Failed to set up BannerApi session"); .expect("Failed to set up BannerApi session");
let app_state = let banner_api_arc = Arc::new(banner_api);
AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState"); let app_state = AppState::new(banner_api_arc.clone(), &config.redis_url)
.expect("Failed to create AppState");
// Create CourseScraper for web service
let scraper = CourseScraper::new(banner_api_arc.clone(), &config.redis_url)
.expect("Failed to create CourseScraper");
// Create BannerState for web service
let banner_state = BannerState {
api: banner_api_arc,
scraper: Arc::new(scraper),
};
// Configure the client with your Discord bot token in the environment // Configure the client with your Discord bot token in the environment
let intents = GatewayIntents::non_privileged(); let intents = GatewayIntents::non_privileged();
@@ -86,77 +121,87 @@ async fn main() {
// Extract shutdown timeout before moving config // Extract shutdown timeout before moving config
let shutdown_timeout = config.shutdown_timeout; let shutdown_timeout = config.shutdown_timeout;
let port = config.port;
// Create service manager // Create service manager
let mut service_manager = ServiceManager::new(); let mut service_manager = ServiceManager::new();
// Create and add services // Register services with the manager
let bot_service = Box::new(BotService::new(client)); let bot_service = Box::new(BotService::new(client));
let web_service = Box::new(WebService::new(port, banner_state));
let bot_handle = tokio::spawn(run_service(bot_service, service_manager.subscribe())); service_manager.register_service("bot", bot_service);
service_manager.register_service("web", web_service);
service_manager.add_service("bot".to_string(), bot_handle); // Spawn all registered services
service_manager.spawn_all();
// Set up CTRL+C signal handling // Set up CTRL+C signal handling
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()
.await .await
.expect("Failed to install CTRL+C signal handler"); .expect("Failed to install CTRL+C signal handler");
info!("Received CTRL+C, gracefully shutting down..."); info!("received ctrl+c, gracefully shutting down...");
}; };
// Main application loop - wait for services or CTRL+C // Main application loop - wait for services or CTRL+C
let mut exit_code = 0; let mut exit_code = 0;
let join = |strings: Vec<String>| {
strings
.iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(", ")
};
tokio::select! { tokio::select! {
(service_name, result) = service_manager.run() => { (service_name, result) = service_manager.run() => {
// A service completed unexpectedly // A service completed unexpectedly
match result { match result {
ServiceResult::GracefulShutdown => { ServiceResult::GracefulShutdown => {
info!(service = service_name, "Service completed gracefully"); info!(service = service_name, "service completed gracefully");
} }
ServiceResult::NormalCompletion => { ServiceResult::NormalCompletion => {
warn!(service = service_name, "Service completed unexpectedly"); warn!(service = service_name, "service completed unexpectedly");
exit_code = 1; exit_code = 1;
} }
ServiceResult::Error(e) => { ServiceResult::Error(e) => {
error!(service = service_name, "Service failed: {e}"); error!(service = service_name, error = ?e, "service failed");
exit_code = 1; exit_code = 1;
} }
} }
// Shutdown remaining services // Shutdown remaining services
match service_manager.shutdown(shutdown_timeout).await { match service_manager.shutdown(shutdown_timeout).await {
Ok(()) => { Ok(elapsed) => {
debug!("Graceful shutdown complete"); info!(
remaining = format!("{:.2?}", shutdown_timeout - elapsed),
"graceful shutdown complete"
);
} }
Err(pending_services) => { Err(pending_services) => {
warn!( warn!(
"Graceful shutdown elapsed - the following service(s) did not complete: {}", pending_count = pending_services.len(),
join(pending_services) pending_services = ?pending_services,
"graceful shutdown elapsed - {} service(s) did not complete",
pending_services.len()
); );
// Non-zero exit code, default to 2 if not set
exit_code = if exit_code == 0 { 2 } else { exit_code }; exit_code = if exit_code == 0 { 2 } else { exit_code };
} }
} }
} }
_ = ctrl_c => { _ = ctrl_c => {
// User requested shutdown // User requested shutdown
info!("user requested shutdown via ctrl+c");
match service_manager.shutdown(shutdown_timeout).await { match service_manager.shutdown(shutdown_timeout).await {
Ok(()) => { Ok(elapsed) => {
debug!("Graceful shutdown complete"); info!(
remaining = format!("{:.2?}", shutdown_timeout - elapsed),
"graceful shutdown complete"
);
info!("graceful shutdown complete");
} }
Err(pending_services) => { Err(pending_services) => {
warn!( warn!(
"Graceful shutdown elapsed - the following service(s) did not complete: {}", pending_count = pending_services.len(),
join(pending_services) pending_services = ?pending_services,
"graceful shutdown elapsed - {} service(s) did not complete",
pending_services.len()
); );
exit_code = 2; exit_code = 2;
} }
@@ -164,6 +209,6 @@ async fn main() {
} }
} }
info!(exit_code = exit_code, "Shutdown complete"); info!(exit_code, "application shutdown complete");
std::process::exit(exit_code); std::process::exit(exit_code);
} }

View File

@@ -1,7 +1,7 @@
use super::Service; use super::Service;
use serenity::Client; use serenity::Client;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, warn}; use tracing::{debug, error};
/// Discord bot service implementation /// Discord bot service implementation
pub struct BotService { pub struct BotService {
@@ -28,11 +28,11 @@ impl Service for BotService {
async fn run(&mut self) -> Result<(), anyhow::Error> { async fn run(&mut self) -> Result<(), anyhow::Error> {
match self.client.start().await { match self.client.start().await {
Ok(()) => { Ok(()) => {
warn!(service = "bot", "Stopped early."); debug!(service = "bot", "stopped early.");
Err(anyhow::anyhow!("bot stopped early")) Err(anyhow::anyhow!("bot stopped early"))
} }
Err(e) => { Err(e) => {
error!(service = "bot", "Error: {e:?}"); error!(service = "bot", "error: {e:?}");
Err(e.into()) Err(e.into())
} }
} }

View File

@@ -2,13 +2,14 @@ use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{error, info, warn}; use tracing::{debug, error, info, warn};
use crate::services::ServiceResult; use crate::services::{Service, ServiceResult, run_service};
/// Manages multiple services and their lifecycle /// Manages multiple services and their lifecycle
pub struct ServiceManager { pub struct ServiceManager {
services: HashMap<String, JoinHandle<ServiceResult>>, registered_services: HashMap<String, Box<dyn Service>>,
running_services: HashMap<String, JoinHandle<ServiceResult>>,
shutdown_tx: broadcast::Sender<()>, shutdown_tx: broadcast::Sender<()>,
} }
@@ -16,51 +17,69 @@ impl ServiceManager {
pub fn new() -> Self { pub fn new() -> Self {
let (shutdown_tx, _) = broadcast::channel(1); let (shutdown_tx, _) = broadcast::channel(1);
Self { Self {
services: HashMap::new(), registered_services: HashMap::new(),
running_services: HashMap::new(),
shutdown_tx, shutdown_tx,
} }
} }
/// Add a service to be managed /// Register a service to be managed (not yet spawned)
pub fn add_service(&mut self, name: String, handle: JoinHandle<ServiceResult>) { pub fn register_service(&mut self, name: &str, service: Box<dyn Service>) {
self.services.insert(name, handle); self.registered_services.insert(name.to_string(), service);
} }
/// Get a shutdown receiver for services to subscribe to /// Spawn all registered services
pub fn subscribe(&self) -> broadcast::Receiver<()> { pub fn spawn_all(&mut self) {
self.shutdown_tx.subscribe() let service_count = self.registered_services.len();
let service_names: Vec<_> = self.registered_services.keys().cloned().collect();
for (name, service) in self.registered_services.drain() {
let shutdown_rx = self.shutdown_tx.subscribe();
let handle = tokio::spawn(run_service(service, shutdown_rx));
self.running_services.insert(name, handle);
}
info!(
service_count,
services = ?service_names,
"spawned {} services",
service_count
);
} }
/// Run all services until one completes or fails /// Run all services until one completes or fails
/// Returns the first service that completes and its result /// Returns the first service that completes and its result
pub async fn run(&mut self) -> (String, ServiceResult) { pub async fn run(&mut self) -> (String, ServiceResult) {
if self.services.is_empty() { if self.running_services.is_empty() {
return ( return (
"none".to_string(), "none".to_string(),
ServiceResult::Error(anyhow::anyhow!("No services to run")), ServiceResult::Error(anyhow::anyhow!("No services to run")),
); );
} }
info!("ServiceManager running {} services", self.services.len()); info!(
"servicemanager running {} services",
self.running_services.len()
);
// Wait for any service to complete // Wait for any service to complete
loop { loop {
let mut completed_services = Vec::new(); let mut completed_services = Vec::new();
for (name, handle) in &mut self.services { for (name, handle) in &mut self.running_services {
if handle.is_finished() { if handle.is_finished() {
completed_services.push(name.clone()); completed_services.push(name.clone());
} }
} }
if let Some(completed_name) = completed_services.first() { if let Some(completed_name) = completed_services.first() {
let handle = self.services.remove(completed_name).unwrap(); let handle = self.running_services.remove(completed_name).unwrap();
match handle.await { match handle.await {
Ok(result) => { Ok(result) => {
return (completed_name.clone(), result); return (completed_name.clone(), result);
} }
Err(e) => { Err(e) => {
error!(service = completed_name, "Service task panicked: {e}"); error!(service = completed_name, "service task panicked: {e}");
return ( return (
completed_name.clone(), completed_name.clone(),
ServiceResult::Error(anyhow::anyhow!("Task panic: {e}")), ServiceResult::Error(anyhow::anyhow!("Task panic: {e}")),
@@ -74,82 +93,65 @@ impl ServiceManager {
} }
} }
/// Shutdown all services gracefully with a timeout /// Shutdown all services gracefully with a timeout.
/// Returns Ok(()) if all services shut down, or Err(Vec<String>) with names of services that timed out ///
pub async fn shutdown(mut self, timeout: Duration) -> Result<(), Vec<String>> { /// If any service fails to shutdown, it will return an error containing the names of the services that failed to shutdown.
if self.services.is_empty() { /// If all services shutdown successfully, the function will return the duration elapsed.
info!("No services to shutdown"); pub async fn shutdown(&mut self, timeout: Duration) -> Result<Duration, Vec<String>> {
return Ok(()); let service_count = self.running_services.len();
} let service_names: Vec<_> = self.running_services.keys().cloned().collect();
info!( info!(
"Shutting down {} services with {}s timeout", service_count,
self.services.len(), services = ?service_names,
timeout.as_secs() timeout = format!("{:.2?}", timeout),
"shutting down {} services with {:?} timeout",
service_count,
timeout
); );
// Signal all services to shutdown // Send shutdown signal to all services
let _ = self.shutdown_tx.send(()); let _ = self.shutdown_tx.send(());
// Wait for all services to complete with timeout // Wait for all services to complete
let shutdown_result = tokio::time::timeout(timeout, async { let start_time = std::time::Instant::now();
let mut completed = Vec::new(); let mut pending_services = Vec::new();
let mut failed = Vec::new();
while !self.services.is_empty() { for (name, handle) in self.running_services.drain() {
let mut to_remove = Vec::new(); match tokio::time::timeout(timeout, handle).await {
Ok(Ok(_)) => {
for (name, handle) in &mut self.services { debug!(service = name, "service shutdown completed");
if handle.is_finished() {
to_remove.push(name.clone());
} }
} Ok(Err(e)) => {
warn!(service = name, error = ?e, "service shutdown failed");
for name in to_remove { pending_services.push(name);
let handle = self.services.remove(&name).unwrap();
match handle.await {
Ok(ServiceResult::GracefulShutdown) => {
completed.push(name);
}
Ok(ServiceResult::NormalCompletion) => {
warn!(service = name, "Service completed normally during shutdown");
completed.push(name);
}
Ok(ServiceResult::Error(e)) => {
error!(service = name, "Service error during shutdown: {e}");
failed.push(name);
}
Err(e) => {
error!(service = name, "Service panic during shutdown: {e}");
failed.push(name);
}
}
}
if !self.services.is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
(completed, failed)
})
.await;
match shutdown_result {
Ok((completed, failed)) => {
if !completed.is_empty() {
info!("Services shutdown completed: {}", completed.join(", "));
}
if !failed.is_empty() {
warn!("Services had errors during shutdown: {}", failed.join(", "));
}
Ok(())
} }
Err(_) => { Err(_) => {
// Timeout occurred - return names of services that didn't complete warn!(service = name, "service shutdown timed out");
let pending_services: Vec<String> = self.services.keys().cloned().collect(); pending_services.push(name);
}
}
}
let elapsed = start_time.elapsed();
if pending_services.is_empty() {
info!(
service_count,
elapsed = format!("{:.2?}", elapsed),
"services shutdown completed: {}",
service_names.join(", ")
);
Ok(elapsed)
} else {
warn!(
pending_count = pending_services.len(),
pending_services = ?pending_services,
elapsed = format!("{:.2?}", elapsed),
"services shutdown completed with {} pending: {}",
pending_services.len(),
pending_services.join(", ")
);
Err(pending_services) Err(pending_services)
} }
} }
} }
}

View File

@@ -3,6 +3,7 @@ use tracing::{error, info, warn};
pub mod bot; pub mod bot;
pub mod manager; pub mod manager;
pub mod web;
#[derive(Debug)] #[derive(Debug)]
pub enum ServiceResult { pub enum ServiceResult {
@@ -21,6 +22,8 @@ pub trait Service: Send + Sync {
async fn run(&mut self) -> Result<(), anyhow::Error>; async fn run(&mut self) -> Result<(), anyhow::Error>;
/// Gracefully shutdown the service /// Gracefully shutdown the service
///
/// An 'Ok' result does not mean the service has completed shutdown, it merely means that the service shutdown was initiated.
async fn shutdown(&mut self) -> Result<(), anyhow::Error>; async fn shutdown(&mut self) -> Result<(), anyhow::Error>;
} }
@@ -30,16 +33,16 @@ pub async fn run_service(
mut shutdown_rx: broadcast::Receiver<()>, mut shutdown_rx: broadcast::Receiver<()>,
) -> ServiceResult { ) -> ServiceResult {
let name = service.name(); let name = service.name();
info!(service = name, "Service started"); info!(service = name, "service started");
let work = async { let work = async {
match service.run().await { match service.run().await {
Ok(()) => { Ok(()) => {
warn!(service = name, "Service completed unexpectedly"); warn!(service = name, "service completed unexpectedly");
ServiceResult::NormalCompletion ServiceResult::NormalCompletion
} }
Err(e) => { Err(e) => {
error!(service = name, "Service failed: {e}"); error!(service = name, "service failed: {e}");
ServiceResult::Error(e) ServiceResult::Error(e)
} }
} }
@@ -48,18 +51,18 @@ pub async fn run_service(
tokio::select! { tokio::select! {
result = work => result, result = work => result,
_ = shutdown_rx.recv() => { _ = shutdown_rx.recv() => {
info!(service = name, "Shutting down..."); info!(service = name, "shutting down...");
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
match service.shutdown().await { match service.shutdown().await {
Ok(()) => { Ok(()) => {
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
info!(service = name, "Shutdown completed in {elapsed:.2?}"); info!(service = name, "shutdown completed in {elapsed:.2?}");
ServiceResult::GracefulShutdown ServiceResult::GracefulShutdown
} }
Err(e) => { Err(e) => {
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
error!(service = name, "Shutdown failed after {elapsed:.2?}: {e}"); error!(service = name, "shutdown failed after {elapsed:.2?}: {e}");
ServiceResult::Error(e) ServiceResult::Error(e)
} }
} }

79
src/services/web.rs Normal file
View File

@@ -0,0 +1,79 @@
use super::Service;
use crate::web::{BannerState, create_router};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
/// Web server service implementation
pub struct WebService {
port: u16,
banner_state: BannerState,
shutdown_tx: Option<broadcast::Sender<()>>,
}
impl WebService {
pub fn new(port: u16, banner_state: BannerState) -> Self {
Self {
port,
banner_state,
shutdown_tx: None,
}
}
}
#[async_trait::async_trait]
impl Service for WebService {
fn name(&self) -> &'static str {
"web"
}
async fn run(&mut self) -> Result<(), anyhow::Error> {
// Create the main router with Banner API routes
let app = create_router(self.banner_state.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
info!(
service = "web",
link = format!("http://localhost:{}", addr.port()),
"starting web server",
);
let listener = TcpListener::bind(addr).await?;
debug!(
service = "web",
"web server listening on {}",
format!("http://{}", addr)
);
// Create internal shutdown channel for axum graceful shutdown
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
self.shutdown_tx = Some(shutdown_tx);
// Use axum's graceful shutdown with the internal shutdown signal
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.recv().await;
debug!(
service = "web",
"received shutdown signal, starting graceful shutdown"
);
})
.await?;
info!(service = "web", "web server stopped");
Ok(())
}
async fn shutdown(&mut self) -> Result<(), anyhow::Error> {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
} else {
warn!(
service = "web",
"no shutdown channel found, cannot trigger graceful shutdown"
);
}
Ok(())
}
}

5
src/web/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! Web API module for the banner application.
pub mod routes;
pub use routes::*;

86
src/web/routes.rs Normal file
View File

@@ -0,0 +1,86 @@
//! Web API endpoints for Banner bot monitoring and metrics.
use axum::{Router, extract::State, response::Json, routing::get};
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::info;
/// Shared application state for web server
#[derive(Clone)]
pub struct BannerState {
pub api: Arc<crate::banner::BannerApi>,
pub scraper: Arc<crate::banner::scraper::CourseScraper>,
}
/// Creates the web server router
pub fn create_router(state: BannerState) -> Router {
Router::new()
.route("/", get(root))
.route("/health", get(health))
.route("/status", get(status))
.route("/metrics", get(metrics))
.with_state(state)
}
async fn root() -> Json<Value> {
Json(json!({
"message": "Banner Discord Bot API",
"version": "0.1.0",
"endpoints": {
"health": "/health",
"status": "/status",
"metrics": "/metrics"
}
}))
}
/// Health check endpoint
async fn health() -> Json<Value> {
info!("health check requested");
Json(json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
/// Status endpoint showing bot and system status
async fn status(State(_state): State<BannerState>) -> Json<Value> {
// For now, return basic status without accessing private fields
Json(json!({
"status": "operational",
"bot": {
"status": "running",
"uptime": "TODO: implement uptime tracking"
},
"cache": {
"status": "connected",
"courses": "TODO: implement course counting",
"subjects": "TODO: implement subject counting"
},
"banner_api": {
"status": "connected"
},
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
/// Metrics endpoint for monitoring
async fn metrics(State(_state): State<BannerState>) -> Json<Value> {
// For now, return basic metrics structure
Json(json!({
"redis": {
"status": "connected",
"connected_clients": "TODO: implement client counting",
"used_memory": "TODO: implement memory tracking"
},
"cache": {
"courses": {
"count": "TODO: implement course counting"
},
"subjects": {
"count": "TODO: implement subject counting"
}
},
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}