CCE

RSS Feed Bot Posting to Matrix.org

Contents

What is this thing?

This is a Python-based RSS -> Matrix bridge.

It is a sibling of Feediverse, a small Matrix.org client which uses Python's feedparser library to post RSS and Atom feeds to a Matrix room.

Additionally, it fetches the opml.xml endpoints of my Arcology sites to post all the RSS feeds advertised there, the implementation of that in the Arcology Routing Logic/Arroyo Feed Cache Generator pages.

My Feeds to Post To Friendface

Friendface is the social network I run with some of my friends. The way it works is that if you have an RSS feed, you join that room and I add your RSS feed to it. When you post things to your RSS feed, it appears in that chat room and we reply to it like it was a message you sent, and we have a conversation about the thing that you wrote.

URL Owner
https://iliana.fyi/atom.xml iliana
https://faust.land/all/feed.atom Maya
https://maya.land/feed.xml Maya
https://occult.institute/@faustland.rss Maya
https://dammit.nl/feeds/all.atom.xml Michiel
https://data.bff.fm/shows/pulse-width-mornings.rss Torrie
https://www.youtube.com/feeds/videos.xml?channel_id=UCvpCqRSI-9rPyT431QkRKig Torrie
https://makerspacemanagers.com/?format=rss Will
https://willbradley.name/feed/ Will
https://xeiaso.net/blog.rss Xe
https://blog.yaelwrites.com/rss/ Yael
https://yaelwrites.com/index.xml Yael
https://kalessin.fr/blog/feed.rss kalessin
lua#+name: mkFeeds:eval arroyo:var tbl=urls:noweb-ref mkFeeds
local lines = {}
for _, row in ipairs(tbl) do
  table.insert(lines, string.format('"%s"', row[1]))
end
return table.concat(lines, "\n")

This is embedded in the configuration.nix which deploys the feed bot... As always, I am up to Literate Programming shenanigans.

The Script Itself

The Worker Class and Loop

Look, this thing is pretty un-exciting. It's a big stonkin while loop with a bunch of scaffolding to persist configuration, fetch feeds, and send messages. Each of those functionalities is broken out below with the core logical loop remaining in place here.

python:tangle ~/Code/feedbot2/matrix_feedbot/feedbot.py:noweb yes:mkdirp yes
import asyncio
import aiohttp
import feedparser
import os
import xml.etree.ElementTree as ET
from typing import Dict
import datetime
import time # i hate python!!

from nio import AsyncClient, MatrixRoom, RoomMessageText

<<YamlBackedDict>>

class Worker():
    """
    first we load configurations and caches and initialize the client
    """
    def __init__(self):
        path = os.environ.get("FEEDBOT_CONFIG", "./cfg.yaml")
        cred_path = os.environ.get("FEEDBOT_CREDENTIALS_CONFIG", "./creds.yaml")
        cache_path = os.environ.get("FEEDBOT_CACHE", "./cache.yaml")

        self.config      = YamlBackedDict(path,      self.stub_config)
        self.credentials = YamlBackedDict(cred_path, self.stub_creds)

        cache = { feed: set() for feed in self.config["feeds"] }
        self.cache = YamlBackedDict(cfg_path=cache_path, stub_fn=self.stub_cache)
        self.cache._dict = {**cache, **self.cache._dict}

        self.client = AsyncClient(self.credentials["homeserver"],
                                  self.credentials["username"])
        self.last_fetch = datetime.datetime.fromtimestamp(0)
        self.feed_failure_count = dict()

    """
    Entrypoint. sets up matrix-neo client and mostly just hangs out waiting for messages and sometimes checks if it's time to fetch feeds.
    """
    async def main(self) -> None:
        await self.login()
        self.client.add_event_callback(self.message_callback, RoomMessageText)
        
        print("starting...")
        await self.maybe_fetch_feeds()
        await self.maybe_fetch_dynamic_feeds()
        self.cache.save()
        self.last_fetch = datetime.datetime.now()
        print("ahh...")

        while True:
            td = datetime.datetime.now() - self.last_fetch
            if td > datetime.timedelta(hours=1):
                print("refreshing...")
                await self.maybe_fetch_feeds()
                await self.maybe_fetch_dynamic_feeds()
                self.cache.save()
                self.last_fetch = datetime.datetime.now()
                print("ahh...")
            await self.client.sync(timeout=30*1000)


    <<stub-functions>>
    <<message>>
    <<feed-fetch>>


    async def login(self) -> None:
        if self.credentials.get("access_token") is None:
            login_resp = await self.client.login(self.credentials["password"])
            self.credentials["access_token"] = login_resp.access_token
            self.credentials["device_id"] = login_resp.device_id
            self.credentials.save()
        else:
            self.client.access_token = self.credentials["access_token"]
            self.client.device_id = self.credentials["device_id"]

        for room_id in self.config["rooms"]:
            await self.client.join(room_id)


def run():
     w = Worker()
     asyncio.get_event_loop().run_until_complete(w.main())

Fetch and Parse Feeds Asynchronously

These functions, starting with fetch_feed_url create an async function which will handle feed parsing and whatnot all the way through sending the message. maybe_fetch_dynamic_feeds will fetch each OPML endpoint in opml_files, collect the xmlUrl of every outline (deduplicated across sites), and iterate over those, and maybe_fetch_feeds will iterate over the statically defined ones.

python#+name: feed-fetch:noweb-ref feed-fetch
async def maybe_fetch_feeds(self):
    feedurls = self.config["feeds"]

    for feed in feedurls:
        try:
            await self.fetch_feed_url(feed)
            self.feed_failure_count[feed] = 0
        except aiohttp.client_exceptions.ClientConnectorError as e:
            if self.feed_failure_count.get(feed, 0) >= 10:
                await self.send_plain(str(e))
            self.feed_failure_count[feed] = self.feed_failure_count.get(feed, 0) + 1


async def send_plain(self, text):
    for room_id in self.config['rooms']:
        print(await self.client.room_send(
            room_id=room_id,
            message_type="m.room.message",
            content={"msgtype": "m.text",
                     "body": text },
        ))


async def maybe_fetch_dynamic_feeds(self):
    opml_urls = self.config['opml_files']
    feed_urls = set()
    async with aiohttp.ClientSession() as session:
        for opml_url in opml_urls:
            data = await self.fetch(session, opml_url)
            root = ET.fromstring(data)
            for outline in root.findall('.//{*}outline'):
                xml_url = outline.get('xmlUrl')
                if xml_url:
                    feed_urls.add(xml_url)
    for feed in feed_urls:
        self.cache[feed] = self.cache.get(feed, set())
        await self.fetch_feed_url(feed)

async def fetch_feed_url(self, feed):
    """
    greetz to https://stackoverflow.com/questions/23847555/asynchronous-feedparser-requests
    """
    async with aiohttp.ClientSession() as session:
        data = await self.fetch(session, feed)
        rss = feedparser.parse(data)

        print(f"got {feed} w/ {len(rss['entries'])}")
        def filter_old_entries(entry):
            if entry['updated_parsed'].tm_year > 1969:
                entry_time = datetime.datetime.fromtimestamp(time.mktime(entry['updated_parsed']))
                if datetime.datetime.now() - entry_time < datetime.timedelta(hours=24) and entry['link'] not in self.cache[feed]:
                    return True
                else:
                    return False
            else:
                return False

        entries_filtered = filter(filter_old_entries, rss['entries'])
        entries_sorted = sorted(entries_filtered, key=lambda entry: entry['updated_parsed'])
        for entry in entries_sorted:
            await self.send_message(rss, entry)
            self.cache[feed].add(str(entry['link']))
            #  if a post is made, we need to save the cache so that we don't spam our friends
            self.cache.save()


async def fetch(self, session, url):
    async with session.get(url) as response:
        return await response.text()

Message Formatting and Sending

python#+name: message:noweb-ref message
async def message_callback(self, room: MatrixRoom, event: RoomMessageText) -> None:
    pass

async def send_message(self, feed, entry):
    # construct message
    html = ''.join([
        'New in <a href="', feed['feed']['link'], '">',
        feed['feed']['title'],
        '</a>: <a href="', entry['link'], '">', entry.get('title', entry.get('link')), '</a>'
    ])
    text = ''.join([
        'New in', feed['feed']['title'],
        ': ', entry['link'], " - ", entry.get('title', entry.get('link'))
    ])

    print(text)
    print(html)
    # TKTKTK configure which rooms a feed goes tooo.
    # TKTKTK configure how often to scrape a feed
    for room_id in self.config['rooms']:
        print(await self.client.room_send(
            room_id=room_id,
            message_type="m.room.message",

            content={"msgtype": "m.text",
                     "format": "org.matrix.custom.html",
                     "formatted_body": html,
                     "body": text },
        ))

YamlBackedDict

Hey Smell This

I have this stupid config structure i made which is just used to serialize and deserialize a python dict to a YAML file so that a simple cache can be constructed. Small Software Can Be Shitty, terms and conditions applied

python#+name: YamlBackedDict:noweb-ref YamlBackedDict
import yaml

class YamlBackedDict():
    def __init__(self, cfg_path: str, stub_fn):
        self.path = cfg_path
        self.stub_generator = stub_fn
        self._dict = self.load()

    def __setitem__(self, k, v):
        self._dict[k] = v

    def __getitem__(self, k):
        return self._dict[k]

    def __str__(self):
        return str(self._dict)

    def get(self, k, default=None):
        return self._dict.get(k, default)

    def stub(self):
        cfg = self.stub_generator()

        with open(self.path, 'w') as f:
            yaml.dump(cfg, f)

        return cfg

    def load(self):
        if not os.path.exists(self.path):
            cfg = self.stub()
        else:
            with open(self.path, 'r') as f:
                cfg = yaml.safe_load(f)

        return cfg

    def save(self):
        with open(self.path, 'w') as f:
            return yaml.safe_dump(self._dict, f)

The YAML files each have an un-documented schema because I am an asshole; because I am not an asshole, there is a function which will stub each of them when they are not existing, these are passed in to the constructor to YamlBackedDict and listed as part of the Worker class but if I do more work on this it should probably be sub-classes..

python#+name: stub-functions:noweb-ref stub-functions
def stub_creds(ybd) -> Dict:
    return dict(
        username=input("Enter the Matrix username: "),
        password=input("Enter the Matrix password: "),
        homeserver=input("Enter the Matrix homserver URL: "),
    )


def stub_config(ybd) -> Dict:
    return dict(
        rooms=[
            input("Enter the first room ID to join (starts with ! not #): "),
        ],
        feeds=[],
        opml_files=[],
    )


def stub_cache(ybd) -> Dict:
    # would be nice if this could include the set() logic in __init__ but...
    return dict()

pyproject/poetry definition

This makes it easier to deploy:

toml:tangle ~/Code/feedbot2/pyproject.toml
[tool.poetry]
name = "matrix-feedbot"
version = "0.0.0"
description = "Send RSS feeds to a Matrix room"
license = "GPL-3.0-only"
authors = [
  "Ryan Rix <code@whatthefuck.computer>"
]

packages = [
  { include = "matrix_feedbot" }
]

[tool.poetry.dependencies]
python = "^3.10"
matrix-nio = ">0.20"
feedparser = "^6.0.10"
pyyaml = "^6.0"

[tool.poetry.scripts]
feedbot = 'matrix_feedbot.feedbot:run'

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

Deploy to The Wobserver

Nix Shell setup

nix:tangle ~/Code/feedbot2/shell.nix:mkdirp yes
{ pkgs ? import <nixpkgs> {} }:

let
  myPython = pkgs.python3.withPackages (ppkgs:
    with ppkgs; [
      feedparser
      click
      pyyaml
      matrix-nio
    ]);
in
pkgs.mkShell {
  packages = [
    pkgs.poetry
    myPython
  ];
}

Package feedbot as a Flake

feedbot is packaged as a standalone flake at //code.rix.si/rrix/feedbot2. The flake exposes a package, a dev shell, and a NixOS module. It's pulled into the Arroyo system as a flake input.

nix:tangle ~/nix/snippets/feedbot-input.nix:mkdirp yes
feedbot = {
  url = "git+http://code.rix.si/rrix/feedbot2";
  inputs.nixpkgs.follows = "nixpkgs";
};

Flake

nix:tangle ~/Code/feedbot2/flake.nix:mkdirp yes
{
  description = "Post RSS/Atom feeds to Matrix";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  };

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      packages.${system}.default = pkgs.python3Packages.callPackage ./default.nix {};
      devShells.${system}.default = import ./shell.nix { inherit pkgs; };
      nixosModules.default = ./module.nix;
    };
}

Package

nix:tangle ~/Code/feedbot2/default.nix:mkdirp yes
{ lib,
  buildPythonPackage,
  matrix-nio,
  feedparser,
  pyyaml,
  poetry-core,
}:

buildPythonPackage {
  pname = "matrix-feedbot";
  version = "0.0.1";

  src = ./.;
  format = "pyproject";

  propagatedBuildInputs = [ matrix-nio feedparser pyyaml ];
  buildInputs = [ poetry-core ];

  meta = {
    homepage = "https://cce.whatthefuck.computer/matrix-feedbot";
    description = "Post RSS/Atom feeds to Matrix";
    license = lib.licenses.agpl3Only;
    maintainers = with lib.maintainers; [ rrix ];
  };
}

NixOS Module

nix:tangle ~/Code/feedbot2/module.nix:mkdirp yes
{ lib, pkgs, config, ... }:

let
  feedbot-pkg = pkgs.python3Packages.callPackage ./default.nix {};
in with lib; {
  options.services.feedbot = {
    enabled = mkOption {
      type = types.bool;
      default = true;
    };

    package = mkOption {
      type = types.package;
      default = feedbot-pkg;
    };

    workDir = mkOption {
      type = types.str;
      default = "/srv/feedbot";
    };

    credentialsFileLocation = mkOption {
      type = types.path;
      default = "/srv/feedbot/creds.yaml";
    };

    cacheFileLocation = mkOption {
      type = types.path;
      default = "/srv/feedbot/cache.yaml";
    };

    rooms = mkOption {
      type = types.listOf types.str;
      default = [ "!THQSEcCQbqCZqLGUbG:kickass.systems" ];
    };

    opmlFiles = mkOption {
      type = types.listOf types.str;
      default = [
        "https://rix.si/opml.xml"
        "https://whatthefuck.computer/opml.xml"
        "https://cce.whatthefuck.computer/opml.xml"
        "https://engine.arcology.garden/opml.xml"
        "https://thechanceencounter.com/opml.xml"
      ];
    };

    feedUrls = mkOption {
      type = types.listOf types.str;
      default = [];
    };
  };

  config = {
    users.groups.feedbot = {};
    users.users.feedbot = {
      createHome = true;
      home = config.services.feedbot.workDir;
      group = "feedbot";
      isSystemUser = true;
    };
    systemd.services.feedbot = mkIf config.services.feedbot.enabled {
      enable = true;
      description = "Post RSS and Atom feeds to a Matrix room.";
      after = [ "network.target" ];
      script = "${config.services.feedbot.package}/bin/feedbot";
      wantedBy = [ "default.target" ];

      environment = {
        FEEDBOT_CONFIG = pkgs.writeTextFile {
          name = "feedbot.config.yaml";
          text = generators.toYAML {} {
            feeds = config.services.feedbot.feedUrls;
            opml_files = config.services.feedbot.opmlFiles;
            rooms = config.services.feedbot.rooms;
          };
        };
        FEEDBOT_CREDENTIALS_CONFIG = config.services.feedbot.credentialsFileLocation;
        FEEDBOT_CACHE = config.services.feedbot.cacheFileLocation;
      };

      serviceConfig = {
        RestartSec = 5;
        Restart = "on-failure";
        User = "feedbot";
      };
    };
  };
}

Deploy in Arroyo NixOS

This is set up to take options; in theory some day someone else could use this... but for now it's mostly for my own benefit.

nix:tangle ~/nix/nixos/feedbot.nix:noweb yes
{ inputs, lib, ... }:
{
  imports = [ inputs.feedbot.nixosModules.default ];
  services.feedbot.feedUrls = [
    <<mkFeeds()>>
  ];
}