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"
"sync"
"syscall"
"time"
"untislogger/Untis"
@ -224,6 +225,7 @@ 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)
}
}
@ -251,6 +253,14 @@ 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,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
## create .env with Credentials
@ -8,13 +10,12 @@
- 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
### 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]"
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>"
# 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
# Installation

View file

@ -32,57 +32,55 @@ 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.Printf("Error marshaling login data: %v", err)
log.Fatalf("Error marshaling login data: %v", err)
return
}
classes := bytes.NewReader(ClassesJson)
prompt, err := http.NewRequest("POST", Url, classes)
if err != nil {
log.Printf("Error creatingrequest: %v", err)
log.Fatalf("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.Printf("Error unmarshaling response: %v", err)
log.Fatalf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
err = os.WriteFile("classes.json", data, 0o644)
err = os.WriteFile("classes.json", data, 0644)
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
// log.Println("Updated Classes")//dont need log anymore
log.Println("Updated Classes")
}

View file

@ -31,63 +31,60 @@ 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.Printf("Error marshaling login data: %v", err)
log.Fatalf("Error marshaling login data: %v", err)
return
}
rooms := bytes.NewReader(roomsJson)
prompt, err := http.NewRequest("POST", Url, rooms)
if err != nil {
log.Printf("Error creatingrequest: %v", err)
log.Fatalf("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.Printf("Error unmarshaling response: %v", err)
return
log.Fatalf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
err = os.WriteFile("rooms.json", data, 0o644)
err = os.WriteFile("rooms.json", data, 0644)
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
// log.Println("Updated Rooms")//dont need log anymore
log.Println("Updated Rooms")
}

View file

@ -32,58 +32,55 @@ 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.Printf("Error marshaling login data: %v", err)
log.Fatalf("Error marshaling login data: %v", err)
return
}
subjects := bytes.NewReader(SubjectsJson)
prompt, err := http.NewRequest("POST", Url, subjects)
if err != nil {
log.Printf("Error creatingrequest: %v", err)
log.Fatalf("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.Printf("Error unmarshaling response: %v", err)
return
log.Fatalf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
err = os.WriteFile("subjects.json", data, 0o644)
err = os.WriteFile("subjects.json", data, 0644)
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
// log.Println("Updated Subjects")//dont need log anymore
log.Println("Updated Subjects")
}

View file

@ -32,58 +32,55 @@ 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.Printf("Error marshaling login data: %v", err)
log.Fatalf("Error marshaling login data: %v", err)
return
}
teachers := bytes.NewReader(TeachersJson)
prompt, err := http.NewRequest("POST", Url, teachers)
if err != nil {
log.Printf("Error creatingrequest: %v", err)
log.Fatalf("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.Printf("Error unmarshaling response: %v", err)
return
log.Fatalf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
err = os.WriteFile("teachers.json", data, 0o644)
err = os.WriteFile("teachers.json", data, 0644)
if err != nil {
log.Print(err)
return
log.Fatal(err)
}
// 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
}
// log.Printf("Updated timetable for user %s", userID) // dont need log anymore
log.Printf("Updated timetable for user %s", userID)
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 not needed
log.Printf("Filled timetable for user %s", userID)
}

View file

@ -7,7 +7,6 @@ import (
"log"
"net/http"
"os"
"time"
"github.com/joho/godotenv"
)
@ -33,6 +32,8 @@ 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)
@ -45,46 +46,26 @@ 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) {
loginJSON, _ := json.Marshal(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)
if err != nil {
return nil, err
}
login := bytes.NewReader(loginJSON)
if Url == "" {
Url = os.Getenv("URL")
}
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)
}
LoginOut, err := http.Post(Url, "application/json", login)
if err != nil {
return nil, err
}
defer LoginOut.Body.Close()
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)
if err != nil {

View file

@ -1,29 +1,9 @@
package Untis
import (
"log"
"os"
"time"
"github.com/joho/godotenv"
)
import "time"
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)]
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
go 1.27.0
go 1.25.2
require (
github.com/bwmarrin/discordgo v0.29.0

55
main.go
View file

@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"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) //Debug info, not needed at the moment
log.Printf("DISCORD_WEBHOOK_URL: %q", discordWebhookURL)
// Check if Discord webhook is configured
if discordWebhookURL != "" {
@ -101,15 +101,33 @@ 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() {
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()
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...")
}
@ -125,12 +143,6 @@ 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)
@ -141,6 +153,7 @@ 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")
@ -154,8 +167,8 @@ func scheduleTimetableUpdate() {
// Process each user
for _, acc := range accounts {
// Fetch the latest timetable and only timetable
Untis.Update(acc.Username, acc.DecryptedPassword, acc.UserID)
// Fetch the latest timetable
Untis.Main(acc.Username, acc.DecryptedPassword, acc.UserID)
// Check for changes
timetableFile := fmt.Sprintf("timetableFilled_%s.json", acc.UserID)
@ -191,10 +204,10 @@ func scheduleTimetableUpdate() {
prevTimeslotMap := make(map[string]NamedTimetableEntry)
// 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
//
now := Untis.GetTime()
now := getTime()
// If before 1am stop and update userdata
if now.Hour() < 1 {
prevData[acc.UserID] = data
@ -298,10 +311,13 @@ 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)
}
}
@ -318,7 +334,7 @@ func scheduleTimetableUpdate() {
// Ticker for scheduled lesson notifications
startMinuteTicker(func() {
now := Untis.GetTime()
now := getTime()
if isScheduledTime(now) {
log.Println("Scheduled time reached, running notifications...")
accounts, _ := BotStart.LoadAndDecryptAccounts()
@ -330,11 +346,10 @@ func scheduleTimetableUpdate() {
log.Println("Finished running notifications.")
}
})
log.Println("Updated all users")
}
func startMinuteTicker(f func()) {
now := Untis.GetTime()
now := getTime()
next := now.Truncate(time.Minute).Add(time.Minute)
time.Sleep(time.Until(next))
ticker := time.NewTicker(1 * time.Minute)
@ -363,7 +378,7 @@ func Run(userID string) {
roomByStartTime, _ := MapTimeToRoom(timetableFile)
subjectByStartTime, _ := MapTimeToSubject(timetableFile)
now := Untis.GetTime().Format("15:04")
now := getTime().Format("15:04")
nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if !foundRoom {
@ -612,7 +627,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, _ := io.ReadAll(resp.Body)
body, _ := ioutil.ReadAll(resp.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 {
log.Println("Detailed Discord webhook sent successfully")
} else {
body, _ := io.ReadAll(resp.Body)
body, _ := ioutil.ReadAll(resp.Body)
log.Printf("Discord webhook failed with status %d: %s", resp.StatusCode, string(body))
}
}