An org-mode inbox fed from a Matrix room

The bots in Matrix status messages from a cron job and A daily morning summary in Matrix only write into Matrix. This one goes the other way: I write a message into a Matrix room, and it ends up as a TODO in an org-mode file in my notes repo.

The setup is a Forgejo Action that runs once an hour. It reads the room, adds every new message from me to matrix-inbox.org, commits and pushes the file, and then reacts to the message with a checkmark.

img1

The bot has its own account @org-bot on my Conduit homeserver. The access token is stored as a Forgejo secret.

The room is not encrypted to reduce complications with olm/megolm persistence.

Reading the room

The script uses the plain client-server API with httpx. It fetches the last 100 events of the room with /messages?dir=b, so the newest event comes first. Then it collects the bot's checkmarks and walks back through my messages until it hits one that is already acked:

acked = {
    ev["content"]["m.relates_to"]["event_id"]
    for ev in events
    if ev["type"] == "m.reaction" and ev["sender"] == BOT
}

new = []
for ev in events:
    if ev["type"] != "m.room.message" or ev["sender"] != OWNER:
        continue
    if ev["event_id"] in acked:
        break  # everything older is acked too
    new.append(ev)

The script skips edits, otherwise fixing a typo in Matrix would add a new entry.

The inbox file

The first line of a message becomes the headline, every further line becomes the body. Entries are grouped by day, newest day on top and newest entry on top within a day:

#+STARTUP: showeverything
#+TITLE: Matrix inbox

* matrix inbox entries

** 2026-10-31

*** TODO prepare a draft blog post for the matrix org bot
*** TODO check the airpressure on the cube29

Ack only after the push

The order matters: if the bot reacts before the push and the push fails, the message is acked but the entry is lost with the runner. So the script has two modes, matrix-inbox.py to add the messages and matrix-inbox.py ack to react, and the git part stays in the workflow between them:

steps:
  - uses: actions/checkout@v7
    with:
      token: ${{ secrets.GITHUB_TOKEN }}
  - name: Install uv
    uses: https://github.com/astral-sh/setup-uv@v6
  - name: Add new messages to inbox
    run: uv run matrix-inbox.py
  - name: Commit and push
    run: |
      git config user.name "Automated"
      git config user.email "actions@forgejo.local"
      git remote set-url origin https://oauth2:${{ secrets.GITHUB_TOKEN }}@forgejo.example.com/${{ github.repository }}
      git add matrix-inbox.org
      git diff --staged --quiet || git commit -m "matrix inbox - $(TZ=Europe/Berlin date +'%Y-%m-%d %H:%M')"
      git push
  - name: Ack messages
    run: uv run matrix-inbox.py ack

The event ids of the added messages are handed from the first uv run matrix-inbox to the second one via a small file in $RUNNER_TEMP.

A daily morning summary in Matrix

The Raspberry Pi I set up in Packer, Ansible and DietPi for my Raspberry PIs runs the Hermes agent and is already connected to Homeassistant and Matrix. So it is the best place for a small summary every morning at 7:00.

What I want to know in the morning:

  • Which waste is collected today and tomorrow.

  • The temperature range for today and when it rains.

  • The battery level of my car.

  • When my local library is open today.

  • Is there a VfB home game in Stuttgart today -- then I avoid public transport.

  • Are there unexpected issues with the S-Bahn or SSB, not the planned roadworks.

There is no section about my appointments or plans for the day. Hermes has no access to my calendar or my todo lists, so it cannot write about them.

This is a single Python script started by cron on the Pi. No LLM is involved, the agent is only used for sending the message.

img1

The message goes to the Matrix room I mainly use for chatting with Hermes, so I can ask follow-up questions right there. For example the summary listed detours because of a demonstration, so I asked which demonstration it was. Hermes searched the web and answered with links to the Stuttgarter Nachrichten and the SSB:

img2

Homeassistant

The Hermes agent already has a Homeassistant token in its .env, so the script reads HASS_URL and HASS_TOKEN from there. Two REST API calls are enough:

class HomeAssistant:
    def __init__(self, url, token):
        self.url = url.rstrip("/")
        self.headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    def state(self, entity_id):
        return fetch_json(f"{self.url}/api/states/{entity_id}", headers=self.headers)

    def forecast(self, kind):
        resp = fetch_json(
            f"{self.url}/api/services/weather/get_forecasts?return_response",
            data={"entity_id": WEATHER_ENTITY, "type": kind},
            headers=self.headers,
        )
        return resp["service_response"][WEATHER_ENTITY]["forecast"]

The waste sensors are the ones from Homeassistant Tracking Waste Collection Dates. Their state is a date, so the script only compares it with today and tomorrow. The car battery is a plain state too (sensor.r5_battery and sensor.r5_battery_autonomy for the range).

The weather forecast comes from the weather.get_forecasts service. The script calls it twice: daily for the min/max temperature and hourly for the rain. Consecutive hours with rain are grouped, so the message shows 22-24 Uhr 0.6 mm instead of a list of single hours.

Library opening hours

The opening hours of my local library branch are on its page on the Stadtbibliothek website. The hours are plain HTML:

<h2>Öffnungszeiten</h2><strong>Dienstag</strong> 14:00 - 19:00 Uhr
<br><strong>Mittwoch</strong> 10:00 - 15:00 Uhr

A regular expression on the block after the heading returns a dict of weekday and hours. The hours rarely change, so fetching them every morning is a bit much and I may cache them later.

VfB home games

OpenLigaDB has an endpoint that returns the matches of a team for a number of weeks in the past and the future. VfB Stuttgart has the team id 16 and the result contains Bundesliga, DFB-Pokal and Champions League matches. A home game is a match where VfB is team1:

def section_vfb(today):
    matches = fetch_json(f"https://api.openligadb.de/getmatchesbyteamid/{VFB_TEAM_ID}/1/1")
    home = [
        m for m in matches
        if m["team1"]["teamId"] == VFB_TEAM_ID and to_local(m["matchDateTimeUTC"]).date() == today
    ]
    return f"⚽ **VfB {'' if home else 'kein '}Heimspiel heute!**"

S-Bahn and SSB

The VVS has an EFA endpoint (XML_ADDINFO_REQUEST) that returns all current info messages as JSON. On the day I tried it there were 414 messages, most of them with AlertCause: roadworks. After removing roadworks and broken elevators, the script keeps only messages for S-Bahn, U-Bahn and SSB lines that are active today. To filter out long planned changes, a message has to be published at most one day before it starts. This is a heuristic and I will see if it is too noisy over the next weeks.

Sending the message

Hermes already has a CLI command for this, hermes send, which reuses the credentials of the agent:

subprocess.run([HERMES, "send", "--to", MATRIX_TARGET, "--quiet", "--file", "-"],
               input=summary, text=True, check=True)

Watch PyPI releases with a Forgejo Action

The Arch Linux package monitor works well, so I built the same thing for PyPI. It is a separate repository, pypi-release-monitor, with the same shape: a packages.yaml, a state/ folder and a daily Forgejo Action.

Why

The trigger was datasette-auth-osm. Its demo now runs on the Datasette 1.0 alpha, and a new alpha could break it without me noticing. So I want to know when one is out, to check if the APIs changed. Apart from the demo, I want to keep track of the Datasette alphas in general.

Dependabot would open pull requests for this, but there are too many of them.

Pre-releases

The list of packages looks like the Arch one, with an optional flag:

packages:
  - name: datasette
    prereleases: true

Without the flag the version comes from https://pypi.org/pypi/<name>/json as .info.version. That field only shows stable releases, so for Datasette it says 0.65.x and never an alpha. With prereleases: true the newest upload counts instead, skipping yanked releases:

current=$(printf '%s' "$json" | jq -r '
  .releases | to_entries
  | map(select(.value | length > 0 and all(.yanked | not)))
  | max_by(.value | map(.upload_time_iso_8601) | min) | .key')

Picking the newest upload avoids comparing version strings like 1.0a9 and 1.0a41 in jq. The rest is copied from the Arch monitor: compare with state/<name>.txt, record first-seen packages silently, commit the state back.

The check lives in check.sh instead of inline in the workflow. Without a token it only prints what it would send, so I can run it locally.

Messages to Matrix

Notifications go to Matrix with one PUT, as in the Matrix status messages from a cron job post. A message looks like this:

PyPI: datasette 1.0a40 -> 1.0a41 https://pypi.org/project/datasette/1.0a41/

One bot account posts into two rooms, pypi-updates and archlinux-updates. In Matrix the room is what I can mute, so two rooms keep the two streams apart.

The Arch monitor moved too

The Arch Linux package monitor used to send its messages to ntfy. It now posts into archlinux-updates. That leaves ntfy for alarms only.