e-Dnevnik bot is a self-hosted alerting system that reads from the official CARNet e-Dnevnik portal and regularly polls for new information. Supported event types:
- Grades — new subject grades
- Exams — newly scheduled exams
- Final grades — end-of-year closing grades per subject
- National exam results — national exam outcomes
- Reading lists — required reading entries (opt-in via
--readinglist)
The bot can log in as multiple AAI/AOSI users from the skole.hr domain and check for new information for all of them, either as a one-shot run or as a long-running service that polls at regular intervals (e.g. hourly). All new, previously unseen events will trigger an alert. The bot can deliver alerts through the following messaging systems and services:
- Discord
- Telegram
- Slack
- Regular e-mail (e.g. Gmail SMTP, etc.)
- Google Calendar (exam events only)
- Any CalDAV calendar — Nextcloud, Radicale, Baïkal, Fastmail, iCloud, … (exam events only)
Each alert can be broadcast through multiple services simultaneously, and each service can have multiple recipients. All authentication credentials remain exclusively on your PC or server.
Important note (May 2023): e-Dnevnik bot will not be able to fetch data unless it is hosted inside Croatia, as CARNet has implemented a firewall that blocks access from non-Croatian IP addresses. This affects all popular cloud VM and VPS providers such as Oracle Cloud, Contabo, AWS, Azure, and GitHub Actions. Self-hosting at home or in a local office is strongly recommended.
The bot uses Google's Application Programming Interface (API) Services to add school exam events to your Google Calendar.
Use of information received from Google APIs complies with the Google API Services User Data Policy, including the Limited Use requirements.
Your Google information is used solely to provide the user-facing calendar integration feature. No data is collected, stored remotely, or tracked in any way. All authorization data (e.g. the Google API token) is stored locally alongside the application and never leaves your premises.
The bot requires:
- A working directory and a small amount of disk space for the database: approximately 1 MiB for ~50 grades,
- AAI/AOSI credentials belonging to the skole.hr domain for e-Dnevnik,
- One or more Discord, Telegram, Slack, WhatsApp, e-mail, Google Calendar or CalDAV accounts/targets.
The bot runs on virtually any embedded device and any supported operating system, using approximately 20–25 MB of RSS memory during normal service operation.
Download the binary from the releases page along with the example configuration file.
NAME
e-dnevnik-bot
FLAGS
-v, --verbose verbose/debug log level
-0, --fulldebug log every scraped event (only with verbose mode)
-d, --daemon enable daemon mode (running as a service)
-?, --help display help
-t, --test send a test event (to check if messaging works)
-l, --colorlogs enable colorized console logs
--version display program version
--readinglist send reading list alerts
--exams BOOL send exam alerts (default: true)
--nationalexams BOOL send national exam alerts (default: true)
--digest BOOL send weekly exam digest (also needs [digest] enabled) (default: true)
-j, --jitter BOOL enable slight (up to 10%) jitter for tick intervals (default: true)
-f, --conffile STRING configuration file (in TOML) (default: .e-dnevnik.toml)
-b, --database STRING alert database file (default: .e-dnevnik.db)
-g, --calendartoken STRING Google Calendar token file (default: calendar_token.json)
-c, --cpuprofile STRING CPU profile output file
-m, --memprofile STRING memory profile output file
-i, --interval DURATION interval between polls when in daemon mode (default: 1h0m0s)
-p, --relevance DURATION maximum relevance period for grade and exam events (0 = unlimited) (default: 720h0m0s)
-r, --retries UINT number of retry attempts on error (default: 3)By default, the bot runs from the current working directory and loads its TOML configuration from .e-dnevnik.toml, or from the file specified with the -f flag.
Other flags:
-b: path to the alert database file used to track seen alerts (default:.e-dnevnik.db); note that the.sqliteextension is appended automatically, so the actual file on disk will be.e-dnevnik.db.sqlite,-d: enables daemon/service mode, where the bot runs continuously and wakes up at regular intervals (specified with-i); disabled by default,-f: path to the configuration file containing usernames, passwords, and messaging service settings (in TOML format),-i: interval between polls in daemon/service mode (minimum 1h, default 1h),-r: number of retry attempts on scraping or delivery failures (default: 3),-t: sends a test message to all configured messaging services, using a throwaway database so a failed test send is never queued for delivery on a later run; calendar backends (Google Calendar, CalDAV) only ever receive exam events, so they are not exercised by-t,-v: enables verbose/debug logging for detailed insight into bot operation; disabled by default,-l: enables colorized console logging with JSON output disabled,-g: path to the Google Calendar API token file for reading and storing the OAuth2 token,-p: maximum relevance period for grade and exam events (default720h, about 30 days;0= unlimited). An event dated further back is recorded as seen and never alerted, so a grade a teacher enters backdated by more than this period produces no alert,--version: displays the program version,-j: enables a ±10% random jitter on the poll interval,--readinglist: enables processing and alerting on reading list events,--exams=false,--nationalexams=false: turn off exam and national exam alerts on every messenger, calendars included. Suppressed events are still recorded as seen, so turning a flag back on does not replay them. Exams still appear in the weekly digest,--digest=false: turns off the weekly exam digest, same asenabled = falsein[digest].
The configuration file consists of several blocks. The user block can be repeated as many times as needed. The Telegram, Discord, Slack, and e-mail blocks can each appear only once, but all can be enabled or disabled independently. Recipient lists (user IDs, chat IDs, and to addresses) are defined as arrays and support any number of entries. Alerts are broadcast to all enabled messaging services simultaneously.
File permissions are your responsibility. The bot does not enforce restrictive permissions on the configuration file, even though it contains AAI/AOSI passwords and messenger tokens. Set
chmod 0600 .e-dnevnik.toml(or equivalent) yourself, and ensure the file's directory is not world-readable. Containerised deployments should use a dedicated volume that is mounted with appropriate ownership.The bot may rewrite your configuration file. When the WhatsApp
groupsfield contains a name that resolves to a JID (see the WhatsApp configuration section), and the file is writable by the bot's user, the resolved JID is migrated fromgroupsintouseridsand the file is rewritten in place. Comments and key ordering are not preserved — the rewrite uses a TOML encoder that emits a canonical form. If you maintain the file under version control or rely on inline comments, either keep the file read-only and manage the JIDs yourself, or expect a single one-time rewrite on first successful resolution.
Credentials are read once, at startup. The configuration file is parsed when the bot starts, and the values it holds — AAI/AOSI passwords, messenger tokens, SMTP credentials — are used unchanged for the lifetime of the process. There is no reload signal. After rotating a password or a token, restart the bot (
systemctl restart e-dnevnik-bot, or recreate the container) before expecting the new value to take effect.
[[user]]
username = "ime.prezime@skole.hr"
password = "lozinka"As many [[user]] blocks as needed can be specified; all users are processed in parallel.
[telegram]
token = "telegram_bot_token"
chatids = [ "chat_id", "chat_id2" ]Steps required:
- Create a Telegram bot by following the official Telegram bot guide, which involves messaging BotFather and completing a few simple steps.
- After creating the bot, message it from each Telegram account you want to receive notifications. Then retrieve the Chat IDs, typically via https://api.telegram.org/botTOKEN/getUpdates, replacing TOKEN with the bot token obtained in step 1.
[discord]
token = "discord_bot_token"
userids = [ "user_id", "user_id2" ]Steps required:
- Create a Discord bot by following the Discord bot guide. This involves both creating the bot and inviting it to your server.
- The only permission needed is Send Messages.
- User IDs can be found by enabling Developer Mode in your Discord client and then messaging your bot.
[slack]
token = "xoxb-slack_bot_token"
chatids = [ "chat_id", "chat_id2" ]Steps required:
- Create a Slack bot by following the official Slack bot guide.
- The only OAuth scope required is chat:write.
- To find a member's Chat ID, click their username in Slack, select View full profile, then Copy member ID. A channel ID can also be used to send messages to a group channel.
[mail]
server = "smtp.gmail.com"
port = "587"
username = "user.name@gmail.com"
password = "legacy_app_password"
from = "user.name@gmail.com"
subject = "Nova ocjena iz e-Dnevnika"
to = [ "user.name@gmail.com", "user2.name2@gmail.com" ]
# Optional per-event subjects; missing keys fall back to `subject`.
# Keys: grade, exam, reading, finalgrade, nationalexam, examdigest.
[mail.subjects]
exam = "Novi ispit u e-Dnevniku"
examdigest = "Tjedni pregled ispita"Upgrading:
fromis now required. It used to be optional, but an emptyfromis rejected by the SMTP library, and the mail messenger treated that as a per-recipient failure — quietly discarding every alert it was given. The address is now validated at startup, so a[mail]block withserverset andfrommissing aborts the bot rather than running with mail silently broken. Because that abort happens before anything starts, the other messengers stop too: addfrombefore upgrading.
Steps required:
- For Gmail, follow the Gmail Help Center guide to configure SMTP access. Other SMTP providers follow a similar, self-explanatory setup.
[whatsapp]
phonenumber = "+385XXYYYYYYY"
userids = [ "+385XXYYYYYYY@s.whatsapp.net", "XXXYYYYYYY-ZZZZZZZZZZ@s.whatsapp.net" ]
groups = [ "group1", "group2" ]Steps required:
- Open the WhatsApp mobile application (iOS, Android, etc.).
- A phone number is required for automatic pairing via PIN. If no phone number is configured, pairing falls back to a QR code, which requires an interactive run on a desktop. During the initial sync, keep your Android/iOS WhatsApp application open and active until it finishes, which can take 15-20 minutes. Once the device is paired, the first Ctrl+C or SIGTERM is deferred until the sync finishes (at most about 27 minutes), so a stray interrupt cannot leave contacts and history half-synced. A second one stops waiting and exits; the sync may then be incomplete.
- Entries in
useridsare either a personal JID (in the form+385XXYYYYYYY@s.whatsapp.net, for direct messages) or a group chat JID (in the formXXXYYYYYYY-ZZZZZZZZZZ@s.whatsapp.net). If you do not know a group's JID, you can specify groups by name in thegroupsfield; the bot will resolve each name to its JID at startup. If the configuration file is writable by the bot, each resolved JID is added touseridsand its name removed fromgroups, so subsequent runs skip the name-resolution step. A name that matched no joined group stays ingroups, is logged once as unmatched, and is retried on every poll until it matches. Usinguseridsdirectly is preferred for performance reasons. - The WhatsApp session is stored in
.e-dnevnik.wa.sqlitein the working directory. When running in Docker, keep the whole working directory on a persistent volume (as in the examples below) so that the pairing survives container restarts. Do not bind-mount the database file alone: SQLite keeps-waland-shmfiles next to each database, and losing them on an unclean stop discards recent session writes.
[calendar]
name = "Djeca ispiti"Steps required:
- Set up the Google Calendar API by following the Go Quickstart guide to create a project, enable the Calendar API, and download
credentials.jsonto the working directory. - On the first run, the bot must be launched interactively in a terminal — it will open a browser for OAuth2 authorization and store the resulting token in
calendar_token.json(configurable with-g). Subsequent runs use the cached token automatically. - The
namefield specifies the target Google Calendar by name (e.g. a calendar you created calledDjeca ispiti). Only exam events are added as calendar entries.
[caldav]
url = "https://cloud.example.com/remote.php/dav/calendars/pero/ispiti/"
username = "pero"
password = "app-password"An alternative to Google Calendar that needs no Google account and no interactive first run.
urlis the full URL of the calendar collection, not the server root. Nextcloud shows it under the calendar's "Copy private link" menu; Radicale useshttp://127.0.0.1:5232/<user>/<calendar>/; Fastmail lists it under Settings → Calendars → Calendar sharing / CalDAV.- Use an app-specific password where the provider supports one (Nextcloud, Fastmail, iCloud).
- The URL must use
https. Plainhttpis accepted only forlocalhost,127.0.0.0/8or::1, so a Radicale on the same machine works without TLS. - Redirects are not followed. If the server redirects, the log names the target — put that URL in the config. The exam is queued meanwhile, like a
401/403/404(see below). - Only exam events are added, as all-day entries. Google Calendar and CalDAV can be enabled together.
- Credentials and the URL are only exercised when the first exam arrives —
-temulation sends a grade, which calendar backends ignore, so it does not test CalDAV. A401/403/404is treated as a configuration error: the exam is queued, not dropped, and delivered once the URL or app password is fixed and the bot restarted (within the queue's maximum age). Verify the URL and app password with a CalDAV client beforehand (e.g. by subscribing to the same URL) rather than finding out on the first real exam. - Plain
httpis loopback-only (see point 3): a Docker-network hostname such ashttp://radicale:5232/is rejected even though it never leaves your infrastructure. Usehttps, or run the bot on the same host network as the CalDAV server so it can reach it vialocalhost. - iCloud does not show collection URLs in its web UI; discovering the correct one requires a CalDAV client — the bot does no discovery of its own.
[digest]
enabled = true # set to false to turn the digest off
day = "sunday"
hour = 18Once a week, each user gets one short message listing every exam in the coming week (Monday to Sunday), sent to all chat messengers (Telegram, Discord, Slack, mail, WhatsApp). Calendar backends ignore it, because they already hold each exam as its own entry.
- The digest is on by default, even with no
[digest]section, as long as at least one chat messenger is enabled (with only calendars it is switched off, since they would discard it). To turn it off, setenabled = false.dayis an English weekday name (defaultsunday).houris an hour from 0 to 23 (default18) in the bot's local time zone. In Docker, that is UTC unless you setTZ(see below). - It goes out on the first poll at or after
dayathour, so with the default 1h poll interval it arrives within the hour. If that poll is missed or fails to scrape a user, a later poll still sends it. A late digest lists only exams from that day onward. - Each user gets at most one digest per week. The bot stores the week it last handled in its database. A week with no exams sends nothing.
- Exams already announced individually are listed again. The digest is a summary, not a new-event alert.
- On first start, or after upgrading mid-week, the bot sends the current week's digest straight away, listing exams from that day onward.
For a minimal systemd setup running the service as user ubuntu inside /home/ubuntu/e-dnevnik, save the following file to /etc/systemd/system/e-dnevnik.service:
# /etc/systemd/system/e-dnevnik.service
[Unit]
Description=e-Dnevnik daemon
After=network-online.target
[Service]
Type=notify
WatchdogSec=30s
WorkingDirectory=/home/ubuntu/e-dnevnik
ExecStart=/home/ubuntu/e-dnevnik/e-dnevnik-bot --daemon --verbose
Restart=always
RestartSec=2
User=ubuntu
Group=ubuntu
[Install]
WantedBy=multi-user.target
Then enable the service with the usual commands:
systemctl daemon-reload
systemctl enable --now e-dnevnik
systemctl status e-dnevnikNote: e-dnevnik-bot supports the systemd sd_notify, Type=notify, and WatchdogSec= features from version 0.13.0 onwards. Earlier versions are only compatible with Type=simple.
Up-to-date images for Linux amd64/arm64/arm are available on Docker Hub. To set this up, create a persistent directory named ednevnik in your working directory, place the configuration file there, and mount it into the container:
cd some/workdir
mkdir ednevnik
curl https://raw.githubusercontent.com/dkorunic/e-dnevnik-bot/main/.e-dnevnik.toml.example \
--output ednevnik/.e-dnevnik.toml
editor ednevnik/.e-dnevnik.toml
docker pull dkorunic/e-dnevnik-bot
docker run --detach \
--volume "$(pwd)/ednevnik:/ednevnik" \
--env TZ=Europe/Zagreb \
--restart unless-stopped \
dkorunic/e-dnevnik-bot \
--daemon \
--verbose \
--database /ednevnik/.e-dnevnik.db \
--conffile /ednevnik/.e-dnevnik.tomlTo use docker compose, first ensure Docker Compose is installed.
Create a docker-compose.yml file in your project directory (e.g. ~/docker-compose/ednevnik):
user@server:~/docker-compose$ mkdir ednevnik
user@server:~/docker-compose$ cd ednevnik
user@server:~/docker-compose$ editor docker-compose.ymlPaste the following into docker-compose.yml:
# More info at https://github.com/dkorunic/e-dnevnik-bot
services:
ednevnik:
container_name: e-dnevnik
image: dkorunic/e-dnevnik-bot:latest
command:
- "--daemon"
- "--database=/ednevnik/.e-dnevnik.db"
- "--conffile=/ednevnik/.e-dnevnik.toml"
# Local time zone for the weekly digest and log timestamps
environment:
- TZ=Europe/Zagreb
# Volumes store your data between container upgrades
volumes:
- ./ednevnik:/ednevnik
restart: unless-stoppedInside the project directory, create a directory called ednevnik for persistent storage, then download and edit the configuration file:
user@server:~/docker-compose/ednevnik$ mkdir ednevnik
user@server:~/docker-compose/ednevnik$ curl https://raw.githubusercontent.com/dkorunic/e-dnevnik-bot/main/.e-dnevnik.toml.example \
--output ednevnik/.e-dnevnik.toml
user@server:~/docker-compose/ednevnik$ editor ednevnik/.e-dnevnik.tomlFrom the project directory where docker-compose.yml is located, start the service with:
user@server:~/docker-compose/ednevnik$ docker compose up -dThe -d / --detach flag runs the containers in the background.
To stop the service, run:
user@server:~/docker-compose/ednevnik$ docker compose downWarning: CARNet has implemented a firewall that blocks access to e-Dnevnik from non-Croatian IP addresses. GitHub Actions runners use IP addresses outside Croatia, so this integration will fail to scrape data. It is kept here for reference only. For reliable operation, self-host the bot on hardware located in Croatia.
This integration was originally created by Luka Kladaric @allixsenos — thanks, Luka! The original Gist is here; a copy is reproduced below:
name: e-imenik run
# Controls when the action will run.
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Run on push to any branch for testing
push:
# Run every 6 hours
schedule:
- cron: "0 */6 * * *"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Render the config file
run: |
cat > .e-dnevnik.toml <<EOF
[[user]]
username = "$SKOLE_USERNAME"
password = "$SKOLE_PASSWORD"
[telegram]
token = "$TELEGRAM_TOKEN"
chatids = [ "$TELEGRAM_CHATID" ]
EOF
env:
SKOLE_USERNAME: ${{ secrets.SKOLE_USERNAME }}
SKOLE_PASSWORD: ${{ secrets.SKOLE_PASSWORD }}
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHATID: ${{ secrets.TELEGRAM_CHATID }}
- name: Run dkorunic/e-dnevnik-bot
run: |
docker run -t \
--volume "$(pwd):/ednevnik" \
--user $(id -u):$(id -g) \
dkorunic/e-dnevnik-bot \
--verbose \
--database /ednevnik/.e-dnevnik.db \
--conffile /ednevnik/.e-dnevnik.toml
- name: Delete the config file so it doesn't get committed back :)
run: rm .e-dnevnik.toml
- name: Commit the DB
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_author: GitHub Actions <actions@github.com>This GitHub Actions workflow runs every 6 hours. On each run it checks out the repository (which includes the database), renders the configuration file from secrets, runs the bot, and commits the updated database back to the repository, providing persistence across runs.
