Compare commits

...

33 commits

Author SHA1 Message Date
Tulir Asokan
81ec8ed864 Bump version to 0.4.1 2025-01-30 15:53:50 +02:00
Tulir Asokan
72d08096b7 Pass raw data to feedparser. Fixes #59 2025-01-30 15:47:09 +02:00
Tulir Asokan
68e5a84096 Bump version to v0.4.0 2025-01-30 14:10:05 +02:00
Tulir Asokan
f62b0335dd Update linters 2025-01-30 14:08:04 +02:00
Tulir Asokan
a8f1340125 Update feedparser input 2025-01-30 14:07:57 +02:00
Tulir Asokan
b58202ebfb
Merge pull request #35 from AndrewKvalheim/entry-id-fallback
Stabilize entry IDs
2023-02-21 13:24:15 +02:00
Tulir Asokan
eeb71a008f Fix formatting 2023-02-21 13:22:19 +02:00
Tulir Asokan
ef4915e434 Add usage to readme 2023-02-21 12:47:13 +02:00
Tulir Asokan
1a52d18f59 Show current template if ran without arguments 2023-02-21 12:43:32 +02:00
Tulir Asokan
f12d32ad3c Bump version to 0.3.2 2022-10-03 09:25:35 +03:00
Andrew Kvalheim
03bb128005 Key entries by link if missing ID
Resolves the problem of incorrectly duplicated entries in feeds that
update content but don’t explicitly provide entry IDs. Example feed:

  - https://www.to-rss.xyz/wikipedia/current_events/

Example entry:

    <item>
      <title>Current events: 2022-07-13</title>
      <link>https://en.wikipedia.org/wiki/Portal:Current_events/2022_July_13</link>
      <description>[VARIABLE CONTENT]</description>
      <pubDate>Wed, 13 Jul 2022 00:00:00 -0000</pubDate>
      </item>
    <item>

This behavior is suggested by the common practice of using an entry’s
link as its ID value, and is consistent with typical feed aggregators
such as Feedbin and Inoreader.
2022-07-14 11:05:40 -07:00
Tulir Asokan
30ad459870 Move CI script to main maubot repo 2022-06-19 14:27:42 +03:00
Tulir Asokan
877dcffb9c Use custom user agent 2022-06-18 17:47:03 +03:00
Tulir Asokan
e7af4d2657 Bump version to 0.3.1 2022-05-02 10:29:41 +03:00
Tulir Asokan
e87f332e0e Don't break on old mautrix-python versions 2022-04-30 21:24:02 +03:00
Tulir Asokan
fa34d80c4f Update and unpin black 2022-04-30 21:14:38 +03:00
Tulir Asokan
70eb6efed5 Fix Python 3.10 compatibility 2022-04-30 21:14:28 +03:00
Tulir Asokan
35f2fe63df Add support for old SQLites
Closes #31
2022-04-30 21:14:19 +03:00
Tulir Asokan
b9bc6fbc81 Bump version to 0.3.0 2022-03-28 17:25:41 +03:00
Tulir Asokan
b7e4a2a7bd Add IF NOT EXISTS for entry table creation 2022-03-28 17:25:08 +03:00
Tulir Asokan
7b609ebb24 Use different message when there are no subscriptions 2022-03-26 17:19:32 +02:00
Tulir Asokan
9a75ee4021 Make default notification template configurable
Closes #29
Fixes #24

Co-authored-by: noantiq <timucin.boldt@udo.edu>
2022-03-26 14:45:07 +02:00
Tulir Asokan
18ef939a04 Switch to asyncpg for database 2022-03-26 14:32:18 +02:00
Tulir Asokan
428b471fec Add some logs and hacky sorting 2022-02-22 23:11:11 +02:00
Tulir Asokan
947c4748b8 Strip surrounding whitespace from item summary 2022-02-22 23:10:42 +02:00
Tulir Asokan
08ff28bf30 Update CI artifact expiry 2021-11-28 15:35:44 +02:00
Tulir Asokan
f93fcb9489 Bump version to v0.2.6 2021-07-28 12:26:31 +03:00
Tulir Asokan
e5380db9bd Fix feed backoff not being reset correctly 2021-07-28 12:22:58 +03:00
Tulir Asokan
419e137848 Add some more logs 2021-07-21 18:19:13 +03:00
Tulir Asokan
b3e76c338e Catch individual message send errors separately 2021-04-11 00:54:25 +03:00
Tulir Asokan
db492640d7 Respond with error when trying to subscribe to already subscribed feed 2021-04-11 00:50:32 +03:00
Tulir Asokan
79cd475312 Show erroring feeds in !rss subscriptions 2021-04-11 00:43:30 +03:00
Tulir Asokan
c185b31b1c Fix incorrect use of time() 2021-04-10 14:20:55 +03:00
10 changed files with 574 additions and 343 deletions

25
.github/workflows/python-lint.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Python lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: "3.13"
- uses: isort/isort-action@master
with:
sortPaths: "./rss"
- uses: psf/black@stable
with:
src: "./rss"
- name: pre-commit
run: |
pip install pre-commit
pre-commit run -av trailing-whitespace
pre-commit run -av end-of-file-fixer
pre-commit run -av check-yaml
pre-commit run -av check-added-large-files

View file

@ -1,27 +1,3 @@
image: dock.mau.dev/maubot/maubot include:
- project: 'maubot/maubot'
stages: file: '/.gitlab-ci-plugin.yml'
- build
variables:
PYTHONPATH: /opt/maubot
build:
stage: build
except:
- tags
script:
- python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA.mbp
artifacts:
paths:
- "*.mbp"
build tags:
stage: build
only:
- tags
script:
- python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_TAG.mbp
artifacts:
paths:
- "*.mbp"

20
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,20 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
language_version: python3
files: ^rss/.*\.pyi?$
- repo: https://github.com/PyCQA/isort
rev: 6.0.0
hooks:
- id: isort
files: ^rss/.*\.pyi?$

View file

@ -1,2 +1,30 @@
# rss # rss
A [maubot](https://github.com/maubot/maubot) that posts RSS feed updates to Matrix. A [maubot](https://github.com/maubot/maubot) that posts RSS feed updates to Matrix.
## Usage
Basic commands:
* `!rss subscribe <url>` - Subscribe the current room to a feed.
* `!rss unsubscribe <feed ID>` - Unsubscribe the current room from a feed.
* `!rss subscriptions` - List subscriptions (and feed IDs) in the current room.
* `!rss notice <feed ID> [true/false]` - Set whether the bot should send new
posts as `m.notice` (if false, they're sent as `m.text`).
* `!rss template <feed ID> [new template]` - Change the post template for a
feed in the current room. If the new template is omitted, the bot replies
with the current template.
### Templates
The default template is `New post in $feed_title: [$title]($link)`.
Templates are interpreted as markdown with some simple variable substitution.
The following variables are available:
* `$feed_url` - The URL that was used to subscribe to the feed.
* `$feed_link` - The home page of the feed.
* `$feed_title` - The title of the feed.
* `$feed_subtitle` - The subtitle of the feed.
* `$id` - The unique ID of the entry.
* `$date` - The date of the entry.
* `$title` - The title of the entry.
* `$summary` - The summary/description of the entry.
* `$link` - The link of the entry.

View file

@ -8,6 +8,8 @@ spam_sleep: 2
# The prefix for all commands # The prefix for all commands
# It has to be prefixed with ! in matrix to be recognised # It has to be prefixed with ! in matrix to be recognised
command_prefix: "rss" command_prefix: "rss"
# Default post notification template for new subscriptions
notification_template: "New post in $feed_title: [$title]($link)"
# Users who can bypass room permission checks # Users who can bypass room permission checks
admins: admins:
- "@user:example.com" - "@user:example.com"

View file

@ -1,6 +1,6 @@
maubot: 0.1.0 maubot: 0.3.0
id: xyz.maubot.rss id: xyz.maubot.rss
version: 0.2.4 version: 0.4.1
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- rss - rss
@ -10,3 +10,4 @@ extra_files:
dependencies: dependencies:
- feedparser>=5.1 - feedparser>=5.1
database: true database: true
database_type: asyncpg

11
pyproject.toml Normal file
View file

@ -0,0 +1,11 @@
[tool.isort]
profile = "black"
force_to_top = "typing"
from_first = true
combine_as_imports = true
known_first_party = ["mautrix", "maubot"]
line_length = 99
[tool.black]
line-length = 99
target-version = ["py310"]

View file

@ -1,5 +1,5 @@
# rss - A maubot plugin to subscribe to RSS/Atom feeds. # rss - A maubot plugin to subscribe to RSS/Atom feeds.
# Copyright (C) 2021 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@ -13,24 +13,36 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Type, List, Any, Dict, Tuple, Awaitable, Iterable, Optional from __future__ import annotations
from typing import Any, Iterable
from datetime import datetime from datetime import datetime
from time import mktime, time
from string import Template from string import Template
from time import mktime, time
import asyncio import asyncio
import time import hashlib
import html
import io
import aiohttp import aiohttp
import hashlib import attr
import feedparser import feedparser
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from maubot import MessageEvent, Plugin, __version__ as maubot_version
from mautrix.types import (StateEvent, EventType, MessageType, RoomID, EventID,
PowerLevelStateEventContent)
from maubot import Plugin, MessageEvent
from maubot.handlers import command, event from maubot.handlers import command, event
from mautrix.types import (
EventID,
EventType,
MessageType,
PowerLevelStateEventContent,
RoomID,
StateEvent,
)
from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from .db import Database, Feed, Entry, Subscription from .db import DBManager, Entry, Feed, Subscription
from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
@ -41,6 +53,7 @@ class Config(BaseProxyConfig):
helper.copy("max_backoff") helper.copy("max_backoff")
helper.copy("spam_sleep") helper.copy("spam_sleep")
helper.copy("command_prefix") helper.copy("command_prefix")
helper.copy("notification_template")
helper.copy("admins") helper.copy("admins")
@ -48,7 +61,7 @@ class BoolArgument(command.Argument):
def __init__(self, name: str, label: str = None, *, required: bool = False) -> None: def __init__(self, name: str, label: str = None, *, required: bool = False) -> None:
super().__init__(name, label, required=required, pass_raw=False) super().__init__(name, label, required=required, pass_raw=False)
def match(self, val: str, **kwargs) -> Tuple[str, Any]: def match(self, val: str, **kwargs) -> tuple[str, Any]:
part = val.split(" ")[0].lower() part = val.split(" ")[0].lower()
if part in ("f", "false", "n", "no", "0"): if part in ("f", "false", "n", "no", "0"):
res = False res = False
@ -56,26 +69,30 @@ class BoolArgument(command.Argument):
res = True res = True
else: else:
raise ValueError("invalid boolean") raise ValueError("invalid boolean")
return val[len(part):], res return val[len(part) :], res
class RSSBot(Plugin): class RSSBot(Plugin):
db: Database dbm: DBManager
poll_task: asyncio.Future poll_task: asyncio.Future
http: aiohttp.ClientSession http: aiohttp.ClientSession
power_level_cache: Dict[RoomID, Tuple[int, PowerLevelStateEventContent]] power_level_cache: dict[RoomID, tuple[int, PowerLevelStateEventContent]]
@classmethod @classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]: def get_config_class(cls) -> type[BaseProxyConfig]:
return Config return Config
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table
async def start(self) -> None: async def start(self) -> None:
await super().start() await super().start()
self.config.load_and_update() self.config.load_and_update()
self.db = Database(self.database) self.dbm = DBManager(self.database)
self.http = self.client.api.session self.http = self.client.api.session
self.power_level_cache = {} self.power_level_cache = {}
self.poll_task = asyncio.ensure_future(self.poll_feeds(), loop=self.loop) self.poll_task = asyncio.create_task(self.poll_feeds())
async def stop(self) -> None: async def stop(self) -> None:
await super().stop() await super().stop()
@ -89,54 +106,77 @@ class RSSBot(Plugin):
except Exception: except Exception:
self.log.exception("Fatal error while polling feeds") self.log.exception("Fatal error while polling feeds")
def _send(self, feed: Feed, entry: Entry, sub: Subscription) -> Awaitable[EventID]: async def _send(self, feed: Feed, entry: Entry, sub: Subscription) -> EventID:
return self.client.send_markdown(sub.room_id, sub.notification_template.safe_substitute({ message = sub.notification_template.safe_substitute(
"feed_url": feed.url, {
"feed_title": feed.title, "feed_url": feed.url,
"feed_subtitle": feed.subtitle, "feed_title": feed.title,
"feed_link": feed.link, "feed_subtitle": feed.subtitle,
**entry._asdict(), "feed_link": feed.link,
}), msgtype=MessageType.NOTICE if sub.send_notice else MessageType.TEXT, allow_html=True) **attr.asdict(entry),
}
)
msgtype = MessageType.NOTICE if sub.send_notice else MessageType.TEXT
try:
return await self.client.send_markdown(
sub.room_id, message, msgtype=msgtype, allow_html=True
)
except Exception as e:
self.log.warning(f"Failed to send {entry.id} of {feed.id} to {sub.room_id}: {e}")
async def _broadcast(self, feed: Feed, entry: Entry, subscriptions: List[Subscription]) -> None: async def _broadcast(
self, feed: Feed, entry: Entry, subscriptions: list[Subscription]
) -> None:
self.log.debug(f"Broadcasting {entry.id} of {feed.id}")
spam_sleep = self.config["spam_sleep"] spam_sleep = self.config["spam_sleep"]
tasks = [self._send(feed, entry, sub) for sub in subscriptions] tasks = [self._send(feed, entry, sub) for sub in subscriptions]
if spam_sleep >= 0: if spam_sleep >= 0:
for task in tasks: for task in tasks:
await task await task
await asyncio.sleep(spam_sleep, loop=self.loop) await asyncio.sleep(spam_sleep)
else: else:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
async def _poll_once(self) -> None: async def _poll_once(self) -> None:
subs = self.db.get_feeds() subs = await self.dbm.get_feeds()
if not subs: if not subs:
return return
now = int(time.time()) now = int(time())
tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now] tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
feed: Feed feed: Feed
entries: Iterable[Entry] entries: Iterable[Entry]
self.log.info(f"Polling {len(tasks)} feeds")
for res in asyncio.as_completed(tasks): for res in asyncio.as_completed(tasks):
feed, entries = await res feed, entries = await res
self.log.trace(
f"Fetching {feed.id} (backoff: {feed.error_count} / {feed.next_retry}) "
f"success: {bool(entries)}"
)
if not entries: if not entries:
error_count = feed.error_count + 1 error_count = feed.error_count + 1
next_retry_delay = self.config["update_interval"] * 60 * error_count next_retry_delay = self.config["update_interval"] * 60 * error_count
next_retry_delay = min(next_retry_delay, self.config["max_backoff"] * 60) next_retry_delay = min(next_retry_delay, self.config["max_backoff"] * 60)
next_retry = int(time.time() + next_retry_delay) next_retry = int(time() + next_retry_delay)
self.db.set_backoff(feed, error_count, next_retry) self.log.debug(f"Setting backoff of {feed.id} to {error_count} / {next_retry}")
await self.dbm.set_backoff(feed, error_count, next_retry)
continue continue
elif feed.error_count > 0: elif feed.error_count > 0:
self.db.set_backoff(feed, error_count=0, next_retry=0) self.log.debug(f"Resetting backoff of {feed.id}")
await self.dbm.set_backoff(feed, error_count=0, next_retry=0)
try: try:
new_entries = {entry.id: entry for entry in entries} new_entries = {entry.id: entry for entry in entries}
except Exception: except Exception:
self.log.exception(f"Weird error in items of {feed.url}") self.log.exception(f"Weird error in items of {feed.url}")
continue continue
for old_entry in self.db.get_entries(feed.id): for old_entry in await self.dbm.get_entries(feed.id):
new_entries.pop(old_entry.id, None) new_entries.pop(old_entry.id, None)
self.db.add_entries(new_entries.values()) self.log.trace(f"Feed {feed.id} had {len(new_entries)} new entries")
for entry in new_entries.values(): new_entry_list: list[Entry] = list(new_entries.values())
new_entry_list.sort(key=lambda entry: (entry.date, entry.id))
await self.dbm.add_entries(new_entry_list)
for entry in new_entry_list:
await self._broadcast(feed, entry, feed.subscriptions) await self._broadcast(feed, entry, feed.subscriptions)
self.log.info(f"Finished polling {len(tasks)} feeds")
async def _poll_feeds(self) -> None: async def _poll_feeds(self) -> None:
self.log.debug("Polling started") self.log.debug("Polling started")
@ -147,24 +187,35 @@ class RSSBot(Plugin):
self.log.debug("Polling stopped") self.log.debug("Polling stopped")
except Exception: except Exception:
self.log.exception("Error while polling feeds") self.log.exception("Error while polling feeds")
await asyncio.sleep(self.config["update_interval"] * 60, loop=self.loop) await asyncio.sleep(self.config["update_interval"] * 60)
async def try_parse_feed(self, feed: Optional[Feed] = None) -> Tuple[Feed, Iterable[Entry]]: async def try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry]]:
try: try:
self.log.trace(
f"Trying to fetch {feed.id} / {feed.url} "
f"(backoff: {feed.error_count} / {feed.next_retry})"
)
return await self.parse_feed(feed=feed) return await self.parse_feed(feed=feed)
except Exception as e: except Exception as e:
self.log.warning(f"Failed to parse feed {feed.id} / {feed.url}: {e}") self.log.warning(f"Failed to parse feed {feed.id} / {feed.url}: {e}")
return feed, [] return feed, []
async def parse_feed(self, *, feed: Optional[Feed] = None, url: Optional[str] = None @property
) -> Tuple[Feed, Iterable[Entry]]: def _feed_get_headers(self) -> dict[str, str]:
return {
"User-Agent": f"maubot/{maubot_version} +https://github.com/maubot/rss",
}
async def parse_feed(
self, *, feed: Feed | None = None, url: str | None = None
) -> tuple[Feed, list[Entry]]:
if feed is None: if feed is None:
if url is None: if url is None:
raise ValueError("Either feed or url must be set") raise ValueError("Either feed or url must be set")
feed = Feed(-1, url, "", "", "", 0, 0, []) feed = Feed(id=-1, url=url, title="", subtitle="", link="")
elif url is not None: elif url is not None:
raise ValueError("Only one of feed or url must be set") raise ValueError("Only one of feed or url must be set")
resp = await self.http.get(feed.url) resp = await self.http.get(feed.url, headers=self._feed_get_headers)
ct = resp.headers["Content-Type"].split(";")[0].strip() ct = resp.headers["Content-Type"].split(";")[0].strip()
if ct == "application/json" or ct == "application/feed+json": if ct == "application/json" or ct == "application/feed+json":
return await self._parse_json(feed, resp) return await self._parse_json(feed, resp)
@ -172,67 +223,71 @@ class RSSBot(Plugin):
return await self._parse_rss(feed, resp) return await self._parse_rss(feed, resp)
@classmethod @classmethod
async def _parse_json(cls, feed: Feed, resp: aiohttp.ClientResponse async def _parse_json(
) -> Tuple[Feed, Iterable[Entry]]: cls, feed: Feed, resp: aiohttp.ClientResponse
) -> tuple[Feed, list[Entry]]:
content = await resp.json() content = await resp.json()
if content["version"] not in ("https://jsonfeed.org/version/1", if content["version"] not in (
"https://jsonfeed.org/version/1.1"): "https://jsonfeed.org/version/1",
"https://jsonfeed.org/version/1.1",
):
raise ValueError("Unsupported JSON feed version") raise ValueError("Unsupported JSON feed version")
if not isinstance(content["items"], list): if not isinstance(content["items"], list):
raise ValueError("Feed is not a valid JSON feed (items is not a list)") raise ValueError("Feed is not a valid JSON feed (items is not a list)")
feed = Feed(id=feed.id, title=content["title"], subtitle=content.get("subtitle", ""), feed.title = content["title"]
url=feed.url, link=content.get("home_page_url", ""), feed.subtitle = content.get("subtitle", "")
next_retry=0, error_count=0, subscriptions=feed.subscriptions) feed.link = content.get("home_page_url", "")
return feed, (cls._parse_json_entry(feed.id, entry) for entry in content["items"]) return feed, [cls._parse_json_entry(feed.id, entry) for entry in content["items"]]
@classmethod @classmethod
def _parse_json_entry(cls, feed_id: int, entry: Dict[str, Any]) -> Entry: def _parse_json_entry(cls, feed_id: int, entry: dict[str, Any]) -> Entry:
try: try:
date = datetime.fromisoformat(entry["date_published"]) date = datetime.fromisoformat(entry["date_published"])
except (ValueError, KeyError): except (ValueError, KeyError):
date = datetime.now() date = datetime.now()
title = entry.get("title", "") title = entry.get("title", "")
summary = (entry.get("summary") summary = (
or entry.get("content_html") entry.get("summary") or entry.get("content_html") or entry.get("content_text") or ""
or entry.get("content_text") ).strip()
or "")
id = str(entry["id"]) id = str(entry["id"])
link = entry.get("url") or id link = entry.get("url") or id
return Entry(feed_id=feed_id, id=id, date=date, title=title, summary=summary, link=link) return Entry(feed_id=feed_id, id=id, date=date, title=title, summary=summary, link=link)
@classmethod @classmethod
async def _parse_rss(cls, feed: Feed, resp: aiohttp.ClientResponse async def _parse_rss(
) -> Tuple[Feed, Iterable[Entry]]: cls, feed: Feed, resp: aiohttp.ClientResponse
try: ) -> tuple[Feed, list[Entry]]:
content = await resp.text() content = await resp.read()
except UnicodeDecodeError:
try:
content = await resp.text(encoding="utf-8", errors="ignore")
except UnicodeDecodeError:
content = str(await resp.read())[2:-1]
headers = {"Content-Location": feed.url, **resp.headers, "Content-Encoding": "identity"} headers = {"Content-Location": feed.url, **resp.headers, "Content-Encoding": "identity"}
parsed_data = feedparser.parse(content, response_headers=headers) parsed_data = feedparser.parse(io.BytesIO(content), response_headers=headers)
if parsed_data.bozo: if parsed_data.bozo:
if not isinstance(parsed_data.bozo_exception, feedparser.ThingsNobodyCaresAboutButMe): if not isinstance(parsed_data.bozo_exception, feedparser.ThingsNobodyCaresAboutButMe):
raise parsed_data.bozo_exception raise parsed_data.bozo_exception
feed_data = parsed_data.get("feed", {}) feed_data = parsed_data.get("feed", {})
feed = Feed(id=feed.id, url=feed.url, title=feed_data.get("title", feed.url), feed.title = feed_data.get("title", feed.url)
subtitle=feed_data.get("description", ""), link=feed_data.get("link", ""), feed.subtitle = feed_data.get("description", "")
error_count=0, next_retry=0, subscriptions=feed.subscriptions) feed.link = feed_data.get("link", "")
return feed, (cls._parse_rss_entry(feed.id, entry) for entry in parsed_data.entries) return feed, [cls._parse_rss_entry(feed.id, entry) for entry in parsed_data.entries]
@classmethod @classmethod
def _parse_rss_entry(cls, feed_id: int, entry: Any) -> Entry: def _parse_rss_entry(cls, feed_id: int, entry: Any) -> Entry:
return Entry( return Entry(
feed_id=feed_id, feed_id=feed_id,
id=(getattr(entry, "id", None) or id=(
hashlib.sha1(" ".join([getattr(entry, "title", ""), getattr(entry, "id", None)
getattr(entry, "description", ""), or getattr(entry, "link", None)
getattr(entry, "link", "")]).encode("utf-8") or hashlib.sha1(
).hexdigest()), " ".join(
[
getattr(entry, "title", ""),
getattr(entry, "description", ""),
]
).encode("utf-8")
).hexdigest()
),
date=cls._parse_rss_date(entry), date=cls._parse_rss_date(entry),
title=getattr(entry, "title", ""), title=getattr(entry, "title", ""),
summary=getattr(entry, "description", ""), summary=getattr(entry, "description", "").strip(),
link=getattr(entry, "link", ""), link=getattr(entry, "link", ""),
) )
@ -268,95 +323,152 @@ class RSSBot(Plugin):
if not isinstance(state_level, int): if not isinstance(state_level, int):
state_level = 50 state_level = 50
if user_level < state_level: if user_level < state_level:
await evt.reply("You don't have the permission to " await evt.reply(
"manage the subscriptions of this room.") "You don't have the permission to manage the subscriptions of this room."
)
return False return False
return True return True
@command.new(name=lambda self: self.config["command_prefix"], @command.new(
help="Manage this RSS bot", require_subcommand=True) name=lambda self: self.config["command_prefix"],
help="Manage this RSS bot",
require_subcommand=True,
)
async def rss(self) -> None: async def rss(self) -> None:
pass pass
@rss.subcommand("subscribe", aliases=("s", "sub"), @rss.subcommand("subscribe", aliases=("s", "sub"), help="Subscribe this room to a feed.")
help="Subscribe this room to a feed.")
@command.argument("url", "feed URL", pass_raw=True) @command.argument("url", "feed URL", pass_raw=True)
async def subscribe(self, evt: MessageEvent, url: str) -> None: async def subscribe(self, evt: MessageEvent, url: str) -> None:
if not await self.can_manage(evt): if not await self.can_manage(evt):
return return
feed = self.db.get_feed_by_url(url) feed = await self.dbm.get_feed_by_url(url)
if not feed: if not feed:
try: try:
info, entries = await self.parse_feed(url=url) info, entries = await self.parse_feed(url=url)
except Exception as e: except Exception as e:
await evt.reply(f"Failed to load feed: {e}") await evt.reply(f"Failed to load feed: {e}")
return return
feed = self.db.create_feed(info) feed = await self.dbm.create_feed(info)
self.db.add_entries(entries, override_feed_id=feed.id) await self.dbm.add_entries(entries, override_feed_id=feed.id)
elif feed.error_count > 0: elif feed.error_count > 0:
self.db.set_backoff(feed, error_count=feed.error_count, next_retry=0) await self.dbm.set_backoff(feed, error_count=feed.error_count, next_retry=0)
self.db.subscribe(feed.id, evt.room_id, evt.sender) feed_info = f"feed ID {feed.id}: [{feed.title}]({feed.url})"
await evt.reply(f"Subscribed to feed ID {feed.id}: [{feed.title}]({feed.url})") sub, _ = await self.dbm.get_subscription(feed.id, evt.room_id)
if sub is not None:
subscriber = (
"You"
if sub.user_id == evt.sender
else f"[{sub.user_id}](https://matrix.to/#/{sub.user_id})"
)
await evt.reply(f"{subscriber} had already subscribed this room to {feed_info}")
else:
await self.dbm.subscribe(
feed.id, evt.room_id, evt.sender, self.config["notification_template"]
)
await evt.reply(f"Subscribed to {feed_info}")
@rss.subcommand("unsubscribe", aliases=("u", "unsub"), @rss.subcommand(
help="Unsubscribe this room from a feed.") "unsubscribe", aliases=("u", "unsub"), help="Unsubscribe this room from a feed."
)
@command.argument("feed_id", "feed ID", parser=int) @command.argument("feed_id", "feed ID", parser=int)
async def unsubscribe(self, evt: MessageEvent, feed_id: int) -> None: async def unsubscribe(self, evt: MessageEvent, feed_id: int) -> None:
if not await self.can_manage(evt): if not await self.can_manage(evt):
return return
sub, feed = self.db.get_subscription(feed_id, evt.room_id) sub, feed = await self.dbm.get_subscription(feed_id, evt.room_id)
if not sub: if not sub:
await evt.reply("This room is not subscribed to that feed") await evt.reply("This room is not subscribed to that feed")
return return
self.db.unsubscribe(feed.id, evt.room_id) await self.dbm.unsubscribe(feed.id, evt.room_id)
await evt.reply(f"Unsubscribed from feed ID {feed.id}: [{feed.title}]({feed.url})") await evt.reply(f"Unsubscribed from feed ID {feed.id}: [{feed.title}]({feed.url})")
@rss.subcommand("template", aliases=("t", "tpl"), @rss.subcommand(
help="Change the notification template for a subscription in this room") "template",
aliases=("t", "tpl"),
help="Change the notification template for a subscription in this room",
)
@command.argument("feed_id", "feed ID", parser=int) @command.argument("feed_id", "feed ID", parser=int)
@command.argument("template", "new template", pass_raw=True) @command.argument("template", "new template", pass_raw=True, required=False)
async def command_template(self, evt: MessageEvent, feed_id: int, template: str) -> None: async def command_template(self, evt: MessageEvent, feed_id: int, template: str) -> None:
if not await self.can_manage(evt): if not await self.can_manage(evt):
return return
sub, feed = self.db.get_subscription(feed_id, evt.room_id) sub, feed = await self.dbm.get_subscription(feed_id, evt.room_id)
if not sub: if not sub:
await evt.reply("This room is not subscribed to that feed") await evt.reply("This room is not subscribed to that feed")
return return
self.db.update_template(feed.id, evt.room_id, template) if not template:
sub = Subscription(feed_id=feed.id, room_id=sub.room_id, user_id=sub.user_id, await evt.reply(
notification_template=Template(template), send_notice=sub.send_notice) '<p>Current template in this room:</p><pre><code language="markdown">'
sample_entry = Entry(feed.id, "SAMPLE", datetime.now(), "Sample entry", f"{html.escape(sub.notification_template.template)}"
"This is a sample entry to demonstrate your new template", "</code></pre>",
"http://example.com") allow_html=True,
markdown=False,
)
return
await self.dbm.update_template(feed.id, evt.room_id, template)
sub = Subscription(
feed_id=feed.id,
room_id=sub.room_id,
user_id=sub.user_id,
notification_template=Template(template),
send_notice=sub.send_notice,
)
sample_entry = Entry(
feed_id=feed.id,
id="SAMPLE",
date=datetime.now(),
title="Sample entry",
summary="This is a sample entry to demonstrate your new template",
link="http://example.com",
)
await evt.reply(f"Template for feed ID {feed.id} updated. Sample notification:") await evt.reply(f"Template for feed ID {feed.id} updated. Sample notification:")
await self._send(feed, sample_entry, sub) await self._send(feed, sample_entry, sub)
@rss.subcommand("notice", aliases=("n",), @rss.subcommand(
help="Set whether or not the bot should send updates as m.notice") "notice", aliases=("n",), help="Set whether or not the bot should send updates as m.notice"
)
@command.argument("feed_id", "feed ID", parser=int) @command.argument("feed_id", "feed ID", parser=int)
@BoolArgument("setting", "true/false") @BoolArgument("setting", "true/false")
async def command_notice(self, evt: MessageEvent, feed_id: int, setting: bool) -> None: async def command_notice(self, evt: MessageEvent, feed_id: int, setting: bool) -> None:
if not await self.can_manage(evt): if not await self.can_manage(evt):
return return
sub, feed = self.db.get_subscription(feed_id, evt.room_id) sub, feed = await self.dbm.get_subscription(feed_id, evt.room_id)
if not sub: if not sub:
await evt.reply("This room is not subscribed to that feed") await evt.reply("This room is not subscribed to that feed")
return return
self.db.set_send_notice(feed.id, evt.room_id, setting) await self.dbm.set_send_notice(feed.id, evt.room_id, setting)
send_type = "m.notice" if setting else "m.text" send_type = "m.notice" if setting else "m.text"
await evt.reply(f"Updates for feed ID {feed.id} will now be sent as `{send_type}`") await evt.reply(f"Updates for feed ID {feed.id} will now be sent as `{send_type}`")
@rss.subcommand("subscriptions", aliases=("ls", "list", "subs"), @staticmethod
help="List the subscriptions in the current room.") def _format_subscription(feed: Feed, subscriber: str) -> str:
msg = (
f"* {feed.id} - [{feed.title}]({feed.url}) "
f"(subscribed by [{subscriber}](https://matrix.to/#/{subscriber}))"
)
if feed.error_count > 1:
msg += f" \n ⚠️ The last {feed.error_count} attempts to fetch the feed have failed!"
return msg
@rss.subcommand(
"subscriptions",
aliases=("ls", "list", "subs"),
help="List the subscriptions in the current room.",
)
async def command_subscriptions(self, evt: MessageEvent) -> None: async def command_subscriptions(self, evt: MessageEvent) -> None:
subscriptions = self.db.get_feeds_by_room(evt.room_id) subscriptions = await self.dbm.get_feeds_by_room(evt.room_id)
await evt.reply("**Subscriptions in this room:**\n\n" if len(subscriptions) == 0:
+ "\n".join(f"* {feed.id} - [{feed.title}]({feed.url}) (subscribed by " await evt.reply("There are no RSS subscriptions in this room")
f"[{subscriber}](https://matrix.to/#/{subscriber}))" return
for feed, subscriber in subscriptions)) await evt.reply(
"**Subscriptions in this room:**\n\n"
+ "\n".join(
self._format_subscription(feed, subscriber) for feed, subscriber in subscriptions
)
)
@event.on(EventType.ROOM_TOMBSTONE) @event.on(EventType.ROOM_TOMBSTONE)
async def tombstone(self, evt: StateEvent) -> None: async def tombstone(self, evt: StateEvent) -> None:
if not evt.content.replacement_room: if not evt.content.replacement_room:
return return
self.db.update_room_id(evt.room_id, evt.content.replacement_room) await self.dbm.update_room_id(evt.room_id, evt.content.replacement_room)

393
rss/db.py
View file

@ -1,5 +1,5 @@
# rss - A maubot plugin to subscribe to RSS/Atom feeds. # rss - A maubot plugin to subscribe to RSS/Atom feeds.
# Copyright (C) 2020 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@ -13,221 +13,228 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Iterable, NamedTuple, List, Optional, Dict, Tuple from __future__ import annotations
from datetime import datetime from datetime import datetime
from string import Template from string import Template
from sqlalchemy import (Column, String, Integer, DateTime, Text, Boolean, ForeignKey, from asyncpg import Record
Table, MetaData, from attr import dataclass
select, and_, true) import attr
from sqlalchemy.engine.base import Engine
from mautrix.types import UserID, RoomID from mautrix.types import RoomID, UserID
from mautrix.util.async_db import Database, Scheme
Subscription = NamedTuple("Subscription", feed_id=int, room_id=RoomID, user_id=UserID, # TODO make this import unconditional after updating mautrix-python
notification_template=Template, send_notice=bool) try:
Feed = NamedTuple("Feed", id=int, url=str, title=str, subtitle=str, link=str, next_retry=int, from mautrix.util.async_db import SQLiteCursor
error_count=int, subscriptions=List[Subscription]) except ImportError:
Entry = NamedTuple("Entry", feed_id=int, id=str, date=datetime, title=str, summary=str, link=str) SQLiteCursor = None
class Database: @dataclass
db: Engine class Subscription:
feed: Table feed_id: int
subscription: Table room_id: RoomID
entry: Table user_id: UserID
version: Table notification_template: Template
send_notice: bool
def __init__(self, db: Engine) -> None: @classmethod
def from_row(cls, row: Record | None) -> Subscription | None:
if not row:
return None
feed_id = row["id"]
room_id = row["room_id"]
user_id = row["user_id"]
if not room_id or not user_id:
return None
send_notice = bool(row["send_notice"])
tpl = Template(row["notification_template"])
return cls(
feed_id=feed_id,
room_id=room_id,
user_id=user_id,
notification_template=tpl,
send_notice=send_notice,
)
@dataclass
class Feed:
id: int
url: str
title: str
subtitle: str
link: str
next_retry: int = 0
error_count: int = 0
subscriptions: list[Subscription] = attr.ib(factory=lambda: [])
@classmethod
def from_row(cls, row: Record | None) -> Feed | None:
if not row:
return None
data = {**row}
data.pop("room_id", None)
data.pop("user_id", None)
data.pop("send_notice", None)
data.pop("notification_template", None)
return cls(**data, subscriptions=[])
date_fmt = "%Y-%m-%d %H:%M:%S"
date_fmt_microseconds = "%Y-%m-%d %H:%M:%S.%f"
@dataclass
class Entry:
feed_id: int
id: str
date: datetime
title: str
summary: str
link: str
@classmethod
def from_row(cls, row: Record | None) -> Entry | None:
if not row:
return None
data = {**row}
date = data.pop("date")
if not isinstance(date, datetime):
try:
date = datetime.strptime(date, date_fmt_microseconds if "." in date else date_fmt)
except ValueError:
date = datetime.now()
return cls(**data, date=date)
class DBManager:
db: Database
def __init__(self, db: Database) -> None:
self.db = db self.db = db
metadata = MetaData()
self.feed = Table("feed", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("url", Text, nullable=False, unique=True),
Column("title", Text, nullable=False),
Column("subtitle", Text, nullable=False),
Column("link", Text, nullable=False),
Column("next_retry", Integer, nullable=False),
Column("error_count", Integer, nullable=False))
self.subscription = Table("subscription", metadata,
Column("feed_id", Integer, ForeignKey("feed.id"),
primary_key=True),
Column("room_id", String(255), primary_key=True),
Column("user_id", String(255), nullable=False),
Column("notification_template", String(255), nullable=True),
Column("send_notice", Boolean, nullable=False,
server_default=true()))
self.entry = Table("entry", metadata,
Column("feed_id", Integer, ForeignKey("feed.id"), primary_key=True),
Column("id", String(255), primary_key=True),
Column("date", DateTime, nullable=False),
Column("title", Text, nullable=False),
Column("summary", Text, nullable=False),
Column("link", Text, nullable=False))
self.version = Table("version", metadata,
Column("version", Integer, primary_key=True))
self.upgrade()
def upgrade(self) -> None: async def get_feeds(self) -> list[Feed]:
self.db.execute("CREATE TABLE IF NOT EXISTS version (version INTEGER PRIMARY KEY)") q = """
try: SELECT id, url, title, subtitle, link, next_retry, error_count,
version, = next(self.db.execute(select([self.version.c.version]))) room_id, user_id, notification_template, send_notice
except (StopIteration, IndexError): FROM feed INNER JOIN subscription ON feed.id = subscription.feed_id
version = 0 """
if version == 0: rows = await self.db.fetch(q)
self.db.execute("""CREATE TABLE IF NOT EXISTS feed ( feeds: dict[int, Feed] = {}
id INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL,
link TEXT NOT NULL,
PRIMARY KEY (id),
UNIQUE (url)
)""")
self.db.execute("""CREATE TABLE IF NOT EXISTS subscription (
feed_id INTEGER NOT NULL,
room_id VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL,
notification_template VARCHAR(255),
PRIMARY KEY (feed_id, room_id),
FOREIGN KEY(feed_id) REFERENCES feed (id)
)""")
self.db.execute("""CREATE TABLE IF NOT EXISTS entry (
feed_id INTEGER NOT NULL,
id VARCHAR(255) NOT NULL,
date DATETIME NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
link TEXT NOT NULL,
PRIMARY KEY (feed_id, id),
FOREIGN KEY(feed_id) REFERENCES feed (id)
)""")
version = 1
if version == 1:
self.db.execute("ALTER TABLE subscription ADD COLUMN send_notice BOOLEAN DEFAULT true")
version = 2
if version == 2:
self.db.execute("ALTER TABLE feed ADD COLUMN next_retry BIGINT DEFAULT 0")
self.db.execute("ALTER TABLE feed ADD COLUMN error_count BIGINT DEFAULT 0")
version = 3
self.db.execute(self.version.delete())
self.db.execute(self.version.insert().values(version=version))
def get_feeds(self) -> Iterable[Feed]:
rows = self.db.execute(select([self.feed,
self.subscription.c.room_id,
self.subscription.c.user_id,
self.subscription.c.notification_template,
self.subscription.c.send_notice])
.where(self.subscription.c.feed_id == self.feed.c.id))
map: Dict[int, Feed] = {}
for row in rows: for row in rows:
(feed_id, url, title, subtitle, link, next_retry, error_count, try:
room_id, user_id, notification_template, send_notice) = row feed = feeds[row["id"]]
map.setdefault(feed_id, Feed(feed_id, url, title, subtitle, link, next_retry, except KeyError:
error_count, subscriptions=[])) feed = feeds[row["id"]] = Feed.from_row(row)
map[feed_id].subscriptions.append( feed.subscriptions.append(Subscription.from_row(row))
Subscription(feed_id=feed_id, room_id=room_id, user_id=user_id, return list(feeds.values())
notification_template=Template(notification_template),
send_notice=send_notice))
return map.values()
def get_feeds_by_room(self, room_id: RoomID) -> Iterable[Tuple[Feed, UserID]]: async def get_feeds_by_room(self, room_id: RoomID) -> list[tuple[Feed, UserID]]:
return ((Feed(feed_id, url, title, subtitle, link, next_retry, error_count, q = """
subscriptions=[]), SELECT id, url, title, subtitle, link, next_retry, error_count, user_id FROM feed
user_id) INNER JOIN subscription ON feed.id = subscription.feed_id AND subscription.room_id = $1
for (feed_id, url, title, subtitle, link, next_retry, error_count, user_id) in """
self.db.execute(select([self.feed, self.subscription.c.user_id]) rows = await self.db.fetch(q, room_id)
.where(and_(self.subscription.c.room_id == room_id, return [(Feed.from_row(row), row["user_id"]) for row in rows]
self.subscription.c.feed_id == self.feed.c.id))))
def get_rooms_by_feed(self, feed_id: int) -> Iterable[RoomID]: async def get_entries(self, feed_id: int) -> list[Entry]:
return (row[0] for row in q = "SELECT feed_id, id, date, title, summary, link FROM entry WHERE feed_id = $1"
self.db.execute(select([self.subscription.c.room_id]) return [Entry.from_row(row) for row in await self.db.fetch(q, feed_id)]
.where(self.subscription.c.feed_id == feed_id)))
def get_entries(self, feed_id: int) -> Iterable[Entry]: async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None:
return (Entry(*row) for row in
self.db.execute(select([self.entry]).where(self.entry.c.feed_id == feed_id)))
def add_entries(self, entries: Iterable[Entry], override_feed_id: Optional[int] = None) -> None:
if not entries: if not entries:
return return
entries = [entry._asdict() for entry in entries] if override_feed_id:
if override_feed_id is not None:
for entry in entries: for entry in entries:
entry["feed_id"] = override_feed_id entry.feed_id = override_feed_id
self.db.execute(self.entry.insert(), entries) records = [attr.astuple(entry) for entry in entries]
columns = ("feed_id", "id", "date", "title", "summary", "link")
async with self.db.acquire() as conn:
if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("entry", records=records, columns=columns)
else:
q = (
"INSERT INTO entry (feed_id, id, date, title, summary, link) "
"VALUES ($1, $2, $3, $4, $5, $6)"
)
await conn.executemany(q, records)
def get_feed_by_url(self, url: str) -> Optional[Feed]: async def get_feed_by_url(self, url: str) -> Feed | None:
rows = self.db.execute(select([self.feed]).where(self.feed.c.url == url)) q = "SELECT id, url, title, subtitle, link, next_retry, error_count FROM feed WHERE url=$1"
try: return Feed.from_row(await self.db.fetchrow(q, url))
row = next(rows)
return Feed(*row, subscriptions=[])
except (ValueError, StopIteration):
return None
def get_feed_by_id(self, feed_id: int) -> Optional[Feed]: async def get_subscription(
rows = self.db.execute(select([self.feed]).where(self.feed.c.id == feed_id)) self, feed_id: int, room_id: RoomID
try: ) -> tuple[Subscription | None, Feed | None]:
row = next(rows) q = """
return Feed(*row, subscriptions=[]) SELECT id, url, title, subtitle, link, next_retry, error_count,
except (ValueError, StopIteration): room_id, user_id, notification_template, send_notice
return None FROM feed LEFT JOIN subscription ON feed.id = subscription.feed_id AND room_id = $2
WHERE feed.id = $1
"""
row = await self.db.fetchrow(q, feed_id, room_id)
return Subscription.from_row(row), Feed.from_row(row)
def get_subscription(self, feed_id: int, room_id: RoomID) -> Tuple[Optional[Subscription], async def update_room_id(self, old: RoomID, new: RoomID) -> None:
Optional[Feed]]: await self.db.execute("UPDATE subscription SET room_id = $1 WHERE room_id = $2", new, old)
tbl = self.subscription
rows = self.db.execute(select([self.feed, tbl.c.room_id, tbl.c.user_id,
tbl.c.notification_template, tbl.c.send_notice])
.where(and_(tbl.c.feed_id == feed_id, tbl.c.room_id == room_id,
self.feed.c.id == feed_id)))
try:
(feed_id, url, title, subtitle, link, next_retry, error_count,
room_id, user_id, template, send_notice) = next(rows)
notification_template = Template(template)
return (Subscription(feed_id, room_id, user_id, notification_template, send_notice)
if room_id else None,
Feed(feed_id, url, title, subtitle, link, next_retry, error_count, []))
except (ValueError, StopIteration):
return None, None
def update_room_id(self, old: RoomID, new: RoomID) -> None: async def create_feed(self, info: Feed) -> Feed:
self.db.execute(self.subscription.update() q = (
.where(self.subscription.c.room_id == old) "INSERT INTO feed (url, title, subtitle, link, next_retry) "
.values(room_id=new)) "VALUES ($1, $2, $3, $4, $5) RETURNING (id)"
)
# SQLite only gained RETURNING support in v3.35 (2021-03-12)
# TODO remove this special case in a couple of years
if self.db.scheme == Scheme.SQLITE:
cur = await self.db.execute(
q.replace(" RETURNING (id)", ""),
info.url,
info.title,
info.subtitle,
info.link,
info.next_retry,
)
if SQLiteCursor is not None:
assert isinstance(cur, SQLiteCursor)
info.id = cur.lastrowid
else:
info.id = await self.db.fetchval(
q, info.url, info.title, info.subtitle, info.link, info.next_retry
)
return info
def create_feed(self, info: Feed) -> Feed: async def set_backoff(self, info: Feed, error_count: int, next_retry: int) -> None:
res = self.db.execute(self.feed.insert().values(url=info.url, title=info.title, q = "UPDATE feed SET error_count = $2, next_retry = $3 WHERE id = $1"
subtitle=info.subtitle, link=info.link, await self.db.execute(q, info.id, error_count, next_retry)
next_retry=info.next_retry))
return Feed(id=res.inserted_primary_key[0], url=info.url, title=info.title,
subtitle=info.subtitle, link=info.link, next_retry=info.next_retry,
error_count=info.error_count, subscriptions=[])
def set_backoff(self, info: Feed, error_count: int, next_retry: int) -> None: async def subscribe(
self.db.execute(self.feed.update() self,
.where(self.feed.c.id == info.id) feed_id: int,
.values(error_count=error_count, next_retry=next_retry)) room_id: RoomID,
user_id: UserID,
template: str | None = None,
send_notice: bool = True,
) -> None:
q = """
INSERT INTO subscription (feed_id, room_id, user_id, notification_template, send_notice)
VALUES ($1, $2, $3, $4, $5)
"""
template = template or "New post in $feed_title: [$title]($link)"
await self.db.execute(q, feed_id, room_id, user_id, template, send_notice)
def subscribe(self, feed_id: int, room_id: RoomID, user_id: UserID) -> None: async def unsubscribe(self, feed_id: int, room_id: RoomID) -> None:
self.db.execute(self.subscription.insert().values( q = "DELETE FROM subscription WHERE feed_id = $1 AND room_id = $2"
feed_id=feed_id, room_id=room_id, user_id=user_id, await self.db.execute(q, feed_id, room_id)
notification_template="New post in $feed_title: [$title]($link)"))
def unsubscribe(self, feed_id: int, room_id: RoomID) -> None: async def update_template(self, feed_id: int, room_id: RoomID, template: str) -> None:
tbl = self.subscription q = "UPDATE subscription SET notification_template=$3 WHERE feed_id=$1 AND room_id=$2"
self.db.execute(tbl.delete().where(and_(tbl.c.feed_id == feed_id, await self.db.execute(q, feed_id, room_id, template)
tbl.c.room_id == room_id)))
def update_template(self, feed_id: int, room_id: RoomID, template: str) -> None: async def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None:
tbl = self.subscription q = "UPDATE subscription SET send_notice=$3 WHERE feed_id=$1 AND room_id=$2"
self.db.execute(tbl.update() await self.db.execute(q, feed_id, room_id, send_notice)
.where(and_(tbl.c.feed_id == feed_id, tbl.c.room_id == room_id))
.values(notification_template=template))
def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None:
tbl = self.subscription
self.db.execute(tbl.update()
.where(and_(tbl.c.feed_id == feed_id, tbl.c.room_id == room_id))
.values(send_notice=send_notice))

View file

@ -1,5 +1,5 @@
# rss - A maubot plugin to subscribe to RSS/Atom feeds. # rss - A maubot plugin to subscribe to RSS/Atom feeds.
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@ -13,13 +13,62 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import select from mautrix.util.async_db import Connection, Scheme, UpgradeTable
from sqlalchemy.engine.base import Engine
from alembic.migration import MigrationContext upgrade_table = UpgradeTable()
from alembic.operations import Operations
def run(engine: Engine): @upgrade_table.register(description="Latest revision", upgrades_to=3)
conn = engine.connect() async def upgrade_latest(conn: Connection, scheme: Scheme) -> None:
ctx = MigrationContext.configure(conn) gen = "GENERATED ALWAYS AS IDENTITY" if scheme != Scheme.SQLITE else ""
op = Operations(ctx) await conn.execute(
f"""CREATE TABLE IF NOT EXISTS feed (
id INTEGER {gen},
url TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL,
link TEXT NOT NULL,
next_retry BIGINT DEFAULT 0,
error_count BIGINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE (url)
)"""
)
await conn.execute(
"""CREATE TABLE IF NOT EXISTS subscription (
feed_id INTEGER,
room_id TEXT,
user_id TEXT NOT NULL,
notification_template TEXT,
send_notice BOOLEAN DEFAULT true,
PRIMARY KEY (feed_id, room_id),
FOREIGN KEY (feed_id) REFERENCES feed (id)
)"""
)
await conn.execute(
"""CREATE TABLE IF NOT EXISTS entry (
feed_id INTEGER,
id TEXT,
date timestamp NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
link TEXT NOT NULL,
PRIMARY KEY (feed_id, id),
FOREIGN KEY (feed_id) REFERENCES feed (id)
)"""
)
@upgrade_table.register(description="Add send_notice field to subscriptions")
async def upgrade_v2(conn: Connection) -> None:
await conn.execute("ALTER TABLE subscription ADD COLUMN send_notice BOOLEAN DEFAULT true")
@upgrade_table.register(description="Add error counts to feeds")
async def upgrade_v3(conn: Connection) -> None:
await conn.execute("ALTER TABLE feed ADD COLUMN next_retry BIGINT DEFAULT 0")
await conn.execute("ALTER TABLE feed ADD COLUMN error_count BIGINT DEFAULT 0")