feat: smart day string, terse refactor and use types properly, work on unimplemented commands lightly, util modules,

This commit is contained in:
2025-08-27 13:46:41 -05:00
parent cb8a595326
commit c7117f14a3
18 changed files with 228 additions and 111 deletions

View File

@@ -1,6 +1,6 @@
//! Main Banner API client implementation.
use crate::banner::{SessionManager, models::*, query::SearchQuery};
use crate::banner::{models::*, query::SearchQuery, session::SessionManager, util::user_agent};
use anyhow::{Context, Result};
use axum::http::HeaderValue;
use reqwest::Client;
@@ -166,7 +166,7 @@ impl BannerApi {
pub async fn get_campuses(
&self,
search: &str,
term: i32,
term: &str,
offset: i32,
max_results: i32,
) -> Result<Vec<Pair>> {
@@ -178,7 +178,7 @@ impl BannerApi {
let url = format!("{}/classSearch/get_campus", self.base_url);
let params = [
("searchTerm", search),
("term", &term.to_string()),
("term", term),
("offset", &offset.to_string()),
("max", &max_results.to_string()),
("uniqueSessionId", &session_id),
@@ -205,10 +205,10 @@ impl BannerApi {
pub async fn get_course_meeting_time(
&self,
term: &str,
crn: i32,
crn: &str,
) -> Result<Vec<MeetingScheduleInfo>> {
let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url);
let params = [("term", term), ("courseReferenceNumber", &crn.to_string())];
let params = [("term", term), ("courseReferenceNumber", crn)];
let response = self
.client
@@ -242,14 +242,14 @@ impl BannerApi {
));
}
#[derive(serde::Deserialize)]
struct ResponseWrapper {
fmt: Vec<MeetingTimeResponse>,
}
let response: MeetingTimesApiResponse =
response.json().await.context("Failed to parse response")?;
let wrapper: ResponseWrapper = response.json().await.context("Failed to parse response")?;
Ok(wrapper.fmt.into_iter().map(|m| m.schedule_info()).collect())
Ok(response
.fmt
.into_iter()
.map(|m| m.schedule_info())
.collect())
}
/// Performs a search for courses.
@@ -357,10 +357,10 @@ impl BannerApi {
}
/// Gets course details (placeholder - needs implementation).
pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> {
pub async fn get_course_details(&self, term: &str, crn: &str) -> Result<ClassDetails> {
let body = serde_json::json!({
"term": term.to_string(),
"courseReferenceNumber": crn.to_string(),
"term": term,
"courseReferenceNumber": crn,
"first": "first"
});
@@ -382,11 +382,6 @@ impl BannerApi {
}
}
/// Returns a browser-like user agent string.
fn user_agent() -> &'static str {
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
}
/// Attempt to parse JSON and, on failure, include a contextual snippet around the error location
fn parse_json_with_context<T: serde::de::DeserializeOwned>(body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {

View File

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

View File

@@ -59,6 +59,24 @@ pub struct Course {
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)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassDetails {

View File

@@ -1,7 +1,7 @@
use bitflags::{Flags, bitflags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, fmt::Display, str::FromStr};
use std::{cmp::Ordering, collections::HashSet, fmt::Display, str::FromStr};
use super::terms::Term;
@@ -159,6 +159,19 @@ impl DayOfWeek {
DayOfWeek::Sunday => "Su",
}
}
/// Convert to full string representation
pub fn to_full_string(self) -> &'static str {
match self {
DayOfWeek::Monday => "Monday",
DayOfWeek::Tuesday => "Tuesday",
DayOfWeek::Wednesday => "Wednesday",
DayOfWeek::Thursday => "Thursday",
DayOfWeek::Friday => "Friday",
DayOfWeek::Saturday => "Saturday",
DayOfWeek::Sunday => "Sunday",
}
}
}
impl TryFrom<MeetingDays> for DayOfWeek {
@@ -423,18 +436,36 @@ impl MeetingScheduleInfo {
}
/// Get formatted days string
pub fn days_string(&self) -> String {
pub fn days_string(&self) -> Option<String> {
if self.days.is_empty() {
"None".to_string()
} else if self.days.is_all() {
"Everyday".to_string()
} else {
self.days_of_week()
.iter()
.map(|day| day.to_short_string())
.collect::<Vec<_>>()
.join("")
return None;
}
if self.days.is_all() {
return Some("Everyday".to_string());
}
let days_of_week = self.days_of_week();
if days_of_week.len() == 1 {
return Some(days_of_week[0].to_full_string().to_string());
}
// Mapper function to get the short string representation of the day of week
let mapper = {
let ambiguous = self.days.intersects(
MeetingDays::Tuesday
| MeetingDays::Thursday
| MeetingDays::Saturday
| MeetingDays::Sunday,
);
if ambiguous {
|day: &DayOfWeek| day.to_short_string().to_string()
} else {
|day: &DayOfWeek| day.to_short_string().chars().next().unwrap().to_string()
}
};
Some(days_of_week.iter().map(mapper).collect::<String>())
}
/// Returns a formatted string representing the location of the meeting

View File

@@ -1,5 +1,6 @@
//! Session management for Banner API.
use crate::banner::util::user_agent;
use anyhow::Result;
use rand::distributions::{Alphanumeric, DistString};
use reqwest::Client;
@@ -195,8 +196,3 @@ impl SessionManager {
.to_string()
}
}
/// Returns a browser-like user agent string
fn user_agent() -> &'static str {
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -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 banner;
pub mod bot;
pub mod error;
pub mod services;
pub mod web;

View File

@@ -18,6 +18,7 @@ mod app_state;
mod banner;
mod bot;
mod config;
mod error;
mod services;
mod web;

View File

@@ -1,5 +1,5 @@
use super::Service;
use crate::web::{BannerState, create_banner_router};
use crate::web::{BannerState, create_router};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
@@ -30,7 +30,7 @@ impl Service for WebService {
async fn run(&mut self) -> Result<(), anyhow::Error> {
// Create the main router with Banner API routes
let app = create_banner_router(self.banner_state.clone());
let app = create_router(self.banner_state.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
info!(

View File

@@ -3,7 +3,7 @@
use axum::{Router, extract::State, response::Json, routing::get};
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::{debug, info};
use tracing::info;
/// Shared application state for web server
#[derive(Clone)]
@@ -13,7 +13,7 @@ pub struct BannerState {
}
/// Creates the web server router
pub fn create_banner_router(state: BannerState) -> Router {
pub fn create_router(state: BannerState) -> Router {
Router::new()
.route("/", get(root))
.route("/health", get(health))
@@ -22,9 +22,7 @@ pub fn create_banner_router(state: BannerState) -> Router {
.with_state(state)
}
/// Root endpoint - shows API info
async fn root() -> Json<Value> {
debug!("root endpoint accessed");
Json(json!({
"message": "Banner Discord Bot API",
"version": "0.1.0",