refactor: rearrange & rename files, fix meeting times response decode

This commit is contained in:
2025-08-26 11:21:38 -05:00
parent ae50b1462c
commit 49fa964d3a
13 changed files with 63 additions and 67 deletions

View File

@@ -22,10 +22,10 @@ import (
"github.com/samber/lo"
"resty.dev/v3"
"banner/internal"
"banner/internal/api"
"banner/internal/bot"
"banner/internal/config"
"banner/internal/utils"
)
var (
@@ -57,25 +57,25 @@ func init() {
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
// Use the custom console writer if we're in development
isDevelopment := utils.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
isDevelopment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
if isDevelopment == "" {
isDevelopment = "development"
}
if isDevelopment == "development" {
log.Logger = zerolog.New(utils.NewConsoleWriter()).With().Timestamp().Logger()
log.Logger = zerolog.New(config.NewConsoleWriter()).With().Timestamp().Logger()
} else {
log.Logger = zerolog.New(utils.LogSplitter{Std: os.Stdout, Err: os.Stderr}).With().Timestamp().Logger()
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 = utils.DiscordGoLogger
discordgo.Logger = internal.DiscordGoLogger
}
func initRedis(cfg *config.Config) {
// Setup redis
redisUrl := utils.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")
redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")
if redisUrl == "" {
log.Fatal().Stack().Msg("REDIS_URL/REDIS_PRIVATE_URL not set")
}
@@ -130,7 +130,7 @@ func main() {
}
// Try to grab the environment variable, or default to development
environment := utils.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
environment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT")
if environment == "" {
environment = "development"
}

View File

@@ -1,9 +1,9 @@
package api
import (
"banner/internal"
"banner/internal/config"
"banner/internal/models"
"banner/internal/utils"
"context"
"encoding/json"
"errors"
@@ -51,7 +51,7 @@ func SessionMiddleware(c *resty.Client, r *resty.Response) error {
// 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 utils.RandomString(5) + utils.Nonce()
return internal.RandomString(5) + internal.Nonce()
}
var terms []BannerTerm
@@ -131,7 +131,7 @@ func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, e
SetQueryParam("searchTerm", search).
SetQueryParam("offset", strconv.Itoa(page)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]BannerTerm{})
@@ -206,7 +206,7 @@ func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]BannerTerm{})
@@ -239,7 +239,7 @@ func (a *API) GetInstructors(search string, term string, offset int, maxResults
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Instructor{})
@@ -337,7 +337,7 @@ func (a *API) GetSubjects(search string, term string, offset int, maxResults int
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
@@ -370,7 +370,7 @@ func (a *API) GetCampuses(search string, term int, offset int, maxResults int) (
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
@@ -403,7 +403,7 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma
SetQueryParam("offset", strconv.Itoa(offset)).
SetQueryParam("max", strconv.Itoa(maxResults)).
SetQueryParam("uniqueSessionId", a.EnsureSession()).
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json").
SetResult(&[]Pair{})
@@ -423,23 +423,27 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma
// It makes an HTTP GET request to the appropriate API endpoint and parses the response to extract the meeting time data.
// The function returns a MeetingTimeResponse struct containing the extracted information.
func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) {
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(&[]models.MeetingTimeResponse{})
SetResult(&responseWrapper{})
res, err := req.Get("/searchResults/getFacultyMeetingTimes")
if err != nil {
return nil, fmt.Errorf("failed to get meeting time: %w", err)
}
meetingTimes, ok := res.Result().(*[]models.MeetingTimeResponse)
result, ok := res.Result().(*responseWrapper)
if !ok {
return nil, fmt.Errorf("meeting times parsing failed to cast: %v", res.Result())
}
return *meetingTimes, nil
return result.Fmt, nil
}
// ResetDataForm makes a POST request that needs to be made upon before new search requests can be made.

View File

@@ -1,8 +1,8 @@
package api
import (
"banner/internal"
"banner/internal/models"
"banner/internal/utils"
"context"
"fmt"
"math/rand"
@@ -70,7 +70,7 @@ func (a *API) Scrape() error {
// GetExpiredSubjects returns a list of subjects that are expired and should be scraped.
func (a *API) GetExpiredSubjects() ([]string, error) {
term := utils.Default(time.Now()).ToString()
term := Default(time.Now()).ToString()
subjects := make([]string, 0)
// Create a timeout context for Redis operations
@@ -109,7 +109,7 @@ func (a *API) ScrapeMajor(subject string) error {
for {
// Build & execute the query
query := NewQuery().Offset(offset).MaxResults(MaxPageSize * 2).Subject(subject)
term := utils.Default(time.Now()).ToString()
term := Default(time.Now()).ToString()
result, err := a.Search(term, query, "subjectDescription", false)
if err != nil {
return fmt.Errorf("search failed: %w (%s)", err, query.String())
@@ -152,7 +152,7 @@ func (a *API) ScrapeMajor(subject string) error {
break
}
term := utils.Default(time.Now()).ToString()
term := Default(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
@@ -190,7 +190,7 @@ func (a *API) CalculateExpiry(term string, count int, priority bool) time.Durati
// 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 := utils.Slope(utils.Point{X: 1, Y: 12}, utils.Point{X: 49, Y: 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))
}

View File

@@ -1,7 +1,7 @@
package api
import (
"banner/internal/utils"
"banner/internal"
"net/url"
log "github.com/rs/zerolog/log"
@@ -18,7 +18,7 @@ func (a *API) Setup() {
for _, path := range requestQueue {
req := a.config.Client.NewRequest().
SetQueryParam("_", utils.Nonce()).
SetQueryParam("_", internal.Nonce()).
SetExpectResponseContentType("application/json")
res, err := req.Get(path)

View File

@@ -1,12 +1,10 @@
package utils
package api
import (
"banner/internal/config"
"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.
@@ -32,9 +30,6 @@ var (
func init() {
loc, _ := time.LoadLocation(config.CentralTimezoneName)
SpringRange, SummerRange, FallRange = GetYearDayRange(loc, 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 {

View File

@@ -1,9 +1,9 @@
package bot
import (
"banner/internal"
"banner/internal/api"
"banner/internal/models"
"banner/internal/utils"
"fmt"
"net/url"
"regexp"
@@ -40,7 +40,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
MinLength: utils.GetIntPointer(0),
MinLength: internal.GetIntPointer(0),
MaxLength: 48,
Name: "title",
Description: "Course Title (exact, use autocomplete)",
@@ -50,7 +50,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "code",
MinLength: utils.GetIntPointer(4),
MinLength: internal.GetIntPointer(4),
Description: "Course Code (e.g. 3743, 3000-3999, 3xxx, 3000-)",
Required: false,
},
@@ -248,8 +248,8 @@ func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.Interaction
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: utils.GetFetchedFooter(b.Config, fetch_time),
Description: fmt.Sprintf("%d Class%s", courses.TotalCount, utils.Plural(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,
},
@@ -267,7 +267,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
MinLength: utils.GetIntPointer(0),
MinLength: internal.GetIntPointer(0),
MaxLength: 8,
Name: "search",
Description: "Term to search for",
@@ -278,7 +278,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{
Name: "page",
Description: "Page Number",
Required: false,
MinValue: utils.GetFloatPointer(1),
MinValue: internal.GetFloatPointer(1),
},
},
}
@@ -303,7 +303,7 @@ func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCr
termResult, err := b.API.GetTerms(searchTerm, pageNumber, 25)
if err != nil {
utils.RespondError(s, i.Interaction, "Error while fetching terms", err)
internal.RespondError(s, i.Interaction, "Error while fetching terms", err)
return err
}
@@ -328,8 +328,8 @@ func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCr
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: utils.GetFetchedFooter(b.Config, fetch_time),
Description: fmt.Sprintf("%d term%s (page %d)", len(termResult), utils.Plural(len(termResult)), 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))],
},
},
@@ -387,7 +387,7 @@ func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCr
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: utils.GetFetchedFooter(b.Config, fetch_time),
Footer: internal.GetFetchedFooter(b.Config, fetch_time),
Description: "",
Fields: []*discordgo.MessageEmbedField{
{
@@ -404,7 +404,7 @@ func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCr
},
{
Name: "Days of Week",
Value: utils.WeekdaysToString(meetingTime.Days()),
Value: internal.WeekdaysToString(meetingTime.Days()),
},
},
},
@@ -430,7 +430,7 @@ var IcsCommandDefinition = &discordgo.ApplicationCommand{
func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// Parse all options
options := utils.ParseOptions(i.ApplicationCommandData().Options)
options := internal.ParseOptions(i.ApplicationCommandData().Options)
crn := options.GetInt("crn")
course, err := b.API.GetCourse(strconv.Itoa(int(crn)))
@@ -460,7 +460,7 @@ func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCre
if !exists {
log.Warn().Str("crn", course.CourseReferenceNumber).Msg("Non-meeting course requested for ICS file")
utils.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
}

View File

@@ -1,7 +1,7 @@
package bot
import (
"banner/internal/utils"
"banner/internal"
"fmt"
"github.com/bwmarrin/discordgo"
@@ -13,7 +13,7 @@ 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 := utils.RespondError(internalSession, interaction.Interaction, "Bot is currently restarting, try again later.", nil)
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")
}
@@ -28,18 +28,18 @@ func (b *Bot) RegisterHandlers() {
options.Str(option.Name, fmt.Sprintf("%v", option.Value))
}
event := log.Info().Str("name", name).Str("user", utils.GetUser(interaction).Username).Dict("options", options)
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", utils.GetGuildName(b.Config, internalSession, 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", utils.GetChannelName(b.Config, internalSession, 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
@@ -58,7 +58,7 @@ func (b *Bot) RegisterHandlers() {
log.Error().Stack().Str("commandName", name).Interface("detail", err).Msg("Command Handler Panic")
// Respond with error
err := utils.RespondError(internalSession, interaction.Interaction, "Unexpected Error: command handler panic", nil)
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")
}
@@ -74,7 +74,7 @@ func (b *Bot) RegisterHandlers() {
log.Error().Str("commandName", name).Err(err).Msg("Command Handler Error")
// Respond with error
err = utils.RespondError(internalSession, interaction.Interaction, fmt.Sprintf("Unexpected Error: %s", err.Error()), nil)
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")
}
@@ -84,7 +84,7 @@ func (b *Bot) RegisterHandlers() {
log.Error().Stack().Str("commandName", name).Msg("Command Interaction Has No Handler")
// Respond with error
utils.RespondError(internalSession, interaction.Interaction, "Unexpected Error: interaction has no handler", nil)
internal.RespondError(internalSession, interaction.Interaction, "Unexpected Error: interaction has no handler", nil)
}
})
}

View File

@@ -3,7 +3,6 @@ package bot
import (
"banner/internal/api"
"banner/internal/config"
"banner/internal/utils"
"fmt"
"time"
@@ -28,7 +27,7 @@ func (b *Bot) SetClosing() {
func (b *Bot) GetSession() (string, error) {
sessionID := b.API.EnsureSession()
term := utils.Default(time.Now()).ToString()
term := api.Default(time.Now()).ToString()
log.Info().Str("term", term).Str("sessionID", sessionID).Msg("Setting selected term")
err := b.API.SelectTerm(term, sessionID)

View File

@@ -1,4 +1,4 @@
package utils
package config
import (
"io"

View File

@@ -1,4 +1,4 @@
package utils
package internal
import "fmt"

View File

@@ -1,4 +1,4 @@
package utils
package internal
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package utils
package internal
import (
"banner/internal/config"

View File

@@ -1,7 +1,7 @@
package models
import (
"banner/internal/utils"
"banner/internal"
"encoding/json"
"fmt"
"strconv"
@@ -11,8 +11,6 @@ import (
log "github.com/rs/zerolog/log"
)
const JsonContentType = "application/json"
type FacultyItem struct {
BannerId string `json:"bannerId"`
Category *string `json:"category"`
@@ -114,7 +112,7 @@ func (m *MeetingTimeResponse) TimeString() string {
return "???"
}
return fmt.Sprintf("%s %s-%s", utils.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
@@ -195,7 +193,7 @@ func (m *MeetingTimeResponse) EndDay() time.Time {
// 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() *utils.NaiveTime {
func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
raw := m.MeetingTime.BeginTime
if raw == "" {
log.Panic().Stack().Msg("Start time is empty")
@@ -206,12 +204,12 @@ func (m *MeetingTimeResponse) StartTime() *utils.NaiveTime {
log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse start time integer")
}
return utils.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() *utils.NaiveTime {
func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
raw := m.MeetingTime.EndTime
if raw == "" {
return nil
@@ -222,7 +220,7 @@ func (m *MeetingTimeResponse) EndTime() *utils.NaiveTime {
log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse end time integer")
}
return utils.ParseNaiveTime(value)
return internal.ParseNaiveTime(value)
}
type RRule struct {