Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab

Posted: - Modified: | org, emacs
  • : Tweaked script to report when the server is already downscaled
  • : Add a check to see if the meetings are still running

Times and time zones trip me up. Even with calendar notifications, I still fumble scheduled events. Automation helps me avoid embarrassing hiccups.

We run BigBlueButton as a self-hosted web conferencing server for EmacsConf. It needs at least 8 GB of RAM when active. When it's dormant, it fits on a 1 GB RAM virtual private server. It's easy enough to scale the server up and down as needed. Using the server for Emacs meetups in between EmacsConfs gives people a way to get together, and it also means I can regularly test the infrastructure. That makes scaling it up for EmacsConf less nerve-wracking.

I have some code that processes various Emacs meetup iCalendar files (often with repeating entries) and combines them into one iCal file that people can subscribe to calendar, as well as Org files in different timezones that they can include in their org-agenda-files. The code I use to parse the iCal seems to handle time zones and daylight savings time just fine. I set it up so that the Org files have simple non-repeating entries, which makes them easy to parse. I can use the Org file to determine the scheduled jobs to run with cron on a home server (named xu4) that's up all the time.

This code parses the Org file for schedule information, then generates pairs of crontab entries. The first entry scales the BigBlueButton server up 1 hour before the event using my bbb-testing script, and the second entry scales the server down 6 hours after the event using my bbb-dormant script (more info). That gives organizers time to test it before the event starts, and it gives people plenty of time to chat. A shared CPU 8 GB RAM Linode costs USD 0.072 per hour, so that's USD 0.50 per meetup hosted.

Using #+begin_src emacs-lisp :file "/ssh:xu4:~/bbb.crontab" :results file as the header for my code block and using an SSH agent for authentication lets me use TRAMP to write the file directly to the server. (See Results of Evaluation (The Org Manual))

(let* ((file "/home/sacha/sync/emacs-calendar/emacs-calendar-toronto.org")
       (time-format "%M %H %d %m")
       (bbb-meetups "OrgMeetup\\|Emacs-Berlin Hybrid\\|Emacs APAC")
       (scale-up "/home/sacha/bin/bbb-testing")
       (scale-down "/home/sacha/bin/bbb-dormant")
       (maybe-scale-down "/home/sacha/bin/bbb-maybe-dormant")
       (extra "/home/sacha/sync/emacs-calendar/extra.txt"))
  (concat
   (mapconcat
    (lambda (o)
      (let* ((base-time (car o))
             (name (cdr o))
             (end-time (+ (car o) (* 6 3600))))
        (concat
         (format "# %s\n" name)
         ;; Start
         (format "%s * %s\n"
                 (format-time-string time-format (- base-time 3600))
                 scale-up)
         ;; In between
         (cl-loop
          for time
          from (+ base-time (* 2 3600)) ; 2 hours after start
          to (- end-time 1800) ; half hour before 6 hours
          by 1800              ; every half-hour
          concat
          (format "%s * %s\n"
                  (format-time-string time-format time)
                  maybe-scale-down))
         ;; End
         (format "%s * %s\n"
                 (format-time-string time-format end-time)
                 scale-down))))
    (delq nil
          (with-temp-buffer
            (insert-file-contents file)
            (org-mode)
            (goto-char (point-min))
            (org-map-entries
             (lambda ()
               (when (and
                      (string-match bbb-meetups (org-entry-get (point) "ITEM"))
                      (re-search-forward org-tr-regexp (save-excursion (org-end-of-subtree)) t))
                 (let ((time (match-string 0)))
                   (cons (org-time-string-to-seconds time)
                         (format "%s - %s" (org-entry-get (point) "ITEM") time)))))
             "LEVEL=1")))
    "\n")
   "\n"
   (if (file-exists-p extra)
       (with-temp-buffer
         (insert-file-contents extra)
         (buffer-string))
     "")))

The code makes entries that look like this:

# OrgMeetup (virtual) - <2026-01-14 Wed 11:00>--<2026-01-14 Wed 21:00>
00 10 14 01 * /home/sacha/bin/bbb-testing
00 13 14 01 * /home/sacha/bin/bbb-maybe-dormant
30 13 14 01 * /home/sacha/bin/bbb-maybe-dormant
00 14 14 01 * /home/sacha/bin/bbb-maybe-dormant
30 14 14 01 * /home/sacha/bin/bbb-maybe-dormant
00 15 14 01 * /home/sacha/bin/bbb-maybe-dormant
30 15 14 01 * /home/sacha/bin/bbb-maybe-dormant
00 16 14 01 * /home/sacha/bin/bbb-maybe-dormant
30 16 14 01 * /home/sacha/bin/bbb-maybe-dormant
00 17 14 01 * /home/sacha/bin/bbb-dormant

# Emacs Berlin (hybrid, in English) - <2026-01-28 Wed 12:30>--<2026-01-28 Wed 20:30>
30 11 28 01 * /home/sacha/bin/bbb-testing
30 14 28 01 * /home/sacha/bin/bbb-maybe-dormant
00 15 28 01 * /home/sacha/bin/bbb-maybe-dormant
30 15 28 01 * /home/sacha/bin/bbb-maybe-dormant
00 16 28 01 * /home/sacha/bin/bbb-maybe-dormant
30 16 28 01 * /home/sacha/bin/bbb-maybe-dormant
00 17 28 01 * /home/sacha/bin/bbb-maybe-dormant
30 17 28 01 * /home/sacha/bin/bbb-maybe-dormant
00 18 28 01 * /home/sacha/bin/bbb-maybe-dormant
30 18 28 01 * /home/sacha/bin/bbb-dormant

bbb-maybe-dormant is:

#!/bin/bash
source ~/.profile
CHECK_SCRIPT="/home/sacha/bin/check_meetings.py"
SCRIPT_OUTPUT=$(python3 $CHECK_SCRIPT)
ACTIVE_COUNT=$(echo "$SCRIPT_OUTPUT" | grep -c "Active:")
if echo "$SCRIPT_OUTPUT" | grep -q "Already downsized"; then
    echo $(date) "Node already downsized, no action needed." | tee -a /home/sacha/bbb.log
elif [ "$ACTIVE_COUNT" -eq 0 ]; then
    echo $(date) "No active meetings found. Okay to put it to sleep..." | tee -a /home/sacha/bbb.log
    ~/bin/bbb-dormant
else
    echo $(date) "Server is busy with $ACTIVE_COUNT active meeting(s)." | tee -a /home/sacha/bbb.log
fi

and that uses this Python file to query the API:

Python script to check if there are ongoing meetings
import hashlib
import requests
import os
from dotenv import load_dotenv
load_dotenv()

SECRET = os.environ['BBB_SECRET']
URL = os.environ['BBB_API_URL']
if SECRET is None or URL is None:
    print("Please specify BBB_SECRET and BBB_API_URL")
    exit(1)

import xml.etree.ElementTree as ET

def format_report(text):
    root = ET.fromstring(text)
    s = ""
    active_meetings = [
        m.find('meetingName').text + ' (' + m.find('participantCount').text + ')'
        for m in root.findall('.//meeting')
        if m.find('running').text == 'true'
    ]
    if active_meetings:
        for name in active_meetings:
            s += f"Active: {name}\n"
    else:
        s = "None"
    return s

def get_meetings():
    query = "getMeetings"
    checksum = hashlib.sha1((query + SECRET).encode('utf-8')).hexdigest()
    full_url = f"{URL}{query}?checksum={checksum}"
    response = requests.get(full_url)
    if 'Bad Gateway' in response.text:
        print('Already downsized')
    else:
        print(format_report(response.text).strip())

get_meetings()

Here's bbb-dormant:

#!/bin/bash
source /home/sacha/.profile
PATH=/home/sacha/.local/bin/:$PATH
CURRENT_TYPE=$(linode-cli linodes view $BBB_ID --format "type" --text --no-headers)
echo "Current node type: $CURRENT_TYPE"
if [ "$CURRENT_TYPE" != "g6-nanode-1" ]; then
   echo Powering off
   linode-cli linodes shutdown $BBB_ID
   # Wait for the Linode to reach the 'offline' state before resizing
   sleep 60
   echo "Waiting for shutdown to complete..."
   while [ "$(linode-cli linodes view $BBB_ID --format 'status' --text --no-headers)" != "offline" ]; do
        sleep 5
   done
   echo $(date) "Resizing BBB node to nanode, dormant" | tee -a /home/sacha/bbb.log
   linode-cli linodes resize $BBB_ID --type g6-nanode-1 --allow_auto_disk_resize false
   sleep 300
   linode-cli linodes boot $BBB_ID
fi

The code for generating the crontab works because meetups don't currently overlap. If there were, I'll need to tweak the code so that the server isn't downscaled in the middle of a meetup. It'll be a good problem to have.

I need to load the crontab entries by using crontab bbb.crontab. Again, I can tell Org Mode to run this on the xu4 home server. This time I use the :dir argument to specify the default directory, like this:

#+begin_src sh :dir "/ssh:xu4:~" :results silent
crontab bbb.crontab
#+end_src

Then cron can take care of things automatically, and I'll just get the e-mail notifications from Linode telling me that the server has been resized. This has already come in handy, like when I thought of Emacs APAC as being on Saturday, but it was actually on Friday my time.

I have another Emacs Lisp block that I use to retrieve all the info and update the list of meetups. I can add (goto-char (org-find-property "CUSTOM_ID" "crontab")) to find this section and use org-babel-execute-subtree to execute all the code blocks. That makes it an automatic part of my process for updating the Emacs Calendar and Emacs News. Here's the code that does the calendar part (Org source):

(defvar my-emacswiki-from-emacs nil "Set to nil when the wiki needs more manual intervention.")
(defun my-prepare-calendar-for-export ()
  (interactive)
  (with-current-buffer (find-file-noselect "~/sync/emacs-calendar/README.org")
    (save-restriction
      (widen)
      (goto-char (point-min))
      (re-search-forward "#\\+NAME: event-summary")
      (org-ctrl-c-ctrl-c)
      (org-export-to-file 'html "README.html")
      ;; (unless my-laptop-p (my-schedule-announcements-for-upcoming-emacs-meetups))
      ;; update the crontab
      (goto-char (org-find-property "CUSTOM_ID" "crontab"))
      (org-babel-execute-subtree)
      (when (and my-laptop-p t)
        (org-babel-goto-named-result "event-summary")
        (re-search-forward "^- ")
        (goto-char (match-beginning 0))
        (let* ((events (org-babel-read-result))
               (result (mapconcat (lambda (s) (concat "* " s "\n")) events "")))
          (if my-emacswiki-from-emacs
              (progn
                (oddmuse-edit "EmacsWiki" "Usergroups")
                (goto-char (point-min))
                (delete-region (progn (re-search-forward "== Upcoming events ==\n\n") (match-end 0))
                               (progn (re-search-forward "^$") (match-beginning 0)))
                (save-excursion (insert result)))
            (kill-new result)
            (browse-url "https://www.emacswiki.org/emacs?action=edit;id=Usergroups")
            (message "%s" result)))))))
(my-prepare-calendar-for-export)

I used a similar technique to generate the EmacsConf crontabs for automatically switching to the next talk. For that one, I used Emacs Lisp to write the files directly instead of using the :file header argument for Org Mode source blocks. That made it easier to loop over multiple files.

Hmm. Come to think of it, the technique of "go to a specific subtree and then execute it" is pretty powerful. In the past, I've found it handy to execute source blocks by name. Executing a subtree by custom ID is even more useful because I can easily mix source blocks in different languages or include other information. I think that's worth adding a my-org-execute-subtree-by-custom-id function to my Emacs configuration. Combined with an elisp: link, I can make links that execute functional blocks that might even be in different files. That could be a good starting point for a dashboard.

I love the way Emacs can easily work with files and scripts in different languages on different computers, and how it can help me with times and time zones too. This code should help me avoid brain hiccups and calendar mixups so that people can just enjoy getting together. Now I don't have to worry about whether I remembered to set up cron entries and if I did the math right for the times. We'll see how it holds up!

View Org source for this post