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
import (
@@ -73,6 +74,7 @@ func init() {
discordgo.Logger = internal.DiscordGoLogger
}
// initRedis initializes the Redis client and pings the server to ensure a connection.
func initRedis(cfg *config.Config) {
// Setup redis
redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")

View File

@@ -20,10 +20,12 @@ import (
"resty.dev/v3"
)
// API provides a client for interacting with the Banner API.
type API struct {
config *config.Config
}
// New creates a new API client with the given configuration.
func New(config *config.Config) *API {
return &API{config: config}
}
@@ -34,7 +36,7 @@ var (
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 {
// 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
}
// GenerateSession generates a new session ID (nonce) for use with the Banner API.
// Don't use this function directly, use GetSession instead.
// GenerateSession generates a new session ID for use with the Banner API.
// This function should not be used directly; use EnsureSession instead.
func GenerateSession() string {
return internal.RandomString(5) + internal.Nonce()
}
@@ -57,7 +59,7 @@ func GenerateSession() string {
var terms []BannerTerm
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 {
if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour {
return nil
@@ -74,8 +76,9 @@ func (a *API) TryReloadTerms() error {
return nil
}
// IsTermArchived checks if the given term is archived
// TODO: Add error, switch missing term logic to error
// IsTermArchived checks if the given term is archived (view only).
//
// TODO: Add error handling for when a term does not exist.
func (a *API) IsTermArchived(term string) bool {
// Ensure the terms are loaded
err := a.TryReloadTerms()
@@ -106,21 +109,25 @@ func (a *API) EnsureSession() string {
return latestSession
}
// Pair represents a key-value pair from the Banner API.
type Pair struct {
Code string `json:"code"`
Description string `json:"description"`
}
// BannerTerm represents a term in the Banner system.
type BannerTerm Pair
// Instructor represents an instructor in the Banner system.
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 {
return strings.Contains(term.Description, "View Only")
}
// GetTerms retrieves and parses the term information for a given search term.
// Page number must be at least 1.
// GetTerms retrieves a list of terms from the Banner API.
// The page number must be at least 1.
func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) {
// Ensure offset is valid
if page <= 0 {
@@ -148,8 +155,8 @@ func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, e
return *terms, nil
}
// SelectTerm selects the given term in the Banner system.
// This function completes the initial term selection process, which is required before any other API calls can be made with the session ID.
// SelectTerm selects a term in the Banner system for the given session.
// This is required before other API calls can be made.
func (a *API) SelectTerm(term string, sessionID string) error {
form := url.Values{
"term": {term},
@@ -192,8 +199,8 @@ func (a *API) SelectTerm(term string, sessionID string) error {
return nil
}
// GetPartOfTerms retrieves and parses the part of term information for a given term.
// Ensure that the offset is greater than 0.
// GetPartOfTerms retrieves a list of parts of a term from the Banner API.
// The page number must be at least 1.
func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) {
// Ensure offset is valid
if offset <= 0 {
@@ -223,10 +230,7 @@ func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int
return *terms, nil
}
// GetInstructors retrieves and parses the instructor information for a given search term.
// 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.
// GetInstructors retrieves a list of instructors from the Banner API.
func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) {
// Ensure offset is valid
if offset <= 0 {
@@ -256,11 +260,13 @@ func (a *API) GetInstructors(search string, term string, offset int, maxResults
return *instructors, nil
}
// ClassDetails represents the details of a course.
// TODO: Finish this struct & function
// ClassDetails represents the detailed information for a class.
//
// TODO: Implement this struct and the associated GetCourseDetails function.
type ClassDetails struct {
}
// GetCourseDetails retrieves the details for a specific course.
func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
body, err := json.Marshal(map[string]string{
"term": strconv.Itoa(term),
@@ -289,7 +295,7 @@ func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
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) {
a.ResetDataForm()
@@ -322,9 +328,8 @@ func (a *API) Search(term string, query *Query, sort string, sortDescending bool
return searchResult, nil
}
// GetSubjects retrieves and parses the subject information for a given search term.
// The results of this response shouldn't change much, but technically could as new majors are developed, or old ones are removed.
// Ensure that the offset is greater than 0.
// GetSubjects retrieves a list of subjects from the Banner API.
// The page number must be at least 1.
func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
@@ -354,10 +359,8 @@ func (a *API) GetSubjects(search string, term string, offset int, maxResults int
return *subjects, nil
}
// GetCampuses retrieves and parses the campus information for a given search term.
// 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.
// GetCampuses retrieves a list of campuses from the Banner API.
// The page number must be at least 1.
func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
@@ -387,10 +390,8 @@ func (a *API) GetCampuses(search string, term int, offset int, maxResults int) (
return *campuses, nil
}
// GetInstructionalMethods retrieves and parses the instructional method information for a given search term.
// 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.
// GetInstructionalMethods retrieves a list of instructional methods from the Banner API.
// The page number must be at least 1.
func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
@@ -419,9 +420,7 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma
return *methods, nil
}
// GetCourseMeetingTime retrieves the meeting time information for a course based on the given term and course reference number (CRN).
// 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.
// GetCourseMeetingTime retrieves the meeting time information for a course.
func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) {
type responseWrapper struct {
Fmt []models.MeetingTimeResponse `json:"fmt"`
@@ -446,7 +445,8 @@ func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeRespo
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() {
req := a.config.Client.NewRequest()
@@ -456,8 +456,7 @@ func (a *API) ResetDataForm() {
}
}
// GetCourse retrieves the course information.
// This course does not retrieve directly from the API, but rather uses scraped data stored in Redis.
// GetCourse retrieves course information from the Redis cache.
func (a *API) GetCourse(crn string) (*models.Course, error) {
// Create a timeout context for Redis operations
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
import (
@@ -17,16 +18,19 @@ const (
)
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"}
// 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
// AllMajors is a list of all majors that are available in the Banner system.
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.
//
// TODO: Switch from hardcoded term to dynamic term
func (a *API) Scrape() error {
// For each subject, retrieve all courses
@@ -68,7 +72,8 @@ func (a *API) Scrape() error {
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) {
term := Default(time.Now()).ToString()
subjects := make([]string, 0)
@@ -100,8 +105,8 @@ func (a *API) GetExpiredSubjects() ([]string, error) {
return subjects, nil
}
// ScrapeMajor is the scraping invocation 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.
// 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.
func (a *API) ScrapeMajor(subject string) error {
offset := 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.
// term is the term for which the relevant course is occurring within.
// count is the number of courses that were scraped.
// priority is a boolean indicating whether the major is a priority major.
// The duration is based on the number of courses, whether the major is a priority, and if the term is archived.
func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
// An hour for every 100 classes
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.
// 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 {
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)

View File

@@ -33,7 +33,7 @@ const (
)
// 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 {
subject *string
title *string
@@ -58,25 +58,25 @@ func NewQuery() *Query {
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 {
q.subject = &subject
return q
}
// Title sets the title for the query
// Title sets the title for the query.
func (q *Query) Title(title string) *Query {
q.title = &title
return q
}
// Keywords sets the keywords for the query
// Keywords sets the keywords for the query.
func (q *Query) Keywords(keywords []string) *Query {
q.keywords = &keywords
return q
}
// Keyword adds a keyword to the query
// Keyword adds a keyword to the query.
func (q *Query) Keyword(keyword string) *Query {
if q.keywords == nil {
q.keywords = &[]string{keyword}
@@ -86,86 +86,86 @@ func (q *Query) Keyword(keyword string) *Query {
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 {
q.openOnly = &openOnly
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 {
q.termPart = &termPart
return q
}
// Campus sets the campuses for the query
// Campus sets the campuses for the query.
func (q *Query) Campus(campus []string) *Query {
q.campus = &campus
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 {
q.instructionalMethod = &instructionalMethod
return q
}
// Attributes sets the attributes for the query
// Attributes sets the attributes for the query.
func (q *Query) Attributes(attributes []string) *Query {
q.attributes = &attributes
return q
}
// Instructor sets the instructors for the query
// Instructor sets the instructors for the query.
func (q *Query) Instructor(instructor []uint64) *Query {
q.instructor = &instructor
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 {
q.startTime = &startTime
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 {
q.endTime = &endTime
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 {
q.minCredits = &low
q.maxCredits = &high
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 {
q.minCredits = &value
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 {
q.maxCredits = &value
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 {
q.courseNumberRange = &Range{low, high}
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 {
q.offset = offset
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 {
q.maxResults = maxResults
return q
@@ -177,8 +177,8 @@ type Range struct {
High int
}
// FormatTimeParameter formats a time.Duration into a tuple of strings
// This is mostly a private helper to keep the parameter formatting for both the start and end time consistent together
// FormatTimeParameter formats a time.Duration into a tuple of strings for use in a POST request.
// It returns the hour, minute, and meridiem (AM/PM) as separate strings.
func FormatTimeParameter(d time.Duration) (string, string, string) {
hourParameter, minuteParameter, meridiemParameter := "", "", ""
@@ -204,7 +204,7 @@ func FormatTimeParameter(d time.Duration) (string, string, string) {
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.
func (q *Query) Paramify() map[string]string {
params := map[string]string{}

View File

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

View File

@@ -18,6 +18,7 @@ const (
Fall
)
// Term represents a school term, consisting of a year and a season.
type Term struct {
Year uint16
Season uint8
@@ -32,16 +33,14 @@ func init() {
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 {
Start uint16
End uint16
}
// 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.
// Spring: January 14th to May
// Summer: May 25th - August 15th
// Fall: August 18th - December 10th
// The ranges are inclusive of the start day and exclusive of the end day.
func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) {
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()
@@ -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.
// YearDay ranges are inclusive of the start, and exclusive of the end.
// You can think of the 'year' part of it as the 'school year', the second part of the 20XX-(20XX+1) phrasing.
//
// 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.
// GetCurrentTerm returns the current and next terms based on the provided time.
// The current term can be nil if the time falls between terms.
// The 'year' in the term corresponds to the academic year, which may differ from the calendar year.
func GetCurrentTerm(now time.Time) (*Term, *Term) {
literalYear := uint16(now.Year())
dayOfYear := uint16(now.YearDay())
@@ -112,7 +99,7 @@ func GetCurrentTerm(now time.Time) (*Term, *Term) {
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 {
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 {
var season string
switch term.Season {
@@ -148,7 +135,7 @@ func (term Term) ToString() string {
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 {
currentTerm, nextTerm := GetCurrentTerm(t)
if currentTerm == nil {

View File

@@ -22,10 +22,13 @@ const (
ICalTimestampFormatLocal = "20060102T150405"
)
// CommandHandler is a function that handles a slash command interaction.
type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error
var (
// CommandDefinitions is a list of all the bot's command definitions.
CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
// CommandHandlers is a map of command names to their handlers.
CommandHandlers = map[string]CommandHandler{
TimeCommandDefinition.Name: TimeCommandHandler,
TermCommandDefinition.Name: TermCommandHandler,
@@ -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 {
data := i.ApplicationCommandData()
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 {
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 {
fetch_time := time.Now()
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 {
// Parse all options
options := internal.ParseOptions(i.ApplicationCommandData().Options)

View File

@@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog/log"
)
// RegisterHandlers registers the bot's command handlers.
func (b *Bot) RegisterHandlers() {
b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) {
// 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
import (
@@ -10,6 +11,7 @@ import (
"github.com/rs/zerolog/log"
)
// Bot represents the state of the Discord bot.
type Bot struct {
Session *discordgo.Session
API *api.API
@@ -17,14 +19,17 @@ type Bot struct {
isClosing bool
}
// New creates a new Bot instance.
func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot {
return &Bot{Session: s, API: a, Config: c}
}
// SetClosing marks the bot as closing, preventing new commands from being processed.
func (b *Bot) SetClosing() {
b.isClosing = true
}
// GetSession ensures a valid session is available and selects the default term.
func (b *Bot) GetSession() (string, error) {
sessionID := b.API.EnsureSession()
term := api.Default(time.Now()).ToString()

View File

@@ -8,14 +8,23 @@ import (
"resty.dev/v3"
)
// Config holds the application's configuration.
type Config struct {
// Ctx is the application's root context.
Ctx context.Context
// CancelFunc cancels the application's root context.
CancelFunc context.CancelFunc
// KV provides access to the Redis cache.
KV *redis.Client
// 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
}
@@ -23,10 +32,11 @@ const (
CentralTimezoneName = "America/Chicago"
)
// New creates a new Config instance with a cancellable context.
func New() (*Config, error) {
ctx, cancel := context.WithCancel(context.Background())
loc, err := time.LoadLocation(CentralTimezoneName)
loc, err := time.LoadLocation("America/Chicago")
if err != nil {
cancel()
return nil, err
@@ -39,23 +49,23 @@ func New() (*Config, error) {
}, nil
}
// SetBaseURL sets the base URL for API requests
// SetBaseURL sets the base URL for the Banner API.
func (c *Config) SetBaseURL(url string) {
c.BaseURL = url
}
// SetEnvironment sets the environment
// SetEnvironment sets the application's environment.
func (c *Config) SetEnvironment(env string) {
c.Environment = env
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) {
c.Client = client
}
// SetRedis sets the Redis client
// SetRedis sets the Redis client for caching.
func (c *Config) SetRedis(r *redis.Client) {
c.KV = r
}

View File

@@ -1,3 +1,4 @@
// Package config provides the configuration and logging setup for the application.
package config
import (
@@ -9,12 +10,7 @@ import (
const timeFormat = "2006-01-02 15:04:05"
var (
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
// NewConsoleWriter creates a new console writer that splits logs between stdout and stderr.
func NewConsoleWriter() zerolog.LevelWriter {
return &ConsoleLogSplitter{
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 {
stdConsole 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) {
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) {
if level <= zerolog.WarnLevel {
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)
}
// 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 {
Std 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) {
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) {
if level <= zerolog.WarnLevel {
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"
// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value.
type UnexpectedContentTypeError struct {
Expected string
Actual string

View File

@@ -21,10 +21,10 @@ import (
"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
// 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 {
if opt, ok := o[key]; ok {
return opt.IntValue()
@@ -32,7 +32,7 @@ func (o Options) GetInt(key string) int64 {
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 {
optionMap := make(Options)
for _, opt := range options {
@@ -41,12 +41,12 @@ func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption)
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) {
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 {
contentType := res.Header().Get("Content-Type")
if contentType == "" {
@@ -57,8 +57,8 @@ func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// RandomString returns a random string of length n using the letterBytes constant
// The constant used is specifically chosen to mimic Ellucian's banner session ID generation.
// RandomString returns a random string of length n.
// The character set is chosen to mimic Ellucian's Banner session ID generation.
func RandomString(n int) string {
b := make([]byte, n)
for i := range b {
@@ -67,8 +67,7 @@ func RandomString(n int) string {
return string(b)
}
// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface.
// It directs all logs to the zerolog implementation.
// DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog.
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
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)
}
// Nonce returns a string made up of the current time in milliseconds, Unix epoch/UTC
// This is typically used as a query parameter to prevent request caching in the browser.
// 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.
func Nonce() string {
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 {
if n == 1 {
return ""
@@ -112,8 +111,7 @@ func Plural(n int) string {
return "s"
}
// Plurale is a simple helper function that returns an empty string if n is 1, and "ess" otherwise.
// This is for words that end in "es" when plural.
// Plurale returns "es" if n is not 1.
func Plurale(n int) string {
if n == 1 {
return ""
@@ -121,6 +119,7 @@ func Plurale(n int) string {
return "es"
}
// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF").
func WeekdaysToString(days map[time.Weekday]bool) string {
// If no days are present
numDays := len(days)
@@ -166,15 +165,18 @@ func WeekdaysToString(days map[time.Weekday]bool) string {
return str
}
// NaiveTime represents a time of day without a date or timezone.
type NaiveTime struct {
Hours uint
Minutes uint
}
// Sub returns the duration between two NaiveTime instances.
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)
}
// ParseNaiveTime converts an integer representation of time (e.g., 1430) to a NaiveTime struct.
func ParseNaiveTime(integer uint64) *NaiveTime {
minutes := uint(integer % 100)
hours := uint(integer / 100)
@@ -182,6 +184,7 @@ func ParseNaiveTime(integer uint64) *NaiveTime {
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 {
meridiem := "AM"
hour := nt.Hours
@@ -194,6 +197,7 @@ func (nt NaiveTime) String() string {
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 {
for _, k := range key {
if v := os.Getenv(k); v != "" {
@@ -203,14 +207,12 @@ func GetFirstEnv(key ...string) string {
return ""
}
// GetIntPointer returns a pointer to the given value.
// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments.
// GetIntPointer returns a pointer to the given integer.
func GetIntPointer(value int) *int {
return &value
}
// GetFloatPointer returns a pointer to the given value.
// This function is useful for discordgo, which inexplicably requires pointers to floats for minLength arguments.
// GetFloatPointer returns a pointer to the given float.
func GetFloatPointer(value float64) *float64 {
return &value
}
@@ -244,6 +246,7 @@ var extensionMap = map[string]string{
"image/jxl": "jxl",
}
// GuessExtension guesses the file extension for a given content type.
func GuessExtension(contentType string) string {
ext, ok := extensionMap[strings.ToLower(contentType)]
if !ok {
@@ -252,7 +255,7 @@ func GuessExtension(contentType string) string {
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) {
contentType := res.Header().Get("Content-Type")
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")
}
// ResponseError responds to an interaction with an error message
// TODO: Improve with a proper embed and colors
// RespondError responds to an interaction with a formatted error message.
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
// Optional: log the error
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 {
return &discordgo.MessageEmbedFooter{
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.
// This helper method is useful as depending on where the message was sent (guild or DM), the user is in a different field.
// GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM.
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 {
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
}
// Encode encodes the values into URL encoded” form
// ("bar=baz&foo=quux") sorted by key.
// EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key.
func EncodeParams(params map[string]*[]string) string {
// Escape hatch for nil
if params == nil {
@@ -362,11 +363,12 @@ func EncodeParams(params map[string]*[]string) string {
return buf.String()
}
// Point represents a point in 2D space
// Point represents a point in 2D space.
type Point struct {
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 {
slope := (p2.Y - p1.Y) / (p2.X - p1.X)
newY := slope*(x-p1.X) + p1.Y

View File

@@ -1,3 +1,4 @@
// Package internal provides shared functionality for the banner application.
package internal
import (
@@ -10,7 +11,7 @@ import (
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 {
// Create a timeout context for Redis operations
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
}
// 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 {
// Create a timeout context for Redis operations
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
import (
@@ -11,8 +12,9 @@ import (
log "github.com/rs/zerolog/log"
)
// FacultyItem represents a faculty member associated with a course.
type FacultyItem struct {
BannerId string `json:"bannerId"`
BannerID string `json:"bannerId"`
Category *string `json:"category"`
Class string `json:"class"`
CourseReferenceNumber string `json:"courseReferenceNumber"`
@@ -22,6 +24,7 @@ type FacultyItem struct {
Term string `json:"term"`
}
// MeetingTimeResponse represents the meeting time information for a course.
type MeetingTimeResponse struct {
Category *string `json:"category"`
Class string `json:"class"`
@@ -80,6 +83,7 @@ type MeetingTimeResponse struct {
Term string `json:"term"`
}
// String returns a formatted string representation of the meeting time.
func (m *MeetingTimeResponse) String() string {
switch m.MeetingTime.MeetingType {
case "HB":
@@ -104,6 +108,7 @@ func (m *MeetingTimeResponse) String() string {
return "Unknown"
}
// TimeString returns a formatted string of the meeting times (e.g., "MWF 1:00PM-2:15PM").
func (m *MeetingTimeResponse) TimeString() string {
startTime := m.StartTime()
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())
}
// 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 {
mt := m.MeetingTime
// TODO: ADd format case for partial online classes
// TODO: Add format case for partial online classes
if mt.Room == "" {
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)
}
// Days returns a map of weekdays on which the course meets.
func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
days := map[time.Weekday]bool{}
@@ -140,7 +146,7 @@ func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
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 {
days := []string{}
@@ -171,8 +177,8 @@ func (m *MeetingTimeResponse) ByDay() string {
const layout = "01/02/2006"
// StartDay returns the start date of the meeting time as a time.Time object
// This is not cached and is parsed on each invocation. It may also panic without handling.
// StartDay returns the start date of the meeting as a time.Time object.
// This method is not cached and will panic if the date cannot be parsed.
func (m *MeetingTimeResponse) StartDay() time.Time {
t, err := time.Parse(layout, m.MeetingTime.StartDate)
if err != nil {
@@ -181,8 +187,8 @@ func (m *MeetingTimeResponse) StartDay() time.Time {
return t
}
// EndDay returns the end date of the meeting time as a time.Time object.
// This is not cached and is parsed on each invocation. It may also panic without handling.
// EndDay returns the end date of the meeting as a time.Time object.
// This method is not cached and will panic if the date cannot be parsed.
func (m *MeetingTimeResponse) EndDay() time.Time {
t, err := time.Parse(layout, m.MeetingTime.EndDate)
if err != nil {
@@ -191,8 +197,8 @@ func (m *MeetingTimeResponse) EndDay() time.Time {
return t
}
// StartTime returns the start time of the meeting time as a NaiveTime object
// This is not cached and is parsed on each invocation. It may also panic without handling.
// StartTime returns the start time of the meeting as a NaiveTime object.
// This method is not cached and will panic if the time cannot be parsed.
func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
raw := m.MeetingTime.BeginTime
if raw == "" {
@@ -207,8 +213,8 @@ func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
return internal.ParseNaiveTime(value)
}
// EndTime returns the end time of the meeting time as a NaiveTime object
// This is not cached and is parsed on each invocation. It may also panic without handling.
// EndTime returns the end time of the meeting as a NaiveTime object.
// This method is not cached and will panic if the time cannot be parsed.
func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
raw := m.MeetingTime.EndTime
if raw == "" {
@@ -223,12 +229,13 @@ func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
return internal.ParseNaiveTime(value)
}
// RRule represents a recurrence rule for an iCalendar event.
type RRule struct {
Until 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 {
return RRule{
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 {
Success bool `json:"success"`
TotalCount int `json:"totalCount"`
@@ -249,41 +257,36 @@ type SearchResult struct {
Data []Course `json:"data"`
}
// Course represents a single course returned from a search.
type Course struct {
// A internal identifier not used outside of the Banner system
Id int `json:"id"`
// The internal identifier for the term this class is in (e.g. 202420)
// ID is an internal identifier not used outside of the Banner system.
ID int `json:"id"`
// Term is the internal identifier for the term this class is in (e.g. 202420).
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"`
// 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"`
// 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"`
// 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"`
// The short acronym of the course subject (e.g. CS, AEPI)
// Subject is the subject acronym (e.g. CS, AEPI).
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"`
// The specific section of the course (e.g. 001, 002)
// SequenceNumber is the course section (e.g. 001, 002).
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"`
// 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"`
// The long name of the course (generally)
CourseTitle string `json:"courseTitle"`
CreditHours int `json:"creditHours"`
// The maximum number of students that can enroll in this course
// MaximumEnrollment is the maximum number of students that can enroll.
MaximumEnrollment int `json:"maximumEnrollment"`
// The number of students currently enrolled in this course
Enrollment int `json:"enrollment"`
// 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"`
CrossList *string `json:"crossList"`
CrossListCapacity *int `json:"crossListCapacity"`
@@ -295,27 +298,26 @@ type Course struct {
OpenSection bool `json:"openSection"`
LinkIdentifier *string `json:"linkIdentifier"`
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"`
ReservedSeatSummary *string `json:"reservedSeatSummary"`
InstructionalMethod string `json:"instructionalMethod"`
InstructionalMethodDescription string `json:"instructionalMethodDescription"`
SectionAttributes []struct {
// A internal API class identifier used by Banner
// Class is an internal API class identifier used by Banner.
Class string `json:"class"`
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"`
// Seems to be the fully qualified meaning of the Code (Upper, Intensive English Program...)
Description string `json:"description"`
TermCode string `json:"termCode"`
// Unknown; always false
IsZtcAttribute bool `json:"isZTCAttribute"`
} `json:"sectionAttributes"`
Faculty []FacultyItem `json:"faculty"`
MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"`
}
// MarshalBinary implements the encoding.BinaryMarshaler interface.
func (course Course) MarshalBinary() ([]byte, error) {
return json.Marshal(course)
}