Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

10 changed files with 227 additions and 685 deletions

View File

@ -1,25 +0,0 @@
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,3 +0,0 @@
include:
- project: 'maubot/maubot'
file: '/.gitlab-ci-plugin.yml'

View File

@ -1,20 +0,0 @@
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,30 +1,2 @@
# 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

@ -1,15 +1,11 @@
# Feed update interval in minutes # Feed update interval in minutes
update_interval: 60 update_interval: 60
# Maximum backoff in minutes when failing to fetch feeds (defaults to 5 days)
max_backoff: 7200
# The time to sleep between send requests when broadcasting a new feed entry. # The time to sleep between send requests when broadcasting a new feed entry.
# Set to 0 to disable sleep or -1 to run all requests asynchronously at once. # Set to 0 to disable sleep or -1 to run all requests asynchronously at once.
spam_sleep: 2 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,5 @@
maubot: 0.3.0
id: xyz.maubot.rss id: xyz.maubot.rss
version: 0.4.1 version: 0.1.0
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- rss - rss
@ -10,4 +9,3 @@ extra_files:
dependencies: dependencies:
- feedparser>=5.1 - feedparser>=5.1
database: true database: true
database_type: asyncpg

View File

@ -1,11 +0,0 @@
[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) 2022 Tulir Asokan # Copyright (C) 2018 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,86 +13,48 @@
# #
# 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 __future__ import annotations from typing import Type, List, Any, Dict, Tuple, Awaitable, Callable
from typing import Any, Iterable
from datetime import datetime from datetime import datetime
from string import Template
from time import mktime, time from time import mktime, time
from string import Template
import asyncio import asyncio
import hashlib
import html
import io
import aiohttp import aiohttp
import attr
import feedparser import feedparser
from maubot import MessageEvent, Plugin, __version__ as maubot_version
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 mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from mautrix.types import EventType, MessageType, RoomID, EventID, PowerLevelStateEventContent
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from .db import DBManager, Entry, Feed, Subscription from .db import Database, Feed, Entry, Subscription
from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
class Config(BaseProxyConfig): class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("update_interval") helper.copy("update_interval")
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")
class BoolArgument(command.Argument):
def __init__(self, name: str, label: str = None, *, required: bool = False) -> None:
super().__init__(name, label, required=required, pass_raw=False)
def match(self, val: str, **kwargs) -> tuple[str, Any]:
part = val.split(" ")[0].lower()
if part in ("f", "false", "n", "no", "0"):
res = False
elif part in ("t", "true", "y", "yes", "1"):
res = True
else:
raise ValueError("invalid boolean")
return val[len(part) :], res
class RSSBot(Plugin): class RSSBot(Plugin):
dbm: DBManager db: Database
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.dbm = DBManager(self.database) self.db = Database(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.create_task(self.poll_feeds()) self.poll_task = asyncio.ensure_future(self.poll_feeds(), loop=self.loop)
async def stop(self) -> None: async def stop(self) -> None:
await super().stop() await super().stop()
@ -106,77 +68,41 @@ class RSSBot(Plugin):
except Exception: except Exception:
self.log.exception("Fatal error while polling feeds") self.log.exception("Fatal error while polling feeds")
async def _send(self, feed: Feed, entry: Entry, sub: Subscription) -> EventID: def _send(self, feed: Feed, entry: Entry, template: Template, room_id: RoomID
message = sub.notification_template.safe_substitute( ) -> Awaitable[EventID]:
{ return self.client.send_markdown(room_id, template.safe_substitute({
"feed_url": feed.url, "feed_url": feed.url,
"feed_title": feed.title, "feed_title": feed.title,
"feed_subtitle": feed.subtitle, "feed_subtitle": feed.subtitle,
"feed_link": feed.link, "feed_link": feed.link,
**attr.asdict(entry), **entry._asdict(),
} }), msgtype=MessageType.NOTICE)
)
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( async def _broadcast(self, feed: Feed, entry: Entry, subscriptions: List[Subscription]) -> None:
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.notification_template, sub.room_id)
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) await asyncio.sleep(spam_sleep, loop=self.loop)
else: else:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
async def _poll_once(self) -> None: async def _poll_once(self) -> None:
subs = await self.dbm.get_feeds() subs = self.db.get_feeds()
if not subs: if not subs:
return return
now = int(time()) datas = await asyncio.gather(*[self.read_feed(feed.url) for feed in subs], loop=self.loop)
tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now] for feed, data in zip(subs, datas):
feed: Feed parsed_data = feedparser.parse(data)
entries: Iterable[Entry] entries = parsed_data.entries
self.log.info(f"Polling {len(tasks)} feeds") new_entries = {entry.id: entry for entry in self.find_entries(feed.id, entries)}
for res in asyncio.as_completed(tasks): for old_entry in self.db.get_entries(feed.id):
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:
error_count = feed.error_count + 1
next_retry_delay = self.config["update_interval"] * 60 * error_count
next_retry_delay = min(next_retry_delay, self.config["max_backoff"] * 60)
next_retry = int(time() + next_retry_delay)
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
elif feed.error_count > 0:
self.log.debug(f"Resetting backoff of {feed.id}")
await self.dbm.set_backoff(feed, error_count=0, next_retry=0)
try:
new_entries = {entry.id: entry for entry in entries}
except Exception:
self.log.exception(f"Weird error in items of {feed.url}")
continue
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.log.trace(f"Feed {feed.id} had {len(new_entries)} new entries") self.db.add_entries(new_entries.values())
new_entry_list: list[Entry] = list(new_entries.values()) for entry in 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")
@ -187,122 +113,46 @@ 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) await asyncio.sleep(self.config["update_interval"] * 60, loop=self.loop)
async def try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry]]: async def read_feed(self, url: str) -> str:
try: try:
self.log.trace( resp = await self.http.get(url)
f"Trying to fetch {feed.id} / {feed.url} " except Exception:
f"(backoff: {feed.error_count} / {feed.next_retry})" self.log.exception(f"Error fetching {url}")
) return ""
return await self.parse_feed(feed=feed)
except Exception as e:
self.log.warning(f"Failed to parse feed {feed.id} / {feed.url}: {e}")
return feed, []
@property
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 url is None:
raise ValueError("Either feed or url must be set")
feed = Feed(id=-1, url=url, title="", subtitle="", link="")
elif url is not None:
raise ValueError("Only one of feed or url must be set")
resp = await self.http.get(feed.url, headers=self._feed_get_headers)
ct = resp.headers["Content-Type"].split(";")[0].strip()
if ct == "application/json" or ct == "application/feed+json":
return await self._parse_json(feed, resp)
else:
return await self._parse_rss(feed, resp)
@classmethod
async def _parse_json(
cls, feed: Feed, resp: aiohttp.ClientResponse
) -> tuple[Feed, list[Entry]]:
content = await resp.json()
if content["version"] not in (
"https://jsonfeed.org/version/1",
"https://jsonfeed.org/version/1.1",
):
raise ValueError("Unsupported JSON feed version")
if not isinstance(content["items"], list):
raise ValueError("Feed is not a valid JSON feed (items is not a list)")
feed.title = content["title"]
feed.subtitle = content.get("subtitle", "")
feed.link = content.get("home_page_url", "")
return feed, [cls._parse_json_entry(feed.id, entry) for entry in content["items"]]
@classmethod
def _parse_json_entry(cls, feed_id: int, entry: dict[str, Any]) -> Entry:
try: try:
date = datetime.fromisoformat(entry["date_published"]) content = await resp.text()
except (ValueError, KeyError): except UnicodeDecodeError:
date = datetime.now() try:
title = entry.get("title", "") content = await resp.text(encoding="utf-8")
summary = ( except:
entry.get("summary") or entry.get("content_html") or entry.get("content_text") or "" content = str(await resp.read())[2:-1]
).strip() return content
id = str(entry["id"])
link = entry.get("url") or id
return Entry(feed_id=feed_id, id=id, date=date, title=title, summary=summary, link=link)
@classmethod
async def _parse_rss(
cls, feed: Feed, resp: aiohttp.ClientResponse
) -> tuple[Feed, list[Entry]]:
content = await resp.read()
headers = {"Content-Location": feed.url, **resp.headers, "Content-Encoding": "identity"}
parsed_data = feedparser.parse(io.BytesIO(content), response_headers=headers)
if parsed_data.bozo:
if not isinstance(parsed_data.bozo_exception, feedparser.ThingsNobodyCaresAboutButMe):
raise parsed_data.bozo_exception
feed_data = parsed_data.get("feed", {})
feed.title = feed_data.get("title", feed.url)
feed.subtitle = feed_data.get("description", "")
feed.link = feed_data.get("link", "")
return feed, [cls._parse_rss_entry(feed.id, entry) for entry in parsed_data.entries]
@classmethod
def _parse_rss_entry(cls, feed_id: int, entry: Any) -> Entry:
return Entry(
feed_id=feed_id,
id=(
getattr(entry, "id", None)
or getattr(entry, "link", None)
or hashlib.sha1(
" ".join(
[
getattr(entry, "title", ""),
getattr(entry, "description", ""),
]
).encode("utf-8")
).hexdigest()
),
date=cls._parse_rss_date(entry),
title=getattr(entry, "title", ""),
summary=getattr(entry, "description", "").strip(),
link=getattr(entry, "link", ""),
)
@staticmethod @staticmethod
def _parse_rss_date(entry: Any) -> datetime: def get_date(entry: Any) -> datetime:
try: try:
return datetime.fromtimestamp(mktime(entry["published_parsed"])) return datetime.fromtimestamp(mktime(entry["published_parsed"]))
except (KeyError, TypeError, ValueError): except (KeyError, TypeError):
pass pass
try: try:
return datetime.fromtimestamp(mktime(entry["date_parsed"])) return datetime.fromtimestamp(mktime(entry["date_parsed"]))
except (KeyError, TypeError, ValueError): except (KeyError, TypeError):
pass pass
return datetime.now() return datetime.now()
@classmethod
def find_entries(cls, feed_id: int, entries: List[Any]) -> List[Entry]:
return [Entry(
feed_id=feed_id,
id=entry.id,
date=cls.get_date(entry),
title=getattr(entry, "title", ""),
summary=getattr(entry, "description", ""),
link=getattr(entry, "link", ""),
) for entry in entries]
async def get_power_levels(self, room_id: RoomID) -> PowerLevelStateEventContent: async def get_power_levels(self, room_id: RoomID) -> PowerLevelStateEventContent:
try: try:
expiry, levels = self.power_level_cache[room_id] expiry, levels = self.power_level_cache[room_id]
@ -319,156 +169,75 @@ class RSSBot(Plugin):
return True return True
levels = await self.get_power_levels(evt.room_id) levels = await self.get_power_levels(evt.room_id)
user_level = levels.get_user_level(evt.sender) user_level = levels.get_user_level(evt.sender)
state_level = levels.get_event_level(rss_change_level) state_level = levels.events.get("xyz.maubot.rss", levels.state_default)
if not isinstance(state_level, int): if type(state_level) != int:
state_level = 50 state_level = 50
if user_level < state_level: if user_level < state_level:
await evt.reply( await evt.reply("You don't have the permission to 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( @command.new(name=lambda self: self.config["command_prefix"],
name=lambda self: self.config["command_prefix"], help="Manage this RSS bot", require_subcommand=True)
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"), help="Subscribe this room to a feed.") @rss.subcommand("subscribe", aliases=("s", "sub"),
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 = await self.dbm.get_feed_by_url(url) feed = self.db.get_feed_by_url(url)
if not feed: if not feed:
try: metadata = feedparser.parse(await self.read_feed(url))
info, entries = await self.parse_feed(url=url) if metadata.bozo:
except Exception as e: await evt.reply("That doesn't look like a valid feed.")
await evt.reply(f"Failed to load feed: {e}")
return return
feed = await self.dbm.create_feed(info) channel = metadata.get("channel", {})
await self.dbm.add_entries(entries, override_feed_id=feed.id) feed = self.db.create_feed(url, channel.get("title", url),
elif feed.error_count > 0: channel.get("description", ""),
await self.dbm.set_backoff(feed, error_count=feed.error_count, next_retry=0) channel.get("link", ""))
feed_info = f"feed ID {feed.id}: [{feed.title}]({feed.url})" self.db.add_entries(self.find_entries(feed.id, metadata.entries))
sub, _ = await self.dbm.get_subscription(feed.id, evt.room_id) self.db.subscribe(feed.id, evt.room_id, evt.sender)
if sub is not None: await evt.reply(f"Subscribed to feed ID {feed.id}: [{feed.title}]({feed.url})")
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( @rss.subcommand("unsubscribe", aliases=("u", "unsub"),
"unsubscribe", aliases=("u", "unsub"), help="Unsubscribe this room from a feed." 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 = await self.dbm.get_subscription(feed_id, evt.room_id) sub, feed = self.db.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
await self.dbm.unsubscribe(feed.id, evt.room_id) self.db.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( @rss.subcommand("template", aliases=("t", "tpl"),
"template", help="Change the notification template for a subscription in this room")
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, required=False) @command.argument("template", "new template", pass_raw=True)
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 = await self.dbm.get_subscription(feed_id, evt.room_id) sub, feed = self.db.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
if not template: self.db.update_template(feed.id, evt.room_id, template)
await evt.reply( sample_entry = Entry(feed.id, "SAMPLE", datetime.now(), "Sample entry",
'<p>Current template in this room:</p><pre><code language="markdown">' "This is a sample entry to demonstrate your new template",
f"{html.escape(sub.notification_template.template)}" "http://example.com")
"</code></pre>",
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, Template(template), sub.room_id)
@rss.subcommand( @rss.subcommand("subscriptions", aliases=("ls", "list", "subs"),
"notice", aliases=("n",), help="Set whether or not the bot should send updates as m.notice" help="List the subscriptions in the current room.")
)
@command.argument("feed_id", "feed ID", parser=int)
@BoolArgument("setting", "true/false")
async def command_notice(self, evt: MessageEvent, feed_id: int, setting: bool) -> None:
if not await self.can_manage(evt):
return
sub, feed = await self.dbm.get_subscription(feed_id, evt.room_id)
if not sub:
await evt.reply("This room is not subscribed to that feed")
return
await self.dbm.set_send_notice(feed.id, evt.room_id, setting)
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}`")
@staticmethod
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 = await self.dbm.get_feeds_by_room(evt.room_id) subscriptions = self.db.get_feeds_by_room(evt.room_id)
if len(subscriptions) == 0: await evt.reply("**Subscriptions in this room:**\n\n"
await evt.reply("There are no RSS subscriptions in this room") + "\n".join(f"* {feed.id} - [{feed.title}]({feed.url}) (subscribed by "
return f"[{subscriber}](https://matrix.to/#/{subscriber}))"
await evt.reply( for feed, subscriber in subscriptions))
"**Subscriptions in this room:**\n\n"
+ "\n".join(
self._format_subscription(feed, subscriber) for feed, subscriber in subscriptions
)
)
@event.on(EventType.ROOM_TOMBSTONE)
async def tombstone(self, evt: StateEvent) -> None:
if not evt.content.replacement_room:
return
await self.dbm.update_room_id(evt.room_id, evt.content.replacement_room)

321
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) 2022 Tulir Asokan # Copyright (C) 2018 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,228 +13,143 @@
# #
# 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 __future__ import annotations from typing import Iterable, NamedTuple, List, Optional, Dict, Tuple
from datetime import datetime from datetime import datetime
from string import Template from string import Template
from asyncpg import Record from sqlalchemy import (Column, String, Integer, DateTime, Text, ForeignKey,
from attr import dataclass Table, MetaData,
import attr select, and_)
from sqlalchemy.engine.base import Engine
from mautrix.types import RoomID, UserID from mautrix.types import UserID, RoomID
from mautrix.util.async_db import Database, Scheme
# TODO make this import unconditional after updating mautrix-python Subscription = NamedTuple("Subscription", feed_id=int, room_id=RoomID, user_id=UserID,
try: notification_template=Template)
from mautrix.util.async_db import SQLiteCursor Feed = NamedTuple("Feed", id=int, url=str, title=str, subtitle=str, link=str,
except ImportError: subscriptions=List[Subscription])
SQLiteCursor = None Entry = NamedTuple("Entry", feed_id=int, id=str, date=datetime, title=str, summary=str, link=str)
@dataclass class Database:
class Subscription: db: Engine
feed_id: int feed: Table
room_id: RoomID subscription: Table
user_id: UserID entry: Table
notification_template: Template version: Table
send_notice: bool
@classmethod def __init__(self, db: Engine) -> None:
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))
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))
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))
metadata.create_all(db)
async def get_feeds(self) -> list[Feed]: def get_feeds(self) -> Iterable[Feed]:
q = """ rows = self.db.execute(select([self.feed,
SELECT id, url, title, subtitle, link, next_retry, error_count, self.subscription.c.room_id,
room_id, user_id, notification_template, send_notice self.subscription.c.user_id,
FROM feed INNER JOIN subscription ON feed.id = subscription.feed_id self.subscription.c.notification_template])
""" .where(self.subscription.c.feed_id == self.feed.c.id))
rows = await self.db.fetch(q) map: Dict[int, Feed] = {}
feeds: dict[int, Feed] = {}
for row in rows: for row in rows:
try: feed_id, url, title, subtitle, link, room_id, user_id, notification_template = row
feed = feeds[row["id"]] map.setdefault(feed_id, Feed(feed_id, url, title, subtitle, link, subscriptions=[]))
except KeyError: map[feed_id].subscriptions.append(
feed = feeds[row["id"]] = Feed.from_row(row) Subscription(feed_id=feed_id, room_id=room_id, user_id=user_id,
feed.subscriptions.append(Subscription.from_row(row)) notification_template=Template(notification_template)))
return list(feeds.values()) return map.values()
async def get_feeds_by_room(self, room_id: RoomID) -> list[tuple[Feed, UserID]]: def get_feeds_by_room(self, room_id: RoomID) -> Iterable[Tuple[Feed, UserID]]:
q = """ return ((Feed(feed_id, url, title, subtitle, link, subscriptions=[]), user_id)
SELECT id, url, title, subtitle, link, next_retry, error_count, user_id FROM feed for (feed_id, url, title, subtitle, link, user_id) in
INNER JOIN subscription ON feed.id = subscription.feed_id AND subscription.room_id = $1 self.db.execute(select([self.feed, self.subscription.c.user_id])
""" .where(and_(self.subscription.c.room_id == room_id,
rows = await self.db.fetch(q, room_id) self.subscription.c.feed_id == self.feed.c.id))))
return [(Feed.from_row(row), row["user_id"]) for row in rows]
async def get_entries(self, feed_id: int) -> list[Entry]: def get_rooms_by_feed(self, feed_id: int) -> Iterable[RoomID]:
q = "SELECT feed_id, id, date, title, summary, link FROM entry WHERE feed_id = $1" return (row[0] for row in
return [Entry.from_row(row) for row in await self.db.fetch(q, feed_id)] self.db.execute(select([self.subscription.c.room_id])
.where(self.subscription.c.feed_id == feed_id)))
async def add_entries(self, entries: list[Entry], override_feed_id: int | None = None) -> None: def get_entries(self, feed_id: int) -> Iterable[Entry]:
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]) -> None:
if not entries: if not entries:
return return
if override_feed_id: self.db.execute(self.entry.insert(), [entry._asdict() for entry in entries])
for entry in entries:
entry.feed_id = override_feed_id
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)
async def get_feed_by_url(self, url: str) -> Feed | None: def get_feed_by_url(self, url: str) -> Optional[Feed]:
q = "SELECT id, url, title, subtitle, link, next_retry, error_count FROM feed WHERE url=$1" rows = self.db.execute(select([self.feed]).where(self.feed.c.url == url))
return Feed.from_row(await self.db.fetchrow(q, url)) try:
row = next(rows)
return Feed(*row, subscriptions=[])
except (ValueError, StopIteration):
return None
async def get_subscription( def get_feed_by_id(self, feed_id: int) -> Optional[Feed]:
self, feed_id: int, room_id: RoomID rows = self.db.execute(select([self.feed]).where(self.feed.c.id == feed_id))
) -> tuple[Subscription | None, Feed | None]: try:
q = """ row = next(rows)
SELECT id, url, title, subtitle, link, next_retry, error_count, return Feed(*row, subscriptions=[])
room_id, user_id, notification_template, send_notice except (ValueError, StopIteration):
FROM feed LEFT JOIN subscription ON feed.id = subscription.feed_id AND room_id = $2 return None
WHERE feed.id = $1
"""
row = await self.db.fetchrow(q, feed_id, room_id)
return Subscription.from_row(row), Feed.from_row(row)
async def update_room_id(self, old: RoomID, new: RoomID) -> None: def get_subscription(self, feed_id: int, room_id: RoomID) -> Tuple[Optional[Subscription],
await self.db.execute("UPDATE subscription SET room_id = $1 WHERE room_id = $2", new, old) Optional[Feed]]:
tbl = self.subscription
rows = self.db.execute(select([self.feed, tbl.c.room_id, tbl.c.user_id,
tbl.c.notification_template])
.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, room_id, user_id, template = next(rows)
notification_template = Template(template)
return (Subscription(feed_id, room_id, user_id, notification_template)
if room_id else None,
Feed(feed_id, url, title, subtitle, link, []))
except (ValueError, StopIteration):
return (None, None)
async def create_feed(self, info: Feed) -> Feed: def create_feed(self, url: str, title: str, subtitle: str, link: str) -> Feed:
q = ( res = self.db.execute(self.feed.insert().values(url=url, title=title, subtitle=subtitle,
"INSERT INTO feed (url, title, subtitle, link, next_retry) " link=link))
"VALUES ($1, $2, $3, $4, $5) RETURNING (id)" return Feed(id=res.inserted_primary_key[0], url=url, title=title, subtitle=subtitle,
) link=link, subscriptions=[])
# 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
async def set_backoff(self, info: Feed, error_count: int, next_retry: int) -> None: def subscribe(self, feed_id: int, room_id: RoomID, user_id: UserID) -> None:
q = "UPDATE feed SET error_count = $2, next_retry = $3 WHERE id = $1" self.db.execute(self.subscription.insert().values(
await self.db.execute(q, info.id, error_count, next_retry) feed_id=feed_id, room_id=room_id, user_id=user_id,
notification_template="New post in $feed_title: [$title]($link)"))
async def subscribe( def unsubscribe(self, feed_id: int, room_id: RoomID) -> None:
self, tbl = self.subscription
feed_id: int, self.db.execute(tbl.delete().where(and_(tbl.c.feed_id == feed_id,
room_id: RoomID, tbl.c.room_id == room_id)))
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)
async def unsubscribe(self, feed_id: int, room_id: RoomID) -> None: def update_template(self, feed_id: int, room_id: RoomID, template: str) -> None:
q = "DELETE FROM subscription WHERE feed_id = $1 AND room_id = $2" tbl = self.subscription
await self.db.execute(q, feed_id, room_id) self.db.execute(tbl.update()
.where(and_(tbl.c.feed_id == feed_id, tbl.c.room_id == room_id))
async def update_template(self, feed_id: int, room_id: RoomID, template: str) -> None: .values(notification_template=template))
q = "UPDATE subscription SET notification_template=$3 WHERE feed_id=$1 AND room_id=$2"
await self.db.execute(q, feed_id, room_id, template)
async def set_send_notice(self, feed_id: int, room_id: RoomID, send_notice: bool) -> None:
q = "UPDATE subscription SET send_notice=$3 WHERE feed_id=$1 AND room_id=$2"
await self.db.execute(q, feed_id, room_id, 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) 2022 Tulir Asokan # Copyright (C) 2018 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,62 +13,13 @@
# #
# 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 mautrix.util.async_db import Connection, Scheme, UpgradeTable from sqlalchemy import select
from sqlalchemy.engine.base import Engine
upgrade_table = UpgradeTable() from alembic.migration import MigrationContext
from alembic.operations import Operations
@upgrade_table.register(description="Latest revision", upgrades_to=3) def run(engine: Engine):
async def upgrade_latest(conn: Connection, scheme: Scheme) -> None: conn = engine.connect()
gen = "GENERATED ALWAYS AS IDENTITY" if scheme != Scheme.SQLITE else "" ctx = MigrationContext.configure(conn)
await conn.execute( op = Operations(ctx)
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")