Compare commits

..

61 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
Jannover5000r
5f8f21732c fix 2 2026-08-24 03:09:29 +02:00
Jannover
5e247cc87c
Merge pull request #14 from Jannover5000r/feature-UpdateAt2Am
fix
2026-08-24 00:54:15 +00:00
Jannover5000r
6cfe550bed fix 2026-08-24 02:53:25 +02:00
Jannover
0444051dcf
Merge pull request #13 from Jannover5000r/feature-UpdateAt2Am
Feature update at2 am
2026-08-24 00:44:27 +00:00
Jannover5000r
7f3b8b2672 added the file since i forgot 2026-08-24 02:43:07 +02:00
Jannover5000r
ffd83811ab made update stop sending before 1am
since i fixed the update time i can make the deadline 1am
2026-08-24 02:39:26 +02:00
Jannover5000r
9357f7381b Fixed timezone management
timezones now get loaded by a function in /Untis
Should make fetching happen at 0 am
2026-08-24 02:36:41 +02:00
Jannover
3fc436fe93
Update README.md 2026-08-22 23:59:47 +00:00
Jannover
bf0b89e024
Merge pull request #12 from Jannover5000r/feature-newDayUpdate
added a new if clause to check if the time is past or before 3am before sending the class update message
2026-08-22 23:41:54 +00:00
Jannover5000r
2659237b3f added a new if clause to check if the time is past or before 3am before
sending the class update message

This will hopefully remove the spam everyday
2026-08-23 01:39:17 +02:00
Jannover
6051141d91
Merge branch 'main' into temp-timezoneFix 2026-08-22 22:01:19 +00:00
Jannover5000r
18f953e444 modified README 2026-08-22 23:52:44 +02:00
Jannover5000r
a62e91b30b added timezonemanagment to timezone fix 2026-04-13 07:58:48 +00:00
Jannover5000r
17baaaf087 timezone database added 2026-03-31 03:36:19 +02:00
Jannover5000r
8d243869ea added timezone managment 2026-03-30 05:24:33 +02:00
Jannover5000r
3bf0158260 WebHook command now also toggles Lesson update message 2026-01-28 09:47:06 +00:00
Jannover5000r
110e4a5104 manual timetable update with !update 2026-01-20 09:25:43 +01:00
Jannover5000r
1b47e7ac04
Fix URL structure in README for clarity 2025-12-05 08:54:59 +00:00
Jannover5000r
b3628700ae new 2025-11-19 13:03:05 +01:00
Jannover5000r
c9a5dd2ff5 fixed the account creation 2025-11-14 09:10:35 +01:00
Jannover5000r
74297067d8 Accounts.json will now be saved in the accounts directory 2025-11-14 08:44:40 +01:00
Jannover5000r
c29389e8f2 new changes to notification system 2025-11-13 10:13:54 +01:00
Jannover5000r
6c07bd9d13 delete unused 2025-11-11 09:46:15 +01:00
Jannover5000r
f05157916d removed git error 2025-11-11 09:12:04 +01:00
Jannover5000r
b45ac423b0 detailed changed lessons message 2025-11-11 09:08:10 +01:00
Jannover5000r
a718e700c3
main.go aktualisieren 2025-11-03 14:34:34 +01:00
Jannover5000r
3ba05a64ce
main.go aktualisieren 2025-11-03 14:27:42 +01:00
Jannover5000r
b2833ad503
main.go aktualisieren 2025-11-03 14:27:05 +01:00
Jannover5000r
3d64a4947d changed readme 2025-11-03 03:12:10 +01:00
Jannover5000r
c3f73b99d5 removed unnecessary else bracket 2025-11-03 03:05:33 +01:00
Jannover5000r
4d04da7f78 !WebHook works now 2025-10-31 10:16:47 +01:00
Jannover5000r
7646684136 changed message and added toggle for default message 2025-10-30 20:00:58 +01:00
Jannover5000r
f3636c15c9 changed -1 to +1 2025-10-29 14:22:04 +01:00
Jannover5000r
39de95e3ce changes to readme 2025-10-29 13:02:23 +01:00
Jannover5000r
68d358e1e4 timezone adjustment 2025-10-29 12:56:03 +01:00
Jannover5000r
0613d88b28 times for notification changed 2025-10-29 08:46:19 +01:00
Jannover5000r
4a7028e332 new README 2025-10-29 03:04:02 +01:00
Jannover5000r
752aea4b0f new new new, works here 2025-10-28 10:25:26 +01:00
Jannover5000r
909783373c works? 2025-10-28 08:42:22 +01:00
google-labs-jules[bot]
8b42761cf0 feat: Implement timetable fetching in checkTimetableChangesForUser
This commit resolves the TODO in the `checkTimetableChangesForUser` function in `Botrun/BotStart.go`.

The new implementation fetches the user's timetable by calling the `Untis.Auth` and `Untis.Timetable` functions. It then compares the new timetable with the previous one and sends a notification if there are any changes.

As part of this change, the `Bot` directory was renamed to `Untis` to follow Go conventions, and the import paths were updated in `Botrun/BotStart.go` and `main.go`. The `.gitignore` file was also updated to ignore the build artifact.
2025-10-20 02:17:00 +00:00
Jannover5000r
b76b2c72f1 cahnges to gitignore 2025-10-20 03:45:14 +02:00
Jannover5000r
6cbf1153c2 Stop tracking login_*.json 2025-10-20 03:44:25 +02:00
Jannover5000r
f0c860dde8 changes to .gitignore 2025-10-20 03:05:49 +02:00
Jannover5000r
70b2961cee Merge branch 'main' of https://github.com/Jannover5000r/Untis-Logger 2025-10-11 02:27:04 +02:00
Jannover5000r
c7f84799b1 updated readme 2025-10-11 02:26:14 +02:00
Jannover5000r
ad1aee05d2
Merge pull request #2 from Jannover5000r/feature/multi-user-support
changes to gomod and gosum
2025-10-11 02:00:05 +02:00
Jannover5000r
bc22180e10 changes to gomod and gosum 2025-10-11 01:57:08 +02:00
Jannover5000r
8cd7637f33
Merge pull request #1 from Jannover5000r/feature/multi-user-support
feat: Implement multi-user timetable tracking and notifications
2025-10-10 23:00:28 +02:00
Jannover5000r
f91674daa1 new readme 2025-10-10 13:00:13 +02:00
Jannover5000r
2c0c1b289b changed readme and gitignore to updated features
-added deleteaccount command with alias removeaccount
2025-10-10 12:53:43 +02:00
google-labs-jules[bot]
6c33d1a410 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.
2025-10-10 10:10:15 +00:00
Jannover5000r
65d88f8c27 fixed .env loading 2025-08-26 01:47:23 +02:00
Jannover5000r
87887832c8 updated room discord message 2025-08-19 08:54:31 +02:00
17 changed files with 908 additions and 419 deletions

7
.exampleEnv Normal file
View file

@ -0,0 +1,7 @@
DISCORD_WEBHOOK_URL=[Paste Webhook]
UNTIS_PASSWORD=[Default user passwd]
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()]

8
.gitignore vendored
View file

@ -6,4 +6,10 @@ teachers.json
login.json
timetable*.json
timetableFilled*.json
accounts.json
accounts/
login_*.json
AGENTS.md
untislogger
untislogger*
CHANGES_SUMMARY.md

View file

@ -1,104 +0,0 @@
package Untis
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"github.com/joho/godotenv"
)
type Params struct {
Users string `json:"user"`
Password string `json:"password"`
Client string `json:"client"`
}
type Login struct {
Id string `json:"id"`
Method string `json:"method"`
Params Params `json:"params"`
Jsonrpc string `json:"jsonrpc"`
}
type LoginResponse struct {
Jsonrpc string `json:"jsonrpc"`
ID string `json:"id"`
Result Loginresult `json:"result"`
}
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) {
godotenv.Load("../.env")
cookies, err := Auth(user, password)
if err != nil {
log.Fatal(err)
return
}
Rooms(cookies)
Classes(cookies)
Subjects(cookies)
Timetable(cookies)
//getTeachers sends empty response
Teachers(cookies)
}
func Auth(user, password 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.Println("Login successful")
//log.Println("Login successful")
response, err := io.ReadAll(LoginOut.Body)
if err != nil {
log.Fatalf("Error reading response body: %v", err)
}
var Response LoginResponse
err = json.Unmarshal(response, &Response)
if err != nil {
log.Fatalf("Error unmarshaling response: %v", err)
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
log.Fatal(err)
}
err = os.WriteFile("login.json", data, 0644)
if err != nil {
log.Fatal(err)
}
return cookies, nil
}

View file

@ -12,9 +12,11 @@ import (
"io"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"untislogger/Untis"
"github.com/joho/godotenv"
@ -37,13 +39,15 @@ type UserState struct {
var (
userStates = make(map[string]*UserState) // userID -> state
stateMutex sync.Mutex // protect userStates
accountsFile = "accounts.json"
accountsFile = "accounts/accounts.json"
)
var encryptionKey []byte
var WebHook = true
func init() {
godotenv.Load("/home/Jannik/Documents/Code/Go/Untis/Untis-Logger/.env")
godotenv.Load(".env")
keyStr := os.Getenv("ENC_KEY")
if keyStr == "" {
panic("ENC_KEY environment variable not set")
@ -104,6 +108,12 @@ func decrypt(encrypted string) (string, error) {
func saveAccount(userID, username, password string) error {
var accounts []Account
// Ensure the accounts directory exists
accountsDir := filepath.Dir(accountsFile)
if err := os.MkdirAll(accountsDir, 0o755); err != nil {
return fmt.Errorf("failed to create accounts directory: %v", err)
}
// Load existing accounts if file exists
if data, err := os.ReadFile(accountsFile); err == nil && len(data) > 0 {
_ = json.Unmarshal(data, &accounts)
@ -134,7 +144,21 @@ func saveAccount(userID, username, password string) error {
if err != nil {
return err
}
return os.WriteFile(accountsFile, newData, 0644)
if err := os.WriteFile(accountsFile, newData, 0o644); err != nil {
return err
}
// Ensure timetable files exist for this user
timetableFile := getTimetableFile(userID)
timetableFilledFile := getTimetableFilledFile(userID)
if _, err := os.Stat(timetableFile); os.IsNotExist(err) {
os.WriteFile(timetableFile, []byte("[]"), 0o644)
}
if _, err := os.Stat(timetableFilledFile); os.IsNotExist(err) {
os.WriteFile(timetableFilledFile, []byte("[]"), 0o644)
}
return nil
}
// Helper functions for per-user timetable files
@ -164,14 +188,25 @@ func checkTimetableChangesForUser(user Account, decPwd string, s *discordgo.Sess
// Load previous timetable
prevData, _ := os.ReadFile(timetableFilledFile)
// TODO: Fetch new timetable for this user using user.Username and decrypted password
// For demonstration, we'll just copy prevData (replace this with real fetch logic!)
newData := prevData
// Fetch new timetable for this user
cookies, err := Untis.Auth(user.Username, decPwd, user.UserID)
if err != nil {
fmt.Printf("Error authenticating user %s: %v\n", user.Username, err)
return
}
Untis.Timetable(cookies, user.UserID)
// Load the newly fetched timetable data
newData, err := os.ReadFile(timetableFilledFile)
if err != nil {
fmt.Printf("Error reading new timetable for user %s: %v\n", user.Username, err)
return
}
// Compare and notify if changed
if !bytes.Equal(newData, prevData) {
sendLessonNotification(s, user.UserID, user.Username, "Your timetable has changed!")
os.WriteFile(timetableFilledFile, newData, 0644)
// The main.go now handles detailed change notifications
// This function is kept for compatibility but no longer sends generic messages
}
}
@ -189,11 +224,12 @@ 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)
}
}
var DiscordSession *discordgo.Session
func Start() {
token := os.Getenv("DISCORD_BOT_TOKEN")
if token == "" {
@ -206,6 +242,7 @@ func Start() {
fmt.Println("error creating Discord session,", err)
return
}
DiscordSession = dg // Save session for use elsewhere
dg.AddHandler(messageCreate)
err = dg.Open()
@ -214,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)
@ -231,14 +260,130 @@ 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 {
checkAllUsersTimetables(DiscordSession)
}
}
// 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, 0o644); 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) {
// Ignore bot's own messages
if m.Author.ID == s.State.User.ID {
return
}
if m.GuildID != "" && m.Content == "!WebHook" {
_ = s.ChannelMessageDelete(m.ChannelID, m.ID)
WebHook = !WebHook
if WebHook {
s.ChannelMessageSend(m.ChannelID, "Sending Webhook set to True")
} else {
s.ChannelMessageSend(m.ChannelID, "Sending Webhook set to False")
}
return
}
// Handle "!addaccount" only in guilds (not in DMs)
if m.GuildID != "" && m.Content == "!addaccount" {
if m.Content == "!addaccount" {
// Delete the command for privacy
_ = s.ChannelMessageDelete(m.ChannelID, m.ID)
// Create DM channel
@ -255,6 +400,35 @@ func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
return
}
if m.Content == "!update" {
_ = s.ChannelMessageDelete(m.ChannelID, m.ID)
checkAllUsersTimetables(DiscordSession)
s.ChannelMessageSend(m.ChannelID, "Updated all Timetables manually")
}
// 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
if m.GuildID == "" {
stateMutex.Lock()

View file

@ -1,18 +1,31 @@
# 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
- UNTIS_PASSWORD
- UNTIS_USER
- DISCORD_WEBHOOK_URL
- DISCORD_BOT_TOKEN
- ENC_KEY (generated via head -c 32 /dev/urandom | base64)
- URL
- TIMEZONE (will default to UTC if empty)
# 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.
### 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>"
# 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](https://discord.com/developers/applications) 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 also recommend to use a tool like [coolify](https://coolify.io/) to host the bot. Make sure to add the Env's and add the **accounts/** folder to persistent storage
# 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 just untis username). 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 and the password, created with your discord account
## For your timezone setup an ENV called "LOCATION_ENV" with your timezone as explained in the example env file. 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

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"}
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)
@ -72,15 +72,17 @@ func Classes(cookies []*http.Cookie) {
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

@ -35,14 +35,14 @@ func Rooms(cookies []*http.Cookie) {
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)
@ -75,16 +75,19 @@ func Rooms(cookies []*http.Cookie) {
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,14 +32,14 @@ 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)
@ -72,15 +72,18 @@ func Subjects(cookies []*http.Cookie) {
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,14 +32,14 @@ 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)
@ -72,15 +72,18 @@ func Teachers(cookies []*http.Cookie) {
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

@ -8,7 +8,6 @@ import (
"log"
"net/http"
"os"
"time"
)
type TimetableResponse struct {
@ -88,67 +87,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"
}
today := time.Now().Format("20060102")
loginResult, err := ReadLoginResultFromFile(loginFile)
if err != nil {
log.Printf("Could not read login result for user %s: %v", userID, err)
return
}
today := GetTime().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, 0o644); err != nil {
log.Printf("Error writing timetable file: %v", err)
return
}
// log.Printf("Updated timetable for user %s", userID) // dont need log anymore
setTimetable(userID)
}
func LoadIDMap(path string) (map[int]string, error) {
@ -166,6 +171,7 @@ func LoadIDMap(path string) (map[int]string, error) {
}
return m, nil
}
func LoadTimetable(path string) ([]TimetableEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -177,11 +183,13 @@ func LoadTimetable(path string) ([]TimetableEntry, error) {
}
return entries, nil
}
func formatTime(t int) string {
h := t / 100
m := t % 100
return fmt.Sprintf("%02d:%02d", h, m)
}
func formatDate(date int) string {
s := fmt.Sprintf("%08d", date) // ensures leading zeros
year := s[:4]
@ -189,14 +197,19 @@ 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 +237,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, 0o644); err != nil {
log.Printf("Error writing timetableFilled file: %v", err)
return
}
// log.Printf("Filled timetable for user %s", userID)//log not needed
}

113
Untis/Untis.go Normal file
View file

@ -0,0 +1,113 @@
package Untis
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
"github.com/joho/godotenv"
)
type Params struct {
Users string `json:"user"`
Password string `json:"password"`
Client string `json:"client"`
}
type Login struct {
Id string `json:"id"`
Method string `json:"method"`
Params Params `json:"params"`
Jsonrpc string `json:"jsonrpc"`
}
type LoginResponse struct {
Jsonrpc string `json:"jsonrpc"`
ID string `json:"id"`
Result Loginresult `json:"result"`
}
var Url = os.Getenv("URL")
func Main(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
}
Rooms(cookies)
Classes(cookies)
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",
})
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)
}
if err != nil {
return nil, err
}
defer LoginOut.Body.Close()
cookies := LoginOut.Cookies()
// log.Printf("Login successful for user: %s", user)//log not needed
response, err := io.ReadAll(LoginOut.Body)
if err != nil {
return nil, err
}
var Response LoginResponse
if err := json.Unmarshal(response, &Response); err != nil {
return nil, err
}
data, err := json.MarshalIndent(Response.Result, "", " ")
if err != nil {
return nil, err
}
loginFile := "login.json"
if userID != "" {
loginFile = "login_" + userID + ".json"
}
if err := os.WriteFile(loginFile, data, 0o644); err != nil {
return nil, err
}
return cookies, nil
}

29
Untis/timeutil.go Normal file
View file

@ -0,0 +1,29 @@
package Untis
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)
}

7
exampleEnv Normal file
View file

@ -0,0 +1,7 @@
DISCORD_WEBHOOK_URL=[Paste Webhook]
UNTIS_PASSWORD=[Default user passwd]
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)]

8
go.mod
View file

@ -1,6 +1,6 @@
module untislogger
go 1.24
go 1.27.0
require (
github.com/bwmarrin/discordgo v0.29.0
@ -8,7 +8,7 @@ require (
)
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.37.0 // indirect
)

9
go.sum
View file

@ -1,14 +1,17 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

517
main.go
View file

@ -4,15 +4,18 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"log"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
Untis "untislogger/Bot"
_ "time/tzdata"
Untis "untislogger/Untis"
BotStart "untislogger/Botrun"
@ -32,15 +35,65 @@ type NamedTimetableEntry struct {
ActivityType string `json:"activityType"`
}
var location *time.Location
// lessonKey creates a stable key for a lesson (date + time + subject)
func lessonKey(e NamedTimetableEntry) string {
subject := "unknown"
if len(e.Su) > 0 {
subject = e.Su[0]
}
// Include date for multi-day accuracy (e.g., Monday vs Tuesday)
return e.Date + "|" + e.StartTime + "|" + subject
}
// getSubject returns the first subject or "Free period"
func getSubject(e NamedTimetableEntry) string {
if len(e.Su) == 0 {
return "Free period"
}
return e.Su[0]
}
// formatRoom returns a readable room string
func formatRoom(rooms []string) string {
if len(rooms) == 0 {
return "no room"
}
return strings.Join(rooms, ", ")
}
// formatStatus returns a human-readable status
func formatStatus(code string) string {
if code == "" {
return "regular"
}
return code
}
// slicesEqual checks if two string slices are equal (order-sensitive)
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// init and main//
func init() {
godotenv.Load(".env")
err := godotenv.Load()
if err != nil {
log.Printf("Error loading .env file: %v", err)
}
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 != "" {
@ -51,24 +104,18 @@ func init() {
}
func main() {
//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...")
}
func isScheduledTime(now time.Time) bool {
scheduled := []string{"07:45", "08:35", "09:35", "10:25", "11:25", "12:15", "13:45", "14:25"}
scheduled := []string{"07:45", "08:35", "09:35", "10:25", "11:25", "12:15", "13:40", "14:25", "15:20", "16:00"}
current := now.Format("15:04")
for _, t := range scheduled {
if t == current {
@ -77,50 +124,217 @@ 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() {
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)
// Ticker for checking timetable changes every hour
hourTicker := time.NewTicker(1 * time.Minute)
go func() {
for range hourTicker.C {
Untis.Main(user, password)
data, err := os.ReadFile("timetableFilled.json")
// Function to update all users
updateAllUsers := func() {
// Load all registered users
accounts, err := BotStart.LoadAndDecryptAccounts()
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
}
}
}()
// Ticker for checking scheduled times every minute
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.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 and only timetable
Untis.Update(acc.Username, acc.DecryptedPassword, acc.UserID)
// Check for changes
timetableFile := fmt.Sprintf("timetableFilled_%s.json", acc.UserID)
if acc.UserID == "default" {
timetableFile = "timetableFilled_default.json"
}
data, err := os.ReadFile(timetableFile)
if err != nil {
log.Printf("Error reading timetable for user %s: %v", acc.Username, err)
continue
}
// Parse current and previous timetable data
var currentEntries, prevEntries []NamedTimetableEntry
if err := json.Unmarshal(data, &currentEntries); err != nil {
log.Printf("Failed to parse new timetable for user %s: %v", acc.Username, err)
prevData[acc.UserID] = data // still update to avoid repeated errors
continue
}
if prev, ok := prevData[acc.UserID]; ok {
if err := json.Unmarshal(prev, &prevEntries); err != nil {
log.Printf("Failed to parse previous timetable for user %s: %v", acc.Username, err)
prevData[acc.UserID] = data
continue
}
// Build maps keyed by a stable lesson identifier
currMap := make(map[string]NamedTimetableEntry)
prevMap := make(map[string]NamedTimetableEntry)
// Also build timeslot-based maps for better exchange detection
currTimeslotMap := make(map[string]NamedTimetableEntry)
prevTimeslotMap := make(map[string]NamedTimetableEntry)
// Get current date to filter out new day changes
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 := Untis.GetTime()
// If before 1am stop and update userdata
if now.Hour() < 1 {
prevData[acc.UserID] = data
continue
}
for _, e := range currentEntries {
key := lessonKey(e)
currMap[key] = e
}
for _, e := range prevEntries {
key := lessonKey(e)
prevMap[key] = e
}
var changes []string
// Check for modified or new lessons
for key, curr := range currMap {
// Skip lessons that are not for today to avoid new day spam
if curr.Date != today {
continue
}
if prev, exists := prevMap[key]; exists {
// Existing lesson — check for changes
subject := getSubject(curr)
// Room change?
if !slicesEqual(prev.Ro, curr.Ro) {
oldRoom := formatRoom(prev.Ro)
newRoom := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("Room changed for %s: %s → %s", subject, oldRoom, newRoom))
}
for _, e := range currentEntries {
key := lessonKey(e)
currMap[key] = e
// Create timeslot key (date + startTime + endTime)
timeslotKey := e.Date + "|" + e.StartTime + "|" + e.EndTime
currTimeslotMap[timeslotKey] = e
}
for _, e := range prevEntries {
key := lessonKey(e)
prevMap[key] = e
// Create timeslot key (date + startTime + endTime)
timeslotKey := e.Date + "|" + e.StartTime + "|" + e.EndTime
prevTimeslotMap[timeslotKey] = e
}
// Code/status change?
if prev.Code != curr.Code {
oldStatus := formatStatus(prev.Code)
newStatus := formatStatus(curr.Code)
changes = append(changes, fmt.Sprintf("Status changed for %s: %s → %s", subject, oldStatus, newStatus))
}
} else {
// New lesson - but check if it's actually an exchange in the same timeslot
timeslotKey := curr.Date + "|" + curr.StartTime + "|" + curr.EndTime
if prevInTimeslot, exists := prevTimeslotMap[timeslotKey]; exists {
// This is an exchange: old lesson was removed, new one added in same timeslot
oldSubject := getSubject(prevInTimeslot)
newSubject := getSubject(curr)
room := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("Lesson exchanged: %s → %s in %s", oldSubject, newSubject, room))
} else {
// Truly new lesson
subject := getSubject(curr)
room := formatRoom(curr.Ro)
changes = append(changes, fmt.Sprintf("New lesson: %s in %s", subject, room))
}
}
}
// Check for removed lessons
for key, prev := range prevMap {
// Skip lessons that were not for today
if prev.Date != today {
continue
}
if _, exists := currMap[key]; !exists {
subject := getSubject(prev)
// Check if this removal is part of an exchange (already handled above)
timeslotKey := prev.Date + "|" + prev.StartTime + "|" + prev.EndTime
if _, newExists := currTimeslotMap[timeslotKey]; !newExists {
// Only report as removed if no new lesson took its place
changes = append(changes, fmt.Sprintf("Lesson removed: %s", subject))
}
}
}
// Send notification if changes detected
if len(changes) > 0 {
log.Printf("Timetable changed for user %s: %d changes", acc.Username, len(changes))
message := "Your timetable has changed!\n\n" + strings.Join(changes, "\n")
BotStart.SendDM(acc.UserID, message)
// Optional: send to Discord webhook if configured and user is "default"
if acc.UserID == "default" && discordWebhookURL != "" {
sendUpdateDiscordWebhookWithDetails(changes)
}
}
}
// Always update prevData
prevData[acc.UserID] = data
time.Sleep(3 * time.Second)
}
}
// Initial update
updateAllUsers()
// Ticker for periodic updates
hourTicker := time.NewTicker(30 * time.Minute)
go func() {
for range hourTicker.C {
updateAllUsers()
}
}()
// Ticker for scheduled lesson notifications
startMinuteTicker(func() {
now := Untis.GetTime()
if isScheduledTime(now) {
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.")
}
})
log.Println("Updated all users")
}
func startMinuteTicker(f func()) {
now := time.Now()
now := Untis.GetTime()
next := now.Truncate(time.Minute).Add(time.Minute)
time.Sleep(time.Until(next))
ticker := time.NewTicker(1 * time.Minute)
@ -132,42 +346,53 @@ 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_default.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)
codeByStartTime, _ := MapTimeToCode(timetableFile)
roomByStartTime, _ := MapTimeToRoom(timetableFile)
subjectByStartTime, _ := MapTimeToSubject(timetableFile)
now := Untis.GetTime().Format("15:04")
nextTime, room, foundRoom := NextRoomForTime(roomByStartTime, now)
if !foundRoom {
return // No upcoming lessons
}
subjectByStartTime, err := MapTimeToSubject("timetableFilled.json")
if err != nil {
log.Fatal(err)
subject, foundSubject := NextSubjectForTime(subjectByStartTime, now)
if !foundSubject {
return // Should not happen if room was found
}
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)
status, _ := NextCodeForTime(codeByStartTime, now)
var message string
if status == "cancelled" {
message = fmt.Sprintf("Next lesson: **%s** at **%s**. is **%s**", subject, nextTime, status)
} else if status != "" && status != "cancelled" {
message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**. Status: **%s**", subject, room, nextTime, status)
} else {
sendDiscordWebhook(Subject, room, nextTime, "")
}
}
message = fmt.Sprintf("Next lesson: **%s** in room **%s** at **%s**.", subject, room, nextTime)
}
if userID == "default" {
if BotStart.WebHook {
log.Println("WebHook status ", BotStart.WebHook)
sendDiscordWebhook(subject, room, nextTime, status)
}
}
BotStart.SendDM(userID, message)
}
/*
@ -178,6 +403,10 @@ func NextRoomForTime(roomByStartTime map[string]string, current string) (string,
if err != nil {
return "", "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range roomByStartTime {
@ -185,23 +414,32 @@ func NextRoomForTime(roomByStartTime map[string]string, current string) (string,
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
for _, t := range times {
if t.After(now) {
return timeToStr[t], roomByStartTime[timeToStr[t]], true
}
}
if len(times) == 0 {
return "", "", false
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) room within the 30-minute window
return timeToStr[times[0]], roomByStartTime[timeToStr[times[0]]], true
}
func NextSubjectForTime(subjectByStartTime map[string]string, current string) (string, bool) {
layout := "15:04"
now, err := time.Parse(layout, current)
if err != nil {
return "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range subjectByStartTime {
@ -209,24 +447,32 @@ func NextSubjectForTime(subjectByStartTime map[string]string, current string) (s
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
for _, t := range times {
if t.After(now) {
return subjectByStartTime[timeToStr[t]], true
}
}
if len(times) == 0 {
return "", false
}
func NextCodeForTime(codeByStartTime map[string]string, current string) (string, bool) {
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) subject within the 30-minute window
return subjectByStartTime[timeToStr[times[0]]], true
}
func NextCodeForTime(codeByStartTime map[string]string, current string) (string, bool) {
layout := "15:04"
now, err := time.Parse(layout, current)
if err != nil {
return "", false
}
// Calculate 30 minutes from now
thirtyMinutesLater := now.Add(30 * time.Minute)
var times []time.Time
timeToStr := make(map[time.Time]string)
for t := range codeByStartTime {
@ -234,18 +480,22 @@ func NextCodeForTime(codeByStartTime map[string]string, current string) (string,
if err != nil {
continue
}
// Only include times within the next 30 minutes
if parsed.After(now) && parsed.Before(thirtyMinutesLater) {
times = append(times, parsed)
timeToStr[parsed] = t
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
for _, t := range times {
if t.After(now) {
return codeByStartTime[timeToStr[t]], true
}
}
return "", false
if len(times) == 0 {
return "", false
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
// Return the first (earliest) code within the 30-minute window
return codeByStartTime[timeToStr[times[0]]], true
}
func MapTimeToRoom(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -264,6 +514,7 @@ func MapTimeToRoom(path string) (map[string]string, error) {
}
return roomByStartTime, nil
}
func MapTimeToCode(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -277,13 +528,12 @@ func MapTimeToCode(path string) (map[string]string, error) {
codeByStartTime := make(map[string]string)
for _, entry := range table {
if len(entry.Code) > 0 {
if _, exists := codeByStartTime[entry.StartTime]; !exists {
codeByStartTime[entry.StartTime] = entry.Code
}
}
}
return codeByStartTime, nil
}
func MapTimeToSubject(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -330,35 +580,18 @@ type Field struct {
func sendDiscordWebhook(subject string, room string, nextTime string, Status string) {
log.Println("Sending Discord webhook notification...")
// Create a rich embed message
message := fmt.Sprintf(
var message string
if Status != "" {
message = fmt.Sprintf(
"Subject: %s\nRoom: %s\nStart-Time: %s\nStatus: %s",
subject, room, nextTime, Status,
)
/*embed := Embed{
Title: "Next Lesson ",
Description: "The next lesson is starting soon: ",
Color: 3066993, // Green color
Timestamp: time.Now().Format(time.RFC3339),
Fields: []Field{
{
Name: "New Lesson",
// Value: fmt.Sprintf("`%s`", ip),
Value: fmt.Sprintf("Room: %s", room),
Inline: true,
},
{
Name: "Start-Time",
Value: fmt.Sprintf("Start time: %s", nextTime),
Inline: true,
},
{
Name: "Status",
Value: fmt.Sprintf("Status: %s", Status),
Inline: true,
},
},
} else {
message = fmt.Sprintf(
"Subject: %s\nRoom: %s\nStart-Time: %s",
subject, room, nextTime,
)
}
*/
payload := DiscordWebhookPayload{
Content: message,
}
@ -379,40 +612,17 @@ 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))
}
}
func sendUpdateDiscordWebhook() {
log.Println("Sending Discord webhook notification...")
// Create a rich embed message
message := "A lesson on your timetable has changed"
/*embed := Embed{
Title: "Next Lesson ",
Description: "The next lesson is starting soon: ",
Color: 3066993, // Green color
Timestamp: time.Now().Format(time.RFC3339),
Fields: []Field{
{
Name: "New Lesson",
// Value: fmt.Sprintf("`%s`", ip),
Value: fmt.Sprintf("Room: %s", room),
Inline: true,
},
{
Name: "Start-Time",
Value: fmt.Sprintf("Start time: %s", nextTime),
Inline: true,
},
{
Name: "Status",
Value: fmt.Sprintf("Status: %s", Status),
Inline: true,
},
},
}
*/
func sendUpdateDiscordWebhookWithDetails(changes []string) {
if BotStart.WebHook {
log.Println("Sending detailed Discord webhook notification...")
message := "**Timetable Update**\n\n" + strings.Join(changes, "\n")
payload := DiscordWebhookPayload{
Content: message,
}
@ -431,9 +641,10 @@ func sendUpdateDiscordWebhook() {
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Println("Discord webhook notification sent successfully")
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))
}
}
}

Binary file not shown.