Compare commits

..

No commits in common. "main" and "feature-UpdateAt2Am" have entirely different histories.

12 changed files with 124 additions and 148 deletions

View file

@ -15,6 +15,7 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"syscall" "syscall"
"time"
"untislogger/Untis" "untislogger/Untis"
@ -224,6 +225,7 @@ func checkAllUsersTimetables(s *discordgo.Session) {
accounts := loadAllAccounts() accounts := loadAllAccounts()
for _, user := range accounts { for _, user := range accounts {
decPwd, _ := decrypt(user.Password) decPwd, _ := decrypt(user.Password)
// You can use decPwd to fetch the timetable for this user
checkTimetableChangesForUser(user, decPwd, s) checkTimetableChangesForUser(user, decPwd, s)
} }
} }
@ -251,6 +253,14 @@ func Start() {
return return
} }
// Schedule timetable checks every minute (or hour as needed)
go func() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
checkAllUsersTimetables(dg)
}
}()
fmt.Println("Bot is now running. Press CTRL+C to exit.") fmt.Println("Bot is now running. Press CTRL+C to exit.")
// Wait for CTRL+C or other term signal to exit. // Wait for CTRL+C or other term signal to exit.
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)

View file

@ -1,3 +1,5 @@
# Well, i guess this is not temporary anymore and will just become the new main
# Discord bot for logging Untis timetable to a Discord Webhook and Multiple accounts which can be added using !addaccount in a server whit the Bot # Discord bot for logging Untis timetable to a Discord Webhook and Multiple accounts which can be added using !addaccount in a server whit the Bot
## create .env with Credentials ## create .env with Credentials
@ -8,13 +10,12 @@
- DISCORD_BOT_TOKEN - DISCORD_BOT_TOKEN
- ENC_KEY (generated via head -c 32 /dev/urandom | base64) - ENC_KEY (generated via head -c 32 /dev/urandom | base64)
- URL - URL
- TIMEZONE (will default to UTC if empty)
### URL can be found by following this structure ### URL can be found by following this structure: "<https://[SchoolName].webuntis.com/WebUntis/jsonrpc.do?school=[SchholName]" while SchoolName can be found by visiting the official website, going to the login for your school and it will be: "<https://[SchoolName].webuntis.com/WebUntis/?school=[SchoolName]#/basic/login>"
"<https://[SchoolName].webuntis.com/WebUntis/jsonrpc.do?school=[SchholName]" # This is a project for me, issues will be resolved as i find the motivation to do so, improvements may follow in the future
while SchoolName can be found by visiting the official website, going to the login for your school and it will be:
"<https://[SchoolName].webuntis.com/WebUntis/?school=[SchoolName]#/basic/login>" ## I will try to maintain this project as best as possible. Maybe add a better security to it than to trust the host but for now it is working and that was my goal. Please report any errors you find while using this bot
# Installation # Installation

View file

@ -32,14 +32,14 @@ func Classes(cookies []*http.Cookie) {
g := getClasses{"2023-05-06 15:44:22.215292", "getKlassen", map[string]interface{}{}, "2.0"} g := getClasses{"2023-05-06 15:44:22.215292", "getKlassen", map[string]interface{}{}, "2.0"}
ClassesJson, err := json.Marshal(g) ClassesJson, err := json.Marshal(g)
if err != nil { if err != nil {
log.Printf("Error marshaling login data: %v", err) log.Fatalf("Error marshaling login data: %v", err)
return return
} }
classes := bytes.NewReader(ClassesJson) classes := bytes.NewReader(ClassesJson)
prompt, err := http.NewRequest("POST", Url, classes) prompt, err := http.NewRequest("POST", Url, classes)
if err != nil { if err != nil {
log.Printf("Error creatingrequest: %v", err) log.Fatalf("Error creatingrequest: %v", err)
return return
} }
//log.Println("prompt without extra header or cookie ", prompt) //log.Println("prompt without extra header or cookie ", prompt)
@ -72,17 +72,15 @@ func Classes(cookies []*http.Cookie) {
var Response ClassesResponse var Response ClassesResponse
err = json.Unmarshal(response, &Response) err = json.Unmarshal(response, &Response)
if err != nil { if err != nil {
log.Printf("Error unmarshaling response: %v", 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.Print(err) log.Fatal(err)
return
} }
err = os.WriteFile("classes.json", data, 0o644) err = os.WriteFile("classes.json", data, 0644)
if err != nil { if err != nil {
log.Print(err) log.Fatal(err)
return
} }
// log.Println("Updated Classes")//dont need log anymore log.Println("Updated Classes")
} }

View file

@ -35,14 +35,14 @@ func Rooms(cookies []*http.Cookie) {
g := getRooms{"2023-05-06 15:44:22.215292", "getRooms", map[string]interface{}{}, "2.0"} g := getRooms{"2023-05-06 15:44:22.215292", "getRooms", map[string]interface{}{}, "2.0"}
roomsJson, err := json.Marshal(g) roomsJson, err := json.Marshal(g)
if err != nil { if err != nil {
log.Printf("Error marshaling login data: %v", err) log.Fatalf("Error marshaling login data: %v", err)
return return
} }
rooms := bytes.NewReader(roomsJson) rooms := bytes.NewReader(roomsJson)
prompt, err := http.NewRequest("POST", Url, rooms) prompt, err := http.NewRequest("POST", Url, rooms)
if err != nil { if err != nil {
log.Printf("Error creatingrequest: %v", err) log.Fatalf("Error creatingrequest: %v", err)
return return
} }
//log.Println("prompt without extra header or cookie ", prompt) //log.Println("prompt without extra header or cookie ", prompt)
@ -75,19 +75,16 @@ func Rooms(cookies []*http.Cookie) {
var Response RoomsResponse var Response RoomsResponse
err = json.Unmarshal(response, &Response) err = json.Unmarshal(response, &Response)
if err != nil { if err != nil {
log.Printf("Error unmarshaling 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.Print(err) log.Fatal(err)
return
} }
err = os.WriteFile("rooms.json", data, 0o644) err = os.WriteFile("rooms.json", data, 0644)
if err != nil { if err != nil {
log.Print(err) log.Fatal(err)
return
} }
// log.Println("Updated Rooms")//dont need log anymore log.Println("Updated Rooms")
} }

View file

@ -32,14 +32,14 @@ func Subjects(cookies []*http.Cookie) {
g := getSubjects{"2023-05-06 15:44:22.215292", "getSubjects", map[string]interface{}{}, "2.0"} g := getSubjects{"2023-05-06 15:44:22.215292", "getSubjects", map[string]interface{}{}, "2.0"}
SubjectsJson, err := json.Marshal(g) SubjectsJson, err := json.Marshal(g)
if err != nil { if err != nil {
log.Printf("Error marshaling login data: %v", err) log.Fatalf("Error marshaling login data: %v", err)
return return
} }
subjects := bytes.NewReader(SubjectsJson) subjects := bytes.NewReader(SubjectsJson)
prompt, err := http.NewRequest("POST", Url, subjects) prompt, err := http.NewRequest("POST", Url, subjects)
if err != nil { if err != nil {
log.Printf("Error creatingrequest: %v", err) log.Fatalf("Error creatingrequest: %v", err)
return return
} }
//log.Println("prompt without extra header or cookie ", prompt) //log.Println("prompt without extra header or cookie ", prompt)
@ -72,18 +72,15 @@ func Subjects(cookies []*http.Cookie) {
var Response SubjectsResponse var Response SubjectsResponse
err = json.Unmarshal(response, &Response) err = json.Unmarshal(response, &Response)
if err != nil { if err != nil {
log.Printf("Error unmarshaling 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.Print(err) log.Fatal(err)
return
} }
err = os.WriteFile("subjects.json", data, 0o644) err = os.WriteFile("subjects.json", data, 0644)
if err != nil { if err != nil {
log.Print(err) log.Fatal(err)
return
} }
// log.Println("Updated Subjects")//dont need log anymore log.Println("Updated Subjects")
} }

View file

@ -32,14 +32,14 @@ func Teachers(cookies []*http.Cookie) {
g := getTeachers{"2023-05-06 15:44:22.215292", "getTeachers", map[string]interface{}{}, "2.0"} g := getTeachers{"2023-05-06 15:44:22.215292", "getTeachers", map[string]interface{}{}, "2.0"}
TeachersJson, err := json.Marshal(g) TeachersJson, err := json.Marshal(g)
if err != nil { if err != nil {
log.Printf("Error marshaling login data: %v", err) log.Fatalf("Error marshaling login data: %v", err)
return return
} }
teachers := bytes.NewReader(TeachersJson) teachers := bytes.NewReader(TeachersJson)
prompt, err := http.NewRequest("POST", Url, teachers) prompt, err := http.NewRequest("POST", Url, teachers)
if err != nil { if err != nil {
log.Printf("Error creatingrequest: %v", err) log.Fatalf("Error creatingrequest: %v", err)
return return
} }
//log.Println("prompt without extra header or cookie ", prompt) //log.Println("prompt without extra header or cookie ", prompt)
@ -72,18 +72,15 @@ func Teachers(cookies []*http.Cookie) {
var Response TeachersResponse var Response TeachersResponse
err = json.Unmarshal(response, &Response) err = json.Unmarshal(response, &Response)
if err != nil { if err != nil {
log.Printf("Error unmarshaling 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.Print(err) log.Fatal(err)
return
} }
err = os.WriteFile("teachers.json", data, 0o644) err = os.WriteFile("teachers.json", data, 0644)
if err != nil { if err != nil {
log.Print(err) log.Fatal(err)
return
} }
// log.Println("Updated Teachers")//dont need log anymore log.Println("Updated Teachers")
} }

View file

@ -152,7 +152,7 @@ func Timetable(cookies []*http.Cookie, userID string) {
return return
} }
// log.Printf("Updated timetable for user %s", userID) // dont need log anymore log.Printf("Updated timetable for user %s", userID)
setTimetable(userID) setTimetable(userID)
} }
@ -249,5 +249,5 @@ func setTimetable(userID string) {
log.Printf("Error writing timetableFilled file: %v", err) log.Printf("Error writing timetableFilled file: %v", err)
return return
} }
// log.Printf("Filled timetable for user %s", userID)//log not needed log.Printf("Filled timetable for user %s", userID)
} }

View file

@ -7,7 +7,6 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@ -33,6 +32,8 @@ type LoginResponse struct {
var Url = os.Getenv("URL") var Url = os.Getenv("URL")
// var Password = os.Getenv("UNTIS_PASSWORD")
// var USERS = os.Getenv("UNTIS_USER")
func Main(user, password, userID string) { func Main(user, password, userID string) {
godotenv.Load("../.env") godotenv.Load("../.env")
cookies, err := Auth(user, password, userID) cookies, err := Auth(user, password, userID)
@ -45,46 +46,26 @@ func Main(user, password, userID string) {
Subjects(cookies) Subjects(cookies)
Timetable(cookies, userID) Timetable(cookies, userID)
Teachers(cookies) Teachers(cookies)
log.Printf("updated everything for user: %s ", user)
}
func Update(user, password, userID string) {
godotenv.Load("../.env")
cookies, err := Auth(user, password, userID)
if err != nil {
log.Printf("Authentication failed for user %s: %v", user, err)
return
}
Timetable(cookies, userID)
log.Printf("Updated info for user: %s ", user)
} }
func Auth(user, password, userID string) ([]*http.Cookie, error) { func Auth(user, password, userID string) ([]*http.Cookie, error) {
loginJSON, _ := json.Marshal(Login{ l := Login{"2023-05-06 15:44:22.215292", "authenticate", Params{user, password, "WebUntis Test"}, "2.0"}
"2023-05-06 15:44:22.215292", "authenticate", loginJSON, err := json.Marshal(l)
Params{user, password, "WebUntis Test"}, if err != nil {
"2.0", return nil, err
}) }
login := bytes.NewReader(loginJSON)
if Url == "" { if Url == "" {
Url = os.Getenv("URL") Url = os.Getenv("URL")
} }
LoginOut, err := http.Post(Url, "application/json", login)
var LoginOut *http.Response
var err error
for attempt := 1; attempt <= 3; attempt++ {
LoginOut, err = http.Post(Url, "application/json", bytes.NewReader(loginJSON))
if err == nil {
break
}
log.Printf("Auth attempt %d/3 failed for %s: %v", attempt, user, err)
time.Sleep(time.Duration(attempt*2) * time.Second)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer LoginOut.Body.Close() defer LoginOut.Body.Close()
cookies := LoginOut.Cookies() cookies := LoginOut.Cookies()
// log.Printf("Login successful for user: %s", user)//log not needed log.Printf("Login successful for user: %s", user)
response, err := io.ReadAll(LoginOut.Body) response, err := io.ReadAll(LoginOut.Body)
if err != nil { if err != nil {

View file

@ -1,29 +1,9 @@
package Untis package Untis
import ( import "time"
"log"
"os"
"time"
"github.com/joho/godotenv"
)
var Location *time.Location var Location *time.Location
func init() {
godotenv.Load("../.env")
timezone := os.Getenv("LOCATION_ENV")
if timezone == "" {
timezone = "UTC"
}
loc, err := time.LoadLocation(timezone)
if err != nil {
log.Printf("Failed to load timezone, set as UTC")
loc = time.UTC
}
Location = loc
}
func GetTime() time.Time { func GetTime() time.Time {
return time.Now().In(Location) return time.Now().In(Location)
} }

View file

@ -4,4 +4,4 @@ UNTIS_USER=[Default user username]
DISCORD_BOT_TOKEN=[Paste Bot token] DISCORD_BOT_TOKEN=[Paste Bot token]
ENC_KEY=[Generate enc key and paste] ENC_KEY=[Generate enc key and paste]
URL=[Untis url, create from tutorial in readme] URL=[Untis url, create from tutorial in readme]
LOCATION_ENV=Europe/Berlin [change to your timezone, needs to be usable by go's time package via time.LoadLocation(LOCATION_ENV)] LOCATION_ENV=Europe/Berlin [change to your timezone, needs to be usable by go's time package via time.LoadLocation()]

2
go.mod
View file

@ -1,6 +1,6 @@
module untislogger module untislogger
go 1.27.0 go 1.25.2
require ( require (
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0

55
main.go
View file

@ -4,7 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -93,7 +93,7 @@ func init() {
} }
discordWebhookURL = os.Getenv("DISCORD_WEBHOOK_URL") discordWebhookURL = os.Getenv("DISCORD_WEBHOOK_URL")
log.Println("Initializing application...") log.Println("Initializing application...")
// log.Printf("DISCORD_WEBHOOK_URL: %q", discordWebhookURL) //Debug info, not needed at the moment log.Printf("DISCORD_WEBHOOK_URL: %q", discordWebhookURL)
// Check if Discord webhook is configured // Check if Discord webhook is configured
if discordWebhookURL != "" { if discordWebhookURL != "" {
@ -101,15 +101,33 @@ func init() {
} else { } else {
log.Println("No Discord webhook provided, Discord notifications will be disabled") log.Println("No Discord webhook provided, Discord notifications will be disabled")
} }
locEnv := os.Getenv("LOCATION_ENV")
var locerr error
Untis.Location, locerr = time.LoadLocation(locEnv)
if locerr != nil {
log.Fatalf("Failed to load Timezone: %v", locerr)
}
}
func getTime() time.Time {
return Untis.GetTime()
} }
func main() { func main() {
initUpdate() 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() go BotStart.Start()
scheduleTimetableUpdate() scheduleTimetableUpdate()
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 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 <-sigChan
log.Println("Shutting down...") log.Println("Shutting down...")
} }
@ -125,12 +143,6 @@ func isScheduledTime(now time.Time) bool {
return false return false
} }
func initUpdate() {
defaultUser := os.Getenv("UNTIS_USER")
defaultPassword := os.Getenv("UNTIS_PASSWORD")
Untis.Main(defaultUser, defaultPassword, "default")
}
func scheduleTimetableUpdate() { func scheduleTimetableUpdate() {
prevData := make(map[string][]byte) prevData := make(map[string][]byte)
@ -141,6 +153,7 @@ func scheduleTimetableUpdate() {
if err != nil { if err != nil {
log.Printf("Error loading accounts: %v", err) log.Printf("Error loading accounts: %v", err)
} }
// Add the default user from .env // Add the default user from .env
defaultUser := os.Getenv("UNTIS_USER") defaultUser := os.Getenv("UNTIS_USER")
defaultPassword := os.Getenv("UNTIS_PASSWORD") defaultPassword := os.Getenv("UNTIS_PASSWORD")
@ -154,8 +167,8 @@ func scheduleTimetableUpdate() {
// Process each user // Process each user
for _, acc := range accounts { for _, acc := range accounts {
// Fetch the latest timetable and only timetable // Fetch the latest timetable
Untis.Update(acc.Username, acc.DecryptedPassword, acc.UserID) Untis.Main(acc.Username, acc.DecryptedPassword, acc.UserID)
// Check for changes // Check for changes
timetableFile := fmt.Sprintf("timetableFilled_%s.json", acc.UserID) timetableFile := fmt.Sprintf("timetableFilled_%s.json", acc.UserID)
@ -191,10 +204,10 @@ func scheduleTimetableUpdate() {
prevTimeslotMap := make(map[string]NamedTimetableEntry) prevTimeslotMap := make(map[string]NamedTimetableEntry)
// Get current date to filter out new day changes // Get current date to filter out new day changes
today := Untis.GetTime().Format("02-01-2006") today := getTime().Format("02-01-2006")
// Get current time to filter any changes to Timetable before 3am so the new day changes stop // Get current time to filter any changes to Timetable before 3am so the new day changes stop
// //
now := Untis.GetTime() now := getTime()
// If before 1am stop and update userdata // If before 1am stop and update userdata
if now.Hour() < 1 { if now.Hour() < 1 {
prevData[acc.UserID] = data prevData[acc.UserID] = data
@ -298,10 +311,13 @@ func scheduleTimetableUpdate() {
sendUpdateDiscordWebhookWithDetails(changes) 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 // Always update prevData
prevData[acc.UserID] = data prevData[acc.UserID] = data
time.Sleep(3 * time.Second)
} }
} }
@ -318,7 +334,7 @@ func scheduleTimetableUpdate() {
// Ticker for scheduled lesson notifications // Ticker for scheduled lesson notifications
startMinuteTicker(func() { startMinuteTicker(func() {
now := Untis.GetTime() now := getTime()
if isScheduledTime(now) { if isScheduledTime(now) {
log.Println("Scheduled time reached, running notifications...") log.Println("Scheduled time reached, running notifications...")
accounts, _ := BotStart.LoadAndDecryptAccounts() accounts, _ := BotStart.LoadAndDecryptAccounts()
@ -330,11 +346,10 @@ func scheduleTimetableUpdate() {
log.Println("Finished running notifications.") log.Println("Finished running notifications.")
} }
}) })
log.Println("Updated all users")
} }
func startMinuteTicker(f func()) { func startMinuteTicker(f func()) {
now := Untis.GetTime() now := getTime()
next := now.Truncate(time.Minute).Add(time.Minute) next := now.Truncate(time.Minute).Add(time.Minute)
time.Sleep(time.Until(next)) time.Sleep(time.Until(next))
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
@ -363,7 +378,7 @@ func Run(userID string) {
roomByStartTime, _ := MapTimeToRoom(timetableFile) roomByStartTime, _ := MapTimeToRoom(timetableFile)
subjectByStartTime, _ := MapTimeToSubject(timetableFile) subjectByStartTime, _ := MapTimeToSubject(timetableFile)
now := Untis.GetTime().Format("15:04") now := getTime().Format("15:04")
nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now) nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if !foundRoom { if !foundRoom {
@ -612,7 +627,7 @@ func sendDiscordWebhook(subject string, room string, nextTime string, Status str
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Discord webhook notification sent successfully") log.Println("Discord webhook notification sent successfully")
} else { } else {
body, _ := io.ReadAll(resp.Body) body, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body)) log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
} }
} }
@ -643,7 +658,7 @@ func sendUpdateDiscordWebhookWithDetails(changes []string) {
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Detailed Discord webhook sent successfully") log.Println("Detailed Discord webhook sent successfully")
} else { } else {
body, _ := io.ReadAll(resp.Body) body, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body)) log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
} }
} }