Compare commits

..

14 Commits

27 changed files with 1971 additions and 1564 deletions

6
.gitignore vendored
View File

@@ -1,8 +1,10 @@
.env .env
cover.cov cover.cov
banner /banner
.*.go .*.go
dumps/ dumps/
js/ js/
.vscode/ .vscode/
*.prof *.prof
.task/
bin/

View File

@@ -23,21 +23,21 @@ A discord bot for executing queries & searches on the Ellucian Banner instance h
- Full Autocomplete for Every Search Option - Full Autocomplete for Every Search Option
- Metrics, Log Query, Privileged Error Feedback - Metrics, Log Query, Privileged Error Feedback
- Search for Classes - Search for Classes
- Major, Professor, Location, Name, Time of Day - Major, Professor, Location, Name, Time of Day
- Subscribe to Classes - Subscribe to Classes
- Availability (seat, pre-seat) - Availability (seat, pre-seat)
- Waitlist Movement - Waitlist Movement
- Detail Changes (meta, time, location, seats, professor) - Detail Changes (meta, time, location, seats, professor)
- `time` Start, End, Days of Week - `time` Start, End, Days of Week
- `seats` Any change in seat/waitlist data - `seats` Any change in seat/waitlist data
- `meta` - `meta`
- Lookup via Course Reference Number (CRN) - Lookup via Course Reference Number (CRN)
- Smart Time of Day Handling - Smart Time of Day Handling
- "2 PM" -> Start within 2:00 PM to 2:59 PM - "2 PM" -> Start within 2:00 PM to 2:59 PM
- "2-3 PM" -> Start within 2:00 PM to 3:59 PM - "2-3 PM" -> Start within 2:00 PM to 3:59 PM
- "ends by 2 PM" -> Ends within 12:00 AM to 2:00 PM - "ends by 2 PM" -> Ends within 12:00 AM to 2:00 PM
- "after 2 PM" -> Start within 2:01 PM to 11:59 PM - "after 2 PM" -> Start within 2:01 PM to 11:59 PM
- "before 2 PM" -> Ends within 12:00 AM to 1:59 PM - "before 2 PM" -> Ends within 12:00 AM to 1:59 PM
- Get By Section Command - Get By Section Command
- CS 4393 001 => - CS 4393 001 =>
- Will require SQL to be able to search for a class by its section number - Will require SQL to be able to search for a class by its section number
@@ -100,6 +100,7 @@ Scraping will be separated by major to allow for priority majors (namely, Comput
This will lower the overall load on the Banner system while ensuring that data presented by the app is still relevant. This will lower the overall load on the Banner system while ensuring that data presented by the app is still relevant.
For now, all majors will be scraped fully every 4 hours with at least 5 minutes between each one. For now, all majors will be scraped fully every 4 hours with at least 5 minutes between each one.
- On startup, priority majors will be scraped first (if required). - On startup, priority majors will be scraped first (if required).
- Other majors will be scraped in arbitrary order (if required). - Other majors will be scraped in arbitrary order (if required).
- Scrape timing will be stored in Redis. - Scrape timing will be stored in Redis.
@@ -107,6 +108,7 @@ For now, all majors will be scraped fully every 4 hours with at least 5 minutes
- If CRNs are duplicated between terms, then the primary key will be (CRN, Term) - If CRNs are duplicated between terms, then the primary key will be (CRN, Term)
Considerations Considerations
- Change in metadata should decrease the interval - Change in metadata should decrease the interval
- The number of courses scraped should change the interval (2 hours per 500 courses involved) - The number of courses scraped should change the interval (2 hours per 500 courses involved)
@@ -118,5 +120,6 @@ For example, a recent scrape of 350 classes should be weighted 5x more than a se
Still, even if the cap does not normally allow for this request to be processed immediately, the small user search should proceed with a small bursting cap. Still, even if the cap does not normally allow for this request to be processed immediately, the small user search should proceed with a small bursting cap.
The requirements to this hypothetical system would be: The requirements to this hypothetical system would be:
- Conditional Bursting: background processes or other requests deemed "low priority" are not allowed to use bursting. - Conditional Bursting: background processes or other requests deemed "low priority" are not allowed to use bursting.
- Arbitrary Costs: rate limiting is considered in the form of the request size/speed more or less, such that small simple requests can be made more frequently, unlike large requests. - Arbitrary Costs: rate limiting is considered in the form of the request size/speed more or less, such that small simple requests can be made more frequently, unlike large requests.

39
Taskfile.yml Normal file
View File

@@ -0,0 +1,39 @@
version: "3"
tasks:
build:
desc: Build the application
cmds:
- go build -o bin/banner ./cmd/banner
sources:
- ./cmd/banner/**/*.go
- ./internal/**/*.go
generates:
- bin/banner
run:
desc: Run the application
cmds:
- go run ./cmd/banner
deps: [build]
test:
desc: Run tests
cmds:
- go test ./...
env:
ENVIRONMENT: test
clean:
desc: Clean build artifacts
cmds:
- rm -rf bin/
- go clean -cache
- go clean -modcache
dev:
desc: Run in development mode
cmds:
- go run ./cmd/banner
env:
ENVIRONMENT: development

540
api.go
View File

@@ -1,540 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
)
var (
latestSession string = ""
sessionTime time.Time
expiryTime time.Duration = 25 * time.Minute
)
// ResetSessionTimer resets the session timer to the current time.
// This is only used by the DoRequest handler when Banner API calls are detected, which would reset the session timer.
func ResetSessionTimer() {
// Only reset the session time if the session is still valid
if time.Since(sessionTime) <= expiryTime {
sessionTime = time.Now()
}
}
// GenerateSession generates a new session ID (nonce) for use with the Banner API.
// Don't use this function directly, use GetSession instead.
func GenerateSession() string {
return RandomString(5) + Nonce()
}
// GetSession retrieves the current session ID if it's still valid.
// If the session ID is invalid or has expired, a new one is generated and returned.
// SessionIDs are valid for 30 minutes, but we'll be conservative and regenerate every 25 minutes.
func GetSession() string {
// Check if a reset is required
if latestSession == "" || time.Since(sessionTime) >= expiryTime {
// Generate a new session identifier
latestSession = GenerateSession()
// Select the current term
term := Default(time.Now()).ToString()
log.Info().Str("term", term).Str("sessionID", latestSession).Msg("Setting selected term")
err := SelectTerm(term, latestSession)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Failed to select term while generating session ID")
}
sessionTime = time.Now()
}
return latestSession
}
type Pair struct {
Code string `json:"code"`
Description string `json:"description"`
}
type BannerTerm Pair
type Instructor Pair
// Archived returns true if the term is in it's archival state (view only)
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.
func GetTerms(search string, page int, max int) ([]BannerTerm, error) {
// Ensure offset is valid
if page <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/getTerms", map[string]string{
"searchTerm": search,
// Page vs Offset is not a mistake here, the API uses "offset" as the page number
"offset": strconv.Itoa(page),
"max": strconv.Itoa(max),
"_": Nonce(),
})
if page <= 0 {
return nil, errors.New("Offset must be greater than 0")
}
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get terms: %w", err)
}
// Assert that the response is JSON
if contentType := res.Header.Get("Content-Type"); !strings.Contains(contentType, JsonContentType) {
return nil, &UnexpectedContentTypeError{
Expected: JsonContentType,
Actual: contentType,
}
}
// print the response body
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
terms := make([]BannerTerm, 0, 10)
json.Unmarshal(body, &terms)
if err != nil {
return nil, fmt.Errorf("failed to parse terms: %w", err)
}
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.
func SelectTerm(term string, sessionId string) error {
form := url.Values{
"term": {term},
"studyPath": {""},
"studyPathText": {""},
"startDatepicker": {""},
"endDatepicker": {""},
"uniqueSessionId": {sessionId},
}
params := map[string]string{
"mode": "search",
}
req := BuildRequestWithBody("POST", "/term/search", params, bytes.NewBufferString(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := DoRequest(req)
if err != nil {
return fmt.Errorf("failed to select term: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
return fmt.Errorf("response was not JSON: %w", res.Header.Get("Content-Type"))
}
// Acquire fwdUrl
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
var redirectResponse struct {
FwdUrl string `json:"fwdUrl"`
}
json.Unmarshal(body, &redirectResponse)
// Make a GET request to the fwdUrl
req = BuildRequest("GET", redirectResponse.FwdUrl, nil)
res, err = DoRequest(req)
if err != nil {
return fmt.Errorf("failed to follow redirect: %w", err)
}
// Assert that the response is OK (200)
if res.StatusCode != 200 {
return fmt.Errorf("redirect response was not 200: %w", res.StatusCode)
}
return nil
}
// GetPartOfTerms retrieves and parses the part of term information for a given term.
// Ensure that the offset is greater than 0.
func GetPartOfTerms(search string, term int, offset int, max int) ([]BannerTerm, error) {
// Ensure offset is valid
if offset <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/get_partOfTerm", map[string]string{
"searchTerm": search,
"term": strconv.Itoa(term),
"offset": strconv.Itoa(offset),
"max": strconv.Itoa(max),
"uniqueSessionId": GetSession(),
"_": Nonce(),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get part of terms: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Panic().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
terms := make([]BannerTerm, 0, 10)
err = json.Unmarshal(body, &terms)
if err != nil {
return nil, fmt.Errorf("failed to parse part of terms: %w", err)
}
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.
func GetInstructors(search string, term string, offset int, max int) ([]Instructor, error) {
// Ensure offset is valid
if offset <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/get_instructor", map[string]string{
"searchTerm": search,
"term": term,
"offset": strconv.Itoa(offset),
"max": strconv.Itoa(max),
"uniqueSessionId": GetSession(),
"_": Nonce(),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get instructors: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
instructors := make([]Instructor, 0, 10)
err = json.Unmarshal(body, &instructors)
if err != nil {
return nil, fmt.Errorf("failed to parse instructors: %w", err)
}
return instructors, nil
}
// TODO: Finish this struct & function
// ClassDetails represents
type ClassDetails struct {
}
func GetCourseDetails(term int, crn int) *ClassDetails {
body, err := json.Marshal(map[string]string{
"term": strconv.Itoa(term),
"courseReferenceNumber": strconv.Itoa(crn),
"first": "first", // TODO: What is this?
})
if err != nil {
log.Fatal().Stack().Err(err).Msg("Failed to marshal body")
}
req := BuildRequestWithBody("GET", "/searchResults/getClassDetails", nil, bytes.NewBuffer(body))
res, err := DoRequest(req)
if err != nil {
return nil
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
return &ClassDetails{}
}
// Search invokes a search on the Banner system with the given query and returns the results.
func Search(query *Query, sort string, sortDescending bool) (*SearchResult, error) {
ResetDataForm()
params := query.Paramify()
params["txt_term"] = "202510" // TODO: Make this automatic but dynamically specifiable
params["uniqueSessionId"] = GetSession()
params["sortColumn"] = sort
params["sortDirection"] = "asc"
// These dates are not available for usage anywhere in the UI, but are included in every query
params["startDatepicker"] = ""
params["endDatepicker"] = ""
req := BuildRequest("GET", "/searchResults/searchResults", params)
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to search: %w", err)
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("search failed with status code: %d", res.StatusCode)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
// for server 500 errors, parse for the error with '#dialog-message > div.message'
log.Error().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result SearchResult
err = json.Unmarshal(body, &result)
if err != nil {
return nil, fmt.Errorf("failed to parse search results: %w", err)
}
return &result, 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.
func GetSubjects(search string, term string, offset int, max int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/get_subject", map[string]string{
"searchTerm": search,
"term": term,
"offset": strconv.Itoa(offset),
"max": strconv.Itoa(max),
"uniqueSessionId": GetSession(),
"_": Nonce(),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get subjects: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
subjects := make([]Pair, 0, 10)
err = json.Unmarshal(body, &subjects)
if err != nil {
return nil, fmt.Errorf("failed to parse subjects: %w", err)
}
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.
func GetCampuses(search string, term int, offset int, max int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/get_campus", map[string]string{
"searchTerm": search,
"term": strconv.Itoa(term),
"offset": strconv.Itoa(offset),
"max": strconv.Itoa(max),
"uniqueSessionId": GetSession(),
"_": Nonce(),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get campuses: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
campuses := make([]Pair, 0, 10)
err = json.Unmarshal(body, &campuses)
if err != nil {
return nil, fmt.Errorf("failed to parse campuses: %w", err)
}
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.
func GetInstructionalMethods(search string, term string, offset int, max int) ([]Pair, error) {
// Ensure offset is valid
if offset <= 0 {
return nil, errors.New("offset must be greater than 0")
}
req := BuildRequest("GET", "/classSearch/get_instructionalMethod", map[string]string{
"searchTerm": search,
"term": term,
"offset": strconv.Itoa(offset),
"max": strconv.Itoa(max),
"uniqueSessionId": GetSession(),
"_": Nonce(),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get instructional methods: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
methods := make([]Pair, 0, 10)
err = json.Unmarshal(body, &methods)
if err != nil {
return nil, fmt.Errorf("failed to parse instructional methods: %w", err)
}
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.
func GetCourseMeetingTime(term int, crn int) ([]MeetingTimeResponse, error) {
req := BuildRequest("GET", "/searchResults/getFacultyMeetingTimes", map[string]string{
"term": strconv.Itoa(term),
"courseReferenceNumber": strconv.Itoa(crn),
})
res, err := DoRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to get meeting time: %w", err)
}
// Assert that the response is JSON
if !ContentTypeMatch(res, "application/json") {
log.Fatal().Stack().Str("content-type", res.Header.Get("Content-Type")).Msg("Response was not JSON")
}
// Read the response body into JSON
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Parse the JSON into a MeetingTimeResponse struct
var meetingTime struct {
Inner []MeetingTimeResponse `json:"fmt"`
}
err = json.Unmarshal(body, &meetingTime)
if err != nil {
return nil, fmt.Errorf("failed to parse meeting time: %w", err)
}
return meetingTime.Inner, nil
}
// ResetDataForm makes a POST request that needs to be made upon before new search requests can be made.
func ResetDataForm() {
req := BuildRequest("POST", "/classSearch/resetDataForm", nil)
_, err := DoRequest(req)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Failed to reset data form")
}
}
// GetCourse retrieves the course information.
// This course does not retrieve directly from the API, but rather uses scraped data stored in Redis.
func GetCourse(crn string) (*Course, error) {
// Retrieve raw data
result, err := kv.Get(ctx, fmt.Sprintf("class:%s", crn)).Result()
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("course not found: %w", err)
}
return nil, fmt.Errorf("failed to get course: %w", err)
}
// Unmarshal the raw data
var course Course
err = json.Unmarshal([]byte(result), &course)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal course: %w", err)
}
return &course, nil
}

299
cmd/banner/main.go Normal file
View File

@@ -0,0 +1,299 @@
// Package main is the entry point for the banner application.
package main
import (
"context"
"flag"
"net/http"
"net/http/cookiejar"
_ "net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
_ "time/tzdata"
"github.com/bwmarrin/discordgo"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog/pkgerrors"
"github.com/samber/lo"
"resty.dev/v3"
"banner/internal"
"banner/internal/api"
"banner/internal/bot"
"banner/internal/config"
)
var (
Session *discordgo.Session
)
const (
ICalTimestampFormatUtc = "20060102T150405Z"
ICalTimestampFormatLocal = "20060102T150405"
CentralTimezoneName = "America/Chicago"
)
func init() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Debug().Err(err).Msg("Error loading .env file")
}
// Set zerolog's timestamp function to use the central timezone
zerolog.TimestampFunc = func() time.Time {
// TODO: Move this to config
loc, err := time.LoadLocation(CentralTimezoneName)
if err != nil {
panic(err)
}
return time.Now().In(loc)
}
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
// Use the custom console writer if we're in development
isDevelopment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
if isDevelopment == "" {
isDevelopment = "development"
}
if isDevelopment == "development" {
log.Logger = zerolog.New(config.NewConsoleWriter()).With().Timestamp().Logger()
} else {
log.Logger = zerolog.New(config.LogSplitter{Std: os.Stdout, Err: os.Stderr}).With().Timestamp().Logger()
}
log.Debug().Str("environment", isDevelopment).Msg("Loggers Setup")
// Set discordgo's logger to use zerolog
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")
if redisUrl == "" {
log.Fatal().Stack().Msg("REDIS_URL/REDIS_PRIVATE_URL not set")
}
// Parse URL and create client
options, err := redis.ParseURL(redisUrl)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot parse redis url")
}
kv := redis.NewClient(options)
cfg.SetRedis(kv)
var lastPingErr error
pingCount := 0 // Nth ping being attempted
totalPings := 5 // Total pings to attempt
// Wait for private networking to kick in (production only)
if !cfg.IsDevelopment {
time.Sleep(250 * time.Millisecond)
}
// Test the redis instance, try to ping every 2 seconds 5 times, otherwise panic
for {
pingCount++
if pingCount > totalPings {
log.Fatal().Stack().Err(lastPingErr).Msg("Reached ping limit while trying to connect")
}
// Ping redis
pong, err := cfg.KV.Ping(cfg.Ctx).Result()
// Failed; log error and wait 2 seconds
if err != nil {
lastPingErr = err
log.Warn().Err(err).Int("pings", pingCount).Int("remaining", totalPings-pingCount).Msg("Cannot ping redis")
time.Sleep(2 * time.Second)
continue
}
log.Debug().Str("ping", pong).Msg("Redis connection successful")
break
}
}
func main() {
flag.Parse()
cfg, err := config.New()
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot create config")
}
// Try to grab the environment variable, or default to development
environment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
if environment == "" {
environment = "development"
}
cfg.SetEnvironment(environment)
initRedis(cfg)
if strings.EqualFold(os.Getenv("PPROF_ENABLE"), "true") {
// Start pprof server with graceful shutdown
go func() {
port := os.Getenv("PORT")
log.Info().Str("port", port).Msg("Starting pprof server")
server := &http.Server{
Addr: ":" + port,
}
// Start server in a separate goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Stack().Err(err).Msg("Cannot start pprof server")
}
}()
// Wait for context cancellation and then shutdown
<-cfg.Ctx.Done()
log.Info().Msg("Shutting down pprof server")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("Pprof server forced to shutdown")
}
}()
}
// Create cookie jar
cookies, err := cookiejar.New(nil)
if err != nil {
log.Err(err).Msg("Cannot create cookie jar")
}
// Create Resty client with timeout and cookie jar
baseURL := os.Getenv("BANNER_BASE_URL")
client := resty.New().
SetBaseURL(baseURL).
SetTimeout(30*time.Second).
SetCookieJar(cookies).
SetHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36").
AddResponseMiddleware(api.SessionMiddleware)
cfg.SetClient(client)
cfg.SetBaseURL(baseURL)
apiInstance := api.New(cfg)
apiInstance.Setup()
// Create discord session
session, err := discordgo.New("Bot " + os.Getenv("BOT_TOKEN"))
if err != nil {
log.Err(err).Msg("Invalid bot parameters")
}
botInstance := bot.New(session, apiInstance, cfg)
botInstance.RegisterHandlers()
// Open discord session
session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
log.Info().Str("username", r.User.Username).Str("discriminator", r.User.Discriminator).Str("id", r.User.ID).Str("session", s.State.SessionID).Msg("Bot is logged in")
})
err = session.Open()
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot open the session")
}
// Setup command handlers
// Register commands with discord
arr := zerolog.Arr()
lo.ForEach(bot.CommandDefinitions, func(cmd *discordgo.ApplicationCommand, _ int) {
arr.Str(cmd.Name)
})
log.Info().Array("commands", arr).Msg("Registering commands")
// In development, use test server, otherwise empty (global) for command registration
guildTarget := ""
if cfg.IsDevelopment {
guildTarget = os.Getenv("BOT_TARGET_GUILD")
}
// Register commands
existingCommands, err := session.ApplicationCommands(session.State.User.ID, guildTarget)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot get existing commands")
}
newCommands, err := session.ApplicationCommandBulkOverwrite(session.State.User.ID, guildTarget, bot.CommandDefinitions)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot register commands")
}
// Compare existing commands with new commands
for _, newCommand := range newCommands {
existingCommand, found := lo.Find(existingCommands, func(cmd *discordgo.ApplicationCommand) bool {
return cmd.Name == newCommand.Name
})
// New command
if !found {
log.Info().Str("commandName", newCommand.Name).Msg("Registered new command")
continue
}
// Compare versions
if newCommand.Version != existingCommand.Version {
log.Info().Str("commandName", newCommand.Name).
Str("oldVersion", existingCommand.Version).Str("newVersion", newCommand.Version).
Msg("Command Updated")
}
}
// Fetch terms on startup
err = apiInstance.TryReloadTerms()
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot fetch terms on startup")
}
// Launch a goroutine to scrape the banner system periodically
go func() {
ticker := time.NewTicker(3 * time.Minute)
defer ticker.Stop()
for {
select {
case <-cfg.Ctx.Done():
log.Info().Msg("Periodic scraper stopped due to context cancellation")
return
case <-ticker.C:
err := apiInstance.Scrape()
if err != nil {
log.Err(err).Stack().Msg("Periodic Scrape Failed")
}
}
}
}()
// Close session, ensure Resty client closes
defer session.Close()
defer client.Close()
// Setup signal handler channel
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt) // Ctrl+C signal
signal.Notify(stop, syscall.SIGTERM) // Container stop signal
// Wait for signal (indefinite)
closingSignal := <-stop
botInstance.SetClosing() // TODO: Switch to atomic lock with forced close after 10 seconds
// Cancel the context to signal all operations to stop
cfg.CancelFunc()
// Defers are called after this
log.Warn().Str("signal", closingSignal.String()).Msg("Gracefully shutting down")
}

View File

@@ -11,19 +11,20 @@ All notes on the internal workings of Sessions in the Banner system.
- If they click the button, the session will be extended via the keepAliveURL (see `meta[name="keepAliveURL"]`). - If they click the button, the session will be extended via the keepAliveURL (see `meta[name="keepAliveURL"]`).
- The `keepAliveURL` does not seem to care whether the session is or was ever valid, it will always return a 200 OK with `I am Alive` as the content. - The `keepAliveURL` does not seem to care whether the session is or was ever valid, it will always return a 200 OK with `I am Alive` as the content.
- When searching with an invalid session (or none at all, as the case may be), the server will return 200 OK, but with an empty result response structure. - When searching with an invalid session (or none at all, as the case may be), the server will return 200 OK, but with an empty result response structure.
- ```json
{ ```json
"success": true, {
"totalCount": 0, "success": true,
"data": null, // always an array, even if empty "totalCount": 0,
"pageOffset": 0, // "data": null, // always an array, even if empty
"pageMaxSize": 10, "pageOffset": 0, //
"sectionsFetchedCount": 0, "pageMaxSize": 10,
"pathMode": "registration", // normally "search" "sectionsFetchedCount": 0,
"searchResultsConfigs": null, // normally an array "pathMode": "registration", // normally "search"
"ztcEncodedImage": null // normally a static string in base64 "searchResultsConfigs": null, // normally an array
} "ztcEncodedImage": null // normally a static string in base64
}
``` ```
- This is only the handling for the search endpoint, more research is required to see how other endpoints handle invalid/expired sessions. - This is only the handling for the search endpoint, more research is required to see how other endpoints handle invalid/expired sessions.
- TODO: How is `pathMode` affected by an expired session, rather than an invalid/non-existent one? - TODO: How is `pathMode` affected by an expired session, rather than an invalid/non-existent one?

35
go.mod
View File

@@ -1,32 +1,27 @@
module banner module banner
go 1.21 go 1.24.0
require github.com/bwmarrin/discordgo v0.27.1 toolchain go1.24.2
require ( require (
github.com/bwmarrin/discordgo v0.29.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/redis/go-redis/v9 v9.3.1 github.com/redis/go-redis/v9 v9.12.1
github.com/rs/zerolog v1.31.0 github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.39.0 github.com/samber/lo v1.51.0
golang.org/x/text v0.14.0 resty.dev/v3 v3.0.0-beta.3
) )
require ( require (
github.com/arran4/golang-ical v0.2.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/crypto v0.41.0 // indirect
) golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
require ( golang.org/x/text v0.28.0 // indirect
github.com/gorilla/websocket v1.5.1 // fndirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
) )

99
go.sum
View File

@@ -1,89 +1,52 @@
github.com/arran4/golang-ical v0.2.3 h1:C4Vj7+BjJBIrAJhHgi6Ku+XUkQVugRq4re5Cqj5QVdE= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/arran4/golang-ical v0.2.3/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bwmarrin/discordgo v0.27.0 h1:4ZK9KN+rGIxZ0fdGTmgdCcliQeW8Zhu6MnlFI92nf0Q= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bwmarrin/discordgo v0.27.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 h1:ox2F0PSMlrAAiAdknSRMDrAr8mfxPCfSZolH+/qQnyQ=
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las=
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk=
github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ=
github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis v6.15.9+incompatible h1:F+tnlesQSl3h9V8DdmtcYFdvkHLhbb7AgcLW6UJxnC4= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis v6.15.9+incompatible/go.mod h1:ic6dLmR0d9rkHSzaa0Ab3QVRZcjopJ9hSSPCrecj/+s= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=
gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso=
gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo=
gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs=
gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

491
internal/api/api.go Normal file
View File

@@ -0,0 +1,491 @@
package api
import (
"banner/internal"
"banner/internal/config"
"banner/internal/models"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"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}
}
var (
latestSession string
sessionTime time.Time
expiryTime = 25 * time.Minute
)
// SessionMiddleware creates a Resty middleware that resets the session timer on each successful Banner API call.
func SessionMiddleware(_ *resty.Client, r *resty.Response) error {
// log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware")
// Reset session timer on successful requests to Banner API endpoints
if r.IsSuccess() && strings.HasPrefix(r.Request.RawRequest.URL.Path, "StudentRegistrationSsb/ssb/classSearch/") {
// Only reset the session time if the session is still valid
if time.Since(sessionTime) <= expiryTime {
sessionTime = time.Now()
}
}
return nil
}
// 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()
}
// DefaultTerm returns the default term, which is the current term if it exists, otherwise the next term.
func (a *API) DefaultTerm(t time.Time) config.Term {
currentTerm, nextTerm := config.GetCurrentTerm(*a.config.SeasonRanges, t)
if currentTerm == nil {
return *nextTerm
}
return *currentTerm
}
var terms []BannerTerm
var lastTermUpdate time.Time
// 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
}
// Load the terms
var err error
terms, err = a.GetTerms("", 1, 100)
if err != nil {
return fmt.Errorf("failed to load terms: %w", err)
}
lastTermUpdate = time.Now()
return nil
}
// 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()
if err != nil {
log.Err(err).Stack().Msg("Failed to reload terms")
return true
}
// Check if the term is in the list of terms
bannerTerm, exists := lo.Find(terms, func(t BannerTerm) bool {
return t.Code == term
})
if !exists {
log.Warn().Str("term", term).Msg("Term does not exist")
return true
}
return bannerTerm.Archived()
}
// EnsureSession ensures that a valid session is available, creating one if necessary.
func (a *API) EnsureSession() string {
if latestSession == "" || time.Since(sessionTime) >= expiryTime {
latestSession = GenerateSession()
sessionTime = time.Now()
}
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 an archival (view-only) state.
func (term BannerTerm) Archived() bool {
return strings.Contains(term.Description, "View Only")
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("offset", strconv.Itoa(page)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]BannerTerm{})
res, err := req.Get("/classSearch/getTerms")
if err != nil {
return nil, fmt.Errorf("failed to get terms: %w", err)
}
terms, ok := res.Result().(*[]BannerTerm)
if !ok {
return nil, fmt.Errorf("terms parsing failed to cast: %v", res.Result())
}
return *terms, nil
}
// 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},
"studyPath": {""},
"studyPathText": {""},
"startDatepicker": {""},
"endDatepicker": {""},
"uniqueSessionId": {sessionID},
}
type RedirectResponse struct {
FwdURL string `json:"fwdUrl"`
}
req := a.config.Client.NewRequest().
SetResult(&RedirectResponse{}).
SetQueryParam("mode", "search").
SetBody(form.Encode()).
SetExpectResponseContentType("application/json").
SetHeader("Content-Type", "application/x-www-form-urlencoded")
res, err := req.Post("/term/search")
if err != nil {
return fmt.Errorf("failed to select term: %w", err)
}
redirectResponse := res.Result().(*RedirectResponse)
// TODO: Mild validation to ensure the redirect is appropriate
// Make a GET request to the fwdUrl
req = a.config.Client.NewRequest()
res, err = req.Get(redirectResponse.FwdURL)
// Assert that the response is OK (200)
if res.StatusCode() != 200 {
return fmt.Errorf("redirect response was not OK: %d", res.StatusCode())
}
return nil
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("term", strconv.Itoa(term)).
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]BannerTerm{})
res, err := req.Get("/classSearch/get_partOfTerm")
if err != nil {
return nil, fmt.Errorf("failed to get part of terms: %w", err)
}
terms, ok := res.Result().(*[]BannerTerm)
if !ok {
return nil, fmt.Errorf("term parsing failed to cast: %v", res.Result())
}
return *terms, nil
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("term", term).
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Instructor{})
res, err := req.Get("/classSearch/get_instructor")
if err != nil {
return nil, fmt.Errorf("failed to get instructors: %w", err)
}
instructors, ok := res.Result().(*[]Instructor)
if !ok {
return nil, fmt.Errorf("instructor parsing failed to cast: %v", res.Result())
}
return *instructors, nil
}
// 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),
"courseReferenceNumber": strconv.Itoa(crn),
"first": "first", // TODO: What is this?
})
if err != nil {
log.Fatal().Stack().Err(err).Msg("Failed to marshal body")
}
req := a.config.Client.NewRequest().
SetBody(body).
SetExpectResponseContentType("application/json").
SetResult(&ClassDetails{})
res, err := req.Get("/searchResults/getClassDetails")
if err != nil {
return nil, fmt.Errorf("failed to get course details: %w", err)
}
details, ok := res.Result().(*ClassDetails)
if !ok {
return nil, fmt.Errorf("course details parsing failed to cast: %v", res.Result())
}
return details, nil
}
// 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()
params := query.Paramify()
params["txt_term"] = term
params["uniqueSessionId"] = a.EnsureSession()
params["sortColumn"] = sort
params["sortDirection"] = "asc"
// These dates are not available for usage anywhere in the UI, but are included in every query
params["startDatepicker"] = ""
params["endDatepicker"] = ""
req := a.config.Client.NewRequest().
SetQueryParams(params).
SetExpectResponseContentType("application/json").
SetResult(&models.SearchResult{})
res, err := req.Get("/searchResults/searchResults")
if err != nil {
return nil, fmt.Errorf("failed to search: %w", err)
}
searchResult, ok := res.Result().(*models.SearchResult)
if !ok {
return nil, fmt.Errorf("search result parsing failed to cast: %v", res.Result())
}
return searchResult, nil
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("term", term).
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
res, err := req.Get("/classSearch/get_subject")
if err != nil {
return nil, fmt.Errorf("failed to get subjects: %w", err)
}
subjects, ok := res.Result().(*[]Pair)
if !ok {
return nil, fmt.Errorf("subjects parsing failed to cast: %v", res.Result())
}
return *subjects, nil
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("term", strconv.Itoa(term)).
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
res, err := req.Get("/classSearch/get_campus")
if err != nil {
return nil, fmt.Errorf("failed to get campuses: %w", err)
}
campuses, ok := res.Result().(*[]Pair)
if !ok {
return nil, fmt.Errorf("campuses parsing failed to cast: %v", res.Result())
}
return *campuses, nil
}
// 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 {
return nil, errors.New("offset must be greater than 0")
}
req := a.config.Client.NewRequest().
SetQueryParam("searchTerm", search).
SetQueryParam("term", term).
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
res, err := req.Get("/classSearch/get_instructionalMethod")
if err != nil {
return nil, fmt.Errorf("failed to get instructional methods: %w", err)
}
methods, ok := res.Result().(*[]Pair)
if !ok {
return nil, fmt.Errorf("instructional methods parsing failed to cast: %v", res.Result())
}
return *methods, nil
}
// 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"`
}
req := a.config.Client.NewRequest().
SetQueryParam("term", strconv.Itoa(term)).
SetQueryParam("courseReferenceNumber", strconv.Itoa(crn)).
SetExpectResponseContentType("application/json").
SetResult(&responseWrapper{})
res, err := req.Get("/searchResults/getFacultyMeetingTimes")
if err != nil {
return nil, fmt.Errorf("failed to get meeting time: %w", err)
}
result, ok := res.Result().(*responseWrapper)
if !ok {
return nil, fmt.Errorf("meeting times parsing failed to cast: %v", res.Result())
}
return result.Fmt, nil
}
// 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()
_, err := req.Post("/classSearch/resetDataForm")
if err != nil {
log.Fatal().Stack().Err(err).Msg("Failed to reset data form")
}
}
// 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)
defer cancel()
// Retrieve raw data
result, err := a.config.KV.Get(ctx, fmt.Sprintf("class:%s", crn)).Result()
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("course not found: %w", err)
}
return nil, fmt.Errorf("failed to get course: %w", err)
}
// Unmarshal the raw data
var course models.Course
err = json.Unmarshal([]byte(result), &course)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal course: %w", err)
}
return &course, nil
}

View File

@@ -1,6 +1,10 @@
package main // Package api provides the core functionality for interacting with the Banner API.
package api
import ( import (
"banner/internal"
"banner/internal/models"
"context"
"fmt" "fmt"
"math/rand" "math/rand"
"time" "time"
@@ -10,52 +14,57 @@ import (
) )
const ( const (
// MaxPageSize is the maximum number of courses one can scrape per page.
MaxPageSize = 500 MaxPageSize = 500
) )
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 is the general scraping invocation (best called within/as a goroutine) that should be called regularly to initiate scraping of the Banner system. // Scrape retrieves all courses from the Banner API and stores them in Redis.
func Scrape() error { // This is a long-running process that should be run in a goroutine.
// Populate AllMajors if it is empty //
if len(AncillaryMajors) == 0 { // TODO: Switch from hardcoded term to dynamic term
term := Default(time.Now()).ToString() func (a *API) Scrape() error {
subjects, err := GetSubjects("", term, 1, 99) // For each subject, retrieve all courses
if err != nil { // For each course, get the details and store it in redis
return fmt.Errorf("failed to get subjects: %w", err) // Make sure to handle pagination
} subjects, err := a.GetSubjects("", "202510", 1, 100)
if err != nil {
// Ensure subjects were found return fmt.Errorf("failed to get subjects: %w", err)
if len(subjects) == 0 {
return fmt.Errorf("no subjects found")
}
// Extract major code name
for _, subject := range subjects {
// Add to AncillaryMajors if not in PriorityMajors
if !lo.Contains(PriorityMajors, subject.Code) {
AncillaryMajors = append(AncillaryMajors, subject.Code)
}
}
AllMajors = lo.Flatten([][]string{PriorityMajors, AncillaryMajors})
} }
expiredSubjects, err := GetExpiredSubjects() // Ensure subjects were found
if len(subjects) == 0 {
return fmt.Errorf("no subjects found")
}
// Extract major code name
for _, subject := range subjects {
// Add to AncillaryMajors if not in PriorityMajors
if !lo.Contains(PriorityMajors, subject.Code) {
AncillaryMajors = append(AncillaryMajors, subject.Code)
}
}
AllMajors = lo.Flatten([][]string{PriorityMajors, AncillaryMajors})
expiredSubjects, err := a.GetExpiredSubjects()
if err != nil { if err != nil {
return fmt.Errorf("failed to get scrapable majors: %w", err) return fmt.Errorf("failed to get scrapable majors: %w", err)
} }
log.Info().Strs("majors", expiredSubjects).Msg("Scraping majors") log.Info().Strs("majors", expiredSubjects).Msg("Scraping majors")
for _, subject := range expiredSubjects { for _, subject := range expiredSubjects {
err := ScrapeMajor(subject) err := a.ScrapeMajor(subject)
if err != nil { if err != nil {
return fmt.Errorf("failed to scrape major %s: %w", subject, err) return fmt.Errorf("failed to scrape major %s: %w", subject, err)
} }
@@ -64,13 +73,18 @@ func 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.
func GetExpiredSubjects() ([]string, error) { // It checks Redis for the "scraped" status of each major for the current term.
term := Default(time.Now()).ToString() func (a *API) GetExpiredSubjects() ([]string, error) {
term := a.DefaultTerm(time.Now()).ToString()
subjects := make([]string, 0) subjects := make([]string, 0)
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 10*time.Second)
defer cancel()
// Get all subjects // Get all subjects
values, err := kv.MGet(ctx, lo.Map(AllMajors, func(major string, _ int) string { values, err := a.config.KV.MGet(ctx, lo.Map(AllMajors, func(major string, _ int) string {
return fmt.Sprintf("scraped:%s:%s", major, term) return fmt.Sprintf("scraped:%s:%s", major, term)
})...).Result() })...).Result()
if err != nil { if err != nil {
@@ -92,16 +106,17 @@ func 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 ScrapeMajor(subject string) error { func (a *API) ScrapeMajor(subject string) error {
offset := 0 offset := 0
totalClassCount := 0 totalClassCount := 0
for { for {
// Build & execute the query // Build & execute the query
query := NewQuery().Offset(offset).MaxResults(MaxPageSize * 2).Subject(subject) query := NewQuery().Offset(offset).MaxResults(MaxPageSize * 2).Subject(subject)
result, err := Search(query, "subjectDescription", false) term := a.DefaultTerm(time.Now()).ToString()
result, err := a.Search(term, query, "subjectDescription", false)
if err != nil { if err != nil {
return fmt.Errorf("search failed: %w (%s)", err, query.String()) return fmt.Errorf("search failed: %w (%s)", err, query.String())
} }
@@ -118,7 +133,7 @@ func ScrapeMajor(subject string) error {
// Process each class and store it in Redis // Process each class and store it in Redis
for _, course := range result.Data { for _, course := range result.Data {
// Store class in Redis // Store class in Redis
err := IntakeCourse(course) err := a.IntakeCourse(course)
if err != nil { if err != nil {
log.Error().Err(err).Msg("failed to store class in Redis") log.Error().Err(err).Msg("failed to store class in Redis")
} }
@@ -137,28 +152,32 @@ func ScrapeMajor(subject string) error {
log.Debug().Str("subject", subject).Int("nextOffset", offset).Msg("Sleeping before next page") log.Debug().Str("subject", subject).Int("nextOffset", offset).Msg("Sleeping before next page")
time.Sleep(time.Second * 3) time.Sleep(time.Second * 3)
continue continue
} else {
// Log the number of classes scraped
log.Info().Str("subject", subject).Int("total", totalClassCount).Msgf("Subject %s Scraped", subject)
break
} }
// Log the number of classes scraped
log.Info().Str("subject", subject).Int("total", totalClassCount).Msgf("Subject %s Scraped", subject)
break
} }
term := Default(time.Now()).ToString() term := a.DefaultTerm(time.Now()).ToString()
// Calculate the expiry time for the scrape (1 hour for every 200 classes, random +-15%) with a minimum of 1 hour // Calculate the expiry time for the scrape (1 hour for every 200 classes, random +-15%) with a minimum of 1 hour
var scrapeExpiry time.Duration var scrapeExpiry time.Duration
if totalClassCount == 0 { if totalClassCount == 0 {
scrapeExpiry = time.Hour * 12 scrapeExpiry = time.Hour * 12
} else { } else {
scrapeExpiry = CalculateExpiry(term, totalClassCount, lo.Contains(PriorityMajors, subject)) scrapeExpiry = a.CalculateExpiry(term, totalClassCount, lo.Contains(PriorityMajors, subject))
} }
// Mark the major as scraped // Mark the major as scraped
if totalClassCount == 0 { if totalClassCount == 0 {
totalClassCount = -1 totalClassCount = -1
} }
err := kv.Set(ctx, fmt.Sprintf("scraped:%s:%s", subject, term), totalClassCount, scrapeExpiry).Err()
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
defer cancel()
err := a.config.KV.Set(ctx, fmt.Sprintf("scraped:%s:%s", subject, term), totalClassCount, scrapeExpiry).Err()
if err != nil { if err != nil {
log.Error().Err(err).Msg("failed to mark major as scraped") log.Error().Err(err).Msg("failed to mark major as scraped")
} }
@@ -167,17 +186,15 @@ func 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. func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
// priority is a boolean indicating whether the major is a priority major.
func 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)
// Subjects with less than 50 classes have a reversed expiry (less classes, longer interval) // Subjects with less than 50 classes have a reversed expiry (less classes, longer interval)
// 1 class => 12 hours, 49 classes => 1 hour // 1 class => 12 hours, 49 classes => 1 hour
if count < 50 { if count < 50 {
hours := Slope(Point{1, 12}, Point{49, 1}, float64(count)).Y hours := internal.Slope(internal.Point{X: 1, Y: 12}, internal.Point{X: 49, Y: 1}, float64(count)).Y
baseExpiry = time.Duration(hours * float64(time.Hour)) baseExpiry = time.Duration(hours * float64(time.Hour))
} }
@@ -188,7 +205,7 @@ func CalculateExpiry(term string, count int, priority bool) time.Duration {
// If the term is considered "view only" or "archived", then the expiry is multiplied by 5 // If the term is considered "view only" or "archived", then the expiry is multiplied by 5
var expiry = baseExpiry var expiry = baseExpiry
if IsTermArchived(term) { if a.IsTermArchived(term) {
expiry *= 5 expiry *= 5
} }
@@ -209,9 +226,13 @@ func CalculateExpiry(term string, count int, priority bool) time.Duration {
} }
// 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 IntakeCourse(course Course) error { func (a *API) IntakeCourse(course models.Course) error {
err := kv.Set(ctx, fmt.Sprintf("class:%s", course.CourseReferenceNumber), course, 0).Err() // Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
defer cancel()
err := a.config.KV.Set(ctx, fmt.Sprintf("class:%s", course.CourseReferenceNumber), course, 0).Err()
if err != nil { if err != nil {
return fmt.Errorf("failed to store class in Redis: %w", err) return fmt.Errorf("failed to store class in Redis: %w", err)
} }

View File

@@ -1,4 +1,4 @@
package main package api
import ( import (
"fmt" "fmt"
@@ -32,6 +32,8 @@ const (
paramMaxResults = "pageMaxSize" paramMaxResults = "pageMaxSize"
) )
// Query represents a search query for courses.
// 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
@@ -51,29 +53,30 @@ type Query struct {
courseNumberRange *Range courseNumberRange *Range
} }
// NewQuery creates a new Query with default values.
func NewQuery() *Query { 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}
@@ -83,88 +86,99 @@ 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
} }
// Range represents a range of two integers.
type Range struct { type Range struct {
Low int Low int
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 := "", "", ""
@@ -190,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{}

64
internal/api/session.go Normal file
View File

@@ -0,0 +1,64 @@
package api
import (
"banner/internal"
"net/url"
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...")
requestQueue := []string{
"/registration/registration",
"/selfServiceMenu/data",
}
for _, path := range requestQueue {
req := a.config.Client.NewRequest().
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json")
res, err := req.Get(path)
if err != nil {
log.Fatal().Stack().Str("path", path).Err(err).Msg("Failed to make request")
}
if res.StatusCode() != 200 {
log.Fatal().Stack().Str("path", path).Int("status", res.StatusCode()).Msg("Failed to make request")
}
}
// Validate that cookies were set
baseURLParsed, err := url.Parse(a.config.BaseURL)
if err != nil {
log.Fatal().Stack().Str("baseURL", a.config.BaseURL).Err(err).Msg("Failed to parse baseURL")
}
currentCookies := a.config.Client.CookieJar().Cookies(baseURLParsed)
requiredCookies := map[string]bool{
"JSESSIONID": false,
"SSB_COOKIE": false,
}
for _, cookie := range currentCookies {
_, present := requiredCookies[cookie.Name]
// Check if this cookie is required
if present {
requiredCookies[cookie.Name] = true
}
}
// Check if all required cookies were set
for cookieName, cookieSet := range requiredCookies {
if !cookieSet {
log.Warn().Str("cookieName", cookieName).Msg("Required cookie not set")
}
}
log.Debug().Msg("All required cookies set, session setup complete")
// TODO: Validate that the session allows access to termSelection
}

View File

@@ -1,6 +1,9 @@
package main package bot
import ( import (
"banner/internal"
"banner/internal/api"
"banner/internal/models"
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
@@ -14,9 +17,19 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
) )
const (
ICalTimestampFormatUtc = "20060102T150405Z"
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 ( var (
commandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition} // CommandDefinitions is a list of all the bot's command definitions.
commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate) error{ CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
// 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,
@@ -30,7 +43,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionString, Type: discordgo.ApplicationCommandOptionString,
MinLength: GetIntPointer(0), MinLength: internal.GetIntPointer(0),
MaxLength: 48, MaxLength: 48,
Name: "title", Name: "title",
Description: "Course Title (exact, use autocomplete)", Description: "Course Title (exact, use autocomplete)",
@@ -40,7 +53,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
{ {
Type: discordgo.ApplicationCommandOptionString, Type: discordgo.ApplicationCommandOptionString,
Name: "code", Name: "code",
MinLength: GetIntPointer(4), MinLength: internal.GetIntPointer(4),
Description: "Course Code (e.g. 3743, 3000-3999, 3xxx, 3000-)", Description: "Course Code (e.g. 3743, 3000-3999, 3xxx, 3000-)",
Required: false, Required: false,
}, },
@@ -72,9 +85,10 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.InteractionCreate) error { // SearchCommandHandler handles the /search command, which allows users to search for courses.
data := interaction.ApplicationCommandData() func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
query := NewQuery().Credits(3, 6) data := i.ApplicationCommandData()
query := api.NewQuery().Credits(3, 6)
for _, option := range data.Options { for _, option := range data.Options {
switch option.Name { switch option.Name {
@@ -173,9 +187,14 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
} }
} }
courses, err := Search(query, "", false) term, err := b.GetSession()
if err != nil { if err != nil {
session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ return err
}
courses, err := b.API.Search(term, query, "", false)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Content: "Error searching for courses", Content: "Error searching for courses",
@@ -188,13 +207,23 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
fields := []*discordgo.MessageEmbedField{} fields := []*discordgo.MessageEmbedField{}
for _, course := range courses.Data { for _, course := range courses.Data {
displayName := course.Faculty[0].DisplayName // Safe instructor name handling
displayName := "TBA"
if len(course.Faculty) > 0 {
displayName = course.Faculty[0].DisplayName
}
categoryLink := fmt.Sprintf("[%s](https://catalog.utsa.edu/undergraduate/coursedescriptions/%s/)", course.Subject, strings.ToLower(course.Subject)) categoryLink := fmt.Sprintf("[%s](https://catalog.utsa.edu/undergraduate/coursedescriptions/%s/)", course.Subject, strings.ToLower(course.Subject))
classLink := fmt.Sprintf("[%s-%s](https://catalog.utsa.edu/search/?P=%s%%20%s)", course.CourseNumber, course.SequenceNumber, course.Subject, course.CourseNumber) classLink := fmt.Sprintf("[%s-%s](https://catalog.utsa.edu/search/?P=%s%%20%s)", course.CourseNumber, course.SequenceNumber, course.Subject, course.CourseNumber)
professorLink := fmt.Sprintf("[%s](https://www.ratemyprofessors.com/search/professors/1516?q=%s)", displayName, url.QueryEscape(displayName)) professorLink := fmt.Sprintf("[%s](https://www.ratemyprofessors.com/search/professors/1516?q=%s)", displayName, url.QueryEscape(displayName))
identifierText := fmt.Sprintf("%s %s (CRN %s)\n%s", categoryLink, classLink, course.CourseReferenceNumber, professorLink) identifierText := fmt.Sprintf("%s %s (CRN %s)\n%s", categoryLink, classLink, course.CourseReferenceNumber, professorLink)
meetings := course.MeetingsFaculty[0]
// Safe meeting time handling
meetingTime := "No scheduled meetings"
if len(course.MeetingsFaculty) > 0 {
meetingTime = course.MeetingsFaculty[0].String()
}
fields = append(fields, &discordgo.MessageEmbedField{ fields = append(fields, &discordgo.MessageEmbedField{
Name: "Identifier", Name: "Identifier",
@@ -206,7 +235,7 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
Inline: true, Inline: true,
}, &discordgo.MessageEmbedField{ }, &discordgo.MessageEmbedField{
Name: "Meeting Time", Name: "Meeting Time",
Value: meetings.String(), Value: meetingTime,
Inline: true, Inline: true,
}, },
) )
@@ -218,13 +247,13 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
color = 0xFF6500 color = 0xFF6500
} }
err = session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{ Embeds: []*discordgo.MessageEmbed{
{ {
Footer: GetFetchedFooter(fetch_time), Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: p.Sprintf("%d Class%s", courses.TotalCount, Plurale(courses.TotalCount)), Description: fmt.Sprintf("%d Class%s", courses.TotalCount, internal.Plural(courses.TotalCount)),
Fields: fields[:min(25, len(fields))], Fields: fields[:min(25, len(fields))],
Color: color, Color: color,
}, },
@@ -242,7 +271,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionString, Type: discordgo.ApplicationCommandOptionString,
MinLength: GetIntPointer(0), MinLength: internal.GetIntPointer(0),
MaxLength: 8, MaxLength: 8,
Name: "search", Name: "search",
Description: "Term to search for", Description: "Term to search for",
@@ -253,13 +282,14 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Name: "page", Name: "page",
Description: "Page Number", Description: "Page Number",
Required: false, Required: false,
MinValue: GetFloatPointer(1), MinValue: internal.GetFloatPointer(1),
}, },
}, },
} }
func TermCommandHandler(session *discordgo.Session, interaction *discordgo.InteractionCreate) error { // TermCommandHandler handles the /terms command, which allows users to search for terms.
data := interaction.ApplicationCommandData() func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
searchTerm := "" searchTerm := ""
pageNumber := 1 pageNumber := 1
@@ -275,10 +305,10 @@ func TermCommandHandler(session *discordgo.Session, interaction *discordgo.Inter
} }
} }
termResult, err := GetTerms(searchTerm, pageNumber, 25) termResult, err := b.API.GetTerms(searchTerm, pageNumber, 25)
if err != nil { if err != nil {
RespondError(session, interaction.Interaction, "Error while fetching terms", err) internal.RespondError(s, i.Interaction, "Error while fetching terms", err)
return err return err
} }
@@ -298,13 +328,13 @@ func TermCommandHandler(session *discordgo.Session, interaction *discordgo.Inter
log.Warn().Int("count", len(fields)).Msg("Too many fields in term command (trimmed)") log.Warn().Int("count", len(fields)).Msg("Too many fields in term command (trimmed)")
} }
err = session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{ Embeds: []*discordgo.MessageEmbed{
{ {
Footer: GetFetchedFooter(fetch_time), Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: p.Sprintf("%d of %d term%s (page %d)", len(termResult), len(terms), Plural(len(terms)), pageNumber), Description: fmt.Sprintf("%d term%s (page %d)", len(termResult), internal.Plural(len(termResult)), pageNumber),
Fields: fields[:min(25, len(fields))], Fields: fields[:min(25, len(fields))],
}, },
}, },
@@ -328,12 +358,13 @@ var TimeCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) error { // 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() fetch_time := time.Now()
crn := i.ApplicationCommandData().Options[0].IntValue() crn := i.ApplicationCommandData().Options[0].IntValue()
// Fix static term // Fix static term
meetingTimes, err := GetCourseMeetingTime(202510, int(crn)) meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn))
if err != nil { if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -344,6 +375,16 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
return err return err
} }
if len(meetingTimes) == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "No meeting times found for this course",
},
})
return fmt.Errorf("no meeting times found for CRN %d", crn)
}
meetingTime := meetingTimes[0] meetingTime := meetingTimes[0]
duration := meetingTime.EndTime().Sub(meetingTime.StartTime()) duration := meetingTime.EndTime().Sub(meetingTime.StartTime())
@@ -352,7 +393,7 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{ Embeds: []*discordgo.MessageEmbed{
{ {
Footer: GetFetchedFooter(fetch_time), Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: "", Description: "",
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
{ {
@@ -369,7 +410,7 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
}, },
{ {
Name: "Days of Week", Name: "Days of Week",
Value: WeekdaysToString(meetingTime.Days()), Value: internal.WeekdaysToString(meetingTime.Days()),
}, },
}, },
}, },
@@ -393,16 +434,19 @@ var IcsCommandDefinition = &discordgo.ApplicationCommand{
}, },
} }
func IcsCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) error { // IcsCommandHandler handles the /ics command, which allows users to generate an ICS file for a course.
crn := i.ApplicationCommandData().Options[0].IntValue() func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Parse all options
options := internal.ParseOptions(i.ApplicationCommandData().Options)
crn := options.GetInt("crn")
course, err := GetCourse(strconv.Itoa(int(crn))) course, err := b.API.GetCourse(strconv.Itoa(int(crn)))
if err != nil { if err != nil {
return fmt.Errorf("Error retrieving course data: %w", err) return fmt.Errorf("Error retrieving course data: %w", err)
} }
// Fix static term // Fix static term
meetingTimes, err := GetCourseMeetingTime(202510, int(crn)) meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn))
if err != nil { if err != nil {
return fmt.Errorf("Error requesting meeting time: %w", err) return fmt.Errorf("Error requesting meeting time: %w", err)
} }
@@ -412,7 +456,7 @@ func IcsCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) err
} }
// Check if the course has any meeting times // Check if the course has any meeting times
_, exists := lo.Find(meetingTimes, func(mt MeetingTimeResponse) bool { _, exists := lo.Find(meetingTimes, func(mt models.MeetingTimeResponse) bool {
switch mt.MeetingTime.MeetingType { switch mt.MeetingTime.MeetingType {
case "ID", "OA": case "ID", "OA":
return false return false
@@ -423,28 +467,37 @@ func IcsCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) err
if !exists { if !exists {
log.Warn().Str("crn", course.CourseReferenceNumber).Msg("Non-meeting course requested for ICS file") log.Warn().Str("crn", course.CourseReferenceNumber).Msg("Non-meeting course requested for ICS file")
RespondError(s, i.Interaction, "The course requested does not meet at a defined moment in time.", nil) internal.RespondError(s, i.Interaction, "The course requested does not meet at a defined moment in time.", nil)
return nil return nil
} }
events := []string{} events := []string{}
for _, meeting := range meetingTimes { for _, meeting := range meetingTimes {
now := time.Now().In(CentralTimeLocation) now := time.Now().In(b.Config.CentralTimeLocation)
uid := fmt.Sprintf("%d-%s@ical.banner.xevion.dev", now.Unix(), meeting.CourseReferenceNumber) uid := fmt.Sprintf("%d-%s@ical.banner.xevion.dev", now.Unix(), meeting.CourseReferenceNumber)
startDay := meeting.StartDay() startDay := meeting.StartDay()
startTime := meeting.StartTime() startTime := meeting.StartTime()
endTime := meeting.EndTime() endTime := meeting.EndTime()
dtStart := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(startTime.Hours), int(startTime.Minutes), 0, 0, CentralTimeLocation) dtStart := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(startTime.Hours), int(startTime.Minutes), 0, 0, b.Config.CentralTimeLocation)
dtEnd := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(endTime.Hours), int(endTime.Minutes), 0, 0, CentralTimeLocation) dtEnd := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(endTime.Hours), int(endTime.Minutes), 0, 0, b.Config.CentralTimeLocation)
endDay := meeting.EndDay() // endDay := meeting.EndDay()
until := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), 23, 59, 59, 0, CentralTimeLocation) // until := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), 23, 59, 59, 0, b.Config.CentralTimeLocation)
summary := fmt.Sprintf("%s %s %s", course.Subject, course.CourseNumber, course.CourseTitle) summary := fmt.Sprintf("%s %s %s", course.Subject, course.CourseNumber, course.CourseTitle)
description := fmt.Sprintf("Instructor: %s\nSection: %s\nCRN: %s", course.Faculty[0].DisplayName, course.SequenceNumber, meeting.CourseReferenceNumber)
// Safe instructor name handling
instructorName := "TBA"
if len(course.Faculty) > 0 {
instructorName = course.Faculty[0].DisplayName
}
description := fmt.Sprintf("Instructor: %s\nSection: %s\nCRN: %s", instructorName, course.SequenceNumber, meeting.CourseReferenceNumber)
location := meeting.PlaceString() location := meeting.PlaceString()
rrule := meeting.RRule()
event := fmt.Sprintf(`BEGIN:VEVENT event := fmt.Sprintf(`BEGIN:VEVENT
DTSTAMP:%s DTSTAMP:%s
UID:%s UID:%s
@@ -454,7 +507,7 @@ DTEND;TZID=America/Chicago:%s
SUMMARY:%s SUMMARY:%s
DESCRIPTION:%s DESCRIPTION:%s
LOCATION:%s LOCATION:%s
END:VEVENT`, now.Format(ICalTimestampFormatLocal), uid, dtStart.Format(ICalTimestampFormatLocal), meeting.ByDay(), until.Format(ICalTimestampFormatLocal), dtEnd.Format(ICalTimestampFormatLocal), summary, strings.Replace(description, "\n", `\n`, -1), location) END:VEVENT`, now.Format(ICalTimestampFormatLocal), uid, dtStart.Format(ICalTimestampFormatLocal), rrule.ByDay, rrule.Until, dtEnd.Format(ICalTimestampFormatLocal), summary, strings.Replace(description, "\n", `\n`, -1), location)
events = append(events, event) events = append(events, event)
} }
@@ -489,7 +542,7 @@ CALSCALE:GREGORIAN
%s %s
END:VCALENDAR`, vTimezone, strings.Join(events, "\n")) END:VCALENDAR`, vTimezone, strings.Join(events, "\n"))
session.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
Files: []*discordgo.File{ Files: []*discordgo.File{

91
internal/bot/handlers.go Normal file
View File

@@ -0,0 +1,91 @@
package bot
import (
"banner/internal"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"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)
if b.isClosing {
err := internal.RespondError(internalSession, interaction.Interaction, "Bot is currently restarting, try again later.", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to respond with restart error feedback")
}
return
}
name := interaction.ApplicationCommandData().Name
if handler, ok := CommandHandlers[name]; ok {
// Build dict of options for the log
options := zerolog.Dict()
for _, option := range interaction.ApplicationCommandData().Options {
options.Str(option.Name, fmt.Sprintf("%v", option.Value))
}
event := log.Info().Str("name", name).Str("user", internal.GetUser(interaction).Username).Dict("options", options)
// If the command was invoked in a guild, add guild & channel info to the log
if interaction.Member != nil {
guild := zerolog.Dict()
guild.Str("id", interaction.GuildID)
guild.Str("name", internal.GetGuildName(b.Config, internalSession, interaction.GuildID))
event.Dict("guild", guild)
channel := zerolog.Dict()
channel.Str("id", interaction.ChannelID)
guild.Str("name", internal.GetChannelName(b.Config, internalSession, interaction.ChannelID))
event.Dict("channel", channel)
} else {
// If the command was invoked in a DM, add the user info to the log
user := zerolog.Dict()
user.Str("id", interaction.User.ID)
user.Str("name", interaction.User.Username)
event.Dict("user", user)
}
// Log command invocation
event.Msg("Command Invoked")
// Prepare to recover
defer func() {
if err := recover(); err != nil {
log.Error().Stack().Str("commandName", name).Interface("detail", err).Msg("Command Handler Panic")
// Respond with error
err := internal.RespondError(internalSession, interaction.Interaction, "Unexpected Error: command handler panic", nil)
if err != nil {
log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with panic error feedback")
}
}
}()
// Call handler
err := handler(b, internalSession, interaction)
// Log & respond error
if err != nil {
// TODO: Find a way to merge the response with the handler's error
log.Error().Str("commandName", name).Err(err).Msg("Command Handler Error")
// Respond with error
err = internal.RespondError(internalSession, interaction.Interaction, fmt.Sprintf("Unexpected Error: %s", err.Error()), nil)
if err != nil {
log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with error feedback")
}
}
} else {
log.Error().Stack().Str("commandName", name).Msg("Command Interaction Has No Handler")
// Respond with error
internal.RespondError(internalSession, interaction.Interaction, "Unexpected Error: interaction has no handler", nil)
}
})
}

44
internal/bot/state.go Normal file
View File

@@ -0,0 +1,44 @@
// Package bot provides the core functionality for the Discord bot.
package bot
import (
"banner/internal/api"
"banner/internal/config"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog/log"
)
// Bot represents the state of the Discord bot.
type Bot struct {
Session *discordgo.Session
API *api.API
Config *config.Config
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 := b.API.DefaultTerm(time.Now()).ToString()
log.Info().Str("term", term).Str("sessionID", sessionID).Msg("Setting selected term")
err := b.API.SelectTerm(term, sessionID)
if err != nil {
return "", fmt.Errorf("failed to select term while generating session ID: %w", err)
}
return sessionID, nil
}

72
internal/config/config.go Normal file
View File

@@ -0,0 +1,72 @@
package config
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"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
// SeasonRanges is the time.Location for US Central Time.
SeasonRanges *SeasonRanges
}
// New creates a new Config instance with a cancellable context.
func New() (*Config, error) {
ctx, cancel := context.WithCancel(context.Background())
loc, err := time.LoadLocation("America/Chicago")
if err != nil {
cancel()
return nil, err
}
seasonRanges := GetYearDayRange(loc, uint16(time.Now().Year()))
return &Config{
Ctx: ctx,
CancelFunc: cancel,
CentralTimeLocation: loc,
SeasonRanges: &seasonRanges,
}, nil
}
// SetBaseURL sets the base URL for the Banner API.
func (c *Config) SetBaseURL(url string) {
c.BaseURL = url
}
// SetEnvironment sets the application's environment.
func (c *Config) SetEnvironment(env string) {
c.Environment = env
c.IsDevelopment = env == "development"
}
// SetClient sets the Resty client for making HTTP requests.
func (c *Config) SetClient(client *resty.Client) {
c.Client = client
}
// SetRedis sets the Redis client for caching.
func (c *Config) SetRedis(r *redis.Client) {
c.KV = r
}

View File

@@ -0,0 +1,71 @@
// Package config provides the configuration and logging setup for the application.
package config
import (
"io"
"os"
"github.com/rs/zerolog"
)
const timeFormat = "2006-01-02 15:04:05"
// NewConsoleWriter creates a new console writer that splits logs between stdout and stderr.
func NewConsoleWriter() zerolog.LevelWriter {
return &ConsoleLogSplitter{
stdConsole: zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: timeFormat,
NoColor: false,
PartsOrder: []string{zerolog.TimestampFieldName, zerolog.LevelFieldName, zerolog.MessageFieldName},
PartsExclude: []string{},
FieldsExclude: []string{},
},
errConsole: zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: timeFormat,
NoColor: false,
PartsOrder: []string{zerolog.TimestampFieldName, zerolog.LevelFieldName, zerolog.MessageFieldName},
PartsExclude: []string{},
FieldsExclude: []string{},
},
}
}
// 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 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 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)
}
return c.errConsole.Write(p)
}
// 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 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 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)
}
return l.Err.Write(p)
}

140
internal/config/terms.go Normal file
View File

@@ -0,0 +1,140 @@
package config
import (
"fmt"
"strconv"
"time"
)
// Term selection should yield smart results based on the current time, as well as the input provided.
// Fall 2024, "spring" => Spring 2025
// Fall 2024, "fall" => Fall 2025
// Summer 2024, "fall" => Fall 2024
const (
// Fall is the first term of the school year.
Fall = iota
// Spring is the second term of the school year.
Spring
// Summer is the third term of the school year.
Summer
)
// Term represents a school term, consisting of a year and a season.
type Term struct {
Year uint16
Season uint8
}
// SeasonRanges represents the start and end day of each term within a year.
type SeasonRanges struct {
Spring YearDayRange
Summer YearDayRange
Fall YearDayRange
}
// 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.
// The ranges are inclusive of the start day and exclusive of the end day.
func GetYearDayRange(loc *time.Location, year uint16) SeasonRanges {
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()
summerStart := time.Date(int(year), time.May, 25, 0, 0, 0, 0, loc).YearDay()
summerEnd := time.Date(int(year), time.August, 15, 0, 0, 0, 0, loc).YearDay()
fallStart := time.Date(int(year), time.August, 18, 0, 0, 0, 0, loc).YearDay()
fallEnd := time.Date(int(year), time.December, 10, 0, 0, 0, 0, loc).YearDay()
return SeasonRanges{
Spring: YearDayRange{
Start: uint16(springStart),
End: uint16(springEnd),
},
Summer: YearDayRange{
Start: uint16(summerStart),
End: uint16(summerEnd),
},
Fall: YearDayRange{
Start: uint16(fallStart),
End: uint16(fallEnd),
},
}
}
// 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(ranges SeasonRanges, now time.Time) (*Term, *Term) {
literalYear := uint16(now.Year())
dayOfYear := uint16(now.YearDay())
// If we're past the end of the summer term, we're 'in' the next school year.
var termYear uint16
if dayOfYear > ranges.Summer.End {
termYear = literalYear + 1
} else {
termYear = literalYear
}
if (dayOfYear < ranges.Spring.Start) || (dayOfYear >= ranges.Fall.End) {
// Fall over, Spring not yet begun
return nil, &Term{Year: termYear, Season: Spring}
} else if (dayOfYear >= ranges.Spring.Start) && (dayOfYear < ranges.Spring.End) {
// Spring
return &Term{Year: termYear, Season: Spring}, &Term{Year: termYear, Season: Summer}
} else if dayOfYear < ranges.Summer.Start {
// Spring over, Summer not yet begun
return nil, &Term{Year: termYear, Season: Summer}
} else if (dayOfYear >= ranges.Summer.Start) && (dayOfYear < ranges.Summer.End) {
// Summer
return &Term{Year: termYear, Season: Summer}, &Term{Year: termYear, Season: Fall}
} else if dayOfYear < ranges.Fall.Start {
// Summer over, Fall not yet begun
return nil, &Term{Year: termYear, Season: Fall}
} else if (dayOfYear >= ranges.Fall.Start) && (dayOfYear < ranges.Fall.End) {
// Fall
return &Term{Year: termYear, Season: Fall}, nil
}
panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear))
}
// ParseTerm converts a Banner term code string to a Term struct.
func ParseTerm(code string) Term {
year, _ := strconv.ParseUint(code[0:4], 10, 16)
var season uint8
termCode := code[4:6]
switch termCode {
case "10":
season = Fall
case "20":
season = Spring
case "30":
season = Summer
}
return Term{
Year: uint16(year),
Season: season,
}
}
// ToString converts a Term struct to a Banner term code string.
func (term Term) ToString() string {
var season string
switch term.Season {
case Fall:
season = "10"
case Spring:
season = "20"
case Summer:
season = "30"
}
return fmt.Sprintf("%d%s", term.Year, season)
}

View File

@@ -1,7 +1,8 @@
package main 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

@@ -1,4 +1,4 @@
package main package internal
import ( import (
"fmt" "fmt"
@@ -14,59 +14,51 @@ import (
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/pkg/errors"
"github.com/rs/zerolog" "github.com/rs/zerolog"
log "github.com/rs/zerolog/log" log "github.com/rs/zerolog/log"
"github.com/samber/lo" "resty.dev/v3"
"banner/internal/config"
) )
// BuildRequestWithBody builds a request with the given method, path, parameters, and body // Options is a map of options from a Discord command.
func BuildRequestWithBody(method string, path string, params map[string]string, body io.Reader) *http.Request { type Options map[string]*discordgo.ApplicationCommandInteractionDataOption
// Builds a URL for the given path and parameters
requestUrl := baseURL + path
if params != nil { // GetInt returns the integer value of an option, or 0 if it doesn't exist.
takenFirst := false func (o Options) GetInt(key string) int64 {
for key, value := range params { if opt, ok := o[key]; ok {
paramChar := "&" return opt.IntValue()
if !takenFirst {
paramChar = "?"
takenFirst = true
}
requestUrl += paramChar + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
} }
return 0
request, _ := http.NewRequest(method, requestUrl, body)
AddUserAgent(request)
return request
} }
// BuildRequest builds a request with the given method, path, and parameters and an empty body // ParseOptions parses slash command options into a map for easier access.
func BuildRequest(method string, path string, params map[string]string) *http.Request { func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options {
return BuildRequestWithBody(method, path, params, nil) optionMap := make(Options)
for _, opt := range options {
optionMap[opt.Name] = opt
}
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 response has the given content type // ContentTypeMatch checks if a Resty response has the given content type.
func ContentTypeMatch(response *http.Response, search string) bool { func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
contentType := response.Header.Get("Content-Type") contentType := res.Header().Get("Content-Type")
if contentType == "" { if contentType == "" {
return search == "application/octect-stream" return expectedContentType == "application/octect-stream"
} }
return strings.HasPrefix(contentType, expectedContentType)
return strings.HasPrefix(contentType, search)
} }
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 {
@@ -75,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)
@@ -106,64 +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()))
} }
// DoRequest performs & logs the request, logging and returning the response // Plural returns "s" if n is not 1.
func DoRequest(req *http.Request) (*http.Response, error) {
headerSize := 0
for key, values := range req.Header {
for _, value := range values {
headerSize += len(key)
headerSize += len(value)
}
}
bodySize := int64(0)
if req.Body != nil {
bodySize, _ = io.Copy(io.Discard, req.Body)
}
size := zerolog.Dict().Int64("body", bodySize).Int("header", headerSize).Int("url", len(req.URL.String()))
log.Debug().
Dict("size", size).
Str("method", strings.TrimRight(req.Method, " ")).
Str("url", req.URL.String()).
Str("query", req.URL.RawQuery).
Str("content-type", req.Header.Get("Content-Type")).
Msg("Request")
res, err := client.Do(req)
if err != nil {
log.Err(err).Stack().Str("method", req.Method).Msg("Request Failed")
} else {
contentLengthHeader := res.Header.Get("Content-Length")
contentLength := int64(-1)
// If this request was a Banner API request, reset the session timer
if strings.HasPrefix(req.URL.Path, "StudentRegistrationSsb/ssb/classSearch/") {
ResetSessionTimer()
}
// Get the content length
if contentLengthHeader != "" {
contentLength, err = strconv.ParseInt(contentLengthHeader, 10, 64)
if err != nil {
contentLength = -1
}
}
log.Debug().Int("status", res.StatusCode).Int64("content-length", contentLength).Strs("content-type", res.Header["Content-Type"]).Msg("Response")
}
return res, err
}
// Plural is a simple helper function that returns an empty string if n is 1, and "s" otherwise.
func Plural(n int) string { func Plural(n int) string {
if n == 1 { if n == 1 {
return "" return ""
@@ -171,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 ""
@@ -180,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)
@@ -225,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)
@@ -241,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
@@ -253,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 != "" {
@@ -262,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
} }
@@ -303,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 {
@@ -311,9 +255,9 @@ func GuessExtension(contentType string) string {
return ext return ext
} }
// DumpResponse dumps a response body to a file for debugging purposes // DumpResponse dumps the body of a Resty response to a file for debugging.
func DumpResponse(res *http.Response) { func DumpResponse(res *resty.Response) {
contentType := res.Header.Get("Content-Type") contentType := res.Header().Get("Content-Type")
ext := GuessExtension(contentType) ext := GuessExtension(contentType)
// Use current time as filename + /dumps/ prefix // Use current time as filename + /dumps/ prefix
@@ -326,17 +270,22 @@ func DumpResponse(res *http.Response) {
} }
defer file.Close() defer file.Close()
_, err = io.Copy(file, res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
log.Err(err).Stack().Msg("Error copying response body") log.Err(err).Stack().Msg("Error reading response body")
return
}
_, err = file.Write(body)
if err != nil {
log.Err(err).Stack().Msg("Error writing response body")
return return
} }
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 {
@@ -360,26 +309,25 @@ func RespondError(session *discordgo.Session, interaction *discordgo.Interaction
}) })
} }
func GetFetchedFooter(time time.Time) *discordgo.MessageEmbedFooter { // 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{ return &discordgo.MessageEmbedFooter{
Text: fmt.Sprintf("Fetched at %s", time.In(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 {
@@ -415,54 +363,12 @@ func EncodeParams(params map[string]*[]string) string {
return buf.String() return buf.String()
} }
var terms []BannerTerm // Point represents a point in 2D space.
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
func TryReloadTerms() error {
if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour {
return nil
}
// Load the terms
var err error
terms, err = GetTerms("", 1, 100)
if err != nil {
return errors.Wrap(err, "failed to load terms")
}
lastTermUpdate = time.Now()
return nil
}
// IsTermArchived checks if the given term is archived
// TODO: Add error, switch missing term logic to error
func IsTermArchived(term string) bool {
// Ensure the terms are loaded
err := TryReloadTerms()
if err != nil {
log.Err(err).Stack().Msg("Failed to reload terms")
return true
}
// Check if the term is in the list of terms
bannerTerm, exists := lo.Find(terms, func(t BannerTerm) bool {
return t.Code == term
})
if !exists {
log.Warn().Str("term", term).Msg("Term does not exist")
return true
}
return bannerTerm.Archived()
}
// 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,16 +1,24 @@
package main // Package internal provides shared functionality for the banner application.
package internal
import ( import (
"banner/internal/config"
"context"
"time" "time"
"github.com/bwmarrin/discordgo"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
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(guildID string) string { 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)
defer cancel()
// Check Redis for the guild name // Check Redis for the guild name
guildName, err := kv.Get(ctx, "guild:"+guildID+":name").Result() guildName, err := cfg.KV.Get(ctx, "guild:"+guildID+":name").Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
log.Error().Stack().Err(err).Msg("Error getting guild name from Redis") log.Error().Stack().Err(err).Msg("Error getting guild name from Redis")
return "err" return "err"
@@ -27,7 +35,9 @@ func GetGuildName(guildID string) string {
log.Error().Stack().Err(err).Msg("Error getting guild name") log.Error().Stack().Err(err).Msg("Error getting guild name")
// Store an invalid value in Redis so we don't keep trying to get the guild name // Store an invalid value in Redis so we don't keep trying to get the guild name
_, err := kv.Set(ctx, "guild:"+guildID+":name", "x", time.Minute*5).Result() ctx2, cancel2 := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel2()
_, err := cfg.KV.Set(ctx2, "guild:"+guildID+":name", "x", time.Minute*5).Result()
if err != nil { if err != nil {
log.Error().Stack().Err(err).Msg("Error setting false guild name in Redis") log.Error().Stack().Err(err).Msg("Error setting false guild name in Redis")
} }
@@ -36,15 +46,21 @@ func GetGuildName(guildID string) string {
} }
// Cache the guild name in Redis // Cache the guild name in Redis
kv.Set(ctx, "guild:"+guildID+":name", guild.Name, time.Hour*3) ctx3, cancel3 := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel3()
cfg.KV.Set(ctx3, "guild:"+guildID+":name", guild.Name, time.Hour*3)
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(channelID string) string { 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)
defer cancel()
// Check Redis for the channel name // Check Redis for the channel name
channelName, err := kv.Get(ctx, "channel:"+channelID+":name").Result() channelName, err := cfg.KV.Get(ctx, "channel:"+channelID+":name").Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
log.Error().Stack().Err(err).Msg("Error getting channel name from Redis") log.Error().Stack().Err(err).Msg("Error getting channel name from Redis")
return "err" return "err"
@@ -61,7 +77,9 @@ func GetChannelName(channelID string) string {
log.Error().Stack().Err(err).Msg("Error getting channel name") log.Error().Stack().Err(err).Msg("Error getting channel name")
// Store an invalid value in Redis so we don't keep trying to get the channel name // Store an invalid value in Redis so we don't keep trying to get the channel name
_, err := kv.Set(ctx, "channel:"+channelID+":name", "x", time.Minute*5).Result() ctx2, cancel2 := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel2()
_, err := cfg.KV.Set(ctx2, "channel:"+channelID+":name", "x", time.Minute*5).Result()
if err != nil { if err != nil {
log.Error().Stack().Err(err).Msg("Error setting false channel name in Redis") log.Error().Stack().Err(err).Msg("Error setting false channel name in Redis")
} }
@@ -70,7 +88,9 @@ func GetChannelName(channelID string) string {
} }
// Cache the channel name in Redis // Cache the channel name in Redis
kv.Set(ctx, "channel:"+channelID+":name", channel.Name, time.Hour*3) ctx3, cancel3 := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel3()
cfg.KV.Set(ctx3, "channel:"+channelID+":name", channel.Name, time.Hour*3)
return channel.Name return channel.Name
} }

View File

@@ -1,6 +1,8 @@
package main // Package models provides the data structures for the Banner API.
package models
import ( import (
"banner/internal"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
@@ -10,10 +12,9 @@ import (
log "github.com/rs/zerolog/log" log "github.com/rs/zerolog/log"
) )
const JsonContentType = "application/json" // 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"`
@@ -23,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"`
@@ -81,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":
@@ -105,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()
@@ -113,14 +117,14 @@ func (m *MeetingTimeResponse) TimeString() string {
return "???" return "???"
} }
return fmt.Sprintf("%s %s-%s", 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"
} }
@@ -128,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{}
@@ -141,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{}
@@ -172,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 {
@@ -182,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 {
@@ -192,9 +197,9 @@ 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() *NaiveTime { func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
raw := m.MeetingTime.BeginTime raw := m.MeetingTime.BeginTime
if raw == "" { if raw == "" {
log.Panic().Stack().Msg("Start time is empty") log.Panic().Stack().Msg("Start time is empty")
@@ -205,12 +210,12 @@ func (m *MeetingTimeResponse) StartTime() *NaiveTime {
log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse start time integer") log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse start time integer")
} }
return 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() *NaiveTime { func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
raw := m.MeetingTime.EndTime raw := m.MeetingTime.EndTime
if raw == "" { if raw == "" {
return nil return nil
@@ -221,20 +226,24 @@ func (m *MeetingTimeResponse) EndTime() *NaiveTime {
log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse end time integer") log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse end time integer")
} }
return ParseNaiveTime(value) return internal.ParseNaiveTime(value)
} }
// Converts the meeting time to a string that satisfies the iCalendar RRule format // RRule represents a recurrence rule for an iCalendar event.
func (m *MeetingTimeResponse) RRule() string { type RRule struct {
sb := strings.Builder{} Until string
ByDay string
sb.WriteString("FREQ=WEEKLY;")
sb.WriteString(fmt.Sprintf("UNTIL=%s;", m.EndDay().UTC().Format(ICalTimestampFormatUtc)))
sb.WriteString(fmt.Sprintf("BYDAY=%s;", m.ByDay()))
return sb.String()
} }
// 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"),
ByDay: m.ByDay(),
}
}
// 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"`
@@ -248,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"`
@@ -294,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)
} }

35
logs.go
View File

@@ -1,35 +0,0 @@
package main
import (
"io"
"os"
"github.com/rs/zerolog"
)
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}
)
// logSplitter implements zerolog.LevelWriter
type logSplitter struct {
std io.Writer
err io.Writer
}
// Write should not be called
func (l logSplitter) Write(p []byte) (n int, err error) {
return l.std.Write(p)
}
// WriteLevel write to the appropriate output
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)
}
}

335
main.go
View File

@@ -1,335 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"net/http"
"net/http/cookiejar"
_ "net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
_ "time/tzdata"
"github.com/bwmarrin/discordgo"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog/pkgerrors"
"github.com/samber/lo"
"golang.org/x/text/message"
)
var (
ctx context.Context
kv *redis.Client
session *discordgo.Session
client http.Client
cookies http.CookieJar
isDevelopment bool
baseURL string // Base URL for all requests to the banner system
environment string
p *message.Printer = message.NewPrinter(message.MatchLanguage("en"))
CentralTimeLocation *time.Location
isClosing bool = false
)
const (
ICalTimestampFormatUtc = "20060102T150405Z"
ICalTimestampFormatLocal = "20060102T150405"
CentralTimezoneName = "America/Chicago"
)
func init() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Debug().Err(err).Msg("Error loading .env file")
}
ctx = context.Background()
var err error
CentralTimeLocation, err = time.LoadLocation(CentralTimezoneName)
if err != nil {
panic(err)
}
// Set zerolog's timestamp function to use the central timezone
zerolog.TimestampFunc = func() time.Time {
return time.Now().In(CentralTimeLocation)
}
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
// Try to grab the environment variable, or default to development
environment = GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
if environment == "" {
environment = "development"
}
// Use the custom console writer if we're in development
isDevelopment = environment == "development"
if isDevelopment {
log.Logger = zerolog.New(logSplitter{std: stdConsole, err: errConsole}).With().Timestamp().Logger()
} else {
log.Logger = zerolog.New(logSplitter{std: os.Stdout, err: os.Stderr}).With().Timestamp().Logger()
}
log.Debug().Str("environment", environment).Msg("Loggers Setup")
// Set discordgo's logger to use zerolog
discordgo.Logger = DiscordGoLogger
baseURL = os.Getenv("BANNER_BASE_URL")
}
func initRedis() {
// Setup redis
redisUrl := GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")
if redisUrl == "" {
log.Fatal().Stack().Msg("REDIS_URL/REDIS_PRIVATE_URL not set")
}
// Parse URL and create client
options, err := redis.ParseURL(redisUrl)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot parse redis url")
}
kv = redis.NewClient(options)
var lastPingErr error
pingCount := 0 // Nth ping being attempted
totalPings := 5 // Total pings to attempt
// Wait for private networking to kick in (production only)
if !isDevelopment {
time.Sleep(250 * time.Millisecond)
}
// Test the redis instance, try to ping every 2 seconds 5 times, otherwise panic
for {
pingCount++
if pingCount > totalPings {
log.Fatal().Stack().Err(lastPingErr).Msg("Reached ping limit while trying to connect")
}
// Ping redis
pong, err := kv.Ping(ctx).Result()
// Failed; log error and wait 2 seconds
if err != nil {
lastPingErr = err
log.Warn().Err(err).Int("pings", pingCount).Int("remaining", totalPings-pingCount).Msg("Cannot ping redis")
time.Sleep(2 * time.Second)
continue
}
log.Debug().Str("ping", pong).Msg("Redis connection successful")
break
}
}
func main() {
flag.Parse()
initRedis()
if strings.EqualFold(os.Getenv("PPROF_ENABLE"), "true") {
// Start pprof server
go func() {
port := os.Getenv("PORT")
log.Info().Str("port", port).Msg("Starting pprof server")
err := http.ListenAndServe(":"+port, nil)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot start pprof server")
}
}()
}
// Create cookie jar
var err error
cookies, err = cookiejar.New(nil)
if err != nil {
log.Err(err).Msg("Cannot create cookie jar")
}
// Create client, setup session (acquire cookies)
client = http.Client{Jar: cookies}
setup()
// Create discord session
session, err = discordgo.New("Bot " + os.Getenv("BOT_TOKEN"))
if err != nil {
log.Err(err).Msg("Invalid bot parameters")
}
// Open discord session
session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
log.Info().Str("username", r.User.Username).Str("discriminator", r.User.Discriminator).Str("id", r.User.ID).Str("session", s.State.SessionID).Msg("Bot is logged in")
})
err = session.Open()
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot open the session")
}
// Setup command handlers
session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) {
// Handle commands during restart (highly unlikely, but just in case)
if isClosing {
err := RespondError(internalSession, interaction.Interaction, "Bot is currently restarting, try again later.", nil)
if err != nil {
log.Error().Err(err).Msg("Failed to respond with restart error feedback")
}
return
}
name := interaction.ApplicationCommandData().Name
if handler, ok := commandHandlers[name]; ok {
// Build dict of options for the log
options := zerolog.Dict()
for _, option := range interaction.ApplicationCommandData().Options {
options.Str(option.Name, fmt.Sprintf("%v", option.Value))
}
event := log.Info().Str("name", name).Str("user", GetUser(interaction).Username).Dict("options", options)
// If the command was invoked in a guild, add guild & channel info to the log
if interaction.Member != nil {
guild := zerolog.Dict()
guild.Str("id", interaction.GuildID)
guild.Str("name", GetGuildName(interaction.GuildID))
event.Dict("guild", guild)
channel := zerolog.Dict()
channel.Str("id", interaction.ChannelID)
guild.Str("name", GetChannelName(interaction.ChannelID))
event.Dict("channel", channel)
} else {
// If the command was invoked in a DM, add the user info to the log
user := zerolog.Dict()
user.Str("id", interaction.User.ID)
user.Str("name", interaction.User.Username)
event.Dict("user", user)
}
// Log command invocation
event.Msg("Command Invoked")
// Prepare to recover
defer func() {
if err := recover(); err != nil {
log.Error().Stack().Str("commandName", name).Interface("detail", err).Msg("Command Handler Panic")
// Respond with error
err := RespondError(internalSession, interaction.Interaction, "Unexpected Error: command handler panic", nil)
if err != nil {
log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with panic error feedback")
}
}
}()
// Call handler
err := handler(internalSession, interaction)
// Log & respond error
if err != nil {
// TODO: Find a way to merge the response with the handler's error
log.Error().Str("commandName", name).Err(err).Msg("Command Handler Error")
// Respond with error
err = RespondError(internalSession, interaction.Interaction, fmt.Sprintf("Unexpected Error: %s", err.Error()), nil)
if err != nil {
log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with error feedback")
}
}
} else {
log.Error().Stack().Str("commandName", name).Msg("Command Interaction Has No Handler")
// Respond with error
RespondError(internalSession, interaction.Interaction, "Unexpected Error: interaction has no handler", nil)
}
})
// Register commands with discord
arr := zerolog.Arr()
lo.ForEach(commandDefinitions, func(cmd *discordgo.ApplicationCommand, _ int) {
arr.Str(cmd.Name)
})
log.Info().Array("commands", arr).Msg("Registering commands")
// In development, use test server, otherwise empty (global) for command registration
guildTarget := ""
if isDevelopment {
guildTarget = os.Getenv("BOT_TARGET_GUILD")
}
// Register commands
existingCommands, err := session.ApplicationCommands(session.State.User.ID, guildTarget)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot get existing commands")
}
newCommands, err := session.ApplicationCommandBulkOverwrite(session.State.User.ID, guildTarget, commandDefinitions)
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot register commands")
}
// Compare existing commands with new commands
for _, newCommand := range newCommands {
existingCommand, found := lo.Find(existingCommands, func(cmd *discordgo.ApplicationCommand) bool {
return cmd.Name == newCommand.Name
})
// New command
if !found {
log.Info().Str("commandName", newCommand.Name).Msg("Registered new command")
continue
}
// Compare versions
if newCommand.Version != existingCommand.Version {
log.Info().Str("commandName", newCommand.Name).
Str("oldVersion", existingCommand.Version).Str("newVersion", newCommand.Version).
Msg("Command Updated")
}
}
// Fetch terms on startup
err = TryReloadTerms()
if err != nil {
log.Fatal().Stack().Err(err).Msg("Cannot fetch terms on startup")
}
// Launch a goroutine to scrape the banner system periodically
go func() {
for {
err := Scrape()
if err != nil {
log.Err(err).Stack().Msg("Periodic Scrape Failed")
}
time.Sleep(3 * time.Minute)
}
}()
// Close session, ensure http client closes idle connections
defer session.Close()
defer client.CloseIdleConnections()
// Setup signal handler channel
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt) // Ctrl+C signal
signal.Notify(stop, syscall.SIGTERM) // Container stop signal
// Wait for signal (indefinite)
closingSignal := <-stop
isClosing = true // TODO: Switch to atomic lock with forced close after 10 seconds
// Defers are called after this
log.Warn().Str("signal", closingSignal.String()).Msg("Gracefully shutting down")
}

View File

@@ -1,52 +0,0 @@
package main
import (
"net/url"
log "github.com/rs/zerolog/log"
)
func setup() {
// Makes the initial requests that sets up the session cookies for the rest of the application
log.Info().Msg("Setting up session...")
request_queue := []string{
"/registration/registration",
"/selfServiceMenu/data",
}
for _, path := range request_queue {
req := BuildRequest("GET", path, nil)
DoRequest(req)
}
// Validate that cookies were set
baseUrlParsed, err := url.Parse(baseURL)
if err != nil {
log.Fatal().Stack().Str("baseURL", baseURL).Err(err).Msg("Failed to parse baseURL")
}
current_cookies := client.Jar.Cookies(baseUrlParsed)
required_cookies := map[string]bool{
"JSESSIONID": false,
"SSB_COOKIE": false,
}
for _, cookie := range current_cookies {
_, present := required_cookies[cookie.Name]
// Check if this cookie is required
if present {
required_cookies[cookie.Name] = true
}
}
// Check if all required cookies were set
for cookieName, cookie_set := range required_cookies {
if !cookie_set {
log.Warn().Str("cookieName", cookieName).Msg("Required cookie not set")
}
}
log.Debug().Msg("All required cookies set, session setup complete")
// TODO: Validate that the session allows access to termSelection
}

145
term.go
View File

@@ -1,145 +0,0 @@
package main
import (
"fmt"
"strconv"
"time"
"github.com/rs/zerolog/log"
)
// Term selection should yield smart results based on the current time, as well as the input provided.
// Fall 2024, "spring" => Spring 2025
// Fall 2024, "fall" => Fall 2025
// Summer 2024, "fall" => Fall 2024
const (
Spring = iota
Summer
Fall
)
type Term struct {
Year uint16
Season uint8
}
var (
SpringRange, SummerRange, FallRange YearDayRange
)
func init() {
SpringRange, SummerRange, FallRange = GetYearDayRange(uint16(time.Now().Year()))
currentTerm, nextTerm := GetCurrentTerm(time.Now())
log.Debug().Str("CurrentTerm", fmt.Sprintf("%+v", currentTerm)).Str("NextTerm", fmt.Sprintf("%+v", nextTerm)).Msg("GetCurrentTerm")
}
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
func GetYearDayRange(year uint16) (YearDayRange, YearDayRange, YearDayRange) {
springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, CentralTimeLocation).YearDay()
springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, CentralTimeLocation).YearDay()
summerStart := time.Date(int(year), time.May, 25, 0, 0, 0, 0, CentralTimeLocation).YearDay()
summerEnd := time.Date(int(year), time.August, 15, 0, 0, 0, 0, CentralTimeLocation).YearDay()
fallStart := time.Date(int(year), time.August, 18, 0, 0, 0, 0, CentralTimeLocation).YearDay()
fallEnd := time.Date(int(year), time.December, 10, 0, 0, 0, 0, CentralTimeLocation).YearDay()
return YearDayRange{
Start: uint16(springStart),
End: uint16(springEnd),
}, YearDayRange{
Start: uint16(summerStart),
End: uint16(summerEnd),
}, YearDayRange{
Start: uint16(fallStart),
End: uint16(fallEnd),
}
}
// 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.
func GetCurrentTerm(now time.Time) (*Term, *Term) {
year := uint16(now.Year())
dayOfYear := uint16(now.YearDay())
// Fall of 2024 => 202410
// Spring of 2024 => 202420
// Fall of 2025 => 202510
// Summer of 2025 => 202530
if (dayOfYear < SpringRange.Start) || (dayOfYear >= FallRange.End) {
// Fall over, Spring not yet begun
return nil, &Term{Year: year + 1, Season: Spring}
} else if (dayOfYear >= SpringRange.Start) && (dayOfYear < SpringRange.End) {
// Spring
return &Term{Year: year, Season: Spring}, &Term{Year: year, Season: Summer}
} else if dayOfYear < SummerRange.Start {
// Spring over, Summer not yet begun
return nil, &Term{Year: year, Season: Summer}
} else if (dayOfYear >= SummerRange.Start) && (dayOfYear < SummerRange.End) {
// Summer
return &Term{Year: year, Season: Summer}, &Term{Year: year, Season: Fall}
} else if dayOfYear < FallRange.Start {
// Summer over, Fall not yet begun
return nil, &Term{Year: year + 1, Season: Fall}
} else if (dayOfYear >= FallRange.Start) && (dayOfYear < FallRange.End) {
// Fall
return &Term{Year: year + 1, Season: Fall}, nil
}
panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear))
}
// ParseTerm converts a Banner term code to a Term struct
func ParseTerm(code string) Term {
year, _ := strconv.ParseUint(code[0:4], 10, 16)
var season uint8
termCode := code[4:6]
switch termCode {
case "10":
season = Fall
case "20":
season = Spring
case "30":
season = Summer
}
return Term{
Year: uint16(year),
Season: season,
}
}
// TermToBannerTerm converts a Term struct to a Banner term code
func (term Term) ToString() string {
var season string
switch term.Season {
case Fall:
season = "10"
case Spring:
season = "20"
case Summer:
season = "30"
}
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.
func Default(t time.Time) Term {
currentTerm, nextTerm := GetCurrentTerm(t)
if currentTerm == nil {
return *nextTerm
}
return *currentTerm
}

221
tests/term_test.go Normal file
View File

@@ -0,0 +1,221 @@
package utils_test
import (
"banner/internal/config"
"banner/internal/utils"
"testing"
"time"
)
func TestGetCurrentTerm(t *testing.T) {
// Initialize config for testing
config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago")
// Use current year to avoid issues with global state
currentYear := uint16(time.Now().Year())
tests := []struct {
name string
date time.Time
expectedCurrent *utils.Term
expectedNext *utils.Term
}{
{
name: "Spring term",
date: time.Date(int(currentYear), 3, 15, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Spring},
expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer},
},
{
name: "Summer term",
date: time.Date(int(currentYear), 6, 15, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Summer},
expectedNext: &utils.Term{Year: currentYear, Season: utils.Fall},
},
{
name: "Fall term",
date: time.Date(int(currentYear), 9, 15, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear + 1, Season: utils.Fall},
expectedNext: nil,
},
{
name: "Between Spring and Summer",
date: time.Date(int(currentYear), 5, 20, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: nil,
expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer},
},
{
name: "Between Summer and Fall",
date: time.Date(int(currentYear), 8, 16, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: nil,
expectedNext: &utils.Term{Year: currentYear + 1, Season: utils.Fall},
},
{
name: "Between Fall and Spring",
date: time.Date(int(currentYear), 12, 15, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: nil,
expectedNext: &utils.Term{Year: currentYear + 1, Season: utils.Spring},
},
{
name: "Early January before Spring",
date: time.Date(int(currentYear), 1, 10, 12, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: nil,
expectedNext: &utils.Term{Year: currentYear, Season: utils.Spring},
},
{
name: "Spring start date",
date: time.Date(int(currentYear), 1, 14, 0, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Spring},
expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer},
},
{
name: "Summer start date",
date: time.Date(int(currentYear), 5, 25, 0, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Summer},
expectedNext: &utils.Term{Year: currentYear, Season: utils.Fall},
},
{
name: "Fall start date",
date: time.Date(int(currentYear), 8, 18, 0, 0, 0, 0, config.CentralTimeLocation),
expectedCurrent: &utils.Term{Year: currentYear + 1, Season: utils.Fall},
expectedNext: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
current, next := utils.GetCurrentTerm(tt.date)
if !termsEqual(current, tt.expectedCurrent) {
t.Errorf("GetCurrentTerm() current = %v, want %v", current, tt.expectedCurrent)
}
if !termsEqual(next, tt.expectedNext) {
t.Errorf("GetCurrentTerm() next = %v, want %v", next, tt.expectedNext)
}
})
}
}
func TestGetYearDayRange(t *testing.T) {
config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago")
spring, summer, fall := utils.GetYearDayRange(2024)
// Verify Spring range (Jan 14 to May 1)
expectedSpringStart := time.Date(2024, 1, 14, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
expectedSpringEnd := time.Date(2024, 5, 1, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
if spring.Start != uint16(expectedSpringStart) {
t.Errorf("Spring start = %d, want %d", spring.Start, expectedSpringStart)
}
if spring.End != uint16(expectedSpringEnd) {
t.Errorf("Spring end = %d, want %d", spring.End, expectedSpringEnd)
}
// Verify Summer range (May 25 to Aug 15)
expectedSummerStart := time.Date(2024, 5, 25, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
expectedSummerEnd := time.Date(2024, 8, 15, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
if summer.Start != uint16(expectedSummerStart) {
t.Errorf("Summer start = %d, want %d", summer.Start, expectedSummerStart)
}
if summer.End != uint16(expectedSummerEnd) {
t.Errorf("Summer end = %d, want %d", summer.End, expectedSummerEnd)
}
// Verify Fall range (Aug 18 to Dec 10)
expectedFallStart := time.Date(2024, 8, 18, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
expectedFallEnd := time.Date(2024, 12, 10, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
if fall.Start != uint16(expectedFallStart) {
t.Errorf("Fall start = %d, want %d", fall.Start, expectedFallStart)
}
if fall.End != uint16(expectedFallEnd) {
t.Errorf("Fall end = %d, want %d", fall.End, expectedFallEnd)
}
}
func TestParseTerm(t *testing.T) {
tests := []struct {
code string
expected utils.Term
}{
{"202410", utils.Term{Year: 2024, Season: utils.Fall}},
{"202420", utils.Term{Year: 2024, Season: utils.Spring}},
{"202430", utils.Term{Year: 2024, Season: utils.Summer}},
{"202510", utils.Term{Year: 2025, Season: utils.Fall}},
}
for _, tt := range tests {
t.Run(tt.code, func(t *testing.T) {
result := utils.ParseTerm(tt.code)
if result != tt.expected {
t.Errorf("ParseTerm(%s) = %v, want %v", tt.code, result, tt.expected)
}
})
}
}
func TestTermToString(t *testing.T) {
tests := []struct {
term utils.Term
expected string
}{
{utils.Term{Year: 2024, Season: utils.Fall}, "202410"},
{utils.Term{Year: 2024, Season: utils.Spring}, "202420"},
{utils.Term{Year: 2024, Season: utils.Summer}, "202430"},
{utils.Term{Year: 2025, Season: utils.Fall}, "202510"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := tt.term.ToString()
if result != tt.expected {
t.Errorf("Term{Year: %d, Season: %d}.ToString() = %s, want %s",
tt.term.Year, tt.term.Season, result, tt.expected)
}
})
}
}
func TestDefault(t *testing.T) {
config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago")
tests := []struct {
name string
date time.Time
expected utils.Term
}{
{
name: "During Spring term",
date: time.Date(2024, 3, 15, 12, 0, 0, 0, config.CentralTimeLocation),
expected: utils.Term{Year: 2024, Season: utils.Spring},
},
{
name: "Between terms - returns next term",
date: time.Date(2024, 5, 20, 12, 0, 0, 0, config.CentralTimeLocation),
expected: utils.Term{Year: 2024, Season: utils.Summer},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := utils.Default(tt.date)
if result != tt.expected {
t.Errorf("Default() = %v, want %v", result, tt.expected)
}
})
}
}
// Helper function to compare terms, handling nil cases
func termsEqual(a, b *utils.Term) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}