feat: Implement multi-user timetable tracking and notifications
Refactors the application to support multiple users for timetable logging. - User accounts added via a Discord command are now stored in `accounts.json`. - The main scheduling loop now fetches timetables for all registered users, in addition to the default user defined in the `.env` file. - Notifications for registered users are now sent as direct messages, providing personalized updates. The default user continues to use the existing webhook. - User-specific data files (e.g., `timetable_filled_<userID>.json`) are now used to keep each user's data separate. - Error handling has been improved to prevent the application from crashing if an operation fails for a single user.
This commit is contained in:
parent
65d88f8c27
commit
6c33d1a410
4 changed files with 226 additions and 135 deletions
106
Bot/Timetable.go
106
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)
|
||||
}
|
||||
|
|
|
|||
49
Bot/Untis.go
49
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
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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) {
|
||||
// Ignore bot's own messages
|
||||
if m.Author.ID == s.State.User.ID {
|
||||
|
|
|
|||
156
main.go
156
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)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
Loading…
Reference in a new issue