Use Steam game names as feed display titles; custom.html are now partly rendered from Python
All checks were successful
Test and build Docker image / docker (push) Successful in 30s

This commit is contained in:
Joakim Hellsén 2026-08-12 21:55:08 +02:00
commit 6c0aea053b
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
15 changed files with 976 additions and 615 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import html
import json
import logging
from dataclasses import dataclass
@ -7,13 +8,16 @@ from typing import TYPE_CHECKING
from bs4 import BeautifulSoup
from bs4 import Tag
from markupsafe import Markup
from discord_rss_bot.extensions import run_extensions
from discord_rss_bot.extensions.steam import fix_steam_image_url
from discord_rss_bot.extensions.steam import get_feed_display_title
from discord_rss_bot.html_format import format_entry_html_for_discord
from discord_rss_bot.is_url_valid import is_url_valid
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Sequence
from reader import Content
@ -105,7 +109,7 @@ def replace_tags_in_text_message(entry: Entry, reader: Reader) -> str:
{"{{feed_last_updated}}": feed_last_updated},
{"{{feed_link}}": feed.link or ""},
{"{{feed_subtitle}}": feed.subtitle or ""},
{"{{feed_title}}": feed.title or ""},
{"{{feed_title}}": get_feed_display_title(feed, reader)},
{"{{feed_updated}}": feed_updated},
{"{{feed_updates_enabled}}": str(feed.updates_enabled) or ""},
{"{{feed_url}}": feed.url or ""},
@ -278,7 +282,7 @@ def replace_tags_in_embed(feed: Feed, entry: Entry, reader: Reader) -> CustomEmb
{"{{feed_last_updated}}": feed_last_updated or ""},
{"{{feed_link}}": feed.link or ""},
{"{{feed_subtitle}}": feed.subtitle or ""},
{"{{feed_title}}": feed.title or ""},
{"{{feed_title}}": get_feed_display_title(feed, reader)},
{"{{feed_updated}}": feed_updated or ""},
{"{{feed_updates_enabled}}": "True" if feed.updates_enabled else "False"},
{"{{feed_url}}": feed.url or ""},
@ -344,6 +348,143 @@ def _replace_embed_tags(embed: CustomEmbed, template: str, replace_with: str) ->
embed.username = try_to_replace(embed.username, template, replace_with)
def _tag_reference_item(tag: str, value: object) -> str:
"""Return one reference row for the template-variable preview list.
Args:
tag: The template variable name without braces, e.g. ``feed_title``.
value: The preview value to display next to the tag.
Returns:
An HTML ``<li>`` with the tag in ``<code>`` and the escaped value.
"""
escaped_value: str = html.escape(str(value), quote=True)
return f"<li><code>{{{{{tag}}}}}</code> {escaped_value}</li>"
def _tag_reference_value(obj: object, name: str) -> object:
"""Read ``obj.name``, returning an empty string when the attribute is missing.
Mirrors Jinja's lenient attribute lookup so the reference list also works
with the minimal stubs used in tests.
Args:
obj: The object to read the attribute from.
name: The attribute name.
Returns:
The attribute value, or an empty string when it is missing.
"""
return getattr(obj, name, "")
def render_tag_reference_html(
*,
feed: Feed,
entry: Entry | None,
feed_title: str,
first_image: str,
extension_variables: Sequence[str] = (),
extension_values: Mapping[str, str] | None = None,
) -> Markup:
"""Render the template-variable reference list for the custom/embed pages.
The list used to be hardcoded in ``custom.html`` and ``embed.html`` with
many ``{% raw %}`` blocks, which broke whenever a formatter touched the
templates. Building the markup here keeps the ``{{ ... }}`` tags intact
because Jinja does not re-parse the returned string.
Args:
feed: The feed whose values are previewed.
entry: The first entry to preview, or None when the feed has no entries.
feed_title: The resolved display title for the feed (``{{feed_title}}``).
first_image: The first image URL found in the entry (``{{image_1}}``).
extension_variables: Names of the enabled extension variables.
extension_values: Values produced by the enabled extensions.
Returns:
A ``<ul>`` list of reference rows, safe to embed directly in HTML.
"""
if extension_values is None:
extension_values = {}
rows: list[str] = ['<ul class="list-inline">', "<br />"]
rows.extend(
[
_tag_reference_item("feed_author", _tag_reference_value(feed, "authors_str")),
_tag_reference_item("feed_added", _tag_reference_value(feed, "added")),
_tag_reference_item("feed_last_exception", _tag_reference_value(feed, "last_exception")),
_tag_reference_item("feed_last_updated", _tag_reference_value(feed, "last_updated")),
_tag_reference_item("feed_link", _tag_reference_value(feed, "link")),
_tag_reference_item("feed_subtitle", _tag_reference_value(feed, "subtitle")),
_tag_reference_item("feed_title", feed_title),
_tag_reference_item("feed_updated", _tag_reference_value(feed, "updated")),
_tag_reference_item("feed_updates_enabled", _tag_reference_value(feed, "updates_enabled")),
_tag_reference_item("feed_url", _tag_reference_value(feed, "url")),
_tag_reference_item("feed_user_title", _tag_reference_value(feed, "user_title")),
_tag_reference_item("feed_version", _tag_reference_value(feed, "version")),
],
)
rows.append("<br />")
if entry is not None:
content_html: str = entry.content[0].value if entry.content else ""
summary: str = entry.summary or ""
entry_content: str = format_entry_html_for_discord(content_html) if entry.content else ""
entry_summary: str = format_entry_html_for_discord(summary) if entry.summary else ""
entry_text: str = entry_content if entry.content else entry_summary
rows.extend(
[
_tag_reference_item("entry_added", entry.added),
_tag_reference_item("entry_author", entry.authors_str or ""),
],
)
if entry.content:
rows.extend((
_tag_reference_item("entry_content", entry_content),
_tag_reference_item("entry_content_raw", content_html),
))
rows.extend(
[
_tag_reference_item("entry_id", entry.id),
_tag_reference_item("entry_important", entry.important),
_tag_reference_item("entry_link", entry.link or ""),
_tag_reference_item("entry_published", entry.published),
_tag_reference_item("entry_read", entry.read),
_tag_reference_item("entry_read_modified", entry.read_modified),
],
)
if entry.summary:
rows.extend((
_tag_reference_item("entry_summary", entry_summary),
_tag_reference_item("entry_summary_raw", summary),
))
rows.extend(
[
_tag_reference_item("entry_title", entry.title or ""),
_tag_reference_item("entry_text", entry_text),
_tag_reference_item("entry_updated", entry.updated),
],
)
rows.extend(("<br />", _tag_reference_item("image_1", first_image)))
if extension_variables:
rows.extend(("<br />", "<li>Extension variables (from enabled extensions):</li>"))
for var_name in extension_variables:
var_value: str = extension_values.get(var_name, "") or "(empty)"
rows.append(_tag_reference_item(var_name, var_value))
else:
rows.append(
"Something went wrong, there was no entry found. If this feed has entries and you still see this "
"message, please contact the developer.",
)
rows.append("</ul>")
# Safe: every dynamic value is HTML-escaped by _tag_reference_item.
return Markup("\n".join(rows)) # ruff: ignore[unsafe-markup-use]
def get_custom_message(reader: Reader, feed: Feed) -> str:
"""Get custom_message tag from feed.

View file

@ -8,6 +8,11 @@ It also rewrites broken Steam CDN image URLs: some news feeds reference
localized image names such as ``.../english.png`` that return a 404, and
the working URL omits the language segment (``.../<hash>.png``).
Steam feeds' ``{{feed_title}}`` (and web UI display names) use the game
name (e.g. "Dota") instead of the raw feed title (e.g. "570 RSS Feed").
The name is fetched once from the Steam Store API and cached in the reader
DB per feed, so no repeated network requests are needed.
Usage:
1. Enable ``steam`` on the feed's Extensions page.
2. In embed settings, set Thumbnail URL to ``{{steam_thumbnail_url}}``.
@ -19,6 +24,7 @@ import hashlib
import json
import logging
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING
from typing import ClassVar
@ -28,6 +34,9 @@ from urllib.parse import parse_qs
from urllib.parse import urlparse
from urllib.parse import urlunsplit
import httpx2
from httpx2 import HTTPError
from httpx2 import Response
from reader import ReaderError
from discord_rss_bot.extensions.base import FeedExtension
@ -258,6 +267,172 @@ def fix_steam_image_urls_in_payload(value: JsonValue) -> JsonValue:
return value
#: Reader tag under which the Steam game name is cached per feed. Populated
#: once per feed so title rendering never needs a second network request.
STEAM_GAME_NAME_TAG: str = "steam_game_name"
#: Steam Store API endpoint used to resolve app IDs to game names.
_STEAM_APPDETAILS_URL: str = "https://store.steampowered.com/api/appdetails"
#: How long to wait before retrying a failed game-name lookup for the same app.
_STEAM_GAME_NAME_RETRY_COOLDOWN: float = 60.0 * 60.0
#: Last lookup attempt time per app ID. Used so a repeatedly failing feed
#: does not trigger a network request on every single message.
_steam_game_name_attempts: dict[str, float] = {}
def get_steam_game_name(app_id: str) -> str | None: # ruff:ignore[too-many-return-statements]
"""Fetch the game name for *app_id* from the Steam Store API.
Args:
app_id: The Steam application ID.
Returns:
The game name, or ``None`` if it could not be determined.
"""
try:
response: Response = httpx2.get(
_STEAM_APPDETAILS_URL,
params={"appids": app_id, "l": "english"},
timeout=10.0,
)
response.raise_for_status()
except (HTTPError, OSError) as exc:
logger.warning("Failed to fetch Steam game name for app %s: %s", app_id, exc)
return None
try:
data: object = response.json()
except ValueError as exc:
logger.warning("Invalid JSON from Steam Store API for app %s: %s", app_id, exc)
return None
if not isinstance(data, dict):
return None
details: object = data.get(app_id)
if not isinstance(details, dict) or not details.get("success"):
logger.warning("Steam Store API returned no details for app %s", app_id)
return None
app_data: object = details.get("data")
if not isinstance(app_data, dict):
return None
name: object = app_data.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
return None
def get_cached_steam_game_name(reader: Reader, feed: Feed | str) -> str | None:
"""Return the Steam game name cached in the reader DB for *feed*, if any.
Args:
reader: The reader instance.
feed: The feed (or feed URL) to look up.
Returns:
The cached game name, or ``None`` when nothing is cached.
"""
try:
cached = reader.get_tag(feed, STEAM_GAME_NAME_TAG, None)
except ReaderError:
logger.exception("Failed to read Steam game name tag for %s", feed)
return None
if isinstance(cached, str) and cached.strip():
return cached.strip()
return None
def set_steam_game_name(reader: Reader, feed: Feed | str, game_name: str) -> None:
"""Cache *game_name* for *feed* in the reader DB.
Args:
reader: The reader instance.
feed: The feed (or feed URL) to store the name for.
game_name: The game name to cache.
"""
try:
reader.set_tag(feed, STEAM_GAME_NAME_TAG, game_name) # pyright: ignore[reportArgumentType]
except ReaderError:
logger.exception("Failed to cache Steam game name for %s", feed)
def get_feed_display_title(feed: Feed, reader: Reader) -> str:
"""Return the display title for a feed.
For Steam feeds, uses the game name (e.g. "Dota") instead of the raw feed
title (e.g. "570 RSS Feed"). The name is read from the reader DB cache;
when the cache is empty it is fetched from the Steam Store API once and
then cached, so title rendering never makes "unnecessary network requests"
for feeds that have already been resolved. Falls back to the feed's own
title when the game name cannot be determined. Non-Steam feeds are
returned with their title unchanged.
Args:
feed: The feed to render a title for.
reader: The reader instance.
Returns:
The display title for *feed*.
"""
feed_url: str = getattr(feed, "url", "") or ""
fallback_title: str = getattr(feed, "title", "") or ""
if not is_steam_url(feed_url):
return fallback_title
app_id: str | None = extract_app_id(feed_url)
if not app_id:
return fallback_title
cached: str | None = get_cached_steam_game_name(reader, feed)
if cached:
return cached
# Don't hammer the API for app IDs whose lookup recently failed.
now: float = time.monotonic()
last_attempt: float = _steam_game_name_attempts.get(app_id, 0.0)
if now - last_attempt < _STEAM_GAME_NAME_RETRY_COOLDOWN:
return fallback_title
_steam_game_name_attempts[app_id] = now
game_name: str | None = get_steam_game_name(app_id)
if game_name:
set_steam_game_name(reader, feed, game_name)
return game_name
return fallback_title
def get_cached_feed_display_title(feed: Feed, reader: Reader) -> str:
"""Return the display title for a feed using only cached data.
For Steam feeds, uses the cached game name when available, otherwise the
feed's own title. Unlike :func:`get_feed_display_title`, this never
performs network requests or writes to the reader it only reads the
cache. Meant for read-only render paths (e.g. web UI pages) that must not
block on the Steam Store API.
Args:
feed: The feed to render a title for.
reader: The reader instance.
Returns:
The display title for *feed* using only cached data.
"""
feed_url: str = getattr(feed, "url", "") or ""
fallback_title: str = getattr(feed, "title", "") or ""
if not is_steam_url(feed_url):
return fallback_title
app_id: str | None = extract_app_id(feed_url)
if not app_id:
return fallback_title
cached: str | None = get_cached_steam_game_name(reader, feed)
if cached:
return cached
return fallback_title
def _try_read_icon_file(app_id: str) -> WebhookFile | None:
"""Read a Steam game icon from disk, returning a ``WebhookFile`` or ``None``.

View file

@ -57,6 +57,8 @@ from discord_rss_bot.custom_message import replace_tags_in_embed
from discord_rss_bot.custom_message import replace_tags_in_text_message
from discord_rss_bot.extensions import auto_enable_extensions_for_feed
from discord_rss_bot.extensions import run_modify_webhook
from discord_rss_bot.extensions.steam import get_cached_feed_display_title
from discord_rss_bot.extensions.steam import get_feed_display_title
from discord_rss_bot.filter.evaluator import get_entry_filter_decision_from_reader
from discord_rss_bot.is_url_valid import is_url_valid
from discord_rss_bot.settings import default_custom_embed
@ -141,12 +143,14 @@ MESSAGE_PAYLOAD_KEYS: tuple[str, ...] = (
)
def get_feed_display_name(feed: Feed) -> str:
def get_feed_display_name(feed: Feed, reader: Reader | None = None) -> str:
"""Return a human-readable display name for a feed.
For YouTube feeds whose title is just "Videos", shows
``"{author}'s Videos"`` instead. Other feeds return the title
as-is.
``"{author}'s Videos"`` instead. For Steam feeds, shows the game name
(e.g. "Dota") instead of the raw feed title (e.g. "570 RSS Feed") when
*reader* is provided and the name is cached. Other feeds return the
title as-is.
"""
url: str = feed.url or ""
title: str = feed.title or ""
@ -156,6 +160,14 @@ def get_feed_display_name(feed: Feed) -> str:
if author:
return f"{author}'s Videos"
if reader is not None:
steam_title: str = get_cached_feed_display_title(feed, reader)
if steam_title and steam_title != title:
try:
return html.unescape(steam_title)
except (TypeError, AttributeError):
return steam_title
try:
return html.unescape(title) if title else url
except (TypeError, AttributeError):
@ -648,7 +660,7 @@ def upsert_sent_webhook_record(
delivery_mode: DeliveryMode = get_entry_delivery_mode(reader, entry)
record: SentWebhookRecord = {
"feed_url": entry.feed.url,
"feed_title": entry.feed.title or "",
"feed_title": get_feed_display_title(entry.feed, reader),
"entry_id": entry.id,
"entry_title": entry.title or "",
"entry_link": entry.link or "",
@ -878,9 +890,9 @@ def apply_feed_webhook_identity(webhook: DiscordWebhook, entry: Entry, reader: R
avatar_url: str = get_validated_message_avatar_url(reader, feed)
if not username:
# Fall back to feed author or title so the webhook name is
# Fall back to feed author or display title so the webhook name is
# identifiable rather than the generic Discord webhook default.
feed_author: str = feed.authors_str or feed.title or ""
feed_author: str = feed.authors_str or get_feed_display_title(feed, reader) or ""
if feed_author:
username = html.unescape(feed_author)[:80]
@ -1043,7 +1055,7 @@ def update_sent_webhook_record_for_entry(
return (
{
**record,
"feed_title": entry.feed.title or "",
"feed_title": get_feed_display_title(entry.feed, reader),
"entry_title": entry.title or "",
"entry_link": entry.link or "",
"entry_updated": get_entry_timestamp(entry.updated),

View file

@ -58,11 +58,14 @@ from discord_rss_bot.custom_message import get_embed
from discord_rss_bot.custom_message import get_first_image
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 render_tag_reference_html
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 get_cached_feed_display_title
from discord_rss_bot.extensions.steam import get_feed_display_title
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
@ -295,6 +298,7 @@ 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]
templates.env.globals["render_tag_reference_html"] = render_tag_reference_html # pyright: ignore[reportArgumentType]
@app.get("/export_opml")
@ -839,6 +843,7 @@ async def get_whitelist(
context = {
"request": request,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
**build_filter_form_context("whitelist", get_filter_values_from_reader(reader, feed, "whitelist")),
**build_filter_preview_context(reader, feed, "whitelist"),
}
@ -970,6 +975,7 @@ async def get_blacklist(
context = {
"request": request,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
**build_filter_form_context("blacklist", get_filter_values_from_reader(reader, feed, "blacklist")),
**build_filter_preview_context(reader, feed, "blacklist"),
}
@ -1442,6 +1448,7 @@ async def get_custom(
context: dict[str, object] = {
"request": request,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
"custom_message": get_custom_message(reader, feed),
"message_username": get_message_username(reader, feed),
"message_avatar_url": get_message_avatar_url(reader, feed),
@ -1501,6 +1508,7 @@ async def get_embed_page(
context: dict[str, object] = {
"request": request,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
"title": embed.title,
"description": embed.description,
"color": embed.color,
@ -1633,6 +1641,7 @@ async def get_extensions(
context: dict[str, object] = {
"request": request,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
"discovered_extensions": registry,
"enabled_extensions": enabled,
"extensions_dir": extensions_dir,
@ -2185,7 +2194,9 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
context = {
"request": request,
"reader": reader,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
"entries": current_entries,
"feed_counts": reader.get_feed_counts(feed=clean_feed_url),
"html": html,
@ -2253,7 +2264,9 @@ async def get_feed( # ruff:ignore[complex-structure, too-many-branches, too-man
context = {
"request": request,
"reader": reader,
"feed": feed,
"feed_title": get_feed_display_title(feed, reader),
"entries": entries,
"feed_counts": reader.get_feed_counts(feed=clean_feed_url),
"html": html,
@ -2343,7 +2356,7 @@ def create_html_for_feed( # ruff:ignore[complex-structure, too-many-locals]
feed_link: str = ""
if not current_feed_url or source_feed_url != current_feed_url:
encoded_feed_url: str = urllib.parse.quote(source_feed_url)
feed_title: str = entry.feed.title if hasattr(entry.feed, "title") and entry.feed.title else source_feed_url
feed_title: str = get_cached_feed_display_title(entry.feed, reader) or source_feed_url
feed_link = (
f"<a class='text-muted' style='font-size: 0.85em;' "
f"href='/feed?feed_url={encoded_feed_url}'>{feed_title}</a><br>"
@ -2582,7 +2595,9 @@ async def get_sent_webhooks(
webhook_names: dict[str, str] = {
hook.get("url", ""): hook.get("name", "") for hook in webhooks if isinstance(hook, dict)
}
feed_titles: dict[str, str] = {feed.url: (feed.title or feed.url) for feed in reader.get_feeds()}
feed_titles: dict[str, str] = {
feed.url: (get_cached_feed_display_title(feed, reader) or feed.url) for feed in reader.get_feeds()
}
context = {
"request": request,
@ -2653,6 +2668,7 @@ def make_context_index(request: Request, message: str = "", reader: Reader | Non
return {
"request": request,
"reader": effective_reader,
"feeds": feed_list,
"feed_count": effective_reader.get_feed_counts(),
"entry_count": effective_reader.get_entry_counts(),

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}
Blacklist: {{ feed.title if feed.title else feed.url }} | discord-rss-bot
Blacklist: {{ feed_title if feed_title else feed.url }} | discord-rss-bot
{% endblock title %}
{% block description %}
Block matching entries from {{ feed.title if feed.title else feed.url }} before they are delivered to Discord.
Block matching entries from {{ feed_title if feed_title else feed.url }} before they are delivered to Discord.
{% endblock description %}
{% block content %}
<div class="row g-3 filter-page">

View file

@ -1,277 +1,65 @@
{% extends "base.html" %}
{% block title %}Message Template: {{ feed.title if feed.title else feed.url }} | discord-rss-bot{% endblock title %}
{% block description %}Customize the plain text Discord message template for {{ feed.title if feed.title else feed.url }}.{% endblock description %}
{% block title %}Message Template: {{ feed_title if feed_title else feed.url }} | discord-rss-bot{% endblock title %}
{% block description %}Customize the plain text Discord message template for {{ feed_title if feed_title else feed.url }}.{% endblock description %}
{% block content %}
<div class="p-2 border border-dark">
<form action="/custom" method="post">
<!-- Feed URL -->
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<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 remove the embed from links by adding < and> around the link. (For example <
{% raw %} {{entry_link}} {% endraw %}>)
</li>
<br />
<li>
<code>
{% raw %}
{{feed_author}}
{% endraw %}
</code>{{ feed.authors_str }}
</li>
<li>
<code>
{% raw %}
{{feed_added}}
{% endraw %}
</code>{{ feed.added }}
</li>
<li>
<code>
{% raw %}
{{feed_last_exception}}
{% endraw %}
</code>{{ feed.last_exception }}
</li>
<li>
<code>
{% raw %}
{{feed_last_updated}}
{% endraw %}
</code>{{ feed.last_updated }}
</li>
<li>
<code>
{% raw %}
{{feed_link}}
{% endraw %}
</code>{{ feed.link }}
</li>
<li>
<code>
{% raw %}
{{feed_subtitle}}
{% endraw %}
</code>{{ feed.subtitle }}
</li>
<li>
<code>
{% raw %}
{{feed_title}}
{% endraw %}
</code>{{ feed.title }}
</li>
<li>
<code>
{% raw %}
{{feed_updated}}
{% endraw %}
</code>{{ feed.updated }}
</li>
<li>
<code>
{% raw %}
{{feed_updates_enabled}}
{% endraw %}
</code>{{ feed.updates_enabled }}
</li>
<li>
<code>
{% raw %}
{{feed_url}}
{% endraw %}
</code>{{ feed.url }}
</li>
<li>
<code>
{% raw %}
{{feed_user_title}}
{% endraw %}
</code>{{ feed.user_title }}
</li>
<li>
<code>
{% raw %}
{{feed_version}}
{% endraw %}
</code>{{ feed.version }}
</li>
<br />
{% if entry %}
<li>
<code>
{% raw %}
{{entry_added}}
{% endraw %}
</code>{{ entry.added }}
</li>
<li>
<code>
{% raw %}
{{entry_author}}
{% endraw %}
</code>{{ entry.authors_str }}
</li>
{% if entry.content %}
<li>
<code>
{% raw %}
{{entry_content}}
{% endraw %}
</code>{{ entry.content[0].value|discord_markdown }}
</li>
<li>
<code>
{% raw %}
{{entry_content_raw}}
{% endraw %}
</code>{{ entry.content[0].value }}
</li>
{% endif %}
<li>
<code>
{% raw %}
{{entry_id}}
{% endraw %}
</code>{{ entry.id }}
</li>
<li>
<code>
{% raw %}
{{entry_important}}
{% endraw %}
</code>{{ entry.important }}
</li>
<li>
<code>
{% raw %}
{{entry_link}}
{% endraw %}
</code>{{ entry.link }}
</li>
<li>
<code>
{% raw %}
{{entry_published}}
{% endraw %}
</code>{{ entry.published }}
</li>
<li>
<code>
{% raw %}
{{entry_read}}
{% endraw %}
</code>{{ entry.read }}
</li>
<li>
<code>
{% raw %}
{{entry_read_modified}}
{% endraw %}
</code>{{ entry.read_modified }}
</li>
{% if entry.summary %}
<li>
<code>
{% raw %}
{{entry_summary}}
{% endraw %}
</code>{{ entry.summary|discord_markdown }}
</li>
<li>
<code>
{% raw %}
{{entry_summary_raw}}
{% endraw %}
</code>{{ entry.summary }}
</li>
{% endif %}
<li>
<code>
{% raw %}
{{entry_title}}
{% endraw %}
</code>{{ entry.title }}
</li>
<li>
<code>
{% raw %}
{{entry_text}}
{% endraw %}
</code>{{ entry.content[0].value|discord_markdown if entry and entry.content else (entry.summary|discord_markdown if entry and entry.summary else '') }}
</li>
<li>
<code>
{% raw %}
{{entry_updated}}
{% endraw %}
</code>{{ entry.updated }}
</li>
<br />
<li>
<code>
{% raw %}
{{image_1}}
{% endraw %}
</code>{{ first_image }}
</li>
{% if extension_variables %}
<br />
<li>Extension variables (from enabled extensions):</li>
{% for var_name in extension_variables %}
<li>
<code>{% raw %}{{{% endraw %}{{ var_name }}{% raw %}}}{% endraw %}</code>
{% if var_name in extension_values and extension_values[var_name] %}
{{ extension_values[var_name] }}
{% else %}
(empty)
{% endif %}
</li>
{% endfor %}
{% endif %}
</ul>
{% else %}
Something went wrong, there was no entry found. If this feed has entries and you still see this
message, please contact the developer.
{% endif %}
<div class="p-2 border border-dark">
<form action="/custom" method="post">
<!-- Feed URL -->
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<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 remove the embed from links by adding &lt; and &gt; around the link. (For example &lt;{% raw %}{{ entry_link }}{% endraw %}&gt;)
</li>
</ul>
{{ render_tag_reference_html(feed=feed, entry=entry, feed_title=feed_title, first_image=first_image, extension_variables=extension_variables, extension_values=extension_values) }}
</div>
<label for="custom_message" class="col-sm-6 col-form-label">Message</label>
<input name="custom_message"
type="text"
class="form-control bg-dark border-dark text-muted"
id="custom_message"
{% if custom_message %} value="{{- custom_message -}}" {% endif %} />
</div>
<label for="custom_message" class="col-sm-6 col-form-label">Message</label>
<input name="custom_message" type="text" class="form-control bg-dark border-dark text-muted"
id="custom_message" {% if custom_message %} value="{{- custom_message -}}" {% endif %} />
</div>
</div>
<!-- Optional Discord webhook username & avatar for this feed -->
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<li>Optional: override the Discord webhook username and avatar for messages from this feed.</li>
<li>Leave blank to use the webhook's default name and avatar.</li>
<li>Invalid values (empty, disallowed characters, or bad avatar URLs) are ignored when sending.</li>
<li>Username rules: 180 characters; cannot contain <code>@</code>, <code>#</code>, <code>:</code>, or <code>`</code>; cannot contain <code>clyde</code> or <code>discord</code>.</li>
<li>Avatar must be a full <code>http://</code> or <code>https://</code> image URL.</li>
</ul>
<!-- Optional Discord webhook username & avatar for this feed -->
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<li>Optional: override the Discord webhook username and avatar for messages from this feed.</li>
<li>Leave blank to use the webhook's default name and avatar.</li>
<li>Invalid values (empty, disallowed characters, or bad avatar URLs) are ignored when sending.</li>
<li>Username rules: 180 characters; cannot contain <code>@</code>, <code>#</code>, <code>:</code>, or <code>`</code>; cannot contain <code>clyde</code> or <code>discord</code>.</li>
<li>Avatar must be a full <code>http://</code> or <code>https://</code> image URL.</li>
</ul>
</div>
<label for="message_username" class="col-sm-6 col-form-label">Message Username</label>
<input name="message_username"
type="text"
class="form-control bg-dark border-dark text-muted"
id="message_username"
maxlength="80"
placeholder="e.g. Power Of The Shell"
{% if message_username %} value="{{- message_username -}}" {% endif %} />
<label for="message_avatar_url" class="col-sm-6 col-form-label">Message Avatar Image</label>
<input name="message_avatar_url"
type="url"
class="form-control bg-dark border-dark text-muted"
id="message_avatar_url"
placeholder="e.g. https://example.com/icon.png"
{% if message_avatar_url %} value="{{- message_avatar_url -}}" {% endif %} />
</div>
<label for="message_username" class="col-sm-6 col-form-label">Message Username</label>
<input name="message_username" type="text" class="form-control bg-dark border-dark text-muted"
id="message_username" maxlength="80" placeholder="e.g. Power Of The Shell"
{% if message_username %} value="{{- message_username -}}" {% endif %} />
<label for="message_avatar_url" class="col-sm-6 col-form-label">Message Avatar Image</label>
<input name="message_avatar_url" type="url" class="form-control bg-dark border-dark text-muted"
id="message_avatar_url" placeholder="e.g. https://example.com/icon.png"
{% if message_avatar_url %} value="{{- message_avatar_url -}}" {% endif %} />
</div>
</div>
<!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
<!-- Submit button -->
<div class="d-md-flex">
<button class="btn btn-dark btn-sm">Update message</button>
</div>
</form>
</div>
<!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
<!-- Submit button -->
<div class="d-md-flex">
<button class="btn btn-dark btn-sm">Update message</button>
</div>
</form>
</div>
{% endblock content %}

View file

@ -1,324 +1,137 @@
{% extends "base.html" %}
{% block title %}Embed Template: {{ feed.title if feed.title else feed.url }} | discord-rss-bot{% endblock title %}
{% block description %}Customize the Discord embed layout, colors, and media for {{ feed.title if feed.title else feed.url }}.{% endblock description %}
{% block title %}Embed Template: {{ feed_title if feed_title else feed.url }} | discord-rss-bot{% endblock title %}
{% block description %}Customize the Discord embed layout, colors, and media for {{ feed_title if feed_title else feed.url }}.{% endblock description %}
{% block content %}
<div class="p-2 border border-dark">
<form action="/embed" method="post">
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
<ul class="list-inline">
<br />
<li>
<code>
{% raw %}
{{feed_author}}
{% endraw %}
</code>{{feed.authors_str}}
</li>
<li>
<code>
{% raw %}
{{feed_added}}
{% endraw %}
</code>{{feed.added}}
</li>
<li>
<code>
{% raw %}
{{feed_last_exception}}
{% endraw %}
</code>{{feed.last_exception}}
</li>
<li>
<code>
{% raw %}
{{feed_last_updated}}
{% endraw %}
</code>{{feed.last_updated}}
</li>
<li>
<code>
{% raw %}
{{feed_link}}
{% endraw %}
</code>{{feed.link}}
</li>
<li>
<code>
{% raw %}
{{feed_subtitle}}
{% endraw %}
</code>{{feed.subtitle}}
</li>
<li>
<code>
{% raw %}
{{feed_title}}
{% endraw %}
</code>{{feed.title}}
</li>
<li>
<code>
{% raw %}
{{feed_updated}}
{% endraw %}
</code>{{feed.updated}}
</li>
<li>
<code>
{% raw %}
{{feed_updates_enabled}}
{% endraw %}
</code>{{feed.updates_enabled}}
</li>
<li>
<code>
{% raw %}
{{feed_url}}
{% endraw %}
</code>{{feed.url}}
</li>
<li>
<code>
{% raw %}
{{feed_user_title}}
{% endraw %}
</code>{{feed.user_title}}
</li>
<li>
<code>
{% raw %}
{{feed_version}}
{% endraw %}
</code>{{feed.version}}
</li>
<br />
{% if entry %}
<li>
<code>
{% raw %}
{{entry_added}}
{% endraw %}
</code>{{entry.added}}
</li>
<li>
<code>
{% raw %}
{{entry_author}}
{% endraw %}
</code>{{entry.authors_str}}
</li>
{% if entry.content %}
<li>
<code>
{% raw %}
{{entry_content}}
{% endraw %}
</code>{{entry.content[0].value|discord_markdown}}
</li>
<li>
<code>
{% raw %}
{{entry_content_raw}}
{% endraw %}
</code>{{entry.content[0].value}}
</li>
{% endif %}
<li>
<code>
{% raw %}
{{entry_id}}
{% endraw %}
</code>{{entry.id}}
</li>
<li>
<code>
{% raw %}
{{entry_important}}
{% endraw %}
</code>{{entry.important}}
</li>
<li>
<code>
{% raw %}
{{entry_link}}
{% endraw %}
</code>{{entry.link}}
</li>
<li>
<code>
{% raw %}
{{entry_published}}
{% endraw %}
</code>{{entry.published}}
</li>
<li>
<code>
{% raw %}
{{entry_read}}
{% endraw %}
</code>{{entry.read}}
</li>
<li>
<code>
{% raw %}
{{entry_read_modified}}
{% endraw %}
</code>{{entry.read_modified}}
</li>
{% if entry.summary %}
<li>
<code>
{% raw %}
{{entry_summary}}
{% endraw %}
</code>{{entry.summary|discord_markdown}}
</li>
<li>
<code>
{% raw %}
{{entry_summary_raw}}
{% endraw %}
</code>{{entry.summary}}
</li>
{% endif %}
<li>
<code>
{% raw %}
{{entry_title}}
{% endraw %}
</code>{{entry.title}}
</li>
<li>
<code>
{% raw %}
{{entry_text}}
{% endraw %}
</code>{{ entry.content[0].value|discord_markdown if entry and entry.content else (entry.summary|discord_markdown if entry and entry.summary else '') }}
</li>
<li>
<code>
{% raw %}
{{entry_updated}}
{% endraw %}
</code>{{entry.updated}}
</li>
<br />
<li>
<code>
{% raw %}
{{image_1}}
{% endraw %}
</code>{{ first_image }}
</li>
{% if extension_variables %}
<br />
<li>Extension variables (from enabled extensions):</li>
{% for var_name in extension_variables %}
<li>
<code>{% raw %}{{{% endraw %}{{ var_name }}{% raw %}}}{% endraw %}</code>
{% if var_name in extension_values and extension_values[var_name] %}
{{ extension_values[var_name] }}
{% else %}
(empty)
{% endif %}
</li>
{% endfor %}
{% endif %}
<div class="p-2 border border-dark">
<form action="/embed" method="post">
<div class="row pb-2">
<div class="col-sm-12">
<div class="form-text">
{{ render_tag_reference_html(feed=feed, entry=entry, feed_title=feed_title, first_image=first_image, extension_variables=extension_variables, extension_values=extension_values) }}
</div>
<div class="form-text">
<ul class="list-inline">
<li>You can use \n to create a new line.</li>
<li>You can remove the embed from links by adding &lt; and &gt; around the link. (For example &lt;{% raw %}{{ entry_link }}{% endraw %}&gt;)</li>
</ul>
{% else %}
Something went wrong, there was no entry found. If this feed has entries and you still see this
message, please contact the developer.
{% endif %}
</div>
<hr class="border-secondary my-3" />
<h5>Embed content</h5>
<label for="title" class="col-sm-6 col-form-label">Title</label>
<input name="title"
type="text"
class="form-control bg-dark border-dark text-muted"
id="title"
{% if title %} value="{{- title -}}" {% endif %} />
<label for="description" class="col-sm-6 col-form-label">Description</label>
<input name="description"
type="text"
class="form-control bg-dark border-dark text-muted"
id="description"
{% if description %} value="{{- description -}}" {% endif %} />
<label for="color" class="col-sm-6 col-form-label">Embed color</label>
<input name="color"
type="color"
class="form-control form-control-color bg-dark border-dark text-muted"
id="color"
{% if color %} value="{{- color -}}" {% endif %} />
<label for="image_url" class="col-sm-6 col-form-label">Image URL</label>
<input name="image_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="image_url"
{% if image_url %} value="{{- image_url -}}" {% endif %} />
<div class="form-text">
Use {% raw %}{{ image_1 }}{% endraw %} to use the first image URL found in the entry. You can also configure how many images gets sent via the feed page.
</div>
<label for="thumbnail_url" class="col-sm-6 col-form-label">Thumbnail</label>
<input name="thumbnail_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="thumbnail_url"
{% if thumbnail_url %} value="{{- thumbnail_url -}}" {% endif %} />
<hr class="border-secondary my-3" />
<h5>Author</h5>
<label for="author_name" class="col-sm-6 col-form-label">Author name</label>
<input name="author_name"
type="text"
class="form-control bg-dark border-dark text-muted"
id="author_name"
{% if author_name %} value="{{- author_name -}}" {% endif %} />
<label for="author_url" class="col-sm-6 col-form-label">Author URL</label>
<input name="author_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="author_url"
{% if author_url %} value="{{- author_url -}}" {% endif %} />
<label for="author_icon_url" class="col-sm-6 col-form-label">Author icon URL</label>
<input name="author_icon_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="author_icon_url"
{% if author_icon_url %} value="{{- author_icon_url -}}" {% endif %} />
<hr class="border-secondary my-3" />
<h5>Footer</h5>
<label for="footer_text" class="col-sm-6 col-form-label">Footer text</label>
<input name="footer_text"
type="text"
class="form-control bg-dark border-dark text-muted"
id="footer_text"
{% if footer_text %} value="{{- footer_text -}}" {% endif %} />
<label for="footer_icon_url" class="col-sm-6 col-form-label">Footer icon</label>
<input name="footer_icon_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="footer_icon_url"
{% if footer_icon_url %} value="{{- footer_icon_url -}}" {% endif %} />
{% if is_steam_feed %}
<hr class="border-secondary my-3" />
<h5>Options</h5>
<div class="form-check mt-2">
<input class="form-check-input"
type="checkbox"
name="show_steam_game_icon_in_thumbnail"
id="show_steam_game_icon_in_thumbnail"
value="true"
{% if show_steam_game_icon_in_thumbnail %}checked{% endif %}>
<label class="form-check-label" for="show_steam_game_icon_in_thumbnail">
Use Steam game icon as thumbnail override
</label>
</div>
{% endif %}
<hr class="border-secondary my-3" />
<h5>Webhook identity</h5>
<div class="form-text">
<ul class="list-inline">
<li>These override the webhook's display name and avatar (profile picture) for messages from this feed.</li>
<li>Leave blank to use the webhook's default name and avatar.</li>
<li>Username rules: 180 characters; cannot contain <code>@</code>, <code>#</code>, <code>:</code>, or <code>`</code>; cannot contain <code>clyde</code> or <code>discord</code>.</li>
<li>Avatar must be a full <code>http://</code> or <code>https://</code> image URL.</li>
</ul>
</div>
<label for="username" class="col-sm-6 col-form-label">Webhook name</label>
<input name="username"
type="text"
class="form-control bg-dark border-dark text-muted"
id="username"
maxlength="80"
placeholder="e.g. My Feed Bot"
{% if username %} value="{{- username -}}" {% endif %} />
<label for="avatar_url" class="col-sm-6 col-form-label">Webhook avatar</label>
<input name="avatar_url"
type="text"
class="form-control bg-dark border-dark text-muted"
id="avatar_url"
placeholder="e.g. https://example.com/avatar.png or {% raw %}{{ hoyolab_author_avatar_url }}{% endraw %}"
{% if avatar_url %} value="{{- avatar_url -}}" {% endif %} />
</div>
<div class="form-text">
<ul class="list-inline">
<li>You can use \n to create a new line.</li>
<li>You can remove the embed from links by adding &lt; and &gt; around the link. (For example &lt;{% raw %}{{entry_link}}{% endraw %}&gt;)</li>
</ul>
</div>
<hr class="border-secondary my-3" />
<h5>Embed content</h5>
<label for="title" class="col-sm-6 col-form-label">Title</label>
<input name="title" type="text" class="form-control bg-dark border-dark text-muted" id="title"
{% if title %} value="{{- title -}}" {% endif %} />
<label for="description" class="col-sm-6 col-form-label">Description</label>
<input name="description" type="text" class="form-control bg-dark border-dark text-muted"
id="description" {% if description %} value="{{- description -}}" {% endif %} />
<label for="color" class="col-sm-6 col-form-label">Embed color</label>
<input name="color" type="color" class="form-control form-control-color bg-dark border-dark text-muted"
id="color" {% if color %} value="{{- color -}}" {% endif %} />
<label for="image_url" class="col-sm-6 col-form-label">Image URL</label>
<input name="image_url" type="text" class="form-control bg-dark border-dark text-muted" id="image_url"
{% if image_url %} value="{{- image_url -}}" {% endif %} />
<div class="form-text">
Use {% raw %}{{image_1}}{% endraw %} to use the first image URL found in the entry. You can also configure how many images gets sent via the feed page.
</div>
<label for="thumbnail_url" class="col-sm-6 col-form-label">Thumbnail</label>
<input name="thumbnail_url" type="text" class="form-control bg-dark border-dark text-muted"
id="thumbnail_url" {% if thumbnail_url %} value="{{- thumbnail_url -}}" {% endif %} />
<hr class="border-secondary my-3" />
<h5>Author</h5>
<label for="author_name" class="col-sm-6 col-form-label">Author name</label>
<input name="author_name" type="text" class="form-control bg-dark border-dark text-muted"
id="author_name" {% if author_name %} value="{{- author_name -}}" {% endif %} />
<label for="author_url" class="col-sm-6 col-form-label">Author URL</label>
<input name="author_url" type="text" class="form-control bg-dark border-dark text-muted" id="author_url"
{% if author_url %} value="{{- author_url -}}" {% endif %} />
<label for="author_icon_url" class="col-sm-6 col-form-label">Author icon URL</label>
<input name="author_icon_url" type="text" class="form-control bg-dark border-dark text-muted"
id="author_icon_url" {% if author_icon_url %} value="{{- author_icon_url -}}" {% endif %} />
<hr class="border-secondary my-3" />
<h5>Footer</h5>
<label for="footer_text" class="col-sm-6 col-form-label">Footer text</label>
<input name="footer_text" type="text" class="form-control bg-dark border-dark text-muted"
id="footer_text" {% if footer_text %} value="{{- footer_text -}}" {% endif %} />
<label for="footer_icon_url" class="col-sm-6 col-form-label">Footer icon</label>
<input name="footer_icon_url" type="text" class="form-control bg-dark border-dark text-muted"
id="footer_icon_url" {% if footer_icon_url %} value="{{- footer_icon_url -}}" {% endif %} />
{% if is_steam_feed %}
<hr class="border-secondary my-3" />
<h5>Options</h5>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="show_steam_game_icon_in_thumbnail"
id="show_steam_game_icon_in_thumbnail" value="true"
{% if show_steam_game_icon_in_thumbnail %}checked{% endif %}>
<label class="form-check-label" for="show_steam_game_icon_in_thumbnail">
Use Steam game icon as thumbnail override
</label>
</div>
{% endif %}
<hr class="border-secondary my-3" />
<h5>Webhook identity</h5>
<div class="form-text">
<ul class="list-inline">
<li>These override the webhook's display name and avatar (profile picture) for messages from this feed.</li>
<li>Leave blank to use the webhook's default name and avatar.</li>
<li>Username rules: 180 characters; cannot contain <code>@</code>, <code>#</code>, <code>:</code>, or <code>`</code>; cannot contain <code>clyde</code> or <code>discord</code>.</li>
<li>Avatar must be a full <code>http://</code> or <code>https://</code> image URL.</li>
</ul>
</div>
<label for="username" class="col-sm-6 col-form-label">Webhook name</label>
<input name="username" type="text" class="form-control bg-dark border-dark text-muted"
id="username" maxlength="80" placeholder="e.g. My Feed Bot"
{% if username %} value="{{- username -}}" {% endif %} />
<label for="avatar_url" class="col-sm-6 col-form-label">Webhook avatar</label>
<input name="avatar_url" type="text" class="form-control bg-dark border-dark text-muted"
id="avatar_url" placeholder="e.g. https://example.com/avatar.png or {% raw %}{{hoyolab_author_avatar_url}}{% endraw %}"
{% if avatar_url %} value="{{- avatar_url -}}" {% endif %} />
</div>
</div>
<!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
<!-- Submit button -->
<div class="d-md-flex">
<button class="btn btn-dark btn-sm">Update embed</button>
</div>
</form>
</div>
<!-- Add a hidden feed_url field to the form -->
<input type="hidden" name="feed_url" value="{{ feed.url }}" />
<!-- Submit button -->
<div class="d-md-flex">
<button class="btn btn-dark btn-sm">Update embed</button>
</div>
</form>
</div>
{% endblock content %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}
Extensions: {{ feed.title if feed.title else feed.url }} | discord-rss-bot
Extensions: {{ feed_title if feed_title else feed.url }} | discord-rss-bot
{% endblock title %}
{% block description %}
Enable or disable content extension plugins for {{ feed.title if feed.title else feed.url }}.
Enable or disable content extension plugins for {{ feed_title if feed_title else feed.url }}.
{% endblock description %}
{% block content %}
<div class="p-2 border border-dark">

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}
{{ feed.title if feed.title else feed.url }} | discord-rss-bot
{{ feed_title if feed_title else feed.url }} | discord-rss-bot
{% endblock title %}
{% block description %}
Review feed health, delivery settings, filters, webhook attachment, and update intervals for {{ feed.title if feed.title else feed.url }}.
Review feed health, delivery settings, filters, webhook attachment, and update intervals for {{ feed_title if feed_title else feed.url }}.
{% endblock description %}
{% block content %}
<div class="row g-3 feed-page">
@ -14,7 +14,7 @@
<div class="feed-page__content">
<h2 class="h3 mb-1">
<a class="text-muted text-decoration-none feed-page__wrap"
href="{{ feed.url }}">{{ feed | feed_display_name }}</a>
href="{{ feed.url }}">{{ feed | feed_display_name(reader) }}</a>
</h2>
<p class="text-muted mb-0">{{ total_entries }} entries</p>
</div>

View file

@ -72,9 +72,7 @@
<ul class="list-group list-unstyled mb-0">
{% for feed in domain_feeds %}
<li>
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">
{{ feed | feed_display_name }}
</a>
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">{{ feed | feed_display_name(reader) }}</a>
{% if not feed.updates_enabled %}<span class="text-warning">Disabled</span>{% endif %}
{% if feed.last_exception %}<span class="text-danger">({{ feed.last_exception.value_str }})</span>{% endif %}
</li>
@ -112,7 +110,7 @@
{% for broken_feed in broken_feeds %}
<a class="text-muted"
href="/feed?feed_url={{ broken_feed.url|encode_url }}">
{{ broken_feed | feed_display_name }}
{{ broken_feed | feed_display_name(reader) }}
</a>
{% endfor %}
</ul>
@ -126,9 +124,7 @@
{% for feed in feeds_without_attached_webhook %}
<li class="list-group-item bg-dark border-dark text-danger">
<div class="d-flex flex-wrap align-items-center gap-2">
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">
{{ feed | feed_display_name }}
</a>
<a class="text-muted" href="/feed?feed_url={{ feed.url|encode_url }}">{{ feed | feed_display_name(reader) }}</a>
{% if webhooks %}
<form action="/attach_feed_webhook"
method="post"

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block title %}
Whitelist: {{ feed.title if feed.title else feed.url }} | discord-rss-bot
Whitelist: {{ feed_title if feed_title else feed.url }} | discord-rss-bot
{% endblock title %}
{% block description %}
Allow only matching entries from {{ feed.title if feed.title else feed.url }} to reach Discord.
Allow only matching entries from {{ feed_title if feed_title else feed.url }} to reach Discord.
{% endblock description %}
{% block content %}
<div class="row g-3 filter-page">

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import inspect
import re
import typing
from types import SimpleNamespace
from unittest.mock import MagicMock
@ -16,6 +18,7 @@ from discord_rss_bot.custom_message import get_first_image
from discord_rss_bot.custom_message import get_image_urls
from discord_rss_bot.custom_message import normalize_message_avatar_url
from discord_rss_bot.custom_message import normalize_message_username
from discord_rss_bot.custom_message import render_tag_reference_html
from discord_rss_bot.custom_message import replace_tags_in_embed
from discord_rss_bot.custom_message import replace_tags_in_text_message
from discord_rss_bot.custom_message import save_embed
@ -225,6 +228,42 @@ def test_replace_tags_in_embed_uses_authors_str(mock_get_embed: MagicMock) -> No
assert embed.description == "Feed Author One, Feed Author Two | Entry Author One, Entry Author Two"
@patch("discord_rss_bot.custom_message.get_custom_message")
def test_replace_tags_in_text_message_uses_steam_game_name_for_feed_title(
mock_get_custom_message: MagicMock,
) -> None:
"""{{feed_title}} should render the cached Steam game name for Steam feeds."""
mock_reader = MagicMock()
mock_reader.get_tag.return_value = "Dota"
mock_get_custom_message.return_value = "{{feed_title}}"
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english"
entry_ns.feed.title = "570 RSS Feed"
rendered: str = replace_tags_in_text_message(typing.cast("Entry", entry_ns), reader=mock_reader)
assert rendered == "Dota"
@patch("discord_rss_bot.custom_message.get_embed")
def test_replace_tags_in_embed_uses_steam_game_name_for_feed_title(mock_get_embed: MagicMock) -> None:
"""{{feed_title}} in an embed should render the cached Steam game name."""
mock_reader = MagicMock()
mock_reader.get_tag.return_value = "Dota"
mock_get_embed.return_value = CustomEmbed(description="{{feed_title}}")
entry_ns: SimpleNamespace = make_entry("<p>Summary</p>")
entry_ns.feed.url = "https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english"
entry_ns.feed.title = "570 RSS Feed"
embed: CustomEmbed = replace_tags_in_embed(
entry_ns.feed,
typing.cast("Entry", entry_ns),
reader=mock_reader,
)
assert embed.description == "Dota"
def test_get_first_image_prefers_content_image_over_summary_image() -> None:
summary = '<p><img src="https://example.com/from-summary.jpg" /></p>'
content = '<p><img src="https://example.com/from-content.jpg" /></p>'
@ -556,3 +595,123 @@ class TestNormalizeMessageAvatarUrl:
def test_invalid_url_returns_empty(self) -> None:
assert not normalize_message_avatar_url("not-a-url")
def test_render_tag_reference_html_renders_feed_tags() -> None:
"""The reference list should show every feed tag with its preview value."""
feed = typing.cast("Feed", make_feed())
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert '<ul class="list-inline">' in output
assert "{{feed_author}}</code> Entry Author" in output
assert "{{feed_title}}</code> Example Feed" in output
assert "{{feed_url}}</code> https://example.com/feed.xml" in output
assert "{{ feed_author }}" not in output
assert "{{entry_added}}" not in output
def test_render_tag_reference_html_renders_entry_tags() -> None:
"""The reference list should include entry tags, content, and image tags."""
feed = typing.cast("Feed", make_feed())
entry_ns = make_entry("<p>Summary text</p>")
entry_ns.content = [SimpleNamespace(value="<p>Content text</p>")]
entry = typing.cast("Entry", entry_ns)
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="https://example.com/first.jpg",
)
assert "{{entry_title}}</code> Entry Title" in output
assert "{{entry_content}}</code> Content text" in output
assert "{{entry_content_raw}}" in output
assert "{{entry_summary}}</code> Summary text" in output
assert "{{entry_summary_raw}}" in output
assert "{{entry_text}}" in output
assert "{{image_1}}</code> https://example.com/first.jpg" in output
assert "{{ entry_title }}" not in output
def test_render_tag_reference_html_no_entry_shows_message() -> None:
"""Without an entry the list should show the fallback message instead."""
feed = typing.cast("Feed", make_feed())
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert "Something went wrong, there was no entry found" in output
assert "{{entry_title}}" not in output
def test_render_tag_reference_html_escapes_values() -> None:
"""Values containing HTML should be escaped so they render as text."""
feed_ns = make_feed()
feed_ns.subtitle = "<b>bold & loud</b>"
feed = typing.cast("Feed", feed_ns)
output = render_tag_reference_html(
feed=feed,
entry=None,
feed_title="Example Feed",
first_image="",
)
assert "<b>bold & loud</b>" not in output
assert "&lt;b&gt;bold &amp; loud&lt;/b&gt;" in output
def test_render_tag_reference_html_shows_extension_variables() -> None:
"""Enabled extension variables should be listed with their values."""
feed = typing.cast("Feed", make_feed())
entry = typing.cast("Entry", make_entry("<p>Summary text</p>"))
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="",
extension_variables=["wp_content", "wp_excerpt"],
extension_values={"wp_content": "Full <p>post</p>", "wp_excerpt": ""},
)
assert "Extension variables (from enabled extensions):" in output
assert "{{wp_content}}</code> Full &lt;p&gt;post&lt;/p&gt;" in output
assert "{{wp_excerpt}}</code> (empty)" in output
assert "{{ wp_content }}" not in output
def test_render_tag_reference_html_lists_all_replaceable_tags() -> None:
"""Every replaceable tag should be listed on the reference preview."""
feed = typing.cast("Feed", make_feed())
entry_ns = make_entry("<p>Summary text</p>")
entry_ns.content = [SimpleNamespace(value="<p>Content text</p>")]
entry = typing.cast("Entry", entry_ns)
output = render_tag_reference_html(
feed=feed,
entry=entry,
feed_title="Example Feed",
first_image="https://example.com/first.jpg",
)
# Extract the literal {{...}} tags from both replacement tables so this
# test automatically catches any tag that is replaceable but not shown.
tag_pattern: re.Pattern[str] = re.compile(r'\{"\{\{([a-z_0-9]+)\}\}":')
replaceable_tags: set[str] = set(tag_pattern.findall(inspect.getsource(replace_tags_in_text_message)))
replaceable_tags.update(tag_pattern.findall(inspect.getsource(replace_tags_in_embed)))
assert replaceable_tags, "No replaceable tags were found to compare"
for tag in sorted(replaceable_tags):
assert f"{{{{{tag}}}}}" in output, f"Replaceable tag {{{{{tag}}}}} is missing from the reference list"

View file

@ -17,8 +17,10 @@ from threading import Thread
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from httpx2 import HTTPError
from reader import Reader as ReaderType
from reader import make_reader
@ -39,6 +41,11 @@ from discord_rss_bot.extensions.jwplayer_thumbnail import JWPlayerThumbnailExten
from discord_rss_bot.extensions.steam import SteamExtension
from discord_rss_bot.extensions.steam import fix_steam_image_url
from discord_rss_bot.extensions.steam import fix_steam_image_urls_in_payload
from discord_rss_bot.extensions.steam import get_cached_feed_display_title
from discord_rss_bot.extensions.steam import get_cached_steam_game_name
from discord_rss_bot.extensions.steam import get_feed_display_title
from discord_rss_bot.extensions.steam import get_steam_game_name
from discord_rss_bot.extensions.steam import set_steam_game_name
from discord_rss_bot.extensions.storage import get_enabled_extensions_for_feed
from discord_rss_bot.extensions.storage import set_enabled_extensions_for_feed
from discord_rss_bot.extensions.wordpress import WordPressExtension
@ -829,6 +836,175 @@ def test_steam_modify_webhook_skips_non_steam_feed() -> None:
assert result.json["content"] == "https://clan.akamai.steamstatic.com/images/123/abc/english.png"
def _steam_feed(url: str = "https://store.steampowered.com/feeds/news/app/570/") -> SimpleNamespace:
"""Build a minimal Steam feed object.
Returns:
A ``SimpleNamespace`` with a Steam feed URL and its raw title.
"""
return SimpleNamespace(url=url, title="570 RSS Feed")
def _appdetails_response(app_id: str, name: str) -> MagicMock:
"""Build a mock Steam Store API response carrying a game name.
Returns:
A mock response whose ``json()`` returns an app-details payload.
"""
response = MagicMock()
response.json.return_value = {app_id: {"success": True, "data": {"name": name}}}
return response
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_fetches_from_api(mock_get: MagicMock) -> None:
"""get_steam_game_name should return the name from the Steam Store API."""
mock_get.return_value = _appdetails_response("570", "Dota")
result: str | None = get_steam_game_name("570")
assert result == "Dota"
mock_get.assert_called_once()
assert mock_get.call_args.kwargs["params"]["appids"] == "570"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_returns_none_on_api_error(mock_get: MagicMock) -> None:
"""get_steam_game_name should return None when the API call fails."""
mock_get.side_effect = HTTPError("boom")
assert get_steam_game_name("570") is None
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_steam_game_name_returns_none_on_missing_details(mock_get: MagicMock) -> None:
"""get_steam_game_name should return None when the API lacks game details."""
mock_get.return_value = _appdetails_response("570", "")
mock_get.return_value.json.return_value = {"570": {"success": False, "data": {}}}
assert get_steam_game_name("570") is None
def test_get_cached_steam_game_name_reads_reader_tag(mock_reader: MagicMock) -> None:
"""get_cached_steam_game_name should read the cached name from the reader."""
feed: SimpleNamespace = _steam_feed()
assert get_cached_steam_game_name(mock_reader, feed) is None
set_steam_game_name(mock_reader, feed, "Dota")
assert get_cached_steam_game_name(mock_reader, feed) == "Dota"
def test_set_steam_game_name_stores_reader_tag(mock_reader: MagicMock) -> None:
"""set_steam_game_name should persist the name in the reader DB."""
feed: SimpleNamespace = _steam_feed()
set_steam_game_name(mock_reader, feed, "Dota 2")
assert mock_reader.tags[feed.url]["steam_game_name"] == "Dota 2"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_uses_cached_game_name(
mock_get: MagicMock,
mock_reader: MagicMock,
) -> None:
"""A cached game name should be used without any network request."""
feed: SimpleNamespace = _steam_feed()
set_steam_game_name(mock_reader, feed, "Dota")
result: str = get_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert result == "Dota"
mock_get.assert_not_called()
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_fetches_and_caches_once(mock_get: MagicMock) -> None:
"""A missing game name should be fetched once, cached, then reused."""
mock_get.return_value = _appdetails_response("570", "Dota")
feed: SimpleNamespace = _steam_feed()
reader: MagicMock = MagicMock()
reader.tags = {} # type: ignore[valid-type]
def set_tag(feed_url: object, key: str, value: object) -> None:
reader.tags.setdefault(str(feed_url), {})[key] = value
def get_tag(feed_url: object, key: str, default: object = None) -> object:
return reader.tags.get(str(feed_url), {}).get(key, default)
reader.set_tag.side_effect = set_tag
reader.get_tag.side_effect = get_tag
first: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
second: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
assert first == "Dota"
assert second == "Dota"
mock_get.assert_called_once()
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_falls_back_to_feed_title_on_error(mock_get: MagicMock) -> None:
"""When the API fails, the feed's own title should be returned."""
mock_get.side_effect = HTTPError("boom")
feed: SimpleNamespace = _steam_feed(url="https://store.steampowered.com/feeds/news/app/111111/")
result: str = get_feed_display_title(feed, MagicMock()) # type: ignore[arg-type]
assert result == "570 RSS Feed"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_feed_display_title_does_not_repeat_failed_fetch(mock_get: MagicMock) -> None:
"""A failed lookup should not trigger a network request on every call."""
mock_get.side_effect = HTTPError("boom")
feed: SimpleNamespace = _steam_feed(url="https://store.steampowered.com/feeds/news/app/999999/")
reader: MagicMock = MagicMock()
reader.tags = {} # type: ignore[valid-type]
def set_tag(feed_url: object, key: str, value: object) -> None:
reader.tags.setdefault(str(feed_url), {})[key] = value
def get_tag(feed_url: object, key: str, default: object = None) -> object:
return reader.tags.get(str(feed_url), {}).get(key, default)
reader.set_tag.side_effect = set_tag
reader.get_tag.side_effect = get_tag
first: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
second: str = get_feed_display_title(feed, reader) # type: ignore[arg-type]
assert first == "570 RSS Feed"
assert second == "570 RSS Feed"
mock_get.assert_called_once()
def test_get_feed_display_title_returns_title_for_non_steam_feed() -> None:
"""Non-Steam feeds should be returned with their title unchanged."""
feed: SimpleNamespace = SimpleNamespace(url="https://example.com/feed.xml", title="Example Feed")
result: str = get_feed_display_title(feed, MagicMock()) # type: ignore[arg-type]
assert result == "Example Feed"
@patch("discord_rss_bot.extensions.steam.httpx2.get")
def test_get_cached_feed_display_title_never_fetches(
mock_get: MagicMock,
mock_reader: MagicMock,
) -> None:
"""get_cached_feed_display_title should be read-only and never hit the network."""
feed: SimpleNamespace = _steam_feed()
uncached: str = get_cached_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert uncached == "570 RSS Feed"
mock_get.assert_not_called()
set_steam_game_name(mock_reader, feed, "Dota")
cached: str = get_cached_feed_display_title(feed, mock_reader) # type: ignore[arg-type]
assert cached == "Dota"
mock_get.assert_not_called()
def test_youtube_extension_has_auto_enable_patterns() -> None:
"""The built-in YouTube extension should declare URL patterns."""
assert len(YouTubeExtension.auto_enable_url_patterns) > 0

View file

@ -330,6 +330,49 @@ def test_extract_steam_app_id_from_url(url: str, expected_app_id: str | None) ->
assert extract_app_id(url) == expected_app_id
def test_get_feed_display_name_uses_cached_steam_game_name() -> None:
"""Steam feeds should display the cached game name when a reader is provided."""
reader = MagicMock()
reader.get_tag.return_value = "Dota"
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
result: str = feeds.get_feed_display_name(feed, reader)
assert result == "Dota"
def test_get_feed_display_name_without_reader_returns_feed_title() -> None:
"""Without a reader, Steam feeds should keep their raw title."""
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
assert feeds.get_feed_display_name(feed) == "570 RSS Feed"
def test_apply_feed_webhook_identity_uses_steam_game_name_for_username_fallback() -> None:
"""Webhook username fallback should use the Steam game name, not the raw title."""
feed = MagicMock()
feed.url = "https://store.steampowered.com/feeds/news/app/570/"
feed.title = "570 RSS Feed"
feed.authors_str = ""
entry = MagicMock()
entry.feed = feed
reader = MagicMock()
reader.get_tag.side_effect = lambda resource, key, default=None: ( # ruff:ignore[unused-lambda-argument]
"Dota 2" if key == "steam_game_name" else default
)
webhook = feeds.DiscordWebhook(url="https://discord.com/api/webhooks/123/abc")
result: feeds.DiscordWebhook = feeds.apply_feed_webhook_identity(webhook, entry, reader)
assert result.username == "Dota 2"
@pytest.mark.parametrize(
("tag_value", "expected_limit"),
[

View file

@ -325,6 +325,7 @@ def test_feed_page_shows_steam_thumbnail_hint_for_steam_feeds() -> None:
"media_gallery_image_limit": 0,
"webhook_text_length_limit": 4000,
"save_sent_webhooks": True,
"steam_game_name": "Dota 2",
}.get(key, default)
def get_entry_counts(self, **_kwargs: TestKwargValue) -> SimpleNamespace:
@ -344,6 +345,47 @@ def test_feed_page_shows_steam_thumbnail_hint_for_steam_feeds() -> None:
response: Response = client.get(url="/feed", params={"feed_url": stub.feed.url})
assert response.status_code == 200, f"/feed failed: {response.text}"
assert "Dota 2" in response.text
finally:
app.dependency_overrides = {}
def test_embed_page_shows_steam_game_name_for_feed_title() -> None:
"""The /embed page's {{feed_title}} preview should show the cached game name."""
@dataclass(slots=True)
class DummyFeed:
url: str
title: str
class StubReader:
def __init__(self) -> None:
self.feed = DummyFeed(
url="https://store.steampowered.com/feeds/news/app/570/?cc=US&l=english",
title="570 RSS Feed",
)
def get_feed(self, feed_url: str) -> DummyFeed:
assert feed_url == self.feed.url
return self.feed
def get_tag(self, _resource: object, key: str, default: TestTagValue = None) -> TestTagValue:
return {
"embed": "",
"steam_game_name": "Dota 2",
}.get(key, default)
def get_entries(self, **_kwargs: TestKwargValue) -> list[Entry]:
return []
stub = StubReader()
app.dependency_overrides[get_reader_dependency] = lambda: stub
try:
response: Response = client.get(url="/embed", params={"feed_url": stub.feed.url})
assert response.status_code == 200, f"/embed failed: {response.text}"
assert "Dota 2" in response.text
finally:
app.dependency_overrides = {}