Temp timezone fix #11

Merged
Jannover5000r merged 22 commits from temp-timezoneFix into main 2026-08-23 00:02:29 +02:00
6 changed files with 310 additions and 73 deletions

7
.exampleEnv Normal file
View file

@ -0,0 +1,7 @@
DISCORD_WEBHOOK_URL=[Paste Webhook]
UNTIS_PASSWORD=[Default user passwd]
UNTIS_USER=[Default user username]
DISCORD_BOT_TOKEN=[Paste Bot token]
ENC_KEY=[Generate enc key and paste]
URL=[Untis url, create from tutorial in readme]
LOCATION_ENV=Europe/Berlin [change to your timezone, needs to be usable by go's time package via time.LoadLocation()]

5
.gitignore vendored
View file

@ -6,7 +6,10 @@ teachers.json
login.json login.json
timetable*.json timetable*.json
timetableFilled*.json timetableFilled*.json
accounts.json accounts/
login_*.json login_*.json
AGENTS.md
untislogger untislogger
untislogger* untislogger*
CHANGES_SUMMARY.md

View file

@ -12,13 +12,15 @@ import (
"io" "io"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sync" "sync"
"syscall" "syscall"
"time" "time"
"github.com/joho/godotenv"
"untislogger/Untis" "untislogger/Untis"
"github.com/joho/godotenv"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@ -38,11 +40,13 @@ type UserState struct {
var ( var (
userStates = make(map[string]*UserState) // userID -> state userStates = make(map[string]*UserState) // userID -> state
stateMutex sync.Mutex // protect userStates stateMutex sync.Mutex // protect userStates
accountsFile = "accounts.json" accountsFile = "accounts/accounts.json"
) )
var encryptionKey []byte var encryptionKey []byte
var WebHook = true
func init() { func init() {
godotenv.Load(".env") godotenv.Load(".env")
keyStr := os.Getenv("ENC_KEY") keyStr := os.Getenv("ENC_KEY")
@ -105,6 +109,12 @@ func decrypt(encrypted string) (string, error) {
func saveAccount(userID, username, password string) error { func saveAccount(userID, username, password string) error {
var accounts []Account var accounts []Account
// Ensure the accounts directory exists
accountsDir := filepath.Dir(accountsFile)
if err := os.MkdirAll(accountsDir, 0o755); err != nil {
return fmt.Errorf("failed to create accounts directory: %v", err)
}
// Load existing accounts if file exists // Load existing accounts if file exists
if data, err := os.ReadFile(accountsFile); err == nil && len(data) > 0 { if data, err := os.ReadFile(accountsFile); err == nil && len(data) > 0 {
_ = json.Unmarshal(data, &accounts) _ = json.Unmarshal(data, &accounts)
@ -135,7 +145,7 @@ func saveAccount(userID, username, password string) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.WriteFile(accountsFile, newData, 0644); err != nil { if err := os.WriteFile(accountsFile, newData, 0o644); err != nil {
return err return err
} }
@ -143,10 +153,10 @@ func saveAccount(userID, username, password string) error {
timetableFile := getTimetableFile(userID) timetableFile := getTimetableFile(userID)
timetableFilledFile := getTimetableFilledFile(userID) timetableFilledFile := getTimetableFilledFile(userID)
if _, err := os.Stat(timetableFile); os.IsNotExist(err) { if _, err := os.Stat(timetableFile); os.IsNotExist(err) {
os.WriteFile(timetableFile, []byte("[]"), 0644) os.WriteFile(timetableFile, []byte("[]"), 0o644)
} }
if _, err := os.Stat(timetableFilledFile); os.IsNotExist(err) { if _, err := os.Stat(timetableFilledFile); os.IsNotExist(err) {
os.WriteFile(timetableFilledFile, []byte("[]"), 0644) os.WriteFile(timetableFilledFile, []byte("[]"), 0o644)
} }
return nil return nil
@ -196,7 +206,8 @@ func checkTimetableChangesForUser(user Account, decPwd string, s *discordgo.Sess
// Compare and notify if changed // Compare and notify if changed
if !bytes.Equal(newData, prevData) { if !bytes.Equal(newData, prevData) {
sendLessonNotification(s, user.UserID, user.Username, "Your timetable has changed!") // The main.go now handles detailed change notifications
// This function is kept for compatibility but no longer sends generic messages
} }
} }
@ -355,7 +366,7 @@ func deleteAccount(userID string) error {
if err != nil { if err != nil {
return err return err
} }
if err := os.WriteFile(accountsFile, newData, 0644); err != nil { if err := os.WriteFile(accountsFile, newData, 0o644); err != nil {
return err return err
} }
@ -371,9 +382,18 @@ func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID { if m.Author.ID == s.State.User.ID {
return return
} }
if m.GuildID != "" && m.Content == "!WebHook" {
_ = s.ChannelMessageDelete(m.ChannelID, m.ID)
WebHook = !WebHook
if WebHook {
s.ChannelMessageSend(m.ChannelID, "Sending Webhook set to True")
} else {
s.ChannelMessageSend(m.ChannelID, "Sending Webhook set to False")
}
return
}
// Handle "!addaccount" only in guilds (not in DMs) // Handle "!addaccount" only in guilds (not in DMs)
if m.GuildID != "" && m.Content == "!addaccount" { if m.Content == "!addaccount" {
// Delete the command for privacy // Delete the command for privacy
_ = s.ChannelMessageDelete(m.ChannelID, m.ID) _ = s.ChannelMessageDelete(m.ChannelID, m.ID)
// Create DM channel // Create DM channel
@ -390,6 +410,12 @@ func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
return return
} }
if m.Content == "!update" {
_ = s.ChannelMessageDelete(m.ChannelID, m.ID)
checkAllUsersTimetables(DiscordSession)
s.ChannelMessageSend(m.ChannelID, "Updated all Timetables manually")
}
// Handle "!deleteaccount" and "!removeaccount" commands // Handle "!deleteaccount" and "!removeaccount" commands
if m.GuildID != "" && (m.Content == "!deleteaccount" || m.Content == "!removeaccount") { if m.GuildID != "" && (m.Content == "!deleteaccount" || m.Content == "!removeaccount") {
// Delete the command message for privacy // Delete the command message for privacy

View file

@ -1,3 +1,5 @@
# Well, i guess this is not temporary anymore and will just become the new main
# Discord bot for logging Untis timetable to a Discord Webhook and Multiple accounts which can be added using !addaccount in a server whit the Bot # Discord bot for logging Untis timetable to a Discord Webhook and Multiple accounts which can be added using !addaccount in a server whit the Bot
## create .env with Credentials ## create .env with Credentials
@ -13,13 +15,13 @@
# This is a project for me, issues will be resolved as i find the motivation to do so, improvements may follow in the future # This is a project for me, issues will be resolved as i find the motivation to do so, improvements may follow in the future
### I will try to maintain this project as best as possible. Maybe add a better security to it than to trust the host but for now it is working and that was my goal. Please report any errors you find while using this bot ## I will try to maintain this project as best as possible. Maybe add a better security to it than to trust the host but for now it is working and that was my goal. Please report any errors you find while using this bot
# Installation # Installation
# This is a discord application intended for self hosting. You will need to add an application in the [disord developer website](https://discord.com/developers/applications) for this bot mainly with the read messages and write messages privelege as well as send privat messages for the account handling. If you give it administrator permissions you dont have to worry further but this could present you with a security risk if my github account gets compromised and a malicious update to the script gets pushed which you than use since the bot got access to your whole server # This is a discord application intended for self hosting. You will need to add an application in the [disord developer website](https://discord.com/developers/applications) for this bot mainly with the read messages and write messages privelege as well as send privat messages for the account handling. If you give it administrator permissions you dont have to worry further but this could present you with a security risk if my github account gets compromised and a malicious update to the script gets pushed which you than use since the bot got access to your whole server
# I also recommend to use a tool like [coolify](https://coolify.io/) to host the bot. Make sure to add the Env's and add **accounts.json** to persistant storage # I also recommend to use a tool like [coolify](https://coolify.io/) to host the bot. Make sure to add the Env's and add the **accounts/** folder to persistent storage
### This bot is self hosted and will run correctly when doing "go run . " in the root folder of the project. **Before usage add the .env file with the Credentials as mentioned above.** When you run the Program for the first time, all important files will be created automatically and the bot is ready to go. The user added via the fields UNTIS_USER and UNTIS_PASSWORD in the .env will be the one where the Webhook is sourced from and the other users will be send a DM after adding their account with the command. Add the Discord Webhook via the DISCORD_WEBHOOK_URL point in the .env and will send the messages for the timetable in there ### This bot is self hosted and will run correctly when doing "go run . " in the root folder of the project. **Before usage add the .env file with the Credentials as mentioned above.** When you run the Program for the first time, all important files will be created automatically and the bot is ready to go. The user added via the fields UNTIS_USER and UNTIS_PASSWORD in the .env will be the one where the Webhook is sourced from and the other users will be send a DM after adding their account with the command. Add the Discord Webhook via the DISCORD_WEBHOOK_URL point in the .env and will send the messages for the timetable in there

319
main.go
View file

@ -10,6 +10,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"sort" "sort"
"strings"
"syscall" "syscall"
"time" "time"
_ "time/tzdata" _ "time/tzdata"
@ -36,6 +37,53 @@ type NamedTimetableEntry struct {
var location *time.Location var location *time.Location
// lessonKey creates a stable key for a lesson (date + time + subject)
func lessonKey(e NamedTimetableEntry) string {
subject := "unknown"
if len(e.Su) > 0 {
subject = e.Su[0]
}
// Include date for multi-day accuracy (e.g., Monday vs Tuesday)
return e.Date + "|" + e.StartTime + "|" + subject
}
// getSubject returns the first subject or "Free period"
func getSubject(e NamedTimetableEntry) string {
if len(e.Su) == 0 {
return "Free period"
}
return e.Su[0]
}
// formatRoom returns a readable room string
func formatRoom(rooms []string) string {
if len(rooms) == 0 {
return "no room"
}
return strings.Join(rooms, ", ")
}
// formatStatus returns a human-readable status
func formatStatus(code string) string {
if code == "" {
return "regular"
}
return code
}
// slicesEqual checks if two string slices are equal (order-sensitive)
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// init and main// // init and main//
func init() { func init() {
godotenv.Load(".env") godotenv.Load(".env")
@ -53,7 +101,6 @@ func init() {
} else { } else {
log.Println("No Discord webhook provided, Discord notifications will be disabled") log.Println("No Discord webhook provided, Discord notifications will be disabled")
} }
locEnv := os.Getenv("LOCATION_ENV") locEnv := os.Getenv("LOCATION_ENV")
var locErr error var locErr error
@ -90,7 +137,7 @@ func main() {
} }
func isScheduledTime(now time.Time) bool { func isScheduledTime(now time.Time) bool {
scheduled := []string{"07:45", "08:35", "09:35", "10:25", "11:25", "12:15", "13:40", "14:25"} scheduled := []string{"07:45", "08:35", "09:35", "10:25", "11:25", "12:15", "13:40", "14:25", "15:20", "16:00"}
current := now.Format("15:04") current := now.Format("15:04")
for _, t := range scheduled { for _, t := range scheduled {
if t == current { if t == current {
@ -137,11 +184,135 @@ func scheduleTimetableUpdate() {
log.Printf("Error reading timetable for user %s: %v", acc.Username, err) log.Printf("Error reading timetable for user %s: %v", acc.Username, err)
continue continue
} }
// Parse current and previous timetable data
var currentEntries, prevEntries []NamedTimetableEntry
if prev, ok := prevData[acc.UserID]; ok && !bytes.Equal(data, prev) { if err := json.Unmarshal(data, &currentEntries); err != nil {
log.Printf("Timetable has changed for user %s.", acc.Username) log.Printf("Failed to parse new timetable for user %s: %v", acc.Username, err)
BotStart.SendDM(acc.UserID, "Your timetable has changed!") prevData[acc.UserID] = data // still update to avoid repeated errors
continue
} }
if prev, ok := prevData[acc.UserID]; ok {
if err := json.Unmarshal(prev, &prevEntries); err != nil {
log.Printf("Failed to parse previous timetable for user %s: %v", acc.Username, err)
prevData[acc.UserID] = data
continue
}
// Build maps keyed by a stable lesson identifier
currMap := make(map[string]NamedTimetableEntry)
prevMap := make(map[string]NamedTimetableEntry)
// Also build timeslot-based maps for better exchange detection
currTimeslotMap := make(map[string]NamedTimetableEntry)
prevTimeslotMap := make(map[string]NamedTimetableEntry)
// Get current date to filter out new day changes
today := time.Now().Format("02-01-2006")
for _, e := range currentEntries {
key := lessonKey(e)
currMap[key] = e
}
for _, e := range prevEntries {
key := lessonKey(e)
prevMap[key] = e
}
var changes []string
// Check for modified or new lessons
for key, curr := range currMap {
// Skip lessons that are not for today to avoid new day spam
if curr.Date != today {
continue
}
if prev, exists := prevMap[key]; exists {
// Existing lesson — check for changes
subject := getSubject(curr)
// Room change?
if !slicesEqual(prev.Ro, curr.Ro) {
oldRoom := formatRoom(prev.Ro)
newRoom := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("Room changed for %s: %s → %s", subject, oldRoom, newRoom))
}
for _, e := range currentEntries {
key := lessonKey(e)
currMap[key] = e
// Create timeslot key (date + startTime + endTime)
timeslotKey := e.Date + "|" + e.StartTime + "|" + e.EndTime
currTimeslotMap[timeslotKey] = e
}
for _, e := range prevEntries {
key := lessonKey(e)
prevMap[key] = e
// Create timeslot key (date + startTime + endTime)
timeslotKey := e.Date + "|" + e.StartTime + "|" + e.EndTime
prevTimeslotMap[timeslotKey] = e
}
// Code/status change?
if prev.Code != curr.Code {
oldStatus := formatStatus(prev.Code)
newStatus := formatStatus(curr.Code)
changes = append(changes, fmt.Sprintf("Status changed for %s: %s → %s", subject, oldStatus, newStatus))
}
} else {
// New lesson - but check if it's actually an exchange in the same timeslot
timeslotKey := curr.Date + "|" + curr.StartTime + "|" + curr.EndTime
if prevInTimeslot, exists := prevTimeslotMap[timeslotKey]; exists {
// This is an exchange: old lesson was removed, new one added in same timeslot
oldSubject := getSubject(prevInTimeslot)
newSubject := getSubject(curr)
room := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("Lesson exchanged: %s → %s in %s", oldSubject, newSubject, room))
} else {
// Truly new lesson
subject := getSubject(curr)
room := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("New lesson: %s in %s", subject, room))
}
}
}
// Check for removed lessons
for key, prev := range prevMap {
// Skip lessons that were not for today
if prev.Date != today {
continue
}
if _, exists := currMap[key]; !exists {
subject := getSubject(prev)
// Check if this removal is part of an exchange (already handled above)
timeslotKey := prev.Date + "|" + prev.StartTime + "|" + prev.EndTime
if _, newExists := currTimeslotMap[timeslotKey]; !newExists {
// Only report as removed if no new lesson took its place
changes = append(changes, fmt.Sprintf("Lesson removed: %s", subject))
}
}
}
// Send notification if changes detected
if len(changes) > 0 {
log.Printf("Timetable changed for user %s: %d changes", acc.Username, len(changes))
message := "Your timetable has changed!\n\n" + strings.Join(changes, "\n")
BotStart.SendDM(acc.UserID, message)
// Optional: send to Discord webhook if configured and user is "default"
if acc.UserID == "default" && discordWebhookURL != "" {
sendUpdateDiscordWebhookWithDetails(changes)
}
}
} else {
// First run — no previous data, so don't notify
log.Printf("First timetable fetch for user %s", acc.Username)
}
// Always update prevData
prevData[acc.UserID] = data prevData[acc.UserID] = data
} }
} }
@ -204,6 +375,7 @@ func Run(userID string) {
subjectByStartTime, _ := MapTimeToSubject(timetableFile) subjectByStartTime, _ := MapTimeToSubject(timetableFile)
now := getTime().Format("15:04") now := getTime().Format("15:04")
nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now) nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if !foundRoom { if !foundRoom {
return // No upcoming lessons return // No upcoming lessons
@ -217,18 +389,21 @@ func Run(userID string) {
status, _ := NextCodeForTime(codeByStartTime, now) status, _ := NextCodeForTime(codeByStartTime, now)
var message string var message string
if status != "" { if status == "cancelled" {
message = fmt.Sprintf("Next lesson: **%s** at **%s**. is **%s**", subject, nextTime, status)
} else if status != "" && status != "cancelled" {
message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**. Status: **%s**", subject, room, nextTime, status) message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**. Status: **%s**", subject, room, nextTime, status)
} else { } else {
message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**.", subject, room, nextTime) message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**.", subject, room, nextTime)
} }
if userID == "default" { if userID == "default" {
// The default user still sends to the webhook if BotStart.WebHook {
sendDiscordWebhook(subject, room, nextTime, status) log.Println("WebHook status ", BotStart.WebHook)
} else { sendDiscordWebhook(subject, room, nextTime, status)
BotStart.SendDM(userID, message) }
} }
BotStart.SendDM(userID, message)
} }
/* /*
@ -239,6 +414,10 @@ func NextRoomForTime(roomByStartTime map[string]string, current string) (string,
if err != nil { if err != nil {
return "", "", false return "", "", false
} }
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time var times []time.Time
timeToStr := make(map[time.Time]string) timeToStr := make(map[time.Time]string)
for t := range roomByStartTime { for t := range roomByStartTime {
@ -246,16 +425,20 @@ func NextRoomForTime(roomByStartTime map[string]string, current string) (string,
if err != nil { if err != nil {
continue continue
} }
times = append(times, parsed) // Only include times within the next 30 minutes
timeToStr[parsed] = t if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
} times = append(times, parsed)
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) }) timeToStr[parsed] = t
for _, t := range times {
if t.After(now) {
return timeToStr[t], roomByStartTime[timeToStr[t]], true
} }
} }
return "", "", false
if len(times) == 0 {
return "", "", false
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) room within the 30-minute window
return timeToStr[times[0]], roomByStartTime[timeToStr[times[0]]], true
} }
func NextSubjectForTime(subjectByStartTime map[string]string, current string) (string, bool) { func NextSubjectForTime(subjectByStartTime map[string]string, current string) (string, bool) {
@ -264,6 +447,10 @@ func NextSubjectForTime(subjectByStartTime map[string]string, current string) (s
if err != nil { if err != nil {
return "", false return "", false
} }
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time var times []time.Time
timeToStr := make(map[time.Time]string) timeToStr := make(map[time.Time]string)
for t := range subjectByStartTime { for t := range subjectByStartTime {
@ -271,16 +458,20 @@ func NextSubjectForTime(subjectByStartTime map[string]string, current string) (s
if err != nil { if err != nil {
continue continue
} }
times = append(times, parsed) // Only include times within the next 30 minutes
timeToStr[parsed] = t if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
} times = append(times, parsed)
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) }) timeToStr[parsed] = t
for _, t := range times {
if t.After(now) {
return subjectByStartTime[timeToStr[t]], true
} }
} }
return "", false
if len(times) == 0 {
return "", false
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) subject within the 30-minute window
return subjectByStartTime[timeToStr[times[0]]], true
} }
func NextCodeForTime(codeByStartTime map[string]string, current string) (string, bool) { func NextCodeForTime(codeByStartTime map[string]string, current string) (string, bool) {
@ -289,6 +480,10 @@ func NextCodeForTime(codeByStartTime map[string]string, current string) (string,
if err != nil { if err != nil {
return "", false return "", false
} }
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time var times []time.Time
timeToStr := make(map[time.Time]string) timeToStr := make(map[time.Time]string)
for t := range codeByStartTime { for t := range codeByStartTime {
@ -296,16 +491,20 @@ func NextCodeForTime(codeByStartTime map[string]string, current string) (string,
if err != nil { if err != nil {
continue continue
} }
times = append(times, parsed) // Only include times within the next 30 minutes
timeToStr[parsed] = t if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
} times = append(times, parsed)
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) }) timeToStr[parsed] = t
for _, t := range times {
if t.After(now) {
return codeByStartTime[timeToStr[t]], true
} }
} }
return "", false
if len(times) == 0 {
return "", false
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) code within the 30-minute window
return codeByStartTime[timeToStr[times[0]]], true
} }
func MapTimeToRoom(path string) (map[string]string, error) { func MapTimeToRoom(path string) (map[string]string, error) {
@ -340,9 +539,7 @@ func MapTimeToCode(path string) (map[string]string, error) {
codeByStartTime := make(map[string]string) codeByStartTime := make(map[string]string)
for _, entry := range table { for _, entry := range table {
if len(entry.Code) > 0 { if len(entry.Code) > 0 {
if _, exists := codeByStartTime[entry.StartTime]; !exists { codeByStartTime[entry.StartTime] = entry.Code
codeByStartTime[entry.StartTime] = entry.Code
}
} }
} }
return codeByStartTime, nil return codeByStartTime, nil
@ -431,32 +628,34 @@ func sendDiscordWebhook(subject string, room string, nextTime string, Status str
} }
} }
func sendUpdateDiscordWebhook() { func sendUpdateDiscordWebhookWithDetails(changes []string) {
log.Println("Sending Discord webhook notification...") if BotStart.WebHook {
// Create a rich embed message log.Println("Sending detailed Discord webhook notification...")
message := "A lesson on your timetable has changed"
payload := DiscordWebhookPayload{ message := "**Timetable Update**\n\n" + strings.Join(changes, "\n")
Content: message,
}
jsonData, err := json.Marshal(payload) payload := DiscordWebhookPayload{
if err != nil { Content: message,
log.Printf("Error marshaling webhook payload: %v", err) }
return
}
resp, err := http.Post(discordWebhookURL, "application/json", bytes.NewBuffer(jsonData)) jsonData, err := json.Marshal(payload)
if err != nil { if err != nil {
log.Printf("Error sending Discord webhook: %v", err) log.Printf("Error marshaling webhook payload: %v", err)
return return
} }
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 { resp, err := http.Post(discordWebhookURL, "application/json", bytes.NewBuffer(jsonData))
log.Println("Discord webhook notification sent successfully") if err != nil {
} else { log.Printf("Error sending Discord webhook: %v", err)
body, _ := ioutil.ReadAll(resp.Body) return
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body)) }
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Detailed Discord webhook sent successfully")
} else {
body, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
} }
} }

Binary file not shown.