Untis-Logger/main.go
Jannover5000r 218916e831 fixed timeutil to set UTC zone correctly
UTC will actually be set new when nothing is inserted

moved initUpdate() to the top of main and finish as first order of operations
2026-08-25 23:40:18 +02:00

650 lines
18 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
_ "time/tzdata"
Untis "untislogger/Untis"
BotStart "untislogger/Botrun"
"github.com/joho/godotenv"
)
type NamedTimetableEntry struct {
ID int `json:"id"`
Date string `json:"date"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Code string `json:"code,omitempty"`
Statflags string `json:"statflags,omitempty"`
Kl []string `json:"kl"`
Su []string `json:"su"`
Ro []string `json:"ro"`
ActivityType string `json:"activityType"`
}
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//
func init() {
godotenv.Load(".env")
err := godotenv.Load()
if err != nil {
log.Printf("Error loading .env file: %v", err)
}
discordWebhookURL = os.Getenv("DISCORD_WEBHOOK_URL")
log.Println("Initializing application...")
// log.Printf("DISCORD_WEBHOOK_URL: %q", discordWebhookURL) //Debug info, not needed at the moment
// Check if Discord webhook is configured
if discordWebhookURL != "" {
log.Println("Discord webhook configured")
} else {
log.Println("No Discord webhook provided, Discord notifications will be disabled")
}
}
func main() {
initUpdate()
go BotStart.Start()
scheduleTimetableUpdate()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down...")
}
func isScheduledTime(now time.Time) bool {
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")
for _, t := range scheduled {
if t == current {
return true
}
}
return false
}
func initUpdate() {
defaultUser := os.Getenv("UNTIS_USER")
defaultPassword := os.Getenv("UNTIS_PASSWORD")
Untis.Main(defaultUser, defaultPassword, "default")
}
func scheduleTimetableUpdate() {
prevData := make(map[string][]byte)
// Function to update all users
updateAllUsers := func() {
// Load all registered users
accounts, err := BotStart.LoadAndDecryptAccounts()
if err != nil {
log.Printf("Error loading accounts: %v", err)
}
// Add the default user from .env
defaultUser := os.Getenv("UNTIS_USER")
defaultPassword := os.Getenv("UNTIS_PASSWORD")
if defaultUser != "" && defaultPassword != "" {
accounts = append(accounts, BotStart.DecryptedAccount{
UserID: "default",
Username: defaultUser,
DecryptedPassword: defaultPassword,
})
}
// Process each user
for _, acc := range accounts {
// Fetch the latest timetable and only timetable
Untis.Update(acc.Username, acc.DecryptedPassword, acc.UserID)
// Check for changes
timetableFile := fmt.Sprintf("timetableFilled_%s.json", acc.UserID)
if acc.UserID == "default" {
timetableFile = "timetableFilled_default.json"
}
data, err := os.ReadFile(timetableFile)
if err != nil {
log.Printf("Error reading timetable for user %s: %v", acc.Username, err)
continue
}
// Parse current and previous timetable data
var currentEntries, prevEntries []NamedTimetableEntry
if err := json.Unmarshal(data, &currentEntries); err != nil {
log.Printf("Failed to parse new timetable for user %s: %v", acc.Username, err)
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 := Untis.GetTime().Format("02-01-2006")
// Get current time to filter any changes to Timetable before 3am so the new day changes stop
//
now := Untis.GetTime()
// If before 1am stop and update userdata
if now.Hour() < 1 {
prevData[acc.UserID] = data
continue
}
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)
}
}
}
// Always update prevData
prevData[acc.UserID] = data
time.Sleep(3 * time.Second)
}
}
// Initial update
updateAllUsers()
// Ticker for periodic updates
hourTicker := time.NewTicker(30 * time.Minute)
go func() {
for range hourTicker.C {
updateAllUsers()
}
}()
// Ticker for scheduled lesson notifications
startMinuteTicker(func() {
now := Untis.GetTime()
if isScheduledTime(now) {
log.Println("Scheduled time reached, running notifications...")
accounts, _ := BotStart.LoadAndDecryptAccounts()
// Also notify the default user
accounts = append(accounts, BotStart.DecryptedAccount{UserID: "default"})
for _, acc := range accounts {
Run(acc.UserID)
}
log.Println("Finished running notifications.")
}
})
log.Println("Updated all users")
}
func startMinuteTicker(f func()) {
now := Untis.GetTime()
next := now.Truncate(time.Minute).Add(time.Minute)
time.Sleep(time.Until(next))
ticker := time.NewTicker(1 * time.Minute)
go func() {
for {
f()
<-ticker.C
}
}()
}
func Run(userID string) {
timetableFile := "timetableFilled_default.json"
if userID != "default" {
timetableFile = fmt.Sprintf("timetableFilled_%s.json", userID)
}
log.Printf("Sending next lesson for user %s", userID)
// Check if the timetable file exists
if _, err := os.Stat(timetableFile); os.IsNotExist(err) {
log.Printf("Timetable file %s not found for user %s.", timetableFile, userID)
return
}
codeByStartTime, _ := MapTimeToCode(timetableFile)
roomByStartTime, _ := MapTimeToRoom(timetableFile)
subjectByStartTime, _ := MapTimeToSubject(timetableFile)
now := Untis.GetTime().Format("15:04")
nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if !foundRoom {
return // No upcoming lessons
}
subject, foundSubject := NextSubjectForTime(subjectByStartTime, now)
if !foundSubject {
return // Should not happen if room was found
}
status, _ := NextCodeForTime(codeByStartTime, now)
var message string
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)
} else {
message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**.", subject, room, nextTime)
}
if userID == "default" {
if BotStart.WebHook {
log.Println("WebHook status ", BotStart.WebHook)
sendDiscordWebhook(subject, room, nextTime, status)
}
}
BotStart.SendDM(userID, message)
}
/*
*/
func NextRoomForTime(roomByStartTime map[string]string, current string) (string, string, bool) {
layout := "15:04"
now, err := time.Parse(layout, current)
if err != nil {
return "", "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range roomByStartTime {
parsed, err := time.Parse(layout, t)
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
}
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) {
layout := "15:04"
now, err := time.Parse(layout, current)
if err != nil {
return "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range subjectByStartTime {
parsed, err := time.Parse(layout, t)
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
}
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) {
layout := "15:04"
now, err := time.Parse(layout, current)
if err != nil {
return "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range codeByStartTime {
parsed, err := time.Parse(layout, t)
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
}
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) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var table []NamedTimetableEntry
err = json.Unmarshal(data, &table)
if err != nil {
return nil, err
}
roomByStartTime := make(map[string]string)
for _, entry := range table {
if len(entry.Ro) > 0 {
roomByStartTime[entry.StartTime] = entry.Ro[0]
}
}
return roomByStartTime, nil
}
func MapTimeToCode(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var table []NamedTimetableEntry
err = json.Unmarshal(data, &table)
if err != nil {
return nil, err
}
codeByStartTime := make(map[string]string)
for _, entry := range table {
if len(entry.Code) > 0 {
codeByStartTime[entry.StartTime] = entry.Code
}
}
return codeByStartTime, nil
}
func MapTimeToSubject(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var table []NamedTimetableEntry
err = json.Unmarshal(data, &table)
if err != nil {
return nil, err
}
subjectByStartTime := make(map[string]string)
for _, entry := range table {
if len(entry.Su) > 0 {
subjectByStartTime[entry.StartTime] = entry.Su[0]
}
}
return subjectByStartTime, nil
}
// Discord webhook configuration
var discordWebhookURL string // Webhook URL from environment variable
// DiscordWebhookPayload represents the structure for Discord webhook messages
type DiscordWebhookPayload struct {
Content string `json:"content"`
Embeds []Embed `json:"embeds,omitempty"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Timestamp string `json:"timestamp"`
Fields []Field `json:"fields,omitempty"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
func sendDiscordWebhook(subject string, room string, nextTime string, Status string) {
log.Println("Sending Discord webhook notification...")
// Create a rich embed message
var message string
if Status != "" {
message = fmt.Sprintf(
"Subject: %s\nRoom: %s\nStart-Time: %s\nStatus: %s",
subject, room, nextTime, Status,
)
} else {
message = fmt.Sprintf(
"Subject: %s\nRoom: %s\nStart-Time: %s",
subject, room, nextTime,
)
}
payload := DiscordWebhookPayload{
Content: message,
}
jsonData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling webhook payload: %v", err)
return
}
resp, err := http.Post(discordWebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("Error sending Discord webhook: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Discord webhook notification sent successfully")
} else {
body, _ := io.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
}
func sendUpdateDiscordWebhookWithDetails(changes []string) {
if BotStart.WebHook {
log.Println("Sending detailed Discord webhook notification...")
message := "**Timetable Update**\n\n" + strings.Join(changes, "\n")
payload := DiscordWebhookPayload{
Content: message,
}
jsonData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling webhook payload: %v", err)
return
}
resp, err := http.Post(discordWebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("Error sending Discord webhook: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Detailed Discord webhook sent successfully")
} else {
body, _ := io.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
}
}