diff --git a/.gitignore b/.gitignore index 153d98a..71732ee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ teachers.json login.json timetable*.json timetableFilled*.json -accounts.json \ No newline at end of file +accounts.json +login*.json diff --git a/Bot/Timetable.go b/Bot/Timetable.go index 74afefc..898cca4 100644 --- a/Bot/Timetable.go +++ b/Bot/Timetable.go @@ -88,67 +88,73 @@ func ReadLoginResultFromFile(path string) (Loginresult, error) { return result, err } -func Timetable(cookies []*http.Cookie) { - loginResult, err := ReadLoginResultFromFile("login.json") - if err != nil { - log.Fatal(err) +func Timetable(cookies []*http.Cookie, userID string) { + loginFile := "login.json" + if userID != "" { + loginFile = "login_" + userID + ".json" } + + loginResult, err := ReadLoginResultFromFile(loginFile) + if err != nil { + log.Printf("Could not read login result for user %s: %v", userID, err) + return + } + today := time.Now().Format("20060102") g := getTimetable{"2023-05-06 15:44:22.215292", "getTimetable", params{today, today, loginResult.PersonID, loginResult.PersonType}, "2.0"} - TimetablesJson, err := json.Marshal(g) + timetablesJson, err := json.Marshal(g) if err != nil { - log.Fatalf("Error marshaling login data: %v", err) + log.Printf("Error marshaling timetable request: %v", err) return } - timetable := bytes.NewReader(TimetablesJson) + timetable := bytes.NewReader(timetablesJson) - prompt, err := http.NewRequest("GET", Url, timetable) + req, err := http.NewRequest("GET", Url, timetable) if err != nil { - log.Fatalf("Error creatingrequest: %v", err) + log.Printf("Error creating timetable request: %v", err) return } - //log.Println("prompt without extra header or cookie ", prompt) - //log.Println("Cookie: ", cookies) - - prompt.Header.Set("Content-Type", "application/json") - prompt.Header.Set("User-Agent", "Webuntis Test") - + req.Header.Set("Content-Type", "application/json") for _, cookie := range cookies { - //if cookie.Name == "JSESSIONID" { - prompt.AddCookie(cookie) - //log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value) - //} + req.AddCookie(cookie) } - //log.Println("Request JSON:", string(TeachersJson)) - out, err := http.DefaultClient.Do(prompt) + + resp, err := http.DefaultClient.Do(req) if err != nil { - log.Printf("Error during request: %v", err) + log.Printf("Error fetching timetable: %v", err) return } - defer out.Body.Close() - //log.Println(out.Status) - response, err := io.ReadAll(out.Body) + defer resp.Body.Close() + + response, err := io.ReadAll(resp.Body) if err != nil { - log.Printf("Error reading response body: %v", err) + log.Printf("Error reading timetable response: %v", err) return } - //responseString := string(response) - //log.Println("Repsonse ", responseString) + var Response TimetableResponse - err = json.Unmarshal(response, &Response) - if err != nil { - log.Fatalf("Error unmarshaling response: %v", err) + if err := json.Unmarshal(response, &Response); err != nil { + log.Printf("Error unmarshaling timetable response: %v", err) + return } + data, err := json.MarshalIndent(Response.Result, "", " ") if err != nil { - log.Fatal(err) + log.Printf("Error marshaling timetable result: %v", err) + return } - err = os.WriteFile("timetable.json", data, 0644) - if err != nil { - log.Fatal(err) + + timetableFile := "timetable.json" + if userID != "" { + timetableFile = "timetable_" + userID + ".json" } - log.Println("Updated Timetable") - setTimetable() + if err := os.WriteFile(timetableFile, data, 0644); err != nil { + log.Printf("Error writing timetable file: %v", err) + return + } + + log.Printf("Updated timetable for user %s", userID) + setTimetable(userID) } func LoadIDMap(path string) (map[int]string, error) { @@ -189,14 +195,18 @@ func formatDate(date int) string { day := s[6:8] return fmt.Sprintf("%s-%s-%s", day, month, year) } -func setTimetable() { +func setTimetable(userID string) { subjects, _ := LoadIDMap("subjects.json") rooms, _ := LoadIDMap("rooms.json") classes, _ := LoadIDMap("classes.json") - timetable, _ := LoadTimetable("timetable.json") + + timetableFile := "timetable.json" + if userID != "" { + timetableFile = "timetable_" + userID + ".json" + } + timetable, _ := LoadTimetable(timetableFile) var namedTimetable []NamedTimetableEntry - for _, lesson := range timetable { var klNames, suNames, roNames []string for _, kl := range lesson.Kl { @@ -224,11 +234,17 @@ func setTimetable() { data, err := json.MarshalIndent(namedTimetable, "", " ") if err != nil { - log.Fatal(err) + log.Printf("Error marshaling named timetable: %v", err) + return } - err = os.WriteFile("timetableFilled.json", data, 0644) - if err != nil { - log.Fatal(err) + + timetableFilledFile := "timetableFilled.json" + if userID != "" { + timetableFilledFile = "timetableFilled_" + userID + ".json" } - log.Println("Filled Timetable") + if err := os.WriteFile(timetableFilledFile, data, 0644); err != nil { + log.Printf("Error writing timetableFilled file: %v", err) + return + } + log.Printf("Filled timetable for user %s", userID) } diff --git a/Bot/Untis.go b/Bot/Untis.go index 260afae..7b3faf9 100644 --- a/Bot/Untis.go +++ b/Bot/Untis.go @@ -35,70 +35,59 @@ var Url = "https://thalia.webuntis.com/WebUntis/jsonrpc.do?school=Mons_Tabor" //var Password = os.Getenv("UNTIS_PASSWORD") //var USERS = os.Getenv("UNTIS_USER") -func Main(user, password string) { +func Main(user, password, userID string) { godotenv.Load("../.env") - cookies, err := Auth(user, password) + cookies, err := Auth(user, password, userID) if err != nil { - log.Fatal(err) + log.Printf("Authentication failed for user %s: %v", user, err) return } Rooms(cookies) - Classes(cookies) - Subjects(cookies) - - Timetable(cookies) - - //getTeachers sends empty response + Timetable(cookies, userID) Teachers(cookies) } -func Auth(user, password string) ([]*http.Cookie, error) { +func Auth(user, password, userID string) ([]*http.Cookie, error) { l := Login{"2023-05-06 15:44:22.215292", "authenticate", Params{user, password, "WebUntis Test"}, "2.0"} loginJSON, err := json.Marshal(l) if err != nil { - log.Fatalf("Error marshaling login data: %v", err) return nil, err } login := bytes.NewReader(loginJSON) - //log.Println("Run") LoginOut, err := http.Post(Url, "application/json", login) if err != nil { - log.Printf("Error during authentication: %v", err) return nil, err } defer LoginOut.Body.Close() - // Parse cookies from response cookies := LoginOut.Cookies() - //log.Println("Received cookies:", cookies) - //log.Println("Set-Cookie headers:", LoginOut.Header["Set-Cookie"]) - //loginRespBody, _ := io.ReadAll(LoginOut.Body) - //log.Println("Login response body:", string(loginRespBody)) + log.Printf("Login successful for user: %s", user) - log.Println("Login successful") - - //log.Println("Login successful") response, err := io.ReadAll(LoginOut.Body) if err != nil { - log.Fatalf("Error reading response body: %v", err) + return nil, err } + var Response LoginResponse - err = json.Unmarshal(response, &Response) - if err != nil { - log.Fatalf("Error unmarshaling response: %v", err) + if err := json.Unmarshal(response, &Response); err != nil { + return nil, err } + data, err := json.MarshalIndent(Response.Result, "", " ") if err != nil { - log.Fatal(err) + return nil, err } - err = os.WriteFile("login.json", data, 0644) - if err != nil { - log.Fatal(err) + + loginFile := "login.json" + if userID != "" { + loginFile = "login_" + userID + ".json" + } + if err := os.WriteFile(loginFile, data, 0644); err != nil { + return nil, err } return cookies, nil - } diff --git a/Botrun/BotStart.go b/Botrun/BotStart.go index 63a5c6d..145450c 100644 --- a/Botrun/BotStart.go +++ b/Botrun/BotStart.go @@ -248,6 +248,35 @@ func Start() { dg.Close() } +// DecryptedAccount holds user information with a decrypted password. +type DecryptedAccount struct { + UserID string + Username string + DecryptedPassword string +} + +// LoadAndDecryptAccounts loads all accounts from the JSON file and decrypts their passwords. +func LoadAndDecryptAccounts() ([]DecryptedAccount, error) { + accounts := loadAllAccounts() + decryptedAccounts := make([]DecryptedAccount, 0, len(accounts)) + + for _, acc := range accounts { + decPwd, err := decrypt(acc.Password) + if err != nil { + // Log the error but continue with other accounts + fmt.Printf("Error decrypting password for user %s: %v\n", acc.UserID, err) + continue + } + decryptedAccounts = append(decryptedAccounts, DecryptedAccount{ + UserID: acc.UserID, + Username: acc.Username, + DecryptedPassword: decPwd, + }) + } + + return decryptedAccounts, nil +} + // Expose this for main.go to trigger notifications func NotifyAllUsers() { if DiscordSession != nil { @@ -255,6 +284,77 @@ func NotifyAllUsers() { } } +// SendDM sends a direct message to a user. +func SendDM(userID, message string) { + if DiscordSession == nil { + fmt.Println("Discord session not initialized, cannot send DM.") + return + } + // Avoid sending messages to the "default" user placeholder + if userID == "default" { + return + } + channel, err := DiscordSession.UserChannelCreate(userID) + if err != nil { + fmt.Printf("Error creating DM channel for %s: %v\n", userID, err) + return + } + _, err = DiscordSession.ChannelMessageSend(channel.ID, message) + if err != nil { + fmt.Printf("Error sending DM to %s: %v\n", userID, err) + } +} + +// Delete account and associated files for a given userID +func deleteAccount(userID string) error { + var accounts []Account + + // Load existing accounts + data, err := os.ReadFile(accountsFile) + if err != nil { + if os.IsNotExist(err) { + return nil // No accounts file means nothing to delete + } + return err + } + + if len(data) > 0 { + if err := json.Unmarshal(data, &accounts); err != nil { + return err + } + } + + // Filter out the account with matching userID + newAccounts := accounts[:0] + found := false + for _, acc := range accounts { + if acc.UserID != userID { + newAccounts = append(newAccounts, acc) + } else { + found = true + } + } + + if !found { + return errors.New("account not found") + } + + // Save remaining accounts back to file + newData, err := json.MarshalIndent(newAccounts, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(accountsFile, newData, 0644); err != nil { + return err + } + + // Delete associated timetable files + os.Remove(getTimetableFile(userID)) + os.Remove(getTimetableFilledFile(userID)) + + return nil +} + func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { // Ignore bot's own messages if m.Author.ID == s.State.User.ID { @@ -279,6 +379,29 @@ func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { return } + // Handle "!deleteaccount" and "!removeaccount" commands + if m.GuildID != "" && (m.Content == "!deleteaccount" || m.Content == "!removeaccount") { + // Delete the command message for privacy + _ = s.ChannelMessageDelete(m.ChannelID, m.ID) + + err := deleteAccount(m.Author.ID) + if err != nil { + // Create DM channel to notify of error + channel, err := s.UserChannelCreate(m.Author.ID) + if err == nil { + s.ChannelMessageSend(channel.ID, "Failed to delete your account: "+err.Error()) + } + return + } + + // Notify success via DM + channel, err := s.UserChannelCreate(m.Author.ID) + if err == nil { + s.ChannelMessageSend(channel.ID, "Your account has been successfully deleted.") + } + return + } + // Handle DMs for the two-step process if m.GuildID == "" { stateMutex.Lock() diff --git a/go.mod b/go.mod index 8f32964..1d720a8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module untislogger -go 1.24 +go 1.25.2 require ( github.com/bwmarrin/discordgo v0.29.0 @@ -8,7 +8,7 @@ require ( ) require ( - github.com/gorilla/websocket v1.4.2 // indirect - golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 04715b6..3ce7d10 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,17 @@ github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 3751813..921ea3f 100644 --- a/main.go +++ b/main.go @@ -80,45 +80,74 @@ func isScheduledTime(now time.Time) bool { return false } func scheduleTimetableUpdate() { - var prevData []byte - //declare user and pass - godotenv.Load(".env") - var password = os.Getenv("UNTIS_PASSWORD") - var user = os.Getenv("UNTIS_USER") - Untis.Main(user, password) - // Initial read of the file - data, err := os.ReadFile("timetableFilled.json") - if err == nil { - prevData = data + 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.json" + } + data, err := os.ReadFile(timetableFile) + if err != nil { + log.Printf("Error reading timetable for user %s: %v", acc.Username, err) + continue + } + + if prev, ok := prevData[acc.UserID]; ok && !bytes.Equal(data, prev) { + log.Printf("Timetable has changed for user %s.", acc.Username) + BotStart.SendDM(acc.UserID, "Your timetable has changed!") + } + prevData[acc.UserID] = data + } } - // Ticker for checking timetable changes every hour - hourTicker := time.NewTicker(1 * time.Minute) + // 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 { - Untis.Main(user, password) - data, err := os.ReadFile("timetableFilled.json") - if err != nil { - log.Printf("Error reading timetable: %v", err) - } else if !bytes.Equal(data, prevData) { - log.Println("Timetable file has changed.") - sendUpdateDiscordWebhook() - prevData = data - } - // Trigger bot notifications for all users - BotStart.NotifyAllUsers() + updateAllUsers() } }() - // Ticker for checking scheduled times every minute + // Ticker for scheduled lesson notifications startMinuteTicker(func() { now := time.Now() if isScheduledTime(now) { - log.Println("Scheduled time reached, updating and running Run()") - Untis.Main(user, password) - log.Println("Updated now running Run()") - Run() - log.Println("Finished running Run") + 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.") } }) } @@ -136,42 +165,49 @@ func startMinuteTicker(f func()) { }() } -/* -#Main Run Function -*/ -func Run() { - log.Println("Sending next Lesson") - codeByStartTime, err := MapTimeToCode("timetableFilled.json") - if err != nil { - log.Println(err) +func Run(userID string) { + timetableFile := "timetableFilled.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 } - roomByStartTime, err := MapTimeToRoom("timetableFilled.json") - if err != nil { - log.Fatal(err) - } - subjectByStartTime, err := MapTimeToSubject("timetableFilled.json") - if err != nil { - log.Fatal(err) - } + codeByStartTime, _ := MapTimeToCode(timetableFile) + roomByStartTime, _ := MapTimeToRoom(timetableFile) + subjectByStartTime, _ := MapTimeToSubject(timetableFile) + now := time.Now().Format("15:04") - nextTime, room, found := NextRoomForTime(roomByStartTime, now) - if found { - log.Println("found Room") - Subject, found := NextSubjectForTime(subjectByStartTime, now) - if found { - log.Println("Found Subject") - Status, found := NextCodeForTime(codeByStartTime, now) - if found { - log.Println("Found Status") - fmt.Printf("Next time: %s, Room: %s\n", nextTime, room) - sendDiscordWebhook(Subject, room, nextTime, Status) - } else { - sendDiscordWebhook(Subject, room, nextTime, "") - } - } + 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 != "" { + 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" { + // The default user still sends to the webhook + sendDiscordWebhook(subject, room, nextTime, status) + } else { + BotStart.SendDM(userID, message) + } } /*