Compare commits

..

58 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
Tulir Asokan
5efba56c3b Add backoff for fetching feeds that are down 2021-04-10 00:30:10 +03:00
Tulir Asokan
794d8e1bb9 Bump version to v0.2.3 2020-12-08 16:14:04 +02:00
Tulir Asokan
a8784f4377 Remove unused import 2020-10-22 13:26:06 +03:00
Tulir Asokan
be90ee5465 Fix replying to template update 2020-08-11 03:05:12 +03:00
Tulir Asokan
334acf141e Add support for JSON feed version 1.1 2020-08-07 19:55:37 +03:00
Tulir Asokan
f96b2202b0 Bump version to v0.2.2 2020-08-07 12:47:50 +03:00
Tulir Asokan
85e0fd9927 Fix getting feed title and other metadata 2020-08-05 18:41:09 +03:00
Tulir Asokan
45cafe1ad5 Bump version to v0.2.1 2020-08-05 13:17:39 +03:00
Tulir Asokan
e05ebe5dc6 Remove unused import 2020-08-05 13:17:35 +03:00
Tulir Asokan
135fa779f8 Create version table before checking database version 2020-08-05 13:16:04 +03:00
Tulir Asokan
21a65bc189 Ignore non-problematic feed parsing errors 2020-08-03 14:40:42 +03:00
Tulir Asokan
2079504e39 Fix handling errors in feed parsing 2020-08-03 03:19:32 +03:00
Tulir Asokan
ad39e34ae2 Fix handling JSON feeds with extra stuff in content-type header 2020-08-03 03:12:39 +03:00
Tulir Asokan
44927f2cf5 Add support for JSON feeds 2020-08-03 03:03:19 +03:00
Tulir Asokan
c07beb23be Remove loop parameter in asyncio.gather 2020-07-13 03:10:50 +03:00
Tulir Asokan
89ebfe7283 Add option to send posts as m.text 2020-07-01 17:20:14 +03:00
Tulir Asokan
391db1405f Allow HTML in RSS messages 2020-05-21 01:10:25 +03:00
Tulir Asokan
adae757081
Merge pull request #10 from rda0/master
Hash entry content if id is missing
2020-05-16 17:25:30 +03:00
Tulir Asokan
4532e1bb4f Handle ValueError in get_date. Fixes #11 2020-02-09 21:59:42 +02:00
Sven Mäder
eed44fcafc Hash entry content if id is missing 2020-01-16 22:21:55 +01:00
Tulir Asokan
4e3b9ef416 Move RSS subscriptions on room upgrade 2019-11-21 23:50:56 +02:00
Tulir Asokan
d3ddebedb5 Fix content-encoding header 2019-10-16 12:55:06 +03:00
Tulir Asokan
bcbab52c0c Pass feed URL to feedparser. Fixes #7 2019-09-17 22:19:30 +03:00
Tulir Asokan
8fcc2e8fb1 Add .gitlab-ci.yml 2019-07-28 22:10:17 +03:00
Tulir Asokan
6833af3404 Update copyright year 2019-06-08 17:42:48 +03:00
10 changed files with 685 additions and 227 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

3
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,3 @@
include:
- project: 'maubot/maubot'
file: '/.gitlab-ci-plugin.yml'

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

@ -1,11 +1,15 @@
# 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,5 +1,6 @@
maubot: 0.3.0
id: xyz.maubot.rss id: xyz.maubot.rss
version: 0.1.0 version: 0.4.1
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- rss - rss
@ -9,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) 2018 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,48 +13,86 @@
# #
# 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, Callable 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 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 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)
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):
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()
@ -68,41 +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, template: Template, room_id: RoomID async def _send(self, feed: Feed, entry: Entry, sub: Subscription) -> EventID:
) -> Awaitable[EventID]: message = sub.notification_template.safe_substitute(
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,
**entry._asdict(), **attr.asdict(entry),
}), 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(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.notification_template, sub.room_id) tasks = [self._send(feed, entry, sub) for sub in subscriptions]
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
datas = await asyncio.gather(*[self.read_feed(feed.url) for feed in subs], loop=self.loop) now = int(time())
for feed, data in zip(subs, datas): tasks = [self.try_parse_feed(feed=feed) for feed in subs if feed.next_retry < now]
parsed_data = feedparser.parse(data) feed: Feed
entries = parsed_data.entries entries: Iterable[Entry]
new_entries = {entry.id: entry for entry in self.find_entries(feed.id, entries)} self.log.info(f"Polling {len(tasks)} feeds")
for old_entry in self.db.get_entries(feed.id): for res in asyncio.as_completed(tasks):
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.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")
@ -113,46 +187,122 @@ 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 read_feed(self, url: str) -> str: async def try_parse_feed(self, feed: Feed | None = None) -> tuple[Feed, list[Entry]]:
try: try:
resp = await self.http.get(url) self.log.trace(
except Exception: f"Trying to fetch {feed.id} / {feed.url} "
self.log.exception(f"Error fetching {url}") f"(backoff: {feed.error_count} / {feed.next_retry})"
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:
content = await resp.text() date = datetime.fromisoformat(entry["date_published"])
except UnicodeDecodeError: except (ValueError, KeyError):
try: date = datetime.now()
content = await resp.text(encoding="utf-8") title = entry.get("title", "")
except: summary = (
content = str(await resp.read())[2:-1] entry.get("summary") or entry.get("content_html") or entry.get("content_text") or ""
return content ).strip()
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 get_date(entry: Any) -> datetime: def _parse_rss_date(entry: Any) -> datetime:
try: try:
return datetime.fromtimestamp(mktime(entry["published_parsed"])) return datetime.fromtimestamp(mktime(entry["published_parsed"]))
except (KeyError, TypeError): except (KeyError, TypeError, ValueError):
pass pass
try: try:
return datetime.fromtimestamp(mktime(entry["date_parsed"])) return datetime.fromtimestamp(mktime(entry["date_parsed"]))
except (KeyError, TypeError): except (KeyError, TypeError, ValueError):
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]
@ -169,75 +319,156 @@ 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.events.get("xyz.maubot.rss", levels.state_default) state_level = levels.get_event_level(rss_change_level)
if type(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 manage the subscriptions of this room.") await evt.reply(
"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:
metadata = feedparser.parse(await self.read_feed(url)) try:
if metadata.bozo: info, entries = await self.parse_feed(url=url)
await evt.reply("That doesn't look like a valid feed.") except Exception as e:
await evt.reply(f"Failed to load feed: {e}")
return return
channel = metadata.get("channel", {}) feed = await self.dbm.create_feed(info)
feed = self.db.create_feed(url, channel.get("title", url), await self.dbm.add_entries(entries, override_feed_id=feed.id)
channel.get("description", ""), elif feed.error_count > 0:
channel.get("link", "")) await self.dbm.set_backoff(feed, error_count=feed.error_count, next_retry=0)
self.db.add_entries(self.find_entries(feed.id, metadata.entries)) feed_info = f"feed ID {feed.id}: [{feed.title}]({feed.url})"
self.db.subscribe(feed.id, evt.room_id, evt.sender) sub, _ = await self.dbm.get_subscription(feed.id, evt.room_id)
await evt.reply(f"Subscribed to feed ID {feed.id}: [{feed.title}]({feed.url})") 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:
sample_entry = Entry(feed.id, "SAMPLE", datetime.now(), "Sample entry", await evt.reply(
"This is a sample entry to demonstrate your new template", '<p>Current template in this room:</p><pre><code language="markdown">'
"http://example.com") f"{html.escape(sub.notification_template.template)}"
"</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, Template(template), sub.room_id) await self._send(feed, sample_entry, sub)
@rss.subcommand("subscriptions", aliases=("ls", "list", "subs"), @rss.subcommand(
help="List the subscriptions in the current room.") "notice", aliases=("n",), help="Set whether or not the bot should send updates as m.notice"
)
@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 = 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)
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) 2018 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,143 +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, ForeignKey, from asyncpg import Record
Table, MetaData, from attr import dataclass
select, and_) 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) try:
Feed = NamedTuple("Feed", id=int, url=str, title=str, subtitle=str, link=str, from mautrix.util.async_db import SQLiteCursor
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))
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)
def get_feeds(self) -> Iterable[Feed]: async def get_feeds(self) -> list[Feed]:
rows = self.db.execute(select([self.feed, q = """
self.subscription.c.room_id, SELECT id, url, title, subtitle, link, next_retry, error_count,
self.subscription.c.user_id, room_id, user_id, notification_template, send_notice
self.subscription.c.notification_template]) FROM feed INNER JOIN subscription ON feed.id = subscription.feed_id
.where(self.subscription.c.feed_id == self.feed.c.id)) """
map: Dict[int, Feed] = {} rows = await self.db.fetch(q)
feeds: dict[int, Feed] = {}
for row in rows: for row in rows:
feed_id, url, title, subtitle, link, room_id, user_id, notification_template = row try:
map.setdefault(feed_id, Feed(feed_id, url, title, subtitle, link, subscriptions=[])) feed = feeds[row["id"]]
map[feed_id].subscriptions.append( except KeyError:
Subscription(feed_id=feed_id, room_id=room_id, user_id=user_id, feed = feeds[row["id"]] = Feed.from_row(row)
notification_template=Template(notification_template))) feed.subscriptions.append(Subscription.from_row(row))
return map.values() return list(feeds.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, subscriptions=[]), user_id) q = """
for (feed_id, url, title, subtitle, link, user_id) in SELECT id, url, title, subtitle, link, next_retry, error_count, user_id FROM feed
self.db.execute(select([self.feed, self.subscription.c.user_id]) INNER JOIN subscription ON feed.id = subscription.feed_id AND subscription.room_id = $1
.where(and_(self.subscription.c.room_id == room_id, """
self.subscription.c.feed_id == self.feed.c.id)))) rows = await self.db.fetch(q, room_id)
return [(Feed.from_row(row), row["user_id"]) for row in rows]
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]) -> None:
if not entries: if not entries:
return return
self.db.execute(self.entry.insert(), [entry._asdict() for entry in entries]) if override_feed_id:
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)
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])
.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)
def create_feed(self, url: str, title: str, subtitle: str, link: str) -> Feed: async def create_feed(self, info: Feed) -> Feed:
res = self.db.execute(self.feed.insert().values(url=url, title=title, subtitle=subtitle, q = (
link=link)) "INSERT INTO feed (url, title, subtitle, link, next_retry) "
return Feed(id=res.inserted_primary_key[0], url=url, title=title, subtitle=subtitle, "VALUES ($1, $2, $3, $4, $5) RETURNING (id)"
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
def subscribe(self, feed_id: int, room_id: RoomID, user_id: UserID) -> None: async def set_backoff(self, info: Feed, error_count: int, next_retry: int) -> None:
self.db.execute(self.subscription.insert().values( q = "UPDATE feed SET error_count = $2, next_retry = $3 WHERE id = $1"
feed_id=feed_id, room_id=room_id, user_id=user_id, await self.db.execute(q, info.id, error_count, next_retry)
notification_template="New post in $feed_title: [$title]($link)"))
def unsubscribe(self, feed_id: int, room_id: RoomID) -> None: async def subscribe(
tbl = self.subscription self,
self.db.execute(tbl.delete().where(and_(tbl.c.feed_id == feed_id, feed_id: int,
tbl.c.room_id == room_id))) 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 update_template(self, feed_id: int, room_id: RoomID, template: str) -> None: async def unsubscribe(self, feed_id: int, room_id: RoomID) -> None:
tbl = self.subscription q = "DELETE FROM subscription WHERE feed_id = $1 AND room_id = $2"
self.db.execute(tbl.update() await self.db.execute(q, feed_id, room_id)
.where(and_(tbl.c.feed_id == feed_id, tbl.c.room_id == room_id))
.values(notification_template=template)) async def update_template(self, feed_id: int, room_id: RoomID, template: str) -> None:
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) 2018 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")