Calendar notifications with a Matrix.org bot

After setting up Radicale as my self-hosted calendar system (CalDav), I hit the issue that my wife and I have a shared calendar and I couldn't find a good way to get notifications that a new event has been added/changed there, which was a nice feature of the iCloud calendar, which is what we were using before.

At first I set up a python script that would monitor the calendar every 30s and send us an e-mail if there's anything new, that worked fine but it wasn't very elegant: I'm already flooded with e-mails and this wouldn't make it any better. Also, if we wanted to talk about the event it would require forwarding the e-mail and have the discussion on another medium, while we've been mostly using Matrix and Riot to chat already.

So I decided to try out the matrix-python-sdk library and build a bot that would send a message to our chat room when there's something new on our calendar.

Making a basic matrix.org bot

First, let's create a Python 3 virtual environment and install the packages we're going to use:

$ virtualenv --python=python3 venv
$ source venv/bin/activate
$ pip install matrix_client caldav icalendar

From there, let's assume you have a matrix server running on my-matrix.com with an user called @my-user:my-matrix.com already joined a room with internal ID of !ABcDEFgHizIJlmnop:my-matrix.com.

from matrix_client.client import MatrixClient
client = MatrixClient("https://my-matrix.com")
token = client.login_with_password(username="my-user", password="my-password")
room = client.get_rooms()["!ABcDEFgHizIJlmnop:my-matrix.com"]
room.send_text("Hello World!")

And that's it! Can't get simpler than that!

Caldav

Now, let's connect to the calendar:

import caldav
from icalendar import Calendar, Event
client = caldav.DAVClient(caldav_server,
                          username=caldav_user,
                          password=caldav_passwd)

From there we can list the events in the calendar:

principal = self.client.principal()
for calendar in principal.calendars():
    for event in calendar.events():
        asciidata = event.data.encode("ascii","ignore")
        c = Calendar.from_ical(asciidata)
        for comp in c.walk():
            if comp.name == "VEVENT":
                v = [d.strftime("%Y-%m-%d %H:%M:%S") \
                     for d in (comp[u"DTSTART"].dt,  \
                               comp[u"DTEND"].dt,    \
                               comp[u"DTSTAMP"].dt)]

The idea is to just gather that list every 30s and see if there's anything new. The full source is on the bottom of this post.

Systemd service

Now, I want to run that script on start-up time on my server in the background, so let's create a systemd unit file called "caldav_bot.service":

[Unit]
Description=calbot

[Service]
Type=simple
User=myuser
Group=mygroup
WorkingDirectory=/home/myuser/caldav_bot
ExecStart=/home/myuser/caldav_bot/venv/bin/python /home/myuser/caldav_bot/caldav_bot.py
Restart=always

[Install]
WantedBy=multi-user.target

And let's create a link for that in /etc/systemd/system:

$ sudo su
# cd /etc/systemd/system
# ln -s /home/myuser/caldav_bot/caldav_bot.service .
# systemctl start caldav_bot
# systemctl status caldav_bot

And the status message should say the daemon is running!

Full source for Calbot

Edit: Thanks to dubtooth on reddit for pointing out that the script needs a token for your matrix user, you can get one by doing:

curl -XPOST -d '{"type":"m.login.password", "user":"my-user", "password":"my-password"}' "https://my-matrix.com/_matrix/client/r0/login"

#!/usr/bin/env python
from matrix_client.client import MatrixClient
import caldav
from icalendar import Calendar, Event
import pickle
import time
import os

config = {
    "caldav" : {
        "url"      : "",
        "user"     : "",
        "password" : ""
    },
    "matrix" : {
        "user_id" : "",
        "room" : "",
        "base_url" : "",
        "token" : ""
    }
}

class CaldavWatcher(object):
    def __init__(self, caldav_server, caldav_user, caldav_passwd):
        self.client = caldav.DAVClient(caldav_server, username=caldav_user, password=caldav_passwd)
        self.data_loc = "/tmp/caldav_data.set"
        open(self.data_loc, 'a+').close()
        os.chmod(self.data_loc, 700) # accessible only by root

    def sync(self):
        try:
            with open(self.data_loc, 'rb') as f:
                old_s = pickle.load(f)
        except:
            old_s = set()

        store = set()

        principal = self.client.principal()
        for calendar in principal.calendars():
            for event in calendar.events():
                asciidata = event.data.encode("ascii","ignore")
                c = Calendar.from_ical(asciidata)
                for comp in c.walk():
                    if comp.name == "VEVENT":
                        v = [d.strftime("%Y-%m-%d %H:%M:%S") for d in (comp[u"DTSTART"].dt, comp[u"DTEND"].dt, comp[u"DTSTAMP"].dt)]
                        store.add( (comp["SUMMARY"], v[0], v[1], v[2]) )

        with open(self.data_loc, 'wb') as f:
            pickle.dump(store, f)

        print("finished caldav sync")
        k = store - old_s
        return k

    def run(self):
        d = self.sync()
        if len(d) > 0:
            print("new events: %d" % len(d))
            msg = ""
            for k in d:
                summary = k[0]
                start = k[1]
                end   = k[2]
                msg += "New event: %s\n\tStart:\t%s\n\tEnd:\t%s\n" % (summary,start,end)
            if len(d) > 5: return "There is %d new events" % len(d)
            else: return msg
        else:
            return None

def main(config):

    matrix_config = config["matrix"]
    caldav_config = config["caldav"]

    # setup api/endpoint
    client = MatrixClient(matrix_config["base_url"],
            token=matrix_config["token"],
            user_id=matrix_config["user_id"])

    room = client.get_rooms()[matrix_config["room"]]

    cal = CaldavWatcher(caldav_config["url"],
            caldav_config["user"],
            caldav_config["password"])

    while True:
        m = cal.run()
        print("message", m)
        if m:
            room.send_notice("New Calendar Events:\n" + m)

        time.sleep(30)

if __name__ == '__main__':
    main(config)

Edit: discussion on reddit.


catboli

I'm a cat-shadow floating in the web. Contact: https://keybase.io/catboli