Compare commits

..

11 commits

Author SHA1 Message Date
Jannover5000r
8ed61f8b97 Update README and exampleENV 2026-08-25 23:50:58 +02:00
Jannover5000r
218916e831 fixed timeutil to set UTC zone correctly
UTC will actually be set new when nothing is inserted

moved initUpdate() to the top of main and finish as first order of operations
2026-08-25 23:40:18 +02:00
Jannover5000r
1f5f10f545 added initupdate as a go routine 2026-08-25 23:28:46 +02:00
Jannover5000r
d2a84a8435 Removed Fatalf
in order to prevent a huge amount of restarts i removed Fatalf and made it return normally

I also made GetTime load the timezone once and just return the correct value as well as default to utc

made the Auth function retry 3 times per account and send an error after that

put time.Sleep into the correct loop in main.go

initUpdate is in main now
2026-08-25 23:27:37 +02:00
Jannover5000r
1d57e8a867 made a lot of changes for the workflow of the bot
- Timezone handling got updated
- Logs have been deleted to avoid duplicates and clean up the terminal
- removed unused code
- fixed an issue where the updating time threw errors
- improvements to the updateting of timetable data, only using a single api call now
- improved init to intitialize all needed files (classrooms, subjects etc.)
2026-08-25 22:53:59 +02:00
Jannover5000r
ae5b1f7b28 Merge branch 'main' of https://github.com/Jannover5000r/Untis-Logger 2026-08-24 18:41:36 +02:00
Jannover5000r
fb503f6a9e added a few things to
ticker will now only update timetable not send all requests to avoid rate limiting

Untis.go Main() will only be used in initial startup

ticker cleanup in botstart since that isnt being used anymore
2026-08-24 18:37:25 +02:00
Jannover
0de661c88f
Merge pull request #15 from Jannover5000r/feature-UpdateAt2Am
fix 2
2026-08-24 01:10:14 +00:00
Jannover
5e247cc87c
Merge pull request #14 from Jannover5000r/feature-UpdateAt2Am
fix
2026-08-24 00:54:15 +00:00
Jannover
0444051dcf
Merge pull request #13 from Jannover5000r/feature-UpdateAt2Am
Feature update at2 am
2026-08-24 00:44:27 +00:00
Jannover
3fc436fe93
Update README.md 2026-08-22 23:59:47 +00:00
12 changed files with 148 additions and 124 deletions

View file

@ -15,7 +15,6 @@ import (
"path/filepath"
"sync"
"syscall"
"time"
"untislogger/Untis"
@ -225,7 +224,6 @@ func checkAllUsersTimetables(s *discordgo.Session) {
accounts := loadAllAccounts()
for _, user := range accounts {
decPwd, _ := decrypt(user.Password)
// You can use decPwd to fetch the timetable for this user
checkTimetableChangesForUser(user, decPwd, s)
}
}
@ -253,14 +251,6 @@ func Start() {
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.")
// Wait for CTRL+C or other term signal to exit.
sc := make(chan os.Signal, 1)

View file

@ -1,5 +1,3 @@
# 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
## create .env with Credentials
@ -10,12 +8,13 @@
- DISCORD_BOT_TOKEN
- ENC_KEY (generated via head -c 32 /dev/urandom | base64)
- URL
- TIMEZONE (will default to UTC if empty)
### 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>"
### URL can be found by following this structure
# This is a project for me, issues will be resolved as i find the motivation to do so, improvements may follow in the future
## 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
"<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>"
# Installation

View file

@ -32,55 +32,57 @@ func Classes(cookies []*http.Cookie) {
g := getClasses{"2023-05-06 15:44:22.215292", "getKlassen", map[string]interface{}{}, "2.0"}
ClassesJson, err := json.Marshal(g)
if err != nil {
log.Fatalf("Error marshaling login data: %v", err)
log.Printf("Error marshaling login data: %v", err)
return
}
classes := bytes.NewReader(ClassesJson)
prompt, err := http.NewRequest("POST", Url, classes)
if err != nil {
log.Fatalf("Error creatingrequest: %v", err)
log.Printf("Error creatingrequest: %v", err)
return
}
//log.Println("prompt without extra header or cookie ", prompt)
//log.Println("Cookie: ", cookies)
// 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")
for _, cookie := range cookies {
//if cookie.Name == "JSESSIONID" {
// if cookie.Name == "JSESSIONID" {
prompt.AddCookie(cookie)
//log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value)
//}
}
//log.Println("Request JSON:", string(ClassesJson))
// log.Println("Request JSON:", string(ClassesJson))
out, err := http.DefaultClient.Do(prompt)
if err != nil {
log.Printf("Error during request: %v", err)
return
}
defer out.Body.Close()
//log.Println(out.Status)
// log.Println(out.Status)
response, err := io.ReadAll(out.Body)
if err != nil {
log.Printf("Error reading response body: %v", err)
return
}
//responseString := string(response)
//log.Println("Repsonse ", responseString)
// responseString := string(response)
// log.Println("Repsonse ", responseString)
var Response ClassesResponse
err = json.Unmarshal(response, &Response)
if err != nil {
log.Fatalf("Error unmarshaling response: %v", err)
log.Printf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
err = os.WriteFile("classes.json", data, 0644)
err = os.WriteFile("classes.json", data, 0o644)
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
log.Println("Updated Classes")
// log.Println("Updated Classes")//dont need log anymore
}

View file

@ -31,60 +31,63 @@ type Room struct {
}
func Rooms(cookies []*http.Cookie) {
//log.Println("Abrufen der Stunden")
// log.Println("Abrufen der Stunden")
g := getRooms{"2023-05-06 15:44:22.215292", "getRooms", map[string]interface{}{}, "2.0"}
roomsJson, err := json.Marshal(g)
if err != nil {
log.Fatalf("Error marshaling login data: %v", err)
log.Printf("Error marshaling login data: %v", err)
return
}
rooms := bytes.NewReader(roomsJson)
prompt, err := http.NewRequest("POST", Url, rooms)
if err != nil {
log.Fatalf("Error creatingrequest: %v", err)
log.Printf("Error creatingrequest: %v", err)
return
}
//log.Println("prompt without extra header or cookie ", prompt)
//log.Println("Cookie: ", cookies)
// 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")
for _, cookie := range cookies {
//if cookie.Name == "JSESSIONID" {
// if cookie.Name == "JSESSIONID" {
prompt.AddCookie(cookie)
// log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value)
//}
}
//log.Println("Request JSON:", string(roomsJson))
// log.Println("Request JSON:", string(roomsJson))
out, err := http.DefaultClient.Do(prompt)
if err != nil {
log.Printf("Error during request: %v", err)
return
}
defer out.Body.Close()
//log.Println(out.Status)
// log.Println(out.Status)
response, err := io.ReadAll(out.Body)
if err != nil {
log.Printf("Error reading response body: %v", err)
return
}
//responseString := string(response)
//log.Println("Repsonse ", responseString)
// responseString := string(response)
// log.Println("Repsonse ", responseString)
var Response RoomsResponse
err = json.Unmarshal(response, &Response)
if err != nil {
log.Fatalf("Error unmarshaling response: %v", err)
log.Printf("Error unmarshaling response: %v", err)
return
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
err = os.WriteFile("rooms.json", data, 0644)
err = os.WriteFile("rooms.json", data, 0o644)
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
log.Println("Updated Rooms")
// log.Println("Updated Rooms")//dont need log anymore
}

View file

@ -32,55 +32,58 @@ func Subjects(cookies []*http.Cookie) {
g := getSubjects{"2023-05-06 15:44:22.215292", "getSubjects", map[string]interface{}{}, "2.0"}
SubjectsJson, err := json.Marshal(g)
if err != nil {
log.Fatalf("Error marshaling login data: %v", err)
log.Printf("Error marshaling login data: %v", err)
return
}
subjects := bytes.NewReader(SubjectsJson)
prompt, err := http.NewRequest("POST", Url, subjects)
if err != nil {
log.Fatalf("Error creatingrequest: %v", err)
log.Printf("Error creatingrequest: %v", err)
return
}
//log.Println("prompt without extra header or cookie ", prompt)
//log.Println("Cookie: ", cookies)
// 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")
for _, cookie := range cookies {
//if cookie.Name == "JSESSIONID" {
// if cookie.Name == "JSESSIONID" {
prompt.AddCookie(cookie)
//log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value)
//}
}
//log.Println("Request JSON:", string(SubjectsJson))
// log.Println("Request JSON:", string(SubjectsJson))
out, err := http.DefaultClient.Do(prompt)
if err != nil {
log.Printf("Error during request: %v", err)
return
}
defer out.Body.Close()
//log.Println(out.Status)
// log.Println(out.Status)
response, err := io.ReadAll(out.Body)
if err != nil {
log.Printf("Error reading response body: %v", err)
return
}
//responseString := string(response)
//log.Println("Repsonse ", responseString)
// responseString := string(response)
// log.Println("Repsonse ", responseString)
var Response SubjectsResponse
err = json.Unmarshal(response, &Response)
if err != nil {
log.Fatalf("Error unmarshaling response: %v", err)
log.Printf("Error unmarshaling response: %v", err)
return
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
err = os.WriteFile("subjects.json", data, 0644)
err = os.WriteFile("subjects.json", data, 0o644)
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
log.Println("Updated Subjects")
// log.Println("Updated Subjects")//dont need log anymore
}

View file

@ -32,55 +32,58 @@ func Teachers(cookies []*http.Cookie) {
g := getTeachers{"2023-05-06 15:44:22.215292", "getTeachers", map[string]interface{}{}, "2.0"}
TeachersJson, err := json.Marshal(g)
if err != nil {
log.Fatalf("Error marshaling login data: %v", err)
log.Printf("Error marshaling login data: %v", err)
return
}
teachers := bytes.NewReader(TeachersJson)
prompt, err := http.NewRequest("POST", Url, teachers)
if err != nil {
log.Fatalf("Error creatingrequest: %v", err)
log.Printf("Error creatingrequest: %v", err)
return
}
//log.Println("prompt without extra header or cookie ", prompt)
//log.Println("Cookie: ", cookies)
// 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")
for _, cookie := range cookies {
//if cookie.Name == "JSESSIONID" {
// if cookie.Name == "JSESSIONID" {
prompt.AddCookie(cookie)
//log.Printf("Added JSESSIONID cookie: %s=%s", cookie.Name, cookie.Value)
//}
}
//log.Println("Request JSON:", string(TeachersJson))
// log.Println("Request JSON:", string(TeachersJson))
out, err := http.DefaultClient.Do(prompt)
if err != nil {
log.Printf("Error during request: %v", err)
return
}
defer out.Body.Close()
//log.Println(out.Status)
// log.Println(out.Status)
response, err := io.ReadAll(out.Body)
if err != nil {
log.Printf("Error reading response body: %v", err)
return
}
//responseString := string(response)
//log.Println("Repsonse ", responseString)
// responseString := string(response)
// log.Println("Repsonse ", responseString)
var Response TeachersResponse
err = json.Unmarshal(response, &Response)
if err != nil {
log.Fatalf("Error unmarshaling response: %v", err)
log.Printf("Error unmarshaling response: %v", err)
return
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
err = os.WriteFile("teachers.json", data, 0644)
err = os.WriteFile("teachers.json", data, 0o644)
if err != nil {
log.Fatal(err)
log.Print(err)
return
}
log.Println("Updated Teachers")
// log.Println("Updated Teachers")//dont need log anymore
}

View file

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

View file

@ -7,6 +7,7 @@ import (
"log"
"net/http"
"os"
"time"
"github.com/joho/godotenv"
)
@ -32,8 +33,6 @@ type LoginResponse struct {
var Url = os.Getenv("URL")
// var Password = os.Getenv("UNTIS_PASSWORD")
// var USERS = os.Getenv("UNTIS_USER")
func Main(user, password, userID string) {
godotenv.Load("../.env")
cookies, err := Auth(user, password, userID)
@ -46,26 +45,46 @@ func Main(user, password, userID string) {
Subjects(cookies)
Timetable(cookies, userID)
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) {
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 {
return nil, err
}
login := bytes.NewReader(loginJSON)
loginJSON, _ := json.Marshal(Login{
"2023-05-06 15:44:22.215292", "authenticate",
Params{user, password, "WebUntis Test"},
"2.0",
})
if 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 {
return nil, err
}
defer LoginOut.Body.Close()
cookies := LoginOut.Cookies()
log.Printf("Login successful for user: %s", user)
// log.Printf("Login successful for user: %s", user)//log not needed
response, err := io.ReadAll(LoginOut.Body)
if err != nil {

View file

@ -1,9 +1,29 @@
package Untis
import "time"
import (
"log"
"os"
"time"
"github.com/joho/godotenv"
)
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 {
return time.Now().In(Location)
}

View file

@ -4,4 +4,4 @@ UNTIS_USER=[Default user username]
DISCORD_BOT_TOKEN=[Paste Bot token]
ENC_KEY=[Generate enc key and paste]
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=Europe/Berlin [change to your timezone, needs to be usable by go's time package via time.LoadLocation(LOCATION_ENV)]

2
go.mod
View file

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

55
main.go
View file

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