Merge pull request #1 from Jannover5000r/feature/multi-user-support

feat: Implement multi-user timetable tracking and notifications
This commit is contained in:
Jannover5000r 2025-10-10 23:00:28 +02:00 committed by GitHub
commit 8cd7637f33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 309 additions and 139 deletions

3
.gitignore vendored
View file

@ -6,4 +6,5 @@ teachers.json
login.json login.json
timetable*.json timetable*.json
timetableFilled*.json timetableFilled*.json
accounts.json accounts.json
login*.json

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,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) { 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 {
@ -279,6 +379,29 @@ func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
return 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 // Handle DMs for the two-step process
if m.GuildID == "" { if m.GuildID == "" {
stateMutex.Lock() stateMutex.Lock()

View file

@ -11,8 +11,13 @@
# Installation # Installation
### 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 Programm 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. # This is a discord application intended for self hosting. You will need to add an application in the disord developer website 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.
## In case your school is using a different timing for the Lessons than mine, you can change the times where you will be notified with the next room and Lesson for the day in **Line 71 in the main.go** file.
# I am neither a representative of Untis Untis Baden-Württemberg GmbH nor a Developer in their team. This project is based on their API and is not affiliated with them. ### 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.
### For multi user support: using the command **!addaccount** in the discord server the bot is used in, it will than dm you with the sign up process prompting you to enter your untis Username and password (no email neededd). The Username is stored as plain text and the password is hashed on the device hosting the bot, so currently it is **not secure** since the host will have access to the hashed password and the hash used! Deleting an account is done by using **!removeaccount** which will delete the account, created with you discord account.
## In case your school is using a different timing for the Lessons than mine, you can change the times where you will be notified with the next room and Lesson for the day in **Line 71 in the main.go** file. The bot may not send you a message if there is no lesson after the specified time so that you can leave empty lessons as is for the days.
# I am neither a representative of Untis, Untis Baden-Württemberg GmbH nor a Developer in their team. This project is based on their API and my code and is not affiliated with them.

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
// 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 // Initial update
hourTicker := time.NewTicker(1 * time.Minute) 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)
}
} }
/* /*