docs: add trivial documentation for all types, functions, packages etc.

This commit is contained in:
2025-08-26 11:39:30 -05:00
parent deef4cabaa
commit 5a722d16c6
15 changed files with 211 additions and 195 deletions

View File

@@ -1,3 +1,4 @@
// Package main is the entry point for the banner application.
package main package main
import ( import (
@@ -73,6 +74,7 @@ func init() {
discordgo.Logger = internal.DiscordGoLogger discordgo.Logger = internal.DiscordGoLogger
} }
// initRedis initializes the Redis client and pings the server to ensure a connection.
func initRedis(cfg *config.Config) { func initRedis(cfg *config.Config) {
// Setup redis // Setup redis
redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL") redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")

View File

@@ -20,10 +20,12 @@ import (
"resty.dev/v3" "resty.dev/v3"
) )
// API provides a client for interacting with the Banner API.
type API struct { type API struct {
config *config.Config config *config.Config
} }
// New creates a new API client with the given configuration.
func New(config *config.Config) *API { func New(config *config.Config) *API {
return &API{config: config} return &API{config: config}
} }
@@ -34,7 +36,7 @@ var (
expiryTime = 25 * time.Minute expiryTime = 25 * time.Minute
) )
// SessionMiddleware creates a Resty middleware that resets the session timer on each Banner API call. // SessionMiddleware creates a Resty middleware that resets the session timer on each successful Banner API call.
func SessionMiddleware(c *resty.Client, r *resty.Response) error { func SessionMiddleware(c *resty.Client, r *resty.Response) error {
// log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware") // log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware")
@@ -48,8 +50,8 @@ func SessionMiddleware(c *resty.Client, r *resty.Response) error {
return nil return nil
} }
// GenerateSession generates a new session ID (nonce) for use with the Banner API. // GenerateSession generates a new session ID for use with the Banner API.
// Don't use this function directly, use GetSession instead. // This function should not be used directly; use EnsureSession instead.
func GenerateSession() string { func GenerateSession() string {
return internal.RandomString(5) + internal.Nonce() return internal.RandomString(5) + internal.Nonce()
} }
@@ -57,7 +59,7 @@ func GenerateSession() string {
var terms []BannerTerm var terms []BannerTerm
var lastTermUpdate time.Time var lastTermUpdate time.Time
// TryReloadTerms attempts to reload the terms if they are not loaded or the last update was more than 24 hours ago // TryReloadTerms attempts to reload the terms if they are not loaded or if the last update was more than 24 hours ago.
func (a *API) TryReloadTerms() error { func (a *API) TryReloadTerms() error {
if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour { if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour {
return nil return nil
@@ -74,8 +76,9 @@ func (a *API) TryReloadTerms() error {
return nil return nil
} }
// IsTermArchived checks if the given term is archived // IsTermArchived checks if the given term is archived (view only).
// TODO: Add error, switch missing term logic to error //
// TODO: Add error handling for when a term does not exist.
func (a *API) IsTermArchived(term string) bool { func (a *API) IsTermArchived(term string) bool {
// Ensure the terms are loaded // Ensure the terms are loaded
err := a.TryReloadTerms() err := a.TryReloadTerms()
@@ -106,21 +109,25 @@ func (a *API) EnsureSession() string {
return latestSession return latestSession
} }
// Pair represents a key-value pair from the Banner API.
type Pair struct { type Pair struct {
Code string `json:"code"` Code string `json:"code"`
Description string `json:"description"` Description string `json:"description"`
} }
// BannerTerm represents a term in the Banner system.
type BannerTerm Pair type BannerTerm Pair
// Instructor represents an instructor in the Banner system.
type Instructor Pair type Instructor Pair
// Archived returns true if the term is in it's archival state (view only) // Archived returns true if the term is in an archival (view-only) state.
func (term BannerTerm) Archived() bool { func (term BannerTerm) Archived() bool {
return strings.Contains(term.Description, "View Only") return strings.Contains(term.Description, "View Only")
} }
// GetTerms retrieves and parses the term information for a given search term. // GetTerms retrieves a list of terms from the Banner API.
// Page number must be at least 1. // The page number must be at least 1.
func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) { func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) {
// Ensure offset is valid // Ensure offset is valid
if page <= 0 { if page <= 0 {
@@ -148,8 +155,8 @@ func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, e
return *terms, nil return *terms, nil
} }
// SelectTerm selects the given term in the Banner system. // SelectTerm selects a term in the Banner system for the given session.
// This function completes the initial term selection process, which is required before any other API calls can be made with the session ID. // This is required before other API calls can be made.
func (a *API) SelectTerm(term string, sessionID string) error { func (a *API) SelectTerm(term string, sessionID string) error {
form := url.Values{ form := url.Values{
"term": {term}, "term": {term},
@@ -192,8 +199,8 @@ func (a *API) SelectTerm(term string, sessionID string) error {
return nil return nil
} }
// GetPartOfTerms retrieves and parses the part of term information for a given term. // GetPartOfTerms retrieves a list of parts of a term from the Banner API.
// Ensure that the offset is greater than 0. // The page number must be at least 1.
func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) { func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) {
// Ensure offset is valid // Ensure offset is valid
if offset <= 0 { if offset <= 0 {
@@ -223,10 +230,7 @@ func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int
return *terms, nil return *terms, nil
} }
// GetInstructors retrieves and parses the instructor information for a given search term. // GetInstructors retrieves a list of instructors from the Banner API.
// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term.
// This function is included for completeness, but probably isn't useful.
// Ensure that the offset is greater than 0.
func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) { func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) {
// Ensure offset is valid // Ensure offset is valid
if offset <= 0 { if offset <= 0 {
@@ -256,11 +260,13 @@ func (a *API) GetInstructors(search string, term string, offset int, maxResults
return *instructors, nil return *instructors, nil
} }
// ClassDetails represents the details of a course. // ClassDetails represents the detailed information for a class.
// TODO: Finish this struct & function //
// TODO: Implement this struct and the associated GetCourseDetails function.
type ClassDetails struct { type ClassDetails struct {
} }
// GetCourseDetails retrieves the details for a specific course.
func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) { func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
body, err := json.Marshal(map[string]string{ body, err := json.Marshal(map[string]string{
"term": strconv.Itoa(term), "term": strconv.Itoa(term),
@@ -289,7 +295,7 @@ func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
return details, nil return details, nil
} }
// Search invokes a search on the Banner system with the given query and returns the results. // Search performs a search for courses with the given query and returns the results.
func (a *API) Search(term string, query *Query, sort string, sortDescending bool) (*models.SearchResult, error) { func (a *API) Search(term string, query *Query, sort string, sortDescending bool) (*models.SearchResult, error) {
a.ResetDataForm() a.ResetDataForm()
@@ -322,9 +328,8 @@ func (a *API) Search(term string, query *Query, sort string, sortDescending bool
return searchResult, nil return searchResult, nil
} }
// GetSubjects retrieves and parses the subject information for a given search term. // GetSubjects retrieves a list of subjects from the Banner API.
// The results of this response shouldn't change much, but technically could as new majors are developed, or old ones are removed. // The page number must be at least 1.
// Ensure that the offset is greater than 0.
func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) { func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid // Ensure offset is valid
if offset <= 0 { if offset <= 0 {
@@ -354,10 +359,8 @@ func (a *API) GetSubjects(search string, term string, offset int, maxResults int
return *subjects, nil return *subjects, nil
} }
// GetCampuses retrieves and parses the campus information for a given search term. // GetCampuses retrieves a list of campuses from the Banner API.
// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term. // The page number must be at least 1.
// This function is included for completeness, but probably isn't useful.
// Ensure that the offset is greater than 0.
func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) { func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid // Ensure offset is valid
if offset <= 0 { if offset <= 0 {
@@ -387,10 +390,8 @@ func (a *API) GetCampuses(search string, term int, offset int, maxResults int) (
return *campuses, nil return *campuses, nil
} }
// GetInstructionalMethods retrieves and parses the instructional method information for a given search term. // GetInstructionalMethods retrieves a list of instructional methods from the Banner API.
// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term. // The page number must be at least 1.
// This function is included for completeness, but probably isn't useful.
// Ensure that the offset is greater than 0.
func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) { func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid // Ensure offset is valid
if offset <= 0 { if offset <= 0 {
@@ -419,9 +420,7 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma
return *methods, nil return *methods, nil
} }
// GetCourseMeetingTime retrieves the meeting time information for a course based on the given term and course reference number (CRN). // GetCourseMeetingTime retrieves the meeting time information for a course.
// It makes an HTTP GET request to the appropriate API endpoint and parses the response to extract the meeting time data.
// The function returns a MeetingTimeResponse struct containing the extracted information.
func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) { func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) {
type responseWrapper struct { type responseWrapper struct {
Fmt []models.MeetingTimeResponse `json:"fmt"` Fmt []models.MeetingTimeResponse `json:"fmt"`
@@ -446,7 +445,8 @@ func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeRespo
return result.Fmt, nil return result.Fmt, nil
} }
// ResetDataForm makes a POST request that needs to be made upon before new search requests can be made. // ResetDataForm resets the search form in the Banner system.
// This must be called before a new search can be performed.
func (a *API) ResetDataForm() { func (a *API) ResetDataForm() {
req := a.config.Client.NewRequest() req := a.config.Client.NewRequest()
@@ -456,8 +456,7 @@ func (a *API) ResetDataForm() {
} }
} }
// GetCourse retrieves the course information. // GetCourse retrieves course information from the Redis cache.
// This course does not retrieve directly from the API, but rather uses scraped data stored in Redis.
func (a *API) GetCourse(crn string) (*models.Course, error) { func (a *API) GetCourse(crn string) (*models.Course, error) {
// Create a timeout context for Redis operations // Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)

View File

@@ -1,3 +1,4 @@
// Package api provides the core functionality for interacting with the Banner API.
package api package api
import ( import (
@@ -17,16 +18,19 @@ const (
) )
var ( var (
// PriorityMajors is a list of majors that are considered to be high priority for scraping. This list is used to determine which majors to scrape first/most often. // PriorityMajors is a list of majors that are considered to be high priority for scraping.
// This list is used to determine which majors to scrape first/most often.
PriorityMajors = []string{"CS", "CPE", "MAT", "EE", "IS"} PriorityMajors = []string{"CS", "CPE", "MAT", "EE", "IS"}
// AncillaryMajors is a list of majors that are considered to be low priority for scraping. This list will not contain any majors that are in PriorityMajors. // AncillaryMajors is a list of majors that are considered to be low priority for scraping.
// This list will not contain any majors that are in PriorityMajors.
AncillaryMajors []string AncillaryMajors []string
// AllMajors is a list of all majors that are available in the Banner system. // AllMajors is a list of all majors that are available in the Banner system.
AllMajors []string AllMajors []string
) )
// Scrape scrapes the API for all courses and stores them in Redis. // Scrape retrieves all courses from the Banner API and stores them in Redis.
// This is a long-running process that should be run in a goroutine. // This is a long-running process that should be run in a goroutine.
//
// TODO: Switch from hardcoded term to dynamic term // TODO: Switch from hardcoded term to dynamic term
func (a *API) Scrape() error { func (a *API) Scrape() error {
// For each subject, retrieve all courses // For each subject, retrieve all courses
@@ -68,7 +72,8 @@ func (a *API) Scrape() error {
return nil return nil
} }
// GetExpiredSubjects returns a list of subjects that are expired and should be scraped. // GetExpiredSubjects returns a list of subjects that have expired and should be scraped again.
// It checks Redis for the "scraped" status of each major for the current term.
func (a *API) GetExpiredSubjects() ([]string, error) { func (a *API) GetExpiredSubjects() ([]string, error) {
term := Default(time.Now()).ToString() term := Default(time.Now()).ToString()
subjects := make([]string, 0) subjects := make([]string, 0)
@@ -100,8 +105,8 @@ func (a *API) GetExpiredSubjects() ([]string, error) {
return subjects, nil return subjects, nil
} }
// ScrapeMajor is the scraping invocation for a specific major. // ScrapeMajor scrapes all courses for a specific major.
// This function does not check whether scraping is required at this time, it is assumed that the caller has already done so. // This function does not check whether scraping is required at this time; it is assumed that the caller has already done so.
func (a *API) ScrapeMajor(subject string) error { func (a *API) ScrapeMajor(subject string) error {
offset := 0 offset := 0
totalClassCount := 0 totalClassCount := 0
@@ -180,9 +185,7 @@ func (a *API) ScrapeMajor(subject string) error {
} }
// CalculateExpiry calculates the expiry time until the next scrape for a major. // CalculateExpiry calculates the expiry time until the next scrape for a major.
// term is the term for which the relevant course is occurring within. // The duration is based on the number of courses, whether the major is a priority, and if the term is archived.
// count is the number of courses that were scraped.
// priority is a boolean indicating whether the major is a priority major.
func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration { func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
// An hour for every 100 classes // An hour for every 100 classes
baseExpiry := time.Hour * time.Duration(count/100) baseExpiry := time.Hour * time.Duration(count/100)
@@ -222,7 +225,7 @@ func (a *API) CalculateExpiry(term string, count int, priority bool) time.Durati
} }
// IntakeCourse stores a course in Redis. // IntakeCourse stores a course in Redis.
// This function is mostly a stub for now, but will be used to handle change identification, notifications, and SQLite upserts in the future. // This function will be used to handle change identification, notifications, and SQLite upserts in the future.
func (a *API) IntakeCourse(course models.Course) error { func (a *API) IntakeCourse(course models.Course) error {
// Create a timeout context for Redis operations // Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)

View File

@@ -33,7 +33,7 @@ const (
) )
// Query represents a search query for courses. // Query represents a search query for courses.
// It is a builder struct that allows for chaining of methods to build a query. // It is a builder that allows for chaining methods to construct a query.
type Query struct { type Query struct {
subject *string subject *string
title *string title *string
@@ -58,25 +58,25 @@ func NewQuery() *Query {
return &Query{maxResults: 8, offset: 0} return &Query{maxResults: 8, offset: 0}
} }
// Subject sets the subject for the query // Subject sets the subject for the query.
func (q *Query) Subject(subject string) *Query { func (q *Query) Subject(subject string) *Query {
q.subject = &subject q.subject = &subject
return q return q
} }
// Title sets the title for the query // Title sets the title for the query.
func (q *Query) Title(title string) *Query { func (q *Query) Title(title string) *Query {
q.title = &title q.title = &title
return q return q
} }
// Keywords sets the keywords for the query // Keywords sets the keywords for the query.
func (q *Query) Keywords(keywords []string) *Query { func (q *Query) Keywords(keywords []string) *Query {
q.keywords = &keywords q.keywords = &keywords
return q return q
} }
// Keyword adds a keyword to the query // Keyword adds a keyword to the query.
func (q *Query) Keyword(keyword string) *Query { func (q *Query) Keyword(keyword string) *Query {
if q.keywords == nil { if q.keywords == nil {
q.keywords = &[]string{keyword} q.keywords = &[]string{keyword}
@@ -86,86 +86,86 @@ func (q *Query) Keyword(keyword string) *Query {
return q return q
} }
// OpenOnly sets the open only flag for the query // OpenOnly sets whether to search for open courses only.
func (q *Query) OpenOnly(openOnly bool) *Query { func (q *Query) OpenOnly(openOnly bool) *Query {
q.openOnly = &openOnly q.openOnly = &openOnly
return q return q
} }
// TermPart sets the term part for the query // TermPart sets the term part for the query.
func (q *Query) TermPart(termPart []string) *Query { func (q *Query) TermPart(termPart []string) *Query {
q.termPart = &termPart q.termPart = &termPart
return q return q
} }
// Campus sets the campuses for the query // Campus sets the campuses for the query.
func (q *Query) Campus(campus []string) *Query { func (q *Query) Campus(campus []string) *Query {
q.campus = &campus q.campus = &campus
return q return q
} }
// InstructionalMethod sets the instructional methods for the query // InstructionalMethod sets the instructional methods for the query.
func (q *Query) InstructionalMethod(instructionalMethod []string) *Query { func (q *Query) InstructionalMethod(instructionalMethod []string) *Query {
q.instructionalMethod = &instructionalMethod q.instructionalMethod = &instructionalMethod
return q return q
} }
// Attributes sets the attributes for the query // Attributes sets the attributes for the query.
func (q *Query) Attributes(attributes []string) *Query { func (q *Query) Attributes(attributes []string) *Query {
q.attributes = &attributes q.attributes = &attributes
return q return q
} }
// Instructor sets the instructors for the query // Instructor sets the instructors for the query.
func (q *Query) Instructor(instructor []uint64) *Query { func (q *Query) Instructor(instructor []uint64) *Query {
q.instructor = &instructor q.instructor = &instructor
return q return q
} }
// StartTime sets the start time for the query // StartTime sets the start time for the query.
func (q *Query) StartTime(startTime time.Duration) *Query { func (q *Query) StartTime(startTime time.Duration) *Query {
q.startTime = &startTime q.startTime = &startTime
return q return q
} }
// EndTime sets the end time for the query // EndTime sets the end time for the query.
func (q *Query) EndTime(endTime time.Duration) *Query { func (q *Query) EndTime(endTime time.Duration) *Query {
q.endTime = &endTime q.endTime = &endTime
return q return q
} }
// Credits sets the credit range for the query // Credits sets the credit range for the query.
func (q *Query) Credits(low int, high int) *Query { func (q *Query) Credits(low int, high int) *Query {
q.minCredits = &low q.minCredits = &low
q.maxCredits = &high q.maxCredits = &high
return q return q
} }
// MinCredits sets the minimum credits for the query // MinCredits sets the minimum credits for the query.
func (q *Query) MinCredits(value int) *Query { func (q *Query) MinCredits(value int) *Query {
q.minCredits = &value q.minCredits = &value
return q return q
} }
// MaxCredits sets the maximum credits for the query // MaxCredits sets the maximum credits for the query.
func (q *Query) MaxCredits(value int) *Query { func (q *Query) MaxCredits(value int) *Query {
q.maxCredits = &value q.maxCredits = &value
return q return q
} }
// CourseNumbers sets the course number range for the query // CourseNumbers sets the course number range for the query.
func (q *Query) CourseNumbers(low int, high int) *Query { func (q *Query) CourseNumbers(low int, high int) *Query {
q.courseNumberRange = &Range{low, high} q.courseNumberRange = &Range{low, high}
return q return q
} }
// Offset sets the offset for the query, allowing for pagination // Offset sets the offset for pagination.
func (q *Query) Offset(offset int) *Query { func (q *Query) Offset(offset int) *Query {
q.offset = offset q.offset = offset
return q return q
} }
// MaxResults sets the maximum number of results for the query // MaxResults sets the maximum number of results to return.
func (q *Query) MaxResults(maxResults int) *Query { func (q *Query) MaxResults(maxResults int) *Query {
q.maxResults = maxResults q.maxResults = maxResults
return q return q
@@ -177,8 +177,8 @@ type Range struct {
High int High int
} }
// FormatTimeParameter formats a time.Duration into a tuple of strings // FormatTimeParameter formats a time.Duration into a tuple of strings for use in a POST request.
// This is mostly a private helper to keep the parameter formatting for both the start and end time consistent together // It returns the hour, minute, and meridiem (AM/PM) as separate strings.
func FormatTimeParameter(d time.Duration) (string, string, string) { func FormatTimeParameter(d time.Duration) (string, string, string) {
hourParameter, minuteParameter, meridiemParameter := "", "", "" hourParameter, minuteParameter, meridiemParameter := "", "", ""
@@ -204,7 +204,7 @@ func FormatTimeParameter(d time.Duration) (string, string, string) {
return hourParameter, minuteParameter, meridiemParameter return hourParameter, minuteParameter, meridiemParameter
} }
// Paramify converts a Query into a map of parameters that can be used in a POST request // Paramify converts a Query into a map of parameters for a POST request.
// This function assumes each query key only appears once. // This function assumes each query key only appears once.
func (q *Query) Paramify() map[string]string { func (q *Query) Paramify() map[string]string {
params := map[string]string{} params := map[string]string{}

View File

@@ -7,6 +7,7 @@ import (
log "github.com/rs/zerolog/log" log "github.com/rs/zerolog/log"
) )
// Setup makes the initial requests to set up the session cookies for the application.
func (a *API) Setup() { func (a *API) Setup() {
// Makes the initial requests that sets up the session cookies for the rest of the application // Makes the initial requests that sets up the session cookies for the rest of the application
log.Info().Msg("Setting up session...") log.Info().Msg("Setting up session...")

View File

@@ -18,6 +18,7 @@ const (
Fall Fall
) )
// Term represents a school term, consisting of a year and a season.
type Term struct { type Term struct {
Year uint16 Year uint16
Season uint8 Season uint8
@@ -32,16 +33,14 @@ func init() {
SpringRange, SummerRange, FallRange = GetYearDayRange(loc, uint16(time.Now().Year())) SpringRange, SummerRange, FallRange = GetYearDayRange(loc, uint16(time.Now().Year()))
} }
// YearDayRange represents the start and end day of a term within a year.
type YearDayRange struct { type YearDayRange struct {
Start uint16 Start uint16
End uint16 End uint16
} }
// GetYearDayRange returns the start and end day of each term for the given year. // GetYearDayRange returns the start and end day of each term for the given year.
// This could technically introduce race conditions, but it's more likely confusion from UTC will be a greater issue. // The ranges are inclusive of the start day and exclusive of the end day.
// Spring: January 14th to May
// Summer: May 25th - August 15th
// Fall: August 18th - December 10th
func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) { func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) {
springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, loc).YearDay() springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, loc).YearDay()
springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, loc).YearDay() springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, loc).YearDay()
@@ -62,21 +61,9 @@ func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRang
} }
} }
// GetCurrentTerm returns the current term, and the next term. Only the first term is nillable. // GetCurrentTerm returns the current and next terms based on the provided time.
// YearDay ranges are inclusive of the start, and exclusive of the end. // The current term can be nil if the time falls between terms.
// You can think of the 'year' part of it as the 'school year', the second part of the 20XX-(20XX+1) phrasing. // The 'year' in the term corresponds to the academic year, which may differ from the calendar year.
//
// e.g. the Fall 2025, Spring 2026, and Summer 2026 terms all occur as part of the 2025-2026 school year. The second year, 2026, is the part used in all term identifiers.
// So even though the Fall 2025 term occurs in 2025, it uses the 2026 year in it's term identifier.
//
// Fall of 2024 => 202510
// Spring of 2025 => 202520
// Summer of 2025 => 202530
// Fall of 2025 => 202610
// Spring of 2026 => 202620
// Summer of 2026 => 202630
//
// Reading out 'Fall of 2024' as '202510' might be confusing, but it's correct.
func GetCurrentTerm(now time.Time) (*Term, *Term) { func GetCurrentTerm(now time.Time) (*Term, *Term) {
literalYear := uint16(now.Year()) literalYear := uint16(now.Year())
dayOfYear := uint16(now.YearDay()) dayOfYear := uint16(now.YearDay())
@@ -112,7 +99,7 @@ func GetCurrentTerm(now time.Time) (*Term, *Term) {
panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear)) panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear))
} }
// ParseTerm converts a Banner term code to a Term struct // ParseTerm converts a Banner term code string to a Term struct.
func ParseTerm(code string) Term { func ParseTerm(code string) Term {
year, _ := strconv.ParseUint(code[0:4], 10, 16) year, _ := strconv.ParseUint(code[0:4], 10, 16)
@@ -133,7 +120,7 @@ func ParseTerm(code string) Term {
} }
} }
// TermToBannerTerm converts a Term struct to a Banner term code // ToString converts a Term struct to a Banner term code string.
func (term Term) ToString() string { func (term Term) ToString() string {
var season string var season string
switch term.Season { switch term.Season {
@@ -148,7 +135,7 @@ func (term Term) ToString() string {
return fmt.Sprintf("%d%s", term.Year, season) return fmt.Sprintf("%d%s", term.Year, season)
} }
// Default chooses the default term, which is the current term if it exists, otherwise the next term. // Default returns the default term, which is the current term if it exists, otherwise the next term.
func Default(t time.Time) Term { func Default(t time.Time) Term {
currentTerm, nextTerm := GetCurrentTerm(t) currentTerm, nextTerm := GetCurrentTerm(t)
if currentTerm == nil { if currentTerm == nil {

View File

@@ -22,11 +22,14 @@ const (
ICalTimestampFormatLocal = "20060102T150405" ICalTimestampFormatLocal = "20060102T150405"
) )
// CommandHandler is a function that handles a slash command interaction.
type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error
var ( var (
// CommandDefinitions is a list of all the bot's command definitions.
CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition} CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
CommandHandlers = map[string]CommandHandler{ // CommandHandlers is a map of command names to their handlers.
CommandHandlers = map[string]CommandHandler{
TimeCommandDefinition.Name: TimeCommandHandler, TimeCommandDefinition.Name: TimeCommandHandler,
TermCommandDefinition.Name: TermCommandHandler, TermCommandDefinition.Name: TermCommandHandler,
SearchCommandDefinition.Name: SearchCommandHandler, SearchCommandDefinition.Name: SearchCommandHandler,
@@ -82,6 +85,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
// SearchCommandHandler handles the /search command, which allows users to search for courses.
func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
query := api.NewQuery().Credits(3, 6) query := api.NewQuery().Credits(3, 6)
@@ -283,6 +287,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
// TermCommandHandler handles the /terms command, which allows users to search for terms.
func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
@@ -353,6 +358,7 @@ var TimeCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
// TimeCommandHandler handles the /time command, which allows users to get the meeting times for a course.
func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
fetch_time := time.Now() fetch_time := time.Now()
crn := i.ApplicationCommandData().Options[0].IntValue() crn := i.ApplicationCommandData().Options[0].IntValue()
@@ -428,6 +434,7 @@ var IcsCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
// IcsCommandHandler handles the /ics command, which allows users to generate an ICS file for a course.
func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Parse all options // Parse all options
options := internal.ParseOptions(i.ApplicationCommandData().Options) options := internal.ParseOptions(i.ApplicationCommandData().Options)

View File

@@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// RegisterHandlers registers the bot's command handlers.
func (b *Bot) RegisterHandlers() { func (b *Bot) RegisterHandlers() {
b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) { b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) {
// Handle commands during restart (highly unlikely, but just in case) // Handle commands during restart (highly unlikely, but just in case)

View File

@@ -1,3 +1,4 @@
// Package bot provides the core functionality for the Discord bot.
package bot package bot
import ( import (
@@ -10,6 +11,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// Bot represents the state of the Discord bot.
type Bot struct { type Bot struct {
Session *discordgo.Session Session *discordgo.Session
API *api.API API *api.API
@@ -17,14 +19,17 @@ type Bot struct {
isClosing bool isClosing bool
} }
// New creates a new Bot instance.
func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot { func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot {
return &Bot{Session: s, API: a, Config: c} return &Bot{Session: s, API: a, Config: c}
} }
// SetClosing marks the bot as closing, preventing new commands from being processed.
func (b *Bot) SetClosing() { func (b *Bot) SetClosing() {
b.isClosing = true b.isClosing = true
} }
// GetSession ensures a valid session is available and selects the default term.
func (b *Bot) GetSession() (string, error) { func (b *Bot) GetSession() (string, error) {
sessionID := b.API.EnsureSession() sessionID := b.API.EnsureSession()
term := api.Default(time.Now()).ToString() term := api.Default(time.Now()).ToString()

View File

@@ -8,14 +8,23 @@ import (
"resty.dev/v3" "resty.dev/v3"
) )
// Config holds the application's configuration.
type Config struct { type Config struct {
Ctx context.Context // Ctx is the application's root context.
CancelFunc context.CancelFunc Ctx context.Context
KV *redis.Client // CancelFunc cancels the application's root context.
Client *resty.Client CancelFunc context.CancelFunc
IsDevelopment bool // KV provides access to the Redis cache.
BaseURL string KV *redis.Client
Environment string // Client is the HTTP client for making API requests.
Client *resty.Client
// IsDevelopment is true if the application is running in a development environment.
IsDevelopment bool
// BaseURL is the base URL for the Banner API.
BaseURL string
// Environment is the application's running environment (e.g. "development").
Environment string
// CentralTimeLocation is the time.Location for US Central Time.
CentralTimeLocation *time.Location CentralTimeLocation *time.Location
} }
@@ -23,10 +32,11 @@ const (
CentralTimezoneName = "America/Chicago" CentralTimezoneName = "America/Chicago"
) )
// New creates a new Config instance with a cancellable context.
func New() (*Config, error) { func New() (*Config, error) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
loc, err := time.LoadLocation(CentralTimezoneName) loc, err := time.LoadLocation("America/Chicago")
if err != nil { if err != nil {
cancel() cancel()
return nil, err return nil, err
@@ -39,23 +49,23 @@ func New() (*Config, error) {
}, nil }, nil
} }
// SetBaseURL sets the base URL for API requests // SetBaseURL sets the base URL for the Banner API.
func (c *Config) SetBaseURL(url string) { func (c *Config) SetBaseURL(url string) {
c.BaseURL = url c.BaseURL = url
} }
// SetEnvironment sets the environment // SetEnvironment sets the application's environment.
func (c *Config) SetEnvironment(env string) { func (c *Config) SetEnvironment(env string) {
c.Environment = env c.Environment = env
c.IsDevelopment = env == "development" c.IsDevelopment = env == "development"
} }
// SetClient sets the Resty client // SetClient sets the Resty client for making HTTP requests.
func (c *Config) SetClient(client *resty.Client) { func (c *Config) SetClient(client *resty.Client) {
c.Client = client c.Client = client
} }
// SetRedis sets the Redis client // SetRedis sets the Redis client for caching.
func (c *Config) SetRedis(r *redis.Client) { func (c *Config) SetRedis(r *redis.Client) {
c.KV = r c.KV = r
} }

View File

@@ -1,3 +1,4 @@
// Package config provides the configuration and logging setup for the application.
package config package config
import ( import (
@@ -9,12 +10,7 @@ import (
const timeFormat = "2006-01-02 15:04:05" const timeFormat = "2006-01-02 15:04:05"
var ( // NewConsoleWriter creates a new console writer that splits logs between stdout and stderr.
stdConsole = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: timeFormat}
errConsole = zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: timeFormat}
)
// NewConsoleWriter creates a new console writer with improved formatting for development
func NewConsoleWriter() zerolog.LevelWriter { func NewConsoleWriter() zerolog.LevelWriter {
return &ConsoleLogSplitter{ return &ConsoleLogSplitter{
stdConsole: zerolog.ConsoleWriter{ stdConsole: zerolog.ConsoleWriter{
@@ -36,18 +32,18 @@ func NewConsoleWriter() zerolog.LevelWriter {
} }
} }
// ConsoleLogSplitter implements zerolog.LevelWriter with console formatting // ConsoleLogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs, with console-friendly formatting.
type ConsoleLogSplitter struct { type ConsoleLogSplitter struct {
stdConsole zerolog.ConsoleWriter stdConsole zerolog.ConsoleWriter
errConsole zerolog.ConsoleWriter errConsole zerolog.ConsoleWriter
} }
// Write should not be called // Write is a passthrough to the standard console writer and should not be called directly.
func (c *ConsoleLogSplitter) Write(p []byte) (n int, err error) { func (c *ConsoleLogSplitter) Write(p []byte) (n int, err error) {
return c.stdConsole.Write(p) return c.stdConsole.Write(p)
} }
// WriteLevel write to the appropriate output with console formatting // WriteLevel writes to the appropriate output (stdout or stderr) with console formatting based on the log level.
func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) {
if level <= zerolog.WarnLevel { if level <= zerolog.WarnLevel {
return c.stdConsole.Write(p) return c.stdConsole.Write(p)
@@ -55,22 +51,21 @@ func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, e
return c.errConsole.Write(p) return c.errConsole.Write(p)
} }
// LogSplitter implements zerolog.LevelWriter // LogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs.
type LogSplitter struct { type LogSplitter struct {
Std io.Writer Std io.Writer
Err io.Writer Err io.Writer
} }
// Write should not be called // Write is a passthrough to the standard writer and should not be called directly.
func (l LogSplitter) Write(p []byte) (n int, err error) { func (l LogSplitter) Write(p []byte) (n int, err error) {
return l.Std.Write(p) return l.Std.Write(p)
} }
// WriteLevel write to the appropriate output // WriteLevel writes to the appropriate output (stdout or stderr) based on the log level.
func (l LogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { func (l LogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) {
if level <= zerolog.WarnLevel { if level <= zerolog.WarnLevel {
return l.Std.Write(p) return l.Std.Write(p)
} else {
return l.Err.Write(p)
} }
return l.Err.Write(p)
} }

View File

@@ -2,6 +2,7 @@ package internal
import "fmt" import "fmt"
// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value.
type UnexpectedContentTypeError struct { type UnexpectedContentTypeError struct {
Expected string Expected string
Actual string Actual string

View File

@@ -21,10 +21,10 @@ import (
"banner/internal/config" "banner/internal/config"
) )
// Options is a map of options from a discord command. // Options is a map of options from a Discord command.
type Options map[string]*discordgo.ApplicationCommandInteractionDataOption type Options map[string]*discordgo.ApplicationCommandInteractionDataOption
// GetInt returns the integer value of an option. // GetInt returns the integer value of an option, or 0 if it doesn't exist.
func (o Options) GetInt(key string) int64 { func (o Options) GetInt(key string) int64 {
if opt, ok := o[key]; ok { if opt, ok := o[key]; ok {
return opt.IntValue() return opt.IntValue()
@@ -32,7 +32,7 @@ func (o Options) GetInt(key string) int64 {
return 0 return 0
} }
// ParseOptions parses slash command options into a map. // ParseOptions parses slash command options into a map for easier access.
func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options { func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options {
optionMap := make(Options) optionMap := make(Options)
for _, opt := range options { for _, opt := range options {
@@ -41,12 +41,12 @@ func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption)
return optionMap return optionMap
} }
// AddUserAgent adds a false but consistent user agent to the request // AddUserAgent adds a consistent user agent to the request to mimic a real browser.
func AddUserAgent(req *http.Request) { func AddUserAgent(req *http.Request) {
req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
} }
// ContentTypeMatch checks if the Resty response has the given content type // ContentTypeMatch checks if a Resty response has the given content type.
func ContentTypeMatch(res *resty.Response, expectedContentType string) bool { func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
contentType := res.Header().Get("Content-Type") contentType := res.Header().Get("Content-Type")
if contentType == "" { if contentType == "" {
@@ -57,8 +57,8 @@ func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// RandomString returns a random string of length n using the letterBytes constant // RandomString returns a random string of length n.
// The constant used is specifically chosen to mimic Ellucian's banner session ID generation. // The character set is chosen to mimic Ellucian's Banner session ID generation.
func RandomString(n int) string { func RandomString(n int) string {
b := make([]byte, n) b := make([]byte, n)
for i := range b { for i := range b {
@@ -67,8 +67,7 @@ func RandomString(n int) string {
return string(b) return string(b)
} }
// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface. // DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog.
// It directs all logs to the zerolog implementation.
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) { func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
pc, file, line, _ := runtime.Caller(caller) pc, file, line, _ := runtime.Caller(caller)
@@ -98,13 +97,13 @@ func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
event.Str("file", file).Int("line", line).Str("function", name).Msg(msg) event.Str("file", file).Int("line", line).Str("function", name).Msg(msg)
} }
// Nonce returns a string made up of the current time in milliseconds, Unix epoch/UTC // Nonce returns the current time in milliseconds since the Unix epoch as a string.
// This is typically used as a query parameter to prevent request caching in the browser. // This is typically used as a query parameter to prevent request caching.
func Nonce() string { func Nonce() string {
return strconv.Itoa(int(time.Now().UnixMilli())) return strconv.Itoa(int(time.Now().UnixMilli()))
} }
// Plural is a simple helper function that returns an empty string if n is 1, and "s" otherwise. // Plural returns "s" if n is not 1.
func Plural(n int) string { func Plural(n int) string {
if n == 1 { if n == 1 {
return "" return ""
@@ -112,8 +111,7 @@ func Plural(n int) string {
return "s" return "s"
} }
// Plurale is a simple helper function that returns an empty string if n is 1, and "ess" otherwise. // Plurale returns "es" if n is not 1.
// This is for words that end in "es" when plural.
func Plurale(n int) string { func Plurale(n int) string {
if n == 1 { if n == 1 {
return "" return ""
@@ -121,6 +119,7 @@ func Plurale(n int) string {
return "es" return "es"
} }
// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF").
func WeekdaysToString(days map[time.Weekday]bool) string { func WeekdaysToString(days map[time.Weekday]bool) string {
// If no days are present // If no days are present
numDays := len(days) numDays := len(days)
@@ -166,15 +165,18 @@ func WeekdaysToString(days map[time.Weekday]bool) string {
return str return str
} }
// NaiveTime represents a time of day without a date or timezone.
type NaiveTime struct { type NaiveTime struct {
Hours uint Hours uint
Minutes uint Minutes uint
} }
// Sub returns the duration between two NaiveTime instances.
func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration { func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration {
return time.Hour*time.Duration(nt.Hours-other.Hours) + time.Minute*time.Duration(nt.Minutes-other.Minutes) return time.Hour*time.Duration(nt.Hours-other.Hours) + time.Minute*time.Duration(nt.Minutes-other.Minutes)
} }
// ParseNaiveTime converts an integer representation of time (e.g., 1430) to a NaiveTime struct.
func ParseNaiveTime(integer uint64) *NaiveTime { func ParseNaiveTime(integer uint64) *NaiveTime {
minutes := uint(integer % 100) minutes := uint(integer % 100)
hours := uint(integer / 100) hours := uint(integer / 100)
@@ -182,6 +184,7 @@ func ParseNaiveTime(integer uint64) *NaiveTime {
return &NaiveTime{Hours: hours, Minutes: minutes} return &NaiveTime{Hours: hours, Minutes: minutes}
} }
// String returns a string representation of the NaiveTime in 12-hour format (e.g., "2:30PM").
func (nt NaiveTime) String() string { func (nt NaiveTime) String() string {
meridiem := "AM" meridiem := "AM"
hour := nt.Hours hour := nt.Hours
@@ -194,6 +197,7 @@ func (nt NaiveTime) String() string {
return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem) return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem)
} }
// GetFirstEnv returns the value of the first environment variable that is set.
func GetFirstEnv(key ...string) string { func GetFirstEnv(key ...string) string {
for _, k := range key { for _, k := range key {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {
@@ -203,14 +207,12 @@ func GetFirstEnv(key ...string) string {
return "" return ""
} }
// GetIntPointer returns a pointer to the given value. // GetIntPointer returns a pointer to the given integer.
// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments.
func GetIntPointer(value int) *int { func GetIntPointer(value int) *int {
return &value return &value
} }
// GetFloatPointer returns a pointer to the given value. // GetFloatPointer returns a pointer to the given float.
// This function is useful for discordgo, which inexplicably requires pointers to floats for minLength arguments.
func GetFloatPointer(value float64) *float64 { func GetFloatPointer(value float64) *float64 {
return &value return &value
} }
@@ -244,6 +246,7 @@ var extensionMap = map[string]string{
"image/jxl": "jxl", "image/jxl": "jxl",
} }
// GuessExtension guesses the file extension for a given content type.
func GuessExtension(contentType string) string { func GuessExtension(contentType string) string {
ext, ok := extensionMap[strings.ToLower(contentType)] ext, ok := extensionMap[strings.ToLower(contentType)]
if !ok { if !ok {
@@ -252,7 +255,7 @@ func GuessExtension(contentType string) string {
return ext return ext
} }
// DumpResponse dumps a Resty response body to a file for debugging purposes // DumpResponse dumps the body of a Resty response to a file for debugging.
func DumpResponse(res *resty.Response) { func DumpResponse(res *resty.Response) {
contentType := res.Header().Get("Content-Type") contentType := res.Header().Get("Content-Type")
ext := GuessExtension(contentType) ext := GuessExtension(contentType)
@@ -282,8 +285,7 @@ func DumpResponse(res *resty.Response) {
log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body") log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body")
} }
// ResponseError responds to an interaction with an error message // RespondError responds to an interaction with a formatted error message.
// TODO: Improve with a proper embed and colors
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error { func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
// Optional: log the error // Optional: log the error
if err != nil { if err != nil {
@@ -307,26 +309,25 @@ func RespondError(session *discordgo.Session, interaction *discordgo.Interaction
}) })
} }
// GetFetchedFooter returns a standard footer for embeds, indicating when the data was fetched.
func GetFetchedFooter(cfg *config.Config, time time.Time) *discordgo.MessageEmbedFooter { func GetFetchedFooter(cfg *config.Config, time time.Time) *discordgo.MessageEmbedFooter {
return &discordgo.MessageEmbedFooter{ return &discordgo.MessageEmbedFooter{
Text: fmt.Sprintf("Fetched at %s", time.In(cfg.CentralTimeLocation).Format("Monday, January 2, 2006 at 3:04:05PM")), Text: fmt.Sprintf("Fetched at %s", time.In(cfg.CentralTimeLocation).Format("Monday, January 2, 2006 at 3:04:05PM")),
} }
} }
// GetUser returns the user from the interaction. // GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM.
// This helper method is useful as depending on where the message was sent (guild or DM), the user is in a different field.
func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User { func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User {
// If the interaction is in a guild, the user is kept in the Member field // If the interaction is in a guild, the user is in the Member field
if interaction.Member != nil { if interaction.Member != nil {
return interaction.Member.User return interaction.Member.User
} }
// If the interaction is in a DM, the user is kept in the User field // If the interaction is in a DM, the user is in the User field
return interaction.User return interaction.User
} }
// Encode encodes the values into URL encoded” form // EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key.
// ("bar=baz&foo=quux") sorted by key.
func EncodeParams(params map[string]*[]string) string { func EncodeParams(params map[string]*[]string) string {
// Escape hatch for nil // Escape hatch for nil
if params == nil { if params == nil {
@@ -362,11 +363,12 @@ func EncodeParams(params map[string]*[]string) string {
return buf.String() return buf.String()
} }
// Point represents a point in 2D space // Point represents a point in 2D space.
type Point struct { type Point struct {
X, Y float64 X, Y float64
} }
// Slope calculates the y-coordinate of a point on a line given two other points and an x-coordinate.
func Slope(p1 Point, p2 Point, x float64) Point { func Slope(p1 Point, p2 Point, x float64) Point {
slope := (p2.Y - p1.Y) / (p2.X - p1.X) slope := (p2.Y - p1.Y) / (p2.X - p1.X)
newY := slope*(x-p1.X) + p1.Y newY := slope*(x-p1.X) + p1.Y

View File

@@ -1,3 +1,4 @@
// Package internal provides shared functionality for the banner application.
package internal package internal
import ( import (
@@ -10,7 +11,7 @@ import (
log "github.com/rs/zerolog/log" log "github.com/rs/zerolog/log"
) )
// GetGuildName returns the name of the guild with the given ID, utilizing Redis to cache the value // GetGuildName returns the name of a guild by its ID, using Redis for caching.
func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string { func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string {
// Create a timeout context for Redis operations // Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)
@@ -52,7 +53,7 @@ func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string
return guild.Name return guild.Name
} }
// GetChannelName returns the name of the channel with the given ID, utilizing Redis to cache the value // GetChannelName returns the name of a channel by its ID, using Redis for caching.
func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string { func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string {
// Create a timeout context for Redis operations // Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)

View File

@@ -1,3 +1,4 @@
// Package models provides the data structures for the Banner API.
package models package models
import ( import (
@@ -11,8 +12,9 @@ import (
log "github.com/rs/zerolog/log" log "github.com/rs/zerolog/log"
) )
// FacultyItem represents a faculty member associated with a course.
type FacultyItem struct { type FacultyItem struct {
BannerId string `json:"bannerId"` BannerID string `json:"bannerId"`
Category *string `json:"category"` Category *string `json:"category"`
Class string `json:"class"` Class string `json:"class"`
CourseReferenceNumber string `json:"courseReferenceNumber"` CourseReferenceNumber string `json:"courseReferenceNumber"`
@@ -22,6 +24,7 @@ type FacultyItem struct {
Term string `json:"term"` Term string `json:"term"`
} }
// MeetingTimeResponse represents the meeting time information for a course.
type MeetingTimeResponse struct { type MeetingTimeResponse struct {
Category *string `json:"category"` Category *string `json:"category"`
Class string `json:"class"` Class string `json:"class"`
@@ -80,6 +83,7 @@ type MeetingTimeResponse struct {
Term string `json:"term"` Term string `json:"term"`
} }
// String returns a formatted string representation of the meeting time.
func (m *MeetingTimeResponse) String() string { func (m *MeetingTimeResponse) String() string {
switch m.MeetingTime.MeetingType { switch m.MeetingTime.MeetingType {
case "HB": case "HB":
@@ -104,6 +108,7 @@ func (m *MeetingTimeResponse) String() string {
return "Unknown" return "Unknown"
} }
// TimeString returns a formatted string of the meeting times (e.g., "MWF 1:00PM-2:15PM").
func (m *MeetingTimeResponse) TimeString() string { func (m *MeetingTimeResponse) TimeString() string {
startTime := m.StartTime() startTime := m.StartTime()
endTime := m.EndTime() endTime := m.EndTime()
@@ -115,11 +120,11 @@ func (m *MeetingTimeResponse) TimeString() string {
return fmt.Sprintf("%s %s-%s", internal.WeekdaysToString(m.Days()), m.StartTime().String(), m.EndTime().String()) return fmt.Sprintf("%s %s-%s", internal.WeekdaysToString(m.Days()), m.StartTime().String(), m.EndTime().String())
} }
// PlaceString returns a formatted string best representing the place of the meeting time // PlaceString returns a formatted string representing the location of the meeting.
func (m *MeetingTimeResponse) PlaceString() string { func (m *MeetingTimeResponse) PlaceString() string {
mt := m.MeetingTime mt := m.MeetingTime
// TODO: ADd format case for partial online classes // TODO: Add format case for partial online classes
if mt.Room == "" { if mt.Room == "" {
return "Online" return "Online"
} }
@@ -127,6 +132,7 @@ func (m *MeetingTimeResponse) PlaceString() string {
return fmt.Sprintf("%s | %s | %s %s", mt.CampusDescription, mt.BuildingDescription, mt.Building, mt.Room) return fmt.Sprintf("%s | %s | %s %s", mt.CampusDescription, mt.BuildingDescription, mt.Building, mt.Room)
} }
// Days returns a map of weekdays on which the course meets.
func (m *MeetingTimeResponse) Days() map[time.Weekday]bool { func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
days := map[time.Weekday]bool{} days := map[time.Weekday]bool{}
@@ -140,7 +146,7 @@ func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
return days return days
} }
// Returns the BYDAY value for the iCalendar RRule format // ByDay returns a comma-separated string of two-letter day abbreviations for the iCalendar RRule.
func (m *MeetingTimeResponse) ByDay() string { func (m *MeetingTimeResponse) ByDay() string {
days := []string{} days := []string{}
@@ -171,8 +177,8 @@ func (m *MeetingTimeResponse) ByDay() string {
const layout = "01/02/2006" const layout = "01/02/2006"
// StartDay returns the start date of the meeting time as a time.Time object // StartDay returns the start date of the meeting as a time.Time object.
// This is not cached and is parsed on each invocation. It may also panic without handling. // This method is not cached and will panic if the date cannot be parsed.
func (m *MeetingTimeResponse) StartDay() time.Time { func (m *MeetingTimeResponse) StartDay() time.Time {
t, err := time.Parse(layout, m.MeetingTime.StartDate) t, err := time.Parse(layout, m.MeetingTime.StartDate)
if err != nil { if err != nil {
@@ -181,8 +187,8 @@ func (m *MeetingTimeResponse) StartDay() time.Time {
return t return t
} }
// EndDay returns the end date of the meeting time as a time.Time object. // EndDay returns the end date of the meeting as a time.Time object.
// This is not cached and is parsed on each invocation. It may also panic without handling. // This method is not cached and will panic if the date cannot be parsed.
func (m *MeetingTimeResponse) EndDay() time.Time { func (m *MeetingTimeResponse) EndDay() time.Time {
t, err := time.Parse(layout, m.MeetingTime.EndDate) t, err := time.Parse(layout, m.MeetingTime.EndDate)
if err != nil { if err != nil {
@@ -191,8 +197,8 @@ func (m *MeetingTimeResponse) EndDay() time.Time {
return t return t
} }
// StartTime returns the start time of the meeting time as a NaiveTime object // StartTime returns the start time of the meeting as a NaiveTime object.
// This is not cached and is parsed on each invocation. It may also panic without handling. // This method is not cached and will panic if the time cannot be parsed.
func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime { func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
raw := m.MeetingTime.BeginTime raw := m.MeetingTime.BeginTime
if raw == "" { if raw == "" {
@@ -207,8 +213,8 @@ func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
return internal.ParseNaiveTime(value) return internal.ParseNaiveTime(value)
} }
// EndTime returns the end time of the meeting time as a NaiveTime object // EndTime returns the end time of the meeting as a NaiveTime object.
// This is not cached and is parsed on each invocation. It may also panic without handling. // This method is not cached and will panic if the time cannot be parsed.
func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime { func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
raw := m.MeetingTime.EndTime raw := m.MeetingTime.EndTime
if raw == "" { if raw == "" {
@@ -223,12 +229,13 @@ func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
return internal.ParseNaiveTime(value) return internal.ParseNaiveTime(value)
} }
// RRule represents a recurrence rule for an iCalendar event.
type RRule struct { type RRule struct {
Until string Until string
ByDay string ByDay string
} }
// Converts the meeting time to a string that satisfies the iCalendar RRule format // RRule converts the meeting time to a struct that satisfies the iCalendar RRule format.
func (m *MeetingTimeResponse) RRule() RRule { func (m *MeetingTimeResponse) RRule() RRule {
return RRule{ return RRule{
Until: m.EndDay().UTC().Format("20060102T150405Z"), Until: m.EndDay().UTC().Format("20060102T150405Z"),
@@ -236,6 +243,7 @@ func (m *MeetingTimeResponse) RRule() RRule {
} }
} }
// SearchResult represents the result of a course search.
type SearchResult struct { type SearchResult struct {
Success bool `json:"success"` Success bool `json:"success"`
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
@@ -249,41 +257,36 @@ type SearchResult struct {
Data []Course `json:"data"` Data []Course `json:"data"`
} }
// Course represents a single course returned from a search.
type Course struct { type Course struct {
// A internal identifier not used outside of the Banner system // ID is an internal identifier not used outside of the Banner system.
Id int `json:"id"` ID int `json:"id"`
// The internal identifier for the term this class is in (e.g. 202420) // Term is the internal identifier for the term this class is in (e.g. 202420).
Term string `json:"term"` Term string `json:"term"`
// The human-readable name of the term this class is in (e.g. Fall 2021) // TermDesc is the human-readable name of the term this class is in (e.g. Fall 2021).
TermDesc string `json:"termDesc"` TermDesc string `json:"termDesc"`
// The specific identifier that describes this individual course. CRNs are unique to a term. (TODO: Verify this is true) // CourseReferenceNumber is the unique identifier for a course within a term.
CourseReferenceNumber string `json:"courseReferenceNumber"` CourseReferenceNumber string `json:"courseReferenceNumber"`
// A rarely used identifier that species which part of the given term this course is in. By default, this is "1" to encompass the entire term. (e.g. B6, B5) // PartOfTerm specifies which part of the term the course is in (e.g. B6, B5).
PartOfTerm string `json:"partOfTerm"` PartOfTerm string `json:"partOfTerm"`
// The 4-digit course code that defines this course (e.g. 3743, 0120, 4855, 7339), but not the specific instance (see CourseReferenceNumber) // CourseNumber is the 4-digit code for the course (e.g. 3743).
CourseNumber string `json:"courseNumber"` CourseNumber string `json:"courseNumber"`
// The short acronym of the course subject (e.g. CS, AEPI) // Subject is the subject acronym (e.g. CS, AEPI).
Subject string `json:"subject"` Subject string `json:"subject"`
// The full name of the course subject (e.g. Computer Science, Academic English Program-Intl.) // SubjectDescription is the full name of the course subject.
SubjectDescription string `json:"subjectDescription"` SubjectDescription string `json:"subjectDescription"`
// The specific section of the course (e.g. 001, 002) // SequenceNumber is the course section (e.g. 001, 002).
SequenceNumber string `json:"sequenceNumber"` SequenceNumber string `json:"sequenceNumber"`
// The long name of the campus this course takes place at (e.g. Main Campus, Downtown Campus)
CampusDescription string `json:"campusDescription"` CampusDescription string `json:"campusDescription"`
// e.g. Lecture, Seminar, Dissertation, Internship, Independent Study, Thesis, Self-paced, Laboratory // ScheduleTypeDescription is the type of schedule for the course (e.g. Lecture, Seminar).
ScheduleTypeDescription string `json:"scheduleTypeDescription"` ScheduleTypeDescription string `json:"scheduleTypeDescription"`
// The long name of the course (generally) CourseTitle string `json:"courseTitle"`
CourseTitle string `json:"courseTitle"` CreditHours int `json:"creditHours"`
CreditHours int `json:"creditHours"` // MaximumEnrollment is the maximum number of students that can enroll.
// The maximum number of students that can enroll in this course MaximumEnrollment int `json:"maximumEnrollment"`
MaximumEnrollment int `json:"maximumEnrollment"` Enrollment int `json:"enrollment"`
// The number of students currently enrolled in this course SeatsAvailable int `json:"seatsAvailable"`
Enrollment int `json:"enrollment"` WaitCapacity int `json:"waitCapacity"`
// The number of seats available in this course (MaximumEnrollment - Enrollment)
SeatsAvailable int `json:"seatsAvailable"`
// The number of students that could waitlist for this course
WaitCapacity int `json:"waitCapacity"`
// The number of students currently on the waitlist for this course
WaitCount int `json:"waitCount"` WaitCount int `json:"waitCount"`
CrossList *string `json:"crossList"` CrossList *string `json:"crossList"`
CrossListCapacity *int `json:"crossListCapacity"` CrossListCapacity *int `json:"crossListCapacity"`
@@ -295,27 +298,26 @@ type Course struct {
OpenSection bool `json:"openSection"` OpenSection bool `json:"openSection"`
LinkIdentifier *string `json:"linkIdentifier"` LinkIdentifier *string `json:"linkIdentifier"`
IsSectionLinked bool `json:"isSectionLinked"` IsSectionLinked bool `json:"isSectionLinked"`
// A combination of the subject and course number (e.g. subject=CS, courseNumber=3443 => "CS3443") // SubjectCourse is the combination of the subject and course number (e.g. CS3443).
SubjectCourse string `json:"subjectCourse"` SubjectCourse string `json:"subjectCourse"`
ReservedSeatSummary *string `json:"reservedSeatSummary"` ReservedSeatSummary *string `json:"reservedSeatSummary"`
InstructionalMethod string `json:"instructionalMethod"` InstructionalMethod string `json:"instructionalMethod"`
InstructionalMethodDescription string `json:"instructionalMethodDescription"` InstructionalMethodDescription string `json:"instructionalMethodDescription"`
SectionAttributes []struct { SectionAttributes []struct {
// A internal API class identifier used by Banner // Class is an internal API class identifier used by Banner.
Class string `json:"class"` Class string `json:"class"`
CourseReferenceNumber string `json:"courseReferenceNumber"` CourseReferenceNumber string `json:"courseReferenceNumber"`
// UPPR, ZIEP, AIS, LEWR, ZZSL, 090, GRAD, ZZTL, 020, BU, CLEP // Code for the attribute (e.g., UPPR, ZIEP, AIS).
Code string `json:"code"` Code string `json:"code"`
// Seems to be the fully qualified meaning of the Code (Upper, Intensive English Program...) Description string `json:"description"`
Description string `json:"description"` TermCode string `json:"termCode"`
TermCode string `json:"termCode"` IsZtcAttribute bool `json:"isZTCAttribute"`
// Unknown; always false
IsZtcAttribute bool `json:"isZTCAttribute"`
} `json:"sectionAttributes"` } `json:"sectionAttributes"`
Faculty []FacultyItem `json:"faculty"` Faculty []FacultyItem `json:"faculty"`
MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"` MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"`
} }
// MarshalBinary implements the encoding.BinaryMarshaler interface.
func (course Course) MarshalBinary() ([]byte, error) { func (course Course) MarshalBinary() ([]byte, error) {
return json.Marshal(course) return json.Marshal(course)
} }