mirror of
https://github.com/Xevion/banner.git
synced 2025-12-09 10:06:34 -06:00
Compare commits
4 Commits
2ec899cf25
...
c7117f14a3
| Author | SHA1 | Date | |
|---|---|---|---|
| c7117f14a3 | |||
| cb8a595326 | |||
| ac70306c04 | |||
| 9972357cf6 |
@@ -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}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
("_", ×tamp_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),
|
||||||
("_", ×tamp_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),
|
||||||
("_", ×tamp_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),
|
||||||
("_", ×tamp_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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
6
src/banner/util.rs
Normal 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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¤t_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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
24
src/bot/utils.rs
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! Application-specific error types.
|
||||||
|
|
||||||
|
pub type Error = anyhow::Error;
|
||||||
|
pub type Result<T, E = Error> = anyhow::Result<T, E>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
101
src/main.rs
101
src/main.rs
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
79
src/services/web.rs
Normal 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
5
src/web/mod.rs
Normal 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
86
src/web/routes.rs
Normal 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()
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user