Use Ruff and fix all its warnings and errors

This commit is contained in:
2023-03-18 01:50:45 +01:00
parent 15284c5646
commit 948a5a2af9
27 changed files with 504 additions and 313 deletions

View File

@ -2,9 +2,9 @@
name: Test code name: Test code
on: on:
push: push:
branches: [ master ] branches: [master]
pull_request: pull_request:
branches: [ master ] branches: [master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: "3.11" python-version: "3.11"
cache: 'poetry' cache: "poetry"
- run: poetry install - run: poetry install
- run: poetry run pytest - run: poetry run pytest
env: env:
@ -41,4 +41,4 @@ jobs:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@ -11,7 +11,7 @@ from discord_rss_bot.settings import get_reader
reader: Reader = get_reader() reader: Reader = get_reader()
@lru_cache() @lru_cache
def encode_url(url_to_quote: str) -> str: def encode_url(url_to_quote: str) -> str:
"""%-escape the URL so it can be used in a URL. """%-escape the URL so it can be used in a URL.
@ -28,8 +28,7 @@ def encode_url(url_to_quote: str) -> str:
def entry_is_whitelisted(entry_to_check: Entry) -> bool: def entry_is_whitelisted(entry_to_check: Entry) -> bool:
""" """Check if the entry is whitelisted.
Check if the entry is whitelisted.
Args: Args:
entry_to_check: The feed to check. entry_to_check: The feed to check.
@ -42,8 +41,7 @@ def entry_is_whitelisted(entry_to_check: Entry) -> bool:
def entry_is_blacklisted(entry_to_check: Entry) -> bool: def entry_is_blacklisted(entry_to_check: Entry) -> bool:
""" """Check if the entry is blacklisted.
Check if the entry is blacklisted.
Args: Args:
entry_to_check: The feed to check. entry_to_check: The feed to check.

View File

@ -22,31 +22,6 @@ class CustomEmbed:
footer_icon_url: str footer_icon_url: str
def return_image(found_images) -> list[tuple[str, str]] | None:
soup: BeautifulSoup = BeautifulSoup(found_images, features="lxml")
images = soup.find_all("img")
for image in images:
image_src: str = str(image["src"]) or ""
image_alt: str = "Link to image"
if image.get("alt"):
image_alt = image.get("alt")
return [(image_src, image_alt)]
def get_first_image_html(html: str):
"""Get images from a entry.
Args:
html: The HTML to get the images from.
Returns:
Returns a list of images.
"""
if images := BeautifulSoup(html, features="lxml").find_all("img"):
return images[0].attrs["src"]
return None
def try_to_replace(custom_message: str, template: str, replace_with: str) -> str: def try_to_replace(custom_message: str, template: str, replace_with: str) -> str:
"""Try to replace a tag in custom_message. """Try to replace a tag in custom_message.
@ -84,7 +59,7 @@ def replace_tags_in_text_message(entry: Entry) -> str:
summary: str = entry.summary or "" summary: str = entry.summary or ""
first_image = get_image(summary, content) first_image = get_first_image(summary, content)
summary = convert_html_to_md(summary) summary = convert_html_to_md(summary)
content = convert_html_to_md(content) content = convert_html_to_md(content)
@ -127,8 +102,8 @@ def replace_tags_in_text_message(entry: Entry) -> str:
return custom_message.replace("\\n", "\n") return custom_message.replace("\\n", "\n")
def get_image(summary, content): def get_first_image(summary, content):
"""Get image from summary or content """Get image from summary or content.
Args: Args:
summary: The summary from the entry summary: The summary from the entry
@ -137,12 +112,10 @@ def get_image(summary, content):
Returns: Returns:
The first image The first image
""" """
if content: if content and (images := BeautifulSoup(content, features="lxml").find_all("img")):
if images := BeautifulSoup(content, features="lxml").find_all("img"): return images[0].attrs["src"]
return images[0].attrs["src"] if summary and (images := BeautifulSoup(summary, features="lxml").find_all("img")):
if summary: return images[0].attrs["src"]
if images := BeautifulSoup(summary, features="lxml").find_all("img"):
return images[0].attrs["src"]
return "" return ""
@ -156,7 +129,6 @@ def replace_tags_in_embed(feed: Feed, entry: Entry) -> CustomEmbed:
Returns: Returns:
Returns the embed with the tags replaced. Returns the embed with the tags replaced.
""" """
custom_reader: Reader = get_reader() custom_reader: Reader = get_reader()
embed: CustomEmbed = get_embed(feed=feed, custom_reader=custom_reader) embed: CustomEmbed = get_embed(feed=feed, custom_reader=custom_reader)
@ -167,7 +139,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry) -> CustomEmbed:
summary: str = entry.summary or "" summary: str = entry.summary or ""
first_image = get_image(summary, content) first_image = get_first_image(summary, content)
summary = convert_html_to_md(summary) summary = convert_html_to_md(summary)
content = convert_html_to_md(content) content = convert_html_to_md(content)
@ -274,7 +246,6 @@ def get_embed(custom_reader: Reader, feed: Feed) -> CustomEmbed:
Returns: Returns:
Returns the contents from the embed tag. Returns the contents from the embed tag.
""" """
if embed := custom_reader.get_tag(feed, "embed", ""): if embed := custom_reader.get_tag(feed, "embed", ""):
if type(embed) != str: if type(embed) != str:
return get_embed_data(embed) return get_embed_data(embed)

View File

@ -1,22 +1,29 @@
from typing import Iterable from typing import TYPE_CHECKING
from discord_webhook import DiscordEmbed, DiscordWebhook from discord_webhook import DiscordEmbed, DiscordWebhook
from fastapi import HTTPException from fastapi import HTTPException
from reader import Entry, Feed, FeedExistsError, Reader, TagNotFoundError from reader import Entry, Feed, FeedExistsError, Reader, TagNotFoundError
from requests import Response
from discord_rss_bot import custom_message from discord_rss_bot import custom_message
from discord_rss_bot.filter.blacklist import should_be_skipped from discord_rss_bot.filter.blacklist import should_be_skipped
from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent
from discord_rss_bot.settings import default_custom_message, get_reader from discord_rss_bot.settings import default_custom_message, get_reader
if TYPE_CHECKING:
from collections.abc import Iterable
def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None): from requests import Response
"""
Send a single entry to Discord.
def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None:
"""Send a single entry to Discord.
Args: Args:
entry: The entry to send to Discord. entry: The entry to send to Discord.
custom_reader: The reader to use. If None, the default reader will be used.
Returns:
str | None: The error message if there was an error, otherwise None.
""" """
# Get the default reader if we didn't get a custom one. # Get the default reader if we didn't get a custom one.
reader: Reader = get_reader() if custom_reader is None else custom_reader reader: Reader = get_reader() if custom_reader is None else custom_reader
@ -27,7 +34,7 @@ def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None):
return "No webhook URL found." return "No webhook URL found."
# Try to get the custom message for the feed. If the user has none, we will use the default message. # Try to get the custom message for the feed. If the user has none, we will use the default message.
if custom_message.get_custom_message(reader, entry.feed) != "": if not custom_message.get_custom_message(reader, entry.feed):
webhook_message = custom_message.replace_tags_in_text_message(entry=entry) webhook_message = custom_message.replace_tags_in_text_message(entry=entry)
else: else:
webhook_message: str = default_custom_message webhook_message: str = default_custom_message
@ -39,11 +46,19 @@ def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None):
webhook: DiscordWebhook = DiscordWebhook(url=webhook_url, content=webhook_message, rate_limit_retry=True) webhook: DiscordWebhook = DiscordWebhook(url=webhook_url, content=webhook_message, rate_limit_retry=True)
response: Response = webhook.execute() response: Response = webhook.execute()
if not response.ok: return None if response.ok else f"Error sending entry to Discord: {response.text}"
return f"Error sending entry to Discord: {response.text}"
def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook:
"""Create a webhook with an embed.
Args:
webhook_url (str): The webhook URL.
entry (Entry): The entry to send to Discord.
Returns:
DiscordWebhook: The webhook with the embed.
"""
webhook: DiscordWebhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True) webhook: DiscordWebhook = DiscordWebhook(url=webhook_url, rate_limit_retry=True)
feed: Feed = entry.feed feed: Feed = entry.feed
@ -66,7 +81,11 @@ def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook:
if custom_embed.author_name and not custom_embed.author_url and custom_embed.author_icon_url: if custom_embed.author_name and not custom_embed.author_url and custom_embed.author_icon_url:
discord_embed.set_author(name=custom_embed.author_name, icon_url=custom_embed.author_icon_url) discord_embed.set_author(name=custom_embed.author_name, icon_url=custom_embed.author_icon_url)
if custom_embed.author_name and custom_embed.author_url and custom_embed.author_icon_url: if custom_embed.author_name and custom_embed.author_url and custom_embed.author_icon_url:
discord_embed.set_author(name=custom_embed.author_name, url=custom_embed.author_url, icon_url=custom_embed.author_icon_url) # noqa: E501 discord_embed.set_author(
name=custom_embed.author_name,
url=custom_embed.author_url,
icon_url=custom_embed.author_icon_url,
)
if custom_embed.thumbnail_url: if custom_embed.thumbnail_url:
discord_embed.set_thumbnail(url=custom_embed.thumbnail_url) discord_embed.set_thumbnail(url=custom_embed.thumbnail_url)
if custom_embed.image_url: if custom_embed.image_url:
@ -85,8 +104,7 @@ def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook:
def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, do_once: bool = False) -> None: def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, do_once: bool = False) -> None:
""" """Send entries to Discord.
Send entries to Discord.
If response was not ok, we will log the error and mark the entry as unread, so it will be sent again next time. If response was not ok, we will log the error and mark the entry as unread, so it will be sent again next time.
@ -120,7 +138,7 @@ def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = Non
else: else:
# If the user has set the custom message to an empty string, we will use the default message, otherwise we # If the user has set the custom message to an empty string, we will use the default message, otherwise we
# will use the custom message. # will use the custom message.
if custom_message.get_custom_message(reader, entry.feed) != "": if not custom_message.get_custom_message(reader, entry.feed):
webhook_message = custom_message.replace_tags_in_text_message(entry) webhook_message = custom_message.replace_tags_in_text_message(entry)
else: else:
webhook_message: str = default_custom_message webhook_message: str = default_custom_message

View File

View File

@ -4,11 +4,12 @@ from discord_rss_bot.filter.utils import is_word_in_text
def has_black_tags(custom_reader: Reader, feed: Feed) -> bool: def has_black_tags(custom_reader: Reader, feed: Feed) -> bool:
""" """Return True if the feed has blacklist tags.
Return True if the feed has any of the following tags:
The following tags are checked:
- blacklist_title - blacklist_title
- blacklist_summary - blacklist_summary
- blacklist_content - blacklist_content.
Args: Args:
custom_reader: The reader. custom_reader: The reader.
@ -25,8 +26,7 @@ def has_black_tags(custom_reader: Reader, feed: Feed) -> bool:
def should_be_skipped(custom_reader: Reader, entry: Entry) -> bool: def should_be_skipped(custom_reader: Reader, entry: Entry) -> bool:
""" """Return True if the entry is in the blacklist.
Return True if the entry is in the blacklist.
Args: Args:
custom_reader: The reader. custom_reader: The reader.
@ -40,15 +40,17 @@ def should_be_skipped(custom_reader: Reader, entry: Entry) -> bool:
blacklist_summary: str = str(custom_reader.get_tag(feed, "blacklist_summary", "")) blacklist_summary: str = str(custom_reader.get_tag(feed, "blacklist_summary", ""))
blacklist_content: str = str(custom_reader.get_tag(feed, "blacklist_content", "")) blacklist_content: str = str(custom_reader.get_tag(feed, "blacklist_content", ""))
blacklist_author: str = str(custom_reader.get_tag(feed, "blacklist_author", "")) blacklist_author: str = str(custom_reader.get_tag(feed, "blacklist_author", ""))
# TODO: Also add support for entry_text # TODO: Also add support for entry_text and more.
if entry.title and blacklist_title and is_word_in_text(blacklist_title, entry.title): if entry.title and blacklist_title and is_word_in_text(blacklist_title, entry.title):
return True return True
elif entry.summary and blacklist_summary and is_word_in_text(blacklist_summary, entry.summary): if entry.summary and blacklist_summary and is_word_in_text(blacklist_summary, entry.summary):
return True return True
elif entry.author and blacklist_author and is_word_in_text(blacklist_author, entry.author): if entry.author and blacklist_author and is_word_in_text(blacklist_author, entry.author):
return True return True
elif entry.content: return bool(
if entry.content[0].value and blacklist_content and is_word_in_text(blacklist_content, entry.content[0].value): entry.content
return True and entry.content[0].value
return False and blacklist_content
and is_word_in_text(blacklist_content, entry.content[0].value),
)

View File

@ -2,7 +2,8 @@ import re
def is_word_in_text(words: str, text: str) -> bool: def is_word_in_text(words: str, text: str) -> bool:
""" """Check if the word is in the text.
Args: Args:
words: The words to search for. words: The words to search for.
text: The text to search in. text: The text to search in.

View File

@ -4,11 +4,12 @@ from discord_rss_bot.filter.utils import is_word_in_text
def has_white_tags(custom_reader: Reader, feed: Feed) -> bool: def has_white_tags(custom_reader: Reader, feed: Feed) -> bool:
""" """Return True if the feed has whitelist tags.
Return True if the feed has any of the following tags:
The following tags are checked:
- whitelist_title - whitelist_title
- whitelist_summary - whitelist_summary
- whitelist_content - whitelist_content.
Args: Args:
custom_reader: The reader. custom_reader: The reader.
@ -25,8 +26,7 @@ def has_white_tags(custom_reader: Reader, feed: Feed) -> bool:
def should_be_sent(custom_reader: Reader, entry: Entry) -> bool: def should_be_sent(custom_reader: Reader, entry: Entry) -> bool:
""" """Return True if the entry is in the whitelist.
Return True if the entry is in the whitelist.
Args: Args:
custom_reader: The reader. custom_reader: The reader.
@ -43,11 +43,13 @@ def should_be_sent(custom_reader: Reader, entry: Entry) -> bool:
if entry.title and whitelist_title and is_word_in_text(whitelist_title, entry.title): if entry.title and whitelist_title and is_word_in_text(whitelist_title, entry.title):
return True return True
elif entry.summary and whitelist_summary and is_word_in_text(whitelist_summary, entry.summary): if entry.summary and whitelist_summary and is_word_in_text(whitelist_summary, entry.summary):
return True return True
elif entry.author and whitelist_author and is_word_in_text(whitelist_author, entry.author): if entry.author and whitelist_author and is_word_in_text(whitelist_author, entry.author):
return True return True
elif entry.content: return bool(
if entry.content[0].value and whitelist_content and is_word_in_text(whitelist_content, entry.content[0].value): entry.content
return True and entry.content[0].value
return False and whitelist_content
and is_word_in_text(whitelist_content, entry.content[0].value),
)

View File

@ -7,14 +7,15 @@ def healthcheck() -> None:
"""Check if the website is up. """Check if the website is up.
sys.exit(0): success - the container is healthy and ready for use. sys.exit(0): success - the container is healthy and ready for use.
sys.exit(1): unhealthy - the container is not working correctly.""" sys.exit(1): unhealthy - the container is not working correctly.
"""
# TODO: We should check more than just that the website is up. # TODO: We should check more than just that the website is up.
try: try:
r: requests.Response = requests.get(url="http://localhost:5000", timeout=5) r: requests.Response = requests.get(url="http://localhost:5000", timeout=5)
if r.ok: if r.ok:
sys.exit(0) sys.exit(0)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"Healthcheck failed: {e}", file=sys.stderr) print(f"Healthcheck failed: {e}", file=sys.stderr) # noqa: T201
sys.exit(1) sys.exit(1)

View File

@ -1,9 +1,9 @@
import json import json
import urllib.parse import urllib.parse
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timezone
from functools import lru_cache from functools import lru_cache
from typing import Iterable
import httpx import httpx
import uvicorn import uvicorn
@ -22,7 +22,7 @@ from discord_rss_bot.custom_message import (
CustomEmbed, CustomEmbed,
get_custom_message, get_custom_message,
get_embed, get_embed,
get_image, get_first_image,
replace_tags_in_text_message, replace_tags_in_text_message,
save_embed, save_embed,
) )
@ -47,45 +47,44 @@ templates.env.filters["discord_markdown"] = convert_html_to_md
@app.post("/add_webhook") @app.post("/add_webhook")
async def post_add_webhook(webhook_name: str = Form(), webhook_url: str = Form()): async def post_add_webhook(webhook_name: str = Form(), webhook_url: str = Form()) -> RedirectResponse:
""" """Add a feed to the database.
Add a feed to the database.
Args: Args:
webhook_name: The name of the webhook. webhook_name: The name of the webhook.
webhook_url: The url of the webhook. webhook_url: The url of the webhook.
""" """
if add_webhook(reader, webhook_name, webhook_url): add_webhook(reader, webhook_name, webhook_url)
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
@app.post("/delete_webhook") @app.post("/delete_webhook")
async def post_delete_webhook(webhook_url: str = Form()): async def post_delete_webhook(webhook_url: str = Form()) -> RedirectResponse:
""" """Delete a webhook from the database.
Delete a webhook from the database.
Args: Args:
webhook_url: The url of the webhook. webhook_url: The url of the webhook.
""" """
if remove_webhook(reader, webhook_url): # TODO: Check if the webhook is in use by any feeds before deleting it.
return RedirectResponse(url="/", status_code=303) remove_webhook(reader, webhook_url)
return RedirectResponse(url="/", status_code=303)
@app.post("/add") @app.post("/add")
async def post_create_feed(feed_url: str = Form(), webhook_dropdown: str = Form()): async def post_create_feed(feed_url: str = Form(), webhook_dropdown: str = Form()) -> RedirectResponse:
""" """Add a feed to the database.
Add a feed to the database.
Args: Args:
feed_url: The feed to add. feed_url: The feed to add.
webhook_dropdown: The webhook to use. webhook_dropdown: The webhook to use.
""" """
clean_feed_url: str = feed_url.strip()
create_feed(reader, feed_url, webhook_dropdown) create_feed(reader, feed_url, webhook_dropdown)
return RedirectResponse(url=f"/feed/?feed_url={feed_url}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.post("/pause") @app.post("/pause")
async def post_pause_feed(feed_url: str = Form()): async def post_pause_feed(feed_url: str = Form()) -> RedirectResponse:
"""Pause a feed. """Pause a feed.
Args: Args:
@ -97,7 +96,7 @@ async def post_pause_feed(feed_url: str = Form()):
@app.post("/unpause") @app.post("/unpause")
async def post_unpause_feed(feed_url: str = Form()): async def post_unpause_feed(feed_url: str = Form()) -> RedirectResponse:
"""Unpause a feed. """Unpause a feed.
Args: Args:
@ -115,7 +114,7 @@ async def post_set_whitelist(
whitelist_content: str = Form(None), whitelist_content: str = Form(None),
whitelist_author: str = Form(None), whitelist_author: str = Form(None),
feed_url: str = Form(), feed_url: str = Form(),
): ) -> RedirectResponse:
"""Set what the whitelist should be sent, if you have this set only words in the whitelist will be sent. """Set what the whitelist should be sent, if you have this set only words in the whitelist will be sent.
Args: Args:
@ -127,7 +126,7 @@ async def post_set_whitelist(
""" """
clean_feed_url: str = feed_url.strip() clean_feed_url: str = feed_url.strip()
if whitelist_title: if whitelist_title:
reader.set_tag(clean_feed_url, "whitelist_title", whitelist_title) # type: ignore reader.set_tag(clean_feed_url, "whitelist_title", [whitelist_title])
if whitelist_summary: if whitelist_summary:
reader.set_tag(clean_feed_url, "whitelist_summary", whitelist_summary) # type: ignore reader.set_tag(clean_feed_url, "whitelist_summary", whitelist_summary) # type: ignore
if whitelist_content: if whitelist_content:
@ -172,8 +171,10 @@ async def post_set_blacklist(
blacklist_content: str = Form(None), blacklist_content: str = Form(None),
blacklist_author: str = Form(None), blacklist_author: str = Form(None),
feed_url: str = Form(), feed_url: str = Form(),
): ) -> RedirectResponse:
"""Set the blacklist, if this is set we will check if words are in the title, summary or content """Set the blacklist.
If this is set we will check if words are in the title, summary or content
and then don't send that entry. and then don't send that entry.
Args: Args:
@ -193,7 +194,7 @@ async def post_set_blacklist(
if blacklist_author: if blacklist_author:
reader.set_tag(clean_feed_url, "blacklist_author", blacklist_author) # type: ignore reader.set_tag(clean_feed_url, "blacklist_author", blacklist_author) # type: ignore
return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(feed_url)}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.get("/blacklist", response_class=HTMLResponse) @app.get("/blacklist", response_class=HTMLResponse)
@ -218,9 +219,8 @@ async def get_blacklist(feed_url: str, request: Request):
@app.post("/custom") @app.post("/custom")
async def post_set_custom(custom_message: str = Form(""), feed_url: str = Form()): async def post_set_custom(custom_message: str = Form(""), feed_url: str = Form()) -> RedirectResponse:
""" """Set the custom message, this is used when sending the message.
Set the custom message, this is used when sending the message.
Args: Args:
custom_message: The custom message. custom_message: The custom message.
@ -231,7 +231,8 @@ async def post_set_custom(custom_message: str = Form(""), feed_url: str = Form()
else: else:
reader.set_tag(feed_url, "custom_message", settings.default_custom_message) # type: ignore reader.set_tag(feed_url, "custom_message", settings.default_custom_message) # type: ignore
return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(feed_url)}", status_code=303) clean_feed_url: str = feed_url.strip()
return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.get("/custom", response_class=HTMLResponse) @app.get("/custom", response_class=HTMLResponse)
@ -304,11 +305,25 @@ async def post_embed(
author_icon_url: str = Form(""), author_icon_url: str = Form(""),
footer_text: str = Form(""), footer_text: str = Form(""),
footer_icon_url: str = Form(""), footer_icon_url: str = Form(""),
): ) -> RedirectResponse:
"""Set the embed settings. """Set the embed settings.
Args: Args:
feed_url: What feed we should get the custom message for. feed_url: What feed we should get the custom message for.
title: The title of the embed.
description: The description of the embed.
color: The color of the embed.
image_url: The image url of the embed.
thumbnail_url: The thumbnail url of the embed.
author_name: The author name of the embed.
author_url: The author url of the embed.
author_icon_url: The author icon url of the embed.
footer_text: The footer text of the embed.
footer_icon_url: The footer icon url of the embed.
Returns:
RedirectResponse: Redirect to the embed page.
""" """
clean_feed_url: str = feed_url.strip() clean_feed_url: str = feed_url.strip()
feed: Feed = reader.get_feed(urllib.parse.unquote(clean_feed_url)) feed: Feed = reader.get_feed(urllib.parse.unquote(clean_feed_url))
@ -328,31 +343,37 @@ async def post_embed(
# Save the data. # Save the data.
save_embed(reader, feed, custom_embed) save_embed(reader, feed, custom_embed)
return RedirectResponse(url=f"/feed/?feed_url={clean_feed_url}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.post("/use_embed") @app.post("/use_embed")
async def post_use_embed(feed_url: str = Form()): async def post_use_embed(feed_url: str = Form()) -> RedirectResponse:
"""Use embed instead of text. """Use embed instead of text.
Args: Args:
feed_url: The feed to change. feed_url: The feed to change.
Returns:
RedirectResponse: Redirect to the feed page.
""" """
clean_feed_url: str = feed_url.strip() clean_feed_url: str = feed_url.strip()
reader.set_tag(clean_feed_url, "should_send_embed", True) # type: ignore reader.set_tag(clean_feed_url, "should_send_embed", True) # type: ignore
return RedirectResponse(url=f"/feed/?feed_url={clean_feed_url}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.post("/use_text") @app.post("/use_text")
async def post_use_text(feed_url: str = Form()): async def post_use_text(feed_url: str = Form()) -> RedirectResponse:
"""Use text instead of embed. """Use text instead of embed.
Args: Args:
feed_url: The feed to change. feed_url: The feed to change.
Returns:
RedirectResponse: Redirect to the feed page.
""" """
clean_feed_url: str = feed_url.strip() clean_feed_url: str = feed_url.strip()
reader.set_tag(clean_feed_url, "should_send_embed", False) # type: ignore reader.set_tag(clean_feed_url, "should_send_embed", False) # type: ignore
return RedirectResponse(url=f"/feed/?feed_url={clean_feed_url}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.get("/add", response_class=HTMLResponse) @app.get("/add", response_class=HTMLResponse)
@ -367,11 +388,14 @@ def get_add(request: Request):
@app.get("/feed", response_class=HTMLResponse) @app.get("/feed", response_class=HTMLResponse)
async def get_feed(feed_url: str, request: Request): async def get_feed(feed_url: str, request: Request):
""" """Get a feed by URL.
Get a feed by URL.
Args: Args:
feed_url: The feed to add. feed_url: The feed to add.
request: The request object.
Returns:
HTMLResponse: The feed page.
""" """
clean_feed_url: str = urllib.parse.unquote(feed_url.strip()) clean_feed_url: str = urllib.parse.unquote(feed_url.strip())
@ -404,19 +428,18 @@ def create_html_for_feed(entries: Iterable[Entry]) -> str:
"""Create HTML for the search results. """Create HTML for the search results.
Args: Args:
search_results: The search results. entries: The entries to create HTML for.
custom_reader: The reader. If None, we will get the reader from the settings.
""" """
html: str = "" html: str = ""
for entry in entries: for entry in entries:
first_image = "" first_image: str = ""
summary: str | None = entry.summary summary: str | None = entry.summary
content = "" content = ""
if entry.content: if entry.content:
for content_item in entry.content: for content_item in entry.content:
content: str = content_item.value content: str = content_item.value
first_image = get_image(summary, content) first_image = get_first_image(summary, content)
text: str = replace_tags_in_text_message(entry) or "<div class='text-muted'>No content available.</div>" text: str = replace_tags_in_text_message(entry) or "<div class='text-muted'>No content available.</div>"
published = "" published = ""
@ -432,24 +455,30 @@ def create_html_for_feed(entries: Iterable[Entry]) -> str:
whitelisted = "<span class='badge bg-success'>Whitelisted</span>" whitelisted = "<span class='badge bg-success'>Whitelisted</span>"
entry_id: str = urllib.parse.quote(entry.id) entry_id: str = urllib.parse.quote(entry.id)
to_disord_html: str = f"<a class='text-muted' href='/post_entry?entry_id={entry_id}'>Send to Discord</a>" to_discord_html: str = f"<a class='text-muted' href='/post_entry?entry_id={entry_id}'>Send to Discord</a>"
image_html: str = f"<img src='{first_image}' class='img-fluid'>" if first_image else "" image_html: str = f"<img src='{first_image}' class='img-fluid'>" if first_image else ""
html += f"""<div class="p-2 mb-2 border border-dark"> html += f"""<div class="p-2 mb-2 border border-dark">
{blacklisted}{whitelisted}<a class="text-muted text-decoration-none" href="{entry.link}"><h2>{entry.title}</h2></a> {blacklisted}{whitelisted}<a class="text-muted text-decoration-none" href="{entry.link}"><h2>{entry.title}</h2></a>
{f"By { entry.author } @" if entry.author else ""}{published} - {to_disord_html} {f"By { entry.author } @" if entry.author else ""}{published} - {to_discord_html}
{text} {text}
{image_html} {image_html}
</div> </div>
""" """
return html.strip() return html.strip()
@app.get("/add_webhook", response_class=HTMLResponse) @app.get("/add_webhook", response_class=HTMLResponse)
async def get_add_webhook(request: Request): async def get_add_webhook(request: Request):
"""Page for adding a new webhook.""" """Page for adding a new webhook.
Args:
request: The request object.
Returns:
HTMLResponse: The add webhook page.
"""
return templates.TemplateResponse("add_webhook.html", {"request": request}) return templates.TemplateResponse("add_webhook.html", {"request": request})
@ -457,36 +486,54 @@ async def get_add_webhook(request: Request):
class WebhookInfo: class WebhookInfo:
custom_name: str custom_name: str
url: str url: str
type: int | None = None webhook_type: int | None = None
id: str | None = None webhook_id: str | None = None
name: str | None = None name: str | None = None
avatar: str | None = None avatar: str | None = None
channel_id: str | None = None channel_id: str | None = None
guild_id: str | None = None guild_id: str | None = None
token: str | None = None token: str | None = None
avatar_mod: int | None = None
@lru_cache() @lru_cache
def get_data_from_hook_url(hook_name: str, hook_url: str): def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo:
"""Get data from a webhook URL.
Args:
hook_name (str): The webhook name.
hook_url (str): The webhook URL.
Returns:
WebhookInfo: The webhook username, avatar, guild id, etc.
"""
our_hook: WebhookInfo = WebhookInfo(custom_name=hook_name, url=hook_url) our_hook: WebhookInfo = WebhookInfo(custom_name=hook_name, url=hook_url)
if hook_url: if hook_url:
response: Response = httpx.get(hook_url) response: Response = httpx.get(hook_url)
if response.status_code == 200: if response.is_success:
webhook_json = json.loads(response.text) webhook_json = json.loads(response.text)
our_hook.type = webhook_json["type"] or None our_hook.webhook_type = webhook_json["type"] or None
our_hook.id = webhook_json["id"] or None our_hook.webhook_id = webhook_json["id"] or None
our_hook.name = webhook_json["name"] or None our_hook.name = webhook_json["name"] or None
our_hook.avatar = webhook_json["avatar"] or None our_hook.avatar = webhook_json["avatar"] or None
our_hook.channel_id = webhook_json["channel_id"] or None our_hook.channel_id = webhook_json["channel_id"] or None
our_hook.guild_id = webhook_json["guild_id"] or None our_hook.guild_id = webhook_json["guild_id"] or None
our_hook.token = webhook_json["token"] or None our_hook.token = webhook_json["token"] or None
our_hook.avatar_mod = int(webhook_json["channel_id"] or 0) % 5
return our_hook return our_hook
@app.get("/webhooks", response_class=HTMLResponse) @app.get("/webhooks", response_class=HTMLResponse)
async def get_webhooks(request: Request): async def get_webhooks(request: Request):
"""Page for adding a new webhook.""" """Page for adding a new webhook.
Args:
request: The request object.
Returns:
HTMLResponse: The add webhook page.
"""
hooks_with_data = [] hooks_with_data = []
for hook in reader.get_tag((), "webhooks", ""): for hook in reader.get_tag((), "webhooks", ""):
@ -499,12 +546,26 @@ async def get_webhooks(request: Request):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def get_index(request: Request): def get_index(request: Request):
"""This is the root of the website.""" """This is the root of the website.
Args:
request: The request object.
Returns:
HTMLResponse: The index page.
"""
return templates.TemplateResponse("index.html", make_context_index(request)) return templates.TemplateResponse("index.html", make_context_index(request))
def make_context_index(request: Request): def make_context_index(request: Request):
"""Create the needed context for the index page.""" """Create the needed context for the index page.
Args:
request: The request object.
Returns:
dict: The context for the index page.
"""
hooks: list[dict] = reader.get_tag((), "webhooks", []) # type: ignore hooks: list[dict] = reader.get_tag((), "webhooks", []) # type: ignore
feed_list = [] feed_list = []
@ -537,11 +598,13 @@ def make_context_index(request: Request):
@app.post("/remove", response_class=HTMLResponse) @app.post("/remove", response_class=HTMLResponse)
async def remove_feed(feed_url: str = Form()): async def remove_feed(feed_url: str = Form()):
""" """Get a feed by URL.
Get a feed by URL.
Args: Args:
feed_url: The feed to add. feed_url: The feed to add.
Returns:
RedirectResponse: Redirect to the index page.
""" """
try: try:
reader.delete_feed(urllib.parse.unquote(feed_url)) reader.delete_feed(urllib.parse.unquote(feed_url))
@ -553,11 +616,14 @@ async def remove_feed(feed_url: str = Form()):
@app.get("/search", response_class=HTMLResponse) @app.get("/search", response_class=HTMLResponse)
async def search(request: Request, query: str): async def search(request: Request, query: str):
""" """Get entries matching a full-text search query.
Get entries matching a full-text search query.
Args: Args:
query: The query to search for. query: The query to search for.
request: The request object.
Returns:
HTMLResponse: The search page.
""" """
reader.update_search() reader.update_search()
@ -572,18 +638,25 @@ async def search(request: Request, query: str):
@app.get("/post_entry", response_class=HTMLResponse) @app.get("/post_entry", response_class=HTMLResponse)
async def post_entry(entry_id: str): async def post_entry(entry_id: str):
"""Send single entry to Discord.""" """Send single entry to Discord.
Args:
entry_id: The entry to send.
Returns:
RedirectResponse: Redirect to the feed page.
"""
unquoted_entry_id: str = urllib.parse.unquote(entry_id) unquoted_entry_id: str = urllib.parse.unquote(entry_id)
entry: Entry | None = next((entry for entry in reader.get_entries() if entry.id == unquoted_entry_id), None) entry: Entry | None = next((entry for entry in reader.get_entries() if entry.id == unquoted_entry_id), None)
if entry is None: if entry is None:
return {"error": f"Failed to get entry '{entry_id}' when posting to Discord."} return HTMLResponse(status_code=404, content=f"Entry '{entry_id}' not found.")
if result := send_entry_to_discord(entry=entry): if result := send_entry_to_discord(entry=entry):
return result return result
# Redirect to the feed page. # Redirect to the feed page.
clean_url: str = entry.feed.url.strip() clean_feed_url: str = entry.feed.url.strip()
return RedirectResponse(url=f"/feed/?feed_url={clean_url}", status_code=303) return RedirectResponse(url=f"/feed/?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.on_event("startup") @app.on_event("startup")
@ -598,7 +671,7 @@ def startup() -> None:
# Update all feeds every 15 minutes. # Update all feeds every 15 minutes.
# TODO: Make this configurable. # TODO: Make this configurable.
scheduler.add_job(send_to_discord, "interval", minutes=15, next_run_time=datetime.now()) scheduler.add_job(send_to_discord, "interval", minutes=15, next_run_time=datetime.now(tz=timezone.utc))
scheduler.start() scheduler.start()

View File

@ -1,9 +1,13 @@
import urllib.parse import urllib.parse
from typing import TYPE_CHECKING
from reader import Feed, HighlightedString, Reader from reader import EntrySearchResult, Feed, HighlightedString, Reader
from discord_rss_bot.settings import get_reader from discord_rss_bot.settings import get_reader
if TYPE_CHECKING:
from collections.abc import Iterable
def create_html_for_search_results(query: str, custom_reader: Reader | None = None) -> str: def create_html_for_search_results(query: str, custom_reader: Reader | None = None) -> str:
"""Create HTML for the search results. """Create HTML for the search results.
@ -21,7 +25,7 @@ def create_html_for_search_results(query: str, custom_reader: Reader | None = No
# Get the default reader if we didn't get a custom one. # Get the default reader if we didn't get a custom one.
reader: Reader = get_reader() if custom_reader is None else custom_reader reader: Reader = get_reader() if custom_reader is None else custom_reader
search_results = reader.search_entries(query) search_results: Iterable[EntrySearchResult] = reader.search_entries(query)
html: str = "" html: str = ""
for result in search_results: for result in search_results:

View File

@ -1,11 +1,11 @@
import os
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from platformdirs import user_data_dir from platformdirs import user_data_dir
from reader import Reader, make_reader # type: ignore from reader import Reader, make_reader # type: ignore
data_dir: str = user_data_dir(appname="discord_rss_bot", appauthor="TheLovinator", roaming=True) data_dir: str = user_data_dir(appname="discord_rss_bot", appauthor="TheLovinator", roaming=True)
os.makedirs(data_dir, exist_ok=True) Path.mkdir(Path(data_dir), exist_ok=True)
print(f"Data is stored in '{data_dir}'.") print(f"Data is stored in '{data_dir}'.")
@ -19,23 +19,21 @@ default_custom_embed: dict[str, str] = {
} }
@lru_cache() @lru_cache
def get_reader(custom_location: str = "") -> Reader: def get_reader(custom_location: Path | None = None) -> Reader:
"""Get the reader. """Get the reader.
Args: Args:
custom_location: The location of the database file. custom_location: The location of the database file.
""" """
db_location: Path = custom_location or Path(data_dir) / "db.sqlite"
db_location: str = custom_location or os.path.join(data_dir, "db.sqlite") return make_reader(url=str(db_location))
return make_reader(url=db_location)
def list_webhooks(reader: Reader) -> list[dict[str, str]]: def list_webhooks(reader: Reader) -> list[dict[str, str]]:
""" """Get current webhooks from the database if they exist otherwise use an empty list.
Get current webhooks from the database if they exist otherwise use an empty list.
Args: Args:
reader: The reader to use. reader: The reader to use.

View File

@ -1,36 +1,42 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
| Add new webhook | Add new webhook
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="p-2 border border-dark"> <div class="p-2 border border-dark">
<form action="/add_webhook" method="post"> <form action="/add_webhook" method="post">
{# Webhook name #} {# Webhook name #}
<div class="row pb-2"> <div class="row pb-2">
<label for="webhook_name" class="col-sm-2 col-form-label">Webhook Name</label> <label for="webhook_name" class="col-sm-2 col-form-label">
<div class="col-sm-10"> Webhook Name
<input name="webhook_name" </label
type="text" >
class="form-control bg-dark border-dark text-muted" <div class="col-sm-10">
id="webhook_name" <input name="webhook_name"
placeholder="TheLovinator #RSS"/> type="text"
</div> class="form-control bg-dark border-dark text-muted"
</div> id="webhook_name"
{# Webhook URL #} placeholder="TheLovinator #RSS"/>
<div class="row pb-2"> </div>
<label for="webhook_url" class="col-sm-2 col-form-label">Webhook URL</label> </div>
<div class="col-sm-10"> {# Webhook URL #}
<input name="webhook_url" <div class="row pb-2">
type="text" <label for="webhook_url" class="col-sm-2 col-form-label">
class="form-control bg-dark border-dark text-muted" Webhook URL
id="webhook_url" </label
placeholder="https://discord.com/api/webhooks/1011224189471124054/CQMa4hJN4gz"/> >
</div> <div class="col-sm-10">
</div> <input name="webhook_url"
{# Submit button #} type="text"
<div class="d-md-flex"> class="form-control bg-dark border-dark text-muted"
<button class="btn btn-dark btn-sm">Add webhook</button> id="webhook_url"
</div> placeholder="https://discord.com/api/webhooks/1011224189471124054/CQMa4hJN4gz"/>
</form> </div>
</div> </div>
{# Submit button #}
<div class="d-md-flex">
<button class="btn btn-dark btn-sm">Add webhook</button>
</div>
</form>
</div>
{% endblock content %} {% endblock content %}

View File

@ -11,209 +11,215 @@
<div class="form-text"> <div class="form-text">
<ul class="list-inline"> <ul class="list-inline">
<li>You can modify the message that is sent to Discord.</li> <li>You can modify the message that is sent to Discord.</li>
<li> You can use \n to create a new line.</li> <li>You can use \n to create a new line.</li>
<li> You can remove the embed from links by adding < and > around the link. (For example <{% raw %}{{entry_link}}{% endraw %}>)</li> <li>
You can remove the embed from links by adding < and > around the link. (For example <
{% raw %}
{{ entry_link }}
{% endraw %}
>)
</li>
<br/> <br/>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_author}} {{ feed_author }}
{% endraw %} {% endraw %}
</code>{{feed.author}} </code>{{ feed.author }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_added}} {{ feed_added }}
{% endraw %} {% endraw %}
</code>{{feed.added}} </code>{{ feed.added }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_last_exception}} {{ feed_last_exception }}
{% endraw %} {% endraw %}
</code>{{feed.last_exception}} </code>{{ feed.last_exception }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_last_updated}} {{ feed_last_updated }}
{% endraw %} {% endraw %}
</code>{{feed.last_updated}} </code>{{ feed.last_updated }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_link}} {{ feed_link }}
{% endraw %} {% endraw %}
</code>{{feed.link}} </code>{{ feed.link }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_subtitle}} {{ feed_subtitle }}
{% endraw %} {% endraw %}
</code>{{feed.subtitle}} </code>{{ feed.subtitle }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_title}} {{ feed_title }}
{% endraw %} {% endraw %}
</code>{{feed.title}} </code>{{ feed.title }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_updated}} {{ feed_updated }}
{% endraw %} {% endraw %}
</code>{{feed.updated}} </code>{{ feed.updated }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_updates_enabled}} {{ feed_updates_enabled }}
{% endraw %} {% endraw %}
</code>{{feed.updates_enabled}} </code>{{ feed.updates_enabled }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_url}} {{ feed_url }}
{% endraw %} {% endraw %}
</code>{{feed.url}} </code>{{ feed.url }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_user_title}} {{ feed_user_title }}
{% endraw %} {% endraw %}
</code>{{feed.user_title}} </code>{{ feed.user_title }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_version}} {{ feed_version }}
{% endraw %} {% endraw %}
</code>{{feed.version}} </code>{{ feed.version }}
</li> </li>
<br/> <br/>
{% if entry %} {% if entry %}
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_added}} {{ entry_added }}
{% endraw %} {% endraw %}
</code>{{entry.added}} </code>{{ entry.added }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_author}} {{ entry_author }}
{% endraw %} {% endraw %}
</code>{{entry.author}} </code>{{ entry.author }}
</li> </li>
{% if entry.content %} {% if entry.content %}
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_content}} {{ entry_content }}
{% endraw %} {% endraw %}
</code>{{entry.content[0].value|discord_markdown}} </code>{{ entry.content[0].value|discord_markdown }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_content_raw}} {{ entry_content_raw }}
{% endraw %} {% endraw %}
</code>{{entry.content[0].value}} </code>{{ entry.content[0].value }}
</li> </li>
{% endif %} {% endif %}
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_id}} {{ entry_id }}
{% endraw %} {% endraw %}
</code>{{entry.id}} </code>{{ entry.id }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_important}} {{ entry_important }}
{% endraw %} {% endraw %}
</code>{{entry.important}} </code>{{ entry.important }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_link}} {{ entry_link }}
{% endraw %} {% endraw %}
</code>{{entry.link}} </code>{{ entry.link }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_published}} {{ entry_published }}
{% endraw %} {% endraw %}
</code>{{entry.published}} </code>{{ entry.published }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_read}} {{ entry_read }}
{% endraw %} {% endraw %}
</code>{{entry.read}} </code>{{ entry.read }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_read_modified}} {{ entry_read_modified }}
{% endraw %} {% endraw %}
</code>{{entry.read_modified}} </code>{{ entry.read_modified }}
</li> </li>
{% if entry.summary %} {% if entry.summary %}
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_summary}} {{ entry_summary }}
{% endraw %} {% endraw %}
</code>{{entry.summary|discord_markdown}} </code>{{ entry.summary|discord_markdown }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_summary_raw}} {{ entry_summary_raw }}
{% endraw %} {% endraw %}
</code>{{entry.summary}} </code>{{ entry.summary }}
</li> </li>
{% endif %} {% endif %}
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_title}} {{ entry_title }}
{% endraw %} {% endraw %}
</code>{{entry.title}} </code>{{ entry.title }}
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_text}} {{ entry_text }}
{% endraw %} {% endraw %}
</code> Same as entry_content if it exists, otherwise entry_summary </code> Same as entry_content if it exists, otherwise entry_summary
</li> </li>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{entry_updated}} {{ entry_updated }}
{% endraw %} {% endraw %}
</code>{{entry.updated}} </code>{{ entry.updated }}
</li> </li>
<br/> <br/>
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{image_1}} {{ image_1 }}
{% endraw %} {% endraw %}
</code>First image in the entry if it exists </code>First image in the entry if it exists
</li> </li>
@ -223,7 +229,7 @@
<li> <li>
<code> <code>
{% raw %} {% raw %}
{{feed_title}}\n{{entry_content}} {{ feed_title }}\n{{ entry_content }}
{% endraw %} {% endraw %}
</code> </code>
</li> </li>
@ -238,12 +244,12 @@
class="form-control bg-dark border-dark text-muted" class="form-control bg-dark border-dark text-muted"
id="custom_message" id="custom_message"
{% if custom_message %} {% if custom_message %}
value="{{- custom_message -}}" value="{{- custom_message -}}"
{% endif %}/> {% endif %}/>
</div> </div>
</div> </div>
<!-- Add a hidden feed_url field to the form --> <!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{feed.url}}"/> <input type="hidden" name="feed_url" value="{{ feed.url }}"/>
<!-- Submit button --> <!-- Submit button -->
<div class="d-md-flex"> <div class="d-md-flex">
<button class="btn btn-dark btn-sm">Update message</button> <button class="btn btn-dark btn-sm">Update message</button>

View File

@ -10,8 +10,19 @@
<br/> <br/>
{% for hook in hooks_with_data %} {% for hook in hooks_with_data %}
<div class="p-2 border border-dark text-muted"> <div class="p-2 border border-dark text-muted">
<img src="https://cdn.discordapp.com/avatars/{{ hook.id }}/{{ hook.avatar }}.webp" {% if hook.avatar is not none %}
class="img-thumbnail"> <img src="https://cdn.discordapp.com/avatars/{{ hook.id }}/{{ hook.avatar }}.webp"
class="img-thumbnail"
height="128"
width="128"
alt="Webhook avatar"/>
{% else %}
<img src="https://cdn.discordapp.com/embed/avatars/{{ hook.avatar_mod }}.png"
class="img-thumbnail"
height="128"
width="128"
alt="Default Discord avatar"/>
{% endif %}
<h3>{{ hook.custom_name }}</h3> <h3>{{ hook.custom_name }}</h3>
<li> <li>
<strong>Name</strong>: {{ hook.name }} <strong>Name</strong>: {{ hook.name }}
@ -23,13 +34,13 @@
<strong>Guild ID</strong>: {{ hook.guild_id }} <strong>Guild ID</strong>: {{ hook.guild_id }}
</li> </li>
<li> <li>
<strong>Webhook ID</strong>: {{ hook.id }} <strong>Webhook ID</strong>: {{ hook.webhook_id }}
</li> </li>
<li> <li>
<strong>Webhook token</strong>: {{ hook.token }} <strong>Webhook token</strong>: {{ hook.token }}
</li> </li>
<li> <li>
<strong>Webhook type</strong>: {{ hook.type }} <strong>Webhook type</strong>: {{ hook.webhook_type }}
</li> </li>
<li> <li>
<strong>Webhook URL</strong>: <a href="{{ hook.url }}">{{ hook.url }}</a> <strong>Webhook URL</strong>: <a href="{{ hook.url }}">{{ hook.url }}</a>

View File

@ -5,7 +5,7 @@ from discord_rss_bot.missing_tags import add_missing_tags
from discord_rss_bot.settings import list_webhooks from discord_rss_bot.settings import list_webhooks
def add_webhook(reader: Reader, webhook_name: str, webhook_url: str): def add_webhook(reader: Reader, webhook_name: str, webhook_url: str) -> None:
"""Add new webhook. """Add new webhook.
Args: Args:
@ -15,9 +15,6 @@ def add_webhook(reader: Reader, webhook_name: str, webhook_url: str):
Raises: Raises:
HTTPException: This is raised when the webhook already exists HTTPException: This is raised when the webhook already exists
Returns:
Returns True if everyting was succesful
""" """
# Get current webhooks from the database if they exist otherwise use an empty list. # Get current webhooks from the database if they exist otherwise use an empty list.
webhooks: list[dict[str, str]] = list_webhooks(reader) webhooks: list[dict[str, str]] = list_webhooks(reader)
@ -31,13 +28,25 @@ def add_webhook(reader: Reader, webhook_name: str, webhook_url: str):
reader.set_tag((), "webhooks", webhooks) # type: ignore reader.set_tag((), "webhooks", webhooks) # type: ignore
add_missing_tags(reader) add_missing_tags(reader)
return True return
# TODO: Show this error on the page. # TODO: Show this error on the page.
# TODO: Replace HTTPException with a custom exception.
raise HTTPException(status_code=409, detail="Webhook already exists") raise HTTPException(status_code=409, detail="Webhook already exists")
def remove_webhook(reader: Reader, webhook_url: str): def remove_webhook(reader: Reader, webhook_url: str) -> None:
"""Remove webhook.
Args:
reader (Reader): The Reader to use
webhook_url (str): The webhook URL to remove
Raises:
HTTPException: If webhook could not be deleted
HTTPException: Webhook not found
"""
# TODO: Replace HTTPException with a custom exception for both of these.
# Get current webhooks from the database if they exist otherwise use an empty list. # Get current webhooks from the database if they exist otherwise use an empty list.
webhooks: list[dict[str, str]] = list_webhooks(reader) webhooks: list[dict[str, str]] = list_webhooks(reader)
@ -52,7 +61,7 @@ def remove_webhook(reader: Reader, webhook_url: str):
# Add our new list of webhooks to the database. # Add our new list of webhooks to the database.
reader.set_tag((), "webhooks", webhooks) # type: ignore reader.set_tag((), "webhooks", webhooks) # type: ignore
return True return
# TODO: Show this error on the page. # TODO: Show this error on the page.
raise HTTPException(status_code=404, detail="Webhook not found") raise HTTPException(status_code=404, detail="Webhook not found")

8
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. # This file is automatically @generated by Poetry and should not be changed by hand.
[[package]] [[package]]
name = "anyio" name = "anyio"
@ -708,14 +708,14 @@ files = [
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.11.0" version = "0.11.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
{file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
] ]
[[package]] [[package]]

View File

@ -28,15 +28,91 @@ djlint = "^1.19.13"
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.isort]
profile = "black"
[tool.black] [tool.black]
line-length = 120 line-length = 120
target-version = ["py311"] target-version = ["py311"]
preview = true
[tool.djlint] [tool.djlint]
ignore = "D004,D018,J018,T001" ignore = "D004,D018,J018,T001"
profile = "jinja" profile = "jinja"
max_line_length = 120 max_line_length = 120
format_attribute_template_tags = true format_attribute_template_tags = true
[tool.ruff]
line-length = 120
select = [
"E",
"F",
"B",
"W",
"C90",
"I",
"N",
"D",
"UP",
"YTT",
"ANN",
"S",
"BLE",
# "FBT", # Reader uses positional boolean values in its function calls
"A",
"COM",
"C4",
"DTZ",
"EM",
"EXE",
"ISC",
"ICN",
"G",
"INP",
"PIE",
"T20",
"PYI",
"PT",
"Q",
"RSE",
"RET",
"SLF",
"SIM",
"TID",
"TCH",
"ARG",
"PTH",
"ERA",
"PGH",
"PL",
"PLC",
"PLE",
"PLR",
"PLW",
"TRY",
"RUF",
]
ignore = [
"D100", # pydocstyle - missing docstring in public module
"D101", # pydocstyle - missing docstring in public class
"D102", # pydocstyle - missing docstring in public method
"D103", # pydocstyle - missing docstring in public function
"D104", # pydocstyle - missing docstring in public package
"D105", # pydocstyle - missing docstring in magic method
"D106", # pydocstyle - missing docstring in public nested class
"D107", # pydocstyle - missing docstring in __init__
"G002", # Allow % in logging
"UP031", # Allow % in logging
"B008", # Allow Form() as a default value
]
[tool.ruff.pydocstyle]
convention = "google"
[tool.ruff.per-file-ignores]
"tests/*" = ["S101"]
[tool.pytest.ini_options]
addopts = "-vvvvvv --exitfirst"
filterwarnings = [
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
"ignore:pkg_resources is deprecated as an API:DeprecationWarning",
"ignore:No parser was explicitly specified:UserWarning",
]

0
tests/__init__.py Normal file
View File

View File

@ -1,11 +1,14 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import TYPE_CHECKING
from reader import Entry, Feed, Reader, make_reader from reader import Entry, Feed, Reader, make_reader
from discord_rss_bot.filter.blacklist import has_black_tags, should_be_skipped from discord_rss_bot.filter.blacklist import has_black_tags, should_be_skipped
if TYPE_CHECKING:
from collections.abc import Iterable
feed_url: str = "https://lovinator.space/rss_test.xml" feed_url: str = "https://lovinator.space/rss_test.xml"

View File

@ -1,12 +1,14 @@
import os
import pathlib import pathlib
import tempfile import tempfile
from pathlib import Path
from reader import Reader from typing import TYPE_CHECKING
from discord_rss_bot.custom_filters import encode_url, entry_is_blacklisted, entry_is_whitelisted from discord_rss_bot.custom_filters import encode_url, entry_is_blacklisted, entry_is_whitelisted
from discord_rss_bot.settings import get_reader from discord_rss_bot.settings import get_reader
if TYPE_CHECKING:
from reader import Reader
def test_encode_url() -> None: def test_encode_url() -> None:
# Test normal input # Test normal input
@ -19,16 +21,16 @@ def test_encode_url() -> None:
== r"https%3A//www.example.com/my%20path%3Fq%3Dabc%26b%3D1" == r"https%3A//www.example.com/my%20path%3Fq%3Dabc%26b%3D1"
) )
# Test empty input # Test empty input
assert encode_url("") == "" assert not encode_url("")
# Test input as None # Test input as None
assert encode_url(None) == "" # type: ignore assert not encode_url(None) # type: ignore
def test_entry_is_whitelisted() -> None: def test_entry_is_whitelisted() -> None:
# Test with a custom reader. # Test with a custom reader.
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory # Create the temp directory
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite") custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite")
custom_reader: Reader = get_reader(custom_location=str(custom_loc)) custom_reader: Reader = get_reader(custom_location=str(custom_loc))
@ -69,7 +71,7 @@ def test_entry_is_blacklisted() -> None:
# Test with a custom reader. # Test with a custom reader.
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory # Create the temp directory
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite") custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite")
custom_reader: Reader = get_reader(custom_location=str(custom_loc)) custom_reader: Reader = get_reader(custom_location=str(custom_loc))

View File

@ -13,11 +13,11 @@ def test_send_to_discord() -> None:
"""Test sending to Discord.""" """Test sending to Discord."""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory. # Create the temp directory.
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
assert os.path.exists(temp_dir) assert Path.exists(Path(temp_dir))
# Create a temporary reader. # Create a temporary reader.
reader: Reader = make_reader(url=str(Path(temp_dir, "test_db.sqlite"))) reader: Reader = make_reader(url=str(Path(temp_dir) / "test_db.sqlite"))
assert reader is not None assert reader is not None
# Add a feed to the reader. # Add a feed to the reader.
@ -35,7 +35,8 @@ def test_send_to_discord() -> None:
# Get the webhook. # Get the webhook.
webhook_url: str | None = os.environ.get("TEST_WEBHOOK_URL") webhook_url: str | None = os.environ.get("TEST_WEBHOOK_URL")
if webhook_url is None: if not webhook_url:
reader.close()
pytest.skip("No webhook URL provided.") pytest.skip("No webhook URL provided.")
assert webhook_url is not None assert webhook_url is not None

View File

@ -1,10 +1,12 @@
from typing import Literal from typing import TYPE_CHECKING, Literal
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from httpx import Response
from discord_rss_bot.main import app, encode_url from discord_rss_bot.main import app, encode_url
if TYPE_CHECKING:
from httpx import Response
client: TestClient = TestClient(app) client: TestClient = TestClient(app)
webhook_name: str = "Hello, I am a webhook!" webhook_name: str = "Hello, I am a webhook!"
webhook_url: str = "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz" webhook_url: str = "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz"
@ -180,7 +182,7 @@ def test_unpause_feed() -> None:
assert feed_url in response.text assert feed_url in response.text
def test_remove_feed(): def test_remove_feed() -> None:
"""Test the /remove page.""" """Test the /remove page."""
# Remove the feed if it already exists before we run the test. # Remove the feed if it already exists before we run the test.
feeds: Response = client.get("/") feeds: Response = client.get("/")
@ -201,7 +203,7 @@ def test_remove_feed():
assert feed_url not in response.text assert feed_url not in response.text
def test_delete_webhook(): def test_delete_webhook() -> None:
"""Test the /delete_webhook page.""" """Test the /delete_webhook page."""
# Remove the feed if it already exists before we run the test. # Remove the feed if it already exists before we run the test.
feeds: Response = client.get("/webhooks") feeds: Response = client.get("/webhooks")

View File

@ -28,7 +28,9 @@ def test_convert_to_md() -> None:
# Test multiple tags # Test multiple tags
assert ( assert (
convert_html_to_md('<b>bold</b> <i>italic</i> <a href="https://example.com">link</a> <code>code</code> <s>strikethrough</s>') # noqa: E501 convert_html_to_md(
'<b>bold</b> <i>italic</i> <a href="https://example.com">link</a> <code>code</code> <s>strikethrough</s>',
)
== "**bold** *italic* [link](https://example.com) `code` ~~strikethrough~~" == "**bold** *italic* [link](https://example.com) `code` ~~strikethrough~~"
) )

View File

@ -1,20 +1,22 @@
import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import TYPE_CHECKING
from reader import Feed, Reader, make_reader from reader import Feed, Reader, make_reader
from discord_rss_bot.search import create_html_for_search_results from discord_rss_bot.search import create_html_for_search_results
if TYPE_CHECKING:
from collections.abc import Iterable
def test_create_html_for_search_results() -> None: def test_create_html_for_search_results() -> None:
"""Test create_html_for_search_results.""" """Test create_html_for_search_results."""
# Create a reader. # Create a reader.
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory. # Create the temp directory.
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
assert os.path.exists(temp_dir) assert Path.exists(Path(temp_dir))
# Create a temporary reader. # Create a temporary reader.
reader: Reader = make_reader(url=str(Path(temp_dir, "test_db.sqlite"))) reader: Reader = make_reader(url=str(Path(temp_dir, "test_db.sqlite")))

View File

@ -1,6 +1,6 @@
import os
import pathlib import pathlib
import tempfile import tempfile
from pathlib import Path
from reader import Reader from reader import Reader
@ -15,7 +15,7 @@ def test_reader() -> None:
# Test the reader with a custom location. # Test the reader with a custom location.
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory # Create the temp directory
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite") custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite")
custom_reader: Reader = get_reader(custom_location=str(custom_loc)) custom_reader: Reader = get_reader(custom_location=str(custom_loc))
@ -27,12 +27,12 @@ def test_reader() -> None:
def test_data_dir() -> None: def test_data_dir() -> None:
"""Test the data directory.""" """Test the data directory."""
assert os.path.exists(data_dir) assert Path.exists(Path(data_dir))
def test_default_custom_message() -> None: def test_default_custom_message() -> None:
"""Test the default custom message.""" """Test the default custom message."""
assert "{{entry_title}}\n{{entry_link}}" == default_custom_message assert default_custom_message == "{{entry_title}}\n{{entry_link}}"
def test_get_webhook_for_entry() -> None: def test_get_webhook_for_entry() -> None:
@ -40,7 +40,7 @@ def test_get_webhook_for_entry() -> None:
# Test with a custom reader. # Test with a custom reader.
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
# Create the temp directory # Create the temp directory
os.makedirs(temp_dir, exist_ok=True) Path.mkdir(Path(temp_dir), exist_ok=True)
custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite") custom_loc: pathlib.Path = pathlib.Path(temp_dir, "custom_loc_db.sqlite")
custom_reader: Reader = get_reader(custom_location=str(custom_loc)) custom_reader: Reader = get_reader(custom_location=str(custom_loc))

View File

@ -1,11 +1,14 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import TYPE_CHECKING
from reader import Entry, Feed, Reader, make_reader from reader import Entry, Feed, Reader, make_reader
from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent
if TYPE_CHECKING:
from collections.abc import Iterable
feed_url: str = "https://lovinator.space/rss_test.xml" feed_url: str = "https://lovinator.space/rss_test.xml"