refactor: complete refactor into cmd/ & internal/ submodules

This commit is contained in:
2025-08-25 22:57:05 -05:00
parent 2d25bb8921
commit b16c2d51bc
14 changed files with 333 additions and 243 deletions

12
internal/utils/errors.go Normal file
View File

@@ -0,0 +1,12 @@
package utils
import "fmt"
type UnexpectedContentTypeError struct {
Expected string
Actual string
}
func (e *UnexpectedContentTypeError) Error() string {
return fmt.Sprintf("Expected content type '%s', received '%s'", e.Expected, e.Actual)
}

376
internal/utils/helpers.go Normal file
View File

@@ -0,0 +1,376 @@
package utils
import (
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
log "github.com/rs/zerolog/log"
"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 := config.BaseURL + path
if params != nil {
takenFirst := false
for key, value := range params {
paramChar := "&"
if !takenFirst {
paramChar = "?"
takenFirst = true
}
requestUrl += paramChar + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
}
request, _ := http.NewRequest(method, requestUrl, body)
AddUserAgent(request)
return request
}
// BuildRequest builds a request with the given method, path, and parameters and an empty body
func BuildRequest(method string, path string, params map[string]string) *http.Request {
return BuildRequestWithBody(method, path, params, nil)
}
// AddUserAgent adds a false but consistent user agent to the request
func AddUserAgent(req *http.Request) {
req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
}
// ContentTypeMatch checks if the response has the given content type
func ContentTypeMatch(response *http.Response, search string) bool {
contentType := response.Header.Get("Content-Type")
if contentType == "" {
return search == "application/octect-stream"
}
return strings.HasPrefix(contentType, search)
}
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.
func RandomString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface.
// It directs all logs to the zerolog implementation.
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
pc, file, line, _ := runtime.Caller(caller)
files := strings.Split(file, "/")
file = files[len(files)-1]
name := runtime.FuncForPC(pc).Name()
fns := strings.Split(name, ".")
name = fns[len(fns)-1]
msg := fmt.Sprintf(format, a...)
var event *zerolog.Event
switch msgL {
case 0:
event = log.Debug()
case 1:
event = log.Info()
case 2:
event = log.Warn()
case 3:
event = log.Error()
default:
event = log.Info()
}
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.
func Nonce() string {
return strconv.Itoa(int(time.Now().UnixMilli()))
}
// Plural is a simple helper function that returns an empty string if n is 1, and "s" otherwise.
func Plural(n int) string {
if n == 1 {
return ""
}
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.
func Plurale(n int) string {
if n == 1 {
return ""
}
return "es"
}
func WeekdaysToString(days map[time.Weekday]bool) string {
// If no days are present
numDays := len(days)
if numDays == 0 {
return "None"
}
// If all days are present
if numDays == 7 {
return "Everyday"
}
str := ""
if days[time.Monday] {
str += "M"
}
if days[time.Tuesday] {
str += "Tu"
}
if days[time.Wednesday] {
str += "W"
}
if days[time.Thursday] {
str += "Th"
}
if days[time.Friday] {
str += "F"
}
if days[time.Saturday] {
str += "Sa"
}
if days[time.Sunday] {
str += "Su"
}
return str
}
type NaiveTime struct {
Hours uint
Minutes uint
}
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)
}
func ParseNaiveTime(integer uint64) *NaiveTime {
minutes := uint(integer % 100)
hours := uint(integer / 100)
return &NaiveTime{Hours: hours, Minutes: minutes}
}
func (nt NaiveTime) String() string {
meridiem := "AM"
hour := nt.Hours
if nt.Hours >= 12 {
meridiem = "PM"
if nt.Hours > 12 {
hour -= 12
}
}
return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem)
}
func GetFirstEnv(key ...string) string {
for _, k := range key {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
// GetIntPointer returns a pointer to the given value.
// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments.
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.
func GetFloatPointer(value float64) *float64 {
return &value
}
var extensionMap = map[string]string{
"text/plain": "txt",
"application/json": "json",
"text/html": "html",
"text/css": "css",
"text/csv": "csv",
"text/calendar": "ics",
"text/markdown": "md",
"text/xml": "xml",
"text/yaml": "yaml",
"text/javascript": "js",
"text/vtt": "vtt",
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/tiff": "tiff",
"image/svg+xml": "svg",
"image/bmp": "bmp",
"image/vnd.microsoft.icon": "ico",
"image/x-icon": "ico",
"image/x-xbitmap": "xbm",
"image/x-xpixmap": "xpm",
"image/x-xwindowdump": "xwd",
"image/avif": "avif",
"image/apng": "apng",
"image/jxl": "jxl",
}
func GuessExtension(contentType string) string {
ext, ok := extensionMap[strings.ToLower(contentType)]
if !ok {
return ""
}
return ext
}
// DumpResponse dumps a response body to a file for debugging purposes
func DumpResponse(res *http.Response) {
contentType := res.Header.Get("Content-Type")
ext := GuessExtension(contentType)
// Use current time as filename + /dumps/ prefix
filename := fmt.Sprintf("dumps/%d.%s", time.Now().Unix(), ext)
file, err := os.Create(filename)
if err != nil {
log.Err(err).Stack().Msg("Error creating file")
return
}
defer file.Close()
_, err = io.Copy(file, res.Body)
if err != nil {
log.Err(err).Stack().Msg("Error copying 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
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
// Optional: log the error
if err != nil {
log.Err(err).Stack().Msg(message)
}
return session.InteractionRespond(interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Footer: &discordgo.MessageEmbedFooter{
Text: fmt.Sprintf("Occurred at %s", time.Now().Format("Monday, January 2, 2006 at 3:04:05PM")),
},
Description: message,
Color: 0xff0000,
},
},
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
})
}
func GetFetchedFooter(time time.Time) *discordgo.MessageEmbedFooter {
return &discordgo.MessageEmbedFooter{
Text: fmt.Sprintf("Fetched at %s", time.In(config.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.
func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User {
// If the interaction is in a guild, the user is kept 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
return interaction.User
}
// Encode encodes the values into “URL encoded” form
// ("bar=baz&foo=quux") sorted by key.
func EncodeParams(params map[string]*[]string) string {
// Escape hatch for nil
if params == nil {
return ""
}
// Sort the keys
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var buf strings.Builder
for _, k := range keys {
// Multiple values are allowed, so extract the slice & prepare the key
values := params[k]
keyEscaped := url.QueryEscape(k)
for _, v := range *values {
// If any parameters have been written, add the ampersand
if buf.Len() > 0 {
buf.WriteByte('&')
}
// Write the key and value
buf.WriteString(keyEscaped)
buf.WriteByte('=')
buf.WriteString(url.QueryEscape(v))
}
}
return buf.String()
}
// Point represents a point in 2D space
type Point struct {
X, Y float64
}
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
return Point{X: x, Y: newY}
}

35
internal/utils/logs.go Normal file
View File

@@ -0,0 +1,35 @@
package utils
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)
}
}

78
internal/utils/meta.go Normal file
View File

@@ -0,0 +1,78 @@
package utils
import (
"banner/internal/config"
"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(session *discordgo.Session, guildID string) string {
// Check Redis for the guild name
guildName, err := config.KV.Get(config.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"
}
// If the guild name is invalid (1 character long), then return "unknown"
if len(guildName) == 1 {
return "unknown"
}
// If the guild name isn't in Redis, get it from Discord and cache it
guild, err := session.Guild(guildID)
if err != nil {
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 := config.KV.Set(config.Ctx, "guild:"+guildID+":name", "x", time.Minute*5).Result()
if err != nil {
log.Error().Stack().Err(err).Msg("Error setting false guild name in Redis")
}
return "unknown"
}
// Cache the guild name in Redis
config.KV.Set(config.Ctx, "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(session *discordgo.Session, channelID string) string {
// Check Redis for the channel name
channelName, err := config.KV.Get(config.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"
}
// If the channel name is invalid (1 character long), then return "unknown"
if len(channelName) == 1 {
return "unknown"
}
// If the channel name isn't in Redis, get it from Discord and cache it
channel, err := session.Channel(channelID)
if err != nil {
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 := config.KV.Set(config.Ctx, "channel:"+channelID+":name", "x", time.Minute*5).Result()
if err != nil {
log.Error().Stack().Err(err).Msg("Error setting false channel name in Redis")
}
return "unknown"
}
// Cache the channel name in Redis
config.KV.Set(config.Ctx, "channel:"+channelID+":name", channel.Name, time.Hour*3)
return channel.Name
}

146
internal/utils/term.go Normal file
View File

@@ -0,0 +1,146 @@
package utils
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.
// 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, config.CentralTimeLocation).YearDay()
springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
summerStart := time.Date(int(year), time.May, 25, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
summerEnd := time.Date(int(year), time.August, 15, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
fallStart := time.Date(int(year), time.August, 18, 0, 0, 0, 0, config.CentralTimeLocation).YearDay()
fallEnd := time.Date(int(year), time.December, 10, 0, 0, 0, 0, config.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
}