Add support for extensions
Some checks failed
Test and build Docker image / docker (push) Failing after 3s

This commit is contained in:
Joakim Hellsén 2026-07-20 06:02:25 +02:00
commit 793f67db42
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
39 changed files with 3670 additions and 1309 deletions

View file

@ -52,8 +52,6 @@ from reader import opml
from starlette.responses import RedirectResponse
from starlette.responses import Response as StarletteResponse
from discord_rss_bot.custom_filters import entry_is_blacklisted
from discord_rss_bot.custom_filters import entry_is_whitelisted
from discord_rss_bot.custom_message import CustomEmbed
from discord_rss_bot.custom_message import get_custom_message
from discord_rss_bot.custom_message import get_embed
@ -62,6 +60,14 @@ from discord_rss_bot.custom_message import get_message_avatar_url
from discord_rss_bot.custom_message import get_message_username
from discord_rss_bot.custom_message import replace_tags_in_text_message
from discord_rss_bot.custom_message import save_embed
from discord_rss_bot.extensions import FeedExtension as FeedExtensionABC
from discord_rss_bot.extensions import get_registry as get_extension_registry
from discord_rss_bot.extensions import run_extensions
from discord_rss_bot.extensions.steam import is_steam_url as is_steam_feed_url
from discord_rss_bot.extensions.storage import get_enabled_extensions_for_feed as get_enabled_extensions
from discord_rss_bot.extensions.storage import set_enabled_extensions_for_feed as set_enabled_extensions
from discord_rss_bot.extensions.youtube import extract_youtube_video_id
from discord_rss_bot.extensions.youtube import is_youtube_feed_url
from discord_rss_bot.feeds import FeedUpdateError
from discord_rss_bot.feeds import JsonValue
from discord_rss_bot.feeds import SentWebhookRecord
@ -71,12 +77,12 @@ from discord_rss_bot.feeds import create_feed
from discord_rss_bot.feeds import extract_domain
from discord_rss_bot.feeds import feed_saves_sent_webhooks
from discord_rss_bot.feeds import get_feed_delivery_mode
from discord_rss_bot.feeds import get_feed_display_name
from discord_rss_bot.feeds import get_feed_media_gallery_image_limit
from discord_rss_bot.feeds import get_feed_webhook_text_length_limit
from discord_rss_bot.feeds import get_screenshot_layout
from discord_rss_bot.feeds import get_sent_webhook_records
from discord_rss_bot.feeds import is_chromium_installed
from discord_rss_bot.feeds import is_steam_feed_url
from discord_rss_bot.feeds import send_entry_to_discord
from discord_rss_bot.feeds import send_to_discord
from discord_rss_bot.feeds import update_feed_and_collect_modified_entries
@ -85,6 +91,8 @@ from discord_rss_bot.filter.evaluator import FILTER_FIELDS
from discord_rss_bot.filter.evaluator import EntryFilterDecision
from discord_rss_bot.filter.evaluator import FilterMatch
from discord_rss_bot.filter.evaluator import coerce_filter_values
from discord_rss_bot.filter.evaluator import entry_is_blacklisted
from discord_rss_bot.filter.evaluator import entry_is_whitelisted
from discord_rss_bot.filter.evaluator import evaluate_entry_filters
from discord_rss_bot.filter.evaluator import get_entry_decision_key
from discord_rss_bot.filter.evaluator import get_entry_fields
@ -97,6 +105,7 @@ from discord_rss_bot.search import create_search_context
from discord_rss_bot.settings import data_dir
from discord_rss_bot.settings import default_custom_embed
from discord_rss_bot.settings import default_custom_message
from discord_rss_bot.settings import extensions_dir
from discord_rss_bot.settings import get_reader
from discord_rss_bot.settings import make_app_reader
@ -283,6 +292,7 @@ templates: Jinja2Templates = Jinja2Templates(directory="discord_rss_bot/template
templates.env.filters["encode_url"] = lambda url: urllib.parse.quote(str(url)) if url else ""
templates.env.filters["discord_markdown"] = markdownify # pyright: ignore[reportArgumentType]
templates.env.filters["relative_time"] = relative_time
templates.env.filters["feed_display_name"] = get_feed_display_name
templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[reportArgumentType]
templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType]
@ -493,7 +503,7 @@ def get_global_delivery_mode(reader: Reader) -> str:
def get_autodiscover_links(
reader: Reader,
feed_url: str,
stored_links: object | None = None,
stored_links: JsonValue = None,
) -> list[AutodiscoverLink]:
"""Return valid autodiscovered links stored for a failed feed update.
@ -1402,18 +1412,41 @@ async def get_custom(
"""
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
context: dict[str, Request | Feed | str | Entry] = {
# Collect extension variable names for the template.
ext_registry: dict[str, type] = get_extension_registry()
enabled_ext_names: list[str] = get_enabled_extensions(reader, feed.url)
extension_variables: list[str] = FeedExtensionABC.get_enabled_variables(ext_registry, enabled_ext_names)
# Try to resolve extension values from the first entry.
extension_values: dict[str, str] = {}
first_entry: Entry | None = None
for entry in reader.get_entries(feed=feed, limit=1):
first_entry = entry
break
if first_entry is not None:
try:
extension_values = run_extensions(first_entry, reader)
except Exception:
logger.exception("Failed to run extensions for preview")
# Compute first_image for template preview.
first_image: str = ""
if first_entry is not None:
content_str: str = first_entry.content[0].value if first_entry.content else ""
first_image = get_first_image(first_entry.summary, content_str)
context: dict[str, object] = {
"request": request,
"feed": feed,
"custom_message": get_custom_message(reader, feed),
"message_username": get_message_username(reader, feed),
"message_avatar_url": get_message_avatar_url(reader, feed),
"extension_variables": extension_variables,
"extension_values": extension_values,
"entry": first_entry,
"first_image": first_image,
}
# Get the first entry, this is used to show the user what the custom message will look like.
for entry in reader.get_entries(feed=feed, limit=1):
context["entry"] = entry
return templates.TemplateResponse(request=request, name="custom.html", context=context)
@ -1431,34 +1464,58 @@ async def get_embed_page(
reader: The Reader instance.
Returns:
HTMLResponse: The embed page.
HTMLResponse: The custom message page.
"""
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
# Get previous data, this is used when creating the form.
embed: CustomEmbed = get_embed(reader, feed)
context: dict[str, Request | Feed | str | Entry | CustomEmbed | bool] = {
# Collect extension variables so the template can list them.
ext_registry: dict[str, type] = get_extension_registry()
enabled_ext_names: list[str] = get_enabled_extensions(reader, feed.url)
extension_variables: list[str] = FeedExtensionABC.get_enabled_variables(ext_registry, enabled_ext_names)
# Try to resolve extension values from the first entry.
extension_values: dict[str, str] = {}
first_entry: Entry | None = None
for entry in reader.get_entries(feed=feed, limit=1):
first_entry = entry
break
if first_entry is not None:
try:
extension_values = run_extensions(first_entry, reader)
except Exception:
logger.exception("Failed to run extensions for preview")
# Compute first_image for template preview.
first_image: str = ""
if first_entry is not None:
content_str: str = first_entry.content[0].value if first_entry.content else ""
first_image = get_first_image(first_entry.summary, content_str)
context: dict[str, object] = {
"request": request,
"feed": feed,
"title": embed.title,
"description": embed.description,
"color": embed.color,
"image_url": embed.image_url,
"thumbnail_url": embed.thumbnail_url,
"author_name": embed.author_name,
"author_url": embed.author_url,
"author_icon_url": embed.author_icon_url,
"image_url": embed.image_url,
"thumbnail_url": embed.thumbnail_url,
"footer_text": embed.footer_text,
"footer_icon_url": embed.footer_icon_url,
"show_steam_game_icon_in_thumbnail": embed.show_steam_game_icon_in_thumbnail,
"is_steam_feed": is_steam_feed_url(feed.url or ""),
"extension_variables": extension_variables,
"extension_values": extension_values,
"entry": first_entry,
"first_image": first_image,
}
if custom_embed := get_embed(reader, feed):
context["custom_embed"] = custom_embed
for entry in reader.get_entries(feed=feed, limit=1):
# Append to context.
context["entry"] = entry
return templates.TemplateResponse(request=request, name="embed.html", context=context)
@ -1476,8 +1533,7 @@ async def post_embed( # ruff:ignore[complex-structure]
author_icon_url: Annotated[str, Form()] = "",
footer_text: Annotated[str, Form()] = "",
footer_icon_url: Annotated[str, Form()] = "",
*,
show_steam_game_icon_in_thumbnail: Annotated[bool, Form()] = False,
show_steam_game_icon_in_thumbnail: Annotated[str, Form()] = "",
) -> RedirectResponse:
"""Set the embed settings.
@ -1493,7 +1549,7 @@ async def post_embed( # ruff:ignore[complex-structure]
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.
show_steam_game_icon_in_thumbnail: Whether to use the Steam game icon as the embed thumbnail.
show_steam_game_icon_in_thumbnail: Whether to use Steam game icon as thumbnail.
reader: The Reader instance.
Returns:
@ -1524,8 +1580,11 @@ async def post_embed( # ruff:ignore[complex-structure]
custom_embed.footer_text = footer_text
if footer_icon_url != custom_embed.footer_icon_url:
custom_embed.footer_icon_url = footer_icon_url
if show_steam_game_icon_in_thumbnail != custom_embed.show_steam_game_icon_in_thumbnail:
custom_embed.show_steam_game_icon_in_thumbnail = show_steam_game_icon_in_thumbnail
# Handle the steam thumbnail toggle (checkbox: "true" when checked, "" when unchecked).
new_steam_icon_toggle: bool = show_steam_game_icon_in_thumbnail.strip().lower() in {"true", "on", "1"}
if new_steam_icon_toggle != custom_embed.show_steam_game_icon_in_thumbnail:
custom_embed.show_steam_game_icon_in_thumbnail = new_steam_icon_toggle
# Save the data.
save_embed(reader, feed, custom_embed)
@ -1534,6 +1593,60 @@ async def post_embed( # ruff:ignore[complex-structure]
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.get("/extensions", response_class=HTMLResponse)
async def get_extensions(
feed_url: str,
request: Request,
reader: Annotated[Reader, Depends(get_reader_dependency)],
):
"""Show the extensions configuration page for a feed.
Args:
feed_url: The feed to configure extensions for.
request: The request object.
reader: The Reader instance.
Returns:
HTMLResponse: The extensions configuration page.
"""
feed: Feed = reader.get_feed(urllib.parse.unquote(feed_url.strip()))
registry: dict[str, type] = get_extension_registry()
enabled: list[str] = get_enabled_extensions(reader, feed.url)
context: dict[str, object] = {
"request": request,
"feed": feed,
"discovered_extensions": registry,
"enabled_extensions": enabled,
"extensions_dir": extensions_dir,
}
return templates.TemplateResponse(request=request, name="extensions.html", context=context)
@app.post("/extensions")
async def post_extensions(
feed_url: Annotated[str, Form()],
reader: Annotated[Reader, Depends(get_reader_dependency)],
enabled_extensions: Annotated[list[str] | None, Form()] = None,
) -> RedirectResponse:
"""Save the enabled extensions for a feed.
Args:
feed_url: The feed URL.
reader: The Reader instance.
enabled_extensions: List of extension names to enable.
Returns:
RedirectResponse: Redirect to the feed page.
"""
if enabled_extensions is None:
enabled_extensions = []
clean_feed_url: str = feed_url.strip()
set_enabled_extensions(reader, clean_feed_url, enabled_extensions)
commit_state_change(reader, f"Update extensions for {clean_feed_url}")
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
@app.post("/use_embed")
async def post_use_embed(
feed_url: Annotated[str, Form()],
@ -2078,7 +2191,6 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
"max_webhook_text_length_limit": 4000,
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
"is_steam_feed": is_steam_feed_url(feed.url),
"chromium_installed": is_chromium_installed(),
}
return templates.TemplateResponse(request=request, name="feed.html", context=context)
@ -2146,7 +2258,6 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
"webhook_text_length_limit": get_feed_webhook_text_length_limit(reader, feed),
"max_webhook_text_length_limit": 4000,
"save_sent_webhooks": feed_saves_sent_webhooks(reader, feed),
"is_steam_feed": is_steam_feed_url(feed.url),
"chromium_installed": is_chromium_installed(),
}
return templates.TemplateResponse(request=request, name="feed.html", context=context)
@ -2229,10 +2340,9 @@ def create_html_for_feed( # ruff:ignore[complex-structure, too-many-locals]
)
# Check if this is a YouTube feed entry and the entry has a link
is_youtube_feed = "youtube.com/feeds/videos.xml" in entry.feed.url
video_embed_html = ""
if is_youtube_feed and entry.link:
if is_youtube_feed_url(entry.feed.url) and entry.link:
# Extract the video ID and create an embed if possible
video_id: str | None = extract_youtube_video_id(entry.link)
if video_id:
@ -3213,29 +3323,6 @@ def modify_webhook(
return RedirectResponse(url=redirect_url, status_code=303)
def extract_youtube_video_id(url: str) -> str | None:
"""Extract YouTube video ID from a YouTube video URL.
Args:
url: The YouTube video URL.
Returns:
The video ID if found, None otherwise.
"""
if not url:
return None
# Handle standard YouTube URLs (youtube.com/watch?v=VIDEO_ID)
if "youtube.com/watch" in url and "v=" in url:
return url.split("v=")[1].split("&", maxsplit=1)[0]
# Handle shortened YouTube URLs (youtu.be/VIDEO_ID)
if "youtu.be/" in url:
return url.split("youtu.be/")[1].split("?", maxsplit=1)[0]
return None
def resolve_final_feed_url(url: str) -> tuple[str, str | None]:
"""Resolve a feed URL by following redirects.