Compare commits

...

14 Commits

27 changed files with 1971 additions and 1564 deletions

6
.gitignore vendored
View File

@@ -1,8 +1,10 @@
.env
cover.cov
banner
/banner
.*.go
dumps/
js/
.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
- Metrics, Log Query, Privileged Error Feedback
- Search for Classes
- Major, Professor, Location, Name, Time of Day
- Major, Professor, Location, Name, Time of Day
- Subscribe to Classes
- Availability (seat, pre-seat)
- Waitlist Movement
- Detail Changes (meta, time, location, seats, professor)
- `time` Start, End, Days of Week
- `seats` Any change in seat/waitlist data
- `meta`
- Availability (seat, pre-seat)
- Waitlist Movement
- Detail Changes (meta, time, location, seats, professor)
- `time` Start, End, Days of Week
- `seats` Any change in seat/waitlist data
- `meta`
- Lookup via Course Reference Number (CRN)
- Smart Time of Day Handling
- "2 PM" -> Start within 2:00 PM to 2: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
- "after 2 PM" -> Start within 2:01 PM to 11:59 PM
- "before 2 PM" -> Ends within 12:00 AM to 1: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
- "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
- "before 2 PM" -> Ends within 12:00 AM to 1:59 PM
- Get By Section Command
- CS 4393 001 =>
- 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.
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).
- Other majors will be scraped in arbitrary order (if required).
- 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)
Considerations
- Change in metadata should decrease the interval
- 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.
The requirements to this hypothetical system would be:
- 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"]`).
- 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.
- ```json
{
"success": true,
"totalCount": 0,
"data": null, // always an array, even if empty
"pageOffset": 0, //
"pageMaxSize": 10,
"sectionsFetchedCount": 0,
"pathMode": "registration", // normally "search"
"searchResultsConfigs": null, // normally an array
"ztcEncodedImage": null // normally a static string in base64
}
```json
{
"success": true,
"totalCount": 0,
"data": null, // always an array, even if empty
"pageOffset": 0, //
"pageMaxSize": 10,
"sectionsFetchedCount": 0,
"pathMode": "registration", // normally "search"
"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.
- TODO: How is `pathMode` affected by an expired session, rather than an invalid/non-existent one?
- 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?

35
go.mod
View File

@@ -1,32 +1,27 @@
module banner
go 1.21
go 1.24.0
require github.com/bwmarrin/discordgo v0.27.1
toolchain go1.24.2
require (
github.com/bwmarrin/discordgo v0.29.0
github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1
github.com/redis/go-redis/v9 v9.3.1
github.com/rs/zerolog v1.31.0
github.com/samber/lo v1.39.0
golang.org/x/text v0.14.0
github.com/redis/go-redis/v9 v9.12.1
github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.51.0
resty.dev/v3 v3.0.0-beta.3
)
require (
github.com/arran4/golang-ical v0.2.3 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
)
require (
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
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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
golang.org/x/text v0.28.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/arran4/golang-ical v0.2.3/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ=
github.com/bwmarrin/discordgo v0.27.0 h1:4ZK9KN+rGIxZ0fdGTmgdCcliQeW8Zhu6MnlFI92nf0Q=
github.com/bwmarrin/discordgo v0.27.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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/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.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
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/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.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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis v6.15.9+incompatible h1:F+tnlesQSl3h9V8DdmtcYFdvkHLhbb7AgcLW6UJxnC4=
github.com/redis/go-redis v6.15.9+incompatible/go.mod h1:ic6dLmR0d9rkHSzaa0Ab3QVRZcjopJ9hSSPCrecj/+s=
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
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=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
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.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
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/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
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.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
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.6.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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
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/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=

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 (
"banner/internal"
"banner/internal/models"
"context"
"fmt"
"math/rand"
"time"
@@ -10,52 +14,57 @@ import (
)
const (
// MaxPageSize is the maximum number of courses one can scrape per page.
MaxPageSize = 500
)
var (
// PriorityMajors is a list of majors that are considered to be high priority for scraping. This list is used to determine which majors to scrape first/most often.
// PriorityMajors is a list of majors that are considered to be high priority for scraping.
// This list is used to determine which majors to scrape first/most often.
PriorityMajors = []string{"CS", "CPE", "MAT", "EE", "IS"}
// AncillaryMajors is a list of majors that are considered to be low priority for scraping. This list will not contain any majors that are in PriorityMajors.
// AncillaryMajors is a list of majors that are considered to be low priority for scraping.
// This list will not contain any majors that are in PriorityMajors.
AncillaryMajors []string
// AllMajors is a list of all majors that are available in the Banner system.
AllMajors []string
)
// Scrape is the general scraping invocation (best called within/as a goroutine) that should be called regularly to initiate scraping of the Banner system.
func Scrape() error {
// Populate AllMajors if it is empty
if len(AncillaryMajors) == 0 {
term := Default(time.Now()).ToString()
subjects, err := GetSubjects("", term, 1, 99)
if err != nil {
return fmt.Errorf("failed to get subjects: %w", err)
}
// 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})
// Scrape retrieves all courses from the Banner API and stores them in Redis.
// This is a long-running process that should be run in a goroutine.
//
// TODO: Switch from hardcoded term to dynamic term
func (a *API) Scrape() error {
// For each subject, retrieve all courses
// For each course, get the details and store it in redis
// Make sure to handle pagination
subjects, err := a.GetSubjects("", "202510", 1, 100)
if err != nil {
return fmt.Errorf("failed to get subjects: %w", err)
}
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 {
return fmt.Errorf("failed to get scrapable majors: %w", err)
}
log.Info().Strs("majors", expiredSubjects).Msg("Scraping majors")
for _, subject := range expiredSubjects {
err := ScrapeMajor(subject)
err := a.ScrapeMajor(subject)
if err != nil {
return fmt.Errorf("failed to scrape major %s: %w", subject, err)
}
@@ -64,13 +73,18 @@ func Scrape() error {
return nil
}
// GetExpiredSubjects returns a list of subjects that are expired and should be scraped.
func GetExpiredSubjects() ([]string, error) {
term := Default(time.Now()).ToString()
// GetExpiredSubjects returns a list of subjects that have expired and should be scraped again.
// It checks Redis for the "scraped" status of each major for the current term.
func (a *API) GetExpiredSubjects() ([]string, error) {
term := a.DefaultTerm(time.Now()).ToString()
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
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)
})...).Result()
if err != nil {
@@ -92,16 +106,17 @@ func GetExpiredSubjects() ([]string, error) {
return subjects, nil
}
// ScrapeMajor is the scraping invocation for a specific major.
// This function does not check whether scraping is required at this time, it is assumed that the caller has already done so.
func ScrapeMajor(subject string) error {
// ScrapeMajor scrapes all courses for a specific major.
// This function does not check whether scraping is required at this time; it is assumed that the caller has already done so.
func (a *API) ScrapeMajor(subject string) error {
offset := 0
totalClassCount := 0
for {
// Build & execute the query
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 {
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
for _, course := range result.Data {
// Store class in Redis
err := IntakeCourse(course)
err := a.IntakeCourse(course)
if err != nil {
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")
time.Sleep(time.Second * 3)
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
var scrapeExpiry time.Duration
if totalClassCount == 0 {
scrapeExpiry = time.Hour * 12
} else {
scrapeExpiry = CalculateExpiry(term, totalClassCount, lo.Contains(PriorityMajors, subject))
scrapeExpiry = a.CalculateExpiry(term, totalClassCount, lo.Contains(PriorityMajors, subject))
}
// Mark the major as scraped
if totalClassCount == 0 {
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 {
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.
// term is the term for which the relevant course is occurring within.
// count is the number of courses that were scraped.
// priority is a boolean indicating whether the major is a priority major.
func CalculateExpiry(term string, count int, priority bool) time.Duration {
// The duration is based on the number of courses, whether the major is a priority, and if the term is archived.
func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
// An hour for every 100 classes
baseExpiry := time.Hour * time.Duration(count/100)
// Subjects with less than 50 classes have a reversed expiry (less classes, longer interval)
// 1 class => 12 hours, 49 classes => 1 hour
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))
}
@@ -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
var expiry = baseExpiry
if IsTermArchived(term) {
if a.IsTermArchived(term) {
expiry *= 5
}
@@ -209,9 +226,13 @@ func CalculateExpiry(term string, count int, priority bool) time.Duration {
}
// 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.
func IntakeCourse(course Course) error {
err := kv.Set(ctx, fmt.Sprintf("class:%s", course.CourseReferenceNumber), course, 0).Err()
// This function will be used to handle change identification, notifications, and SQLite upserts in the future.
func (a *API) IntakeCourse(course models.Course) error {
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
defer cancel()
err := a.config.KV.Set(ctx, fmt.Sprintf("class:%s", course.CourseReferenceNumber), course, 0).Err()
if err != nil {
return fmt.Errorf("failed to store class in Redis: %w", err)
}

View File

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

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 (
"banner/internal"
"banner/internal/api"
"banner/internal/models"
"fmt"
"net/url"
"regexp"
@@ -14,9 +17,19 @@ import (
"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 (
commandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate) error{
// CommandDefinitions is a list of all the bot's command definitions.
CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
// CommandHandlers is a map of command names to their handlers.
CommandHandlers = map[string]CommandHandler{
TimeCommandDefinition.Name: TimeCommandHandler,
TermCommandDefinition.Name: TermCommandHandler,
SearchCommandDefinition.Name: SearchCommandHandler,
@@ -30,7 +43,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
MinLength: GetIntPointer(0),
MinLength: internal.GetIntPointer(0),
MaxLength: 48,
Name: "title",
Description: "Course Title (exact, use autocomplete)",
@@ -40,7 +53,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "code",
MinLength: GetIntPointer(4),
MinLength: internal.GetIntPointer(4),
Description: "Course Code (e.g. 3743, 3000-3999, 3xxx, 3000-)",
Required: false,
},
@@ -72,9 +85,10 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
},
}
func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.InteractionCreate) error {
data := interaction.ApplicationCommandData()
query := NewQuery().Credits(3, 6)
// SearchCommandHandler handles the /search command, which allows users to search for courses.
func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
query := api.NewQuery().Credits(3, 6)
for _, option := range data.Options {
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 {
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,
Data: &discordgo.InteractionResponseData{
Content: "Error searching for courses",
@@ -188,13 +207,23 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
fields := []*discordgo.MessageEmbedField{}
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))
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))
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{
Name: "Identifier",
@@ -206,7 +235,7 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
Inline: true,
}, &discordgo.MessageEmbedField{
Name: "Meeting Time",
Value: meetings.String(),
Value: meetingTime,
Inline: true,
},
)
@@ -218,13 +247,13 @@ func SearchCommandHandler(session *discordgo.Session, interaction *discordgo.Int
color = 0xFF6500
}
err = session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: GetFetchedFooter(fetch_time),
Description: p.Sprintf("%d Class%s", courses.TotalCount, Plurale(courses.TotalCount)),
Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: fmt.Sprintf("%d Class%s", courses.TotalCount, internal.Plural(courses.TotalCount)),
Fields: fields[:min(25, len(fields))],
Color: color,
},
@@ -242,7 +271,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
MinLength: GetIntPointer(0),
MinLength: internal.GetIntPointer(0),
MaxLength: 8,
Name: "search",
Description: "Term to search for",
@@ -253,13 +282,14 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Name: "page",
Description: "Page Number",
Required: false,
MinValue: GetFloatPointer(1),
MinValue: internal.GetFloatPointer(1),
},
},
}
func TermCommandHandler(session *discordgo.Session, interaction *discordgo.InteractionCreate) error {
data := interaction.ApplicationCommandData()
// TermCommandHandler handles the /terms command, which allows users to search for terms.
func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
data := i.ApplicationCommandData()
searchTerm := ""
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 {
RespondError(session, interaction.Interaction, "Error while fetching terms", err)
internal.RespondError(s, i.Interaction, "Error while fetching terms", 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)")
}
err = session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: GetFetchedFooter(fetch_time),
Description: p.Sprintf("%d of %d term%s (page %d)", len(termResult), len(terms), Plural(len(terms)), pageNumber),
Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: fmt.Sprintf("%d term%s (page %d)", len(termResult), internal.Plural(len(termResult)), pageNumber),
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()
crn := i.ApplicationCommandData().Options[0].IntValue()
// Fix static term
meetingTimes, err := GetCourseMeetingTime(202510, int(crn))
meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn))
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -344,6 +375,16 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
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]
duration := meetingTime.EndTime().Sub(meetingTime.StartTime())
@@ -352,7 +393,7 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: GetFetchedFooter(fetch_time),
Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: "",
Fields: []*discordgo.MessageEmbedField{
{
@@ -369,7 +410,7 @@ func TimeCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) er
},
{
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 {
crn := i.ApplicationCommandData().Options[0].IntValue()
// IcsCommandHandler handles the /ics command, which allows users to generate an ICS file for a course.
func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Parse all options
options := internal.ParseOptions(i.ApplicationCommandData().Options)
crn := options.GetInt("crn")
course, err := GetCourse(strconv.Itoa(int(crn)))
course, err := b.API.GetCourse(strconv.Itoa(int(crn)))
if err != nil {
return fmt.Errorf("Error retrieving course data: %w", err)
}
// Fix static term
meetingTimes, err := GetCourseMeetingTime(202510, int(crn))
meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn))
if err != nil {
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
_, exists := lo.Find(meetingTimes, func(mt MeetingTimeResponse) bool {
_, exists := lo.Find(meetingTimes, func(mt models.MeetingTimeResponse) bool {
switch mt.MeetingTime.MeetingType {
case "ID", "OA":
return false
@@ -423,28 +467,37 @@ func IcsCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) err
if !exists {
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
}
events := []string{}
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)
startDay := meeting.StartDay()
startTime := meeting.StartTime()
endTime := meeting.EndTime()
dtStart := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(startTime.Hours), int(startTime.Minutes), 0, 0, CentralTimeLocation)
dtEnd := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(endTime.Hours), int(endTime.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, b.Config.CentralTimeLocation)
endDay := meeting.EndDay()
until := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), 23, 59, 59, 0, CentralTimeLocation)
// endDay := meeting.EndDay()
// 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)
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()
rrule := meeting.RRule()
event := fmt.Sprintf(`BEGIN:VEVENT
DTSTAMP:%s
UID:%s
@@ -454,7 +507,7 @@ DTEND;TZID=America/Chicago:%s
SUMMARY:%s
DESCRIPTION:%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)
}
@@ -489,7 +542,7 @@ CALSCALE:GREGORIAN
%s
END:VCALENDAR`, vTimezone, strings.Join(events, "\n"))
session.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
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"
// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value.
type UnexpectedContentTypeError struct {
Expected string
Actual string

View File

@@ -1,4 +1,4 @@
package main
package internal
import (
"fmt"
@@ -14,59 +14,51 @@ import (
"time"
"github.com/bwmarrin/discordgo"
"github.com/pkg/errors"
"github.com/rs/zerolog"
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
func BuildRequestWithBody(method string, path string, params map[string]string, body io.Reader) *http.Request {
// Builds a URL for the given path and parameters
requestUrl := baseURL + path
// Options is a map of options from a Discord command.
type Options map[string]*discordgo.ApplicationCommandInteractionDataOption
if params != nil {
takenFirst := false
for key, value := range params {
paramChar := "&"
if !takenFirst {
paramChar = "?"
takenFirst = true
}
requestUrl += paramChar + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
// GetInt returns the integer value of an option, or 0 if it doesn't exist.
func (o Options) GetInt(key string) int64 {
if opt, ok := o[key]; ok {
return opt.IntValue()
}
request, _ := http.NewRequest(method, requestUrl, body)
AddUserAgent(request)
return request
return 0
}
// BuildRequest builds a request with the given method, path, and parameters and an empty body
func BuildRequest(method string, path string, params map[string]string) *http.Request {
return BuildRequestWithBody(method, path, params, nil)
// ParseOptions parses slash command options into a map for easier access.
func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options {
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) {
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
func ContentTypeMatch(response *http.Response, search string) bool {
contentType := response.Header.Get("Content-Type")
// ContentTypeMatch checks if a Resty response has the given content type.
func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
contentType := res.Header().Get("Content-Type")
if contentType == "" {
return search == "application/octect-stream"
return expectedContentType == "application/octect-stream"
}
return strings.HasPrefix(contentType, search)
return strings.HasPrefix(contentType, expectedContentType)
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// RandomString returns a random string of length n using the letterBytes constant
// The constant used is specifically chosen to mimic Ellucian's banner session ID generation.
// RandomString returns a random string of length n.
// The character set is chosen to mimic Ellucian's Banner session ID generation.
func RandomString(n int) string {
b := make([]byte, n)
for i := range b {
@@ -75,8 +67,7 @@ func RandomString(n int) string {
return string(b)
}
// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface.
// It directs all logs to the zerolog implementation.
// DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog.
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
pc, file, line, _ := runtime.Caller(caller)
@@ -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)
}
// Nonce returns a string made up of the current time in milliseconds, Unix epoch/UTC
// This is typically used as a query parameter to prevent request caching in the browser.
// Nonce returns the current time in milliseconds since the Unix epoch as a string.
// This is typically used as a query parameter to prevent request caching.
func Nonce() string {
return strconv.Itoa(int(time.Now().UnixMilli()))
}
// DoRequest performs & logs the request, logging and returning the response
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.
// Plural returns "s" if n is not 1.
func Plural(n int) string {
if n == 1 {
return ""
@@ -171,8 +111,7 @@ func Plural(n int) string {
return "s"
}
// Plurale is a simple helper function that returns an empty string if n is 1, and "ess" otherwise.
// This is for words that end in "es" when plural.
// Plurale returns "es" if n is not 1.
func Plurale(n int) string {
if n == 1 {
return ""
@@ -180,6 +119,7 @@ func Plurale(n int) string {
return "es"
}
// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF").
func WeekdaysToString(days map[time.Weekday]bool) string {
// If no days are present
numDays := len(days)
@@ -225,15 +165,18 @@ func WeekdaysToString(days map[time.Weekday]bool) string {
return str
}
// NaiveTime represents a time of day without a date or timezone.
type NaiveTime struct {
Hours uint
Minutes uint
}
// Sub returns the duration between two NaiveTime instances.
func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration {
return time.Hour*time.Duration(nt.Hours-other.Hours) + time.Minute*time.Duration(nt.Minutes-other.Minutes)
}
// ParseNaiveTime converts an integer representation of time (e.g., 1430) to a NaiveTime struct.
func ParseNaiveTime(integer uint64) *NaiveTime {
minutes := uint(integer % 100)
hours := uint(integer / 100)
@@ -241,6 +184,7 @@ func ParseNaiveTime(integer uint64) *NaiveTime {
return &NaiveTime{Hours: hours, Minutes: minutes}
}
// String returns a string representation of the NaiveTime in 12-hour format (e.g., "2:30PM").
func (nt NaiveTime) String() string {
meridiem := "AM"
hour := nt.Hours
@@ -253,6 +197,7 @@ func (nt NaiveTime) String() string {
return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem)
}
// GetFirstEnv returns the value of the first environment variable that is set.
func GetFirstEnv(key ...string) string {
for _, k := range key {
if v := os.Getenv(k); v != "" {
@@ -262,14 +207,12 @@ func GetFirstEnv(key ...string) string {
return ""
}
// GetIntPointer returns a pointer to the given value.
// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments.
// GetIntPointer returns a pointer to the given integer.
func GetIntPointer(value int) *int {
return &value
}
// GetFloatPointer returns a pointer to the given value.
// This function is useful for discordgo, which inexplicably requires pointers to floats for minLength arguments.
// GetFloatPointer returns a pointer to the given float.
func GetFloatPointer(value float64) *float64 {
return &value
}
@@ -303,6 +246,7 @@ var extensionMap = map[string]string{
"image/jxl": "jxl",
}
// GuessExtension guesses the file extension for a given content type.
func GuessExtension(contentType string) string {
ext, ok := extensionMap[strings.ToLower(contentType)]
if !ok {
@@ -311,9 +255,9 @@ func GuessExtension(contentType string) string {
return ext
}
// DumpResponse dumps a response body to a file for debugging purposes
func DumpResponse(res *http.Response) {
contentType := res.Header.Get("Content-Type")
// DumpResponse dumps the body of a Resty response to a file for debugging.
func DumpResponse(res *resty.Response) {
contentType := res.Header().Get("Content-Type")
ext := GuessExtension(contentType)
// Use current time as filename + /dumps/ prefix
@@ -326,17 +270,22 @@ func DumpResponse(res *http.Response) {
}
defer file.Close()
_, err = io.Copy(file, res.Body)
body, err := io.ReadAll(res.Body)
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
}
log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body")
}
// ResponseError responds to an interaction with an error message
// TODO: Improve with a proper embed and colors
// RespondError responds to an interaction with a formatted error message.
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
// Optional: log the error
if err != nil {
@@ -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{
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.
// This helper method is useful as depending on where the message was sent (guild or DM), the user is in a different field.
// GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM.
func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User {
// If the interaction is in a guild, the user is kept in the Member field
// If the interaction is in a guild, the user is in the Member field
if interaction.Member != nil {
return interaction.Member.User
}
// If the interaction is in a DM, the user is kept in the User field
// If the interaction is in a DM, the user is in the User field
return interaction.User
}
// Encode encodes the values into URL encoded” form
// ("bar=baz&foo=quux") sorted by key.
// EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key.
func EncodeParams(params map[string]*[]string) string {
// Escape hatch for nil
if params == nil {
@@ -415,54 +363,12 @@ func EncodeParams(params map[string]*[]string) string {
return buf.String()
}
var terms []BannerTerm
var lastTermUpdate time.Time
// TryReloadTerms attempts to reload the terms if they are not loaded or the last update was more than 24 hours ago
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
// Point represents a point in 2D space.
type Point struct {
X, Y float64
}
// Slope calculates the y-coordinate of a point on a line given two other points and an x-coordinate.
func Slope(p1 Point, p2 Point, x float64) Point {
slope := (p2.Y - p1.Y) / (p2.X - p1.X)
newY := slope*(x-p1.X) + p1.Y

View File

@@ -1,16 +1,24 @@
package main
// Package internal provides shared functionality for the banner application.
package internal
import (
"banner/internal/config"
"context"
"time"
"github.com/bwmarrin/discordgo"
"github.com/redis/go-redis/v9"
log "github.com/rs/zerolog/log"
)
// GetGuildName returns the name of the guild with the given ID, utilizing Redis to cache the value
func GetGuildName(guildID string) string {
// GetGuildName returns the name of a guild by its ID, using Redis for caching.
func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string {
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel()
// 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 {
log.Error().Stack().Err(err).Msg("Error getting guild name from Redis")
return "err"
@@ -27,7 +35,9 @@ func GetGuildName(guildID string) string {
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
_, 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 {
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
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
}
// GetChannelName returns the name of the channel with the given ID, utilizing Redis to cache the value
func GetChannelName(channelID string) string {
// GetChannelName returns the name of a channel by its ID, using Redis for caching.
func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string {
// Create a timeout context for Redis operations
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)
defer cancel()
// 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 {
log.Error().Stack().Err(err).Msg("Error getting channel name from Redis")
return "err"
@@ -61,7 +77,9 @@ func GetChannelName(channelID string) string {
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
_, 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 {
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
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
}

View File

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

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
}