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
All checks were successful
Test and build Docker image / docker (push) Successful in 30s
This commit is contained in:
parent
cd9201d4ef
commit
6c0aea053b
15 changed files with 976 additions and 615 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 < and > around the link. (For example <{% raw %}{{ entry_link }}{% endraw %}>)
|
||||
</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: 1–80 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: 1–80 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 %}
|
||||
|
|
|
|||
|
|
@ -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 < and > around the link. (For example <{% raw %}{{ entry_link }}{% endraw %}>)</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: 1–80 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 < and > around the link. (For example <{% raw %}{{entry_link}}{% endraw %}>)</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: 1–80 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 %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 "<b>bold & loud</b>" 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 <p>post</p>" 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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue