mirror of
https://github.com/Xevion/banner.git
synced 2025-12-05 23:14:20 -06:00
Compare commits
14 Commits
2d25bb8921
...
95e760c549
| Author | SHA1 | Date | |
|---|---|---|---|
| 95e760c549 | |||
| 5a722d16c6 | |||
| deef4cabaa | |||
| 49fa964d3a | |||
| ae50b1462c | |||
| be047cf209 | |||
| c01a112ec6 | |||
| 65fe4f101f | |||
| a37fbeb224 | |||
| 165e32bbf6 | |||
| 7edd1f16bf | |||
| 2bf0e72e2e | |||
| 6cc0cfb997 | |||
| b16c2d51bc |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,8 +1,10 @@
|
||||
.env
|
||||
cover.cov
|
||||
banner
|
||||
/banner
|
||||
.*.go
|
||||
dumps/
|
||||
js/
|
||||
.vscode/
|
||||
*.prof
|
||||
*.prof
|
||||
.task/
|
||||
bin/
|
||||
29
README.md
29
README.md
@@ -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
39
Taskfile.yml
Normal 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
540
api.go
@@ -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
299
cmd/banner/main.go
Normal 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")
|
||||
}
|
||||
@@ -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
35
go.mod
@@ -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
99
go.sum
@@ -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
491
internal/api/api.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
64
internal/api/session.go
Normal 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
|
||||
}
|
||||
@@ -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
91
internal/bot/handlers.go
Normal 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
44
internal/bot/state.go
Normal 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
72
internal/config/config.go
Normal 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
|
||||
}
|
||||
71
internal/config/logging.go
Normal file
71
internal/config/logging.go
Normal 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
140
internal/config/terms.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
35
logs.go
@@ -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
335
main.go
@@ -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")
|
||||
}
|
||||
52
session.go
52
session.go
@@ -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
145
term.go
@@ -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
221
tests/term_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user