feat: Implement multi-user timetable tracking and notifications #1

Merged
Jannover5000r merged 3 commits from feature/multi-user-support into main 2025-10-10 23:00:28 +02:00
4 changed files with 226 additions and 135 deletions
Showing only changes of commit 6c33d1a410 - Show all commits

View file

@ -88,67 +88,73 @@ func ReadLoginResultFromFile(path string) (Loginresult, error) {
return result, err return result, err
} }
func Timetable(cookies []*http.Cookie) { func Timetable(cookies []*http.Cookie, userID string) {
loginResult, err := ReadLoginResultFromFile("login.json") loginFile := "login.json"
if err != nil { if userID != "" {
log.Fatal(err) 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") today := time.Now().Format("20060102")
g := getTimetable{"2023-05-06 15:44:22.215292", "getTimetable", params{today, today, loginResult.PersonID, loginResult.PersonType}, "2.0"} 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 { if err != nil {
log.Fatalf("Error marshaling login data: %v", err) log.Printf("Error marshaling timetable request: %v", err)
return 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 { if err != nil {
log.Fatalf("Error creatingrequest: %v", err) log.Printf("Error creating timetable request: %v", err)
return return
} }
//log.Println("prompt without extra header or cookie ", prompt) req.Header.Set("Content-Type", "application/json")
//log.Println("Cookie: ", cookies)
prompt.Header.Set("Content-Type", "application/json")
prompt.Header.Set("User-Agent", "Webuntis Test")
for _, cookie := range cookies { for _, cookie := range cookies {
//if cookie.Name == "JSESSIONID" { req.AddCookie(cookie)
prompt.AddCookie(cookie)
//log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value)
//}
} }
//log.Println("Request JSON:", string(TeachersJson))
out, err := http.DefaultClient.Do(prompt) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
log.Printf("Error during request: %v", err) log.Printf("Error fetching timetable: %v", err)
return return
} }
defer out.Body.Close() defer resp.Body.Close()
//log.Println(out.Status)
response, err := io.ReadAll(out.Body) response, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Printf("Error reading response body: %v", err) log.Printf("Error reading timetable response: %v", err)
return return
} }
//responseString := string(response)
//log.Println("Repsonse ", responseString)
var Response TimetableResponse var Response TimetableResponse
err = json.Unmarshal(response, &Response) if err := json.Unmarshal(response, &Response); err != nil {
if err != nil { log.Printf("Error unmarshaling timetable response: %v", err)
log.Fatalf("Error unmarshaling response: %v", err) return
} }
data, err := json.MarshalIndent(Response.Result, "", " ") data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil { 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 { timetableFile := "timetable.json"
log.Fatal(err) if userID != "" {
timetableFile = "timetable_" + userID + ".json"
} }
log.Println("Updated Timetable") if err := os.WriteFile(timetableFile, data, 0644); err != nil {
setTimetable() 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) { func LoadIDMap(path string) (map[int]string, error) {
@ -189,14 +195,18 @@ func formatDate(date int) string {
day := s[6:8] day := s[6:8]
return fmt.Sprintf("%s-%s-%s", day, month, year) return fmt.Sprintf("%s-%s-%s", day, month, year)
} }
func setTimetable() { func setTimetable(userID string) {
subjects, _ := LoadIDMap("subjects.json") subjects, _ := LoadIDMap("subjects.json")
rooms, _ := LoadIDMap("rooms.json") rooms, _ := LoadIDMap("rooms.json")
classes, _ := LoadIDMap("classes.json") classes, _ := LoadIDMap("classes.json")
timetable, _ := LoadTimetable("timetable.json")
timetableFile := "timetable.json"
if userID != "" {
timetableFile = "timetable_" + userID + ".json"
}
timetable, _ := LoadTimetable(timetableFile)
var namedTimetable []NamedTimetableEntry var namedTimetable []NamedTimetableEntry
for _, lesson := range timetable { for _, lesson := range timetable {
var klNames, suNames, roNames []string var klNames, suNames, roNames []string
for _, kl := range lesson.Kl { for _, kl := range lesson.Kl {
@ -224,11 +234,17 @@ func setTimetable() {
data, err := json.MarshalIndent(namedTimetable, "", " ") data, err := json.MarshalIndent(namedTimetable, "", " ")
if err != nil { 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 { timetableFilledFile := "timetableFilled.json"
log.Fatal(err) 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)
} }

View file

@ -35,70 +35,59 @@ var Url = "https://thalia.webuntis.com/WebUntis/jsonrpc.do?school=Mons_Tabor"
//var Password = os.Getenv("UNTIS_PASSWORD") //var Password = os.Getenv("UNTIS_PASSWORD")
//var USERS = os.Getenv("UNTIS_USER") //var USERS = os.Getenv("UNTIS_USER")
func Main(user, password string) { func Main(user, password, userID string) {
godotenv.Load("../.env") godotenv.Load("../.env")
cookies, err := Auth(user, password) cookies, err := Auth(user, password, userID)
if err != nil { if err != nil {
log.Fatal(err) log.Printf("Authentication failed for user %s: %v", user, err)
return return
} }
Rooms(cookies) Rooms(cookies)
Classes(cookies) Classes(cookies)
Subjects(cookies) Subjects(cookies)
Timetable(cookies, userID)
Timetable(cookies)
//getTeachers sends empty response
Teachers(cookies) 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"} l := Login{"2023-05-06 15:44:22.215292", "authenticate", Params{user, password, "WebUntis Test"}, "2.0"}
loginJSON, err := json.Marshal(l) loginJSON, err := json.Marshal(l)
if err != nil { if err != nil {
log.Fatalf("Error marshaling login data: %v", err)
return nil, err return nil, err
} }
login := bytes.NewReader(loginJSON) login := bytes.NewReader(loginJSON)
//log.Println("Run")
LoginOut, err := http.Post(Url, "application/json", login) LoginOut, err := http.Post(Url, "application/json", login)
if err != nil { if err != nil {
log.Printf("Error during authentication: %v", err)
return nil, err return nil, err
} }
defer LoginOut.Body.Close() defer LoginOut.Body.Close()
// Parse cookies from response
cookies := LoginOut.Cookies() cookies := LoginOut.Cookies()
//log.Println("Received cookies:", cookies) log.Printf("Login successful for user: %s", user)
//log.Println("Set-Cookie headers:", LoginOut.Header["Set-Cookie"])
//loginRespBody, _ := io.ReadAll(LoginOut.Body)
//log.Println("Login response body:", string(loginRespBody))
log.Println("Login successful")
//log.Println("Login successful")
response, err := io.ReadAll(LoginOut.Body) response, err := io.ReadAll(LoginOut.Body)
if err != nil { if err != nil {
log.Fatalf("Error reading response body: %v", err) return nil, err
} }
var Response LoginResponse var Response LoginResponse
err = json.Unmarshal(response, &Response) if err := json.Unmarshal(response, &Response); err != nil {
if err != nil { return nil, err
log.Fatalf("Error unmarshaling response: %v", err)
} }
data, err := json.MarshalIndent(Response.Result, "", " ") data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
err = os.WriteFile("login.json", data, 0644)
if err != nil { loginFile := "login.json"
log.Fatal(err) if userID != "" {
loginFile = "login_" + userID + ".json"
}
if err := os.WriteFile(loginFile, data, 0644); err != nil {
return nil, err
} }
return cookies, nil return cookies, nil
} }

View file

@ -248,6 +248,35 @@ func Start() {
dg.Close() 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 // Expose this for main.go to trigger notifications
func NotifyAllUsers() { func NotifyAllUsers() {
if DiscordSession != nil { if DiscordSession != nil {
@ -255,6 +284,27 @@ 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)
}
}
func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
// Ignore bot's own messages // Ignore bot's own messages
if m.Author.ID == s.State.User.ID { if m.Author.ID == s.State.User.ID {

156
main.go
View file

@ -80,45 +80,74 @@ func isScheduledTime(now time.Time) bool {
return false return false
} }
func scheduleTimetableUpdate() { func scheduleTimetableUpdate() {
var prevData []byte prevData := make(map[string][]byte)
//declare user and pass
godotenv.Load(".env") // Function to update all users
var password = os.Getenv("UNTIS_PASSWORD") updateAllUsers := func() {
var user = os.Getenv("UNTIS_USER") // Load all registered users
Untis.Main(user, password) accounts, err := BotStart.LoadAndDecryptAccounts()
// Initial read of the file if err != nil {
data, err := os.ReadFile("timetableFilled.json") log.Printf("Error loading accounts: %v", err)
if err == nil {
prevData = data
} }
// Ticker for checking timetable changes every hour // Add the default user from .env
hourTicker := time.NewTicker(1 * time.Minute) 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
}
}
// 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() { go func() {
for range hourTicker.C { for range hourTicker.C {
Untis.Main(user, password) updateAllUsers()
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()
} }
}() }()
// Ticker for checking scheduled times every minute // Ticker for scheduled lesson notifications
startMinuteTicker(func() { startMinuteTicker(func() {
now := time.Now() now := time.Now()
if isScheduledTime(now) { if isScheduledTime(now) {
log.Println("Scheduled time reached, updating and running Run()") log.Println("Scheduled time reached, running notifications...")
Untis.Main(user, password) accounts, _ := BotStart.LoadAndDecryptAccounts()
log.Println("Updated now running Run()") // Also notify the default user
Run() accounts = append(accounts, BotStart.DecryptedAccount{UserID: "default"})
log.Println("Finished running Run") for _, acc := range accounts {
Run(acc.UserID)
}
log.Println("Finished running notifications.")
} }
}) })
} }
@ -136,42 +165,49 @@ func startMinuteTicker(f func()) {
}() }()
} }
/* func Run(userID string) {
#Main Run Function timetableFile := "timetableFilled.json"
*/ if userID != "default" {
func Run() { timetableFile = fmt.Sprintf("timetableFilled_%s.json", userID)
log.Println("Sending next Lesson") }
codeByStartTime, err := MapTimeToCode("timetableFilled.json") log.Printf("Sending next lesson for user %s", userID)
if err != nil {
log.Println(err) // 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") codeByStartTime, _ := MapTimeToCode(timetableFile)
if err != nil { roomByStartTime, _ := MapTimeToRoom(timetableFile)
log.Fatal(err) subjectByStartTime, _ := MapTimeToSubject(timetableFile)
}
subjectByStartTime, err := MapTimeToSubject("timetableFilled.json")
if err != nil {
log.Fatal(err)
}
now := time.Now().Format("15:04") now := time.Now().Format("15:04")
nextTime, room, found := NextRoomForTime(roomByStartTime, now) nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if found { if !foundRoom {
log.Println("found Room") return // No upcoming lessons
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, "")
}
}
} }
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)
}
} }
/* /*