mirror of
https://github.com/Xevion/banner.git
synced 2025-12-06 01:14:22 -06:00
feat: smart day string, terse refactor and use types properly, work on unimplemented commands lightly, util modules,
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
//! 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;
|
||||||
@@ -166,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>> {
|
||||||
@@ -178,7 +178,7 @@ 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),
|
||||||
@@ -205,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
|
||||||
@@ -242,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.
|
||||||
@@ -357,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"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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
|
/// 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) {
|
||||||
|
|||||||
@@ -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, fmt::Display, str::FromStr};
|
use std::{cmp::Ordering, collections::HashSet, fmt::Display, str::FromStr};
|
||||||
|
|
||||||
use super::terms::Term;
|
use super::terms::Term;
|
||||||
|
|
||||||
@@ -159,6 +159,19 @@ impl DayOfWeek {
|
|||||||
DayOfWeek::Sunday => "Su",
|
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 {
|
impl TryFrom<MeetingDays> for DayOfWeek {
|
||||||
@@ -423,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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -195,8 +196,3 @@ impl SessionManager {
|
|||||||
.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 !(1000..=9999).contains(&code) {
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod app_state;
|
|||||||
mod banner;
|
mod banner;
|
||||||
mod bot;
|
mod bot;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod error;
|
||||||
mod services;
|
mod services;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::Service;
|
use super::Service;
|
||||||
use crate::web::{BannerState, create_banner_router};
|
use crate::web::{BannerState, create_router};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -30,7 +30,7 @@ impl Service for WebService {
|
|||||||
|
|
||||||
async fn run(&mut self) -> Result<(), anyhow::Error> {
|
async fn run(&mut self) -> Result<(), anyhow::Error> {
|
||||||
// Create the main router with Banner API routes
|
// 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));
|
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use axum::{Router, extract::State, response::Json, routing::get};
|
use axum::{Router, extract::State, response::Json, routing::get};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info};
|
use tracing::info;
|
||||||
|
|
||||||
/// Shared application state for web server
|
/// Shared application state for web server
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -13,7 +13,7 @@ pub struct BannerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the web server router
|
/// Creates the web server router
|
||||||
pub fn create_banner_router(state: BannerState) -> Router {
|
pub fn create_router(state: BannerState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
@@ -22,9 +22,7 @@ pub fn create_banner_router(state: BannerState) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root endpoint - shows API info
|
|
||||||
async fn root() -> Json<Value> {
|
async fn root() -> Json<Value> {
|
||||||
debug!("root endpoint accessed");
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"message": "Banner Discord Bot API",
|
"message": "Banner Discord Bot API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user