Untis-Logger/main.go
2025-11-14 09:10:35 +01:00

599 lines
16 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
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"`
}
// 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)
// 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() {
godotenv.Load(".env")
// Untis.Main() //starting API calls function| happens in schedule func
// Run()
// Starts logging the timetable for each new Lesson and logs changes
go BotStart.Start()
scheduleTimetableUpdate()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
log.Println("Program is running. Press Ctrl+C to stop.")
// Start logging
// Run()
// Block until we receive a signal
<-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 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
Untis.Main(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)
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 {
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))
}
// 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
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 {
if _, exists := currMap[key]; !exists {
subject := getSubject(prev)
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
}
}
// Initial update
updateAllUsers()
// Ticker for periodic updates
hourTicker := time.NewTicker(1 * time.Minute) // For testing, 1 minute. Change to 1 * time.Hour for production.
go func() {
for range hourTicker.C {
updateAllUsers()
}
}()
// Ticker for scheduled lesson notifications
startMinuteTicker(func() {
now := time.Now()
now = now.Add(1 * time.Hour)
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.")
}
})
}
func startMinuteTicker(f func()) {
now := time.Now()
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)
nowOld := time.Now()
nowOld = nowOld.Add(1 * time.Hour)
now := nowOld.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, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
}
func sendUpdateDiscordWebhookWithDetails(changes []string) {
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, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
}